/** * 画个标 · UI:动态表情工作台(独立模块,不加载共享 panels.js) * 职责:行业/动画类型/图标配置、表情方案批量生成、方案库动画预览、 * 预览弹窗(换动画/换配色/换布局)、GIF/WebM/PNG 导出、内置库选择 * 动画预览与导出同源:卡片/预览 rAF 驱动 HGB_Motion.apply,导出逐帧采样同一 apply */ const HGB_EmojiUI = (function () { 'use strict'; /* ================= 工具 ================= */ function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function svgInline(svgStr) { const div = document.createElement('div'); div.innerHTML = svgStr; const svg = div.querySelector('svg'); return svg ? svg.outerHTML : ''; } let toastTimer = null; function toast(msg) { const el = document.getElementById('toast'); el.textContent = msg; el.classList.remove('hidden'); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.add('hidden'), 2600); } function download(blob, filename) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 6000); } function safeName(plan) { return (String(plan.name || '表情').replace(/[\\/:*?"<>|]/g, '') || '表情'); } /* ================= 状态 ================= */ const state = { industry: null, mood: 'happy', // 情绪(影响动画动效;与 emotion-params 技能同源) icons: [], // 已选图标 [{id,name,svg,source}] plans: [], // 同图 × 全部动画类型的一组表情 activePlan: null, genParam: null }; // 情绪 → 动画决策选项(与 emotion-params 技能映射一致,AI 与内置引擎共用) const MOODS = [ { id: 'happy', emoji: '😄', name: '开心' }, { id: 'angry', emoji: '😠', name: '生气' }, { id: 'surprised', emoji: '😲', name: '惊讶' }, { id: 'sad', emoji: '😢', name: '难过' }, { id: 'excited', emoji: '🤩', name: '兴奋' }, { id: 'calm', emoji: '😌', name: '平静' } ]; // 情绪 → emotion-fit 评审关键词(中文) const MOOD_KEYWORD = { happy: '开心', angry: '生气', surprised: '惊讶', sad: '难过', excited: '兴奋', calm: '平静' }; // 动画预览节点(rAF 驱动) let animNodes = []; let rafId = null; /* ================= 动画循环 ================= */ function collectNodes(svgEl, plan) { const els = HGB_EmojiGen.markElements(plan).filter(e => e.visible && e.type === 'icon'); const gs = Array.from(svgEl.children).filter(c => c.tagName === 'g'); return gs.slice(0, els.length).map((g, i) => ({ g, el: els[i], i, base: g.getAttribute('transform') || '' })); } function registerAnim(svgEl, plan) { animNodes.push({ svg: svgEl, plan, nodes: collectNodes(svgEl, plan) }); } function tick(now) { animNodes = animNodes.filter(an => an.svg && an.svg.isConnected); for (const an of animNodes) { const t = HGB_Motion.previewT(now, an.plan.anim); for (const nd of an.nodes) { if (!nd.el) continue; // 分层动画:各元素按索引错峰取时间,与导出帧同源 const tEl = HGB_Motion.elementT(t, an.plan.anim, nd.i, an.nodes.length); const f = HGB_Motion.apply(nd.el, tEl, an.plan.anim); nd.g.setAttribute('transform', HGB_Motion.composeTransform(nd.el, f, nd.base)); if (f.opacity < 1) nd.g.setAttribute('opacity', String(Math.round(f.opacity * 1000) / 1000)); else nd.g.removeAttribute('opacity'); } } rafId = requestAnimationFrame(tick); } function startLoop() { if (!rafId) rafId = requestAnimationFrame(tick); } /* ================= 渲染:行业 ================= */ function renderIndustries() { const wrap = document.getElementById('design-industry'); wrap.innerHTML = HGB_Industries.map(ind => ` `).join(''); wrap.querySelectorAll('.industry-tab').forEach(btn => { btn.addEventListener('click', () => { state.industry = HGB_Industries.find(btn.dataset.industry); state.icons = []; state.plans = []; state.activePlan = null; renderIndustries(); renderIcons(); renderPlans(); toast('🏭 已切换到「' + state.industry.name + '」行业'); }); }); } /* ================= 渲染:情绪 ================= */ function renderMood() { const wrap = document.getElementById('design-mood'); wrap.innerHTML = MOODS.map(m => ` `).join(''); wrap.querySelectorAll('[data-mood]').forEach(btn => { btn.addEventListener('click', () => { state.mood = btn.dataset.mood; renderMood(); toast('😊 情绪已设为「' + MOODS.find(m => m.id === state.mood).name + '」'); }); }); } /* ================= 渲染:选中图标 ================= */ function renderIcons() { const wrap = document.getElementById('design-icons'); if (!state.icons.length) { wrap.innerHTML = '

未选择图标(AI 自动从行业与内置库选用)

'; return; } wrap.innerHTML = state.icons.map((ic, i) => `
${svgInline(`${ic.svg}`)}
`).join(''); wrap.querySelectorAll('.icon-del').forEach(btn => { btn.addEventListener('click', () => { state.icons.splice(Number(btn.dataset.del), 1); renderIcons(); }); }); } /* ================= 生成 ================= */ async function generate() { const genBtn = document.getElementById('btn-generate'); genBtn.disabled = true; const old = genBtn.textContent; genBtn.textContent = '⏳ 生成中…'; try { const opt = { industry: state.industry, brand: '表情', // 纯图形无文字,品牌名不影响 sub: '', desc: '', mood: state.mood, // 情绪 → AI 编排/降级均按情绪匹配主动画 icons: state.icons.map(i => ({ id: i.id, name: i.name, svg: i.svg })), styleIds: [], skills: [], count: 1 // 一张底图 → 展开为全部动画类型 }; const plans = await HGB_EmojiGen.generateEmojiSet(opt); state.plans = plans; state.activePlan = plans[0] || null; state.genParam = opt; renderPlans(); toast('✅ 已生成表情包:同图 × ' + plans.length + ' 种动画'); } catch (err) { toast('❌ 生成失败:' + err.message); } finally { genBtn.disabled = false; genBtn.textContent = old; } } /* ================= 渲染:方案库卡片 ================= */ function animLabel(anim) { const t = HGB_Motion.animTypes().find(x => x.id === (anim && anim.type)); return t ? t.emoji + ' ' + t.name : '动画'; } function renderPlans() { const grid = document.getElementById('plans-grid'); const empty = document.getElementById('plans-empty'); const aiTag = state.plans.length && state.plans[0]._aiDesign ? ' · 🤖 AI 设计' : ''; document.getElementById('plans-title').textContent = `😀 表情包(共 ${state.plans.length} 张 · 同图 × 全动画)${aiTag}`; empty.classList.add('hidden'); if (!state.plans.length) { empty.classList.remove('hidden'); grid.innerHTML = ''; animNodes = []; return; } grid.innerHTML = state.plans.map(p => { const markSvg = HGB_Exporter.buildSvgFrom(HGB_EmojiGen.markElements(p), p.bg, null, { trim: true }); const active = state.activePlan && state.activePlan.id === p.id; return `
${svgInline(markSvg)}
${animLabel(p.anim)}
${p.concept ? `
${esc(p.name)} · ${esc(p.concept)}
` : ''}
🏷 ${esc(p.layoutName)} 🎨 ${esc(p.paletteName)}
`; }).join(''); animNodes = []; grid.querySelectorAll('.plan-card').forEach(card => { const plan = state.plans.find(p => p.id === card.dataset.plan); if (!plan) return; const svgEl = card.querySelector('.plan-preview-emoji svg'); if (svgEl) registerAnim(svgEl, plan); card.querySelectorAll('button').forEach(btn => { btn.addEventListener('click', async () => { const act = btn.dataset.pact; if (act === 'preview') { openPreview(plan); } else if (act === 'png') { state.activePlan = plan; renderPlans(); try { const canvas = await HGB_EmojiGen.exportPng(plan, 512, plan.bg); canvas.toBlob(b => { if (b) download(b, safeName(plan) + '_512px.png'); }); toast('✅ 已导出静态 PNG(512px)'); } catch (err) { toast('❌ 导出失败:' + err.message); } } }); }); }); startLoop(); } /* ================= 预览弹窗 ================= */ function openPreview(plan) { state.activePlan = plan; renderPlans(); renderPreview(); document.getElementById('plan-modal').classList.remove('hidden'); } function closePreview() { document.getElementById('plan-modal').classList.add('hidden'); } function renderPreview() { const p = state.activePlan; if (!p) return; const wrap = document.getElementById('plan-mark'); wrap.innerHTML = ''; const markSvg = HGB_Exporter.buildSvgFrom(HGB_EmojiGen.markElements(p), p.bg, null, { trim: true }); wrap.innerHTML = svgInline(markSvg); const svgEl = wrap.querySelector('svg'); if (svgEl) { animNodes = animNodes.filter(an => an.svg !== svgEl && an.svg.isConnected); registerAnim(svgEl, p); startLoop(); } document.getElementById('plan-modal-sub').textContent = `${p.layoutName} · ${p.paletteName} · ${animLabel(p.anim)}`; document.getElementById('plan-concept').innerHTML = p.concept ? `
💡 设计理念:${esc(p.concept)}
` : ''; // 动画类型切换(预览内) const aw = document.getElementById('plan-anim'); aw.innerHTML = HGB_Motion.animTypes().map(t => ` `).join(''); aw.querySelectorAll('.industry-tab').forEach(btn => { btn.addEventListener('click', () => { p.anim = HGB_Motion.normalize({ type: btn.dataset.panim, speed: p.anim.speed, amplitude: p.anim.amplitude }); renderPreview(); renderPlans(); }); }); // 技能评审提示(表情专属技能) renderStickerAudit(p); } function renderStickerAudit(p) { const el = document.getElementById('plan-audit'); // 通用入参:表情技能按需取字段(追加 motion 类技能即自动生效) const els = HGB_EmojiGen.markElements(p).filter(e => e.visible && e.type === 'icon'); const common = { duration: p.anim.duration, fps: p.anim.fps, type: p.anim.type, amplitude: p.anim.amplitude, speed: p.anim.speed, layered: p.anim.layered, layerSpread: p.anim.layerSpread, elementCount: els.length, size: 240, platform: 'wechat', styleId: p.styleId, mood: state.mood, keyword: MOOD_KEYWORD[state.mood] || '' // 情绪评审(emotion-fit)生效 }; const items = HGB_StickerSkills.all() .filter(s => s.category === 'motion') // 评审类技能全跑 .map(s => { let r; try { r = HGB_StickerSkills.run(s.id, common); } catch (e) { r = { error: e.message }; } return { name: s.name, ok: !r.error && r.ok !== false, detail: r.error ? r.error : (r.tip || '') }; }); el.innerHTML = `
🔬 动画技能检查 (表情专属 · ${items.filter(i => i.ok).length}/${items.length} 项通过)
${items.map(it => `
${it.ok ? '✓' : '⚠'} ${it.name} ${esc(it.detail)}
`).join('')}
`; } /* ================= 变体微调 ================= */ function reVariant(opts) { const p = state.activePlan; if (!p) return; const opt = { ...(state.genParam || {}), layoutId: p.layoutId, styleId: p.styleId, anim: p.anim, ...opts }; const v = HGB_EmojiGen.generateVariant(opt); // 整组以新底图重新展开,保持「同图 × 全动画」一致性 state.plans = HGB_EmojiGen.expandAnimations(v); state.activePlan = state.plans[0] || null; renderPlans(); renderPreview(); } function variantColor() { // 换配色:轮换到下一套风格(配色/背景跟随风格) const styles = HGB_Styles.slice(); const cur = styles.findIndex(s => s.id === state.activePlan.styleId); const next = styles[(cur + 1) % styles.length]; reVariant({ styleId: next.id }); toast('🎨 已换底图配色/风格'); } function variantLayout() { const layouts = HGB_EmojiGen.EMOJI_LAYOUTS; const cur = layouts.indexOf(state.activePlan.layoutId); const next = layouts[(cur + 1) % layouts.length]; reVariant({ layoutId: next }); toast('🅰 已换底图布局'); } /* ================= 换一张底图 ================= */ function regenerate() { if (!state.activePlan) { generate(); return; } // 随机组合风格 + 布局(保留用户图标),保证换出的底图与当前不同 const styles = HGB_Styles.slice(); const style = styles[Math.floor(Math.random() * styles.length)]; const layouts = HGB_EmojiGen.EMOJI_LAYOUTS; const layout = layouts[Math.floor(Math.random() * layouts.length)]; reVariant({ styleId: style.id, layoutId: layout }); toast('🎲 已换一张底图(' + style.name + ' · ' + layout + ')'); } /* ================= 导出 ================= */ function exportOpts() { const size = Number(document.querySelector('input[name="em-size"]:checked').value); const fps = Number(document.getElementById('em-fps').value); const bg = document.querySelector('input[name="em-bg"]:checked').value; const a = HGB_Motion.normalize({ ...state.activePlan.anim, fps }); return { size, fps, bg, anim: a }; } async function exportGif() { const p = state.activePlan; if (!p) return; const { size, bg, anim } = exportOpts(); toast('⏳ 正在生成 GIF(' + HGB_Motion.frameCount(anim) + ' 帧)…'); try { const blob = await HGB_EmojiGen.exportGif(p, size, anim, { bg }); download(blob, safeName(p) + '_' + anim.type + '_' + size + 'px.gif'); toast('✅ GIF 已导出(' + (blob.size / 1024).toFixed(0) + 'KB)'); } catch (err) { toast('❌ GIF 导出失败:' + err.message); } } async function exportWebm() { const p = state.activePlan; if (!p) return; const { size, bg, anim } = exportOpts(); toast('⏳ 正在录制 WebM…'); try { const blob = await HGB_EmojiGen.exportWebm(p, size, anim, { bg }); download(blob, safeName(p) + '_' + anim.type + '_' + size + 'px.webm'); toast('✅ WebM 已导出'); } catch (err) { toast('❌ WebM 导出失败:' + err.message); } } async function exportPng() { const p = state.activePlan; if (!p) return; const { size, bg } = exportOpts(); try { const canvas = await HGB_EmojiGen.exportPng(p, size, bg); canvas.toBlob(b => { if (b) download(b, safeName(p) + '_' + size + 'px.png'); }); toast('✅ 静态 PNG 已导出(' + size + 'px)'); } catch (err) { toast('❌ PNG 导出失败:' + err.message); } } /* ================= 内置库选择(简化版) ================= */ let builtinList = [], builtinOffset = 0, builtinQuery = ''; async function openBuiltin() { builtinQuery = ''; document.getElementById('builtin-search').value = ''; document.getElementById('builtin-modal').classList.remove('hidden'); // 预载表情主力单色源,保证搜索可用 const core = (typeof HGB_AvatarGen !== 'undefined' && HGB_AvatarGen.AVATAR_CORE_SOURCES) || ['lucide', 'tabler', 'feather', 'mdi', 'fluent', 'solar']; await HGB_IconLib.preload(core); await searchBuiltin(); } async function searchBuiltin(append) { const kw = builtinQuery.toLowerCase(); let hits; if (kw) { hits = HGB_IconLib.searchLoaded(kw, 60); if (hits.length < 30) { const idx = HGB_IconLib.searchIndex(kw, 60); for (const it of idx) { if (!HGB_IconLib.loaded(it.source)) { await HGB_IconLib.loadSource(it.source).catch(() => null); } } hits = HGB_IconLib.searchLoaded(kw, 60); } } else { // 无关键词:遍历已加载源前若干 hits = []; for (const sid of HGB_IconLib.loadedIds()) { for (const ic of HGB_IconLib.getSource(sid).icons || []) { hits.push({ id: sid + ':' + ic.name, name: ic.name, svg: ic.svg, source: sid, type: ic.type }); if (hits.length >= 200) break; } if (hits.length >= 200) break; } } builtinList = hits; builtinOffset = append ? builtinOffset : 0; renderBuiltinGrid(); } function renderBuiltinGrid() { const grid = document.getElementById('builtin-grid'); const page = builtinList.slice(builtinOffset, builtinOffset + 30); const selIds = new Set(state.icons.map(i => i.id)); if (builtinOffset === 0) grid.innerHTML = ''; grid.insertAdjacentHTML('beforeend', page.map(ic => `
${svgInline(`${ic.svg}`)} ${esc(ic.name)}
`).join('')); builtinOffset += page.length; document.getElementById('builtin-count').textContent = `已选 ${state.icons.length} / 最多 5`; document.getElementById('builtin-load-more').classList.toggle('hidden', builtinOffset >= builtinList.length); grid.querySelectorAll('.gh-item').forEach(item => { item.addEventListener('click', () => { const ic = builtinList.find(x => x.id === item.dataset.icid); if (!ic) return; if (state.icons.some(i => i.id === ic.id)) { state.icons = state.icons.filter(i => i.id !== ic.id); } else { if (state.icons.length >= 5) { toast('⚠ 最多选择 5 个图标'); return; } state.icons.push({ id: ic.id, name: ic.name, svg: ic.svg, source: ic.source }); } renderBuiltinGrid(); renderIcons(); }); }); } /* ================= 事件绑定 ================= */ function bindAll() { document.getElementById('btn-generate').addEventListener('click', generate); const regen = document.getElementById('btn-regenerate'); if (regen) regen.addEventListener('click', regenerate); document.getElementById('btn-builtin-icons').addEventListener('click', openBuiltin); document.getElementById('builtin-search').addEventListener('input', (e) => { builtinQuery = e.target.value.trim(); searchBuiltin(); }); document.getElementById('builtin-load-more').addEventListener('click', () => searchBuiltin(true)); document.querySelectorAll('#builtin-modal [data-builtin-close]').forEach(b => { b.addEventListener('click', () => document.getElementById('builtin-modal').classList.add('hidden')); }); document.getElementById('builtin-modal').addEventListener('click', (e) => { if (e.target === e.currentTarget) e.currentTarget.classList.add('hidden'); }); // 预览弹窗 document.querySelectorAll('#plan-modal [data-plan-close]').forEach(b => { b.addEventListener('click', closePreview); }); document.getElementById('plan-modal').addEventListener('click', (e) => { if (e.target === e.currentTarget) closePreview(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !document.getElementById('plan-modal').classList.contains('hidden')) closePreview(); }); document.getElementById('btn-var-color').addEventListener('click', variantColor); document.getElementById('btn-var-layout').addEventListener('click', variantLayout); document.getElementById('btn-export-gif').addEventListener('click', exportGif); document.getElementById('btn-export-webm').addEventListener('click', exportWebm); document.getElementById('btn-export-png').addEventListener('click', exportPng); // 项目名持久化 const nameInput = document.getElementById('project-name'); if (nameInput) { nameInput.addEventListener('change', () => { HGB_Store.get().name = nameInput.value || '我的品牌'; HGB_Store.persistToLocal(); }); } } /* ================= 启动 ================= */ function init() { state.industry = HGB_Industries.current(); bindAll(); renderIndustries(); renderMood(); renderIcons(); renderPlans(); startLoop(); } function boot() { HGB_Store.init(true); init(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); return { init }; })();