// frontend/utils/image-compositor.js // 分享图离屏 canvas 合成(spec § 5.3) import { BRAND_SLOGANS } from './brand-slogans.js'; const CANVAS_WIDTH = 750; const CANVAS_HEIGHT = 1334; const QR_SIZE = 240; const AVATAR_SIZE = 240; const COVER_HEIGHT = 1000; const QR_Y = 960; const AVATAR_Y = 720; const SLOGAN_Y = 1200; const FALLBACK_QRCODE = '/static/share/mock_qrcode.png'; const FALLBACK_AVATAR = '/static/square/gerenzhongxinkangpinkuang.png'; /** * 合成分享图(pure function — 不发起任何网络 IO) * @param {Object} opts * @param {string} opts.coverLocalPath - 本地封面临时路径(uni.downloadFile 后) * @param {string} opts.qrcodeLocalPath - 本地二维码临时路径(缺失时回退占位图) * @param {string} opts.avatarLocalPath - 本地头像临时路径(缺失时回退占位图) * @param {string} opts.nickname - 用户昵称(缺首字母时回退 'T') * @param {{brandLine: string, brandDesc: string}} [opts.slogan] - 品牌文案 * @returns {Promise<{tempFilePath: string, width: number, height: number}>} */ export function composeShareImage(opts, componentThis) { return new Promise((resolve, reject) => { const { coverLocalPath, qrcodeLocalPath = FALLBACK_QRCODE, avatarLocalPath = FALLBACK_AVATAR, nickname = '', slogan = { brandLine: BRAND_SLOGANS[0].brandLine, brandDesc: BRAND_SLOGANS[0].brandDesc }, canvasId = 'shareCanvas' } = opts; // app-plus 实测: 任何非 undefined 的 vm 参数都会触发 'reading ready' 错误。 // 唯一稳的调用方式是只传 canvasId,完全省略第二参数(uni-app 默认在当前页找 canvas) // 重要:componentThis 参数保留兼容性但完全忽略 const ctx = uni.createCanvasContext(canvasId); if (!ctx) { reject(new Error('canvas context unavailable on this platform')); return; } // L0: 白底(规避 iOS 离屏 canvas 黑底) ctx.setFillStyle('#FFFFFF'); ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // L1: 封面 if (coverLocalPath) { ctx.drawImage(coverLocalPath, 0, 0, CANVAS_WIDTH, COVER_HEIGHT); } // L2: 头像 + nickname const avatarX = 60; const avatarYCenter = AVATAR_Y + AVATAR_SIZE / 2; ctx.save(); ctx.beginPath(); ctx.arc(avatarX + AVATAR_SIZE / 2, avatarYCenter, AVATAR_SIZE / 2, 0, 2 * Math.PI); ctx.clip(); ctx.drawImage(avatarLocalPath, avatarX, AVATAR_Y, AVATAR_SIZE, AVATAR_SIZE); ctx.restore(); ctx.setFillStyle('#FFFFFF'); ctx.setFontSize(36); ctx.setTextAlign('left'); ctx.fillText(nickname || 'T', avatarX + AVATAR_SIZE + 30, avatarYCenter + 12); // L3: 二维码 const qrX = CANVAS_WIDTH - QR_SIZE - 60; ctx.drawImage(qrcodeLocalPath, qrX, QR_Y, QR_SIZE, QR_SIZE); // L4: slogan ctx.setFillStyle('#333333'); ctx.setFontSize(36); ctx.setTextAlign('center'); if (slogan.brandLine) { ctx.fillText(slogan.brandLine, CANVAS_WIDTH / 2, SLOGAN_Y + 30); } ctx.setFontSize(24); ctx.setFillStyle('#666666'); // if (slogan.brandDesc) { // ctx.fillText(slogan.brandDesc, CANVAS_WIDTH / 2, SLOGAN_Y + 70); // } ctx.draw(false, () => { uni.canvasToTempFilePath({ canvasId: canvasId, x: 0, y: 0, width: CANVAS_WIDTH, height: CANVAS_HEIGHT, destWidth: CANVAS_WIDTH, destHeight: CANVAS_HEIGHT, fileType: 'png', quality: 1, success: (res) => resolve({ tempFilePath: res.tempFilePath, width: CANVAS_WIDTH, height: CANVAS_HEIGHT }), fail: (err) => reject(new Error('canvasToTempFilePath failed: ' + (err && err.errMsg ? err.errMsg : JSON.stringify(err)))) }); }); }); } /** * 计算 composeKey(spec § 5.4) * 用于 L1/L2 缓存查询;任一字段变化则重合成 * * 入参用本地临时路径(_doc/uniapp_temp_/download/...)。 * ⚠️ 当前 trade-off:temp 路径含时间戳,每次 pick 都不同 → L1/L2 cache 实际不会命中, * 每次 pick 都会重合成。代码保留 cache 结构以备将来切回 URL-based key 时复用。 */ export function computeComposeKey(opts) { const { coverLocalPath = '', qrcodeLocalPath = '', avatarLocalPath = '', nickname = '', slogan = {} } = opts; return [coverLocalPath, qrcodeLocalPath, avatarLocalPath, nickname, slogan.brandLine || '', slogan.brandDesc || ''].join('|'); }