/** * 画个标 · 核心:动态表情生成与导出(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} 同图 × 全部动画类型的变体数组 */ 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, '&').replace(//g, '>').replace(/"/g, '"'); } /** 单色图标上色(字符串处理,零 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 = ''; for (const id of Object.keys(grads)) { const g = grads[id]; d += (g.type === 'radial' ? `` : ``); } defs = d + ''; } let inner = ''; if (!isTransparent) inner += ``; 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 += `${colorizeIconSvg(el.svg || '', el)}`; }); return '\n' + `` + inner + ''; } /* ============ 导出 ============ */ /** * 逐帧渲染 canvas(浏览器:SVG → img → canvas) * @returns {Promise>} */ 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;