从 Vite 空项目到 AIGC 图片工作台:我如何打通生图、任务轮询、生成库与 Canvas 动态特效
项目地址:AIGC-Creative-Studio
很多 AIGC 项目的第一版,通常只有一个输入框:
- 用户输入 Prompt;
- 前端调用模型接口;
- 页面显示一张图片。
这个流程可以验证模型能力,但它还不能算一个完整的应用。真实项目还要面对异步任务、失败处理、接口密钥安全、图片链接过期、历史记录、文件下载,以及生成后的二次编辑。
我最近从一个能正常运行的 Vite 空项目开始,逐步完成了一个名为 AIGC Creative Studio 的图片创作工作台。它目前已经打通:
text
React 表单
→ Express 创建本地任务
→ 调用阿里云百炼万相
→ 查询外部任务状态
→ 下载并持久化图片
→ 前端自动轮询
→ 生成库管理
→ Canvas 图片编辑
→ PNG / WebM 导出
本文不只展示最终效果,也会完整复盘我为什么按这个顺序开发、其中遇到了哪些问题,以及这个项目对前端和全栈能力有什么帮助。
一、为什么要做这个项目
我本身有前端、全栈和 HarmonyOS 开发经历,也做过 Web 容器、JavaScript Bridge 和异步通信相关项目。
在完成跨端运行时项目之后,我希望再做一个更贴近当前应用方向的作品。这个项目需要满足几个条件:
- 不是只展示静态页面;
- 有真实的第三方 AI 服务;
- 有前后端通信和异步任务;
- 有文件处理与本地持久化;
- 有能体现前端深度的交互;
- 可以部署、演示,也可以继续扩展。
因此,我选择了"AIGC 图片创作工作台"。
我没有一开始就创建复杂脚手架,也没有直接生成一个庞大的项目。第一步只是:
bash
npm create vite@latest
选择:
text
React
TypeScript
ESLint
先让空项目正常启动,再一点点增加功能。这样做的好处是,每一步都知道自己加入了什么,也更容易定位问题。
二、技术栈
当前项目的主要技术栈如下:
前端
- Vite
- React
- TypeScript
- React Router
- Canvas 2D API
- MediaRecorder
- 原生 Fetch API
后端
- Node.js
- Express
- TypeScript
- 原生 Fetch API
- 本地 JSON 元数据存储
- 本地文件存储
AIGC 服务
- 阿里云百炼
- 万相文生图模型
- 异步任务接口
我没有在第一版引入 UI 组件库、Redux、数据库、Redis 和消息队列。不是因为这些技术没有价值,而是因为 MVP 阶段最重要的是先验证核心链路。
三、第一阶段:先做一个纯前端创作台
第一版页面只有三个区域。
顶部导航
展示项目名称、页面入口和后端服务状态。
左侧参数面板
包括:
- Prompt;
- Negative Prompt;
- 图片比例;
- 生成数量;
- Seed;
- 风格预设;
- 开始生成按钮。
右侧结果区域
根据任务状态展示:
- 空状态;
- 提交中;
- 等待处理;
- 生成中;
- 生成成功;
- 生成失败。
表单状态最初直接使用 React useState 管理。这个阶段不接接口,只验证页面结构、响应式布局和交互状态。
这里有一个很实际的经验:结果区域要保持稳定高度。
如果空状态、加载状态、失败状态和图片状态的高度完全不同,轮询期间整个页面会不断跳动。按钮文字从"刷新状态"变成"查询中"时,如果没有设置稳定宽度,也会造成明显抖动。
因此,我后来做了两项调整:
- 结果区域设置稳定的最小高度;
- 状态按钮设置固定宽度,并区分自动轮询状态与手动查询状态。
四、第二阶段:建立最小后端
前端页面稳定后,我在项目中增加 server 目录,使用 Express 和 TypeScript 搭建后端。
第一个接口不是生图,而是健康检查:
http
GET /api/health
响应:
json
{
"success": true,
"message": "AIGC Creative Studio API is running"
}
然后让前端导航栏显示:
text
服务检测中
服务正常
服务未连接
这个接口看起来很简单,但它验证了第一条真正的全栈链路:
text
React → Fetch → Express → JSON → React 状态
后来增加路由时,我还遇到过一个问题:
/create显示"服务未连接";/library显示"服务正常"。
原因不是后端不稳定,而是两个页面分别维护了一份健康检查状态。最终我把 Header 放到公共布局中,只保留一份应用级状态。
推荐结构如下:
text
BrowserRouter
└── AppLayout
├── Header
└── Routes
├── CreatePage
├── LibraryPage
└── EditorPage
服务状态属于整个应用,不应该分别散落在页面组件里。
五、生成任务为什么不能只返回一张图片
真实生图通常不是立即完成的。
请求提交到模型平台后,平台先返回一个任务 ID,任务状态随后经历:
text
PENDING → RUNNING → SUCCEEDED / FAILED
因此,后端不能把它设计成普通同步接口。
项目中的本地任务状态为:
ts
type GenerationStatus =
| 'pending'
| 'processing'
| 'succeeded'
| 'failed'
创建接口:
http
POST /api/generations
查询接口:
http
GET /api/generations/:taskId
前端提交参数后,后端立即创建本地任务:
json
{
"success": true,
"data": {
"taskId": "本地任务 ID",
"status": "pending"
}
}
随后由后端调用模型服务,并更新本地任务状态。
这样做的好处是,前端只需要理解自己系统的任务协议,不需要直接依赖某一家模型平台的字段。
六、Provider 抽象:不要把业务代码绑死在模型平台上
在接入真实万相接口之前,我先定义了 Provider 接口:
ts
interface ImageGenerationProvider {
readonly name: string
generate(
input: GenerateImageInput
): Promise<GenerateImageResult>
}
业务层只关心:
text
输入生成参数
→ 等待生成结果
→ 得到图片数组
至于底层使用万相、OpenAI Images、本地 Stable Diffusion,还是其他服务,由 Provider 负责。
这层抽象解决了两个问题:
- 第三方接口字段不会污染业务路由;
- 以后替换模型时,不需要重写任务和生成库。
当前使用的是 WanxImageProvider,主要负责:
- 读取环境变量;
- 创建万相异步任务;
- 查询任务状态;
- 映射图片比例;
- 处理超时和错误;
- 提取图片 URL;
- 转换为项目内部统一结果。
为了避免开发期间意外消耗额度,我还增加了安全开关:
env
ENABLE_REAL_GENERATION=false
需要真实生成时才设置:
env
ENABLE_REAL_GENERATION=true
这不是模型平台要求的字段,而是项目自己的成本保护机制。
七、接入阿里云百炼万相
测试阶段我选择了成本较低、带有新人免费额度的万相文生图模型。
后端环境变量示例:
env
PORT=3001
DASHSCOPE_API_KEY=
DASHSCOPE_MODEL=wanx2.0-t2i-turbo
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
ENABLE_REAL_GENERATION=false
真实 .env 不应该提交到 Git。
万相旧版文生图使用异步流程:
text
创建任务
→ 获得外部 task_id
→ 定时查询
→ 获取图片 URL
Provider 内部将外部状态映射为项目状态:
text
PENDING → pending
RUNNING → processing
SUCCEEDED → succeeded
FAILED → failed
前端则根据本地 taskId 自动轮询:
text
等待处理
→ 生成中
→ 生成完成
任务进入 succeeded 或 failed 后立即停止轮询。组件卸载、任务变化或重新生成时,也会清理旧定时器。
这里需要注意:轮询不是越快越好。频率过高会增加服务器和第三方接口压力,甚至触发限流。
八、失败信息必须真正展示出来
最初失败时,页面只显示:
text
生成失败,请稍后重试
这对用户和开发者都不够友好。
后来任务增加了结构化错误:
json
{
"code": "REAL_GENERATION_DISABLED",
"message": "Real image generation is disabled",
"retryable": false
}
前端失败卡片展示:
- 生成失败;
- 错误原因;
- 可选错误码;
- 重新生成按钮。
但不能把以下内容返回给用户:
- API Key;
- Authorization;
- 服务器绝对路径;
- 完整异常堆栈;
- 第三方敏感响应。
好的错误处理,不只是 catch 一下,而是要在安全与可诊断性之间取得平衡。
九、为什么必须把图片下载到本地
模型服务返回的图片通常是临时 URL。
如果生成库直接保存这个 URL,过一段时间后,历史图片就会全部失效。
因此,Provider成功后,后端立即执行:
text
读取临时 URL
→ 下载图片二进制
→ 保存到 storage/images
→ 将任务中的 URL 替换为本地地址
本地地址示例:
text
/api/images/{taskId}-0.png
任务元数据保存到:
text
server/data/generations.json
结构类似:
json
[
{
"taskId": "fced3f18-6914-48de-b5b9-23fc86d26cec",
"status": "succeeded",
"request": {
"prompt": "一只猫被一个少女抱着逛街",
"negativePrompt": "",
"aspectRatio": "1:1",
"count": 1,
"style": "anime"
},
"result": {
"images": [
{
"url": "/api/images/fced3f18-6914-48de-b5b9-23fc86d26cec-0.png"
}
]
}
}
]
服务启动时重新读取 JSON,恢复历史任务。
当前阶段使用 JSON 是为了快速完成 MVP。它不适合多实例和高并发写入,后续计划迁移 PostgreSQL;图片则可以从本地目录迁移到 OSS。
十、生成库:让"生成历史"真正有意义
最初导航栏同时出现了"生成库"和"生成历史",实际功能完全重合。后来我删除重复入口,只保留:
text
图片创作 | 生成库 | 服务状态
生成库接口:
http
GET /api/generations?status=succeeded&limit=20&offset=0
生成库展示:
- 图片;
- Prompt;
- 风格;
- 图片比例;
- 生成时间;
- 下载;
- 新窗口查看;
- 编辑;
- 删除。
图片下载没有直接依赖第三方 URL,而是通过后端下载接口:
http
GET /api/generations/:taskId/images/:imageIndex/download
后端只能读取任务中已有的图片地址,不接受用户提交任意 URL,从而降低 SSRF 风险。
删除功能同样需要同时处理两部分:
text
删除本地图片文件
+
更新任务元数据
如果一个任务的最后一张图片被删除,则一并清理任务记录。
十一、Canvas 编辑器:项目真正有辨识度的部分
单纯调用生图 API,更多体现接口集成能力。为了增加前端技术深度,我又加入 Canvas 编辑器。
路由:
text
/editor/:taskId/:imageIndex
编辑器会:
- 根据任务 ID 查询任务;
- 找到指定图片;
- 加载本地图片;
- 绘制到原始分辨率的 Canvas;
- 在页面中按容器尺寸缩放显示。
页面看到的是缩放后的 Canvas,但导出时仍使用原始分辨率,避免把页面显示尺寸误当成图片输出尺寸。
十二、黑白滤镜与灰度渐变
黑白滤镜不是简单使用 CSS:
css
filter: grayscale(1);
而是真正处理 Canvas 像素。
灰度值计算:
text
gray = 0.299 × red
+ 0.587 × green
+ 0.114 × blue
强度混合:
text
result = original × (1 - intensity)
+ gray × intensity
每次调整都从原始 ImageData 重新计算,不能基于上一次处理结果继续计算,否则来回拖动后会累计失真。
在此基础上,我又实现了"灰度到彩色"的横向渐变:
- 左侧黑白;
- 右侧彩色;
- 中间平滑过渡;
- 可以拖动分界位置;
- 可以调节过渡宽度。
过渡使用 smoothstep:
text
t = clamp((x - start) / (end - start), 0, 1)
smooth = t × t × (3 - 2 × t)
最后使用 smooth 混合彩色与灰度值。
十三、动态雨滴与"雨滴唤醒色彩"
普通静态雨丝可以通过 Canvas 绘制半透明线段完成,但项目中更有辨识度的效果是:
text
整张图片变灰
→ 用户设置雨滴落点
→ 雨滴从顶部落下
→ 落点出现涟漪
→ 涟漪覆盖区域恢复原图色彩
这个效果使用两份离屏画布:
text
colorCanvas:彩色原图
grayscaleCanvas:灰度图片
每一帧:
- 主画布绘制灰度底图;
- 绘制下落雨滴;
- 雨滴到达目标点后进入涟漪阶段;
- 使用圆形裁剪区域绘制彩色图层;
- 绘制逐渐扩散并淡出的涟漪圆环。
伪代码如下:
ts
ctx.drawImage(grayscaleCanvas, 0, 0)
ctx.save()
ctx.beginPath()
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)
ctx.clip()
ctx.drawImage(colorCanvas, 0, 0)
ctx.restore()
动画使用 requestAnimationFrame,并通过 deltaTime 更新位置,避免不同刷新率下动画速度不一致。
此外还需要处理:
- 切换工具时取消动画;
- 组件卸载时取消动画;
- 页面不可见时暂停;
- React StrictMode 下避免双动画循环;
- 动画数据放在
useRef,不在每一帧触发 React 渲染。
十四、PNG 与 WebM 导出
Canvas 当前画面可以通过:
ts
canvas.toBlob()
导出 PNG。
但 PNG 只能保存静态瞬间。为了保存完整的雨滴与涟漪动画,项目又使用:
ts
canvas.captureStream(30)
配合浏览器原生 MediaRecorder 导出 WebM。
大致流程:
text
重置动画
→ captureStream 获取 Canvas 视频流
→ MediaRecorder 开始录制
→ 播放雨滴与涟漪动画
→ 动画完成后保留最终画面
→ 停止录制
→ 合并 Blob
→ 下载 WebM
格式选择需要逐级检测:
text
video/webm;codecs=vp9
video/webm;codecs=vp8
video/webm
不能默认所有浏览器都支持 VP9。
录制结束后,还必须清理:
- MediaStream Track;
- MediaRecorder;
- Blob URL;
- 超时定时器;
- requestAnimationFrame。
十五、编辑后的图片如何回到生成库
Canvas编辑完成后,除了本地下载,还可以保存回生成库。
前端通过 canvas.toBlob() 得到 PNG,再发送:
http
POST /api/generations/:taskId/images/:imageIndex/edits
Content-Type: image/png
后端使用路由级 express.raw() 接收二进制,校验:
- Content-Type;
- 文件大小;
- PNG 文件头;
- 原任务;
- 原图片索引;
- 存储路径。
编辑后的图片不会覆盖原图,而是作为新资产追加:
text
AI 生成图
└── 编辑作品
生成库中可以继续查看、下载和再次编辑。
至此,项目形成完整闭环:
text
生成
→ 保存
→ 浏览
→ 编辑
→ 导出
→ 再保存
→ 删除
十六、开发中遇到的几个典型问题
1. .env 明明存在,却读取不到
我曾经执行:
powershell
node -e "require('dotenv').config(); console.log(process.env.ENABLE_REAL_GENERATION)"
结果是:
text
undefined
最终发现配置没有正确写入真实的 server/.env,或者文件名实际是 .env.txt。
排查环境变量时,不要直接打印 API Key,可以只判断是否存在:
js
Boolean(process.env.DASHSCOPE_API_KEY)
2. 健康检查正常,生图却提示无法连接
增加 React Router 后,如果请求写成:
ts
fetch('api/generations')
在 /create 页面可能被解析为:
text
/create/api/generations
因此我统一封装了 API Base URL:
env
VITE_API_BASE_URL=http://localhost:3001
所有健康检查、任务创建、轮询、图片地址和下载地址都通过同一个方法拼接。
3. 图片能在 Network 看到,却不能在页面下载
跨域图片使用 <a download> 时,浏览器不一定直接下载。
因此增加后端下载代理,只下载任务中已经保存的本地图片,再设置:
http
Content-Disposition: attachment
4. Canvas 跨域污染
如果图片跨域加载且响应头不正确,调用 toBlob() 或 getImageData() 时可能出现:
text
SecurityError: canvas has been tainted
因此图片加载需要正确的 CORS 配置,并在设置 src 之前配置:
ts
image.crossOrigin = 'anonymous'
十七、如何运行项目
克隆:
bash
git clone https://github.com/lichenyang5/AIGC-Creative-Studio.git
cd AIGC-Creative-Studio
安装前端依赖:
bash
npm install
安装后端依赖:
bash
cd server
npm install
根据 .env.example 创建:
text
server/.env
示例:
env
PORT=3001
DASHSCOPE_API_KEY=你的APIKey
DASHSCOPE_MODEL=wanx2.0-t2i-turbo
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
ENABLE_REAL_GENERATION=true
不要将真实 .env 提交到 GitHub。
启动后端:
bash
cd server
npm run dev
启动前端:
bash
npm run dev
打开:
text
http://localhost:5173
如果只想调试前后端链路、不调用真实模型:
env
ENABLE_REAL_GENERATION=false
十八、为什么暂时没有数据库
项目目前使用:
text
任务数据:generations.json
图片文件:storage/images
我原本计划使用:
text
PostgreSQL + Prisma + Docker
但在受限网络环境中,Docker无法正常拉取 PostgreSQL 镜像。因此我没有为了"看起来技术栈更多"而强行改造,而是先保留可运行的 JSON Repository。
后续网络条件允许时,计划迁移:
text
JSON → PostgreSQL
本地图片 → OSS
数据库只保存任务和图片元数据,不直接存储大图片二进制。
这个取舍也让我更加明确:项目开发不是技术名词收集,而是根据当前目标、环境和成本做选择。
十九、这个项目体现了哪些能力
从求职角度看,这个项目的价值不只在"AIGC"三个字。
前端能力
- React组件拆分;
- TypeScript类型设计;
- React Router公共布局;
- 表单和异步状态管理;
- 自动轮询和生命周期清理;
- 响应式工作台;
- Canvas像素处理;
- Pointer Events;
- requestAnimationFrame;
- MediaRecorder;
- 文件导出和下载;
- 错误、空状态和加载状态设计。
后端能力
- Express接口设计;
- 参数校验;
- 异步任务建模;
- Provider抽象;
- 第三方 API 集成;
- 错误码转换;
- 文件下载和持久化;
- 二进制上传;
- 静态资源服务;
- 路径安全和 SSRF 防护;
- JSON Repository。
工程能力
- 环境变量管理;
- API Key安全;
- 成本开关;
- 前后端统一协议;
- 本地任务与第三方任务解耦;
- 渐进式开发;
- Git阶段提交;
- 为数据库和对象存储预留迁移空间。
二十、下一步计划
项目核心功能已经打通,后续重点不会继续无限堆滤镜,而是提高工程完整度:
- 使用 Vitest + Supertest 增加后端接口测试;
- 增加前端核心组件测试;
- 完善 README、架构图和截图;
- 增加演示视频;
- 在合适环境下迁移 PostgreSQL;
- 将图片存储迁移到 OSS;
- 增加用户和权限体系;
- 部署前端和后端;
- 对生成频率和成本增加限流。
结语
这个项目最重要的收获,不是成功调用了一次生图 API。
真正有价值的是把一个模型调用,逐步变成一个完整的软件流程:
text
输入
→ 校验
→ 创建任务
→ 异步处理
→ 状态同步
→ 文件持久化
→ 历史管理
→ 图片编辑
→ 动态导出
如果只看最终页面,很多功能似乎理所当然。但把它们逐个实现后,会发现 AIGC 应用本质上仍然离不开传统的软件工程能力。
模型负责生成内容,应用负责让这项能力真正可用。
如果你也准备做一个 AIGC 项目,我的建议是:不要第一天就设计数据库、队列、登录和复杂架构。先从一个可运行的页面开始,完成最小链路,然后每次只解决一个真实问题。
当每一步都能运行、验证和提交时,最后得到的不只是一个 Demo,而是一个自己真正理解的项目。
参考资料
- 项目源码:AIGC-Creative-Studio
- 阿里云百炼万相文生图 API:万相文生图 V2 API 参考
- 阿里云百炼模型价格:模型调用价格