从 Vite 空项目到 AIGC 图片工作台:我如何打通生图、任务轮询、生成库与 Canvas 动态特效

从 Vite 空项目到 AIGC 图片工作台:我如何打通生图、任务轮询、生成库与 Canvas 动态特效

项目地址:AIGC-Creative-Studio

很多 AIGC 项目的第一版,通常只有一个输入框:

  1. 用户输入 Prompt;
  2. 前端调用模型接口;
  3. 页面显示一张图片。

这个流程可以验证模型能力,但它还不能算一个完整的应用。真实项目还要面对异步任务、失败处理、接口密钥安全、图片链接过期、历史记录、文件下载,以及生成后的二次编辑。

我最近从一个能正常运行的 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 负责。

这层抽象解决了两个问题:

  1. 第三方接口字段不会污染业务路由;
  2. 以后替换模型时,不需要重写任务和生成库。

当前使用的是 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 复制代码
等待处理
→ 生成中
→ 生成完成

任务进入 succeededfailed 后立即停止轮询。组件卸载、任务变化或重新生成时,也会清理旧定时器。

这里需要注意:轮询不是越快越好。频率过高会增加服务器和第三方接口压力,甚至触发限流。


八、失败信息必须真正展示出来

最初失败时,页面只显示:

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

编辑器会:

  1. 根据任务 ID 查询任务;
  2. 找到指定图片;
  3. 加载本地图片;
  4. 绘制到原始分辨率的 Canvas;
  5. 在页面中按容器尺寸缩放显示。

页面看到的是缩放后的 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:灰度图片

每一帧:

  1. 主画布绘制灰度底图;
  2. 绘制下落雨滴;
  3. 雨滴到达目标点后进入涟漪阶段;
  4. 使用圆形裁剪区域绘制彩色图层;
  5. 绘制逐渐扩散并淡出的涟漪圆环。

伪代码如下:

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阶段提交;
  • 为数据库和对象存储预留迁移空间。

二十、下一步计划

项目核心功能已经打通,后续重点不会继续无限堆滤镜,而是提高工程完整度:

  1. 使用 Vitest + Supertest 增加后端接口测试;
  2. 增加前端核心组件测试;
  3. 完善 README、架构图和截图;
  4. 增加演示视频;
  5. 在合适环境下迁移 PostgreSQL;
  6. 将图片存储迁移到 OSS;
  7. 增加用户和权限体系;
  8. 部署前端和后端;
  9. 对生成频率和成本增加限流。

结语

这个项目最重要的收获,不是成功调用了一次生图 API。

真正有价值的是把一个模型调用,逐步变成一个完整的软件流程:

text 复制代码
输入
→ 校验
→ 创建任务
→ 异步处理
→ 状态同步
→ 文件持久化
→ 历史管理
→ 图片编辑
→ 动态导出

如果只看最终页面,很多功能似乎理所当然。但把它们逐个实现后,会发现 AIGC 应用本质上仍然离不开传统的软件工程能力。

模型负责生成内容,应用负责让这项能力真正可用。

如果你也准备做一个 AIGC 项目,我的建议是:不要第一天就设计数据库、队列、登录和复杂架构。先从一个可运行的页面开始,完成最小链路,然后每次只解决一个真实问题。

当每一步都能运行、验证和提交时,最后得到的不只是一个 Demo,而是一个自己真正理解的项目。


参考资料

相关推荐
黄啊码1 小时前
【黄啊码】省下美工钱,抢到搜索位,这个电商作图工具真香
人工智能
Freak嵌入式1 小时前
MCU 低功耗模式解析:时钟门控、电源门控、深度休眠
人工智能·python·单片机·嵌入式硬件·开源·依赖倒置原则·micropython
泡沫冰@1 小时前
上章节中文件的讲解
前端·网络·nginx
Shockang2 小时前
AI Harness 工程学:用 Claude Agent SDK 把缺陷调查封装成专属智能体驭具的 18 节实战
人工智能
zhangfeng11332 小时前
K3 + 自建 Ascend C 算子专用微调模型 方案技术审核报告
人工智能·算子开发
阿里云大数据AI技术2 小时前
莫刻机器人LJM登榜WorldArena第二!阿里云PAI提供全流程训练支撑
人工智能·机器人
进击的丸子2 小时前
APP人脸识别增值版Harmony Demo实操与关键代码解析
前端·程序员·harmonyos
栈底拾遗2 小时前
AtomGit:国内唯一开源 + AI 一体化自主基础设施
人工智能·开源
a1117762 小时前
唯美花朵风格的黑胶唱片音乐播放器
前端·css·css3