/** * 贴纸合成引擎(Canvas 2D) * * 将贴纸/装饰素材按指定位置、旋转、缩放、透明度、混合模式 * 叠加到目标人物图片上,输出高清合成图。 * * 数据库对齐:asset_material_relations 表的字段 * material_type, pos_x, pos_y, opacity, rotation, * scale_x, scale_y, layer_order */ const DEFAULT_EXPORT_W = 450 const DEFAULT_EXPORT_H = 600 const CORNER_RADIUS = 32 // ---- 图片工具 ---- function getImageInfo(src) { return new Promise((resolve, reject) => { uni.getImageInfo({ src, success: resolve, fail: (e) => reject(e || new Error('读取图片失败')), }) }) } function computeCover(iw, ih, cw, ch) { const w = Number(iw) || 1 const h = Number(ih) || 1 const scale = Math.max(cw / w, ch / h) const dw = w * scale const dh = h * scale return { dx: (cw - dw) / 2, dy: (ch - dh) / 2, dw, dh } } function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) } // ---- Canvas 兼容工具 ---- function canvasDraw(ctx) { return new Promise((resolve) => { ctx.draw(false, () => setTimeout(resolve, 80)) }) } function setBlend(ctx, mode) { if (typeof ctx.setGlobalCompositeOperation === 'function') { ctx.setGlobalCompositeOperation(mode) } } function clipRoundRect(ctx, cw, ch, r) { const rr = r || Math.min(32, cw * 0.065, ch * 0.048) ctx.beginPath() ctx.moveTo(rr, 0) ctx.lineTo(cw - rr, 0) ctx.arc(cw - rr, rr, rr, -Math.PI / 2, 0, false) ctx.lineTo(cw, ch - rr) ctx.arc(cw - rr, ch - rr, rr, 0, Math.PI / 2, false) ctx.lineTo(rr, ch) ctx.arc(rr, ch - rr, rr, Math.PI / 2, Math.PI, false) ctx.lineTo(0, rr) ctx.arc(rr, rr, rr, Math.PI, Math.PI * 1.5, false) ctx.closePath() ctx.clip() } function canvasToTemp(canvasId, w, h) { return new Promise((resolve, reject) => { uni.canvasToTempFilePath({ canvasId, width: w, height: h, destWidth: w * 2, destHeight: h * 2, fileType: 'jpg', quality: 0.96, success: (res) => resolve(res.tempFilePath), fail: (e) => reject(e || new Error('导出失败')), }) }) } // ---- 阴影绘制(让贴纸看起来真实贴合) ---- function drawStickerShadow(ctx, stickerData, cw, ch) { const { pos_x = 0.5, pos_y = 0.5, scale_x = 1, scale_y = 1, rotation = 0, stickerLayout } = stickerData if (!stickerLayout) return const cx = pos_x * cw const cy = pos_y * ch const sw = stickerLayout.dw * scale_x const sh = stickerLayout.dh * scale_y const approxR = Math.max(sw, sh) * 0.55 ctx.save() ctx.translate(cx + 2, cy + 2) ctx.rotate((rotation * Math.PI) / 180) const shadowGrad = ctx.createRadialGradient ? ctx.createRadialGradient(0, 0, approxR * 0.2, 0, 0, approxR) : ctx.createCircularGradient ? ctx.createCircularGradient(0, 0, approxR) : ctx.createLinearGradient(-approxR, -approxR, approxR, approxR) if (shadowGrad.addColorStop) { shadowGrad.addColorStop(0, 'rgba(0,0,0,0.25)') shadowGrad.addColorStop(0.5, 'rgba(0,0,0,0.08)') shadowGrad.addColorStop(1, 'rgba(0,0,0,0)') } ctx.setGlobalAlpha(0.6) ctx.setFillStyle(shadowGrad || 'rgba(0,0,0,0.12)') ctx.fillRect(-approxR, -approxR, approxR * 2, approxR * 2) ctx.restore() ctx.setGlobalAlpha(1) } // ---- 贴纸绘制 ---- function drawSticker(ctx, stickerData, cw, ch) { const { pos_x = 0.5, pos_y = 0.5, scale_x = 1, scale_y = 1, rotation = 0, opacity = 1, blendMode = 'source-over', stickerLayout, } = stickerData if (!stickerLayout || !stickerLayout.path) return const cx = pos_x * cw const cy = pos_y * ch const sw = stickerLayout.dw * scale_x const sh = stickerLayout.dh * scale_y ctx.save() ctx.translate(cx, cy) ctx.rotate((rotation * Math.PI) / 180) ctx.setGlobalAlpha(clamp(opacity, 0, 1)) setBlend(ctx, blendMode) ctx.drawImage(stickerLayout.path, -sw / 2, -sh / 2, sw, sh) ctx.restore() ctx.setGlobalAlpha(1) setBlend(ctx, 'source-over') } // ---- 边缘微光(让贴纸融入场景) ---- function drawStickerEdgeGlow(ctx, stickerData, cw, ch) { const { pos_x = 0.5, pos_y = 0.5, scale_x = 1, scale_y = 1, rotation = 0, opacity = 1, stickerLayout, } = stickerData if (opacity < 0.3 || !stickerLayout) return const cx = pos_x * cw const cy = pos_y * ch const sw = stickerLayout.dw * scale_x const sh = stickerLayout.dh * scale_y const approxR = Math.max(sw, sh) * 0.55 ctx.save() ctx.translate(cx, cy) ctx.rotate((rotation * Math.PI) / 180) const glowGrad = ctx.createRadialGradient ? ctx.createRadialGradient(0, 0, approxR * 0.35, 0, 0, approxR) : ctx.createLinearGradient(-approxR, -approxR, approxR, approxR) if (glowGrad.addColorStop) { glowGrad.addColorStop(0, 'rgba(255,255,255,0)') glowGrad.addColorStop(0.7, 'rgba(255,255,255,0.06)') glowGrad.addColorStop(1, 'rgba(255,255,255,0)') } setBlend(ctx, 'soft-light') ctx.setGlobalAlpha(clamp(opacity * 0.35, 0, 0.35)) ctx.setFillStyle(glowGrad || 'rgba(255,255,255,0.04)') ctx.fillRect(-approxR, -approxR, approxR * 2, approxR * 2) ctx.restore() ctx.setGlobalAlpha(1) setBlend(ctx, 'source-over') } // ============ 公开 API ============ /** * 预计算贴纸的 cover 布局 * @param {string} stickerSrc 贴纸图片路径 * @param {number} cw 画布宽度 * @param {number} ch 画布高度 * @param {number} [targetFraction=0.22] 贴纸占画布的比例 */ export async function computeStickerLayout(stickerSrc, cw, ch, targetFraction = 0.22) { const info = await getImageInfo(stickerSrc) const targetW = cw * targetFraction const targetH = ch * targetFraction const cover = computeCover(info.width, info.height, targetW, targetH) return { ...cover, path: info.path } } /** * 在画布上绘制完整贴纸合成层 * * @param {CanvasContext} ctx uni-app Canvas 上下文 * @param {number} cw 画布宽度 * @param {number} ch 画布高度 * @param {Array} stickers 贴纸数组,每项包含: * { pos_x, pos_y, scale_x, scale_y, rotation, opacity, blendMode, stickerLayout } * @param {boolean} [drawShadow=true] 是否绘制投影 * @param {boolean} [drawGlow=true] 是否绘制边缘柔光 */ export function drawStickers(ctx, cw, ch, stickers, { drawShadow = true, drawGlow = true } = {}) { if (!stickers || !stickers.length) return const sorted = [...stickers].sort((a, b) => (a.layer_order || 0) - (b.layer_order || 0)) for (const sticker of sorted) { if (!sticker.stickerLayout || !sticker.stickerLayout.path) continue if (drawShadow) drawStickerShadow(ctx, sticker, cw, ch) drawSticker(ctx, sticker, cw, ch) if (drawGlow) drawStickerEdgeGlow(ctx, sticker, cw, ch) } } /** * 完整贴纸合成:人物底图 → 贴纸叠加 → 导出 * * @param {object} opts * @param {string} opts.baseImageSrc 人物底图路径 * @param {Array} opts.stickers 贴纸配置数组 * @param {number} [opts.exportW=450] 导出宽度 * @param {number} [opts.exportH=600] 导出高度 * @param {string} [opts.canvasId] uni-app canvas-id * @param {number} [opts.cornerRadius] 圆角半径 * @returns {Promise} 导出后的临时文件路径 */ export async function composeStickers(opts) { const { baseImageSrc, stickers = [], exportW = DEFAULT_EXPORT_W, exportH = DEFAULT_EXPORT_H, canvasId = 'stickerCompositCanvas', cornerRadius = CORNER_RADIUS, } = opts const ctx = uni.createCanvasContext(canvasId) // 1. 预加载贴纸布局 const stickerLayouts = await Promise.all( stickers.map(async (s) => { if (!s.src) return { ...s, stickerLayout: null } try { const layout = await computeStickerLayout(s.src, exportW, exportH, s.sizeFraction || 0.22) return { ...s, stickerLayout: layout } } catch (e) { console.warn('[sticker-compositor] load sticker failed:', s.src, e) return { ...s, stickerLayout: null } } }) ) // 2. 绘制底色 ctx.setFillStyle('#0a0a0a') ctx.fillRect(0, 0, exportW, exportH) // 3. 圆角裁剪 ctx.save() clipRoundRect(ctx, exportW, exportH, cornerRadius) // 4. 绘制人物底图(cover 填满) if (baseImageSrc) { const baseInfo = await getImageInfo(baseImageSrc) const cover = computeCover(baseInfo.width, baseInfo.height, exportW, exportH) ctx.drawImage(baseInfo.path, cover.dx, cover.dy, cover.dw, cover.dh) } await canvasDraw(ctx) // 5. 逐层叠加贴纸 drawStickers(ctx, exportW, exportH, stickerLayouts, { drawShadow: true, drawGlow: true }) ctx.restore() await canvasDraw(ctx) // 6. 导出高清图 const tempPath = await canvasToTemp(canvasId, exportW, exportH) return tempPath } /** * 从 asset_material_relations 记录转换为贴纸配置 * * @param {Array} relations asset_material_relations 行数组 * @returns {Array} 贴纸配置数组 */ export function relationsToStickers(relations) { if (!relations || !relations.length) return [] return relations .filter((r) => r.material_type === 'sticker' || r.material_type === 'patch') .map((r) => ({ src: r.oss_key || r.material_url || '', pos_x: r.pos_x != null ? Number(r.pos_x) : 0.5, pos_y: r.pos_y != null ? Number(r.pos_y) : 0.5, opacity: r.opacity != null ? Number(r.opacity) : 1, rotation: r.rotation != null ? Number(r.rotation) : 0, scale_x: r.scale_x != null ? Number(r.scale_x) : 1, scale_y: r.scale_y != null ? Number(r.scale_y) : 1, layer_order: r.layer_order != null ? Number(r.layer_order) : 0, blendMode: r.blend_mode || 'source-over', })) }