文档中心内部

接口参考

两个端点的完整参数和尺寸规则。怎么拿 key、为什么要设超时,见概览。

两个端点,什么时候用哪个?

手上只有一句话用文生图;已经有图要改用图生图,其余全一样。

✏️一句提示词没有参考图
→
📮/images/generations请求体是 JSON
→
🖼️一张图当场返回,没有轮询
🖼️底图 + 提示词第一张是底图
→
📮/images/edits请求体是 multipart 表单
→
🖼️改好的图同样当场返回

文生图 · POST /v1/images/generations

POST https://api.tutujin.com/v1/images/generations

请求头

只有两个,没有签名、没有时间戳。

请求头值说明
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": "16:9"

照常出图、照常计费,只是悄悄给你 2048x2048 方图;整个省略则塌成 1024x1024。接口里也没有 match_input_image 这种“跟随原图”的值。

✓ 每次都写像素串
"size": "1536x864"

所以本页一律把 size 当必填:每个请求都显式传,可用值见本页「尺寸速查表」。

请求体就是上面表里那几个字段:

请求体 · application/json
{
  "model": "gpt-image-2",
  "prompt": "a red apple on a wooden table, soft studio light",
  "size": "1536x864",
  "quality": "high",
  "response_format": "url"
}

TUTUJIN 那份配置在概览页,extractImage() 在本页最后一节:

text-to-image.mjs · Node 20+
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,但响应体有两种形式。同一个通道换个配置就换一种,两种都要接:

形式一 · 返回临时 URL
{
  "created": 1735000000,
  "data": [
    { "url": "https://.../xxx.png" }
  ]
}
形式二 · 返回 base64
{
  "created": 1735000000,
  "data": [
    { "b64_json": "iVBORw0KGgo..." }
  ]
}

data 永远只有一个元素。非 2xx 的各种情况见错误处理与避坑。

图生图 · POST /v1/images/edits

POST https://api.tutujin.com/v1/images/edits

文本参数和文生图一模一样,只多出下面三个文件字段。

请求参数 · 文件字段

参数类型说明
image[] 必填 静默出错 file 参考图,可重复 append 多张。第一张是底图,格式 png / jpeg / webp,单张 ≤ 25MB。多张是否真的全部生效取决于中转层是否原样透传,见下方规矩一
image 选填 file 单图写法,和 image[] 二选一。如果实测多图不生效,就改用这个字段只发底图
mask 选填 file 蒙版,用来指定只改哪块区域。局部重绘效果不稳定,别当核心功能依赖

三条必须记住的规矩

1第 0 张要被改的那张
底图
+
2后面几张补充参考
可能被悄悄丢掉
→
🖼️出图顺序错了不报错
只是变成另一回事
一 · 第一张是底图,顺序有语义

images[0] 是要被改的那张。攒这个数组别用 Set、Object.values() 或并发收集这类不保证顺序的写法。后面几张是否真的生效属于静默失败——请求 200、正常出图、正常计费,只能人工看图确认。

✕ 二 · 自己写这个头
'Content-Type': 'multipart/form-data'

它会覆盖掉 FormData 自动生成的分隔串(boundary),服务端一个字段都解析不出来。

✓ 只放 Authorization
headers: { Authorization: `Bearer ${TUTUJIN.apiKey}` }

图生图的 headers 里就这一项,FormData 会自己把请求头写对。

✕ 三 · 一律叫 .png
x.png

上游拿后缀猜格式,image/jpeg 的文件叫这个会被拒。用户手动改过后缀的最容易这样。

✓ 后缀跟着真实 MIME
x.jpg

别信原始文件名,按真实 MIME 反推后缀。

完整示例

可以直接放进项目。loadImage() 在本地就把格式和大小挡掉,比发出去等一分半钟再被拒划算。

image-to-image.mjs · Node 20+
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() };
}

尺寸速查表

1024×1024 方图 1536×864 横 16:9 864×1536 竖 9:16

同样是「一张图」,比例差别就这么大——所以每次都要说清楚。

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 的倍数放在最后做(放前面会被后续缩放破坏):

normalizeSize() · 规整任意尺寸
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)}`;
}

跟随原图比例

图生图最常见的诉求是“输出跟原图一个比例”。接口没有这种值,得自己读出源图真实像素、规整、再显式传:

sizeFollowingSource() · 图生图常用
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 等)→ 安全回落,绝不因此中断
  }
}
别漏了 EXIF 那一行

手机竖拍的 JPEG 存的往往是「横着的 4032×3024 + 一个 orientation=6 的旋转标记」,看图软件会转正,读元数据拿到的却是转正之前的宽高。少了那行换轴,竖拍照片会被当成横图发出去,比例全错、全程无报错。
最后那个 1536 也别去掉:把手机原图直接推成 8MP 请求,又慢又容易失败。

响应解析

两个端点的成功响应结构完全一样,解析写一份共用。要点只有一个:无条件兼容 url 和 b64_json 两种形式——response_format: 'url' 只是“期望”,不是保证。

extractImage() · 两个端点共用
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 当长期地址

响应里那个 url 存活时间以小时计,域名也不固定。不要入库当图片地址、不要直接发给终端用户——今天一切正常,明天用户点开就是一排破图,那时图已经找不回来了。拿到的当下就转存到自己的存储。

土土金内部文档 · 本页最后更新 2026-09-09

内容有误或步骤跑不通,请把页面地址和截图发给内部技术支持。