文档中心内部

接口参考

两个端点的完整参数、请求与响应结构,加上尺寸硬限制和一份可以直接抄走的规整函数。怎么拿 key、超时设置、这个接口为什么是同步的,都在概览那一页,这里不重复。

文生图 · POST /v1/images/generations

只给提示词、不给参考图的场景走这个端点。请求体是普通 JSON,请求返回的时候图已经生成完了,直接从响应体里取。

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 标成必填

协议上 size 是可以省略的,省略也不报错。但实测省略之后输出会塌成 1024x1024 方图 —— 拿一张 1536×864 的源图做图生图、不传 size,出来的一样是 1024×1024。“不传 size 上游就会自动跟随原图”是错的,接口里也没有 match_input_image 这种可以直接发出去的值。所以这份文档一律按必填对待:每个请求都显式传像素串。

请求体本身很短,就是上面那张表里的几个字段:

请求体 · 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 永远只有一个元素(n 参数不存在)。两个端点的成功响应结构完全一样,所以解析逻辑写一份共用就行,见本页「响应解析」。非 2xx 的各种情况以及哪些该重试,在错误处理与避坑

图生图 · POST /v1/images/edits

带参考图改图走这个端点。和文生图最大的区别是请求体不再是 JSON,而是 multipart/form-data —— 图片文件和文本参数一起装在同一个表单里。

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

请求参数 · 文件字段

文本参数和文生图完全一致(model / prompt / size / quality / response_format,语义和标记都一样,不再重复列),下面只列多出来的三个文件字段。

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

三条必须记住的规矩

一 · 第一张是底图,顺序有语义

images[0]要被改的那张,其余是补充参考。这个顺序不是随便排的,打乱了不会报错,只是出来的图变成了另一回事 —— 所以不要用 SetObject.values() 或者并发收集这类不保证顺序的写法去攒这个数组。另外,多张参考图是否真的每张都参与了生成,属于静默失败(请求 200、正常出图、正常计费,后面几张被丢掉),必须人工看图确认,详见错误处理与避坑

二 · 千万别手写 Content-Type

multipart 请求头里必须带一个随机生成的分隔串(boundary),服务端靠它切分各个字段。FormData 会自己生成并写进请求头,你一旦手写 'Content-Type': 'multipart/form-data',就把这个 boundary 覆盖没了,服务端一个字段都解析不出来。所以图生图的 headers只放 Authorization 一项,别的什么都不要加。

三 · 文件名后缀必须和真实 MIME 一致

上游会拿文件名后缀去猜格式,image/jpeg 的文件就得叫 x.jpg不能因为“看着像图片”就统一叫 x.png —— 不一致会被猜错格式而拒绝。用户上传的文件最容易踩这个坑(手动改个后缀、文件内容根本没变),所以稳妥做法是按真实 MIME 反推后缀,别信原始文件名

完整示例

下面这段可以直接放进项目。loadImage() 负责把本地文件读成带正确 MIME 和文件名的三元组,顺手做掉格式和大小校验 —— 这两项在本地拦下来,比发出去等一分半钟再被拒绝划算得多。

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() };
}

尺寸速查表

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别把手机原图 4032×3024 直接推成 8MP 请求,又慢又容易失败。

响应解析

两个端点的成功响应结构完全一样,所以解析写一份共用就行。要点只有一个:无条件兼容 urlb64_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

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