331 lines
9.5 KiB
JavaScript
331 lines
9.5 KiB
JavaScript
/**
|
||
* 贴纸合成引擎(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',
|
||
}))
|
||
}
|