商会办活动的频率不低,一年十几场。每场活动都要出海报——年会、对接会、走访、沙龙,样式还要有形有色。秘书长找设计做,一张海报几十到几百块,一年下来也是一笔开销;找会员里的设计师帮忙,欠人情。
去年我们把这件事自动化了。这篇把方案记下来:用通义万相生成底图,前端合成文字,上传到 OSS,全程不用人画图。
先说清楚边界
有一点要先说明:我们没有让模型直接生成带文字的完整海报。原因很实际——文生图模型写汉字的能力不稳定,生成的标题经常是错字或者乱码。商会的海报上,活动名称、时间、地点必须准确,错一个字就不敢往外发。
所以方案拆成两步:模型只负责生成底图(背景、氛围、配色),文字部分用前端 Canvas 合成。这样既省了设计成本,文字又完全可控。
整体架构
流程是四步。第一步,活动信息录入系统,拿到标题、时间、地点、主办方。第二步,把活动类型映射成提示词模板,调通义万相生成底图。第三步,前端用 Canvas 把底图和文字合成一张完整海报。第四步,合成结果上传 OSS,拿到链接返回给前端。
为什么要拆成"模型出底图、前端合文字"两步,而不是让模型一步出完?除了前面说的汉字准确性问题,还有一个考虑是可复用性。同一张底图,换个活动标题就能再用一次;如果让模型把文字也画进去,每换一个活动就要重新生成一次,成本和耗时都上去了。
还有一点是版式对齐。商会的海报经常要发给会员、发到群里,版式得统一。让模型自由发挥,每一张的标题位置、字号都不一样,看起来不像一个系列。用 Canvas 合成,版式是写死在代码里的,几十张海报的标题都在同一个位置。
调用通义万相
用 DashScope 的 Node SDK。文生图是异步接口,提交任务之后拿 task_id 去轮询结果。
import DashScope from 'dashscope-sdk';
const client = new DashScope({
apiKey: process.env.DASHSCOPE_KEY });
async function generatePoster(prompt) {
// 1. 提交任务
const task = await client.images.generations.create({
model: 'wanx2.1-t2i-turbo',
input: {
prompt },
parameters: {
size: '1024*1536', n: 1 }
});
const taskId = task.output.task_id;
// 2. 轮询结果,超时上限设为 60 秒
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const res = await client.tasks.get(taskId);
const status = res.output.task_status;
if (status === 'SUCCEEDED') {
return res.output.results[0].url; // 底图临时地址
}
if (status === 'FAILED') {
throw new Error('底图生成失败: ' + res.output.message);
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error('底图生成超时');
}
模型选型上我们对比过。通义万相的 turbo 版本生成速度快,两三秒出图,适合这种批量、对细节要求不高的场景;标准版出图质量更细腻,但耗时长,用在需要精修的场合。商会的活动海报对画质要求不高——发出去看的是信息,不是画质细节——所以选了 turbo 版本。
提示词模板按活动类型分开维护。年会用偏正式的红金配色,对接会用偏商务的蓝白配色,沙龙用轻松一点的浅色系。
const PROMPT_TPL = {
annual: '年会庆典海报背景,红色与金色为主,喜庆庄重,留白区域在下方三分之一,无文字',
match: '商务对接会海报背景,蓝色与白色为主,简洁现代,留白区域在下方三分之一,无文字',
salon: '轻松沙龙海报背景,浅米色与绿色为主,柔和自然,留白区域在下方三分之一,无文字'
};
function buildPrompt(type) {
return PROMPT_TPL[type] || PROMPT_TPL.salon;
}
提示词里"无文字"这三个字很关键,不加的话模型会自己往上写一堆看不懂的字符,后期不好处理。
前端 Canvas 合成文字
底图拿到之后,前端把底图画到 Canvas 上,再往上写文字。
async function compose(bgUrl, info) {
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
img.src = bgUrl;
});
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// 底部加一层半透明遮罩,保证文字可读
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(0, img.height * 0.62, img.width, img.height * 0.38);
ctx.textAlign = 'center';
ctx.fillStyle = '#ffffff';
ctx.font = `bold ${
img.width * 0.075}px "PingFang SC", "Microsoft YaHei", sans-serif`;
ctx.fillText(info.title, img.width / 2, img.height * 0.72);
ctx.font = `${
img.width * 0.04}px "PingFang SC", "Microsoft YaHei", sans-serif`;
ctx.fillText(`时间:${
info.time}`, img.width / 2, img.height * 0.8);
ctx.fillText(`地点:${
info.place}`, img.width / 2, img.height * 0.85);
ctx.fillText(info.org, img.width / 2, img.height * 0.92);
return canvas;
}
上传 OSS
合成完拿到 canvas,转成 blob 上传。上传走服务端签名,直传 OSS,不经过应用服务器。
async function uploadPoster(canvas, fileName) {
// 1. 向服务端要直传签名
const {
policy, signature, ossAccessKeyId, host, dir } =
await fetch('/api/oss/sign').then((r) => r.json());
const blob = await new Promise((res) =>
canvas.toBlob(res, 'image/png', 0.92)
);
const form = new FormData();
form.append('key', dir + fileName);
form.append('policy', policy);
form.append('OSSAccessKeyId', ossAccessKeyId);
form.append('signature', signature);
form.append('success_action_status', '200');
form.append('file', blob);
await fetch(host, {
method: 'POST', body: form });
return `${
host}/${
dir}${
fileName}`;
}
服务端签名用 STS 临时凭证,不要用主账号 AK,权限只给到指定目录的写权限。
这套方案跑了一年,跟两种替代方式对比过。一种是纯人工找设计做,一张海报几十到几百块,一年十几场活动攒下来不是小数,而且设计的档期经常赶不上活动临时改时间。另一种是第三方海报 SaaS,模板多但风格偏通用,商会的红金喜庆调性得反复调,而且数据在别人服务器上。自己搭这套方案,前期多花点开发时间,后面每次活动点一下就出图,长期看划算。
踩过的坑
第一个是跨域。Canvas 要读底图的像素,底图跨域的话 canvas 会被污染,toBlob 会报错。解决办法是给 OSS 的 Bucket 配跨域规则,并且 img 标签设 crossOrigin。
第二个是异步轮询。文生图是异步的,提交完不能直接拿结果。一开始没做超时,遇到一次任务卡住,前端一直转圈。加上超时和失败处理之后正常了。
第三个是字体。Canvas 里用的字体必须是用户机器上有的,不然会退化成默认字体,海报排版会散。中文字体统一用系统字体族兜底。
第四个是底图的有效期。通义万相返回的底图地址是临时的,合成上传要在这个地址失效前完成。做法是拿到底图后立刻合成上传,不做缓存。
第五个是生成结果不合用。模型出图有随机性,同一句提示词也可能出一张风格跑偏的图。我们的做法是一次生成两张,前端做成左右选择,秘书长挑一张继续合成。多一次请求的成本,比让秘书长重走一遍流程低。
我们这边在跑的商会管理系统叫未来漫城·商会互联平台,上面这套海报生成现在嵌在活动发布流程里,秘书长填完活动信息点一下,两三分钟出一张海报。