零、效果图

一、为什么做这个工具
做短视频、做动画、做漫画脚本的人,几乎都绕不开一个环节------分镜。
传统流程是:编剧写好脚本 → 画师根据文字描述画出每一镜的画面 → 反复修改。这个过程慢、贵、且沟通成本高。
于是我想:能不能让 AI 直接根据每一句分镜描述生成图片?
答案是肯定的。现在的图像生成模型(GPT-Image-2、豆包 Seedream 系列)已经能根据文字描述生成相当可用的画面。我要做的,就是把"输入文字 → 出图 → 保存"这个流程封装成一个简单好用的 Web 工具。
二、需求是怎么一步步清晰的
刚开始我的想法很朴素:"调用 OpenAI 的画图 API,输入提示词,出图保存"。
但真正动手前,我和 AI 助手做了一轮 brainstorming,把需求一点点抠清楚。这个过程很重要,模糊的需求一定会导致返工。
第 1 轮:交互形态
命令行还是 Web 页面?
我选了 Web 页面。理由很简单:分镜创作是个视觉活,命令行黑框框看着没感觉,Web 页面能直接预览图片、能下载、能反复调整。
第 2 轮:技术栈
前端用什么框架?
我选了 纯 HTML + 原生 JavaScript。不引任何前端框架。为什么?因为这个工具逻辑极简------一个输入框、一个按钮、一张图。上 React 纯属杀鸡用牛刀,反而增加构建复杂度。原生 JS 一个 IIFE 就搞定了。
后端用 Flask。Python 生态对 API 调用友好,Flask 又是最轻量的 Web 框架,单文件就能跑。
第 3 轮:API Key 怎么管
环境变量还是页面输入?
最初我选了页面输入(方便演示),但很快意识到这有安全隐患。最后改成 .env 配置文件 + python-dotenv 自动加载 。.env 被 .gitignore 排除,密钥永不进版本库。这是更工程化的做法。
第 4 轮:用哪个模型
这是关键转折点。我一开始说"调 OpenAI 的画图 API",但实际我想用的是 gpt-image-2、gpt-image-2-vip、gpt-image-2-high 和 doubao-seedream-4-0/4-5/5-0 这几个模型。
这些模型并非 OpenAI 官方直出,而是通过一个第三方代理服务提供的(兼容 OpenAI 接口格式)。这一步确认后,后面所有 API 调用逻辑都围绕第三方的文档来写。
第 5 轮:要不要做视频
豆包 Seedream 系列其实也能生成视频。但我果断说 只做图片。
理由:视频生成耗时长(分钟级)、文件大、还要轮询任务状态,复杂度比图片高一个数量级。YAGNI(You Ain't Gonna Need It)------先把图片做稳,视频以后再说。这个决策让整个项目复杂度降了一个档。
三、读懂 API 文档:从同步到异步的踩坑
这是整个项目最关键的技术转折。
我最初按 OpenAI 标准接口写代码:
python
resp = requests.post(f"{API_BASE_URL}/v1/images/generations", json=payload, headers=headers)
result = resp.json()
image_url = result["data"][0]["url"] # 直接拿图
结果发现------ToAPIs 的图像生成是异步的!
打开文生图官方文档,返回值长这样:
json
{
"id": "task_img_abc123def456",
"object": "generation.task",
"model": "gpt-image-2",
"status": "queued",
"progress": 0,
"created_at": 1703884800
}
注意:第一次请求只返回 task_id 和状态,不返回图片! 你得拿着 task_id 去另一个接口轮询:
bash
GET /v1/images/generations/{task_id}
直到 status 变成 completed,才能在 result.data[0].url 拿到真正的图片地址。
文档里还贴心地给了轮询策略建议:
makefile
初始等待: 2 秒
轮询间隔: 3 秒
最大等待: 120 秒
典型耗时: 5-30 秒
还有一个坑:生成的图片 URL 有效期只有 24 小时。所以必须在拿到 URL 后立刻下载到本地,不能只存 URL。
于是后端逻辑变成了三步走:
- 提交任务 → 拿
task_id - 轮询状态 → 每 3 秒查一次,最多 40 次(约 120 秒)
- 下载图片 → 保存到
./images/,返回本地 URL 给前端
python
# 提交任务
resp = requests.post(f"{API_BASE_URL}/v1/images/generations", json=payload, headers=headers, timeout=30)
task_id = resp.json().get("id")
# 轮询
for attempt in range(40):
time.sleep(3)
status_resp = requests.get(f"{API_BASE_URL}/v1/images/generations/{task_id}", headers=headers, timeout=30)
status_data = status_resp.json()
if status_data.get("status") == "completed":
image_url = status_data["result"]["data"][0]["url"]
# 下载并保存
img_resp = requests.get(image_url, timeout=30)
filename = f"image_{int(time.time())}_{os.urandom(4).hex()}.png"
with open(os.path.join(IMAGES_DIR, filename), "wb") as f:
f.write(img_resp.content)
return jsonify({"image_url": f"/images/{filename}"})
elif status_data.get("status") == "failed":
return jsonify({"error": "图片生成失败"}), 500
另外还有一个模型差异细节:Seedream 系列的分辨率参数要走 metadata.resolution ,而 GPT-Image-2 系列直接用 resolution 顶层字段。后端要根据 model 名字判断走哪条路:
python
if model.startswith("doubao-seedream") and resolution:
payload["metadata"] = {"resolution": resolution}
这种"看文档才发现的细节",是远程 API 集成最耗时的部分。没有捷径,就是读文档 + 试错。
四、前端:从单输入框到多输入框的演进
第一版前端就一个 textarea,用户把所有分镜塞进去,用换行分隔。简单,但不好用:
- 想删中间某一条得手动挪光标
- 看不清自己一共写了几条
- 没法对单条单独操作
于是改成 动态多输入框:
- 默认一个输入框
- 点「+ 添加提示词」按钮新增一个
- 每个输入框右上角有「删除」按钮
- 聚焦时才显示删除按钮,避免界面杂乱
核心就是一段 DOM 操作:
javascript
function addPromptGroup(promptText) {
var group = document.createElement("div");
group.className = "prompt-group";
var textarea = document.createElement("textarea");
textarea.className = "promptInput";
// ...
var removeBtn = document.createElement("button");
removeBtn.textContent = "删除";
removeBtn.addEventListener("click", function() {
group.remove();
});
group.appendChild(textarea);
group.appendChild(removeBtn);
promptsContainer.appendChild(group);
}
收集所有提示词时遍历一遍:
javascript
function getPrompts() {
var inputs = promptsContainer.querySelectorAll(".promptInput");
var prompts = [];
for (var i = 0; i < inputs.length; i++) {
var val = inputs[i].value.trim();
if (val.length > 0) prompts.push(val);
}
return prompts;
}
五、依次生成 vs 并行生成:两种模式的设计
分镜往往一画就是十几张。如果一张一张串行生成,10 张图每张 20 秒就要等 3 分钟。如果并行,理论上 20 秒就能全拿回来。
所以加了「处理模式」下拉框:
依次生成(sequential)
javascript
for (var i = 0; i < prompts.length; i++) {
var img = await generateOne(prompts[i], model, size, resolution);
appendResult({ prompt: prompts[i], url: img.image_url, index: i });
}
特点:一张完成才生成下一张。好处是省 API 配额、出错好定位、前端能看到逐张出现的过程。坏处是慢。
并行生成(parallel)
javascript
var tasks = prompts.map(function(p, i) {
return generateOne(p, model, size, resolution)
.then(function(img) {
appendResult({ prompt: p, url: img.image_url, index: i });
})
.catch(function(err) {
appendResult({ prompt: p, error: err.message, index: i });
});
});
await Promise.all(tasks);
特点:所有任务同时发出,谁先回来谁先显示。快,但要注意的限流(429 错误)。如果配额吃紧,建议用依次模式。
两种模式都用 try/catch 包裹单张生成,一张失败不影响其他张------这对分镜这种"批量但单张独立"的场景很重要。
六、那些调试中的小坑
坑 1:点击生成按钮没反应
部署完第一次点按钮,毫无响应。打开浏览器控制台,发现:
arduino
GET http://127.0.0.1:5000/script.js 404 (NOT FOUND)
原因:Flask 默认静态文件目录是 static/,访问路径必须是 /static/script.js,不是 /script.js。改一行:
html
<!-- 错误 -->
<script src="script.js"></script>
<!-- 正确 -->
<script src="/static/script.js"></script>
坑 2:API_BASE_URL 写成了完整路径
.env 里我一开始写了:
bash
API_BASE_URL=https://xxxxx.com/v1/images/generations
但 app.py 里又拼了一遍 /v1/images/generations,导致最终 URL 变成:
bash
https://xxxx.com/v1/images/generations/v1/images/generations
404。改成只写根域名:
ini
API_BASE_URL=https://xxxxx.com
配置项只放根地址,路径在代码里拼------这是惯例,避免重复。
坑 3:pip 命令找不到
PowerShell 里直接敲 pip install 报 "无法识别"。原因是没激活虚拟环境,且系统 Python 没把 pip 加进 PATH。
解法是用 py -m pip 或激活虚拟环境后用 python -m pip:
powershell
venv\Scripts\Activate.ps1
pip install -r requirements.txt
七、最终项目结构
bash
画图程序/
├── app.py # Flask 后端:异步任务 + 轮询 + 本地保存
├── static/
│ ├── index.html # 前端:多输入框 + 参数选择 + 结果展示
│ └── script.js # 前端逻辑:多输入框管理、依次/并行生成
├── .env # 配置:API_KEY + API_BASE_URL(不提交)
├── .env.example # 配置模板
├── requirements.txt # flask / requests / python-dotenv
├── .gitignore # 排除 .env / images/ / venv/
├── README.md # 使用文档
├── venv/ # 虚拟环境
└── images/ # 运行时生成图片存放
启动:
powershell
cd "D:\Users\opencode\画图程序"
venv\Scripts\Activate.ps1
python app.py
浏览器打开 http://127.0.0.1:5000,界面长这样:
- 顶部一个提示词输入框(可点「+ 添加提示词」新增)
- 模型下拉框(6 个模型可选)
- 宽高比下拉框(1:1 / 4:3 / 16:9 / 9:16 / 21:9 等)
- 分辨率下拉框(1K / 2K / 4K,仅 Seedream 系列生效)
- 处理模式下拉框(依次生成 / 并行生成)
- 生成按钮
点生成后,每张图独立卡片展示:序号 + 提示词预览 + 图片 + 任务 ID + 单独下载按钮。
八、几个关键设计决策的复盘
决策 1:为什么不用 React/Vue
这个工具前端逻辑总共不到 150 行 JS。引入 React 要配 Babel、Webpack、JSX,构建链比业务逻辑还复杂。技术选型要看场景,不是越新越好。 原生 JS + IIFE 对这种小工具是最佳解。
决策 2:为什么后端要做"下载图片到本地"这一步
选择生成的图片 URL 只有 24 小时有效期。如果前端直接用这个 URL,第二天分镜文件就全裂图了。所以后端必须把图片下载到本地 images/ 目录 ,前端拿的是本地路径 /images/xxx.png,永久有效。
这也是为什么有个 /images/<filename> 路由------专门serve本地图片。
决策 3:为什么用 .env 而不是环境变量
环境变量要每次启动前手动 $env:API_KEY = "...",换台机器就忘。.env 文件跟着项目走(虽然不进 git),改一次永久生效,用 python-dotenv 一行加载:
python
from dotenv import load_dotenv
load_dotenv()
API_KEY = os.getenv("API_KEY")
开发体验比纯环境变量好太多。
最后修改这个文件,修改这两个变量API_BASE_URL 和 API_KEY为实际值: 
决策 4:为什么单张失败不阻塞其他张
分镜生成是"批量但单张独立"的场景------第 3 张失败了,第 4、5、6 张照样该生成。所以每张生成单独 try/catch,失败的那张显示错误信息,成功的照常出图。这个容错设计让工具在弱网或 API 抖动时也不至于全盘崩溃。
九、后续可以加什么
这个版本是 MVP(最小可用版本)。如果要继续打磨,我会考虑:
- 批量打包下载 ------ 生成完 10 张图后,一键打包成 zip 下载,省得一张张点
- 提示词模板 ------ 内置几套分镜模板(如"电影感""赛博朋克""水墨"),点一下自动填到输入框
- 历史记录 ------ 把每次生成的 prompt + 图片路径存到 SQLite,方便回溯
- 图生图(reference_images) ------ 上传一张参考图,让 AI 基于它改风格。ToAPIs 文档里这个能力是现成的,前端加个上传组件即可
- 进度条 ------ 轮询时把
progress字段(0-100)回传前端,做个真进度条,而不是干等
十、总结
打开后页面样式

输入提示词后等待样式

生成后结果

这个项目本身不复杂,但有几个点值得新手注意:
- 需求要抠清楚再动手 ------ 一轮 brainstorming 省下后面 3 轮返工
- 读 API 文档比写代码重要 ------ 同步还是异步、参数走顶层还是 metadata、URL 有效期多久,这些细节不看文档全是坑
- 小工具别上重框架 ------ 原生 JS + Flask 单文件,部署简单、改起来快、看代码一目了然
- 容错要从单点做 ------ 批量任务里每条单独 try/catch,一条挂了不影响其他
- 密钥管理要工程化 ------
.env+.gitignore,别图省事硬编码