topfans/frontend/utils/sticker-compositor.js
2026-05-16 02:42:32 +08:00

331 lines
9.5 KiB
JavaScript
Raw 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.

/**
* 贴纸合成引擎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<string>} 导出后的临时文件路径
*/
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',
}))
}