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

This commit is contained in:
sgp
2026-10-03 21:44:18 +08:00
commit 9e222877ea
497 changed files with 43611 additions and 0 deletions
+344
View File
@@ -0,0 +1,344 @@
/**
* 画个标 · 核心:动态表情生成与导出(Emoji/Sticker)
* 表情 = 复用 Mark 纯图形引擎(头像布局白名单)+ 动画维度(HGB_Motion)+ GIF/WebM 导出
* - 纯图形:与头像一致排除带文字布局,小尺寸清晰
* - 动画:AI 缺失时本地确定性分配(pickAnim 轮换),预览与导出同源(同一 apply)
* - 导出:GIF(HGB_GifEncoder 逐帧采样)/ WebM(canvas.captureStream + MediaRecorder)/ PNG(静态帧)
*/
const HGB_EmojiGen = (function () {
'use strict';
/** 表情可用的纯图形布局(复用头像白名单) */
const EMOJI_LAYOUTS = typeof HGB_AvatarGen !== 'undefined'
? HGB_AvatarGen.AVATAR_LAYOUTS
: ['orb', 'badge', 'burst', 'duo', 'emblem', 'fullbleed', 'concentric', 'centroid'];
/** 平台表情导出规格预设 */
const EMOJI_SIZES = [
{ id: 'wechat', name: '微信表情', size: 240, note: '微信 240' },
{ id: 'qq', name: 'QQ 表情', size: 320, note: 'QQ 320' },
{ id: 'telegram', name: 'Telegram 贴纸', size: 512, note: 'Telegram 512' }
];
/* ============ 生成 ============ */
/**
* 情绪 → 主动画决策(生成前置:AI 缺失/失败时,首张贴合用户情绪)
* 优先复用 emotion-params 技能(情绪-动效映射,与评审技能同源),未加载时走内置映射兜底
* @param {string} mood 情绪 id(happy/angry/surprised/sad/excited/calm)
* @returns {object|null} {type,speed,amplitude,layered} 或 null(无情绪)
*/
function moodAnim(mood) {
if (!mood) return null;
if (typeof HGB_StickerSkills !== 'undefined') {
try {
const r = HGB_StickerSkills.run('emotion-params', { mood });
if (r && !r.error && r.type && HGB_Motion.animTypes().some(t => t.id === r.type)) {
return { type: r.type, speed: r.speed || 1, amplitude: r.amplitude || 1, layered: true };
}
} catch (e) { /* 技能异常 → 内置映射兜底 */ }
}
const M = {
happy: { type: 'bounce', speed: 1.2, amplitude: 1, layered: true },
angry: { type: 'shake', speed: 1.4, amplitude: 1.2, layered: true },
surprised: { type: 'heartbeat', speed: 1.2, amplitude: 1.1, layered: true },
sad: { type: 'float', speed: 0.8, amplitude: 0.8, layered: true },
excited: { type: 'hopspin', speed: 1.3, amplitude: 1.1, layered: true },
calm: { type: 'breath', speed: 0.9, amplitude: 0.8, layered: true }
};
return M[String(mood).toLowerCase()] || null;
}
/**
* 基于一张静态底图展开为「同图 × 全部动画类型」的一组动画表情
* @param {object} base 静态方案(含 elements/markElements/layoutId 等)
* @param {object} opts 可选 { varParams: 是否微调各动画参数(幅度/速度)增加层次 }
* @returns {Array} 变体数组,同图、每项 anim 为不同动画类型
*/
function expandAnimations(base, opts) {
let types = HGB_Motion.animTypes();
// AI 优先:把 AI 推荐动画类型排到首位(首张即 AI 决策动效)
if (opts && opts.preferType && types.some(t => t.id === opts.preferType)) {
types = [types.find(t => t.id === opts.preferType), ...types.filter(t => t.id !== opts.preferType)];
}
return types.map((t, i) => {
const fp = (i === 0 && opts && opts.firstParams) ? opts.firstParams : {};
return Object.assign({}, base, {
id: base.id + '-' + t.id,
anim: HGB_Motion.normalize({
type: t.id,
speed: (opts && opts.varParams) ? [0.9, 1, 1.1][i % 3] : (fp.speed || 1),
amplitude: (opts && opts.varParams) ? (0.9 + (i % 2) * 0.2) : (fp.amplitude || 1),
// 交错开启元素级分层:一半变体"各元素错峰动",表情更活;AI 指定时首张遵循 AI 决策
layered: (opts && opts.layered === false) ? false : ((fp.layered != null) ? fp.layered : (i % 2 === 1)),
layerSpread: 0.3
})
});
});
}
/**
* 生成一组动态表情(核心入口):AI 编排(行业/情绪 → 布局/风格/动画决策)或降级内置池
* 先生成一张静态底图 → 自动展开全部动画类型
* @param {object} opt 同 HGB_Generator.generatePlans(industry/brand/icons/mood 等)
* @returns {Promise<Array>} 同图 × 全部动画类型的变体数组
*/
async function generateEmojiSet(opt) {
let base = null;
let ai = null;
// AI 编排:输出结构化决策 JSON;失败/无 Key 静默降级内置池
if (typeof HGB_EmojiAI !== 'undefined') {
try { ai = await HGB_EmojiAI.suggest(opt); } catch (e) { ai = null; }
}
if (ai && ai.base) {
try {
const v = HGB_AvatarGen.generateVariant({
...opt,
layoutId: ai.base.layoutId,
styleId: ai.base.styleId,
anim: ai.anim
});
if (v) {
v.concept = v.concept || ai.overallConcept;
v.aiConcept = ai.anim.concept;
v.aiMood = opt.mood;
base = v;
}
} catch (e) { base = null; }
}
if (!base) {
base = (await HGB_AvatarGen.generateAvatars({ ...opt, count: 1 }))[0];
}
// 主动画决策:AI 优先;AI 缺失/失败时按情绪匹配(生成前置),再退化为确定性轮换
const moodA = moodAnim(opt && opt.mood);
base.anim = HGB_Motion.normalize(ai && ai.anim ? ai.anim : (moodA || HGB_Motion.pickAnim(0)));
base._aiDesign = !!ai;
return expandAnimations(base, Object.assign({}, opt || {}, {
preferType: (ai && ai.anim && ai.anim.type) || (moodA && moodA.type) || null,
firstParams: (ai && ai.anim) || moodA || null
}));
}
/** 单个变体(预览弹窗微调:换动画/换配色/换布局) */
function generateVariant(opt) {
const p = HGB_AvatarGen.generateVariant({ ...opt });
return Object.assign({}, p, { anim: opt.anim || HGB_Motion.pickAnim(0) });
}
/** 表情图形元素(纯图形 Mark,避免 lockup 混入文字) */
function markElements(plan) {
return (plan.markElements && plan.markElements.length) ? plan.markElements : (plan.elements || []);
}
/* ============ 帧 SVG 构建(动画烘焙,预览/导出同源) ============ */
/** 元素包围盒(纯图形:icon 元素直接用 x/y/w/h;text 忽略,表情纯图形) */
function elementBox(el) {
if (!el || el.visible === false) return null;
return { x: el.x, y: el.y, w: el.w, h: el.h };
}
/** 全部可见元素内容包围盒 */
function contentBox(elements) {
let box = null;
for (const el of elements || []) {
const b = elementBox(el);
if (!b) continue;
if (!box) box = { x: b.x, y: b.y, w: b.w, h: b.h };
else {
const x2 = Math.max(box.x + box.w, b.x + b.w);
const y2 = Math.max(box.y + box.h, b.y + b.h);
box.x = Math.min(box.x, b.x);
box.y = Math.min(box.y, b.y);
box.w = x2 - box.x;
box.h = y2 - box.y;
}
}
return box;
}
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** 单色图标上色(字符串处理,零 DOM;多色素材 preserve 保留原样) */
function colorizeIconSvg(svg, el) {
if (el.preserve) return svg;
const fill = el.fill && el.fill !== 'none' ? el.fill : 'none';
const stroke = el.stroke && el.stroke !== 'none' ? el.stroke : 'none';
const strokeAttrs = stroke !== 'none'
? ` stroke="${esc(stroke)}" stroke-width="${esc(el.strokeWidth)}" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"`
: ' stroke="none"';
return svg.replace(/<(path|circle|rect|polygon|polyline|line|ellipse)\b/g,
(m, tag) => `<${tag} fill="${esc(fill)}"${strokeAttrs}`);
}
/**
* 构建单帧 SVG 字符串(把动画帧变换烘焙进元素)
* @param {Array} elements 元素数组(plan.elements)
* @param {string} bg 背景色('transparent' 或 hex)
* @param {number} size 输出边长
* @param {number} t 归一化帧时间(0-1)
* @param {object} anim 动画参数(HGB_Motion.normalize 结果)
* @param {object} opts { trim: 内容顶满画布; pad: 留白比例 }
* @returns {string} SVG 字符串(含 XML 声明)
*/
function buildFrameSvg(elements, bg, size, t, anim, opts) {
const W = size || 240, H = size || 240;
const isTransparent = bg === 'transparent' || bg === null || bg === undefined;
// 内容包围盒裁剪 viewBox
let vx = 0, vy = 0, vw = W, vh = H;
if (opts && opts.trim !== false) {
const box = contentBox(elements);
if (box) {
const pad = (opts && opts.pad != null ? opts.pad : 0.06) * Math.max(box.w, box.h);
vx = box.x - pad; vy = box.y - pad;
vw = box.w + pad * 2; vh = box.h + pad * 2;
if (vw < 4) { vx -= 2; vw = 4; }
if (vh < 4) { vy -= 2; vh = 4; }
}
}
// 渐变 defs
let defs = '';
const grads = {};
for (const el of elements) {
if (el && el.grad && el.grad.id && !grads[el.grad.id]) grads[el.grad.id] = el.grad;
}
if (Object.keys(grads).length) {
let d = '<defs>';
for (const id of Object.keys(grads)) {
const g = grads[id];
d += (g.type === 'radial'
? `<radialGradient id="${esc(id)}" cx="0.5" cy="0.36" r="0.78"><stop offset="0%" stop-color="${esc(g.from)}"/><stop offset="100%" stop-color="${esc(g.to)}"/></radialGradient>`
: `<linearGradient id="${esc(id)}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="${esc(g.from)}"/><stop offset="100%" stop-color="${esc(g.to)}"/></linearGradient>`);
}
defs = d + '</defs>';
}
let inner = '';
if (!isTransparent) inner += `<rect x="${vx}" y="${vy}" width="${vw}" height="${vh}" fill="${esc(bg)}"/>`;
if (defs) inner += defs;
// 元素级分层:各图标按索引错峰取时间(layered 时各自动),否则全局同拍
const iconEls = elements.filter(el => el.visible && el.type === 'icon');
iconEls.forEach((el, i) => {
const tEl = HGB_Motion.elementT(t, anim, i, iconEls.length);
const frame = HGB_Motion.apply(el, tEl, anim);
const base = `translate(${el.x} ${el.y}) scale(${(el.w || 24) / 24} ${(el.h || 24) / 24})`;
const tr = HGB_Motion.composeTransform(el, frame, base);
const opacity = frame.opacity < 1 ? ` opacity="${Math.round(frame.opacity * 1000) / 1000}"` : '';
inner += `<g transform="${tr}"${opacity}>${colorizeIconSvg(el.svg || '', el)}</g>`;
});
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="${vx} ${vy} ${vw} ${vh}">` +
inner + '</svg>';
}
/* ============ 导出 ============ */
/**
* 逐帧渲染 canvas(浏览器:SVG → img → canvas)
* @returns {Promise<Array<HTMLCanvasElement>>}
*/
async function renderFrames(plan, size, anim, opts) {
const a = HGB_Motion.normalize(anim);
const n = HGB_Motion.frameCount(a);
const bg = (opts && opts.bg) || '#FFFFFF';
const els = markElements(plan);
const frames = [];
for (let i = 0; i < n; i++) {
const t = HGB_Motion.frameT(i, a);
const svg = buildFrameSvg(els, bg, size, t, a, opts);
frames.push(await HGB_Exporter.renderCanvas(size, bg === 'transparent' ? null : bg, svg));
}
return frames;
}
/** 导出 GIF Blob */
async function exportGif(plan, size, anim, opts) {
const a = HGB_Motion.normalize(anim);
const frames = await renderFrames(plan, size, a, opts);
return HGB_GifEncoder.encodeBlob(frames, {
delay: 1000 / a.fps,
transparent: (opts && opts.bg) === 'transparent'
});
}
/** 导出 WebM(浏览器:canvas.captureStream + MediaRecorder) */
function exportWebm(plan, size, anim, opts) {
return new Promise((resolve, reject) => {
if (typeof MediaRecorder === 'undefined') {
reject(new Error('当前浏览器不支持 MediaRecorder(无法导出 WebM)'));
return;
}
const a = HGB_Motion.normalize(anim);
const bg = (opts && opts.bg) || '#FFFFFF';
const transparent = bg === 'transparent';
const els = markElements(plan);
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
const stream = canvas.captureStream(a.fps);
const mime = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']
.find(m => MediaRecorder.isTypeSupported(m)) || '';
const rec = new MediaRecorder(stream, {
mimeType: mime || undefined,
videoBitsPerSecond: Math.round(size * size * a.fps * 0.25)
});
const chunks = [];
rec.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); };
rec.onstop = () => resolve(new Blob(chunks, { type: mime || 'video/webm' }));
rec.onerror = () => reject(new Error('WebM 录制失败'));
const durationMs = a.duration * 1000;
const start = performance.now();
let raf;
const draw = (now) => {
const t = HGB_Motion.previewT(now - start, a);
const svg = buildFrameSvg(els, bg, size, t, a, opts);
const img = new Image();
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }));
img.onload = () => {
ctx.clearRect(0, 0, size, size);
if (!transparent) { ctx.fillStyle = bg; ctx.fillRect(0, 0, size, size); }
ctx.drawImage(img, 0, 0, size, size);
URL.revokeObjectURL(url);
if (performance.now() - start >= durationMs) {
cancelAnimationFrame(raf);
rec.stop();
} else {
raf = requestAnimationFrame(draw);
}
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('WebM 帧渲染失败')); };
img.src = url;
};
rec.start();
raf = requestAnimationFrame(draw);
});
}
/** 导出静态 PNG(动画初始帧 t=0) */
async function exportPng(plan, size, bg) {
const svg = buildFrameSvg(markElements(plan), bg || '#FFFFFF', size, 0, HGB_Motion.normalize(plan.anim), {});
return HGB_Exporter.renderCanvas(size, (bg || '#FFFFFF') === 'transparent' ? null : (bg || '#FFFFFF'), svg);
}
/** 当前预览帧 SVG(预览弹窗用 buildFrameSvg 可复用,此接口供下载 SVG 静态帧) */
function exportSvg(plan, size, bg, t, anim) {
return buildFrameSvg(markElements(plan), bg || '#FFFFFF', size || 240, t || 0, HGB_Motion.normalize(anim || plan.anim), {});
}
return {
EMOJI_LAYOUTS, EMOJI_SIZES,
generateEmojiSet, expandAnimations, generateVariant,
buildFrameSvg, colorizeIconSvg, contentBox, markElements,
renderFrames, exportGif, exportWebm, exportPng, exportSvg
};
})();
if (typeof module !== 'undefined' && module.exports) module.exports = HGB_EmojiGen;