Files
tamabox/dev/theme-builder/index.html
T
tamakyi c3d34b53d2 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= 把项目里的图直接当素材载入。
2026-10-20 14:52:36 +08:00

2133 lines
84 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>TamaBox 主题生成器</title>
<style>
:root{
--ui-bg:#0f1216; --ui-panel:#161b22; --ui-panel2:#1c232c; --ui-line:#28313d;
--ui-text:#dbe3ee; --ui-text2:#8b98a9; --ui-accent:#5b9bd5; --ui-ok:#4bb27d; --ui-bad:#d9636b;
--ui-warn:#d8a657;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;background:var(--ui-bg);color:var(--ui-text);
font:13px/1.55 -apple-system,"Segoe UI",system-ui,"Microsoft YaHei",sans-serif;
display:flex;flex-direction:column;overflow:hidden;
}
button,input,select{font:inherit;color:inherit}
button{
background:var(--ui-panel2);border:1px solid var(--ui-line);color:var(--ui-text);
border-radius:6px;padding:5px 10px;cursor:pointer;transition:.15s;
}
button:hover{background:#232c37;border-color:#3a4756}
button.primary{background:var(--ui-accent);border-color:var(--ui-accent);color:#08121c;font-weight:600}
button.primary:hover{background:#6fa9dd}
button.on{background:var(--ui-accent);border-color:var(--ui-accent);color:#08121c;font-weight:600}
input[type=text],input[type=number],select{
background:#0d1117;border:1px solid var(--ui-line);border-radius:6px;padding:4px 7px;width:100%;
}
input[type=number]{width:72px}
input[type=color]{
width:34px;height:26px;padding:0;border:1px solid var(--ui-line);border-radius:5px;background:none;cursor:pointer;
}
label.chk{font-size:11px;color:var(--ui-text2);display:inline-flex;gap:4px;align-items:center;font-weight:400}
h2{
font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ui-text2);
margin:0 0 8px;display:flex;align-items:center;justify-content:space-between;font-weight:600;
}
h2 .r{display:flex;gap:6px}
.bar{
flex:0 0 auto;display:flex;align-items:center;gap:12px;padding:0 14px;height:48px;
background:var(--ui-panel);border-bottom:1px solid var(--ui-line);
}
.brand{font-weight:600;letter-spacing:.02em}
.bar .slug{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text2)}
.bar .slug input{width:150px}
.bar .spacer{flex:1}
.conn{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--ui-line)}
.conn.ok{color:var(--ui-ok);border-color:#2f5744}
.conn.bad{color:var(--ui-bad);border-color:#5a3036}
main{flex:1;display:grid;grid-template-columns:352px minmax(0,1fr);min-height:0}
.panel{overflow:auto;border-right:1px solid var(--ui-line);background:var(--ui-panel);padding:14px}
.panel section{margin-bottom:18px}
.scopes{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.sc{
text-align:left;padding:7px 9px;border-radius:7px;line-height:1.3;background:var(--ui-panel2);
border:1px solid var(--ui-line);
}
.sc b{display:block;font-size:12px;font-weight:600}
.sc span{font-size:10px;color:var(--ui-text2)}
.sc.on{border-color:var(--ui-accent);background:#16303f}
.sc.on b{color:#9fc8ea}
.sc .dirty{color:var(--ui-warn);font-size:10px}
.hint{font-size:11px;color:var(--ui-text2);margin:8px 0 0;line-height:1.5}
.layers{display:flex;flex-direction:column;gap:5px}
.ly{
display:flex;align-items:center;gap:7px;padding:6px 8px;border-radius:6px;cursor:pointer;
background:var(--ui-panel2);border:1px solid transparent;
}
.ly:hover{border-color:var(--ui-line)}
.ly.on{border-color:var(--ui-accent);background:#16303f}
.ly .dot{width:9px;height:9px;border-radius:3px;flex:0 0 auto;background:#4a5563}
.ly .nm{flex:1;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ly .k{font-size:10px;color:var(--ui-text2)}
.ly .x{opacity:.5;padding:0 4px}
.ly .x:hover{opacity:1;color:var(--ui-bad)}
.ly .ord{opacity:.55;padding:0 5px;font-size:11px;line-height:1;border-color:transparent;background:none}
.ly .ord:hover:not(:disabled){opacity:1;background:#232c37;border-color:var(--ui-line)}
.ly .ord:disabled{opacity:.18;cursor:default}
.editor{margin-top:10px;padding:10px;border-radius:8px;background:#12171e;border:1px solid var(--ui-line)}
.row{display:flex;align-items:center;gap:8px;margin-bottom:7px}
.row:last-child{margin-bottom:0}
.row > span{font-size:11px;color:var(--ui-text2);min-width:58px;flex:0 0 auto;white-space:nowrap}
.grid9{display:grid;grid-template-columns:repeat(3,24px);gap:3px}
.grid9 button{padding:0;height:22px;border-radius:4px;font-size:0}
.grid9 button.on{background:var(--ui-accent)}
.assets{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.as{
position:relative;aspect-ratio:1;border-radius:6px;overflow:hidden;background:#0b0f14;
border:1px solid var(--ui-line);cursor:pointer;
}
.as img{width:100%;height:100%;object-fit:contain;display:block}
.as.on{border-color:var(--ui-accent)}
.as .del{
position:absolute;top:1px;right:2px;font-size:11px;line-height:1;color:#fff;background:#0008;
border-radius:4px;padding:1px 3px;display:none;
}
.as:hover .del{display:block}
.as .meta{
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)}
.pal .p input[type=text]{width:100%;font-size:11px;padding:2px 6px}
.stage{display:flex;flex-direction:column;min-width:0;min-height:0;background:#0b0e12}
.stagebar{
flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:7px 12px;
border-bottom:1px solid var(--ui-line);background:var(--ui-panel);font-size:11px;color:var(--ui-text2);
}
.stagebar .g{display:flex;gap:4px}
.stagebar .url{margin-left:auto;font-family:ui-monospace,Consolas,monospace;font-size:10px;color:#5f6c7c}
.canvas{flex:1;overflow:auto;padding:14px;min-height:0;min-width:0;position:relative;
display:flex;justify-content:center;align-items:center}
.frame{position:relative;overflow:hidden;border-radius:8px;background:#fff;box-shadow:0 12px 40px -12px #000a}
iframe{display:block;border:0;transform-origin:top left;background:#fff}
.out{
flex:0 0 auto;padding:7px 12px 9px;font-size:11px;color:var(--ui-text2);white-space:pre-wrap;
word-break:break-word;max-height:96px;overflow:auto;border-top:1px solid var(--ui-line);background:var(--ui-panel);
}
.out .e{color:var(--ui-bad)} .out .ok{color:var(--ui-ok)} .out .w{color:var(--ui-warn)}
.empty{position:absolute;inset:0;display:flex;flex-direction:column;gap:8px;align-items:center;justify-content:center;
color:#7c8a9c;font-size:13px;text-align:center;padding:24px;z-index:5;background:#0b0e12}
.empty b{color:#a9b8ca}
.empty code{background:#161b22;border:1px solid var(--ui-line);border-radius:5px;padding:6px 9px;
font-family:ui-monospace,Consolas,monospace;font-size:11px;color:#9fc8ea}
</style>
</head>
<body>
<header class="bar">
<span class="brand">TamaBox 主题生成器</span>
<label class="slug">slug <input id="slug" type="text" value="my-theme" spellcheck="false"></label>
<button id="btn-demo">载入示例</button>
<button id="btn-save">保存工程</button>
<button id="btn-open">打开工程</button>
<button id="btn-reload">重载画板</button>
<span class="spacer"></span>
<span class="conn" id="conn">检测中…</span>
<button id="btn-export" class="primary">导出 zip</button>
</header>
<main>
<aside class="panel">
<section>
<h2>页面</h2>
<div class="scopes" id="scopes"></div>
<p class="hint" id="scope-hint"></p>
</section>
<section>
<h2>装饰图层 <span class="r"><button id="add-layer">+ 加一层</button></span></h2>
<div class="layers" id="layers"></div>
<div id="layer-editor"></div>
</section>
<section>
<h2>整页背景 <label class="chk"><input type="checkbox" id="inherit"> 跟随全局</label></h2>
<div id="bg-editor"></div>
</section>
<section>
<h2>窄屏</h2>
<div id="narrow-editor"></div>
</section>
<section>
<h2>图片池 <span class="r"><button id="add-asset">上传</button></span></h2>
<div class="assets" id="assets"></div>
<p class="hint">透明底 PNG / WebP 最好。上传后自动转 WebP,画布尺寸即自然尺寸。</p>
<input type="file" id="file" accept="image/*" multiple hidden>
<input type="file" id="projfile" accept=".json,application/json" hidden>
</section>
<section>
<h2>配色</h2>
<div class="pal" id="palette"></div>
</section>
</aside>
<section class="stage">
<div class="stagebar">
<span>视口</span>
<span class="g" id="vps"></span>
<span style="margin-left:10px">模式</span>
<span class="g" id="modes"></span>
<span class="url" id="stage-url"></span>
</div>
<div class="canvas" id="canvas">
<div class="frame" id="frame"><iframe id="preview"></iframe></div>
<div class="empty" id="empty">
<b>需要本地服务才能预览真实页面</b>
<span>在项目根执行:</span>
<code>python dev/theme-builder/serve.py</code>
<span>然后打开 http://127.0.0.1:8777/dev/theme-builder/</span>
</div>
</div>
<div class="out" id="out"></div>
</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';
/* ============================================================================
* 页面作用域:必须与 Go 侧 internal/context.pageScope() 的返回值一一对应,
* 否则生成的 data-page-scope 选择器在线上永远匹配不到。
* ========================================================================== */
/* 注意:首页 / 与 /explore 不在这里 —— 全站公开页不注入 ThemeSlug,也就不会加载主题 css,
给它们配装饰不会有任何效果(Go 侧 pageScope 仍会返回 home/explore,留给以后做站点默认主题)。
这里列出的每一个都确认过线上会加载主题。 */
const SCOPES = [
{k:'global', label:'全局', desc:'所有页面默认', page:null},
{k:'box', label:'箱主页', desc:'/_/{domain}', page:'box'},
{k:'question', label:'问题页', desc:'/_/{domain}/{id}', page:'question'},
{k:'admin', label:'题目列表', desc:'/user/questions', page:'admin'},
{k:'profile', label:'设置页', desc:'/user/*', page:'profile'},
{k:'vote', label:'投票页', desc:'/vote/{token}', page:'vote'},
];
const SLOT = 10; // 装饰层槽位数,必须与 internal/theme.DecoSlots 一致
const VIEWPORTS = [1512, 1280, 1024, 820];
const S = {
slug: 'my-theme',
scope: 'global',
viewport: 1512,
mode: 'light',
assets: [],
seq: 0,
curLayer: null,
scopes: {},
palette: {
bg:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'rgba(255,255,255,0.52)',
text:'#222d3b', primary:'#4a7fc1', border:'#d6e0ec',
},
/* 窄屏:手机是「卡片占满宽度」,桌面那套 16:9 场景会被 cover 裁成一条无意义纹理,
固定 px 的人物也会被卡片盖住。所以窄屏要么换竖版图,要么直接退场。
这一份是全局的(不分页面)—— 窄屏的问题是空间不够,各页面诉求差别不大。 */
narrow: { enabled: true, bg: null, hideDeco: true, maxWidth: 900 },
dark: {
bg:'#141a22', surface:'#1d2530', navbar:'#141a22',
text:'#e3eaf4', primary:'#6fa3e0', border:'#334052',
},
};
const PALETTE_KEYS = [
['bg','页面底色'], ['surface','卡片'], ['navbar','顶栏'],
['text','正文'], ['primary','主色'], ['border','描边'],
];
function blankScope(k){
return { inherit: k !== 'global', bg: null, pattern: null, patternSize: '320px 320px', layers: [] };
}
SCOPES.forEach(s => { S.scopes[s.k] = blankScope(s.k); });
/* ---------------------------- 小工具 ---------------------------- */
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
const uid = p => p + (++S.seq);
function out(msg, cls){
const el = $('out');
el.innerHTML = cls ? `<span class="${cls}">${esc(msg)}</span>` : esc(msg);
}
function log(msg, cls){
const el = $('out');
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = msg + '\n';
el.appendChild(span);
el.scrollTop = el.scrollHeight;
}
function assetById(id){ return S.assets.find(a => a.id === id) || null; }
function slugOK(s){ return /^[a-z0-9][a-z0-9-]{0,31}$/.test(s); }
/** 当前作用域生效的配置:没勾「独立配置」就用全局那套。 */
function cfgOf(k){
const c = S.scopes[k];
if (k === 'global' || !c.inherit) return c;
return S.scopes.global;
}
/* ---------------------- 位置 / 尺寸 → CSS ---------------------- */
/* background-position 的百分比是相对 (容器 - 图片),不是相对容器。
统一写成 calc(0%|50%|100% + Npx) 两值形式:0% = 左/上对齐,50% = 居中,100% = 右/下对齐。
不用 left/top 关键字,是因为「水平居中 + 垂直贴底」会变成三值/四值语法,
一旦不合规范整条声明作废,图层会静默掉到左上角。 */
function axisPos(anchor, off){
const pct = anchor === 'center' ? '50%' : (anchor === 'start' ? '0%' : '100%');
const n = Math.round(off);
// 写成 calc(100% - 36px) 而不是 calc(100% + -36px):后者虽然合法,
// 但用 calc 手算时容易被误读成「加负数」,见过的样式事故都出在这种地方。
return n < 0 ? `calc(${pct} - ${-n}px)` : `calc(${pct} + ${n}px)`;
}
const POS = l => `${axisPos(l.ah, l.dx)} ${axisPos(l.av, l.dy)}`;
function rgba(hex, a){
const h = hex.replace('#','');
const v = h.length === 3 ? h.split('').map(c=>c+c).join('') : h;
const n = parseInt(v, 16);
return `rgba(${(n>>16)&255}, ${(n>>8)&255}, ${n&255}, ${a})`;
}
function layerCSS(l, preview){
if (l.kind === 'image'){
const a = assetById(l.asset);
if (!a) return {image:'none', size:'auto', position:'left top'};
const url = preview ? a.dataURL : `/static/images/themes/${S.slug}/${a.id}.webp`;
const h = Math.round(l.h) || 200;
const w = l.w > 0 ? Math.round(l.w) : Math.round(a.w * h / a.h);
return { image:`url("${url}")`, size:`${w}px ${h}px`, position: POS(l) };
}
if (l.kind === 'radial'){
const c = rgba(l.color, l.alpha);
return {
image: `radial-gradient(circle ${Math.round(l.r)}px at ${l.cx}% ${l.cy}%, ${c} 0%, ${rgba(l.color,0)} 72%)`,
size: '100% 100%', position: 'left top',
};
}
const c = rgba(l.color, l.alpha);
return {
image: `linear-gradient(${Math.round(l.angle)}deg, ${c} 0%, ${rgba(l.color,0)} ${Math.round(l.stop)}%)`,
size: '100% 100%', position: 'left top',
};
}
function newLayer(kind){
return {
id: uid('L'), kind,
ah:'center', av:'end', dx:0, dy:-40,
h:360, w:0, asset:null,
color:'#fff8e4', alpha:0.55, r:320, cx:50, cy:50,
angle:112, stop:44,
};
}
/* ---------------------------- 导出 CSS ---------------------------- */
function urlOf(a, preview){
return preview ? a.dataURL : `/static/images/themes/${S.slug}/${a.id}.webp`;
}
function bgVars(cfg, preview){
const img = cfg.bg ? assetById(cfg.bg) : null;
const pat = cfg.pattern ? assetById(cfg.pattern) : null;
return [
` --tb-page-image: ${img ? `url("${urlOf(img, preview)}")` : 'none'};`,
` --tb-page-pattern: ${pat ? `url("${urlOf(pat, preview)}")` : 'none'};`,
` --tb-page-pattern-size: ${cfg.patternSize || '320px 320px'};`,
].join('\n');
}
function decoVars(cfg, preview){
const layers = cfg.layers.slice(0, SLOT);
const imgs = [], sizes = [], poss = [];
for (let i = 0; i < SLOT; i++){
const l = layers[i];
if (!l){ imgs.push('none'); sizes.push('auto'); poss.push('left top'); continue; }
const c = layerCSS(l, preview);
imgs.push(c.image); sizes.push(c.size); poss.push(c.position);
}
return [
` --tb-deco: ${imgs.join(',\n ')};`,
` --tb-deco-size: ${sizes.join(', ')};`,
` --tb-deco-position: ${poss.join(',\n ')};`,
].join('\n');
}
/* 主题至少要提供 10 个令牌(见 internal/theme/theme_test.go 的 requiredTokens),
少一个不会报错,只会让深色模式或次级面板变成默认配色。这里只让用户填 6 个基础色,
其余 4 个按「往文字色 / 主色方向混」派生:
- surface-2 = surface 往 text 靠一点(浅色下更深、深色下更亮,方向自动正确)
- surface-hover = surface 往 primary 靠一点
- text-secondary / text-muted = text 往 bg 靠(浅色下变淡、深色下变暗)
- primary-weak = primary 加 12% 透明度 */
function parseColor(v){
if (typeof v === 'string' && v.startsWith('#')){
const h = v.slice(1);
const x = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
const n = parseInt(x, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1];
}
const m = String(v).match(/rgba?\(([^)]+)\)/);
if (m){
const a = m[1].split(',').map(x => parseFloat(x));
return [a[0] | 0, a[1] | 0, a[2] | 0, a.length > 3 ? a[3] : 1];
}
return [0, 0, 0, 1];
}
function mixRGB(a, b, t){
const A = parseColor(a), B = parseColor(b);
const ch = i => Math.round(A[i] + (B[i] - A[i]) * t);
return A[3] >= 1 ? `rgb(${ch(0)}, ${ch(1)}, ${ch(2)})`
: `rgba(${ch(0)}, ${ch(1)}, ${ch(2)}, ${A[3]})`;
}
function withAlpha(c, a){
const A = parseColor(c);
return `rgba(${A[0]}, ${A[1]}, ${A[2]}, ${a})`;
}
function paletteVars(p){
return [
` --tb-bg: ${p.bg};`,
` --tb-surface: ${p.surface};`,
` --tb-surface-2: ${mixRGB(p.surface, p.text, 0.06)};`,
` --tb-surface-hover: ${mixRGB(p.surface, p.primary, 0.10)};`,
` --tb-navbar: ${p.navbar};`,
` --tb-border: ${p.border};`,
` --tb-text: ${p.text};`,
` --tb-text-secondary: ${mixRGB(p.text, p.bg, 0.36)};`,
` --tb-text-muted: ${mixRGB(p.text, p.bg, 0.55)};`,
` --tb-primary: ${p.primary};`,
` --tb-primary-weak: ${withAlpha(p.primary, 0.12)};`,
].join('\n');
}
const NONE10 = ' --tb-deco: ' + Array(SLOT).fill('none').join(', ') + ';\n' +
' --tb-deco-size: ' + Array(SLOT).fill('auto').join(', ') + ';\n' +
' --tb-deco-position: ' + Array(SLOT).fill('left top').join(', ') + ';';
function buildCSS(preview){
const slug = S.slug || 'my-theme';
const L = [];
L.push('/* ' + slug + ' —— 由 dev/theme-builder 生成,可直接手改。');
L.push(' 装饰层固定 ' + SLOT + ' 个槽位(对应 base.css 的 12 层背景);');
L.push(' 按页面覆盖时也必须写满 ' + SLOT + ' 个,否则图层会与 size/position 错位。 */');
L.push(`html[data-theme-style="${slug}"] {`);
L.push(paletteVars(S.palette));
L.push(bgVars(S.scopes.global, preview));
L.push(decoVars(S.scopes.global, preview));
L.push('}');
L.push('');
L.push(`html[data-theme="dark"][data-theme-style="${slug}"] {`);
L.push(paletteVars(S.dark));
L.push(` --tb-page-image: linear-gradient(180deg, ${S.dark.bg} 0%, ${S.dark.bg} 60%, #0e1319 100%);`);
L.push(' --tb-page-pattern: none;');
L.push(NONE10);
L.push('}');
for (const sc of SCOPES){
if (sc.k === 'global') continue;
const cfg = S.scopes[sc.k];
if (cfg.inherit) continue;
const sel = `html[data-theme-style="${slug}"][data-page-scope="${sc.k}"]`;
L.push('');
L.push(`/* ${sc.label} ${sc.desc} */`);
L.push(sel + ' {');
L.push(bgVars(cfg, preview));
L.push(decoVars(cfg, preview));
L.push('}');
L.push('');
L.push(`html[data-theme="dark"][data-theme-style="${slug}"][data-page-scope="${sc.k}"] {`);
L.push(' --tb-page-image: none;');
L.push(' --tb-page-pattern: none;');
L.push(NONE10);
L.push('}');
}
// 窄屏段:只写浅色规则。深色块特异性更高(0,2,1 > 0,1,1),
// 本来就压住这里,不需要再为深色写一份 —— 少一层嵌套少一处出错。
const nb = S.narrow.bg ? assetById(S.narrow.bg) : null;
if (S.narrow.enabled && (nb || S.narrow.hideDeco)){
L.push('');
L.push('/* 窄屏:卡片占满宽度,两侧没有留白,桌面那套背景与装饰在这里只会被裁/被盖 */');
L.push(`@media (max-width: ${S.narrow.maxWidth || 900}px) {`);
L.push(` html[data-theme-style="${slug}"] {`);
if (nb) L.push(` --tb-page-image: url("${urlOf(nb, preview)}");`);
if (S.narrow.hideDeco){
L.push(' --tb-deco: ' + Array(SLOT).fill('none').join(', ') + ';');
L.push(' --tb-deco-size: ' + Array(SLOT).fill('auto').join(', ') + ';');
L.push(' --tb-deco-position: ' + Array(SLOT).fill('left top').join(', ') + ';');
}
L.push(' }');
L.push('}');
}
return L.join('\n') + '\n';
}
/* ---------------------------- iframe ---------------------------- */
let doc = null, frameReady = false;
function previewFile(){
const sc = SCOPES.find(x => x.k === S.scope);
const page = (sc && sc.page) || 'box';
return `../../outputs/preview-${page}-${S.mode}.html`;
}
function loadPreview(){
frameReady = false;
const url = previewFile();
$('stage-url').textContent = url.replace('../../','');
$('preview').src = url;
}
function onFrameLoad(){
const ifr = $('preview');
try {
doc = ifr.contentDocument || ifr.contentWindow.document;
if (!doc || !doc.documentElement) throw new Error('no document');
} catch (e){
doc = null;
$('empty').style.display = 'flex';
$('conn').className = 'conn bad';
$('conn').textContent = '需要本地服务';
out('无法访问 iframe 的 DOM。请用 python dev/theme-builder/serve.py 启动,再用 http://127.0.0.1:8777/dev/theme-builder/ 打开。', 'e');
return;
}
$('empty').style.display = 'none';
$('conn').className = 'conn ok';
$('conn').textContent = '画板已连接';
frameReady = true;
applyTheme();
}
function applyTheme(){
if (!frameReady || !doc) return;
const html = doc.documentElement;
const sc = SCOPES.find(x => x.k === S.scope);
html.setAttribute('data-theme-style', S.slug || 'my-theme');
// global 要看「纯全局」的效果:给一个不会匹配任何覆盖块的值,
// 否则箱主页自己的独立配置会混进全局预览里,看到的东西是假的。
html.setAttribute('data-page-scope', (sc && sc.page) || '__global');
if (S.mode === 'dark') html.setAttribute('data-theme', 'dark');
else html.removeAttribute('data-theme');
let st = doc.getElementById('__tb_theme');
if (!st){
st = doc.createElement('style');
st.id = '__tb_theme';
doc.head.appendChild(st);
}
st.textContent = buildCSS(true);
syncOverlay();
}
/* ------------------------- 拖拽蒙层 ------------------------- */
function guideGeom(l){
const a = assetById(l.asset);
if (!a) return null;
const h = Math.round(l.h) || 200;
const w = l.w > 0 ? Math.round(l.w) : Math.round(a.w * h / a.h);
const vw = doc.documentElement.clientWidth;
const vh = doc.documentElement.clientHeight;
const px = l.ah === 'center' ? 0.5 : (l.ah === 'start' ? 0 : 1);
const py = l.av === 'center' ? 0.5 : (l.av === 'start' ? 0 : 1);
// 与 background-position 完全一致的换算
return {
w, h,
left: Math.round((vw - w) * px + l.dx),
top: Math.round((vh - h) * py + l.dy),
};
}
function syncOverlay(){
if (!doc) return;
let ov = doc.getElementById('__tb_ov');
if (!ov){
ov = doc.createElement('div');
ov.id = '__tb_ov';
ov.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483000;';
doc.documentElement.appendChild(ov);
}
ov.innerHTML = '';
cfgOf(S.scope).layers.forEach(l => {
if (l.kind !== 'image') return;
const g0 = guideGeom(l);
if (!g0) return;
const g = doc.createElement('div');
g.dataset.layer = l.id;
g.style.cssText = [
'position:absolute', `width:${g0.w}px`, `height:${g0.h}px`,
`left:${g0.left}px`, `top:${g0.top}px`,
'border:1px dashed rgba(91,155,213,.95)', 'border-radius:2px',
'pointer-events:auto', 'cursor:move', 'background:none',
].join(';');
const tag = doc.createElement('span');
tag.textContent = '拖动';
tag.style.cssText = 'position:absolute;left:0;top:0;font:10px/14px system-ui;z-index:1;' +
'color:#0b1219;background:rgba(91,155,213,.92);padding:0 4px;border-radius:2px 0 2px 0';
g.appendChild(tag);
g.title = '拖动调整位置';
attachDrag(g, l);
ov.appendChild(g);
});
highlightGuide(S.curLayer);
}
function attachDrag(el, l){
el.addEventListener('pointerdown', e => {
e.preventDefault(); e.stopPropagation();
try { el.setPointerCapture(e.pointerId); } catch (_) {}
const sx = e.clientX, sy = e.clientY, dx0 = l.dx, dy0 = l.dy;
const move = ev => {
l.dx = Math.round(dx0 + (ev.clientX - sx));
l.dy = Math.round(dy0 + (ev.clientY - sy));
// 快速路径:只改 CSS + 蒙层位置,不重建 DOM(否则指针捕获会被打断)
const st = doc.getElementById('__tb_theme');
if (st) st.textContent = buildCSS(true);
const g0 = guideGeom(l);
if (g0){ el.style.left = g0.left + 'px'; el.style.top = g0.top + 'px'; }
renderLayerEditor(cfgOf(S.scope).layers.find(x => x.id === l.id) || null);
};
const up = () => {
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', up);
el.removeEventListener('pointercancel', up);
try { el.releasePointerCapture(e.pointerId); } catch (_) {}
};
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', up);
el.addEventListener('pointercancel', up);
});
}
/* ---------------------------- 缩放画板 ---------------------------- */
function fitStage(){
const canvas = $('canvas');
const frame = $('frame');
const ifr = $('preview');
const k = Math.min(1, (canvas.clientWidth - 28) / S.viewport, (canvas.clientHeight - 28) / 900);
ifr.style.width = S.viewport + 'px';
ifr.style.height = '900px';
ifr.style.transform = `scale(${k})`;
frame.style.width = Math.round(S.viewport * k) + 'px';
frame.style.height = Math.round(900 * k) + 'px';
}
/* ---------------------------- 面板渲染 ---------------------------- */
function renderScopes(){
const wrap = $('scopes');
wrap.innerHTML = '';
SCOPES.forEach(sc => {
const b = document.createElement('button');
const own = sc.k === 'global' || !S.scopes[sc.k].inherit;
const c = S.scopes[sc.k];
const dirty = own && (c.layers.length || c.bg || c.pattern);
b.className = 'sc' + (sc.k === S.scope ? ' on' : '');
b.innerHTML = `<b>${esc(sc.label)}${dirty ? ' <span class="dirty">●</span>' : ''}</b>` +
`<span>${esc(own ? sc.desc : '跟随全局')}</span>`;
b.onclick = () => {
S.scope = sc.k;
S.curLayer = null;
renderAll();
loadPreview(); // 切页面 = 换一张真实画板
};
wrap.appendChild(b);
});
const sc = SCOPES.find(x => x.k === S.scope);
$('scope-hint').textContent = sc.k === 'global'
? '这里的图层是所有页面的默认装饰。下面选具体页面可以单独覆盖。'
: `${sc.desc} —— 勾掉「跟随全局」后可单独配这一页的背景与图层。`;
}
function renderBgEditor(){
const cfg = cfgOf(S.scope);
const own = S.scope === 'global' || !S.scopes[S.scope].inherit;
$('inherit').checked = S.scope !== 'global' && S.scopes[S.scope].inherit;
$('inherit').disabled = S.scope === 'global';
const el = $('bg-editor');
el.innerHTML = '';
const mkPick = (label, key) => {
const row = document.createElement('div');
row.className = 'row';
const s = document.createElement('span');
s.textContent = label;
row.appendChild(s);
const sel = document.createElement('select');
sel.innerHTML = '<option value="">(无)</option>' +
S.assets.map(a => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
sel.value = cfg[key] || '';
sel.disabled = !own;
sel.onchange = () => { cfg[key] = sel.value || null; renderAll(); };
row.appendChild(sel);
el.appendChild(row);
};
mkPick('背景图', 'bg');
mkPick('平铺图案', 'pattern');
const row = document.createElement('div');
row.className = 'row';
const s = document.createElement('span');
s.textContent = '图案尺寸';
const inp = document.createElement('input');
inp.type = 'text';
inp.value = cfg.patternSize || '320px 320px';
inp.disabled = !own;
inp.style.width = '110px';
inp.onchange = () => { cfg.patternSize = inp.value.trim() || '320px 320px'; renderAll(); };
row.append(s, inp);
el.appendChild(row);
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = '背景图按 cover 铺满整页;平铺图案叠在背景图之上、装饰图层之下。';
el.appendChild(tip);
}
function layerName(l){
if (l.kind === 'image'){
const a = assetById(l.asset);
return a ? a.name : '(未选图片)';
}
return l.kind === 'radial' ? '光斑' : '线性光';
}
function moveLayer(cfg, idx, delta){
const j = idx + delta;
if (j < 0 || j >= cfg.layers.length) return;
const [it] = cfg.layers.splice(idx, 1);
cfg.layers.splice(j, 0, it);
renderAll();
}
function renderLayers(){
const cfg = cfgOf(S.scope);
const own = S.scope === 'global' || !S.scopes[S.scope].inherit;
const wrap = $('layers');
wrap.innerHTML = '';
$('add-layer').disabled = !own || cfg.layers.length >= SLOT;
if (!own){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = '这一页跟随全局。勾掉上方「跟随全局」即可单独配置。';
wrap.appendChild(p);
$('layer-editor').innerHTML = '';
return;
}
if (!cfg.layers.length){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = '还没有图层。点「+ 加一层」,可选图片 / 光斑 / 线性光。图片层选中后可以直接在右侧画板上拖动。';
wrap.appendChild(p);
}
cfg.layers.forEach((l, i) => {
const d = document.createElement('div');
d.className = 'ly' + (l.id === S.curLayer ? ' on' : '');
const color = l.kind === 'image' ? '#5b9bd5' : l.kind === 'radial' ? '#d8a657' : '#9b7fd0';
// 顺序就是层级:background-image 的第一层画在最上面。所以要能调,
// 否则「谁挡住谁」配反了只能删了重加,位置尺寸全要重来。
d.innerHTML = `<i class="dot" style="background:${color}"></i>
<span class="nm">${esc(layerName(l))}</span>
<span class="k">${i === 0 ? '最上层' : '第 ' + (i+1) + ' 层'}</span>
<button class="ord" data-up="${l.id}" title="上移一层(更靠前)" ${i === 0 ? 'disabled' : ''}>↑</button>
<button class="ord" data-down="${l.id}" title="下移一层(更靠后)" ${i === cfg.layers.length - 1 ? 'disabled' : ''}>↓</button>
<button class="x" data-del="${l.id}" title="删除">×</button>`;
d.querySelector('[data-up]').onclick = e => { e.stopPropagation(); moveLayer(cfg, i, -1); };
d.querySelector('[data-down]').onclick = e => { e.stopPropagation(); moveLayer(cfg, i, 1); };
d.onclick = e => {
if (e.target.dataset.del || e.target.dataset.up || e.target.dataset.down) return;
S.curLayer = l.id;
renderLayers();
highlightGuide(l.id);
};
d.querySelector('[data-del]').onclick = e => {
e.stopPropagation();
cfg.layers = cfg.layers.filter(x => x.id !== l.id);
if (S.curLayer === l.id) S.curLayer = null;
renderAll();
};
wrap.appendChild(d);
});
renderLayerEditor(cfg.layers.find(l => l.id === S.curLayer) || cfg.layers[0] || null);
}
function highlightGuide(id){
if (!doc) return;
doc.querySelectorAll('#__tb_ov > div').forEach(g => {
const on = g.dataset.layer === id;
g.style.borderColor = on ? '#ffd479' : 'rgba(91,155,213,.95)';
g.style.background = 'none';
const tag = g.querySelector('span');
if (tag) tag.style.background = on ? 'rgba(255,212,121,.95)' : 'rgba(91,155,213,.92)';
});
}
function renderLayerEditor(l){
const host = $('layer-editor');
host.innerHTML = '';
if (!l){ S.curLayer = null; return; }
S.curLayer = l.id;
const box = document.createElement('div');
box.className = 'editor';
host.appendChild(box);
const row = (label, ...nodes) => {
const r = document.createElement('div');
r.className = 'row';
const s = document.createElement('span');
s.textContent = label;
r.appendChild(s);
nodes.forEach(n => r.appendChild(n));
box.appendChild(r);
return r;
};
if (l.kind === 'image'){
const sel = document.createElement('select');
sel.innerHTML = '<option value="">(选图片)</option>' +
S.assets.map(a => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
sel.value = l.asset || '';
sel.onchange = () => { l.asset = sel.value || null; renderAll(); };
row('图片', sel);
row('高度 px', numInput(l.h, 20, 4000, v => { l.h = v; renderAll(); }));
const wIn = numInput(l.w, 0, 4000, v => { l.w = v; renderAll(); });
wIn.title = '0 表示按图片原始比例自动算宽度';
row('宽度 0=自动', wIn);
}
const g = document.createElement('div');
g.className = 'grid9';
[['start','start'],['center','start'],['end','start'],
['start','center'],['center','center'],['end','center'],
['start','end'],['center','end'],['end','end']].forEach(([hh, vv]) => {
const b = document.createElement('button');
b.className = (l.ah === hh && l.av === vv) ? 'on' : '';
b.title = `${hh} / ${vv}`;
b.onclick = () => { l.ah = hh; l.av = vv; renderAll(); };
g.appendChild(b);
});
row('锚点', g);
row('偏移 x/y', numInput(l.dx, -4000, 4000, v => { l.dx = v; renderAll(); }),
numInput(l.dy, -4000, 4000, v => { l.dy = v; renderAll(); }));
if (l.kind !== 'image'){
const c = document.createElement('input');
c.type = 'color'; c.value = l.color;
c.oninput = () => { l.color = c.value; applyTheme(); };
row('颜色', c);
row('不透明度', rangeInput(l.alpha, 0, 1, 0.01, v => { l.alpha = v; applyTheme(); }));
}
if (l.kind === 'radial') row('半径 px', numInput(l.r, 10, 3000, v => { l.r = v; renderAll(); }));
if (l.kind === 'linear'){
row('角度', numInput(l.angle, 0, 360, v => { l.angle = v; renderAll(); }));
row('衰减%', numInput(l.stop, 1, 100, v => { l.stop = v; renderAll(); }));
}
}
function numInput(val, min, max, on){
const i = document.createElement('input');
i.type = 'number'; i.value = val; i.min = min; i.max = max;
i.onchange = () => on(clamp(Number(i.value) || 0, min, max));
return i;
}
function rangeInput(val, min, max, step, on){
const i = document.createElement('input');
i.type = 'range'; i.value = val; i.min = min; i.max = max; i.step = step;
i.style.width = '100%';
i.oninput = () => on(Number(i.value));
return i;
}
function renderAssets(){
const wrap = $('assets');
wrap.innerHTML = '';
if (!S.assets.length){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = '还没有图片。点「上传」加入透明底 PNG / WebP。';
wrap.appendChild(p);
return;
}
S.assets.forEach(a => {
const d = document.createElement('div');
d.className = 'as';
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);
Object.values(S.scopes).forEach(c => {
if (c.bg === a.id) c.bg = null;
if (c.pattern === a.id) c.pattern = null;
c.layers = c.layers.filter(l => l.asset !== a.id);
});
renderAll();
};
wrap.appendChild(d);
});
}
/** 素材卡片的 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
? '#' + v.slice(1).split('').map(c=>c+c).join('') : v;
const m = v.match(/rgba?\(([^)]+)\)/);
if (!m) return '#000000';
const p = m[1].split(',').map(x => parseInt(x.trim(), 10));
return '#' + p.slice(0,3).map(n => n.toString(16).padStart(2,'0')).join('');
}
function renderNarrowEditor(){
const el = $('narrow-editor');
el.innerHTML = '';
const n = S.narrow;
const mk = (text, checked, onChange) => {
const lab = document.createElement('label');
lab.className = 'chk';
lab.style.cssText = 'font-size:12px;color:var(--ui-text);display:flex;gap:6px;margin-bottom:7px';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = checked;
cb.onchange = () => onChange(cb.checked);
lab.append(cb, document.createTextNode(text));
return lab;
};
el.appendChild(mk('导出 @media 块', n.enabled, v => { n.enabled = v; renderAll(); }));
const row = document.createElement('div');
row.className = 'row';
const sp = document.createElement('span');
sp.textContent = '断点 px';
const mw = document.createElement('input');
mw.type = 'number'; mw.min = 320; mw.max = 1400; mw.value = n.maxWidth;
mw.onchange = () => { n.maxWidth = clamp(Number(mw.value) || 900, 320, 1400); renderAll(); };
row.append(sp, mw);
el.appendChild(row);
const row2 = document.createElement('div');
row2.className = 'row';
const sp2 = document.createElement('span');
sp2.textContent = '竖版图';
const sel = document.createElement('select');
sel.innerHTML = '<option value="">(沿用桌面背景)</option>' +
S.assets.map(a => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
sel.value = n.bg || '';
sel.onchange = () => { n.bg = sel.value || null; renderAll(); };
row2.append(sp2, sel);
el.appendChild(row2);
el.appendChild(mk('隐藏全部装饰层', n.hideDeco, v => { n.hideDeco = v; renderAll(); }));
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = '手机上卡片几乎占满屏幕:16:9 的桌面场景会被裁成一条纹理,固定 px 的人物会被卡片盖住。' +
'要么换一张竖版图,要么让装饰退场。只写浅色规则——深色块特异性更高,本来就压住它。';
el.appendChild(tip);
}
function renderPalette(){
const wrap = $('palette');
wrap.innerHTML = '';
PALETTE_KEYS.forEach(([k, label]) => {
const r = document.createElement('div');
r.className = 'p';
const s = document.createElement('span');
s.textContent = label; s.style.width = '46px'; s.style.flex = '0 0 auto';
const c = document.createElement('input');
c.type = 'color';
c.value = toHex(S.palette[k]);
c.oninput = () => { S.palette[k] = c.value; applyTheme(); };
const t = document.createElement('input');
t.type = 'text'; t.value = S.palette[k];
t.onchange = () => { S.palette[k] = t.value.trim(); renderAll(); };
r.append(s, c, t);
wrap.appendChild(r);
});
const note = document.createElement('p');
note.className = 'hint';
note.textContent = '深色配色写在生成结果里(深色不加载图片);要改深色请直接改 CSS。';
wrap.appendChild(note);
}
function renderViewportBar(){
const vp = $('vps'); vp.innerHTML = '';
VIEWPORTS.forEach(w => {
const b = document.createElement('button');
b.textContent = w;
b.className = w === S.viewport ? 'on' : '';
b.onclick = () => { S.viewport = w; renderViewportBar(); fitStage(); };
vp.appendChild(b);
});
const md = $('modes'); md.innerHTML = '';
[['light','浅色'],['dark','深色']].forEach(([k, t]) => {
const b = document.createElement('button');
b.textContent = t;
b.className = k === S.mode ? 'on' : '';
b.onclick = () => { S.mode = k; renderViewportBar(); loadPreview(); };
md.appendChild(b);
});
}
function renderAll(){
S.slug = ($('slug').value || '').trim() || 'my-theme';
renderScopes();
renderBgEditor();
renderLayers();
renderAssets();
renderNarrowEditor();
renderPalette();
applyTheme();
scheduleSave();
}
/* ---------------------- 工程持久化(留得住配置) ---------------------- */
/* 没有这两样东西,工具就只能用一次:关掉标签页,刚调好的十个图层和六个页面全没。
localStorage 存草稿(自动),json 存工程(手动、可进版本库、可发给别人)。 */
const LS_KEY = 'tamabox-theme-builder-v2';
let saveTimer = null, draftWarned = false;
function toJSON(){
return {
v: 3,
savedAt: new Date().toISOString(),
slug: S.slug,
scope: S.scope,
viewport: S.viewport,
mode: S.mode,
palette: S.palette,
dark: S.dark,
narrow: S.narrow,
// 图片内嵌 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){
// 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';
S.viewport = VIEWPORTS.indexOf(o.viewport) >= 0 ? o.viewport : 1512;
S.mode = o.mode === 'dark' ? 'dark' : 'light';
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 => {
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 => {
const src = (o.scopes || {})[sc.k] || {};
S.scopes[sc.k] = Object.assign(blankScope(sc.k), src, {
layers: Array.isArray(src.layers) ? src.layers : [],
});
// 图层字段补齐:工程文件可能是旧版本写的,缺的字段用默认值兜住,
// 否则读进来会是一堆 undefined,渲染时静默变成 none
S.scopes[sc.k].layers = S.scopes[sc.k].layers.map(l =>
Object.assign(newLayer(l && l.kind ? l.kind : 'radial'), l));
});
S.curLayer = null;
}
function saveDraft(){
try {
localStorage.setItem(LS_KEY, JSON.stringify(toJSON()));
} catch (e){
// 图片多时 base64 会顶到 5MB 配额,只提示一次,别刷屏
if (!draftWarned){
draftWarned = true;
log('草稿太大存不进浏览器(localStorage 配额),请用「保存工程」落成 json。', 'w');
}
}
}
function scheduleSave(){
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 700);
}
function restoreDraft(){
let raw = null;
try { raw = localStorage.getItem(LS_KEY); } catch (_) { return false; }
if (!raw) return false;
try { fromJSON(JSON.parse(raw)); return true; }
catch (e){ return false; }
}
function refreshUIFromState(){
$('slug').value = S.slug;
S.curLayer = null;
renderViewportBar();
renderAll();
loadPreview();
fitStage();
}
/* ---------------------------- 导出前校验 ---------------------------- */
/* 这些规则本来要导出后跑 go test 才知道。能在点导出时就拦住的,不该让人去等编译。 */
function splitTopLevel(v){
const out = []; let buf = '', depth = 0;
for (const ch of String(v)){
if (ch === '(') depth++;
else if (ch === ')') depth--;
if (ch === ',' && depth === 0){ out.push(buf.trim()); buf = ''; }
else buf += ch;
}
if (buf.trim()) out.push(buf.trim());
return out;
}
function validate(){
const errs = [];
const slug = ($('slug').value || '').trim();
if (!slugOK(slug)) errs.push('slug 只能是小写字母、数字、短横线,1~32 字符');
let hasContent = false;
SCOPES.forEach(sc => {
const raw = S.scopes[sc.k];
if (sc.k !== 'global' && raw.inherit) return; // 跟随全局的不单独检查
const cfg = raw;
if (cfg.layers.length > SLOT) errs.push(`${sc.label}:装饰层 ${cfg.layers.length} 个,超过 ${SLOT} 个槽位`);
cfg.layers.forEach((l, i) => {
if (l.kind === 'image' && !assetById(l.asset)) errs.push(`${sc.label} 第 ${i+1} 层是图片层,但没选图片`);
});
if (cfg.bg && !assetById(cfg.bg)) errs.push(`${sc.label}:整页背景引用了已删除的图片`);
if (cfg.pattern && !assetById(cfg.pattern)) errs.push(`${sc.label}:平铺图案引用了已删除的图片`);
if (cfg.layers.length || cfg.bg || cfg.pattern) hasContent = true;
});
if (!hasContent) errs.push('所有页面都是空的 —— 导出的会是一个什么都没配的主题');
if (S.narrow.bg && !assetById(S.narrow.bg)) errs.push('窄屏竖版图引用了已删除的图片');
// 结构自检:三组装饰值必须都是 10 个(与 Go 的 TestDecoSlotCount 同一条规则)。
// 这条是给工具自己兜底的 —— 真跑出问题说明生成逻辑有 bug,而不是用户配错。
const css = buildCSS(false);
['--tb-deco:', '--tb-deco-size:', '--tb-deco-position:'].forEach(key => {
let rest = css, n = 0;
for (;;){
const i = rest.indexOf(key);
if (i < 0) break;
const j = rest.indexOf(';', i);
if (j < 0) break;
n++;
const cnt = splitTopLevel(rest.slice(i + key.length, j)).length;
if (cnt !== SLOT) errs.push(`生成的 ${key} 第 ${n} 处只有 ${cnt} 个值(应为 ${SLOT})—— 这是工具自身的 bug,请反馈`);
rest = rest.slice(j + 1);
}
if (n === 0) errs.push(`生成的 CSS 里找不到 ${key} —— 工具自身的 bug,请反馈`);
});
return errs;
}
/* ---------------------------- 导出 zip ---------------------------- */
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++){
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[i] = c >>> 0;
}
return t;
})();
function crc32(buf){
let c = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
const u16 = n => new Uint8Array([n & 255, (n >>> 8) & 255]);
const u32 = n => new Uint8Array([n & 255, (n >>> 8) & 255, (n >>> 16) & 255, (n >>> 24) & 255]);
const enc = s => new TextEncoder().encode(s);
function concat(arrs){
let n = 0;
arrs.forEach(a => n += a.length);
const o = new Uint8Array(n);
let p = 0;
arrs.forEach(a => { o.set(a, p); p += a.length; });
return o;
}
/** 只做 STORE(不压缩):素材本来就是 WebP,再压收益接近 0,换来 40 行可审计的实现。 */
function zipStore(files){
const local = [], central = [];
let offset = 0;
const dt = new Date();
const dosTime = ((dt.getHours() << 11) | (dt.getMinutes() << 5) | (dt.getSeconds() >> 1)) & 0xFFFF;
const dosDate = (((dt.getFullYear() - 1980) << 9) | ((dt.getMonth() + 1) << 5) | dt.getDate()) & 0xFFFF;
for (const f of files){
const name = enc(f.name);
const crc = crc32(f.data);
const head = concat([
u32(0x04034b50), u16(20), u16(0), u16(0), u16(dosTime), u16(dosDate),
u32(crc), u32(f.data.length), u32(f.data.length), u16(name.length), u16(0),
]);
local.push(head, name, f.data);
central.push(concat([
u32(0x02014b50), u16(20), u16(20), u16(0), u16(0), u16(dosTime), u16(dosDate),
u32(crc), u32(f.data.length), u32(f.data.length),
u16(name.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(offset),
]), name);
offset += head.length + name.length + f.data.length;
}
const cd = concat(central);
return concat([...local, cd, concat([
u32(0x06054b50), u16(0), u16(0), u16(files.length), u16(files.length),
u32(cd.length), u32(offset), u16(0),
])]);
}
function dataURLToBytes(url){
const b64 = url.split(',')[1] || '';
const bin = atob(b64);
const o = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) o[i] = bin.charCodeAt(i);
return o;
}
function installTxt(slug){
const own = SCOPES.filter(s => s.k !== 'global').map(s => {
const st = S.scopes[s.k].inherit ? '跟随全局' : '独立配置';
return ` ${s.k.padEnd(9)} ${s.desc.padEnd(22)} ${st}`;
}).join('\n');
return `主题 ${slug} —— 安装说明
=====================================
1) 解压(保持目录结构,覆盖即可)
static/css/themes/${slug}.css
static/images/themes/${slug}/*.webp
2) 登记主题(conf/app.ini)
[theme]
available = default,${slug}
⚠️ 没登记就「设置页看不到这个主题」,现象和部署没生效一模一样。
启动日志会打 warn:[theme] available 未登记以下主题
3) 让缓存失效
internal/conf/static.go 的 StaticVersion 改一下(末尾字母 +1),
然后重新 go build —— 模板与静态资源都是编译期 embed,只重启不重编不会变。
4) 箱主设置页 → 账号 → 外观,选择「${slug}」
页面作用域
----------
${own}
窄屏
----
${S.narrow.enabled
? `已导出 @media (max-width: ${S.narrow.maxWidth}px):${S.narrow.bg ? '换成竖版背景图' : '背景沿用桌面'}${S.narrow.hideDeco ? ' + 隐藏全部装饰层' : ''}`
: '未导出窄屏规则(桌面装饰会在手机上被卡片盖住 / 背景被 cover 裁成一条)'}
作用域由服务端按请求路径输出 <html data-page-scope="...">,
主题 CSS 用 html[data-theme-style="${slug}"][data-page-scope="xxx"] 覆盖。
没写覆盖块的作用域自动沿用全局,不会出现「某页装饰消失」。
注意
----
装饰层固定 ${SLOT} 个槽位(--tb-deco / -size / -position 必须同长)。
手动改 CSS 时别删值 —— 少一个会让图层与 size/position 错位,
表现是整页重复同一张插画、或人物跑到左上角。
`;
}
function doExport(){
const errs = validate();
if (errs.length){
out('先修这几处再导出:\n' + errs.map(e => '· ' + e).join('\n'), 'e');
return;
}
const slug = ($('slug').value || '').trim();
const used = new Set();
if (S.narrow.bg) used.add(S.narrow.bg);
const collect = c => {
if (c.bg) used.add(c.bg);
if (c.pattern) used.add(c.pattern);
c.layers.forEach(l => { if (l.asset) used.add(l.asset); });
};
SCOPES.forEach(s => { if (s.k === 'global' || !S.scopes[s.k].inherit) collect(S.scopes[s.k]); });
const files = [{ name: `static/css/themes/${slug}.css`, data: enc(buildCSS(false)) }];
for (const id of used){
const a = assetById(id);
if (a) files.push({ name: `static/images/themes/${slug}/${id}.webp`, data: dataURLToBytes(a.dataURL) });
}
// 工程文件也塞进包里:下次想改直接「打开工程」,不必从零重搭
files.push({ name: `theme-${slug}.json`, data: enc(JSON.stringify(toJSON())) });
files.push({ name: `INSTALL-${slug}.txt`, data: enc(installTxt(slug)) });
const blob = new Blob([zipStore(files)], { type: 'application/zip' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `theme-${slug}.zip`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
out(`已导出 theme-${slug}.zip(${files.length} 个文件,${Math.round(blob.size/1024)}KB)。\n` +
`解压到项目根,按 INSTALL-${slug}.txt 走三步;包里的 theme-${slug}.json 是工程源文件,下次用「打开工程」接着改。`, 'ok');
}
/* ---------------------------- 图片处理 ---------------------------- */
/* 设计: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();
img.onload = () => res(img);
img.onerror = () => rej(new Error('无法解码 ' + file.name));
img.src = URL.createObjectURL(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);
const bytes = dataURLToBytes(url);
return { dataURL: url, bytes, w: cv.width, h: cv.height, kb: Math.round(bytes.length / 1024) };
}
async function addFiles(list){
for (const f of list){
if (!f.type.startsWith('image/')) continue;
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++);
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,导出时取到的是判断顺序里靠前的那个)
S.assets = [];
const bg = document.createElement('canvas');
bg.width = 1920; bg.height = 1080;
const g = bg.getContext('2d');
const gr = g.createLinearGradient(0, 0, 1920, 1080);
gr.addColorStop(0, '#dfeaf6'); gr.addColorStop(0.55, '#eef3f9'); gr.addColorStop(1, '#f7f2e8');
g.fillStyle = gr; g.fillRect(0, 0, 1920, 1080);
g.fillStyle = 'rgba(255,255,255,.5)';
for (let i = 0; i < 6; i++) g.fillRect(i * 340 + 60, 0, 46, 1080);
// 示例人物:只为看清「相对地面的比例」,所以用剪影而不追求好看。
// 比例按真实立绘取(1 : 3.2),头不要画大 —— 头一大就会读成方块人。
const ch = document.createElement('canvas');
ch.width = 300; ch.height = 960;
const c = ch.getContext('2d');
c.fillStyle = '#42506a';
c.strokeStyle = 'rgba(126,205,215,.95)';
c.lineWidth = 9;
c.beginPath(); c.ellipse(150, 34, 60, 16, -0.12, 0, Math.PI * 2); c.stroke(); // 光环
c.beginPath(); c.ellipse(150, 122, 52, 60, 0, 0, Math.PI * 2); c.fill(); // 头
c.fillRect(138, 174, 24, 24); // 颈
c.beginPath(); // 上身
c.moveTo(96, 198); c.lineTo(204, 198); c.lineTo(188, 468); c.lineTo(112, 468);
c.closePath(); c.fill();
c.beginPath(); // 裙摆
c.moveTo(112, 460); c.lineTo(188, 460); c.lineTo(216, 600); c.lineTo(84, 600);
c.closePath(); c.fill();
c.fillRect(112, 596, 30, 252); // 腿
c.fillRect(158, 596, 30, 252);
c.fillStyle = '#1e2634';
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();
// 示例素材也走完整结构(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';
gc.layers = [
Object.assign(newLayer('image'), { asset:'char', h:420, ah:'start', av:'end', dx:40, dy:-36 }),
Object.assign(newLayer('radial'), { color:'#ffd9a0', alpha:0.42, r:320, cx:20, cy:86 }),
];
const ac = S.scopes.admin;
ac.inherit = false;
ac.bg = null;
ac.layers = [Object.assign(newLayer('radial'), { color:'#ffcf9a', alpha:0.38, r:380, cx:86, cy:10 })];
S.curLayer = null;
S.scopes.box = blankScope('box');
S.scopes.question = blankScope('question');
S.scopes.profile = blankScope('profile');
S.scopes.vote = blankScope('vote');
S.narrow.bg = null;
renderAll();
log('已载入示例。左侧切换页面看差异 —— 题目列表页是「独立配置」的例子。', 'ok');
}
/* ---------------------------- 初始化 ---------------------------- */
function boot(){
const params = new URLSearchParams(location.search);
const k = params.get('scope');
if (k && SCOPES.some(s => s.k === k)) S.scope = k;
// 自动恢复上次的草稿:调主题是连续的活,每次重开都从零开始等于没有工具
const restored = params.get('fresh') ? false : restoreDraft();
$('slug').value = S.slug;
renderViewportBar();
renderAll();
loadPreview();
fitStage();
if (restored){
log('已恢复上次的草稿(换 slug / 项目时可点「打开工程」,或加 ?fresh=1 从空白开始)。', 'ok');
} else {
out('在项目根执行 python dev/theme-builder/serve.py,用 http://127.0.0.1:8777/dev/theme-builder/ 打开,才能预览真实页面。');
}
$('preview').addEventListener('load', onFrameLoad);
$('slug').addEventListener('input', () => { S.slug = $('slug').value.trim() || 'my-theme'; applyTheme(); });
$('btn-reload').onclick = () => { loadPreview(); log('重载画板'); };
$('btn-demo').onclick = () => makeDemo();
$('btn-export').onclick = () => doExport();
$('add-layer').onclick = () => {
const cfg = cfgOf(S.scope);
if (cfg.layers.length >= SLOT) return;
const l = newLayer(S.assets.length ? 'image' : 'radial');
if (l.kind === 'image' && S.assets.length) l.asset = S.assets[0].id;
cfg.layers.push(l);
S.curLayer = l.id;
renderAll();
};
$('btn-save').onclick = () => {
const errs = validate();
const warn = errs.length ? '(注意:还有 ' + errs.length + ' 处未处理,工程仍可保存)' : '';
const j = JSON.stringify(toJSON());
const blob = new Blob([j], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `theme-${S.slug}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
log(`已保存 theme-${S.slug}.json(${Math.round(blob.size / 1024)}KB)${warn}`, 'ok');
};
$('btn-open').onclick = () => $('projfile').click();
$('projfile').onchange = async e => {
const f = e.target.files && e.target.files[0];
e.target.value = '';
if (!f) return;
try {
fromJSON(JSON.parse(await f.text()));
refreshUIFromState();
log(`已打开工程 ${f.name}`, 'ok');
} catch (err){
log('打开失败:' + err.message, 'e');
}
};
$('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;
renderAll();
};
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 交互的情况下做回归。
const dump = params.get('dump');
if (dump){
const pre = document.createElement('pre');
pre.id = 'dump';
if (dump === 'css'){
pre.textContent = buildCSS(false);
} else if (dump === 'validate'){
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);
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
pre.textContent = btoa(bin);
}
pre.style.display = 'none';
document.body.appendChild(pre);
}
}
boot();
</script>
</body>
</html>