feat: 初始化画个标仓库 - AI驱动设计系统(BYOK模式) - 224源/371504图标+25海报源+130技能
This commit is contained in:
@@ -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`);
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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); });
|
||||
@@ -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`);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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); });
|
||||
@@ -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 + ' 个)');
|
||||
@@ -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');
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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 + ' 个重复)' : ' ✅'));
|
||||
@@ -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)');
|
||||
})();
|
||||
@@ -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');
|
||||
@@ -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 同目录');
|
||||
})();
|
||||
@@ -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/');
|
||||
@@ -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/');
|
||||
@@ -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/');
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user