topfans/frontend/utils/image-compositor.js

138 lines
5.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.

// 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 = 1050; // 二维码下移到画布中下部(再下移 30),留出底部呼吸空间
const QR_BORDER = 20; // 二维码图片自带的白色边框(quiet zone),文字对齐时跳过这个边距,与内部二维码对齐
const AVATAR_Y = 720;
const FALLBACK_QRCODE = '/static/share/mock_qrcode.png';
const FALLBACK_AVATAR = '/static/square/gerenzhongxinkangpinkuang.png';
/**
* 截断链上哈希(前 6 位 + ... + 后 6 位)
* - 空值返回空串
* - 长度 <= 12 原样返回(无需省略号)
* - 长度 > 12 显示前 6 + ... + 后 6
*/
function truncateHash(hash) {
if (!hash) return '';
const s = String(hash);
if (s.length <= 10) return s;
return `${s.substring(0, 5)}...${s.substring(s.length - 5)}`;
}
/**
* 合成分享图(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] - 品牌文案
* @param {string} [opts.displayTxHash] - 链上哈希原文(父组件提供),截断后渲染到二维码侧空白区
* @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 },
displayTxHash = '',
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: 二维码(右侧边距 30,略往右收,让画面重心向右偏)
const qrX = CANVAS_WIDTH - QR_SIZE - 30;
ctx.drawImage(qrcodeLocalPath, qrX, QR_Y, QR_SIZE, QR_SIZE);
// L4: 链上哈希 — 顶部对齐内部二维码(跳过白框 QR_BORDER)
// 文字顶部贴 QR_Y + QR_BORDER,baseline = QR_Y + QR_BORDER + 20(ascent for 26px)
const hashText = truncateHash(displayTxHash);
if (hashText) {
ctx.setFillStyle('#333333');
ctx.setFontSize(40);
ctx.setTextAlign('left');
ctx.fillText(`链上哈希: ${hashText}`, 40, QR_Y + QR_BORDER + 40);
}
// L5: 品牌文案 — 底部对齐内部二维码(跳过白框 QR_BORDER)
// 文字底部贴 QR_Y + QR_SIZE - QR_BORDER,baseline = QR_Y + QR_SIZE - QR_BORDER - 8(descent for 30px)
ctx.setFillStyle('#333333');
ctx.setFontSize(30);
ctx.setTextAlign('left');
if (slogan.brandLine) {
ctx.fillText(slogan.brandLine, 40, QR_Y + QR_SIZE - QR_BORDER - 25);
}
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 缓存查询;任一字段变化则重合成
*
* 入参 cover/qrcode/avatar 本地路径来自 useShare.js#downloadLocal
* - /static/ 前缀 → copyStaticToDoc 落 _doc/<static 原文件名>(无时间戳)
* - 远端 URL → 失败兜底,原样返回(仍可作为 cache key
* 拼接 7 个字段3 路径 + 1 昵称 + 2 slogan + 1 txHash任一变化触发重合成。
*/
export function computeComposeKey(opts) {
const { coverLocalPath = '', qrcodeLocalPath = '', avatarLocalPath = '', nickname = '', slogan = {}, displayTxHash = '' } = opts;
return [coverLocalPath, qrcodeLocalPath, avatarLocalPath, nickname, slogan.brandLine || '', slogan.brandDesc || '', displayTxHash].join('|');
}