作者:互联网 时间: 2026-07-11 09:02:59
做短视频、做动画、做漫画脚本的人,几乎都绕不开一个环节——分镜。
传统流程是:编剧写好脚本 → 画师根据文字描述画出每一镜的画面 → 反复修改。这个过程慢、贵、且沟通成本高。
于是我想:能不能让 AI 直接根据每一句分镜描述生成图片?
答案是肯定的。现在的图像生成模型(GPT-Image-2、豆包 Seedream 系列)已经能根据文字描述生成相当可用的画面。我要做的,就是把"输入文字 → 出图 → 保存"这个流程封装成一个简单好用的 Web 工具。
刚开始我的想法很朴素:"调用 OpenAI 的画图 API,输入提示词,出图保存"。
但真正动手前,我和 AI 助手做了一轮 brainstorming,把需求一点点抠清楚。这个过程很重要,模糊的需求一定会导致返工。
我选了 Web 页面。理由很简单:分镜创作是个视觉活,命令行黑框框看着没感觉,Web 页面能直接预览图片、能下载、能反复调整。
我选了 纯 HTML + 原生 JavaScript。不引任何前端框架。为什么?因为这个工具逻辑极简——一个输入框、一个按钮、一张图。上 React 纯属杀鸡用牛刀,反而增加构建复杂度。原生 JS 一个 IIFE 就搞定了。
后端用 Flask。Python 生态对 API 调用友好,Flask 又是最轻量的 Web 框架,单文件就能跑。
最初我选了页面输入(方便演示),但很快意识到这有安全隐患。最后改成 .env 配置文件 + python-dotenv 自动加载。.env 被 .gitignore 排除,密钥永不进版本库。这是更工程化的做法。
这是关键转折点。我一开始说"调 OpenAI 的画图 API",但实际我想用的是 gpt-image-2、gpt-image-2-vip、gpt-image-2-high 和 doubao-seedream-4-0/4-5/5-0 这几个模型。
这些模型并非 OpenAI 官方直出,而是通过一个第三方袋里服务提供的(兼容 OpenAI 接口格式)。这一步确认后,后面所有 API 调用逻辑都围绕第三方的文档来写。
豆包 Seedream 系列其实也能生成视频。但我果断说 只做图片。
理由:视频生成耗时长(分钟级)、文件大、还要轮询任务状态,复杂度比图片高一个数量级。YAGNI(You Ain't Gonna Need It)——先把图片做稳,视频以后再说。这个决策让整个项目复杂度降了一个档。
这是整个项目最关键的技术转折。
我最初按 OpenAI 标准接口写代码:
resp = requests.post(f"{API_BASE_URL}/v1/images/generations", json=payload, headers=headers)result = resp.json()image_url = result["data"][0]["url"]# 直接拿图
结果发现——ToAPIs 的图像生成是异步的!
打开文生图官方文档,返回值长这样:
{"id": "task_img_abc123def456","object": "generation.task","model": "gpt-image-2","status": "queued","progress": 0,"created_at": 1703884800}
注意:第一次请求只返回 task_id 和状态,不返回图片! 你得拿着 task_id 去另一个接口轮询:
GET /v1/images/generations/{task_id}
直到 status 变成 completed,才能在 result.data[0].url 拿到真正的图片地址。
文档里还贴心地给了轮询策略建议:
初始等待: 2 秒轮询间隔: 3 秒最大等待: 120 秒典型耗时: 5-30 秒
还有一个坑:生成的图片 URL 有效期只有 24 小时。所以必须在拿到 URL 后立刻下载到本地,不能只存 URL。
于是后端逻辑变成了三步走:
task_id./images/,返回本地 URL 给前端# 提交任务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 名字判断走哪条路:
if model.startswith("doubao-seedream") and resolution:payload["metadata"] = {"resolution": resolution}
这种"看文档才发现的细节",是远程 API 集成最耗时的部分。没有捷径,就是读文档 + 试错。
第一版前端就一个 textarea,用户把所有分镜塞进去,用换行分隔。简单,但不好用:
于是改成 动态多输入框:
核心就是一段 DOM 操作:
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);}
收集所有提示词时遍历一遍:
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;}
分镜往往一画就是十几张。如果一张一张串行生成,10 张图每张 20 秒就要等 3 分钟。如果并行,理论上 20 秒就能全拿回来。
所以加了「处理模式」下拉框:
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 配额、出错好定位、前端能看到逐张出现的过程。坏处是慢。
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 包裹单张生成,一张失败不影响其他张——这对分镜这种"批量但单张独立"的场景很重要。
部署完第一次点按钮,毫无响应。打开浏览器控制台,发现:
GET http://127.0.0.1:5000/script.js 404 (NOT FOUND)
原因:Flask 默认静态文件目录是 static/,访问路径必须是 /static/script.js,不是 /script.js。改一行:
<!-- 错误 --><script src="script.js"></script><!-- 正确 --><script src="/static/script.js"></script>
.env 里我一开始写了:
API_BASE_URL=https://xxxxx.com/v1/images/generations
但 app.py 里又拼了一遍 /v1/images/generations,导致最终 URL 变成:
https://xxxx.com/v1/images/generations/v1/images/generations
404。改成只写根域名:
API_BASE_URL=https://xxxxx.com
配置项只放根地址,路径在代码里拼——这是惯例,避免重复。
PowerShell 里直接敲 pip install 报 "无法识别"。原因是没激活虚拟环境,且系统 Python 没把 pip 加进 PATH。
解法是用 py -m pip 或激活虚拟环境后用 python -m pip:
venvScriptsActivate.ps1pip install -r requirements.txt
画图程序/├── 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/ # 运行时生成图片存放
启动:
cd "D:Usersopencode画图程序"venvScriptsActivate.ps1python app.py
浏览器打开 http://127.0.0.1:5000,界面长这样:
点生成后,每张图独立卡片展示:序号 + 提示词预览 + 图片 + 任务 ID + 单独下载按钮。
这个工具前端逻辑总共不到 150 行 JS。引入 React 要配 Babel、Webpack、JSX,构建链比业务逻辑还复杂。技术选型要看场景,不是越新越好。 原生 JS + IIFE 对这种小工具是最佳解。
选择生成的图片 URL 只有 24 小时有效期。如果前端直接用这个 URL,第二天分镜文件就全裂图了。所以后端必须把图片下载到本地 images/ 目录,前端拿的是本地路径 /images/xxx.png,永久有效。
这也是为什么有个 /images/<filename> 路由——专门serve本地图片。
环境变量要每次启动前手动 $env:API_KEY = "...",换台机器就忘。.env 文件跟着项目走(虽然不进 git),改一次永久生效,用 python-dotenv 一行加载:
from dotenv import load_dotenvload_dotenv()API_KEY = os.getenv("API_KEY")
开发体验比纯环境变量好太多。
最后修改这个文件,修改这两个变量API_BASE_URL 和 API_KEY为实际值:
分镜生成是"批量但单张独立"的场景——第 3 张失败了,第 4、5、6 张照样该生成。所以每张生成单独 try/catch,失败的那张显示错误信息,成功的照常出图。这个容错设计让工具在弱网或 API 抖动时也不至于全盘崩溃。
这个版本是 MVP(最小可用版本)。如果要继续打磨,我会考虑:
progress 字段(0-100)回传前端,做个真进度条,而不是干等这个项目本身不复杂,但有几个点值得新手注意:
.env + .gitignore,别图省事硬编码