Files

345 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 画个标 · 核心:动态表情生成与导出(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;