feat: 初始化画个标仓库 - AI驱动设计系统(BYOK模式) - 224源/371504图标+25海报源+130技能

This commit is contained in:
sgp
2026-10-03 21:44:18 +08:00
commit 9e222877ea
497 changed files with 43611 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
/**
* 👤 人脸/人物头像风格样本预览页(18 种,每种 6 个样例,纯 SVG 无需渲染)
* 用户选定风格后,再批量生成 100 个。
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = process.cwd();
global.window = global;
// 1. 加载引擎(const 声明需同一上下文,拼接赋值暴露到 globalThis)
const engine = fs.readFileSync(path.join(ROOT, 'js', 'core', 'dicebear.js'), 'utf8')
+ '\n;globalThis.HGB_Dicebear = HGB_Dicebear;';
vm.runInThisContext(engine, { filename: 'dicebear.js' });
const render = globalThis.HGB_Dicebear.renderAvatar;
// 2. 人脸/人物风格(排除 bottts 机器人、shapes/rings/identicon 抽象、initials 文字)
const STYLES = [
['avataaars', '阿凡达卡通人物'], ['adventurer', '冒险家人物'], ['adventurer-neutral', '冒险家·中性'],
['notionists', 'Notion 现代人物'], ['notionists-neutral', 'Notion·中性'], ['personas', '人物插画'],
['open-peeps', '手绘小人'], ['micah', '米迦人物'], ['lorelei', '洛雷莱人物'],
['lorelei-neutral', '洛雷莱·中性'], ['dylan', '迪伦人物'], ['miniavs', '迷你头像'],
['big-smile', '大笑脸'], ['fun-emoji', '趣味表情'], ['croodles', '卡通线条'],
['croodles-neutral', '卡通线条中性'], ['big-ears', '大耳朵人物'], ['big-ears-neutral', '大耳朵·中性']
];
let cards = '';
let ok = 0, fail = 0;
for (const [id, cn] of STYLES) {
const f = path.join(ROOT, 'js', 'data', 'sources', `dicebear-${id}.js`);
if (!fs.existsSync(f)) { console.warn('⚠️ 缺文件:', id); continue; }
try {
vm.runInThisContext(fs.readFileSync(f, 'utf8'), { filename: f });
const def = window[`HGB_Dicebear_${id.replace(/-/g, '_')}`];
if (!def) throw new Error('未注册 ' + id);
const cw = def.canvas.width, ch = def.canvas.height;
let samples = '';
for (let s = 1; s <= 6; s++) {
const r = render(id, `face-${s}`);
samples += `<img src="data:image/svg+xml;utf8,${encodeURIComponent(r.svg)}" alt="seed${s}">`;
}
cards += `<div class="style"><div class="st">${cn}<span class="id">dicebear:${id} · ${cw}×${ch}</span></div><div class="samples">${samples}</div></div>`;
ok++;
} catch (e) { console.error('❌', id, e.message.split('\n')[0]); fail++; }
}
const html = `<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8">
<title>👤 资源库人脸/人物头像风格 · 18 种样本</title>
<style>
body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:#0f1115;color:#e8eaed;margin:0;padding:24px}
h1{font-size:20px;margin:0 0 4px}.sub{color:#9aa0a6;font-size:13px;margin-bottom:16px}
.style{background:#1b1e26;border:1px solid #2a2f3a;border-radius:12px;padding:14px;margin-bottom:14px}
.st{font-size:14px;margin-bottom:10px}.st .id{color:#80868b;font-size:11px;margin-left:10px}
.samples{display:flex;gap:12px;flex-wrap:wrap}
.samples img{width:88px;height:88px;border-radius:50%;background:#fff;padding:4px;box-sizing:border-box}
</style></head><body>
<h1>👤 资源库人脸/人物头像风格(${ok} 种 · 每种 6 个样例)</h1>
<div class="sub">全部来自资源库 DiceBear 源 · 纯 SVG 预览 · 告诉我选哪种风格,我批量生成 100 个人脸头像</div>
${cards}
</body></html>`;
fs.writeFileSync(path.join(ROOT, 'output', 'agent-avatars-png', 'style-preview.html'), html, 'utf8');
console.log(`\n🎉 完成:成功 ${ok} 个风格,失败 ${fail} 个 → output/agent-avatars-png/style-preview.html`);
+217
View File
@@ -0,0 +1,217 @@
/**
* 全量审计:找出渲染后为空白/不可见的图标
* 检查项(对每个源每个图标):
* 1. 无几何内容(body 里没有任何可渲染元素)
* 2. 几何完全越界(所有坐标在 24 视口外,会被裁剪成空白)
* 3. 所有几何元素均不可见(fill=none 无 stroke / opacity=0 / visibility=hidden / display=none,
* 考虑 <g> 祖先的 fill/stroke/opacity 继承)
* 用法:node scripts/audit-blank.js [源id过滤,逗号分隔]
*/
const fs = require('fs');
const path = require('path');
const SRC_DIR = path.join(process.cwd(), 'js/data/sources');
const only = process.argv[2] ? process.argv[2].split(',').map(s => s.trim()) : null;
const GEOM = ['path', 'circle', 'rect', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'image', 'use', 'textPath'];
/** 与 js/ui/panels.js 的 STROKE_SOURCES 一致:描边风来源(构建时 stroke 被剥离只剩 path d) */
const STROKE_SOURCES = new Set(['feather', 'lucide', 'tabler']);
function load(sid) {
const txt = fs.readFileSync(path.join(SRC_DIR, sid + '.js'), 'utf8');
global.window = global.window || {};
eval(txt);
return global.window['HGB_Source_' + sid];
}
/** 轻量标签解析:返回元素树,含 parent 指针 */
function parseBody(body) {
const roots = [];
const stack = [];
const re = /<(\/?)([a-zA-Z][\w-]*)([^>]*?)(\/?)>/g;
let m;
while ((m = re.exec(body))) {
const closing = m[1] === '/';
const tag = m[2].toLowerCase();
const attrsStr = m[3];
const selfClose = m[4] === '/' || tag === 'style' || tag === 'defs' || tag === 'title' || tag === 'desc';
if (closing) { stack.pop(); continue; }
const attrs = {};
const am = attrsStr.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g);
for (const a of am) attrs[a[1]] = a[2];
const node = { tag, attrs, children: [], parent: stack.length ? stack[stack.length - 1] : null };
if (stack.length) stack[stack.length - 1].children.push(node);
else roots.push(node);
if (!selfClose) stack.push(node);
}
return roots;
}
/** 收集节点自身的最近有效属性(含祖先继承) */
function effAttrs(node) {
const e = {};
for (let n = node; n; n = n.parent) {
if (e.fill === undefined && n.attrs.fill !== undefined) e.fill = n.attrs.fill;
if (e.stroke === undefined && n.attrs.stroke !== undefined) e.stroke = n.attrs.stroke;
if (e['stroke-width'] === undefined && n.attrs['stroke-width'] !== undefined) e['stroke-width'] = n.attrs['stroke-width'];
if (e.opacity === undefined && n.attrs.opacity !== undefined) e.opacity = n.attrs.opacity;
if (e.visibility === undefined && n.attrs.visibility !== undefined) e.visibility = n.attrs.visibility;
if (e.display === undefined && n.attrs.display !== undefined) e.display = n.attrs.display;
}
return e;
}
/** 单个几何元素是否可见(纯静态,无 svg 根属性模拟) */
function nodeVisible(node) {
const e = effAttrs(node);
if (e.visibility === 'hidden' || e.visibility === 'collapse') return false;
if (e.display === 'none') return false;
if (e.opacity !== undefined && parseFloat(e.opacity) === 0) return false;
const hasStroke = e.stroke !== undefined && e.stroke !== 'none';
if (e.fill === undefined) return true; // 全链无 fill → svg 默认黑色填充 → 可见
const hasFill = e.fill !== 'none' && e.fill !== 'transparent'; // url(#…) 引用也算可渲染
return hasStroke || hasFill;
}
/** 收集 body 中所有几何元素 */
function collectGeoms(body) {
const geoms = [];
(function walk(ns) { for (const n of ns) { if (GEOM.includes(n.tag)) geoms.push(n); walk(n.children); } })(parseBody(body));
return geoms;
}
/**
* 渲染模拟:复刻 js/ui/panels.js renderBuiltinGrid 的判定
* - preserve(emoji/brand 多色)保留原色
* - stroke 型:<svg fill="none" stroke=灰 …>,几何靠描边或自带彩色填充显示
* - fill 型:<svg fill=灰 stroke="none">,几何靠填充显示
* body 预处理:fill/stroke="currentColor" → "inherit"
*/
function renderVisible(geoms, body, sid, type) {
const replaced = body.replace(/fill="currentColor"/g, 'fill="inherit"').replace(/stroke="currentColor"/g, 'stroke="inherit"');
if (type && type !== 'icon') {
// 保留原色:按元素自身属性判定
return geoms.some(nodeVisible);
}
const isStroke = STROKE_SOURCES.has(sid) || /stroke\s*=|\bstroke-linecap\s*=|\bstroke-linejoin\s*=/.test(replaced);
const rootFill = isStroke ? 'none' : '#374151';
const rootStroke = isStroke ? '#374151' : 'none';
return geoms.some(g => {
const e = effAttrs(g);
if (e.visibility === 'hidden' || e.visibility === 'collapse') return false;
if (e.display === 'none') return false;
if (e.opacity !== undefined && parseFloat(e.opacity) === 0) return false;
// currentColor→inherit 已在 replaced 中体现,但 effAttrs 用的是原始 body;这里手动映射
const fill = e.fill === undefined ? rootFill : (e.fill === 'currentColor' ? 'inherit' : e.fill);
const stroke = e.stroke === undefined ? rootStroke : (e.stroke === 'currentColor' ? 'inherit' : e.stroke);
const hasStroke = stroke !== undefined && stroke !== 'none' && stroke !== 'inherit' || (stroke === 'inherit' && rootStroke !== 'none');
const hasFill = fill !== 'none' && fill !== 'transparent' && fill !== 'inherit' || (fill === 'inherit' && rootFill !== 'none');
return hasStroke || hasFill;
});
}
/** 提取 path d 字符串列表 */
function pathDs(body) {
const out = [];
let re = /<path\b[^>]*>/g, m;
while ((m = re.exec(body))) {
const dm = m[0].match(/\bd="([^"]*)"/);
if (dm) out.push(dm[1]);
}
return out;
}
/** 从 body 提取所有数值(path d 坐标 + 几何属性,排除弧段 flags) */
function geomNumbers(body) {
const nums = [];
const dRe = /\bd="([^"]*)"/g;
let dm;
while ((dm = dRe.exec(body))) {
// 按命令分段,跳过弧段 flags(仅 0/1,混入会干扰越界判断)
const d = dm[1];
const cmds = d.match(/[a-zA-Z][^a-zA-Z]*/g) || [];
for (const c of cmds) {
const cmd = c[0];
const vals = (c.slice(1).match(/-?\d*\.?\d+(?:e[+-]?\d+)?/gi) || []).map(parseFloat);
if ((cmd === 'A' || cmd === 'a') && vals.length >= 7) {
const rx = vals.shift(), ry = vals.shift();
nums.push(rx, ry);
vals.shift(); // x-rotation
vals.shift(); vals.shift(); // flags 0/1 不参与
nums.push(...vals);
} else {
nums.push(...vals);
}
}
}
const attrs = /(?:cx|cy|rx|ry|x1|y1|x2|y2|width|height|x|y|r|points)="(-?[\d\s.,eE+-]+)"/g;
let m;
while ((m = attrs.exec(body))) {
const vals = m[1].match(/-?\d*\.?\d+(?:e[+-]?\d+)?/gi) || [];
for (const n of vals) nums.push(parseFloat(n));
}
return nums;
}
function hasGeom(body) {
return GEOM.some(t => new RegExp('<' + t + '\\b').test(body));
}
function checkIcon(ic, sid) {
const body = ic.svg || '';
const reasons = [];
if (!body.trim()) reasons.push('空 body');
if (!hasGeom(body)) { reasons.push('无几何元素'); return reasons; }
const geoms = collectGeoms(body);
// 渲染模拟可见性:复刻 panels.js 渲染判定
const visible = renderVisible(geoms, body, sid, ic.type);
if (!visible) {
const sample = geoms.slice(0, 2).map(g => {
const e = effAttrs(g);
return `${g.tag}{fill:${e.fill},stroke:${e.stroke},opacity:${e.opacity}}`;
}).join(' ');
reasons.push(`渲染后不可见 (${geoms.length}个: ${sample})`);
}
// 越界检查:非 wrap 归一化时,几何完全落在 24 视口外
if (!/transform="translate\([^"]*\)\s*scale\(/.test(body)) {
const nums = geomNumbers(body);
if (nums.length) {
const min = Math.min(...nums), max = Math.max(...nums);
if (max < -1 || min > 25) reasons.push(`几何越界 [${min},${max}]`);
}
}
return reasons;
}
function main() {
const files = fs.readdirSync(SRC_DIR).filter(f => f.endsWith('.js') && !f.startsWith('dicebear-'));
const total = { icons: 0, bad: 0 };
const perSource = [];
for (const f of files) {
const sid = f.replace(/\.js$/, '');
if (only && !only.includes(sid)) continue;
let data;
try { data = load(sid); } catch (e) { perSource.push({ sid, error: '加载失败 ' + e.message }); continue; }
if (!data || !Array.isArray(data.icons)) { perSource.push({ sid, error: '无 icons 数组' }); continue; }
const bad = [];
for (const ic of data.icons) {
total.icons++;
const reasons = checkIcon(ic, sid);
if (reasons.length) {
total.bad++;
bad.push({ id: ic.id || ic.name, reasons, len: (ic.svg || '').length });
}
}
if (bad.length) perSource.push({ sid, count: data.icons.length, bad, ratio: (bad.length / data.icons.length * 100).toFixed(1) + '%' });
}
console.log(`\n扫描 ${total.icons.toLocaleString()} 个图标,发现可疑 ${total.bad.toLocaleString()} 个`);
perSource.filter(s => s.bad).sort((a, b) => b.bad.length - a.bad.length).forEach(s => {
console.log(`\n■ ${s.sid}: ${s.bad.length}/${s.count}(${s.ratio})`);
s.bad.slice(0, 40).forEach(b => console.log(` ${b.id} [${b.reasons.join('; ')}] len=${b.len}`));
if (s.bad.length > 40) console.log(` … 等 ${s.bad.length - 40} 个`);
});
perSource.filter(s => s.error).forEach(s => console.log(`\n✗ ${s.sid}: ${s.error}`));
}
main();
+139
View File
@@ -0,0 +1,139 @@
/**
* 冒烟:头像模式 AI 路径(aiDesigner buildPrompt/validate/mapToPlan)
* 验证:
* 1) buildPrompt 头像版布局白名单 = 头像布局、不含 lockup 白名单
* 2) validate + normalizeLayoutDecisions 把 AI 布局修正到头像白名单内
* 3) mapToPlan 产物 layoutId 在头像白名单、lockup 为空、无文字元素
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = path.resolve(__dirname, '..');
// 加载顺序:数据 → 核心(依赖全局常量 HGB_*)
const files = [
'js/data/icons.js',
'js/data/styles.js',
'js/data/industries.js',
'js/data/builtin-icons.js',
'js/data/decorations.js',
'js/core/generator.js',
'js/core/avatarGen.js',
'js/skills/skills.js',
'js/core/ai.js',
'js/core/aiDesigner.js'
];
for (const f of files) {
const src = fs.readFileSync(path.join(ROOT, f), 'utf8');
// vm 在全局作用域执行,使 HGB_* 成为全局词法绑定
vm.runInThisContext(src, { filename: f });
}
const ind = HGB_Industries.find('food');
const opt = {
industry: ind,
brand: '川味小馆',
sub: '',
desc: '川菜馆,麻辣鲜香',
icons: [],
styleIds: [],
skills: ['composition', 'contrast-check'],
count: 5,
avatar: true
};
let pass = 0, fail = 0;
function check(name, ok, detail) {
if (ok) { pass++; console.log(' ✅ ' + name + (detail ? ' — ' + detail : '')); }
else { fail++; console.log(' ❌ ' + name + (detail ? ' — ' + detail : '')); }
}
// 1) Prompt 头像版
const msgs = HGB_AIDesigner.buildPrompt(opt);
const sys = msgs[0].content, user = msgs[1].content;
const avatarLayoutIds = HGB_AvatarGen.AVATAR_LAYOUTS;
const fullLayoutIds = HGB_Generator.LAYOUTS.map(l => l.id);
console.log('== 1) buildPrompt 头像版 ==');
check('系统提示为头像设计师', /头像设计师/.test(sys));
// 布局白名单:LOGO 独有的布局(monogram 等)不应出现在头像 prompt
const logoOnly = fullLayoutIds.filter(id => !avatarLayoutIds.includes(id));
check('头像 prompt 不含 LOGO 专属布局', !logoOnly.some(id => new RegExp('\\b' + id + '\\b').test(user)), '排除: ' + logoOnly.join('/'));
check('头像 prompt 含头像布局', avatarLayoutIds.every(id => new RegExp('\\b' + id + '\\b').test(user)) || user.includes('orb'));
check('头像 prompt 不含 lockup 白名单', !user.includes('lockup') && !user.includes('品牌组合位置'));
check('头像 prompt 强调无文字/纯图形', /纯图形|不含任何文字/.test(sys));
// 2) validate:给一批非法布局(含 LOGO 专属 monogram/ribbon),验证全部修正到头像白名单
console.log('== 2) validate 布局修正 ==');
const fake = {
overallConcept: 'x',
plans: [
{ name: 'A', concept: 'c', icon: 'bad', layout: 'monogram', styleId: 'minimal' },
{ name: 'B', concept: 'c', icon: 'bad', layout: 'ribbon', styleId: 'artdeco' },
{ name: 'C', concept: 'c', icon: 'bad', layout: 'badge', styleId: 'neon' },
{ name: 'D', concept: 'c', icon: 'bad', layout: 'seal', styleId: 'minimal' },
{ name: 'E', concept: 'c', icon: 'bad', layout: 'orb', styleId: 'minimal' }
]
};
const data = HGB_AIDesigner.validate(fake, opt);
const layouts = data.plans.map(p => p.layout);
const uniq = new Set(layouts);
check('全部布局在头像白名单', layouts.every(l => avatarLayoutIds.includes(l)), layouts.join('/'));
check('非法布局被修正并标记', layouts.includes('orb') || data.plans.some(p => p._layoutInvalid));
check('布局多样性 ≥3', uniq.size >= 3, '种类 ' + uniq.size);
const round = layouts.filter(l => ['badge', 'seal', 'orb', 'duo', 'burst'].includes(l)).length;
check('头像圆形构图 ≤4', round <= 4, '圆形 ' + round);
// 3) mapToPlan 产物结构(icon 非法 → 回退行业图标)
console.log('== 3) mapToPlan 产物 ==');
const plans = data.plans.map((p, i) => HGB_AIDesigner.mapToPlan(p, opt, i));
for (let i = 0; i < plans.length; i++) {
const p = plans[i];
check('方案' + (i + 1) + ' layoutId 在头像白名单', avatarLayoutIds.includes(p.layoutId), p.layoutId);
const hasText = (p.markElements || []).some(el => el.type === 'text');
check('方案' + (i + 1) + ' Mark 无文字元素', !hasText);
check('方案' + (i + 1) + ' 有 ai 标记与概念', !!p.ai && !!p.concept);
check('方案' + (i + 1) + ' 配色跟随风格', !!p.paletteName && !!p.bg);
}
// 图标差异:AI 图标非法时按序号轮换行业图标,不应全套同一
const iconSet = new Set(plans.map(p => (p.aiIcon || '') + '|' + (p.markElements || []).filter(e => e.type === 'icon').map(e => e.iconId).join(',')));
check('图标有差异(>1 种)', iconSet.size > 1, '种类 ' + iconSet.size);
// 4) 头像专有布局(avatarOnly)差异化:LOGO 全局池排除、头像白名单包含
console.log('== 4) 头像专有布局差异化 ==');
const avatarOnlyIds = HGB_Generator.LAYOUTS.filter(l => l.avatarOnly).map(l => l.id);
check('存在 3 个头像专有布局', avatarOnlyIds.length === 3 && JSON.stringify(avatarOnlyIds) === JSON.stringify(['fullbleed', 'concentric', 'centroid']), avatarOnlyIds.join('/'));
// LOGO 模式 generatePlans(无白名单)不得出现头像专有布局
const logoPlans = HGB_Generator.generatePlans({ industry: ind, brand: '川味小馆', count: 12 });
check('LOGO 模式 12 套全部布局合法', logoPlans.every(p => !avatarOnlyIds.includes(p.layoutId)));
check('LOGO 模式布局多样', new Set(logoPlans.map(p => p.layoutId)).size >= 5);
// 头像模式 generateAvatars 白名单包含头像专有布局
check('头像白名单含头像专有布局', avatarOnlyIds.every(id => HGB_AvatarGen.AVATAR_LAYOUTS.includes(id)));
const avatarPlans = HGB_AvatarGen.generateAvatars({ industry: ind, brand: '川味小馆', count: 20 });
const avLayouts = new Set(avatarPlans.map(p => p.layoutId));
check('头像 20 套产出头像专有布局', avatarOnlyIds.some(id => avLayouts.has(id)), '头像布局 ' + avLayouts.size + ' 种: ' + [...avLayouts].slice(0, 8).join('/') + '…');
check('头像 20 套全部在白名单内', avatarPlans.every(p => HGB_AvatarGen.isAvatarLayout(p.layoutId)));
// AI 校验:头像 validate 允许头像专有布局;LOGO validate 标记头像专有布局非法
const avFake = { overallConcept: 'x', plans: [
{ name: 'A', concept: 'c', icon: 'bad', layout: 'fullbleed', styleId: 'minimal' },
{ name: 'B', concept: 'c', icon: 'bad', layout: 'concentric', styleId: 'neon' },
{ name: 'C', concept: 'c', icon: 'bad', layout: 'centroid', styleId: 'minimal' },
{ name: 'D', concept: 'c', icon: 'bad', layout: 'orb', styleId: 'minimal' },
{ name: 'E', concept: 'c', icon: 'bad', layout: 'seal', styleId: 'artdeco' }
]};
const avData = HGB_AIDesigner.validate(avFake, opt);
check('AI 头像接受头像专有布局', avData.plans.every(p => avatarOnlyIds.includes(p.layout) || HGB_AvatarGen.isAvatarLayout(p.layout)));
const logoOpt = { ...opt, avatar: false };
const lgFake = { overallConcept: 'x', plans: [
{ name: 'A', concept: 'c', icon: 'bad', layout: 'fullbleed', styleId: 'minimal' },
{ name: 'B', concept: 'c', icon: 'bad', layout: 'badge', styleId: 'minimal' },
{ name: 'C', concept: 'c', icon: 'bad', layout: 'seal', styleId: 'minimal' },
{ name: 'D', concept: 'c', icon: 'bad', layout: 'orb', styleId: 'minimal' },
{ name: 'E', concept: 'c', icon: 'bad', layout: 'monogram', styleId: 'minimal' }
]};
const lgData = HGB_AIDesigner.validate(lgFake, logoOpt);
const lgLayouts = lgData.plans.map(p => p.layout);
check('AI LOGO 排除头像专有布局', lgLayouts.every(l => !avatarOnlyIds.includes(l)), lgLayouts.join('/'));
console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败');
process.exit(fail ? 1 : 0);
+52
View File
@@ -0,0 +1,52 @@
/**
* 🤖 bottts 机器人头像生成共享逻辑(预览页与 PNG 渲染共用,保证 seed 序列一致)
* - 过滤负面表情变体:eyes.dizzy(眩晕)、mouth.bite(龇牙)
* - 确定性 seed(agent-N),负面/重复时自动追加后缀重试
*/
'use strict';
/** 负面变体特征字符串(来自 dicebear-bottts.js 定义,命中即视为负面表情) */
const NEG_MARKERS = [
'M30.5 15.3c.4-.4', // eyes.dizzy(眩晕旋转眼)
'6-8H10z' // mouth.bite(咬合龇牙)
];
function isNegative(svg) {
return NEG_MARKERS.some(m => svg.includes(m));
}
/**
* 生成 count 个不重复、非负面表情的头像
* @param {(seed:string)=>{svg:string}} render 渲染函数(同 seed 同输出)
* @param {number} count 数量
* @returns {{seed:string, svg:string}[]}
*/
function generateSeeds(render, count) {
const out = [];
let n = 1;
let guard = 0;
const MAX_GUARD = count * 120 + 5000;
while (out.length < count) {
if (++guard > MAX_GUARD) {
throw new Error(`[bot-common] 生成 ${count} 个唯一头像失败:该风格可能渲染无变化(n=${n}, 已有 ${out.length})`);
}
let seed = 'agent-' + n;
let svg = render(seed).svg;
if (!svg) { n++; continue; } // 空渲染直接跳过
let attempt = 0;
while (isNegative(svg) || out.some(o => o.svg === svg)) {
attempt++;
if (attempt > 60) break; // 保护
seed = 'agent-' + n + '-' + attempt;
svg = render(seed).svg;
if (!svg) { n++; break; }
}
if (!isNegative(svg) && !out.some(o => o.svg === svg)) {
out.push({ seed, svg });
}
n++;
}
return out;
}
module.exports = { generateSeeds, isNegative };
+100
View File
@@ -0,0 +1,100 @@
// 生成 js/data/manifest/BASELINE.md —— 系统现状基线快照(构建后运行,AI 采集指令的「执行前自查」数据源)
// 用法:node scripts/build-baseline.js
const fs = require('fs');
const path = require('path');
const MAN = 'js/data/manifest';
const read = (f) => JSON.parse(fs.readFileSync(path.join(MAN, f), 'utf8'));
const icons = read('icons.json');
const posters = read('posters.json');
const skills = read('skills.json');
const countBy = (arr, key) => {
const m = {};
arr.forEach((x) => { m[x[key]] = (m[x[key]] || 0) + 1; });
return m;
};
// 三区覆盖:优先 workspaces 字段,缺省按 scene 关键词统计
const wsCount = (skill) => {
if (Array.isArray(skill.workspaces) && skill.workspaces.length) {
return {
logo: skill.workspaces.includes('logo'),
avatar: skill.workspaces.includes('avatar'),
poster: skill.workspaces.includes('poster'),
};
}
const sc = skill.scene || '';
return {
logo: /LOGO|logo/.test(sc),
avatar: /头像|avatar/.test(sc),
poster: /海报|poster/.test(sc),
};
};
const cover = (list) => {
const c = { logo: 0, avatar: 0, poster: 0, none: 0 };
list.forEach((s) => {
const w = wsCount(s);
if (!w.logo && !w.avatar && !w.poster) c.none++;
if (w.logo) c.logo++;
if (w.avatar) c.avatar++;
if (w.poster) c.poster++;
});
return c;
};
const sharedSkills = skills.shared.skills || [];
const posterSkills = (skills.poster && skills.poster.skills) || [];
const stickerSkills = (skills.sticker && skills.sticker.skills) || [];
const sharedCat = countBy(sharedSkills, 'category');
const posterCat = countBy(posterSkills, 'category');
const stickerCat = countBy(stickerSkills, 'category');
const sharedCover = cover(sharedSkills);
const posterCover = cover(posterSkills);
const iconTypes = countBy(icons.sources, 'type');
const posterTypes = countBy(posters.sources, 'type');
const lines = [];
lines.push('# 画个标 · 系统基线(构建自动生成,勿手改)');
lines.push('');
lines.push('> 由 `scripts/build-baseline.js` 生成。素材/技能变更并构建后,重新运行 `node scripts/build-baseline.js` 即自动更新。');
lines.push('> 执行「AI 指令库 指令一~四」前,先读本文件获取当前基线,勿依赖记忆中的数字。');
lines.push('');
lines.push('## 生成时间');
lines.push('');
lines.push(new Date().toISOString());
lines.push('');
lines.push('## ① 图标库(共享,LOGO/头像/海报共用)');
lines.push('');
lines.push(`- 源数:${icons.sources.length} | 图标总数:${icons.total}`);
lines.push(`- 类型分布:${Object.entries(iconTypes).map(([k, v]) => `${k} ${v}`).join(' / ')}`);
lines.push('- 源清单(含 count/file/workspaces):js/data/manifest/icons.json');
lines.push('');
lines.push('## ② 海报素材库');
lines.push('');
lines.push(`- 源数:${posters.sources.length} | 素材总数:${posters.total}`);
lines.push(`- 类型分布:${Object.entries(posterTypes).map(([k, v]) => `${k} ${v}`).join(' / ')}`);
lines.push('- 源清单:js/data/manifest/posters.json');
lines.push('');
lines.push('## ③ 技能库');
lines.push('');
lines.push(`- 共享技能:${sharedSkills.length}(分类:${Object.entries(sharedCat).map(([k, v]) => `${k} ${v}`).join(' / ')})`);
lines.push(`- 海报技能:${posterSkills.length}(分类:${Object.entries(posterCat).map(([k, v]) => `${k} ${v}`).join(' / ')})`);
lines.push(`- 表情技能:${stickerSkills.length}(分类:${Object.entries(stickerCat).map(([k, v]) => `${k} ${v}`).join(' / ')})`);
lines.push(`- 三区覆盖(共享,按 scene/workspaces):LOGO 向 ${sharedCover.logo} / 头像向 ${sharedCover.avatar} / 海报向 ${sharedCover.poster} / 未标注 ${sharedCover.none}`);
lines.push(`- 三区覆盖(海报专属):LOGO 向 ${posterCover.logo} / 头像向 ${posterCover.avatar} / 海报向 ${posterCover.poster}`);
lines.push('- 技能明细:js/data/manifest/skills.json');
lines.push('');
lines.push('## 指令使用提示');
lines.push('');
lines.push('- 指令一(新增图标源):去重以本文件 ① 的源清单 + icon-sources.js 现有 id 为准');
lines.push('- 指令二(新增海报源):去重以 ② + poster-sources.js 现有 id 为准');
lines.push('- 指令三(新增技能):看 ③ 三区覆盖找缺口(头像向最少则优先补头像);表情向技能看表情技能分类分布');
lines.push('');
const out = path.join(MAN, 'BASELINE.md');
fs.writeFileSync(out, lines.join('\n'), 'utf8');
console.log('已生成 ' + out);
console.log(lines.join('\n'));
File diff suppressed because it is too large Load Diff
+594
View File
@@ -0,0 +1,594 @@
/**
* 海报素材构建脚本:从 GitHub / npm 下载开源海报设计资源,解析生成内置海报素材库(离线)
* 产物:
* - js/data/poster-builtin.js 轻量索引(sources 元数据 + 分类索引,无全量内容)
* - js/data/posters/{id}.js 每源一个数据文件(HGB_PosterSource_{id} = {items:[...]})
* - js/data/posters/{id}/files/* 位图/字体等二进制原始文件(浏览器按 file 引用直读)
* 用法:node scripts/build-posters.js
*
* 与 build-icons.js 的差异:
* - 素材为全彩/位图/模板,保留原始宽高比(不归一化到 24 视口)
* - 支持 SVG / PNG / JPG / 字体 / 配色 JSON 多格式
* - 每素材带三维归类:type(资源类型)+ category/categoryCn(应用场景)+ workspaces(工作区)
* - 完整性质检:数量 + sha256 字节 + 分辨率参数 + 格式魔数 + SVG 内容校验
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const vm = require('vm');
/* ================= 数据源 ================= */
/* 来源清单统一维护在 js/data/poster-sources.js(构建脚本与「海报素材」页面共用) */
const SOURCES = require('../js/data/poster-sources.js');
/* ================= 网络 ================= */
function fetchText(url, redirects) {
redirects = redirects || 0;
return new Promise((resolve, reject) => {
const req = https.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (redirects >= 3) return reject(new Error('redirect loop ' + url));
let next = res.headers.location;
if (next.startsWith('/')) next = 'https://unpkg.com' + next;
return resolve(fetchText(next, redirects + 1));
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`${res.statusCode} ${url}`));
}
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(new Error('timeout ' + url)); });
});
}
function fetchBuffer(url, redirects) {
redirects = redirects || 0;
return new Promise((resolve, reject) => {
const req = https.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (redirects >= 3) return reject(new Error('redirect loop ' + url));
let next = res.headers.location;
if (next.startsWith('/')) next = 'https://unpkg.com' + next;
return resolve(fetchBuffer(next, redirects + 1));
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`${res.statusCode} ${url}`));
}
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks)));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(new Error('timeout ' + url)); });
});
}
/** 简易并发池 */
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
/* ================= 完整性工具 ================= */
function sha256(buf) {
return crypto.createHash('sha256').update(buf).digest('hex');
}
/** 构建脚本改动版本(加入缓存 hash,代码变更后自动重下) */
const BUILD_REV = '2'; // v2: 修复 twemoji/heropatterns 排除正则失效、字体应用场景误分类、新增 open-color 源
/** 常见非素材配置文件(即使扩展名命中 ext 也剔除) */
const NON_ASSETS = new Set([
'package.json', 'package-lock.json', 'yarn.lock', 'composer.json', 'bower.json',
'tsconfig.json', 'jsconfig.json', 'deno.json', 'biome.json',
'.eslintrc.json', '.prettierrc.json', '.stylelintrc.json', '.nycrc.json',
'swagger.json', 'openapi.json'
]);
/** 剔除非素材文件(包配置/脚手架),返回新数组 */
function dropNonAssets(files) {
return files.filter(p => {
const base = path.basename(p).toLowerCase();
return !NON_ASSETS.has(base) && !base.startsWith('.'); // .eslintrc.json 等点文件
});
}
/* ================= 应用场景分类(classifyPoster) ================= */
const POSTER_CATEGORY_RULES = [
{ cat: 'marketing', cn: '营销促销', words: ['sale', 'promo', 'discount', 'coupon', 'deal', 'marketing', 'offer', 'price', 'shopping', 'promotion', 'voucher', 'banner', 'ad', 'launch'] },
{ cat: 'activity', cn: '活动庆典', words: ['event', 'party', 'celebration', 'festival', 'ceremony', 'gala', 'anniversary', 'opening', 'grand', 'party-popper', 'confetti', 'ribbon', 'balloon', 'carnival', 'expo', 'conference'] },
{ cat: 'recruitment', cn: '招聘招生', words: ['job', 'hiring', 'recruit', 'career', 'staff', 'talent', 'apply', 'position', 'enroll', 'admission', 'recruitment', 'wanted', 'join-us', 'vacancy'] },
{ cat: 'food', cn: '餐饮美食', words: ['food', 'restaurant', 'coffee', 'cake', 'pizza', 'burger', 'drink', 'menu', 'chef', 'cuisine', 'catering', 'dinner', 'lunch', 'breakfast', 'tea', 'beverage', 'snack', 'dessert', 'bakery', 'barbecue', 'hotpot', 'sushi'] },
{ cat: 'ecommerce', cn: '电商带货', words: ['ecommerce', 'shop', 'store', 'product', 'online', 'mall', 'buy', 'cart', 'order', 'flash', 'seckill', 'live', 'taobao', 'jd', 'goods', 'retail', 'merch'] },
{ cat: 'education', cn: '教育培训', words: ['education', 'school', 'course', 'class', 'learn', 'study', 'training', 'tutor', 'lesson', 'academy', 'campus', 'student', 'teacher', 'workshop', 'seminar', 'exam', 'book', 'graduation'] },
{ cat: 'health', cn: '医疗健康', words: ['health', 'medical', 'hospital', 'clinic', 'doctor', 'dental', 'pharmacy', 'care', 'fitness', 'yoga', 'spa', 'beauty', 'wellness', 'nurse', 'surgery', 'therapy'] },
{ cat: 'tech', cn: '科技数码', words: ['tech', 'digital', 'ai', 'robot', 'software', 'app', 'code', 'data', 'cloud', 'gadget', 'startup', 'innovation', 'internet', 'smart', 'electronic', 'cyber', 'computer', 'mobile', 'it-'] },
{ cat: 'travel', cn: '旅游出行', words: ['travel', 'tour', 'trip', 'tourism', 'hotel', 'flight', 'airline', 'vacation', 'holiday', 'destination', 'resort', 'journey', 'adventure', 'booking', 'scenery', 'landmark'] },
{ cat: 'realestate', cn: '房地产', words: ['realestate', 'real-estate', 'house', 'property', 'apartment', 'villa', 'home', 'estate', 'housing', 'rent', 'sale-house', 'building', 'architecture', 'interior', 'construction'] },
{ cat: 'festival', cn: '节日节气', words: ['festival', 'christmas', 'new-year', 'spring-festival', 'mid-autumn', 'national-day', 'halloween', 'thanksgiving', 'easter', 'valentine', 'lantern', 'dragon-boat', 'qixi', 'double-eleven', 'mothers-day', 'fathers-day'] },
{ cat: 'charity', cn: '公益宣传', words: ['charity', 'donate', 'volunteer', 'public', 'environment', 'green', 'recycle', 'welfare', 'community', 'nonprofit', 'awareness', 'campaign', 'fundraise'] },
{ cat: 'notice', cn: '通知公告', words: ['notice', 'announce', 'bulletin', 'notice-board', 'announcement', 'info', 'alert', 'warning', 'tips', 'guide', 'instruction'] },
{ cat: 'music', cn: '音乐演出', words: ['music', 'concert', 'live-show', 'performance', 'stage', 'band', 'singer', 'dj', 'festival-music', 'opera', 'theater', 'dance', 'ticket', 'gig', 'show'] },
{ cat: 'sport', cn: '体育运动', words: ['sport', 'fitness', 'gym', 'football', 'basketball', 'running', 'marathon', 'race', 'yoga', 'match', 'game', 'athlete', 'training', 'competition', 'champion', 'outdoor'] },
{ cat: 'business', cn: '商务会议', words: ['business', 'meeting', 'conference', 'summit', 'forum', 'corporate', 'finance', 'investment', 'annual', 'report', 'presentation', 'brand', 'enterprise', 'b2b', 'office'] },
{ cat: 'baby', cn: '母婴亲子', words: ['baby', 'kids', 'child', 'parent', 'family', 'mother', 'father', 'toy', 'nursery', 'maternal', 'infant', 'children', 'play', 'story'] },
{ cat: 'others', cn: '其他', words: [] }
];
/** 素材名 → {cat, cn};未命中返回"其他" */
function classifyPoster(name) {
const n = String(name || '').toLowerCase();
for (const r of POSTER_CATEGORY_RULES) {
if (r.words.some(w => n.includes(w))) return { cat: r.cat, cn: r.cn };
}
return { cat: 'others', cn: '其他' };
}
/* ================= 分辨率 / 格式解析 ================= */
/** SVG:解析 viewBox 或 width/height,非法返回 null */
function parseSvgMeta(svgStr) {
const svgTag = (svgStr.match(/<svg[^>]*>/i) || [])[0] || '';
const vb = (svgTag.match(/viewBox\s*=\s*["']([^"']+)["']/i) || [])[1];
if (vb) {
const p = vb.trim().split(/[\s,]+/).map(Number);
if (p.length === 4 && p.every(n => Number.isFinite(n)) && p[2] > 0 && p[3] > 0) {
return { width: p[2], height: p[3], viewBox: p.join(' ') };
}
}
const w = parseFloat((svgTag.match(/width\s*=\s*["']([\d.]+)/i) || [])[1]);
const h = parseFloat((svgTag.match(/height\s*=\s*["']([\d.]+)/i) || [])[1]);
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
return { width: w, height: h, viewBox: `0 0 ${w} ${h}` };
}
return null;
}
/** PNG:解析 IHDR 头取宽高;非 PNG 返回 null */
function parsePngSize(buf) {
if (buf.length < 24) return null;
const sig = buf.slice(0, 8);
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (!sig.equals(PNG_SIG)) return null;
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
return (width > 0 && height > 0) ? { width, height } : null;
}
/** JPG:扫描 SOF 段取宽高;非 JPG 返回 null */
function parseJpgSize(buf) {
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null; // SOI
let i = 2;
while (i + 9 < buf.length) {
if (buf[i] !== 0xff) { i++; continue; }
const marker = buf[i + 1];
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
const height = buf.readUInt16BE(i + 5);
const width = buf.readUInt16BE(i + 7);
return (width > 0 && height > 0) ? { width, height } : null;
}
const len = buf.readUInt16BE(i + 2);
if (len < 2) return null;
i += 2 + len;
}
return null;
}
/* ================= SVG 内容校验 ================= */
/** 剔除注释并压缩空白 */
function stripSvgBody(svgStr) {
const m = svgStr.match(/<svg[^>]*>([\s\S]*?)<\/svg>/i);
if (!m) return '';
return m[1].replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim();
}
/** 是否含可渲染几何(path/circle/rect/text 等) */
function hasGeometry(body) {
return /<(?:path|circle|rect|polygon|polyline|line|ellipse|text|g)\b/i.test(body);
}
/** 剔除悬空引用(url(#id)/href="#id" 无对应定义 → 渲染透明) */
function hasDanglingRef(body) {
const ids = new Set();
const idRe = /\bid\s*=\s*["']([^"']+)["']/g;
let m;
while ((m = idRe.exec(body))) ids.add(m[1]);
const refRe = /(?:url\(|href\s*=\s*["'])#([^"')]+)/g;
while ((m = refRe.exec(body))) {
const ref = m[1].trim();
if (ref && !ids.has(ref)) return true;
}
return false;
}
/* ================= GitHub 仓库目录(jsDelivr gh 代理) ================= */
async function downloadGhFiles(src) {
const items = [];
let ok = 0, fail = 0;
const repo = `${src.gh}@${src.ghBranch || 'master'}`;
const tree = JSON.parse(await fetchText(`https://data.jsdelivr.com/v1/packages/gh/${repo}`));
const exts = (Array.isArray(src.ext) ? src.ext : (src.ext ? String(src.ext).split(',') : ['.svg']))
.map(e => e.trim().toLowerCase());
const files = [];
(function walk(list, prefix) {
for (const f of list) {
if (f.type === 'directory') walk(f.files || [], prefix + f.name + '/');
else if (exts.some(e => f.name.toLowerCase().endsWith(e))) files.push(prefix + f.name);
}
})(tree.files || [], '');
// dirs 支持 '.' 表示全目录;maxItems 用于超大源限量收录(如表情包全量过大)
const allowed = (src.dirs || []).map(d => d === '.' || d === '' ? '' : d.replace(/\/$/, '') + '/');
let picked = files.filter(p => allowed.some(d => d === '' || p.startsWith(d)));
picked = dropNonAssets(picked);
if (src.exclude) {
const re = new RegExp(src.exclude);
picked = picked.filter(p => !re.test(p));
}
const cap = Number(src.maxItems) > 0 ? Math.min(Number(src.maxItems), picked.length) : picked.length;
picked = picked.slice(0, cap);
const results = await mapLimit(picked, 16, async (file) => {
try {
const buf = await fetchBuffer(`https://cdn.jsdelivr.net/gh/${repo}/${file}`);
return { file, buf };
} catch (e) { return null; }
});
for (const r of results) {
if (!r) { fail++; continue; }
const name = path.basename(r.file, path.extname(r.file));
let file = r.file, buf = r.buf;
// 专用转换(如 hero-patterns:JS 函数模板 → 静态 SVG)
if (src.transform === 'heropatterns') {
const svg = transformHeroPatterns(r.buf);
if (!svg) { fail++; continue; }
file = r.file.replace(/\.js$/i, '.svg');
buf = Buffer.from(svg, 'utf8');
}
const c = classifyPoster(name);
const it = await buildItem(src, name, file, buf, c);
if (it) { items.push(it); ok++; } else fail++;
}
return { items, ok, fail };
}
/* ================= npm 目录枚举(jsDelivr API,替代已失效的 unpkg ?meta 端点) ================= */
async function downloadDirFiles(src) {
const items = [];
let ok = 0, fail = 0;
const exts = (Array.isArray(src.ext) ? src.ext : (src.ext ? String(src.ext).split(',') : ['.svg']))
.map(e => e.trim().toLowerCase());
const pkg = src.npm + (src.npmVersion ? '@' + src.npmVersion : '');
const info = JSON.parse(await fetchText(`https://data.jsdelivr.com/v1/packages/npm/${pkg}`));
const ver = src.npmVersion || (info.tags && info.tags.latest);
if (!ver) throw new Error('无法解析 npm 版本: ' + src.npm);
const tree = JSON.parse(await fetchText(`https://data.jsdelivr.com/v1/packages/npm/${src.npm}@${ver}`));
const files = [];
(function walk(list, prefix) {
for (const f of list) {
if (f.type === 'directory') walk(f.files || [], prefix + f.name + '/');
else if (exts.some(e => f.name.toLowerCase().endsWith(e))) files.push(prefix + f.name);
}
})(tree.files || [], '');
const allowed = (src.dirs || []).map(d => d === '.' || d === '' ? '' : d.replace(/\/$/, '') + '/');
let picked = files.filter(p => allowed.some(d => d === '' || p.startsWith(d)));
picked = dropNonAssets(picked);
if (src.exclude) {
const re = new RegExp(src.exclude);
picked = picked.filter(p => !re.test(p));
}
const cap = Number(src.maxItems) > 0 ? Math.min(Number(src.maxItems), picked.length) : picked.length;
picked = picked.slice(0, cap);
const results = await mapLimit(picked, 16, async (p) => {
try {
const buf = await fetchBuffer(`https://cdn.jsdelivr.net/npm/${src.npm}@${ver}/${p}`);
return { file: p, buf };
} catch (e) { return null; }
});
for (const r of results) {
if (!r) { fail++; continue; }
const name = path.basename(r.file, path.extname(r.file));
const c = classifyPoster(name);
const it = await buildItem(src, name, r.file, r.buf, c);
if (it) { items.push(it); ok++; } else fail++;
}
return { items, ok, fail };
}
/**
* hero-patterns 专用转换:ES module 函数模板 → 静态 SVG 字符串
* 源码形如:
* import { defaultFill, defaultOpacity } from './_defaults'
* import { unhex } from './_utils'
* export const polkaDots = (fill = defaultFill, opacity = defaultOpacity) =>
* `url('data:image/svg+xml,%3Csvg%20width...%3E')`
* 处理:剥离 import(依赖注入 sandbox)、以默认黑色调用函数、URL 解码出 <svg>…</svg>
*/
function transformHeroPatterns(buf) {
let code = buf.toString('utf8');
code = code.replace(/^import\s+[^\n]*$/gm, ''); // 去掉 import 语句
code = code.replace(/export\s+const\s+/g, 'const '); // export const → const
const m = code.match(/const\s+(\w+)\s*=/);
if (!m) return null;
const sandbox = {
defaultFill: '#000000',
defaultOpacity: 1,
unhex: str => str.substring(str.indexOf('#') + 1)
};
vm.createContext(sandbox);
try {
vm.runInContext(code + `\n;globalThis.__HP_OUT = typeof ${m[1]} === 'function' ? ${m[1]} : null;`, sandbox);
} catch (e) { return null; }
const fn = sandbox.__HP_OUT;
if (typeof fn !== 'function') return null;
let uri = '';
try { uri = String(fn('#000000', 1) || ''); } catch (e) { return null; }
const mm = uri.match(/url\(\s*['"]data:image\/svg\+xml,([^'")]+)['"]\s*\)/i);
if (!mm) return null;
try {
const svg = decodeURIComponent(mm[1]);
return /<svg[\s>]/i.test(svg) ? svg : null;
} catch (e) { return null; }
}
/**
* 把单个下载文件构建为素材条目:
* - SVG:内联归一化 body(保留原始 viewBox 比例)+ 分辨率元数据
* - PNG/JPG:保存二进制到本地 files 目录,记录 file/mime/width/height/sha256/bytes
* - JSON(配色):内联解析后的 JSON
* - 字体:保存二进制 + 引用
* 返回 null 表示数据损坏(格式魔数不匹配 / 分辨率非法 / 空内容)
*/
async function buildItem(src, name, filePath, buf, c) {
const ext = path.extname(filePath).toLowerCase();
// 字体素材不做应用场景推断(避免名称误命中词表,如 LeagueSpartan 含 "spa" → 医疗健康),统一归「字体排版」
if (src.type === 'font') c = { cat: 'font', cn: '字体排版' };
const base = { id: `${src.id}:${name}`, name, type: src.type, category: c.cat, categoryCn: c.cn, workspaces: src.workspaces || ['poster'] };
if (ext === '.svg') {
const svgStr = buf.toString('utf8');
const meta = parseSvgMeta(svgStr);
if (!meta) return null;
const body = stripSvgBody(svgStr);
if (!body || !hasGeometry(body) || hasDanglingRef(body)) return null;
return Object.assign(base, {
svg: body, viewBox: meta.viewBox, width: meta.width, height: meta.height,
bytes: buf.length, sha256: sha256(buf), mime: 'image/svg+xml'
});
}
if (ext === '.png') {
const size = parsePngSize(buf);
if (!size) return null; // 非法 PNG(魔数/头部损坏)
return saveBinary(src, base, filePath, buf, 'image/png', size);
}
if (ext === '.jpg' || ext === '.jpeg') {
const size = parseJpgSize(buf);
if (!size) return null; // 非法 JPG
return saveBinary(src, base, filePath, buf, 'image/jpeg', size);
}
if (ext === '.json') {
try {
const json = JSON.parse(buf.toString('utf8'));
return Object.assign(base, { data: json, bytes: buf.length, sha256: sha256(buf), mime: 'application/json' });
} catch (e) { return null; }
}
// 字体及其它二进制:保存 + 引用
return saveBinary(src, base, filePath, buf, 'application/octet-stream', null);
}
/** 保存二进制到 js/data/posters/{id}/files/,条目只存 file 引用 + 校验元数据 */
function saveBinary(src, base, filePath, buf, mime, size) {
const rel = `posters/${src.id}/files/${path.basename(filePath)}`;
const abs = path.join(process.cwd(), 'js/data', rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, buf);
return Object.assign(base, {
file: rel, mime, bytes: buf.length, sha256: sha256(buf),
width: size ? size.width : null, height: size ? size.height : null
});
}
/* ================= 批次下载完整性校验 ================= */
/**
* 每源下载+处理后执行:检测数据损坏 / 文件缺失 / 格式错误。
* - expected 原始索引素材数(gh/dir 源枚举到的文件数;无法获知时 null)
* - beforeN 处理前素材数
* - afterN 最终内置素材数(剔除损坏/格式错误后)
* - badRes 分辨率非法数量
* 判定:通过率 = afterN/expected,< 80% 判异常。
*/
function verifyBatch(src, expected, beforeN, afterN, badRes) {
const rate = expected > 0 ? afterN / expected : null;
const dropped = beforeN - afterN;
const parts = [];
if (rate != null) {
if (rate < 0.8) parts.push(`⚠️ 通过率 ${(rate * 100).toFixed(1)}%(${afterN}/${expected})异常,疑似下载损坏/缺失`);
else if (dropped > 0) parts.push(`通过率 ${(rate * 100).toFixed(1)}%(${afterN}/${expected}),剔除 ${dropped} 个坏数据`);
else parts.push(`通过率 ${(rate * 100).toFixed(1)}%(${afterN}/${expected})`);
} else {
parts.push(`下载 ${afterN} 个(无官方索引可比对)`);
}
if (badRes > 0) parts.push(`${badRes} 个非法分辨率已剔除`);
const pass = rate == null || rate >= 0.8;
return { pass, msg: parts.join(';') };
}
/* ================= 配置 hash 与缓存 ================= */
function cfgHash(o) {
const s = JSON.stringify(Object.assign({ rev: BUILD_REV }, o));
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
return h.toString(36);
}
function readCachedItems(id, cfg, dir) {
const fp = path.join(dir, id + '.js');
if (!fs.existsSync(fp)) return null;
const txt = fs.readFileSync(fp, 'utf8');
const m = txt.match(/@cfg:([a-z0-9]+)/);
if (!m || m[1] !== cfg) return null;
global.window = global.window || {};
eval(txt);
const data = global.window['HGB_PosterSource_' + id];
if (!data || !Array.isArray(data.items) || !data.items.length) return null;
return data.items;
}
/* ================= 主流程 ================= */
async function main() {
const out = { meta: { generatedAt: new Date().toISOString(), total: 0 }, sources: {}, itemIndex: [] };
let grand = 0;
const verifyReport = [];
let verifyFailCount = 0;
const srcDir = path.join(process.cwd(), 'js/data/posters');
fs.mkdirSync(srcDir, { recursive: true });
for (const src of SOURCES) {
let items = null, fail = 0, usedUrl = '';
let expected = 0, badRes = 0;
const cfg = src;
try {
const cached = readCachedItems(src.id, cfgHash(cfg), srcDir);
if (cached) {
items = cached;
usedUrl = 'https://cdn.jsdelivr.net/gh/' + (src.gh || '');
console.log(`⏩ ${src.name}: 缓存命中(${items.length} 个素材)`);
}
} catch (e) { /* 缓存读取失败不致命 */ }
try {
if (items) {
// 缓存命中:无需下载
} else if (src.format === 'gh-files') {
console.log(`获取 ${src.name}(GitHub 仓库 ${src.gh})…`);
const r = await downloadGhFiles(src);
items = r.items; fail = r.fail; expected = r.ok + r.fail;
usedUrl = 'cdn.jsdelivr.net/gh/' + src.gh;
} else if (src.format === 'dir-files') {
console.log(`获取 ${src.name}(npm 目录 ${src.npm})…`);
const r = await downloadDirFiles(src);
items = r.items; fail = r.fail; expected = r.ok + r.fail;
usedUrl = 'unpkg.com/' + src.npm;
} else {
throw new Error(`不支持的 format: ${src.format}`);
}
} catch (e) {
console.error(`❌ 无法获取 ${src.id}:${e.message},跳过`);
continue;
}
if (!items || !items.length) {
console.error(`❌ ${src.id} 无有效素材,跳过`);
continue;
}
const beforeN = items.length;
// 后处理:剔除空内容 / 无几何 SVG / 悬空引用(SVG 已在上游 buildItem 过滤,此处兜底)
items = items.filter(it => {
if (!it) return false;
if (it.svg && (!it.svg.trim() || !hasGeometry(it.svg))) { badRes++; return false; }
return true;
});
if (items.length < beforeN) console.log(` ↳ 剔除 ${beforeN - items.length} 个坏数据`);
const v = verifyBatch(src, expected, beforeN, items.length, badRes);
verifyReport.push({ name: src.name, type: src.type || 'bg', ...v });
console.log(` [校验] ${v.pass ? '✅' : '⚠️'} ${src.name}:${v.msg}`);
if (!v.pass) verifyFailCount++;
// 1) 每源独立数据文件(运行时按源懒加载)
fs.writeFileSync(path.join(srcDir, src.id + '.js'),
`/* ${src.name}(由 scripts/build-posters.js 生成,勿手动编辑)@cfg:${cfgHash(cfg)} */\n` +
`window['HGB_PosterSource_${src.id}'] = ` + JSON.stringify({ items }) + ';\n', 'utf8');
// 2) 索引条目:元数据 + 前 4 个示例(poster-sources.html 展示),不内嵌全量内容
out.sources[src.id] = {
name: src.name, nameCn: src.nameCn || src.name, license: src.license, home: src.home,
url: usedUrl, count: items.length, type: src.type,
file: 'posters/' + src.id + '.js',
samples: items.slice(0, 4).map(i => i.svg || (i.file || '')),
coordText: src.coordText || '', variants: src.variants || ''
};
// 3) 全量索引(id + 名称 + 应用场景,供海报工作台/AI/筛选使用)
for (const it of items) {
out.itemIndex.push({ id: it.id, name: it.name, category: it.categoryCn, type: it.type });
}
grand += items.length;
console.log(`✅ ${src.name}: ${items.length} 个素材(${src.type},失败 ${fail})`);
}
out.meta.total = grand;
out.meta.sources = Object.keys(out.sources).length;
const output = 'js/data/poster-builtin.js';
const srcNames = SOURCES.map(s => s.name).join(' / ') || '(待收录)';
const js = '/* 内置海报素材索引(由 scripts/build-posters.js 生成,勿手动编辑)\n' +
' * 素材:' + srcNames + ';内容按源拆分到 js/data/posters/ 懒加载\n' +
' * 每素材含三维归类:type(资源类型)+ categoryCn(应用场景)+ workspaces(工作区) */\n' +
'window.HGB_PosterBuiltin = ' + JSON.stringify(out) + ';\n';
fs.writeFileSync(path.join(process.cwd(), output), js, 'utf8');
// ===== 机器可读 manifest(供外部项目/AI 直接读取,非前端必需;字段 = 配置 + 实际内置数) =====
const manifestDir = path.join(process.cwd(), 'js/data/manifest');
fs.mkdirSync(manifestDir, { recursive: true });
const posterMeta = {
kind: 'posters',
total: out.meta.total,
builtAt: out.meta.generatedAt,
guide: 'js/data/manifest/README.md',
idRule: '「源:相对路径」,如 fontsource-inter:latin-400-normal.woff2',
sources: SOURCES
.filter(s => out.sources[s.id])
.map(s => {
const m = out.sources[s.id];
return {
id: s.id, type: s.type || 'bg', name: s.name, nameCn: s.nameCn || s.name,
desc: s.desc || '', license: s.license || '', licenseNote: s.licenseNote || '',
home: s.home || '', github: s.github || '',
format: s.format || '', coordText: s.coordText || '',
variants: s.variants || '', added: s.added || '',
count: m.count, file: m.file,
workspaces: s.workspaces || ['poster']
};
})
};
fs.writeFileSync(path.join(manifestDir, 'posters.json'), JSON.stringify(posterMeta, null, 1), 'utf8');
console.log(` → manifest js/data/manifest/posters.json(${posterMeta.sources.length} 源元数据,供外部/AI 读取)`);
const sizeKB = (fs.statSync(path.join(process.cwd(), output)).size / 1024).toFixed(0);
console.log(`\n📦 完成:共 ${grand} 个海报素材(${Object.keys(out.sources).length} 源)`);
console.log(` → 索引 ${output} (${sizeKB}KB) + 按源拆分 js/data/posters/`);
const passN = verifyReport.length - verifyFailCount;
console.log(`\n🔍 批次下载质量校验:${passN}/${verifyReport.length} 源通过(通过率 = 最终内置数 ÷ 官方索引数,<80% 判异常)`);
const warnList = verifyReport.filter(r => !r.pass);
if (warnList.length) {
console.warn(`⚠️ 以下 ${warnList.length} 个源通过率异常,需人工复核:`);
for (const r of warnList) console.warn(` - ${r.name}:${r.msg}`);
} else if (verifyReport.length) {
console.log('✅ 全部源数量完整、无异常剔除、无非法分辨率格式问题');
} else {
console.log('ℹ️ 暂无素材源(poster-sources.js 为空),请在 M2 逐条核实许可证后追加来源。');
}
}
main().catch(e => { console.error(e); process.exit(1); });
+107
View File
@@ -0,0 +1,107 @@
/**
* 生成技能库机器可读 manifest:js/data/manifest/skills.json
* 用法:node scripts/build-skill-manifest.js
* 仅导出技能元数据(含流程步骤/性质/工作区),run/test 实现留在 js/skills/、js/poster-skills/、js/sticker-skills/ 供系统执行。
* 注意:技能文件顶层 const HGB_* 是词法绑定,不挂 sandbox,必须全部拼接进同一 vm 代码串后直接引用。
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = process.cwd();
const read = f => fs.readFileSync(path.join(ROOT, f), 'utf8');
// 共享技能(core + 7 分类)+ 海报技能(core + skills)+ 表情技能(core + skills)
const SKILL_FILES = [
'js/skills/core.js',
'js/skills/layout.js', 'js/skills/color.js', 'js/skills/typography.js',
'js/skills/icon.js', 'js/skills/ai.js', 'js/skills/presentation.js', 'js/skills/export.js',
'js/poster-skills/core.js', 'js/poster-skills/skills.js',
'js/sticker-skills/core.js', 'js/sticker-skills/skills.js'
];
// 技能定义期/自测可能引用的数据依赖(与浏览器加载顺序一致;表情技能可能引用动画引擎)
const DEP_FILES = [
'js/data/icons.js', 'js/data/industries.js', 'js/data/styles.js',
'js/data/decorations.js', 'js/data/palettes.js', 'js/data/fonts.js',
'js/data/builtin-icons.js', 'js/core/generator.js', 'js/core/motion.js'
];
const code = DEP_FILES.map(read).join('\n') + '\n' + SKILL_FILES.map(read).join('\n') + `
;globalThis.__MANIFEST = (function () {
var catOf = function (list) {
var m = {};
list.forEach(function (s) { m[s.category] = (m[s.category] || 0) + 1; });
return m;
};
var shared = (typeof HGB_Skills !== 'undefined') ? HGB_Skills.all().map(function (s) {
return {
id: s.id, name: s.name, category: s.category, emoji: s.emoji,
scene: s.scene, summary: s.summary, params: s.params, source: s.source,
step: HGB_Skills.stepOf(s.id), kind: HGB_Skills.kind(s.id),
workspaces: HGB_Skills.workspacesOf(s.id)
};
}) : [];
var poster = (typeof HGB_PosterSkills !== 'undefined') ? HGB_PosterSkills.all().map(function (s) {
return {
id: s.id, name: s.name, category: s.category, emoji: s.emoji,
scene: s.scene, summary: s.summary, params: s.params, source: s.source,
workspaces: HGB_PosterSkills.workspacesOf(s.id)
};
}) : [];
var sticker = (typeof HGB_StickerSkills !== 'undefined') ? HGB_StickerSkills.all().map(function (s) {
return {
id: s.id, name: s.name, category: s.category, emoji: s.emoji,
scene: s.scene, summary: s.summary, params: s.params, source: s.source,
workspaces: HGB_StickerSkills.workspacesOf(s.id)
};
}) : [];
return {
shared: shared,
poster: poster,
sticker: sticker,
steps: (typeof HGB_Skills !== 'undefined' && HGB_Skills.steps) ? HGB_Skills.steps() : []
};
})();
`;
const sandbox = { console, setTimeout, clearTimeout, window: null, self: null };
sandbox.window = sandbox;
sandbox.self = sandbox;
try {
vm.runInNewContext(code, sandbox, { filename: 'skills-manifest.js' });
} catch (e) {
console.error('❌ 技能 manifest 生成失败:' + e.message);
process.exit(1);
}
const m = sandbox.__MANIFEST;
if (!m.shared.length && !m.poster.length && !m.sticker.length) {
console.error('❌ 未注册任何技能,检查技能文件加载');
process.exit(1);
}
const out = {
kind: 'skills',
generatedAt: new Date().toISOString(),
guide: 'js/data/manifest/README.md',
shared: {
total: m.shared.length,
categories: m.steps.map(s => s), // 设计流程 5 步元数据(共享技能按流水线组织)
skills: m.shared
},
poster: {
total: m.poster.length,
categories: null, // 海报技能按 8 分类,见各 skill.category
skills: m.poster
},
sticker: {
total: m.sticker.length,
categories: null, // 表情技能按 motion/emotion 分类,见各 skill.category
skills: m.sticker
}
};
const manifestDir = path.join(ROOT, 'js/data/manifest');
fs.mkdirSync(manifestDir, { recursive: true });
fs.writeFileSync(path.join(manifestDir, 'skills.json'), JSON.stringify(out, null, 1), 'utf8');
console.log(`✅ 技能 manifest:共享 ${m.shared.length} 个 / 海报 ${m.poster.length} 个 / 表情 ${m.sticker.length} 个 → js/data/manifest/skills.json`);
+21
View File
@@ -0,0 +1,21 @@
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = path.resolve(__dirname, '..');
global.window = global;
function load(rel) { vm.runInThisContext(fs.readFileSync(path.join(ROOT, rel), 'utf8'), { filename: rel }); }
load('js/core/dicebear.js');
const STYLES = ['bottts','bottts-neutral','identicon','pixel-art','fun-emoji','rings','shapes','avataaars','notionists','adventurer'];
for (const s of STYLES) {
load('js/data/sources/dicebear-' + s + '.js');
const key = 'HGB_Dicebear_' + s.replace(/-/g, '_');
const def = global[key];
console.log(s, '| def exists:', !!def, '| canvas:', def ? (def.canvas.width + 'x' + def.canvas.height) : '-', '| global key:', key);
try {
const r = HGB_Dicebear.renderAvatar(s, s + '-1');
console.log(' render ok, svg len:', r.svg.length, '| svg head:', r.svg.slice(0, 60).replace(/\n/g, ' '));
} catch (e) {
console.log(' render ERROR:', e.message);
}
}
+180
View File
@@ -0,0 +1,180 @@
/**
* 冒烟:动态表情模块纯函数核心(motion / gifEncoder / emojiGen / sticker-skills)
* 验证:
* 1) motion:帧函数确定性、normalize 补参、帧时间序列、pickAnim 轮换、composeTransform 合法
* 2) gifEncoder:lzwEncode 子块结构 + 确定性(纯函数,用假像素序列)
* 3) emojiGen:generateEmojis 产物带 anim、buildFrameSvg SVG 合法且含动画 transform、纯图形无文字
* 4) sticker-skills:12 技能 test 全部通过、分类白名单 motion/emotion、workspaces 统一 emoji、
* emotion-fit 评审覆盖 6 种标准情绪(与 emotion-params 生成决策同源)
* 5) emojiAi:降级路径 + validate 白名单修正;情绪驱动降级(无 AI 时首张动画贴合情绪)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = path.resolve(__dirname, '..');
const files = [
'js/data/icons.js',
'js/data/styles.js',
'js/data/industries.js',
'js/data/builtin-icons.js',
'js/data/decorations.js',
'js/core/generator.js',
'js/core/iconlib.js',
'js/core/avatarGen.js',
'js/core/motion.js',
'js/core/gifEncoder.js',
'js/core/emojiGen.js',
'js/core/emojiAi.js',
'js/sticker-skills/core.js',
'js/sticker-skills/skills.js'
];
for (const f of files) {
const src = fs.readFileSync(path.join(ROOT, f), 'utf8');
vm.runInThisContext(src, { filename: f });
}
// Node 无头环境无 document/window:stub 掉 iconlib 的浏览器专属加载方法(生成路径走本地+行业图标池)
if (typeof HGB_IconLib !== 'undefined') {
HGB_IconLib.preload = async () => [];
HGB_IconLib.searchLoaded = () => [];
}
let pass = 0, fail = 0;
function check(name, ok, detail) {
if (ok) { pass++; console.log(' ✅ ' + name + (detail ? ' — ' + detail : '')); }
else { fail++; console.log(' ❌ ' + name + (detail ? ' — ' + detail : '')); }
}
/* ============ 1) motion ============ */
console.log('== 1) motion 动画引擎 ==');
const anim = HGB_Motion.normalize({ type: 'bounce' });
check('normalize 补全默认参数', anim.type === 'bounce' && anim.speed === 1 && anim.amplitude === 1 && anim.fps === 12 && Math.abs(anim.duration - 1.2) < 1e-9, JSON.stringify(anim));
check('非法动画类型回退 bounce', HGB_Motion.normalize({ type: 'nope' }).type === 'bounce');
check('动画类型数 ≥6', HGB_Motion.animTypes().length >= 6, HGB_Motion.animTypes().map(a => a.id).join('/'));
const n = HGB_Motion.frameCount({ type: 'pulse', duration: 1.2, fps: 12 });
check('帧数 = duration × fps', n === Math.round(1.2 * 12), 'frames=' + n);
const f0 = HGB_Motion.apply({ x: 0, y: 0, w: 80, h: 80 }, 0, anim);
const f1 = HGB_Motion.apply({ x: 0, y: 0, w: 80, h: 80 }, 0, anim);
check('帧函数确定性(同输入同输出)', JSON.stringify(f0) === JSON.stringify(f1));
const fHalf = HGB_Motion.apply({ x: 0, y: 0, w: 80, h: 80 }, 0.25, anim);
check('bounce t=0.25 在上升段(ty<0)', fHalf.ty < 0, 'ty=' + fHalf.ty);
const ft = HGB_Motion.frameT(0, anim) + HGB_Motion.frameT(n - 1, anim);
check('帧时间在 [0,1)', ft >= 0 && ft < 2);
// pickAnim 轮换多样性
const picked = new Set();
for (let i = 0; i < 12; i++) picked.add(HGB_Motion.pickAnim(i).type);
check('pickAnim 12 批动画类型多样(≥3 种)', picked.size >= 3, picked.size + ' 种');
// composeTransform
const el = { x: 10, y: 20, w: 60, h: 60 };
const frame = { tx: 5, ty: -10, rot: 15, s: 1.1, opacity: 1 };
const tr = HGB_Motion.composeTransform(el, frame, 'translate(10 20) scale(2.5 2.5)');
check('composeTransform 合成合法(含基础 transform)', tr.includes('translate(10 20) scale(2.5 2.5)') && tr.includes('rotate(15)') && tr.includes('scale(1.1)') && tr.includes('translate(5 -10)'), tr.slice(0, 80) + '…');
check('composeTransform 无 0.00 残留', !/0\.00/.test(tr) && !/-0\.00/.test(tr));
/* ============ 2) gifEncoder ============ */
console.log('== 2) gifEncoder LZW ==');
// 假像素序列(重复度高,触发字典增长):256 色体系,索引 1..10 循环
const fakeIndices = new Uint8Array(1000);
for (let i = 0; i < 1000; i++) fakeIndices[i] = (i % 10) + 1;
const b1 = HGB_GifEncoder.lzwEncode(fakeIndices, 8);
const b2 = HGB_GifEncoder.lzwEncode(fakeIndices, 8);
check('lzwEncode 输出子块结构(长度前缀 ≤255,0x00 结尾)', b1.length > 0 && b1[b1.length - 1] === 0 && b1.slice(0, -1).every((v, i) => i % 256 !== 0 ? v >= 0 && v <= 255 : v <= 255), '块数=' + Math.ceil((b1.length - 1) / 256));
check('lzwEncode 确定性', b1.length === b2.length && b1.every((v, i) => v === b2[i]));
// 大范围随机索引(触发 clear code)
const rand = new Uint8Array(2000);
for (let i = 0; i < 2000; i++) rand[i] = Math.floor(Math.random() * 256);
const b3 = HGB_GifEncoder.lzwEncode(rand, 8);
check('lzwEncode 随机序列不崩溃', b3.length > 10 && b3[b3.length - 1] === 0);
check('quantizeFrame 空像素安全', (function () {
const r = HGB_GifEncoder.quantizeFrame({ width: 2, height: 2, data: new Uint8ClampedArray([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) }, true);
return r.indices.length === 4 && r.indices.every(v => v === 0) && r.lct.length === 256 * 3;
})());
/* ============ 3) emojiGen ============ */
console.log('== 3) emojiGen 生成与帧 SVG ==');
const ind = HGB_Industries.find('food');
(async function main() {
const plans = await HGB_EmojiGen.generateEmojiSet({ industry: ind, brand: '美食工坊' });
const animTypes = HGB_Motion.animTypes();
check('表情包 = 全部动画类型数', plans.length === animTypes.length, plans.length + ' 张');
check('每套 anim 合法且覆盖全部类型', plans.every(p => p.anim && HGB_Motion.normalize(p.anim).type) &&
new Set(plans.map(p => p.anim.type)).size === animTypes.length, [...new Set(plans.map(p => p.anim.type))].join('/'));
check('同图:布局/配色完全一致', plans.every(p => p.layoutId === plans[0].layoutId && p.paletteName === plans[0].paletteName), plans[0].layoutId + '/' + plans[0].paletteName);
check('全部布局在表情白名单', plans.every(p => HGB_EmojiGen.EMOJI_LAYOUTS.includes(p.layoutId)), [...new Set(plans.map(p => p.layoutId))].join('/'));
const hasText = plans.some(p => (p.markElements || []).some(el => el.type === 'text'));
check('表情纯图形无文字元素', !hasText);
// expandAnimations 独立展开(自定义底图 → 同图 × 全动画)
const base = { id: 'x', name: '测试', elements: [], layoutId: 'orb', paletteName: '学术蓝', bg: '#fff', concept: 'c', aiIcon: 'i' };
const ex = HGB_EmojiGen.expandAnimations(base);
check('expandAnimations 同图展开全动画', ex.length === animTypes.length && ex.every(v => v.layoutId === 'orb' && v.id.startsWith('x-')));
// 帧 SVG
const p0 = plans[0];
const svg = HGB_EmojiGen.buildFrameSvg(p0.elements, '#FFFFFF', 240, 0.25, p0.anim, {});
check('帧 SVG 是合法 XML 且含 viewBox', svg.includes('<?xml') && svg.includes('<svg') && svg.includes('viewBox=') && svg.includes('</svg>'));
check('帧 SVG 含动画 transform(多次 translate 合成)', (svg.match(/translate\(/g) || []).length >= 4, (svg.match(/translate\(/g) || []).length + ' 次 translate');
check('帧 SVG 含背景 rect(白底)', svg.includes('<rect x='));
const svgT = HGB_EmojiGen.buildFrameSvg(p0.elements, 'transparent', 240, 0, p0.anim, {});
check('透明背景无画布背景 rect', !/viewBox="[^"]*"><rect/.test(svgT));
const svgA = HGB_EmojiGen.buildFrameSvg(p0.elements, '#FFFFFF', 240, 0.1, p0.anim, {});
check('不同帧 SVG 有差异(确实在动)', svgA !== svg || svgA.length !== svg.length);
// contentBox
const box = HGB_EmojiGen.contentBox(p0.elements);
check('contentBox 存在且有效', !!box && box.w > 0 && box.h > 0, box.w.toFixed(0) + '×' + box.h.toFixed(0));
/* ============ 4) sticker-skills ============ */
console.log('== 4) sticker-skills 技能库 ==');
const SKILL_ALL = HGB_StickerSkills.all();
check('技能总数 = 12', SKILL_ALL.length === 12, '实际 ' + SKILL_ALL.length);
check('分类白名单 = motion/emotion', HGB_StickerSkills.categories().every(c => ['motion', 'emotion'].includes(c.id)), HGB_StickerSkills.categories().map(c => c.id).join('/'));
check('workspaces 统一 emoji', SKILL_ALL.every(s => HGB_StickerSkills.workspacesOf(s.id).includes('emoji')));
let sFail = 0;
for (const s of SKILL_ALL) {
const r = HGB_StickerSkills.test(s.id);
if (!r || r.error || r.ok === false) { sFail++; console.log(' ❌ test 失败: ' + s.id + ' — ' + JSON.stringify(r)); }
else check(s.id + ' test 通过', true);
}
check('12 技能 test 全通过', sFail === 0);
// 评审闭环:emotion-fit 覆盖 6 种标准情绪(与 emotion-params 生成决策同源)
const EFIT = [
['开心', 'bounce', true], ['生气', 'shake', true], ['惊讶', 'heartbeat', true],
['难过', 'float', true], ['兴奋', 'hopspin', true], ['平静', 'breath', true],
['平静', 'bounce', false]
];
let eFitOk = true;
for (const [kw, type, expect] of EFIT) {
const r = HGB_StickerSkills.run('emotion-fit', { keyword: kw, type });
if (r.ok !== expect) { eFitOk = false; console.log(' ❌ emotion-fit 不匹配: ' + kw + '/' + type + ' → ' + JSON.stringify(r)); }
}
check('emotion-fit 六情绪映射评审生效', eFitOk);
/* ============ 5) emojiAi 编排(无 HGB_AI → 降级路径) ============ */
console.log('== 5) emojiAi 编排与降级 ==');
check('suggest 无 HGB_AI 返回 null(降级)', (await HGB_EmojiAI.suggest({ industry: ind })) === null);
const msgs = HGB_EmojiAI.buildPrompt({ industry: ind, brand: '美食工坊' });
check('buildPrompt 系统+用户两条', msgs.length === 2 && msgs[0].role === 'system' && msgs[1].role === 'user');
const parsed = HGB_EmojiAI.extractJson('前文 {"a":1,"b":{"c":2}} 后文');
check('extractJson 提取嵌套 JSON', !!parsed && parsed.b.c === 2);
const fixed = HGB_EmojiAI.validate({ overallConcept: '测试', base: { layoutId: 'not-a-layout', styleId: 'nope' }, anim: { type: 'bad', speed: 99, amplitude: -1, layered: true } }, { industry: ind });
check('validate 修正非法布局/风格/动画参数', HGB_EmojiGen.EMOJI_LAYOUTS.includes(fixed.base.layoutId) && fixed.anim.type !== 'bad' && fixed.anim.speed === 1 && fixed.anim.amplitude === 1, JSON.stringify(fixed));
check('validate layered 保留', fixed.anim.layered === true);
const exL = HGB_EmojiGen.expandAnimations(base);
check('分层动画交错开启', exL.some(v => v.anim.layered) && exL.some(v => !v.anim.layered));
// 情绪驱动降级(生成前置):AI 缺失时首张动画贴合用户情绪,且整组仍覆盖全部类型
const moodAngry = await HGB_EmojiGen.generateEmojiSet({ industry: ind, brand: '美食工坊', mood: 'angry' });
check('情绪降级:angry 首张 = shake', moodAngry[0].anim.type === 'shake', '首张 ' + moodAngry[0].anim.type);
check('情绪降级:整组仍覆盖全部动画类型', new Set(moodAngry.map(p => p.anim.type)).size === animTypes.length);
const moodCalm = await HGB_EmojiGen.generateEmojiSet({ industry: ind, brand: '美食工坊', mood: 'calm' });
check('情绪降级:calm 首张 = breath', moodCalm[0].anim.type === 'breath', '首张 ' + moodCalm[0].anim.type);
const moodBad = await HGB_EmojiGen.generateEmojiSet({ industry: ind, brand: '美食工坊', mood: 'nope' });
check('情绪降级:非法情绪回退确定性轮换', moodBad[0].anim.type === HGB_Motion.normalize(HGB_Motion.pickAnim(0)).type, '首张 ' + moodBad[0].anim.type);
// 情绪-动画映射注入 prompt(先审后生成)
const msgsMood = HGB_EmojiAI.buildPrompt({ industry: ind, brand: '美食工坊', mood: 'angry' });
check('buildPrompt 注入情绪-动画映射', msgsMood[0].content.includes('生气→shake'), '含映射行');
console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败');
process.exit(fail ? 1 : 0);
})().catch(e => { console.error('❌ 异常:', e); process.exit(1); });
+144
View File
@@ -0,0 +1,144 @@
/**
* 🎯 100 个科技感头像候选预览页(资源库 lucide 源直选,无需渲染)
* 生成 preview-100.html:每张候选以最终头像样式展示(深蓝渐变圆底 + 白色线性图标),
* 支持勾选,底部可复制已选编号,供用户筛选后批量渲染 PNG。
*
* 用法:node scripts/gen-100-avatars-preview.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = path.resolve(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'output', 'agent-avatars-png');
// —— 1. 加载 lucide 源 ——
global.window = global;
const lucideFile = path.join(ROOT, 'js', 'data', 'sources', 'lucide.js');
vm.runInThisContext(fs.readFileSync(lucideFile, 'utf8'), { filename: lucideFile });
const lucideIcons = window['HGB_Source_lucide'].icons;
const byName = new Map(lucideIcons.map(x => [x.name, x]));
// —— 2. 精选 100 个(按主题分组)——
const GROUPS = [
{ label: '🤖 智能核心', items: ['bot', 'brain-circuit', 'brain', 'cpu', 'circuit-board', 'sparkles', 'sparkle', 'atom', 'orbit', 'radar', 'satellite', 'antenna', 'signal', 'wifi', 'bluetooth', 'usb', 'cable', 'plug', 'power', 'battery', 'battery-charging', 'battery-full'] },
{ label: '⌨️ 代码开发', items: ['terminal', 'code', 'code-xml', 'braces', 'brackets', 'file-code', 'git-branch', 'git-merge', 'git-pull-request', 'command', 'keyboard', 'monitor', 'laptop', 'smartphone', 'server', 'database', 'hard-drive'] },
{ label: '☁️ 网络云数据', items: ['cloud', 'cloud-upload', 'cloud-download', 'cloud-cog', 'cloud-lightning', 'network', 'share-2', 'link', 'globe', 'send', 'mail', 'message-circle', 'message-square', 'phone', 'mic', 'camera', 'video', 'qr-code', 'rss'] },
{ label: '🛡️ 安全防护', items: ['lock', 'key', 'key-round', 'shield', 'shield-check', 'shield-half', 'target', 'crosshair', 'eye', 'scan-face', 'scan'] },
{ label: '⚙️ 智能工具', items: ['settings', 'settings-2', 'cog', 'wrench', 'layers', 'layout-grid', 'box', 'package', 'blocks', 'puzzle', 'gem', 'diamond', 'sliders-horizontal', 'equal'] },
{ label: '🔷 抽象几何', items: ['hexagon', 'triangle', 'circle', 'octagon', 'infinity', 'asterisk', 'zap', 'activity', 'gauge', 'compass', 'navigation', 'navigation-2', 'map-pin', 'map'] },
{ label: '🚀 空间探索', items: ['rocket', 'plane', 'telescope'] }
];
let list = [];
const dup = new Set();
for (const g of GROUPS) {
for (const name of g.items) {
if (dup.has(name)) continue;
dup.add(name);
const icon = byName.get(name);
if (!icon) { console.warn('⚠️ 缺失:', name); continue; }
list.push({ name, id: icon.id, body: icon.svg, group: g.label });
}
}
console.log('候选数:', list.length);
// —— 3. 生成预览页 ——
const S = 24, BG_FROM = '#0b1220', BG_TO = '#16294a';
function avatarSvg(body) {
return `<svg viewBox="0 0 1024 1024" width="128" height="128">
<defs><linearGradient id="g${list.length}" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${BG_FROM}"/><stop offset="100%" stop-color="${BG_TO}"/></linearGradient>
<clipPath id="c${list.length}"><circle cx="512" cy="512" r="512"/></clipPath></defs>
<rect width="1024" height="1024" fill="url(#g${list.length})" clip-path="url(#c${list.length})"/>
<circle cx="512" cy="512" r="452" fill="none" stroke="#3b82f6" stroke-width="5" opacity=".45"/>
<g transform="translate(512 512) scale(20) translate(-12 -12)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</g>
</svg>`;
}
let cards = '';
let idx = 1;
for (const g of GROUPS) {
const members = list.filter(x => x.group === g.label);
if (!members.length) continue;
cards += `<div class="group-title">${g.label}(${members.length})</div>\n<div class="row">\n`;
for (const m of members) {
const n = String(idx).padStart(2, '0');
cards += `<label class="card" data-idx="${idx}">
<input type="checkbox" class="cb" value="${idx}" data-name="${m.name}">
${avatarSvg(m.body)}
<div class="no">#${n}</div>
<div class="name">${m.name}</div>
</label>\n`;
idx++;
}
cards += '</div>\n';
}
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🎯 100 个科技感头像候选 · 勾选后批量渲染</title>
<style>
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background:#0f1115; color:#e8eaed; margin:0; padding:24px; }
h1 { font-size:20px; margin:0 0 4px; }
.sub { color:#9aa0a6; font-size:13px; margin-bottom:16px; }
.toolbar { position:sticky; top:0; background:#0f1115ee; padding:10px 0; z-index:9; border-bottom:1px solid #2a2f3a; margin-bottom:16px; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.toolbar button { background:#2563eb; color:#fff; border:none; border-radius:6px; padding:6px 14px; font-size:13px; cursor:pointer; }
.toolbar .count { color:#8ab4f8; font-size:13px; }
.group-title { font-size:14px; color:#9aa0a6; margin:18px 0 10px; }
.row { display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); gap:12px; }
.card { background:#1b1e26; border:1px solid #2a2f3a; border-radius:12px; padding:12px 8px 10px; text-align:center; cursor:pointer; position:relative; transition:border-color .15s; }
.card:hover { border-color:#3b82f6; }
.card.sel { border-color:#22c55e; background:#1a2b1f; }
.card svg { width:96px; height:96px; border-radius:50%; display:block; margin:0 auto; }
.card .no { font-size:11px; color:#80868b; margin-top:8px; }
.card .name { font-size:11px; color:#bdc1c6; word-break:break-all; }
.card .cb { position:absolute; top:8px; left:8px; width:16px; height:16px; accent-color:#22c55e; }
.out { margin-top:16px; }
.out textarea { width:100%; height:56px; background:#1b1e26; color:#8ab4f8; border:1px solid #2a2f3a; border-radius:8px; padding:8px; font-size:12px; box-sizing:border-box; }
</style>
</head>
<body>
<h1>🎯 100 个科技感头像候选</h1>
<div class="sub">资源库 lucide 源直选 · 点击卡片勾选(默认全选)· 确认后在下方复制编号,交由渲染为 PNG</div>
<div class="toolbar">
<button onclick="sel(true)">全选</button>
<button onclick="sel(false)">清空</button>
<span class="count" id="count">已选 0/100</span>
<button onclick="copySel()">复制已选编号</button>
</div>
<div id="groups">${cards}</div>
<div class="out">
<textarea id="selText" placeholder="已选编号将显示在这里…" readonly></textarea>
</div>
<script>
const cards = document.querySelectorAll('.card');
const countEl = document.getElementById('count');
const selText = document.getElementById('selText');
function update() {
const sel = [...cards].filter(c => c.querySelector('.cb').checked);
cards.forEach(c => c.classList.toggle('sel', c.querySelector('.cb').checked));
countEl.textContent = '已选 ' + sel.length + '/' + cards.length;
selText.value = sel.map(c => c.dataset.idx).join(',');
}
function sel(v) { cards.forEach(c => c.querySelector('.cb').checked = v); update(); }
function copySel() {
if (!selText.value) return alert('请先勾选头像');
navigator.clipboard.writeText(selText.value).then(() => alert('已复制编号:' + selText.value)).catch(() => alert(selText.value));
}
cards.forEach(c => c.addEventListener('click', e => {
if (e.target.tagName === 'INPUT') return;
const cb = c.querySelector('.cb'); cb.checked = !cb.checked; update();
}));
sel(true);
</script>
</body>
</html>`;
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(path.join(OUT_DIR, 'preview-100.html'), html, 'utf8');
console.log('🎉 已生成 → output/agent-avatars-png/preview-100.html(候选 ' + list.length + ' 个)');
+210
View File
@@ -0,0 +1,210 @@
/**
* 🤖 100 个 bottts 机器人「技能变换」变体预览页
* 组合技能库变换(全部来自 js/skills/):
* 1. palette-build → 10 个行业色板(primary/secondary/accent)
* 2. gradient-suggest→ 每个主色的同色/互补/邻近渐变方向
* 3. lucide 源图标 → 科技徽章叠加
* 每个色板 × 5 渐变模式 × 2 徽章 = 10 变体 × 10 色板 = 100 个变体。
* 主体保持 bottts 机器人(资源库 DiceBear 源),渐变圆底 + 亮环 + 科技徽章。
*
* 用法:node scripts/gen-100-bot-variants.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = path.resolve(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'output', 'agent-avatars-png');
// —— 1. 加载技能环境 ——
global.window = global;
function load(rel) { vm.runInThisContext(fs.readFileSync(path.join(ROOT, rel), 'utf8'), { filename: rel }); }
load('js/data/icons.js'); // HGB_Icons
load('js/data/industries.js'); // HGB_Industries
load('js/data/styles.js'); // HGB_Styles
load('js/data/palettes.js'); // HGB_Palettes
load('js/data/fonts.js'); // HGB_Fonts
load('js/data/decorations.js'); // HGB_Decorations
load('js/skills/core.js'); // HGB_Skills (const)
load('js/skills/color.js'); // palette-build / gradient-suggest 等
load('js/data/sources/lucide.js'); // HGB_Source_lucide(徽章图标)
load('js/data/sources/dicebear-bottts.js');
load('js/core/dicebear.js'); // HGB_Dicebear
const Skills = HGB_Skills;
console.log('✅ 技能已加载,技能数:', Skills.all().length);
// —— 2. 色板:6 行业 + 4 内置色板 = 10 ——
const inds = HGB_Industries.slice(0, 6);
const extraPals = HGB_Palettes.slice(0, 4);
const palettes = [
...inds.map(ind => {
const r = Skills.run('palette-build', { desc: '科技感 ' + ind.name, industry: ind.id });
return { industry: ind.id, indName: ind.name, palette: r.palette || { primary: '#2563eb', secondary: '#3b82f6', accent: '#60a5fa' } };
}),
...extraPals.map((p, i) => ({
industry: 'palette-' + i, indName: p.name,
palette: { primary: p.colors[0], secondary: p.colors[1] || p.colors[0], accent: p.colors[2] || p.colors[1] || p.colors[0] }
}))
];
console.log('✅ 色板数:', palettes.length, '→', palettes.map(p => p.indName).join(' / '));
// —— 3. HSL 工具(渐变派生)——
function hexToHsl(hex) {
let c = String(hex).replace('#', ''); if (c.length === 3) c = c.split('').map(x => x + x).join('');
const r = parseInt(c.slice(0, 2), 16) / 255, g = parseInt(c.slice(2, 4), 16) / 255, b = parseInt(c.slice(4, 6), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b); let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
else if (max === g) h = ((b - r) / d + 2) * 60;
else h = ((r - g) / d + 4) * 60;
}
return { h, s: s * 100, l: l * 100 };
}
function hslToHex(h, s, l) {
s /= 100; l /= 100;
const k = n => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = n => { const x = k(n); return Math.round(255 * (l - a * Math.max(-1, Math.min(x - 3, Math.min(9 - x, 1))))); };
return '#' + [f(0), f(8), f(4)].map(x => x.toString(16).padStart(2, '0')).join('');
}
function adjust(hex, dl) { const { h, s, l } = hexToHsl(hex); return hslToHex(h, s, Math.max(2, Math.min(98, l + dl))); }
// —— 4. 徽章图标(lucide 科技图标 10 个)——
const lucideIcons = window['HGB_Source_lucide'].icons;
const badgeNames = ['bot', 'sparkles', 'cpu', 'radar', 'satellite', 'zap', 'shield', 'atom', 'circuit-board', 'brain-circuit'];
const badges = badgeNames.map(n => {
const ic = lucideIcons.find(x => x.name === n);
return { name: n, body: ic ? ic.svg : '<circle cx="12" cy="12" r="9"/>' };
});
// —— 5. 生成 100 个变体 ——
const MODES = [
{ name: '主色渐变', f: (p, g) => [p.primary, p.secondary] },
{ name: '点缀渐变', f: (p, g) => [p.primary, p.accent] },
{ name: '深邃渐变', f: (p, g) => [p.primary, adjust(p.primary, -30)] },
{ name: '互补渐变', f: (p, g) => [p.primary, hslToHex((g.h + 180) % 360, 60, 50)] },
{ name: '邻近渐变', f: (p, g) => [p.primary, hslToHex((g.h + 30) % 360, 60, 50)] }
];
const cards = [];
let idx = 1;
for (let pi = 0; pi < palettes.length; pi++) {
const P = palettes[pi];
const gradInfo = Skills.run('gradient-suggest', { color: P.palette.primary });
for (let mi = 0; mi < MODES.length; mi++) {
for (let bi = 0; bi < 2; bi++) {
const badge = badges[(pi * 5 + mi * 2 + bi) % badges.length];
const [c1, c2] = MODES[mi].f(P.palette, gradInfo);
const seed = 'agent-' + idx;
const { svg } = HGB_Dicebear.renderAvatar('bottts', seed);
// 包裹:渐变圆底 + bottts + 亮环 + 科技徽章
const wrapped = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="96" height="96">
<defs>
<linearGradient id="g${idx}" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${c1}"/><stop offset="100%" stop-color="${c2}"/>
</linearGradient>
<clipPath id="c${idx}"><circle cx="120" cy="120" r="120"/></clipPath>
</defs>
<rect width="240" height="240" fill="url(#g${idx})" clip-path="url(#c${idx})"/>
<g transform="translate(120 120) scale(1.28) translate(-${120 / 1.28} -${120 / 1.28})">${svg.replace(/^<svg[^>]*>|<\/svg>$/g, '')}</g>
<circle cx="120" cy="120" r="112" fill="none" stroke="#ffffff" stroke-width="3" opacity=".35"/>
<g transform="translate(188 188)"><circle r="24" fill="${c2}"/><g transform="translate(-12 -12) scale(1)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${badge.body}</g></g>
</svg>`;
cards.push({ idx, pi, mi, bi, seed, svg: wrapped, name: `${P.indName} · ${MODES[mi].name} · ${badge.name}` });
idx++;
}
}
}
console.log('✅ 变体数:', cards.length);
// —— 6. 唯一性校验 ——
const { createHash } = require('crypto');
const hashes = new Set();
let dup = 0;
for (const c of cards) {
const h = createHash('md5').update(c.svg).digest('hex');
if (hashes.has(h)) dup++;
hashes.add(h);
}
console.log('唯一性:' + (cards.length - dup) + '/' + cards.length + (dup ? '(⚠️ ' + dup + ' 重复)' : ' ✅'));
// —— 7. 预览页 ——
let cardHtml = '';
for (const c of cards) {
const n = String(c.idx).padStart(2, '0');
cardHtml += `<label class="card" data-idx="${c.idx}">
<input type="checkbox" class="cb" value="${c.idx}" data-name="${c.name}">
${c.svg}
<div class="no">#${n}</div>
<div class="name" title="${c.name}">${c.name}</div>
</label>\n`;
}
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🎨 100 个 bottts 技能变换变体 · 勾选后批量渲染</title>
<style>
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background:#0f1115; color:#e8eaed; margin:0; padding:24px; }
h1 { font-size:20px; margin:0 0 4px; }
.sub { color:#9aa0a6; font-size:13px; margin-bottom:16px; }
.toolbar { position:sticky; top:0; background:#0f1115ee; padding:10px 0; z-index:9; border-bottom:1px solid #2a2f3a; margin-bottom:16px; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.toolbar button { background:#2563eb; color:#fff; border:none; border-radius:6px; padding:6px 14px; font-size:13px; cursor:pointer; }
.toolbar .count { color:#8ab4f8; font-size:13px; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); gap:12px; }
.card { background:#1b1e26; border:1px solid #2a2f3a; border-radius:12px; padding:12px 8px 10px; text-align:center; cursor:pointer; position:relative; transition:border-color .15s; }
.card:hover { border-color:#3b82f6; }
.card.sel { border-color:#22c55e; background:#1a2b1f; }
.card svg { width:96px; height:96px; border-radius:50%; display:block; margin:0 auto; }
.card .no { font-size:11px; color:#80868b; margin-top:8px; }
.card .name { font-size:10px; color:#bdc1c6; word-break:break-all; }
.card .cb { position:absolute; top:8px; left:8px; width:16px; height:16px; accent-color:#22c55e; }
.out { margin-top:16px; }
.out textarea { width:100%; height:56px; background:#1b1e26; color:#8ab4f8; border:1px solid #2a2f3a; border-radius:8px; padding:8px; font-size:12px; box-sizing:border-box; }
</style>
</head>
<body>
<h1>🎨 100 个 bottts 技能变换变体</h1>
<div class="sub">技能库组合变换:palette-build 色板 × gradient-suggest 渐变 × lucide 科技徽章 · 点击勾选(默认全选)· 复制编号交渲染</div>
<div class="toolbar">
<button onclick="sel(true)">全选</button>
<button onclick="sel(false)">清空</button>
<span class="count" id="count">已选 0/100</span>
<button onclick="copySel()">复制已选编号</button>
</div>
<div class="grid">${cardHtml}</div>
<div class="out">
<textarea id="selText" placeholder="已选编号将显示在这里…" readonly></textarea>
</div>
<script>
const cards = document.querySelectorAll('.card');
const countEl = document.getElementById('count');
const selText = document.getElementById('selText');
function update() {
const sel = [...cards].filter(c => c.querySelector('.cb').checked);
cards.forEach(c => c.classList.toggle('sel', c.querySelector('.cb').checked));
countEl.textContent = '已选 ' + sel.length + '/' + cards.length;
selText.value = sel.map(c => c.dataset.idx).join(',');
}
function sel(v) { cards.forEach(c => c.querySelector('.cb').checked = v); update(); }
function copySel() {
if (!selText.value) return alert('请先勾选头像');
navigator.clipboard.writeText(selText.value).then(() => alert('已复制编号:' + selText.value)).catch(() => alert(selText.value));
}
cards.forEach(c => c.addEventListener('click', e => {
if (e.target.tagName === 'INPUT') return;
const cb = c.querySelector('.cb'); cb.checked = !cb.checked; update();
}));
sel(true);
</script>
</body>
</html>`;
fs.writeFileSync(path.join(OUT_DIR, 'preview-100-variants.html'), html, 'utf8');
console.log('🎉 已生成 100 个技能变换变体 → output/agent-avatars-png/preview-100-variants.html');
+99
View File
@@ -0,0 +1,99 @@
/**
* 🎨 10 种智能体头像风格 × 100 个 = 1000 个 SVG 生成脚本
* - 风格:DiceBear 10 种(资源库 js/data/sources/dicebear-*.js)
* - 每种风格专属:渐变圆底 + 白色圆环 + lucide 风格徽章(视觉身份差异化)
* - bottts / bottts-neutral 复用 bot-common 过滤负面表情
* - 确定性 seed:{style}-1 .. {style}-100,每风格内部唯一
*
* 用法:node scripts/gen-10styles.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { generateSeeds } = require('./bot-common');
const ROOT = path.resolve(__dirname, '..');
const OUT = path.join(ROOT, 'output', 'agent-avatars-png', 'avatars');
const DATE = '2026-08-19';
// —— 1. 风格配置(视觉身份 + 渐变 + 徽章图标)——
const STYLES = [
{ id: 'bottts', name: '科技机器人', desc: '机械与电路', c1: '#0f2a6b', c2: '#2563eb', badge: 'bot' },
{ id: 'bottts-neutral', name: '极简机器人', desc: '黑白灰几何', c1: '#374151', c2: '#9ca3af', badge: 'cpu' },
{ id: 'micah', name: '米迦人物', desc: '几何人物插画', c1: '#6d28d9', c2: '#ec4899', badge: 'fingerprint' },
{ id: 'pixel-art', name: '像素风', desc: '复古 8-bit 游戏', c1: '#581c87', c2: '#db2777', badge: 'gamepad-2' },
{ id: 'fun-emoji', name: '趣味表情', desc: '活泼可爱 emoji', c1: '#ea580c', c2: '#facc15', badge: 'smile' },
{ id: 'rings', name: '圆环几何', desc: '极简同心圆', c1: '#0d9488', c2: '#5eead4', badge: 'circle' },
{ id: 'shapes', name: '几何形状', desc: '现代简约图形', c1: '#e11d48', c2: '#fb7185', badge: 'shapes' },
{ id: 'avataaars', name: '阿凡达', desc: '可定制人物角色', c1: '#166534', c2: '#4ade80', badge: 'user' },
{ id: 'notionists', name: 'Notion 风', desc: '现代扁平商务', c1: '#1e3a8a', c2: '#38bdf8', badge: 'briefcase' },
{ id: 'adventurer', name: '冒险家', desc: '探险冒险人物', c1: '#92400e', c2: '#fbbf24', badge: 'compass' }
];
// —— 2. 加载引擎 + 风格定义 + lucide 徽章 ——
global.window = global;
function load(rel) { vm.runInThisContext(fs.readFileSync(path.join(ROOT, rel), 'utf8'), { filename: rel }); }
load('js/core/dicebear.js');
load('js/data/sources/lucide.js');
for (const s of STYLES) load('js/data/sources/dicebear-' + s.id + '.js');
const lucideIcons = window['HGB_Source_lucide'].icons;
// —— 3. 徽章 SVG(lucide 24×24 白色线条)——
function badgeSvg(name) {
const ic = lucideIcons.find(x => x.name === name);
if (!ic || !ic.svg) return '';
// 提取 svg 内部
const m = ic.svg.match(/<svg[^>]*>([\s\S]*)<\/svg>/);
return m ? m[1] : '';
}
// —— 4. 包装:渐变圆底 + 头像 + 白环 + 徽章 ——
function wrap(svg, w, h, st, idx) {
const inner = svg.replace(/^<\?xml[^>]*\?>\s*/, '').replace(/^<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '');
// 头像缩放:占 200×200 区域居中
const s = 200 / Math.max(w, h);
const tx = (240 - w * s) / 2, ty = (240 - h * s) / 2;
const badge = badgeSvg(st.badge);
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="240" height="240">
<defs>
<linearGradient id="g${idx}" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${st.c1}"/><stop offset="100%" stop-color="${st.c2}"/>
</linearGradient>
<clipPath id="c${idx}"><circle cx="120" cy="120" r="120"/></clipPath>
</defs>
<rect width="240" height="240" fill="url(#g${idx})" clip-path="url(#c${idx})"/>
<g transform="translate(${tx.toFixed(2)} ${ty.toFixed(2)}) scale(${s.toFixed(4)})">${inner}</g>
<circle cx="120" cy="120" r="114" fill="none" stroke="#ffffff" stroke-opacity="0.4" stroke-width="2.5"/>
<g transform="translate(196 196)"><circle r="27" fill="#ffffff" fill-opacity="0.22"/><g transform="translate(-12 -12)">${badge}</g></g>
</svg>`;
}
// —— 5. 逐风格生成 ——
const meta = { generatedAt: new Date().toISOString(), date: DATE, styles: [] };
fs.mkdirSync(OUT, { recursive: true });
let total = 0;
for (const st of STYLES) {
// 每风格 100 个不同 seed:{style}-1..100,复用 generateSeeds 保证唯一
const cards = generateSeeds(seed => HGB_Dicebear.renderAvatar(st.id, seed), 100);
const styleDir = path.join(OUT, st.id);
fs.mkdirSync(styleDir, { recursive: true });
const list = [];
cards.forEach((c, i) => {
const n = String(i + 1).padStart(3, '0');
const def = window['HGB_Dicebear_' + st.id.replace(/-/g, '_')];
const w = def.canvas.width, h = def.canvas.height;
const out = wrap(c.svg, w, h, st, i + 1);
const file = `${st.id}-${n}.svg`;
fs.writeFileSync(path.join(styleDir, file), out, 'utf8');
list.push({ file, seed: c.seed, date: DATE });
});
meta.styles.push({ id: st.id, name: st.name, desc: st.desc, c1: st.c1, c2: st.c2, badge: st.badge, count: list.length, avatars: list });
total += list.length;
console.log(`✅ ${st.id}(${st.name}): ${list.length} 个`);
}
fs.writeFileSync(path.join(OUT, 'meta.json'), JSON.stringify(meta), 'utf8');
console.log(`🎉 共 ${total} 个 SVG → output/agent-avatars-png/avatars/ + meta.json`);
+100
View File
@@ -0,0 +1,100 @@
/**
* 🎨 14 种智能体头像风格 × 100 个 = 1400 个 SVG 生成脚本
* - 风格:DiceBear 14 种(资源库 js/data/sources/dicebear-*.js)
* - 每种风格专属:渐变圆底 + 白色圆环 + lucide 风格徽章(视觉身份差异化)
* - bottts / bottts-neutral 复用 bot-common 过滤负面表情
* - 确定性 seed:{style}-1 .. {style}-100,每风格内部唯一
*
* 用法:node scripts/gen-14styles.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { generateSeeds } = require('./bot-common');
const ROOT = path.resolve(__dirname, '..');
const OUT = path.join(ROOT, 'output', 'agent-avatars-png', 'avatars');
const DATE = '2026-08-19';
// —— 1. 风格配置(视觉身份 + 渐变 + 徽章图标)——
const STYLES = [
{ id: 'bottts', name: '科技机器人', desc: '机械与电路', c1: '#0f2a6b', c2: '#2563eb', badge: 'bot' },
{ id: 'bottts-neutral', name: '极简机器人', desc: '黑白灰几何', c1: '#374151', c2: '#9ca3af', badge: 'cpu' },
{ id: 'avataaars', name: '阿凡达', desc: '可定制人物角色', c1: '#166534', c2: '#4ade80', badge: 'user' },
{ id: 'adventurer', name: '冒险家', desc: '探险冒险人物', c1: '#92400e', c2: '#fbbf24', badge: 'compass' },
{ id: 'avataaars-neutral', name: '中性阿凡达', desc: '中性色人物角色', c1: '#065f46', c2: '#2dd4bf', badge: 'smile' },
{ id: 'adventurer-neutral',name: '中性冒险家', desc: '中性色探险人物', c1: '#78350f', c2: '#d97706', badge: 'map' },
{ id: 'open-peeps', name: '手绘小人', desc: '涂鸦风人物', c1: '#4338ca', c2: '#818cf8', badge: 'users' },
{ id: 'personas', name: '人物插画', desc: '现代扁平人物', c1: '#be185d', c2: '#f472b6', badge: 'palette' },
{ id: 'dylan', name: '迪伦人物', desc: '涂鸦线条人物', c1: '#0e7490', c2: '#67e8f9', badge: 'pen-tool' },
{ id: 'miniavs', name: '迷你头像', desc: '极简小人头像', c1: '#155e75', c2: '#22d3ee', badge: 'heart' },
{ id: 'big-smile', name: '大笑脸', desc: '圆脸微笑', c1: '#b45309', c2: '#fde047', badge: 'laugh' },
{ id: 'big-ears', name: '大耳朵', desc: '圆脸大耳', c1: '#9a3412', c2: '#fb923c', badge: 'ear' },
{ id: 'croodles', name: '卡通线条', desc: '简单线条卡通', c1: '#7c2d12', c2: '#f97316', badge: 'pencil' },
{ id: 'lorelei', name: '洛雷莱', desc: '唯美插画人物', c1: '#9d174d', c2: '#f9a8d4', badge: 'flower' }
];
// —— 2. 加载引擎 + 风格定义 + lucide 徽章 ——
global.window = global;
function load(rel) { vm.runInThisContext(fs.readFileSync(path.join(ROOT, rel), 'utf8'), { filename: rel }); }
load('js/core/dicebear.js');
load('js/data/sources/lucide.js');
for (const s of STYLES) load('js/data/sources/dicebear-' + s.id + '.js');
const lucideIcons = window['HGB_Source_lucide'].icons;
// —— 3. 徽章 SVG(lucide 24×24 白色线条)——
function badgeSvg(name) {
const ic = lucideIcons.find(x => x.name === name);
if (!ic || !ic.svg) return '';
const m = ic.svg.match(/<svg[^>]*>([\s\S]*)<\/svg>/);
return m ? m[1] : '';
}
// —— 4. 包装:渐变圆底 + 头像 + 白环 + 徽章 ——
function wrap(svg, w, h, st, idx) {
const inner = svg.replace(/^<\?xml[^>]*\?>\s*/, '').replace(/^<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '');
const s = 200 / Math.max(w, h);
const tx = (240 - w * s) / 2, ty = (240 - h * s) / 2;
const badge = badgeSvg(st.badge);
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="240" height="240">
<defs>
<linearGradient id="g${idx}" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${st.c1}"/><stop offset="100%" stop-color="${st.c2}"/>
</linearGradient>
<clipPath id="c${idx}"><circle cx="120" cy="120" r="120"/></clipPath>
</defs>
<rect width="240" height="240" fill="url(#g${idx})" clip-path="url(#c${idx})"/>
<g transform="translate(${tx.toFixed(2)} ${ty.toFixed(2)}) scale(${s.toFixed(4)})">${inner}</g>
<circle cx="120" cy="120" r="114" fill="none" stroke="#ffffff" stroke-opacity="0.4" stroke-width="2.5"/>
<g transform="translate(196 196)"><circle r="27" fill="#ffffff" fill-opacity="0.22"/><g transform="translate(-12 -12)">${badge}</g></g>
</svg>`;
}
// —— 5. 逐风格生成 ——
const meta = { generatedAt: new Date().toISOString(), date: DATE, styles: [] };
fs.mkdirSync(OUT, { recursive: true });
let total = 0;
for (const st of STYLES) {
const cards = generateSeeds(seed => HGB_Dicebear.renderAvatar(st.id, seed), 100);
const styleDir = path.join(OUT, st.id);
fs.mkdirSync(styleDir, { recursive: true });
const list = [];
cards.forEach((c, i) => {
const n = String(i + 1).padStart(3, '0');
const def = window['HGB_Dicebear_' + st.id.replace(/-/g, '_')];
const w = def.canvas.width, h = def.canvas.height;
const out = wrap(c.svg, w, h, st, i + 1);
const file = `${st.id}-${n}.svg`;
fs.writeFileSync(path.join(styleDir, file), out, 'utf8');
list.push({ file, seed: c.seed, date: DATE });
});
meta.styles.push({ id: st.id, name: st.name, desc: st.desc, c1: st.c1, c2: st.c2, badge: st.badge, count: list.length, avatars: list });
total += list.length;
console.log(`✅ ${st.id}(${st.name}): ${list.length} 个`);
}
fs.writeFileSync(path.join(OUT, 'meta.json'), JSON.stringify(meta), 'utf8');
console.log(`🎉 共 ${total} 个 SVG → output/agent-avatars-png/avatars/ + meta.json`);
+118
View File
@@ -0,0 +1,118 @@
/**
* 🤖 300 个 bottts 机器人头像候选预览页(资源库 DiceBear bottts 源直选,无需渲染)
* 用确定性 seed 生成 300 个不同机器人(过滤负面表情变体),内联 SVG 预览,
* 支持勾选,每张可下载白底 PNG,底部可复制已选编号。
*
* 用法:node scripts/gen-300-bot-avatars.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { generateSeeds } = require('./bot-common');
const ROOT = path.resolve(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'output', 'agent-avatars-png');
const COUNT = 300;
// —— 1. 加载 bottts 风格定义 + DiceBear 渲染引擎 ——
global.window = global;
const styleFile = path.join(ROOT, 'js', 'data', 'sources', 'dicebear-bottts.js');
vm.runInThisContext(fs.readFileSync(styleFile, 'utf8'), { filename: styleFile });
vm.runInThisContext(fs.readFileSync(path.join(ROOT, 'js', 'core', 'dicebear.js'), 'utf8'), { filename: 'js/core/dicebear.js' });
// —— 2. 生成 300 个不同 seed(共享逻辑:过滤负面表情)——
const cards = generateSeeds(seed => HGB_Dicebear.renderAvatar('bottts', seed), COUNT);
// —— 3. 预览页(内联 SVG + 勾选交互 + PNG 下载)——
let cardHtml = '';
for (let i = 0; i < cards.length; i++) {
const c = cards[i];
const n = String(i + 1).padStart(2, '0');
cardHtml += `<label class="card" data-idx="${i + 1}">
<input type="checkbox" class="cb" value="${i + 1}" data-name="${c.seed}">
${c.svg}
<div class="no">#${n}</div>
<div class="name">${c.seed}</div>
<a class="dl" href="bot-png/agent-${n}.png" download="agent-${n}.png" onclick="event.stopPropagation()">下载 PNG</a>
</label>\n`;
}
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🤖 300 个 bottts 机器人头像候选 · 勾选后批量渲染</title>
<style>
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background:#0f1115; color:#e8eaed; margin:0; padding:24px; }
h1 { font-size:20px; margin:0 0 4px; }
.sub { color:#9aa0a6; font-size:13px; margin-bottom:16px; }
.toolbar { position:sticky; top:0; background:#0f1115ee; padding:10px 0; z-index:9; border-bottom:1px solid #2a2f3a; margin-bottom:16px; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.toolbar button { background:#2563eb; color:#fff; border:none; border-radius:6px; padding:6px 14px; font-size:13px; cursor:pointer; }
.toolbar .count { color:#8ab4f8; font-size:13px; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); gap:12px; }
.card { background:#1b1e26; border:1px solid #2a2f3a; border-radius:12px; padding:12px 8px 10px; text-align:center; cursor:pointer; position:relative; transition:border-color .15s; }
.card:hover { border-color:#3b82f6; }
.card.sel { border-color:#22c55e; background:#1a2b1f; }
.card svg { width:96px; height:96px; border-radius:50%; background:#fff; display:block; margin:0 auto; }
.card .no { font-size:11px; color:#80868b; margin-top:8px; }
.card .name { font-size:11px; color:#bdc1c6; }
.card .cb { position:absolute; top:8px; left:8px; width:16px; height:16px; accent-color:#22c55e; }
a.dl { display:block; margin-top:6px; font-size:11px; color:#8ab4f8; text-decoration:none; }
a.dl:hover { text-decoration:underline; }
.out { margin-top:16px; }
.out textarea { width:100%; height:56px; background:#1b1e26; color:#8ab4f8; border:1px solid #2a2f3a; border-radius:8px; padding:8px; font-size:12px; box-sizing:border-box; }
</style>
</head>
<body>
<h1>🤖 300 个 bottts 机器人头像候选</h1>
<div class="sub">资源库 DiceBear bottts 源 · 已过滤负面表情(眩晕/龇牙)· 点击卡片勾选(默认全选)· 每张可直接下载白底 PNG(bot-png/)· 可复制编号批量渲染</div>
<div class="toolbar">
<button onclick="sel(true)">全选</button>
<button onclick="sel(false)">清空</button>
<span class="count" id="count">已选 0/${COUNT}</span>
<button onclick="copySel()">复制已选编号</button>
</div>
<div class="grid">${cardHtml}</div>
<div class="out">
<textarea id="selText" placeholder="已选编号将显示在这里…" readonly></textarea>
</div>
<script>
const cards = document.querySelectorAll('.card');
const countEl = document.getElementById('count');
const selText = document.getElementById('selText');
function update() {
const sel = [...cards].filter(c => c.querySelector('.cb').checked);
cards.forEach(c => c.classList.toggle('sel', c.querySelector('.cb').checked));
countEl.textContent = '已选 ' + sel.length + '/' + cards.length;
selText.value = sel.map(c => c.dataset.idx).join(',');
}
function sel(v) { cards.forEach(c => c.querySelector('.cb').checked = v); update(); }
function copySel() {
if (!selText.value) return alert('请先勾选头像');
navigator.clipboard.writeText(selText.value).then(() => alert('已复制编号:' + selText.value)).catch(() => alert(selText.value));
}
cards.forEach(c => c.addEventListener('click', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'A') return;
const cb = c.querySelector('.cb'); cb.checked = !cb.checked; update();
}));
sel(true);
</script>
</body>
</html>`;
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(path.join(OUT_DIR, 'preview-300.html'), html, 'utf8');
console.log('🎉 已生成 ' + COUNT + ' 个 bottts 候选(负面表情已过滤)→ output/agent-avatars-png/preview-300.html');
// 校验唯一性
const { createHash } = require('crypto');
const hashes = new Set();
let dup = 0;
for (const c of cards) {
const h = createHash('md5').update(c.svg).digest('hex');
if (hashes.has(h)) dup++;
hashes.add(h);
}
console.log('头像唯一性:' + (cards.length - dup) + '/' + cards.length + (dup ? '(⚠️ ' + dup + ' 个重复)' : ' ✅'));
+74
View File
@@ -0,0 +1,74 @@
/**
* 🤖 300 个 bottts 机器人 → 白底方形 PNG(1024×1024)
* 与 preview-300.html 的 seed 序列一一对应(共享 bot-common 逻辑,过滤负面表情),
* 白底 + bottts 居中放大,Chrome headless 并发截图。
*
* 用法:node scripts/gen-300-bot-png.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { spawn } = require('child_process');
const { generateSeeds } = require('./bot-common');
const ROOT = path.resolve(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'output', 'agent-avatars-png', 'bot-png');
const TMP_DIR = path.join(ROOT, 'output', '.tmp-bot');
const CHROME = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
const SIZE = 1024;
const COUNT = 300;
// —— 1. 加载 bottts ——
global.window = global;
vm.runInThisContext(fs.readFileSync(path.join(ROOT, 'js', 'data', 'sources', 'dicebear-bottts.js'), 'utf8'), { filename: 'dicebear-bottts.js' });
vm.runInThisContext(fs.readFileSync(path.join(ROOT, 'js', 'core', 'dicebear.js'), 'utf8'), { filename: 'dicebear.js' });
// —— 2. 生成 300 个 seed(与预览页一致)——
const cards = generateSeeds(seed => HGB_Dicebear.renderAvatar('bottts', seed), COUNT);
// —— 3. 生成 HTML(白底 + bottts 居中,SVG 放大到 880)——
fs.mkdirSync(TMP_DIR, { recursive: true });
const htmlFiles = [];
for (let i = 0; i < cards.length; i++) {
const { svg } = cards[i];
const html = `<!DOCTYPE html><html><head><meta charset="utf-8">
<style>html,body{margin:0;padding:0;width:${SIZE}px;height:${SIZE}px;overflow:hidden;background:#ffffff}
.wrap{width:${SIZE}px;height:${SIZE}px;display:flex;align-items:center;justify-content:center}</style>
</head><body><div class="wrap">${svg.replace('<svg ', '<svg width="880" height="880" ')}</div></body></html>`;
const f = path.join(TMP_DIR, `a${i + 1}.html`);
fs.writeFileSync(f, html, 'utf8');
htmlFiles.push(f);
}
// —— 4. 并发截图(8 进程)——
function shot(file, out) {
return new Promise((res) => {
const p = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--hide-scrollbars',
'--force-device-scale-factor=1',
'--window-size=' + SIZE + ',' + SIZE, '--virtual-time-budget=800',
'--screenshot=' + out,
'file:///' + file.replace(/\\/g, '/')
], { stdio: 'ignore' });
p.on('close', () => res());
p.on('error', () => res());
});
}
(async () => {
fs.mkdirSync(OUT_DIR, { recursive: true });
const jobs = htmlFiles.map((f, i) => ({
f, out: path.join(OUT_DIR, 'agent-' + String(i + 1).padStart(2, '0') + '.png')
}));
let done = 0, t0 = Date.now();
const CONC = 8;
for (let s = 0; s < jobs.length; s += CONC) {
const batch = jobs.slice(s, s + CONC);
await Promise.all(batch.map(j => shot(j.f, j.out)));
done += batch.length;
console.log(`⏳ ${done}/${jobs.length} ${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
fs.rmSync(TMP_DIR, { recursive: true, force: true });
console.log('🎉 完成 → output/agent-avatars-png/bot-png/ (' + jobs.length + ' 张,白底 1024×1024)');
})();
+97
View File
@@ -0,0 +1,97 @@
/**
* 智能体头像批量生成(DiceBear bottts 机器人风)
* 从资源库 js/data/sources/dicebear-bottts.js 读取风格定义,
* 用确定性 seed 生成 10 套头像 SVG,输出到 output/agent-avatars/,
* 同时生成 preview.html 便于浏览器预览。
*
* 用法:node scripts/gen-agent-avatars.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = path.resolve(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'output', 'agent-avatars');
// —— 模拟浏览器全局:DiceBear 风格文件写入 window.HGB_Dicebear_* ——
global.window = global;
// 1) 加载 29 种 DiceBear 风格定义中的 bottts(机器人)
const styleFile = path.join(ROOT, 'js', 'data', 'sources', 'dicebear-bottts.js');
const styleSrc = fs.readFileSync(styleFile, 'utf8');
vm.runInThisContext(styleSrc, { filename: styleFile }); // 定义 window.HGB_Dicebear_bottts
// 2) 加载 DiceBear 渲染引擎(const 声明经 vm 进入全局词法绑定,与 scripts/avatar-ai-smoke.js 一致)
const engineSrc = fs.readFileSync(path.join(ROOT, 'js', 'core', 'dicebear.js'), 'utf8');
vm.runInThisContext(engineSrc, { filename: 'js/core/dicebear.js' }); // 定义 HGB_Dicebear
// 3) 从资源库 tabler 源提取「房子属性」图标(smart-home 智能家居)用于叠加徽章
const tablerFile = path.join(ROOT, 'js', 'data', 'sources', 'tabler.js');
vm.runInThisContext(fs.readFileSync(tablerFile, 'utf8'), { filename: 'tabler.js' }); // 定义 window.HGB_Source_tabler
const tablerIcons = window['HGB_Source_tabler'].icons;
const houseIcon = tablerIcons.find(x => x.id === 'tabler:smart-home') || tablerIcons.find(x => x.id === 'tabler:home');
if (!houseIcon) { console.error('❌ 资源库未找到 smart-home/home 图标'); process.exit(1); }
const houseBody = houseIcon.svg; // 形如 <path .../><path .../>(tabler 为 stroke 风格)
// 徽章:圆形底 + 房子图标,右下角 (140,140),整体在 180 viewBox 圆形裁切内
const BADGE_CX = 140, BADGE_CY = 140, BADGE_R = 22, ICON_SIZE = 24, ICON_SCALE = 0.85;
const houseBadge = `<g transform="translate(${BADGE_CX} ${BADGE_CY})"><circle r="${BADGE_R}" fill="#5561d4"/><g transform="translate(${-ICON_SIZE / 2} ${-ICON_SIZE / 2}) scale(${ICON_SCALE})" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${houseBody}</g></g>`;
// 4) 生成 10 套:seed 用固定前缀 + 序号,保证可复现
const COUNT = 10;
const SEED_BASE = 'agent';
const seeds = [];
for (let i = 1; i <= COUNT; i++) seeds.push(SEED_BASE + '-' + i);
const cards = [];
for (let i = 0; i < seeds.length; i++) {
const { svg } = HGB_Dicebear.renderAvatar('bottts', seeds[i]);
// 叠加房子属性徽章(插到 </svg> 前)
const enriched = svg.replace(/<\/svg>$/, houseBadge + '</svg>');
const file = 'agent-' + String(i + 1).padStart(2, '0') + '.svg';
cards.push({ file, seed: seeds[i], svg: enriched });
}
// 4) 写文件
fs.mkdirSync(OUT_DIR, { recursive: true });
for (const c of cards) {
fs.writeFileSync(path.join(OUT_DIR, c.file), c.svg, 'utf8');
console.log('✅ ' + c.file + ' seed=' + c.seed);
}
// 5) 预览页(内联全部 SVG,浏览器直接查看)
const preview = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>智能体头像 · 10 套预览(DiceBear bottts · 房子属性)</title>
<style>
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background:#0f1115; color:#e8eaed; margin:0; padding:32px; }
h1 { font-size:20px; margin:0 0 4px; }
.sub { color:#9aa0a6; font-size:13px; margin-bottom:24px; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); gap:16px; max-width:1000px; }
.card { background:#1b1e26; border:1px solid #2a2f3a; border-radius:12px; padding:16px; text-align:center; }
.card svg { width:96px; height:96px; border-radius:50%; background:#fff; }
.card .name { margin-top:10px; font-size:12px; color:#bdc1c6; }
.card .seed { font-size:11px; color:#80868b; }
a.dl { display:inline-block; margin-top:6px; font-size:11px; color:#8ab4f8; text-decoration:none; }
</style>
</head>
<body>
<h1>🤖 智能体头像 · 10 套(带房子属性)</h1>
<div class="sub">DiceBear bottts(机器人)风格 + 房子属性徽章(资源库 tabler · smart-home)· 确定性 seed 生成</div>
<div class="grid">
${cards.map(c => `<div class="card">
${c.svg}
<div class="name">智能体 ${c.file.replace('.svg', '')}</div>
<div class="seed">seed: ${c.seed}</div>
<a class="dl" href="${c.file}" download>下载 SVG</a>
</div>`).join('\n')}
</div>
</body>
</html>`;
fs.writeFileSync(path.join(OUT_DIR, 'preview.html'), preview, 'utf8');
console.log('\n🎉 已生成 ' + cards.length + ' 套头像 → output/agent-avatars/');
console.log('预览:打开 output/agent-avatars/preview.html');
+72
View File
@@ -0,0 +1,72 @@
/**
* 🖼️ 14 风格 × 100 = 1400 个 透明底 PNG 渲染脚本(512×512)
* 读取 output/agent-avatars-png/avatars/{style}/{file}.svg,
* Chrome headless 截图 → 透明背景 PNG(SVG 自带圆底,外部透明)。
*
* 用法:node scripts/gen-avatars-png.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const SVG_ROOT = path.join(ROOT, 'output', 'agent-avatars-png', 'avatars');
const TMP_DIR = path.join(ROOT, 'output', '.tmp-av');
const CHROME = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
const SIZE = 512;
// —— 1. 收集全部 SVG 文件 ——
const files = [];
for (const dir of fs.readdirSync(SVG_ROOT, { withFileTypes: true })) {
if (!dir.isDirectory()) continue;
for (const f of fs.readdirSync(path.join(SVG_ROOT, dir.name))) {
if (f.endsWith('.svg')) files.push(path.join(dir.name, f));
}
}
files.sort();
console.log('📦 待渲染 SVG:' + files.length + ' 个');
// —— 2. 生成 HTML(透明背景 + SVG 居中,放大到 SIZE)——
fs.mkdirSync(TMP_DIR, { recursive: true });
const jobs = files.map((rel, i) => {
const svg = fs.readFileSync(path.join(SVG_ROOT, rel), 'utf8');
const html = `<!DOCTYPE html><html><head><meta charset="utf-8">
<style>html,body{margin:0;padding:0;width:${SIZE}px;height:${SIZE}px;overflow:hidden;background:transparent}
.wrap{width:${SIZE}px;height:${SIZE}px;display:flex;align-items:center;justify-content:center}</style>
</head><body><div class="wrap">${svg.replace('<svg ', '<svg width="' + SIZE + '" height="' + SIZE + '" ')}</div></body></html>`;
const hf = path.join(TMP_DIR, 'a' + i + '.html');
fs.writeFileSync(hf, html, 'utf8');
return { hf, out: path.join(SVG_ROOT, rel.replace(/\.svg$/, '.png')) };
});
// —— 3. 并发截图(透明底)——
function shot(job) {
return new Promise((res) => {
const p = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--hide-scrollbars',
'--default-background-color=00000000',
'--force-device-scale-factor=1',
'--window-size=' + SIZE + ',' + SIZE, '--virtual-time-budget=600',
'--screenshot=' + job.out,
'file:///' + job.hf.replace(/\\/g, '/')
], { stdio: 'ignore' });
p.on('close', () => res());
p.on('error', () => res());
});
}
(async () => {
let done = 0, t0 = Date.now();
const CONC = 8;
for (let s = 0; s < jobs.length; s += CONC) {
const batch = jobs.slice(s, s + CONC);
await Promise.all(batch.map(shot));
done += batch.length;
if (done % 200 === 0 || done === jobs.length) {
console.log(`⏳ ${done}/${jobs.length} ${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
}
fs.rmSync(TMP_DIR, { recursive: true, force: true });
console.log('🎉 完成 → 透明底 PNG(512×512),与 SVG 同目录');
})();
+86
View File
@@ -0,0 +1,86 @@
/**
* 🏠 emoji 头像 PNG 批量生成
* 从资源库 twemoji 源提取 house 表情彩色 SVG,
* 生成 10 个渐变背景圆形头像(1024×1024),用 Chrome headless 导出 PNG。
*
* 用法:node scripts/gen-emoji-avatars-png.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { execFileSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'output', 'agent-avatars-png');
const TMP_DIR = path.join(ROOT, 'output', '.tmp-emoji');
const CHROME = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
// —— 1. 从资源库读取 twemoji 源,提取 house(🏠)彩色 SVG ——
global.window = global;
vm.runInThisContext(fs.readFileSync(path.join(ROOT, 'js', 'data', 'sources', 'twemoji.js'), 'utf8'), { filename: 'twemoji.js' });
const icons = window['HGB_Source_twemoji'].icons;
const house = icons.find(x => x.id === 'twemoji:house');
if (!house) { console.error('❌ 资源库未找到 twemoji:house'); process.exit(1); }
const houseBody = house.svg; // 24 坐标系的 g 内容(含 translate(0 0) scale(0.666…))
// —— 2. 10 个渐变背景(16 进制)——
const BG = [
{ from: '#667eea', to: '#764ba2' }, // 蓝紫
{ from: '#2193b0', to: '#6dd5ed' }, // 青蓝
{ from: '#11998e', to: '#38ef7d' }, // 绿
{ from: '#f7971e', to: '#ffd200' }, // 橙黄
{ from: '#ee9ca7', to: '#ffdde1' }, // 粉
{ from: '#cb2d3e', to: '#ef473a' }, // 红
{ from: '#41295a', to: '#2F0743' }, // 紫黑
{ from: '#1e3c72', to: '#2a5298' }, // 深蓝
{ from: '#f953c6', to: '#b91d73' }, // 玫红
{ from: '#0575e6', to: '#021B79' } // 靛蓝
];
// —— 3. 生成 HTML:1024×1024 圆形渐变底 + 🏠 居中(emoji 内容按 24 坐标缩放 22 → 528px)——
function buildHtml(from, to) {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>html,body{margin:0;padding:0;width:1024px;height:1024px;overflow:hidden;background:transparent}</style>
</head><body>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${from}"/><stop offset="100%" stop-color="${to}"/>
</linearGradient>
<clipPath id="circle"><circle cx="512" cy="512" r="512"/></clipPath>
</defs>
<rect width="1024" height="1024" fill="url(#bg)" clip-path="url(#circle)"/>
<g transform="translate(512 512) scale(22) translate(-12 -12)">${houseBody}</g>
</svg>
</body></html>`;
}
// —— 4. 逐张导出 PNG ——
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.mkdirSync(TMP_DIR, { recursive: true });
for (let i = 0; i < BG.length; i++) {
const idx = String(i + 1).padStart(2, '0');
const htmlFile = path.join(TMP_DIR, `avatar-${idx}.html`);
const pngFile = path.join(OUT_DIR, `agent-${idx}.png`);
fs.writeFileSync(htmlFile, buildHtml(BG[i].from, BG[i].to), 'utf8');
try {
execFileSync(CHROME, [
'--headless=new', '--disable-gpu', '--hide-scrollbars',
'--force-device-scale-factor=1', '--default-background-color=00000000',
'--window-size=1024,1024',
'--virtual-time-budget=1000',
`--screenshot=${pngFile}`,
'file:///' + htmlFile.replace(/\\/g, '/')
], { stdio: 'pipe' });
console.log(`✅ agent-${idx}.png ${BG[i].from} → ${BG[i].to}`);
} catch (e) {
console.error(`❌ agent-${idx}.png 失败:`, e.message.split('\n')[0]);
}
}
// 清理临时 HTML
fs.rmSync(TMP_DIR, { recursive: true, force: true });
console.log('\n🎉 完成 → output/agent-avatars-png/');
+81
View File
@@ -0,0 +1,81 @@
/**
* 🎯 简洁科技感头像 PNG —— 从资源库直接挑 10 个 lucide 线性图标
* 10 个图标:bot / brain-circuit / cpu / sparkles / satellite / radar /
* circuit-board / atom / terminal / rocket
* 统一深蓝渐变圆底 + 白色线性图标 + 亮蓝细环,Chrome headless 导出 1024×1024 PNG。
*
* 用法:node scripts/gen-icon-avatars-png.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { execFileSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'output', 'agent-avatars-png');
const TMP_DIR = path.join(ROOT, 'output', '.tmp-icons');
const CHROME = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
// —— 1. 加载资源库 lucide 源 ——
global.window = global;
const lucideFile = path.join(ROOT, 'js', 'data', 'sources', 'lucide.js');
vm.runInThisContext(fs.readFileSync(lucideFile, 'utf8'), { filename: lucideFile });
const lucideIcons = window['HGB_Source_lucide'].icons;
// —— 2. 挑选 10 个科技感图标(按名称匹配,保证存在)——
const PICK = ['bot', 'brain-circuit', 'cpu', 'sparkles', 'satellite', 'radar', 'circuit-board', 'atom', 'terminal', 'rocket'];
const picked = PICK.map(name => {
const icon = lucideIcons.find(x => x.name === name);
if (!icon) { console.error('❌ 资源库未找到 lucide:' + name); process.exit(1); }
return { id: icon.id, name, body: icon.svg };
});
const BG_FROM = '#0b1220', BG_TO = '#16294a';
const RING = '#3b82f6';
const S = 24; // lucide 画布 24
const ZOOM = 20; // 放大到 480px
function buildHtml(body) {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>html,body{margin:0;padding:0;width:1024px;height:1024px;overflow:hidden;background:transparent}</style>
</head><body>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${BG_FROM}"/><stop offset="100%" stop-color="${BG_TO}"/>
</linearGradient>
<clipPath id="circle"><circle cx="512" cy="512" r="512"/></clipPath>
</defs>
<rect width="1024" height="1024" fill="url(#bg)" clip-path="url(#circle)"/>
<circle cx="512" cy="512" r="452" fill="none" stroke="${RING}" stroke-width="5" opacity=".45"/>
<g transform="translate(512 512) scale(${ZOOM}) translate(${-S / 2} ${-S / 2})"
fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</g>
</svg>
</body></html>`;
}
// —— 3. 导出 PNG ——
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.mkdirSync(TMP_DIR, { recursive: true });
picked.forEach((p, i) => {
const idx = String(i + 1).padStart(2, '0');
const htmlFile = path.join(TMP_DIR, `avatar-${idx}.html`);
const pngFile = path.join(OUT_DIR, `agent-${idx}.png`);
fs.writeFileSync(htmlFile, buildHtml(p.body), 'utf8');
try {
execFileSync(CHROME, [
'--headless=new', '--disable-gpu', '--hide-scrollbars',
'--force-device-scale-factor=1', '--default-background-color=00000000',
'--window-size=1024,1024', '--virtual-time-budget=1000',
`--screenshot=${pngFile}`,
'file:///' + htmlFile.replace(/\\/g, '/')
], { stdio: 'pipe' });
console.log(`✅ agent-${idx}.png ← lucide:${p.name}`);
} catch (e) {
console.error(`❌ agent-${idx}.png 失败:`, e.message.split('\n')[0]);
}
});
fs.rmSync(TMP_DIR, { recursive: true, force: true });
console.log('\n🎉 完成 → output/agent-avatars-png/');
+79
View File
@@ -0,0 +1,79 @@
/**
* 🤖 简洁科技感头像 PNG 批量生成(DiceBear bottts-neutral 机器人中性)
* 从资源库读取风格定义,seed 确定性渲染;黑灰机器人反白成线框感,
* 配深蓝渐变圆形底 + 细亮蓝圆环,Chrome headless 导出 1024×1024 PNG。
*
* 用法:node scripts/gen-tech-avatars-png.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { execFileSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'output', 'agent-avatars-png');
const TMP_DIR = path.join(ROOT, 'output', '.tmp-tech');
const CHROME = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
// —— 1. 加载资源库风格定义 + 渲染引擎 ——
global.window = global;
const styleFile = path.join(ROOT, 'js', 'data', 'sources', 'dicebear-bottts-neutral.js');
vm.runInThisContext(fs.readFileSync(styleFile, 'utf8'), { filename: styleFile });
vm.runInThisContext(fs.readFileSync(path.join(ROOT, 'js', 'core', 'dicebear.js'), 'utf8'), { filename: 'dicebear.js' });
// —— 2. 渲染 10 个 seed,反白(黑→白线框感),剥离 svg 外壳取 inner ——
const COUNT = 10;
const S = 120; // bottts-neutral 画布 120
const ZOOM = 5.4; // 放大到 ~648px
const BG_FROM = '#0b1220', BG_TO = '#16294a'; // 深蓝科技渐变
const RING = '#3b82f6'; // 亮蓝圆环
function renderInner(seed) {
const { svg } = HGB_Dicebear.renderAvatar('bottts-neutral', seed);
const inner = svg.replace(/^<\?xml[^>]*\?>\s*<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '');
return inner.replace(/fill="black"/g, 'fill="white"'); // 反白
}
function buildHtml(inner) {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>html,body{margin:0;padding:0;width:1024px;height:1024px;overflow:hidden;background:transparent}</style>
</head><body>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${BG_FROM}"/><stop offset="100%" stop-color="${BG_TO}"/>
</linearGradient>
<clipPath id="circle"><circle cx="512" cy="512" r="512"/></clipPath>
</defs>
<rect width="1024" height="1024" fill="url(#bg)" clip-path="url(#circle)"/>
<circle cx="512" cy="512" r="452" fill="none" stroke="${RING}" stroke-width="5" opacity=".45"/>
<g transform="translate(512 512) scale(${ZOOM}) translate(${-S / 2} ${-S / 2})">${inner}</g>
</svg>
</body></html>`;
}
// —— 3. 导出 PNG ——
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.mkdirSync(TMP_DIR, { recursive: true });
for (let i = 1; i <= COUNT; i++) {
const idx = String(i).padStart(2, '0');
const htmlFile = path.join(TMP_DIR, `avatar-${idx}.html`);
const pngFile = path.join(OUT_DIR, `agent-${idx}.png`);
fs.writeFileSync(htmlFile, buildHtml(renderInner('agent-' + i)), 'utf8');
try {
execFileSync(CHROME, [
'--headless=new', '--disable-gpu', '--hide-scrollbars',
'--force-device-scale-factor=1', '--default-background-color=00000000',
'--window-size=1024,1024', '--virtual-time-budget=1000',
`--screenshot=${pngFile}`,
'file:///' + htmlFile.replace(/\\/g, '/')
], { stdio: 'pipe' });
console.log(`✅ agent-${idx}.png seed=agent-${i}`);
} catch (e) {
console.error(`❌ agent-${idx}.png 失败:`, e.message.split('\n')[0]);
}
}
fs.rmSync(TMP_DIR, { recursive: true, force: true });
console.log('\n🎉 完成 → output/agent-avatars-png/');
+39
View File
@@ -0,0 +1,39 @@
/** 扫描 wrap 型源的越界可疑图标(坐标与 transform scale 不匹配 → 24 视口外) */
const fs = require('fs');
const path = require('path');
const S = require('../js/data/icon-sources.js');
const wrap = [];
for (const s of S) {
if (s.scaleMode === 'wrap' && s.format === 'iconify') wrap.push(s);
}
console.log('wrap 源数:', wrap.length);
for (const s of wrap) {
const fp = path.join('js/data/sources', s.id + '.js');
if (!fs.existsSync(fp)) { console.log(s.id, '无数据文件'); continue; }
global.window = {};
try { eval(fs.readFileSync(fp, 'utf8')); } catch (e) { console.log(s.id, '读取失败', e.message); continue; }
const d = global.window['HGB_Source_' + s.id];
if (!d) { continue; }
let bad = 0, anim = 0;
const samples = [];
for (const ic of d.icons) {
const b = ic.svg || '';
if (/<animate\b/.test(b)) anim++;
const tm = b.match(/transform="translate\(([-\d.]+) ([-\d.]+)\) scale\(([-\d.]+)\)"/);
if (!tm) continue;
const sc = parseFloat(tm[3]);
const ds = [...b.matchAll(/ d="([^"]*)"/g)].map(m => m[1]);
let max = 0;
for (const dstr of ds) {
const ns = dstr.match(/-?\d*\.?\d+/g);
if (ns) for (const n of ns) { const v = Math.abs(parseFloat(n)); if (v > max) max = v; }
}
const am = [...b.matchAll(/(?:cx|cy|rx|ry|x|y|r|x1|y1|x2|y2|width|height)="([-\d.]+)"/g)];
for (const m of am) { const v = Math.abs(parseFloat(m[1])); if (v > max) max = v; }
const limit = 25 / sc * 1.15;
if (sc < 0.005 || max > limit) { bad++; if (samples.length < 6) samples.push(ic.name + ' max=' + max.toFixed(0) + ' sc=' + sc.toFixed(4)); }
}
if (bad) console.log(s.id + ': ' + bad + '/' + d.icons.length + ' 越界可疑 [' + samples.join('; ') + '] anim=' + anim);
}
+30
View File
@@ -0,0 +1,30 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const CHROME = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
const SIZE = 512;
const svg = fs.readFileSync(path.join(ROOT, 'output', 'agent-avatars-png', 'avatars', 'bottts', 'bottts-001.svg'), 'utf8');
const html = `<!DOCTYPE html><html><head><meta charset="utf-8">
<style>html,body{margin:0;padding:0;width:${SIZE}px;height:${SIZE}px;overflow:hidden;background:transparent}
.wrap{width:${SIZE}px;height:${SIZE}px;display:flex;align-items:center;justify-content:center}</style>
</head><body><div class="wrap">${svg.replace('<svg ', '<svg width="' + SIZE + '" height="' + SIZE + '" ')}</div></body></html>`;
const hf = path.join(ROOT, 'output', '.tmp-test2.html');
fs.writeFileSync(hf, html, 'utf8');
const out = path.join(ROOT, 'output', 'test-t2.png');
const p = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--hide-scrollbars',
'--default-background-color=00000000',
'--force-device-scale-factor=1',
'--window-size=' + SIZE + ',' + SIZE, '--virtual-time-budget=1000',
'--screenshot=' + out,
'file:///' + hf.replace(/\\/g, '/')
]);
p.stdout.on('data', d => process.stdout.write('[out] ' + d));
p.stderr.on('data', d => process.stdout.write('[err] ' + d));
p.on('close', code => {
console.log('exit code:', code);
console.log('png exists:', fs.existsSync(out), fs.existsSync(out) ? fs.statSync(out).size : 0);
});
+52
View File
@@ -0,0 +1,52 @@
/**
* 抽查 custom-brand-icons 与 game-icon-pack no-padding 的 SVG 内容
*/
'use strict';
const https = require('https');
function fetchRaw(url) {
return new Promise((resolve, reject) => {
const req = https.get(url, { timeout: 25000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume(); return resolve(fetchRaw(res.headers.location));
}
if (res.statusCode !== 200) { reject(new Error('HTTP ' + res.statusCode + ' ' + url)); res.resume(); return; }
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
req.on('error', reject);
});
}
(async () => {
// 1. custom-brand-icons 抽查:单色还是彩色?viewBox?
for (const f of ['cloudflare.svg', 'apple.svg', '2N.svg', 'Hydro-Québec.svg']) {
try {
const svg = await fetchRaw(`https://cdn.jsdelivr.net/gh/elax46/custom-brand-icons@main/icon-svg/${encodeURIComponent(f)}`);
const tag = svg.match(/<svg[^>]*>/);
const vm = tag && tag[0].match(/viewBox=["']([^"']+)["']/);
const fillCount = (svg.match(/fill="#[0-9a-fA-F]{3,6}"/g) || []).length;
const pathCount = (svg.match(/<path/g) || []).length;
console.log(`\ncustom-brand-icons/${f}`);
console.log(` viewBox: ${vm ? vm[1] : '无'} | 显式色 fill 数: ${fillCount} | path 数: ${pathCount}`);
console.log(` 开头 220 字符: ${svg.slice(0, 220).replace(/\n/g, ' ')}`);
} catch (e) { console.log(` ❌ ${f}: ${e.message}`); }
await new Promise(r => setTimeout(r, 300));
}
// 2. game-icon-pack no-padding 文件名是否唯一
try {
const j = JSON.parse(await fetchRaw('https://data.jsdelivr.com/v1/packages/gh/Nieobie/game-icon-pack@main'));
const names = [];
const walk = (files, prefix) => {
for (const f of files || []) {
if (f.type === 'file') names.push(f.name.toLowerCase());
else walk(f.files || [], prefix + f.name + '/');
}
};
const noPad = j.files.find(f => f.name === 'svg').files.find(d => d.name === 'no-padding');
walk(noPad.files || [], '');
const dup = names.filter((n, i) => names.indexOf(n) !== i);
console.log(`\ngame-icon-pack no-padding 文件数: ${names.length} | 重名数: ${dup.length}`, dup.length ? dup.slice(0, 10).join(',') : '');
} catch (e) { console.log('game no-padding: ❌', e.message); }
})().catch((e) => { console.error('❌', e.message); process.exit(1); });
+72
View File
@@ -0,0 +1,72 @@
/**
* 临时调研:Iconify 官方集合 vs 本地已收录,找出未收录且许可证合规的候选源
* 输出:未收录集合清单(id / 图标数 / license / 是否合规 / 收录价值初判)
*/
'use strict';
const https = require('https');
const fs = require('fs');
const path = require('path');
const ROOT = process.cwd();
const SOURCES = require(path.join(ROOT, 'js', 'data', 'icon-sources.js'));
// 本地已收录的 iconify prefix(npm: '@iconify-json/xxx' 的 xxx)
const localPrefixes = new Set();
for (const s of SOURCES) {
const m = String(s.npm || '').match(/@iconify-json\/([\w-]+)/);
if (m) localPrefixes.add(m[1]);
// 有些源 id 即 prefix
localPrefixes.add(s.id);
}
function fetch(url, redirects) {
return new Promise((resolve, reject) => {
const req = https.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const next = res.headers.location;
if (next.startsWith('/')) return resolve(fetch('https://api.iconify.design' + next, (redirects || 0) + 1));
return resolve(fetch(next, (redirects || 0) + 1));
}
if (res.statusCode !== 200) { reject(new Error('HTTP ' + res.statusCode)); res.resume(); return; }
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
req.on('error', reject);
req.setTimeout(60000, () => { req.destroy(new Error('timeout')); });
});
}
(async () => {
console.log('拉取 Iconify 官方集合列表…');
const raw = await fetch('https://api.iconify.design/collections');
const json = JSON.parse(raw);
const all = Object.entries(json.collections || json);
console.log('顶层键:', Object.keys(json).slice(0, 10).join(', '));
// 许可证过滤:排除 BY-SA / BY-NC / BY-ND / GPL / -NC-(兼容空格分隔形式)
const badRe = /by[\s-]?sa|by[\s-]?nc|by[\s-]?nd|gpl|nc-|cc[\s-]?by[\s-]?nc/i;
const missing = [];
for (const [prefix, info] of all) {
if (localPrefixes.has(prefix)) continue;
const license = info.license ? info.license.title : 'unknown';
const bad = badRe.test(license);
missing.push({ prefix, total: info.total, license, bad, author: info.author && info.author.name });
}
// 未收录 + 合规(或 license unknown 单独列出)
const good = missing.filter(m => !m.bad);
const bad = missing.filter(m => m.bad);
good.sort((a, b) => b.total - a.total);
bad.sort((a, b) => b.total - a.total);
console.log(`\n=== 官方集合总数: ${all.length} | 本地已收录 iconify prefix: ${localPrefixes.size} ===`);
console.log(`\n=== 未收录 + 许可证合规(${good.length} 个)===\n`);
for (const m of good) {
console.log(` ${m.prefix.padEnd(28)} ${String(m.total).padStart(6)} ${m.license.padEnd(28)} ${m.author || ''}`);
}
console.log(`\n=== 未收录 + 许可证不合规(${bad.length} 个,仅供参考)===\n`);
for (const m of bad.slice(0, 60)) {
console.log(` ${m.prefix.padEnd(28)} ${String(m.total).padStart(6)} ${m.license.padEnd(28)} ${m.author || ''}`);
}
})().catch((e) => { console.error('❌', e.message); process.exit(1); });
+52
View File
@@ -0,0 +1,52 @@
/**
* 校验 custom-brand 源:缺失文件对比 + 超大 SVG 抽查
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const https = require('https');
const src = fs.readFileSync('js/data/sources/custom-brand.js', 'utf8');
const sandbox = { window: {} };
vm.createContext(sandbox);
vm.runInContext(src, sandbox);
const icons = vm.runInContext("window['HGB_Source_custom-brand'].icons", sandbox);
const local = new Set(icons.map(i => i.name));
function fetchRaw(url) {
return new Promise((res, rej) => {
https.get(url, { timeout: 25000 }, r => {
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location) { r.resume(); return res(fetchRaw(r.headers.location)); }
if (r.statusCode !== 200) { rej(new Error('HTTP ' + r.statusCode)); r.resume(); return; }
const c = [];
r.on('data', x => c.push(x));
r.on('end', () => res(Buffer.concat(c).toString('utf8')));
}).on('error', rej);
});
}
(async () => {
const raw = await fetchRaw('https://data.jsdelivr.com/v1/packages/gh/elax46/custom-brand-icons@main');
const j = JSON.parse(raw);
const all = [];
const walk = (files) => { for (const f of files || []) { if (f.type === 'file') all.push(f.name.replace(/\.svg$/i, '')); else walk(f.files || []); } };
walk(j.files.find(f => f.name === 'icon-svg').files || []);
const missing = all.filter(n => !local.has(n));
console.log('缺失文件(' + missing.length + '):', missing.join(', ') || '(无)');
const big = [...icons].sort((a, b) => b.svg.length - a.svg.length).slice(0, 1)[0];
console.log('最大图标', big.name, '| 长度', big.svg.length, '| path 数', (big.svg.match(/<path/g) || []).length, '| g 数', (big.svg.match(/<g/g) || []).length);
// 含非 ASCII id
const nonAscii = icons.filter(i => /[^\x20-\x7e]/.test(i.name));
console.log('非 ASCII 文件名数:', nonAscii.length, nonAscii.slice(0, 5).map(i => i.name).join(', '));
// 含 defs/渐变 的个数(wrap 类保留原色的检查)
const defs = icons.filter(i => /<(defs|linearGradient|radialGradient|pattern)/.test(i.svg));
console.log('含 defs/渐变 图标数:', defs.length);
// fill 颜色分布
const fills = {};
for (const i of icons) {
const m = i.svg.match(/fill="#[0-9a-fA-F]{3,6}"/);
const k = m ? m[0] : 'currentColor/无';
fills[k] = (fills[k] || 0) + 1;
}
console.log('fill 分布:', JSON.stringify(fills).slice(0, 300));
})().catch(e => { console.error('❌', e.message); process.exit(1); });