接口参考
两个端点的完整参数、请求与响应结构,加上尺寸硬限制和一份可以直接抄走的规整函数。怎么拿 key、超时设置、这个接口为什么是同步的,都在概览那一页,这里不重复。
文生图 · POST /v1/images/generations
只给提示词、不给参考图的场景走这个端点。请求体是普通 JSON,请求返回的时候图已经生成完了,直接从响应体里取。
请求头
只有两个,没有额外的握手、签名或时间戳步骤。
| 请求头 | 值 | 说明 |
|---|---|---|
Authorization 必填 |
Bearer <TUTUJIN_API_KEY> |
Key 形如 sk-xxxxxxxx,找管理员要。放环境变量,不要硬编码进源码 |
Content-Type 必填 |
application/json | 仅文生图需要手写。图生图不要手写这个头,理由见下一节 |
请求参数
标了 静默出错 的几个值得多看两眼:传错了不报错、照常返回 200、照常计费,只是结果不是你要的那张。它们各自的来龙去脉在错误处理与避坑,这里只说结论。
| 参数 | 类型 | 说明 |
|---|---|---|
model 必填 |
string | 固定 gpt-image-2 |
prompt 必填 |
string | 建议用英文,中文也能理解但英文更稳。太短(只有几个词)可能被判成“提示词太模糊” |
size 必填 静默出错 |
string | 只能是 "宽x高" 像素串,例如 "1536x864"。传 "16:9" 这类比例别名会被静默改成 2048x2048。可用值见本页「尺寸速查表」 |
quality 选填 |
string | 要效果就传 high,这一档已验证可用。low / medium 未在本路径验证过 |
response_format 选填 |
string | 传 url 表示期望返回临时图片 URL。但它只是“期望”,解析时两种形式都要兼容,见本页「响应解析」 |
n 静默出错 |
number | 不要传,这个参数不存在。一次请求恒出 1 张;要 4 张就发 4 次请求,每次单独计费 |
moderation 静默出错 |
string | 不要传。审核严格度经中转后是否真正生效未经证实,字段可能被接受却被忽略 —— 你以为放宽了,其实每次照样被拦、照样计费 |
协议上 size 是可以省略的,省略也不报错。但实测省略之后输出会塌成 1024x1024 方图 —— 拿一张 1536×864 的源图做图生图、不传 size,出来的一样是 1024×1024。“不传 size 上游就会自动跟随原图”是错的,接口里也没有 match_input_image 这种可以直接发出去的值。所以这份文档一律按必填对待:每个请求都显式传像素串。
请求体本身很短,就是上面那张表里的几个字段:
{
"model": "gpt-image-2",
"prompt": "a red apple on a wooden table, soft studio light",
"size": "1536x864",
"quality": "high",
"response_format": "url"
}
放进代码里是这样。TUTUJIN 那份配置在概览页,extractImage() 在本页最后一节:
import { TUTUJIN } from './config.js';
export async function textToImage({ prompt, size = '1024x1024', quality = 'high' }) {
const resp = await fetch(`${TUTUJIN.baseUrl}/images/generations`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TUTUJIN.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: TUTUJIN.model,
prompt, // 建议英文;中文也能理解,英文更稳
size, // 恒传,绝不省略
quality,
response_format: 'url', // 只是"期望",响应仍可能给 b64_json
}),
signal: AbortSignal.timeout(TUTUJIN.timeoutMs), // 少这行会永久挂住
});
// 非 2xx 一定要把响应正文带出来,否则无从判断是审核拦截还是参数错
if (!resp.ok) {
const text = await resp.text();
throw new Error(`图像接口返回 ${resp.status}: ${text.slice(0, 500)}`);
}
return await extractImage(await resp.json()); // 见本页「响应解析」
}
成功时是 200,响应体是下面两种形式之一。同一个通道在不同配置下会给不同形式,别赌只会给其中一种:
{
"created": 1735000000,
"data": [
{ "url": "https://.../xxx.png" }
]
}
{
"created": 1735000000,
"data": [
{ "b64_json": "iVBORw0KGgo..." }
]
}
data 永远只有一个元素(n 参数不存在)。两个端点的成功响应结构完全一样,所以解析逻辑写一份共用就行,见本页「响应解析」。非 2xx 的各种情况以及哪些该重试,在错误处理与避坑。
图生图 · POST /v1/images/edits
带参考图改图走这个端点。和文生图最大的区别是请求体不再是 JSON,而是 multipart/form-data —— 图片文件和文本参数一起装在同一个表单里。
请求参数 · 文件字段
文本参数和文生图完全一致(model / prompt / size / quality / response_format,语义和标记都一样,不再重复列),下面只列多出来的三个文件字段。
| 参数 | 类型 | 说明 |
|---|---|---|
image[] 必填 静默出错 |
file | 参考图,可重复 append 多张。第一张是底图,格式 png / jpeg / webp,单张 ≤ 25MB。多张是否真的全部生效取决于中转层是否原样透传,见下方规矩一 |
image 选填 |
file | 单图写法,和 image[] 二选一。如果实测多图不生效,就改用这个字段只发底图 |
mask 选填 |
file | 蒙版,用来指定只改哪块区域。局部重绘效果不稳定,别当核心功能依赖 |
三条必须记住的规矩
images[0] 是要被改的那张,其余是补充参考。这个顺序不是随便排的,打乱了不会报错,只是出来的图变成了另一回事 —— 所以不要用 Set、Object.values() 或者并发收集这类不保证顺序的写法去攒这个数组。另外,多张参考图是否真的每张都参与了生成,属于静默失败(请求 200、正常出图、正常计费,后面几张被丢掉),必须人工看图确认,详见错误处理与避坑。
multipart 请求头里必须带一个随机生成的分隔串(boundary),服务端靠它切分各个字段。FormData 会自己生成并写进请求头,你一旦手写 'Content-Type': 'multipart/form-data',就把这个 boundary 覆盖没了,服务端一个字段都解析不出来。所以图生图的 headers 里只放 Authorization 一项,别的什么都不要加。
上游会拿文件名后缀去猜格式,image/jpeg 的文件就得叫 x.jpg,不能因为“看着像图片”就统一叫 x.png —— 不一致会被猜错格式而拒绝。用户上传的文件最容易踩这个坑(手动改个后缀、文件内容根本没变),所以稳妥做法是按真实 MIME 反推后缀,别信原始文件名。
完整示例
下面这段可以直接放进项目。loadImage() 负责把本地文件读成带正确 MIME 和文件名的三元组,顺手做掉格式和大小校验 —— 这两项在本地拦下来,比发出去等一分半钟再被拒绝划算得多。
import { readFile } from 'node:fs/promises';
import { TUTUJIN } from './config.js';
/**
* @param {string} prompt 改图指令
* @param {Array<{buffer: Buffer, mime: string, filename: string}>} images
* 参考图。第 0 张是底图(要被改的那张),顺序有语义,不要打乱。
* @param {string} size 必须是像素串;"跟随原图" 的算法见本页尺寸一节
*/
export async function imageToImage({ prompt, images, size, quality = 'high', mask }) {
if (!images?.length) throw new Error('图生图至少需要一张参考图');
const form = new FormData();
form.append('model', TUTUJIN.model);
form.append('prompt', prompt);
form.append('size', size); // 恒传,省略会塌成 1024x1024
form.append('quality', quality);
form.append('response_format', 'url');
// 多图字段名先按 image[] 发;若实测中转层不透传数组,改成单个 'image' 只发 images[0]
for (const img of images) {
form.append('image[]', new Blob([img.buffer], { type: img.mime }), img.filename);
}
// 单图写法(fallback):
// form.append('image', new Blob([images[0].buffer], { type: images[0].mime }), images[0].filename);
if (mask) {
form.append('mask', new Blob([mask.buffer], { type: mask.mime }), mask.filename);
}
const resp = await fetch(`${TUTUJIN.baseUrl}/images/edits`, {
method: 'POST',
headers: { Authorization: `Bearer ${TUTUJIN.apiKey}` }, // ← 只放这一个,别手写 Content-Type
body: form,
signal: AbortSignal.timeout(TUTUJIN.timeoutMs),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`图像接口返回 ${resp.status}: ${text.slice(0, 500)}`);
}
return await extractImage(await resp.json());
}
// 从本地文件构造参考图:后缀 → MIME,并在本地先把不合规的挡掉
export async function loadImage(path) {
const ext = path.toLowerCase().split('.').pop();
const mime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp' }[ext];
if (!mime) throw new Error(`不支持的图片格式: ${ext}`);
const buffer = await readFile(path);
if (buffer.length > 25 * 1024 * 1024) throw new Error('参考图超过 25MB');
return { buffer, mime, filename: path.split('/').pop() };
}
尺寸速查表
size 只吃像素串,所以最省事的做法是从下面这张表里挑一个抄走。这些值都落在合法范围内、宽高也都是 16 的倍数,可以直接传,不用再算。
| 想要的比例 | 传这个值 | 备注 |
|---|---|---|
| 1:1 方图 | 1024x1024 | 默认 |
| 2:3 竖 | 1024x1536 | |
| 3:2 横 | 1536x1024 | |
| 16:9 横 | 1536x864 | 不要传 "16:9" |
| 9:16 竖 | 864x1536 | |
| 4:3 横 | 1536x1152 | |
| 3:4 竖 | 1152x1536 | |
| 4:5 竖(社媒) | 1232x1536 | |
| 5:4 横 | 1536x1232 | |
| 2K 方 | 2048x2048 | 更慢,且高清档偶发不稳定 |
| 2K 横 | 2048x1152 | |
| 2K 竖 | 1152x2048 | |
| 4K 横 | 3840x2160 | 明显更慢 |
| 4K 竖 | 2160x3840 |
硬限制
要传表格以外的尺寸,得同时满足这四条。超限的请求会被拒绝,或者被静默改成别的尺寸 —— 后者更麻烦,因为你只会拿到一张尺寸不对的图,没有任何提示。
| 限制 | 值 |
|---|---|
| 宽高比 | ≤ 3:1(横竖都算) |
| 最长边 | ≤ 3840 |
| 宽、高 | 都必须是 16 的倍数 |
| 总像素 | 655,360 ~ 8,294,400 之间 |
别指望上游帮你修,发送前自己规整一遍。下面这个函数把任意想要的宽高变成上游一定收的合法值,四条限制按依赖顺序依次收敛,16 的倍数放在最后做(放前面会被后续缩放破坏掉):
const MAX_EDGE = 3840, MIN_PX = 655_360, MAX_PX = 8_294_400, MAX_RATIO = 3;
const round16 = (n) => Math.max(16, Math.round(n / 16) * 16);
/**
* @param {number} w @param {number} h
* @param {number} [maxEdge] 想限制在 ~1.5MP 常规档就传 1536
* @returns {string} "宽x高"
*/
export function normalizeSize(w, h, maxEdge = MAX_EDGE) {
// ① 比例钳到 3:1 以内
if (w / h > MAX_RATIO) h = w / MAX_RATIO;
else if (h / w > MAX_RATIO) w = h / MAX_RATIO;
// ② 最长边
const longest = Math.max(w, h);
if (longest > maxEdge) { const s = maxEdge / longest; w *= s; h *= s; }
// ③ 总像素上限
let px = w * h;
if (px > MAX_PX) { const s = Math.sqrt(MAX_PX / px); w *= s; h *= s; }
// ④ 总像素下限(放大后若又超边长,边长优先)
px = w * h;
if (px < MIN_PX) {
const s = Math.sqrt(MIN_PX / px); w *= s; h *= s;
const l2 = Math.max(w, h);
if (l2 > maxEdge) { const s2 = maxEdge / l2; w *= s2; h *= s2; }
}
// ⑤ 16 的倍数(最后做)
return `${round16(w)}x${round16(h)}`;
}
跟随原图比例
图生图里最常见的诉求是“输出跟原图一个比例”。接口没有这种值可以直接发,得在自己这边读出源图的真实像素、规整、再显式传过去:
import sharp from 'sharp'; // npm i sharp
export async function sizeFollowingSource(buffer) {
try {
const meta = await sharp(buffer, { failOn: 'none' }).metadata();
let w = meta.width, h = meta.height;
if (!w || !h) return '1024x1024';
// ★ EXIF 方向 5-8 表示图被旋转了 90°,metadata 给的是"存储"宽高、和肉眼看到的相反
// (手机竖拍 JPEG 常见 orientation=6)。不换轴会把竖拍照片当成横图发出去。
if (meta.orientation >= 5 && meta.orientation <= 8) [w, h] = [h, w];
// 常规档:长边压到 1536(约 1.5MP)
return normalizeSize(w, h, 1536);
} catch {
return '1024x1024'; // 读不出尺寸(损坏图 / HEIC 等)→ 安全回落,绝不因此中断
}
}
手机竖拍的 JPEG,文件里存的往往是「横着的 4032×3024 + 一个 orientation=6 的旋转标记」,看图软件会自动转正,读元数据拿到的却是转正之前的宽高。少了那行换轴,用户传一张竖拍照片,你会一本正经地给上游发一个横图尺寸,比例全错且全程无报错。
另外注意最后那个 1536:别把手机原图 4032×3024 直接推成 8MP 请求,又慢又容易失败。
响应解析
两个端点的成功响应结构完全一样,所以解析写一份共用就行。要点只有一个:无条件兼容 url 和 b64_json 两种形式。同一个通道在不同配置下会给不同形式,response_format: 'url' 只是“期望”,不是保证 —— 只处理其中一种的代码,会在某天通道配置变了之后毫无预兆地开始抛错。
export async function extractImage(json) {
const d = json?.data?.[0];
if (!d) throw new Error('上游响应里没有 data');
if (typeof d.b64_json === 'string' && d.b64_json) {
return Buffer.from(d.b64_json, 'base64');
}
if (typeof d.url === 'string' && /^https?:\/\//.test(d.url)) {
// ★ 这个 URL 是临时的(存活以小时计,域名也不固定)。
// 必须在拿到的当下就下载下来,存进你自己的存储。
const r = await fetch(d.url, { signal: AbortSignal.timeout(60_000) });
if (!r.ok) throw new Error(`下载生成图失败: ${r.status}`);
return Buffer.from(await r.arrayBuffer());
}
throw new Error('data[0] 里既没有 url 也没有 b64_json');
}
返回的是 Buffer,接下来存对象存储还是落本地磁盘由你决定。注意下载那一次 fetch 同样要带超时,它和主请求一样会永久挂住。
响应里那个 url 存活时间以小时计,域名也不固定。不要存进数据库当图片地址、不要直接发给终端用户 —— 今天入库一切正常,明天用户点开就是一排破图,而且那时候图已经找不回来了。拿到的当下就转存到自己的存储,之后一律用你自己的地址。