fullpsd

FullPsd 开放平台文档

完整兼容的 PSD 解析、内容替换与变形调整,全部通过 HTTP 提供。这份文档里的每个端点、字段、环境变量都直接对应服务端源码;示例响应大多是真跑一次抓下来的。

概览

FullPsd 的服务端做三件事:

  • —— 把 PSD/PSB 解析成完整语义模型(/api/doc/inspect/api/doc/validate/api/psd/info),未修改时 serialize(parse(file)) 与原文件逐字节相同
  • —— 图层属性、文字、像素、图像资源(core op),换智能对象源图、网格与变形写回(服务端 op),统一入口 /api/doc/edit,出文件前必过守门。
  • 只改一小块就别传整份文件 —— /api/doc/edit/incremental 只往返你要改的那几段字节(改文字 / 改属性 / 改名 / 变形 / 放置描述符),产物与整文件模式逐字节相同。8.4 MB 的 PSD 改一次变形只走 ~5 KB。
  • —— 新的 PSD/PSB,或 PNG / JPEG / TIFF / WebP 位图,RGB 或 CMYK(/api/render/api/doc/composite)。

所有接口都是无状态的:服务端不落盘、不保留你上传的文件。变形写回还有一条片段协议,连 PSD 都不用传。

端点做什么返回
GET /health存活、渲染后端、可用 ICC、鉴权状态JSON
POST /api/session签发编辑器会话(token + 引擎密钥)JSON
GET /api/profiles服务端可用的 ICC 配置文件JSON
POST /api/psd/info图层 / 智能对象结构 + 兼容性提示JSON
POST /api/renderPSD + 替换图 → 渲染成品位图 / PSD
POST /api/doc/inspect完整文档模型摘要(不解像素)JSON
POST /api/doc/layer-preview单层像素预览PNG
POST /api/doc/composite文件里存好的合成图PNG / JPEG
POST /api/doc/patch批量改图层属性PSD
POST /api/doc/validate体检:lint + 字节级往返自检JSON
POST /api/doc/edit统一编辑端点(文件模式 / 片段模式)PSD / 位图 / JSON
POST /api/doc/edit/plandry-run,只判定 ops 可行性JSON
POST /api/doc/edit/incremental增量编辑:只传要改的那几段字节,不上传整份 PSDJSON
POST /api/so/resolve放置块字节 → 网格 / 变形结构JSON
POST /api/so/writeback编辑后的网格 / 变形 → 新放置块字节JSON

本页里凡标 真实响应 的示例,都是对同一份 1200×1200 的 tshirt.psd(3 个图层:背景 / 智能对象 img / 文字层)实际请求后抓取、只做了裁剪;标 结构示意 的是按源码字段拼出的形状,字段名与源码一致,具体数值仅供示意。

快速开始

1 · 拿一个 key

对外接口一律要凭证。API key 由部署方配置,两种方式二选一(见自托管):

部署方执行 · 环境变量

# 格式: key:名字:每分钟次数(rpm 可省略,默认 60),逗号分隔多个
export FULLPSD_API_KEYS="7f1c…随机长串:客户A:120"
npm start

或写 server/api-keys.json(已 gitignore,模板见 server/api-keys.example.json),可以按 key 配 rpm / concurrency / maxUploadMB没有配置任何 key 时,对外接口一律 401;本机调试可以用 FULLPSD_AUTH=off 完全关掉鉴权。

2 · 第一个请求

先确认服务活着(/health 不需要凭证):

bash

curl -s https://your-host/health

然后看看一份 PSD 里有什么 —— 这是绝大多数集成的第一步。下面所有示例里的 $FULLPSD_KEY 只是你本地 shell 里存放 key 的变量export FULLPSD_KEY=…),不是服务端认的环境变量:

bash

curl -s -X POST https://your-host/api/doc/inspect \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -F psd=@tshirt.psd

3 · 改点东西

拿到 flat[].index 之后就能编辑了。建议先 /api/doc/edit/plan 跑一次 dry-run,再提交 /api/doc/edit

bash · 换智能对象源图 + 改文字,一次做完

curl -s -X POST https://your-host/api/doc/edit \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -F psd=@tshirt.psd \
  -F image=@art.png \
  -F 'ops=[
    {"op":"replaceSmartObject","target":0,
     "image":{"file":"image","index":0},"fit":{"mode":"cover"}},
    {"op":"setText","index":2,"text":"新的文字"}
  ]' \
  -F 'output={"format":"psd"}' \
  -D headers.txt -o edited.psd

成功时响应体就是新 PSD 的字节,元数据在 X-Doc-Meta 响应头里;失败时是 JSON,不会给你一个半成品文件(见一致性保证)。

鉴权与会话

GET /healthPOST /api/session 外,所有 /api/* 都要凭证。有两类主体:

主体怎么带用途默认 rpm
API keyX-Api-Key: <key>对外集成60(可按 key 配)
编辑器会话Authorization: Bearer <token>浏览器编辑器(页面本身不带密钥)240(FULLPSD_SESSION_RPM

API key

  • 来源一:环境变量 FULLPSD_API_KEYS="key:名字:每分钟次数,key2:名字2"rpm 省略时按 60。
  • 来源二:JSON 文件 FULLPSD_API_KEYS_FILE(默认 server/api-keys.json),条目形如 { "key":"…", "name":"客户A", "rpm":60, "concurrency":2, "maxUploadMB":200 }
  • 比对用 crypto.timingSafeEqual(先比长度,再比字节)。key 短于 16 字符只会在服务端日志里告警,不会被拒绝 —— 请用随机长串。
  • maxUploadMB 会在 /api/render/api/doc/* 里对上传的 PSD 单独设限,超出 413
  • concurrency 是这把 key 自己的重接口并发上限(不配则不限,只受全局闸约束),与全局 FULLPSD_MAX_CONCURRENT 叠加:任一满都 429,响应体里的 scope 标明撞的是哪道闸。见重接口并发闸

编辑器会话

POST /api/session 签发一个 HMAC 签名的无状态 token(默认 12 小时,FULLPSD_SESSION_TTL_HOURS)。签名密钥取 FULLPSD_SESSION_SECRET,不设则每次启动随机生成 —— 重启后旧会话全部失效。

私有部署可设 FULLPSD_EDITOR_KEY=1,这样连签发会话都要求 X-Api-Key(且必须是 API key 主体,不能拿会话再换会话)。

响应 真实响应

{
  "token": "eyJzaWQiOiIwMmYwNjlmOTZlYzA5YzNlNTg4MDkyNWZlN2FlNjdjMiIsImV4cCI6MTc4ODgzNTU1MDk1NCwic2NvcGUiOiJlZGl0b3IifQ.ethWUEZ6erMTWjYPRV_T8D7GzOxQf1an9k7o7eY60vs",
  "sid": "02f069f96ec09c3e5880925fe7ae67c2",
  "expiresAt": 1788835550954,
  "engineKey": "4SKcKsW8j2nsjpynp48bRclcreMS46TlQnzhjeE6S84"
}

engineKey 只用于解密 GET /api/engine 下发的前端变形引擎,与业务 API 无关;响应带 Cache-Control: no-store

哪些端点认哪种凭证

端点API key会话不带凭证
/health/api/session✅ 放行
/api/profiles/api/psd/info/api/render401
/api/doc/*/api/so/*401
/api/engine403401

FULLPSD_AUTH=off 会让所有请求以匿名主体放行(rpm 视同无限),只应在本机调试用

CORS 与安全响应头

  • 默认只允许同源。FULLPSD_CORS_ORIGINS="https://a.com,https://b.com" 放行指定来源,值为 * 时放行任意 Origin(回显该来源,不是字面 *)。
  • 放行时附带:Access-Control-Allow-Headers: Content-Type, X-Api-Key, AuthorizationAllow-Methods: GET,POST,OPTIONSMax-Age: 600,并 Expose-Headers: X-Render-Meta, X-Doc-Meta, Content-Disposition —— 浏览器要读元数据头就靠这一条。
  • OPTIONS 一律直接 204。
  • 每个响应都带 X-Content-Type-Options: nosniffReferrer-Policy: no-referrer;服务不返回 X-Powered-By

限流与并发

令牌桶(每主体)

每个主体(key:<名字>sess:<sid>)一个桶:容量 = rpm,按 rpm/分钟 匀速回填,每个请求扣 1。桶空即 429,并带 Retry-After: 10。10 分钟不活动的桶会被回收。

rpm=2 的 key 连打 4 次 真实响应

200 200 429 429

HTTP/1.1 429 Too Many Requests
Retry-After: 10
{"error":"超过速率限制(2/分钟)"}

重接口并发闸

要真解像素 / 真渲染的接口另外挂两道叠加的并发闸,任一满都立即 429,不排队(排队只会拖垮内存):

  • 每凭证闸 —— API key 的 concurrencyapi-keys.json 里按 key 配;不配则这把 key 不受此闸约束)。编辑器会话不受此闸约束。
  • 全局闸 —— FULLPSD_MAX_CONCURRENT(默认 2),整机口径。

429 的响应体带 scope 区分撞的是哪道闸:"key" = 你自己把配额打满了(减少在途请求即可),"global" = 整机忙(要么等,要么加机器 / 调大上限)。同时带 limit(该闸的上限)与 Retry-After

429 真实响应

HTTP/1.1 429 Too Many Requests
Retry-After: 2
{"error":"该凭证的并发已满(1),请稍后重试","scope":"key","limit":1,"inflight":1}

HTTP/1.1 429 Too Many Requests
Retry-After: 2
{"error":"服务端渲染并发已满(2),请稍后重试","scope":"global","limit":2,"inflight":2}
过并发闸不过并发闸
/api/render/api/doc/layer-preview/api/doc/composite/api/doc/edit(仅 multipart 文件模式) /api/doc/inspect/api/doc/patch/api/doc/validate/api/doc/edit/plan/api/doc/edit(JSON 片段模式)、/api/doc/edit/incremental/api/so/*/api/psd/info/api/profiles

体积、像素与耗时

限制默认值环境变量超出时
单文件上传上限200 MBMAX_UPLOAD_MB413
单请求文件数32(image 字段最多 16、icc 1)400 / 413
按 key 的上传上限不限key 的 maxUploadMB413
解像素的单图上限4000 万像素FULLPSD_DOC_MAX_PIXELS413
/api/doc/* 阶段耗时闸20 000 msFULLPSD_DOC_TIMEOUT_MS504
/api/doc/edit 阶段耗时闸max(DOC_TIMEOUT_MS, 60000)FULLPSD_EDIT_TIMEOUT_MS504
单次 ops 条数512FULLPSD_DOC_MAX_OPS400
片段模式 JSON 体积32 mbFULLPSD_EDIT_JSON_LIMIT / FULLPSD_SO_JSON_LIMIT413
片段一次处理的智能对象数64400

耗时闸是在阶段边界上掐的(解析 / 执行 ops / 写回 / 守门之间),因为解析与写回本身是同步的,没法在中途打断。

错误码总表

所有错误响应都是 JSON,只给一句话,不带堆栈FULLPSD_DEV=1 时才额外附 stack)。

基本形状

{ "error": "缺少 psd 文件字段(multipart 字段名 psd)" }

/api/doc/edit/api/doc/edit/plan/api/doc/edit/incremental 会在此基础上附加定位字段(存在才出现):opIndexopNamestagecodeoffsetopsguardappliedneed(增量模式:还缺哪几个片段)、fragmentId(增量模式:是哪个片段的校验和不符)。

状态码什么时候出现响应体
400输入不合法:缺 psd 字段、ops 不是 JSON / 空数组 / 未知 op、字段类型错、图层下标越界、混合模式键不在白名单、target 找不到或不是智能对象、图片引用对不上、edit 与图层类型不匹配、PSD 解析失败、format 不支持{ error, opIndex?, opName?, code? }
401没带 X-Api-Key / Authorization: Bearer,或 key 无效、会话过期{ error }
403凭证类型无权访问该接口(例如拿 API key 打 /api/engine{ error }
413超过 MAX_UPLOAD_MB 或该 key 的 maxUploadMB;画布 / 图层 / 上传图片超过 FULLPSD_DOC_MAX_PIXELS{ error }
415/api/doc/editContent-Type 既不是 multipart/form-data 也不是 application/json/api/doc/edit/plan 只收 multipart,/api/doc/edit/incremental 只收 JSON{ error }
422产物没过守门(lintPsd 不通过 / 无法重新解析 / 自身不再字节往返),或写回失败没有产物 —— 不返回文件字节/api/render/api/doc/edit/api/doc/patch 同一语义同一状态码:422 表示「我们产出的文件不合格」,与「你的入参错了」(400)分开{ error, stage, code?, offset?, guard, ops }/patch{ error, lint, applied }/api/render{ error, stage:"lint", code:"artifact_lint_failed", lint }
429超过该主体的 rpm(带 Retry-After: 10),或撞上并发闸(带 Retry-After: 2scopekey = 该凭证的 concurrencyglobal = FULLPSD_MAX_CONCURRENT{ error, scope?, limit?, inflight? }
500服务端自身出错。/api/doc/edit 明确区分:能归因于输入的一律 4xx,剩下的才 500 —— 看到 500 请报 bug{ error }
504某个阶段超过耗时闸(FULLPSD_DOC_TIMEOUT_MS / FULLPSD_EDIT_TIMEOUT_MS{ error }

实际抓到的几条

真实错误响应 真实响应

# 400:POST /api/doc/inspect 不带文件
{"error":"缺少 psd 文件字段(multipart 字段名 psd)"}

# 400:未知 op(错误信息里会列出全部可用 op)
{"error":"ops[0] 未知 op「nope」(可用:deform deleteLayer removeImageResource renameLayer
 reorderLayers replaceLayerPixels replaceSmartObject replaceSmartObjectImage setBlendMode
 setClipping setImageResource setLayerProps setMesh setOpacity setText setVisible)"}

# 401:没带凭证
{"error":"需要鉴权:对外接口请带 X-Api-Key;编辑器请先 POST /api/session 取会话"}

# 401:key 不对
{"error":"API key 无效"}

# 403:拿 API key 打 /api/engine
{"error":"当前凭证无权访问该接口"}

# 413:MAX_UPLOAD_MB=1 时上传 8.8 MB 的 PSD 到 /api/doc/inspect
{"error":"上传的文件超过本服务的上限(MAX_UPLOAD_MB=1 MB)"}

# 415:/api/doc/edit 的 Content-Type 不对
{"error":"Content-Type 必须是 multipart/form-data(文件模式:psd + ops)或 application/json(片段模式:{ fragment, edit })"}

# 404:路径不存在
{"error":"Not found"}
中间件层的错也是 JSON:上传超限(multer)、请求体不是合法 JSON、请求体超过接口上限,这些错发生在进入处理器之前,由 app 级错误中间件统一收口 —— 任何路由(/api/render/api/doc/* 一视同仁)都返回 4xx JSON:超限 413、坏 JSON 400,同样是一句话、非 FULLPSD_DEV=1 不带 stack、不含服务端路径。仍建议在反向代理上另设请求体上限,让超大请求在更前面就被挡掉。
中间件层错误 真实响应

# 413:MAX_UPLOAD_MB=1 时上传 2 MB 的 PSD 到 /api/render
{"error":"上传的文件超过本服务的上限(MAX_UPLOAD_MB=1 MB)","code":"LIMIT_FILE_SIZE"}

# 400:请求体不是合法 JSON(/api/session、/api/so/*、/api/doc/edit 片段模式同)
{"error":"请求体不是合法 JSON"}

# 413:JSON 请求体超过该接口的上限
{"error":"请求体超过本接口的上限"}

端点参考

下面每个端点按同一套模板写:用途 → 鉴权 → 请求 → 响应 → 错误 → 限制 → 可复制的示例。每个端点右上角的「在调试台里打开」会跳到 /console 对应的表单。

GET/health在调试台里打开 →

存活探针:渲染后端、可用 ICC、上传上限、鉴权与构建状态。

鉴权
不需要
请求
无参数
并发闸
不占

响应字段

字段类型说明
okboolean恒为 true(能返回就说明活着)
backendstring渲染后端:cpu(纯 JS,默认)或 gl(部署方自行装了 headless-gl)
fitModesarray[{ value, label }],覆盖模式清单
outputsobject{ format:[…], colorMode:[…] }
iccProfilesstring[]server/profiles/ 下可用 ICC 的名字
maxUploadMBnumber本服务的上传上限
authstringoff / api-key / api-key(未配置任何 key,对外接口不可用)
editorstringready / dev / 未构建(npm run build)
nodestring服务端 Node 版本
响应 真实响应

{
  "ok": true,
  "backend": "cpu",
  "fitModes": [
    { "value": "cover",   "label": "撑满(裁掉溢出)" },
    { "value": "contain", "label": "适应(留白)" },
    { "value": "tile",    "label": "铺满(平铺重复)" },
    { "value": "stretch", "label": "拉伸(非等比填满)" }
  ],
  "outputs": { "format": ["png","jpg","tiff","webp","psd"], "colorMode": ["rgb","cmyk"] },
  "iccProfiles": ["a98","default_cmyk","default_gray","default_rgb","esrgb","FOGRA39L_coated",
                  "gray_to_k","lab","ps_cmyk","ps_gray","ps_rgb","rommrgb","scrgb","sgray","srgb","sRGB"],
  "maxUploadMB": 200,
  "auth": "off",
  "editor": "ready",
  "node": "v22.23.2"
}
bash

curl -s https://your-host/health

POST/api/session在调试台里打开 →

签发一个短期编辑器会话(无状态 HMAC token)。

鉴权
默认不需要;FULLPSD_EDITOR_KEY=1 时要求 X-Api-Key
Content-Type
application/json(请求体可以为空对象,上限 4 kb)
响应头
Cache-Control: no-store

响应字段

字段类型说明
tokenstring放进 Authorization: Bearer。格式 base64url(payload).base64url(HMAC-SHA256)
sidstring会话 id(16 字节 hex)
expiresAtnumber过期时刻(Unix 毫秒)
engineKeystringbase64url 的 32 字节密钥,只用于解密 GET /api/engine 下发的前端引擎

错误

401 —— 仅当 FULLPSD_EDITOR_KEY=1 且没带有效 X-Api-Key{"error":"此部署的编辑器需要 API key"}

bash

curl -s -X POST https://your-host/api/session \
  -H "Content-Type: application/json" -d '{}'

GET/api/profiles在调试台里打开 →

列出服务端 server/profiles/ 下可以按名字引用的 ICC 配置文件。

鉴权
API key 或会话
请求
无参数

响应字段

字段类型说明
profiles[].namestring写进 output.icc 的名字(只接受名字,不接受路径
profiles[].sizeKBnumber文件大小,四舍五入到 KB
notestring关于 CMYK 不带 ICC 时颜色不准的固定说明
响应(裁剪) 真实响应

{
  "profiles": [
    { "name": "a98", "sizeKB": 1 },
    { "name": "default_cmyk", "sizeKB": 183 },
    { "name": "FOGRA39L_coated", "sizeKB": 119 },
    { "name": "ps_cmyk", "sizeKB": 5 },
    { "name": "sRGB", "sizeKB": 3 }
  ],
  "note": "CMYK 输出不传 icc 时走 libvips 内置公式,颜色不准,只能预览。出印刷文件请传目标印刷条件的 ICC(放进 server/profiles/ 按名字引用,或直接上传文件内容)。"
}
bash

curl -s https://your-host/api/profiles -H "X-Api-Key: $FULLPSD_KEY"

POST/api/psd/info在调试台里打开 →

渲染视角的结构信息:图层清单 + 智能对象的网格 / 变形 / 源图,外加合成兼容性提示。

鉴权
API key 或会话
Content-Type
multipart/form-data
并发闸
不占
这个端点服务的是 /api/rendersmartObjects[].index 就是 /api/renderreplacements[].target 认的智能对象序号。要图层树、蒙版、图像资源这类文档结构,请用 /api/doc/inspect(两者的 index 口径不同,别混用)。

请求字段

字段类型必填说明
psdfilePSD / PSB 文件

响应字段

字段类型说明
width / heightnumber画布尺寸
layers[]array{ name, hidden, bounds, blendMode, isSmartObject, isText, layerType, adjustment, fill }
smartObjects[]array{ index, name, kind, bounds, originalSize, warpStyle, deformFilter?, mesh, sourceImage }
smartObjects[].meshobject{ xn, yn, sliceX, sliceY, patches, regular }regular:false 表示不是规则网格,不能直接渲染
smartObjects[].deformFilterobject仅变形滤镜层才有:{ kind, name }
parseWarningsarray解析告警,为空时字段不出现
compatibilityWarningCount
compatibilityWarnings
number
array
合成兼容性提示(最多 20 条明细)。为空时两个字段都不出现
响应 真实响应

{
  "width": 1200,
  "height": 1200,
  "layers": [
    { "name": "背景", "hidden": false, "bounds": { "top": 0, "left": 0, "bottom": 1200, "right": 1200 },
      "blendMode": "norm", "isSmartObject": false, "isText": false,
      "layerType": "pixel", "adjustment": null, "fill": null },
    { "name": "img", "hidden": false, "bounds": { "top": 370, "left": 461, "bottom": 807, "right": 746 },
      "blendMode": "mul ", "isSmartObject": true, "isText": false,
      "layerType": "smartObject", "adjustment": null, "fill": null },
    { "name": "无法无天", "hidden": false, "bounds": { "top": 200, "left": 431, "bottom": 372, "right": 679 },
      "blendMode": "norm", "isSmartObject": false, "isText": true,
      "layerType": "text", "adjustment": null, "fill": null }
  ],
  "smartObjects": [
    { "index": 0, "name": "img", "kind": "SoLd",
      "bounds": { "top": 370, "left": 461, "bottom": 807, "right": 746 },
      "originalSize": { "width": 800, "height": 800 },
      "warpStyle": "warpCustom",
      "mesh": { "xn": 4, "yn": 4, "sliceX": [], "sliceY": [], "patches": "1×1", "regular": true },
      "sourceImage": { "filename": "LULI2874.png", "mime": "image/png", "bytes": 779016 } }
  ]
}

错误与限制

400psd 字段或解析失败 · 413 超过 MAX_UPLOAD_MB · 401/403 凭证问题 · 429 rpm。会解码全部图层栅格,大文件比 /api/doc/inspect 慢得多。

bash

curl -s -X POST https://your-host/api/psd/info \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -F psd=@tshirt.psd

POST/api/render在调试台里打开 →

PSD + 替换图 → 成品:按智能对象的网格 / 变形把新图扭曲上去,输出位图或可继续编辑的 PSD。

鉴权
API key 或会话
Content-Type
multipart/form-data(推荐)或 application/json(图片走 base64)
并发闸
(每 key concurrency + 全局 FULLPSD_MAX_CONCURRENT,默认 2)
响应
二进制;元数据在 X-Render-Meta

请求字段(multipart)

字段类型必填说明
psdfile ×1PSD / PSB
imagefile ×≤16替换图。第 nimage 字段对应 spec.replacements[n]
iccfile ×1直接上传 ICC;给了就覆盖 output.icc
specstring(JSON){ replacements, output, render }。不是合法 JSON → 400

纯 JSON 模式:整个请求体就是 spec,PSD 放 psdBase64,每个替换图放 replacements[].imageBase64(接受 data: 前缀)。

spec.replacements[]

字段类型默认说明
targetnumber | string | null0智能对象序号/api/psd/infosmartObjects[].index)或智能对象图层名。省略 = 第 0 个
fitobject{"mode":"cover"}覆盖模式,见下
meshobject覆盖网格,见下
sourceNamestring上传文件名写进 PSD 的内嵌源图文件名
imageBase64string纯 JSON 模式下的替换图

fit(覆盖模式)

字段类型默认说明
modestringcovercover 撑满裁溢出 · contain 适应留白 · tile 平铺重复 · stretch 非等比填满
alignX / alignYnumber0.50 = 左/上,0.5 = 居中,1 = 右/下
scalenumber1额外缩放(tile 下即平铺密度的倒数)
offsetX / offsetYnumber0额外平移,单位 = 设计区像素
rotatenumber0绕设计区中心旋转,单位是弧度(度数请自行 deg * Math.PI / 180

mesh(可选,改网格)

二选一:{ points:[{x,y}…], xn, yn, sliceX?, sliceY? } 直接给控制点(点数必须等于 xn*yn,且 xn/yn 必须同时给),或 { splits:{ u:[0.33,0.66], v:[0.5] } } 在指定纹理坐标处加经/纬线。拆分过的网格写回时走 quiltWarp,PS 里仍可继续拖。变形滤镜层(透视/操控变形)不接受 mesh —— 会 400。

output / render

字段类型默认说明
output.formatstringpngpng / jpg / tiff / webp / psd
output.colorModestringrgbrgb / cmyk。PNG 不支持 CMYK(400)
output.qualitynumber92JPEG / TIFF / WebP 质量
output.dpinumber72写进位图元数据
output.backgroundstring | number[]#ffffffJPEG / CMYK 拍平 alpha 时的底色,"#rrggbb"[r,g,b]
output.width / heightnumber输出前 resize(fit: inside,允许放大)
output.iccstring/api/profiles 里的名字。/\ 一律 400;也可以直接上传 icc 文件字段
output.attachIccbooleantruefalse 时只做分色、不嵌 ICC
render.backendstringautoauto / cpu / gl。要求 gl 但没装 headless-gl → 400
render.supersamplenumber2超采样倍数
render.subdivnumber20网格细分密度

响应

响应体是二进制。Content-Type 随格式(image/png / image/jpeg / image/tiff / image/webp / image/vnd.adobe.photoshop),Content-Disposition: inline; filename="render.<ext>",元数据在 X-Render-Meta

为什么元数据里的中文是 \uXXXXHTTP 头只能放 Latin-1,而图层名和告警文案里有中文。服务端把 JSON 里 U+0080 以上的字符转成 \uXXXX 转义 —— 转义后仍是合法 JSON,客户端直接 JSON.parse 就能还原。X-Doc-Meta 同理。
字段说明
docW / docH画布尺寸
applied[]每个替换的落地情况:index(智能对象序号)、namelayerIndexmesh"4×4" 或变形滤镜名)、fit
applied[].warpWritten仅 PSD 输出且改过网格:"warp" / "quiltWarp" / "失败(…),已降级为烘焙像素"
applied[].sourceReplaced仅 PSD 输出:内嵌源图是否换掉(决定 PS 里双击进去看到的是不是新图)
applied[].filterCacheUpdated仅变形滤镜层:FEid 缓存是否同步(false/失败字符串意味着 PS 双击后可能恢复原样)
backendcpu / gl
colorMode / icc / iccWarning实际色彩模式、用了哪个 ICC;没传 ICC 的 CMYK 会带告警
compatibilityWarningCount
compatibilityWarnings
合成兼容性提示(调整层 / 填充层 / 矢量蒙版 / 图层样式 / 未实现混合模式 / 组隔离),最多 20 条明细。为空时不出现
lintWarnings仅 PSD 输出且 lint 有告警时出现
ms服务端耗时
X-Render-Meta(PNG 输出) 真实响应

{ "docW": 1200, "docH": 1200,
  "applied": [ { "index": 0, "name": "img", "layerIndex": 1, "mesh": "4×4", "fit": "cover" } ],
  "backend": "cpu", "colorMode": "rgb", "icc": null, "ms": 869 }
X-Render-Meta(PSD 输出,字段来自源码) 结构示意

{ "docW": 1200, "docH": 1200,
  "applied": [ { "index": 0, "name": "img", "layerIndex": 1, "mesh": "4×4", "fit": "cover",
                 "warpWritten": "warp", "sourceReplaced": true, "filterCacheUpdated": true } ],
  "backend": "cpu", "ms": 1240 }

错误与限制

400 缺 PSD / spec 不是 JSON / 找不到 target / 智能对象不是规则网格 / 变形滤镜层传了 mesh / PNG+CMYK / output.icc 带路径 · 413 超过 MAX_UPLOAD_MB 或按 key 的 maxUploadMB(JSON,非 HTML)· 422 生成的 PSD 未过 lintPsd(与 /api/doc/edit 的守门同语义,响应体带 stage:"lint" / code:"artifact_lint_failed" / lint,且不返回文件字节)· 429 rpm 或并发闸(scope 区分 key / global)。

PSD 转 CMYK 只支持 8 位/通道的 RGB 或灰度源文档;文件里没有智能对象(SoLd/PlLd)时直接 400。

bash

curl -s -X POST https://your-host/api/render \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -F psd=@tshirt.psd \
  -F image=@art.png \
  -F 'spec={
    "replacements":[{"target":0,"fit":{"mode":"cover","alignX":0.5,"alignY":0.5}}],
    "output":{"format":"png","dpi":72},
    "render":{"backend":"auto","supersample":2}
  }' \
  -D headers.txt -o render.png

POST/api/doc/inspect在调试台里打开 →

完整文档模型摘要:图层树 + 扁平表 + 图像资源 + 链接文件 + 全局块,纯 JSON、不解一个像素。

鉴权
API key 或会话
Content-Type
multipart/form-data
并发闸
不占
响应头
Cache-Control: no-store

解析层是 core/psd-document.js:七段语义树,每段都带绝对偏移与 _raw 兜底。响应体保证是纯 JSON —— 任何字节数组都被换成 { "bytes": n },不会有大数组。

请求字段

字段类型必填说明
psdfilePSD / PSB

响应字段

字段类型说明
headerobject{ signature, version, psb, width, height, channels, depth, mode, modeName }
colorModeDataobject{ kind, length }kindempty / indexed / duotone
imageResources[]array{ id, sig, name, label, length }
layerCountnumber图层记录条数(含分组头与分隔层)
layers[]array图层:分组节点有 childrenisGroup:truedepth
flat[]array扁平表,字段同上,index 就是所有编辑接口用的图层下标(底 = 0)
globalAdditionalKeys[]string[]全局附加块 key(如 PattTxt2lnk2FMsk
linkedFiles[]array{ id, name, type, fileType, embedded, byteLength }type:"liFD" 即内嵌
imageDataobject末尾合成图段:{ compression, length }
warnings[]array解析告警(读不懂的东西进这里,不抛错)
smartObjects[]arrayflat 里挑出的智能对象:{ index, name, bounds, kind, linkId, source }
textLayers[]array{ index, name, text, length }
imageResourceIds[]number[]资源 ID 清单
countsobjectkind 计数
metaobject{ bytes, filename, ms }

flat[] / layers[] 的单层字段

字段说明
index图层下标(底 = 0)。所有 core op 的 index 都用这个
name / pascalNameUnicode 名(luni 块,PS 显示的就是它)/ 老式 Pascal 名(非 ASCII 会是乱码,属正常)
kindraster / group / divider / text / shape / smartObject,再由 API 层细分出 adjustment / fill
bounds / width / height包围盒与尺寸
section图层记录所在段:layerInfo,或 16/32 位文档的 Lr16 / Lr32
visible / opacity / clipping / blendKey / passThrough混合属性。opacity 是 0…255
groupType1 展开组 · 2 折叠组 · 3 组结束分隔层
locked / colorLabel / layerId锁定标志 / 颜色标签 / lyid
hasMask / maskDisabled / hasVectorMask / vectorKnots蒙版与矢量路径
isSmartObject / smartObject{ kind:"SoLd"|"PlLd", linkId, source }
isText / text / textLength / textTruncated文字内容。超过 1024 字符会截断并置 textTruncated:truetextLength 是截断前长度
adjustment / fill{ key, label }null(如 curv 曲线、SoCo 纯色填充)
effects{ keys, items:[{ key, label, enabled }] }null
channels[]{ id, kind, name, length, compression }id:-1 是透明度通道
additionalKeys[]该层的附加块 key 列表(TyShSoLdluni…)
响应(裁剪:省略了 flat 的其余条目与部分资源) 真实响应

{
  "header": { "signature": "8BPS", "version": 1, "psb": false, "width": 1200, "height": 1200,
              "channels": 3, "depth": 8, "mode": 3, "modeName": "RGB" },
  "colorModeData": { "kind": "empty", "length": 0 },
  "imageResources": [
    { "id": 1028, "sig": "8BIM", "name": "", "label": "IPTC-NAA record", "length": 15 },
    { "id": 1061, "sig": "8BIM", "name": "", "label": "Caption digest (MD5)", "length": 16 },
    { "id": 1060, "sig": "8BIM", "name": "", "label": "XMP metadata", "length": 15345 }
  ],
  "layerCount": 3,
  "layers": [
    { "index": 2, "name": "无法无天", "pascalName": "ÎÞ·¨ÎÞÌì",
      "bounds": { "top": 200, "left": 431, "bottom": 372, "right": 679 },
      "width": 248, "height": 172, "kind": "text", "section": "layerInfo",
      "visible": true, "opacity": 255, "clipping": false, "blendKey": "norm",
      "passThrough": null, "groupType": null, "locked": false, "colorLabel": "none",
      "layerId": 4, "hasMask": false, "maskDisabled": false, "hasVectorMask": false,
      "vectorKnots": 0, "isSmartObject": false, "isText": true, "text": "无法无天",
      "smartObject": null, "effects": null,
      "channels": [ { "id": -1, "kind": "transparency", "name": "Transparency", "length": 6047, "compression": 1 },
                    { "id": 0, "kind": "color", "name": "R", "length": 1034, "compression": 1 } ],
      "additionalKeys": [ "TySh", "luni", "lnsr", "lyid", "clbl", "infx", "knko", "lspf", "lclr", "shmd", "fxrp" ],
      "depth": 0, "isGroup": false, "children": null,
      "adjustment": null, "fill": null, "textLength": 4 },
    { "index": 1, "name": "img", "kind": "smartObject", "blendKey": "mul ",
      "bounds": { "top": 370, "left": 461, "bottom": 807, "right": 746 },
      "isSmartObject": true,
      "smartObject": { "kind": "SoLd", "linkId": "deeadabe-5ccd-2f49-9041-f8a33b59f73f",
                       "source": { "id": "deeadabe-5ccd-2f49-9041-f8a33b59f73f",
                                   "name": "LULI2874.png", "fileType": "png",
                                   "embedded": true, "byteLength": 779016 } },
      "additionalKeys": [ "luni", "lnsr", "lyid", "clbl", "infx", "knko", "lspf", "lclr", "shmd", "PlLd", "SoLd", "fxrp" ] }
  ],
  "flat": [ "…与 layers 同样的对象,只是不分层…" ],
  "globalAdditionalKeys": [ "Patt", "Txt2", "CAI ", "OCIO", "GenI", "lnk2", "lnkE", "FMsk", "cinf" ],
  "linkedFiles": [ { "id": "deeadabe-5ccd-2f49-9041-f8a33b59f73f", "name": "LULI2874.png",
                     "type": "liFD", "fileType": "png", "embedded": true, "byteLength": 779016 } ],
  "imageData": { "compression": 1, "length": 3874862 },
  "warnings": [],
  "smartObjects": [ { "index": 1, "name": "img",
                      "bounds": { "top": 370, "left": 461, "bottom": 807, "right": 746 },
                      "kind": "SoLd", "linkId": "deeadabe-5ccd-2f49-9041-f8a33b59f73f",
                      "source": { "name": "LULI2874.png", "embedded": true, "byteLength": 779016 } } ],
  "textLayers": [ { "index": 2, "name": "无法无天", "text": "无法无天", "length": 4 } ],
  "imageResourceIds": [ 1028, 1061, 1060, 1082, 1083, 1005, 1062, 1010, 1037, 1049, 1011, 10000,
                        1013, 1016, 1024, 1026, 1072, 1069, 1032, 1092, 1097, 1054, 1050, 1064,
                        1041, 1044, 1036, 1057, 1058 ],
  "counts": { "raster": 1, "smartObject": 1, "text": 1 },
  "meta": { "bytes": 8805986, "filename": "tshirt.psd", "ms": 21 }
}
两个 index 别混:inspectsmartObjects[].index图层下标(上例里是 1);/api/psd/infosmartObjects[].index智能对象序号(上例里是 0),也就是 /api/rendertarget/api/doc/edittarget 两种都认,用 {"layerIndex":n} / {"smartObjectIndex":n} 显式区分最保险。

错误与限制

400psd / 解析失败 · 413 上传超限 · 504 解析超过 FULLPSD_DOC_TIMEOUT_MS · 不解码任何像素,所以对大文件也很快。

bash

curl -s -X POST https://your-host/api/doc/inspect \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -F psd=@tshirt.psd

POST/api/doc/layer-preview在调试台里打开 →

解一个图层的像素,输出带 alpha 的 PNG。

鉴权
API key 或会话
Content-Type
multipart/form-data
并发闸
响应
image/png;元数据在 X-Doc-Meta

请求字段

字段类型默认说明
psdfile必填
layerIndexinteger必填,对应 /api/doc/inspectflat[].index
maxSizeinteger0长边上限,等比缩小(0 = 原尺寸,不放大)
applyMaskstring应用0false应用图层蒙版

响应头 X-Doc-Meta

字段说明
index / name / bounds图层下标、Unicode 名、包围盒
width / height解出来的像素尺寸(缩放前)
depth / mode位深与色彩模式号
warnings[]像素解码告警,最多 20 条
ms耗时
响应头 真实响应

HTTP/1.1 200 OK
Content-Type: image/png
Content-Disposition: inline; filename="layer-1.png"
X-Doc-Meta: {"index":1,"name":"img","bounds":{"top":370,"left":461,"bottom":807,"right":746},
             "width":285,"height":437,"depth":8,"mode":3,"warnings":[],"ms":213}

错误与限制

400layerIndex / 下标越界 / 该层没有像素(分组、分隔层宽或高为 0)/ 像素解码失败 · 413 单层像素数超过 FULLPSD_DOC_MAX_PIXELS · 429 并发闸 · 504 超时。

bash

curl -s -X POST https://your-host/api/doc/layer-preview \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -F psd=@tshirt.psd -F layerIndex=1 -F maxSize=512 \
  -D headers.txt -o layer-1.png
JavaScript · fetch

const fd = new FormData();
fd.append('psd', psdFile);
fd.append('layerIndex', '1');
fd.append('maxSize', '512');

const res = await fetch('/api/doc/layer-preview', {
  method: 'POST', headers: { 'X-Api-Key': key }, body: fd
});
if (!res.ok) throw new Error((await res.json()).error);
const meta = JSON.parse(res.headers.get('X-Doc-Meta'));
const url = URL.createObjectURL(await res.blob());

POST/api/doc/composite在调试台里打开 →

取文件末尾 Image Data 段里 Photoshop 存好的合成图,输出 PNG / JPEG。

鉴权
API key 或会话
Content-Type
multipart/form-data
并发闸
响应
image/pngimage/jpeg;元数据在 X-Doc-Meta
这是「取」不是「渲染」。返回的是 PS 保存时写进文件的那张合成图,不重新合成图层 —— 快、而且与 PS 所见完全一致。要按新素材重新合成,用 /api/render

请求字段

字段类型默认说明
psdfile必填
formatstringpngpng / jpgjpeg 同义);其它值 400
qualityinteger92JPEG 质量,钳到 1…100
maxSizeinteger0长边上限,等比缩小、不放大

响应头 X-Doc-Meta

{ width, height, depth, mode, modeName, compression, warnings, ms }compression0 RAW · 1 RLE · 2 ZIP · 3 ZIP+prediction。

响应头 真实响应

HTTP/1.1 200 OK
Content-Type: image/jpeg
Content-Disposition: inline; filename="composite.jpg"
X-Doc-Meta: {"width":1200,"height":1200,"depth":8,"mode":3,"modeName":"RGB",
             "compression":1,"warnings":[],"ms":140}

错误与限制

400 format 不支持 / 合成图解码失败(文件没有合成图段,或压缩方式不支持)· 413 画布像素数超上限 · 429 并发闸 · 504 超时。JPEG 会先在白底上拍平 alpha。

bash

curl -s -X POST https://your-host/api/doc/composite \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -F psd=@tshirt.psd -F format=jpg -F quality=80 -F maxSize=800 \
  -o composite.jpg

POST/api/doc/patch在调试台里打开 →

批量改图层属性(名字 / 可见 / 不透明度 / 剪贴 / 混合模式)→ 新 PSD 字节。

鉴权
API key 或会话
Content-Type
multipart/form-data
并发闸
不占
响应
image/vnd.adobe.photoshop;元数据在 X-Doc-Meta

只改属性,不动像素、不动智能对象内容。要做更多(文字 / 像素 / 换源图 / 变形),用 /api/doc/edit;这个端点保留是为了老集成不用改。

请求字段

字段类型必填说明
psdfilePSD / PSB
opsstring(JSON 数组)见下。也接受 patch 字段(JSON 对象,取其中的 .ops

ops[] 每条

字段类型说明
indexinteger必填flat[].index;越界 400
namestring非空、≤255 字符。写 Unicode 图层名
visibleboolean
opacityinteger0…255(255 = 不透明)
clippingboolean是否剪贴到下层
blendKeystring4 字符混合模式键,不足补空格。白名单校验,见 ops 参考里的模式表

index 外至少要给一个字段,否则 400。

响应头 X-Doc-Meta

字段说明
applied[]{ index, changed:["name","opacity"…] }
bytes / inputBytes产物与输入的字节数
lint{ ok:true, warnings, summary }summary 里有 psb/channels/W/H/depth/mode/resources/layers/linkedFiles/globalBlocks
parseWarnings输入文件的解析告警,最多 20 条
ms耗时
响应头 真实响应

HTTP/1.1 200 OK
Content-Type: image/vnd.adobe.photoshop
Content-Disposition: attachment; filename="patched.psd"
X-Doc-Meta: {"applied":[{"index":1,"changed":["name","opacity"]},
                        {"index":2,"changed":["visible","blendKey"]}],
             "bytes":8805986,"inputBytes":8805986,
             "lint":{"ok":true,"warnings":[],
                     "summary":{"psb":false,"channels":3,"W":1200,"H":1200,"depth":8,"mode":3,
                                "resources":29,"layers":3,"linkedFiles":1,"globalBlocks":9}},
             "parseWarnings":[],"ms":54}

422:体检不过

写回后必须先过 lintPsd,再被自己重新解析一遍。任何一步不过只返回报告、不返回文件 —— 长度字段一旦不自洽,Photoshop 只会说「意外地遇到文件尾」。

422 响应体 结构示意

{
  "error": "写回结果未通过结构体检(lintPsd),已放弃返回文件",
  "lint": { "ok": false, "errors": ["…"], "warnings": ["…"], "summary": { } },
  "applied": [ { "index": 1, "changed": ["name"] } ]
}

错误与限制

400ops / ops 不是 JSON 数组或空数组 / 超过 FULLPSD_DOC_MAX_OPS(默认 512)/ index 越界 / 字段类型错 / blendKey 不在白名单 / 附加区被降级成原样字节的图层不支持改名 · 422 见上 · 504 超时。

bash

curl -s -X POST https://your-host/api/doc/patch \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -F psd=@tshirt.psd \
  -F 'ops=[{"index":1,"name":"新图案","opacity":200},
           {"index":2,"visible":false,"blendKey":"mul "}]' \
  -D headers.txt -o patched.psd

POST/api/doc/validate在调试台里打开 →

体检:解析 + lintPsd 逐段走长度字段 + 字节级往返自检。排查「PS 打不开」时先打这个。

鉴权
API key 或会话
Content-Type
multipart/form-data
并发闸
不占
响应
JSON(文件有问题也返回 200ok 才是结论)

响应字段

字段类型说明
okbooleanlint.ok && roundTripIdentical && 解析告警为空,三者都满足才 true
warnings[]array解析告警(有东西没读懂,但不影响往返)
lintobject{ ok, errors, warnings, summary }
roundTripIdenticalbooleanserialize(parse(file)) 是否与原文件逐字节相同
firstDiffOffsetnumber第一个不同字节的偏移;完全一致时 -1报 bug 请带上它
roundTripErrorstring重新序列化时抛错才出现
documentobject{ psb, width, height, depth, mode, modeName, layerCount, imageResources, linkedFiles, globalAdditionalKeys }
bytesobject{ input, serialized }
msnumber耗时
响应 真实响应

{
  "ok": true,
  "warnings": [],
  "lint": { "ok": true, "errors": [], "warnings": [],
            "summary": { "psb": false, "channels": 3, "W": 1200, "H": 1200, "depth": 8, "mode": 3,
                         "resources": 29, "layers": 3, "linkedFiles": 1, "globalBlocks": 9 } },
  "roundTripIdentical": true,
  "firstDiffOffset": -1,
  "document": { "psb": false, "width": 1200, "height": 1200, "depth": 8, "mode": 3,
                "modeName": "RGB", "layerCount": 3, "imageResources": 29, "linkedFiles": 1,
                "globalAdditionalKeys": ["Patt","Txt2","CAI ","OCIO","GenI","lnk2","lnkE","FMsk","cinf"] },
  "bytes": { "input": 8805986, "serialized": 8805986 },
  "ms": 71
}

怎么读

  • lint.errors 非空 = 文件的长度字段不自洽,PS 大概率打不开。
  • roundTripIdentical:false = 我们的模型还没完全吃透这个文件;firstDiffOffset 指向第一个分歧字节。这不代表文件坏了,但意味着基于它的编辑会更保守。
  • warnings 非空 = 有块读不懂、被按 _raw 原样保留了 —— 往返仍然安全。

400 只在「连 PSD 都不是」时出现。

bash

curl -s -X POST https://your-host/api/doc/validate \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -F psd=@suspect.psd

POST/api/doc/edit在调试台里打开 →

统一编辑端点:一个路径、两种模式,按 Content-Type 分派。改属性 / 文字 / 像素(core op)与换智能对象源图 / 变形写回(服务端 op)可以在同一个 ops 数组里任意混排、按顺序执行

鉴权
API key 或会话
Content-Type
multipart/form-data(文件模式)· application/json(片段模式)· 其它 → 415
并发闸
文件模式 ;片段模式不占
耗时闸
FULLPSD_EDIT_TIMEOUT_MS(默认 60 s)
顺序语义:服务端会把连续同类的 op 切成分段批处理,但这只是批处理手段 —— 段内段间都严格保持数组顺序,与逐条执行完全一致。中间分段不重复守门,整串跑完后统一守门一次。

① 文件模式(multipart/form-data

字段类型必填说明
psdfile ×1PSD / PSB
imagefile ×≤16换源图 / 换像素用的图片,op 里按下标或文件名引用
iccfile ×1output.formatpsd/psb 时生效
opsstring(JSON 数组)ops 参考。也可以放在 spec.ops
outputstring(JSON 对象)见下。也可以放在 spec.output
renderstring(JSON 对象){ backend, supersample, subdiv },只对 replaceSmartObject 有意义。也可放 spec.render
op 里怎么引用上传的图片
写法含义
{"image":{"file":"image","index":0}}第 0 个 image 字段(推荐,最不容易歧义)
{"image":{"name":"logo.png"}}按上传文件名;重名会 400,要你改用 index
{"image":0}下标简写
{"image":"data:image/png;base64,…"}内联(也接受长度 > 64 的裸 base64)
{"image":{"base64":"…"}}同上,对象写法(dataURL 同义)

file 字段只能是 "image"(本端点的图片字段名固定),写别的会 400。

output
字段类型默认说明
formatstringpsdpsd / psb / png / jpg / tiff / webp
colorModestringrgb位图输出时 cmyk 只支持 jpg / tiff(PNG/WebP 会 400)
qualitynumber92JPEG / TIFF / WebP
maxSizenumber0位图长边上限,等比缩小不放大
icc / attachIccstring / boolean仅 PSD/PSB 输出时传给写回管线;icc 只能是 /api/profiles 里的名字,带路径分隔符会 400
位图输出的 CMYK 只能预览:这个端点的 CMYK 走 libvips 内置公式、不带 ICC。要出印刷文件请用 /api/render(会带 ICC 分色),响应元数据里也会附上同样意思的 iccWarning
响应

成功时响应体就是文件字节,Content-Disposition: attachment; filename="edited.<ext>",元数据在 X-Doc-Meta

字段说明
ops[]每条 op 的结果:i(在数组里的位置)、kindcore/server)、op(规范名)、okalias(用了别名才有)、changedchangedFieldsaffected(受影响图层下标,最多 32 个)、notemeta
bytes / psdBytes / inputBytes响应体字节数 / 编辑后 PSD 的字节数 / 输入字节数
output{ format },CMYK 时另有 colorModeiccWarning
guard{ ok, lint:{ok,warnings}, roundTripIdentical, firstDiffOffset, inputWasHealthy? }
warnings[]执行与守门告警,每条截到 160 字符、最多 20 条
ms耗时
X-Doc-Meta(两条 core op;\uXXXX 已还原成中文) 真实响应

{
  "ops": [
    { "i": 0, "kind": "core", "op": "setLayerProps", "ok": true, "alias": "renameLayer",
      "changed": true, "changedFields": ["name"], "affected": [2],
      "note": "图层 2「无法无天」→ name" },
    { "i": 1, "kind": "core", "op": "setLayerProps", "ok": true, "alias": "setOpacity",
      "changed": true, "changedFields": ["opacity"], "affected": [1],
      "note": "图层 1「img」→ opacity" }
  ],
  "bytes": 8805978, "psdBytes": 8805978, "inputBytes": 8805986,
  "output": { "format": "psd" },
  "guard": { "ok": true, "lint": { "ok": true, "warnings": [] },
             "roundTripIdentical": true, "firstDiffOffset": -1 },
  "warnings": ["图层 2 的 Pascal 名含非 ASCII 字符,已按 '?' 落地(真名在 luni 块里,PS 显示的是 luni)"],
  "ms": 125
}
X-Doc-Meta(换智能对象源图 + 改文字) 真实响应

{
  "ops": [
    { "i": 0, "kind": "server", "op": "replaceSmartObject", "ok": true, "changed": true,
      "affected": [1], "note": "智能对象「img」换源图",
      "meta": { "target": { "layerIndex": 1, "layerName": "img", "smartObjectIndex": 0 },
                "warpWritten": null, "sourceReplaced": true,
                "filterCacheUpdated": null, "deformKind": null, "bytes": 7778372 } },
    { "i": 1, "kind": "core", "op": "setText", "ok": true, "changed": true,
      "changedFields": ["text"], "affected": [2], "note": "图层 2「无法无天」文字 → 4 字符" }
  ],
  "bytes": 7765236, "psdBytes": 7765236, "inputBytes": 8805986,
  "output": { "format": "psd" },
  "guard": { "ok": true, "lint": { "ok": true, "warnings": [] },
             "roundTripIdentical": true, "firstDiffOffset": -1 },
  "warnings": [], "ms": 1032
}

warpWrittennull 表示这次没改网格(没写回 warp);filterCacheUpdatednull 表示这层不是变形滤镜层,没有 FEid 缓存要同步。

422:守门不过
422 响应体(字段来自源码) 结构示意

{
  "error": "产物未通过守门(lintPsd + 可解析 + 自身字节往返),已放弃返回文件",
  "stage": "lint",
  "offset": 8808,
  "guard": {
    "fresh": [ { "kind": "lint", "message": "…这次编辑新增的那条错误…" } ],
    "lint": { "ok": false, "errors": ["…"] },
    "parseOk": true, "parseError": null,
    "roundTripIdentical": false, "firstDiffOffset": 8808,
    "inputWasHealthy": true, "bytes": 8805990
  },
  "ops": [ { "i": 0, "kind": "core", "op": "setLayerProps", "ok": true } ]
}

stagelint / parse / roundtripguard.fresh这次编辑新增的问题 —— 输入文件本来就体检不过的,会以输入为基线放行并降级成 warnings,不算到本次编辑头上。详见一致性保证

② 片段模式(application/json,不传 PSD)

请求体 { fragment, edit }{ items:[{fragment,edit}…] }(一次最多 64 个)。与 /api/so/writeback 完全等价 —— 同一段 writebackFragment 实现、同样的字段与顺序,只是走统一鉴权与统一错误约定。不占并发闸。

请求体里出现 opspsdBase64 会直接 400(这是文件模式的字段,说明你用错了 Content-Type)。

请求 / 响应 真实响应(base64 已截断)

// 请求
{
  "fragment": { "layerName": "img", "kind": "SoLd", "psb": false,
                "bounds": { "top": 370, "left": 461, "bottom": 807, "right": 746 },
                "blocks": [ { "key": "PlLd", "data": "cGxjTAAAAAMkZGVl…" },
                            { "key": "SoLd", "data": "c29MRAAAAAUAAAAB…" } ] },
  "edit": { "points": [ { "x": 418.00000000000006, "y": 417.9085020932756 }, "…共 16 个…" ],
            "xn": 4, "yn": 4, "sliceX": [], "sliceY": [] }
}

// 响应
{ "mode": "warp",
  "blocks": [ { "key": "PlLd", "data": "cGxjTAAAAAMkZGVl…" } ],
  "ms": 2 }

错误码

什么情况
400psd/opsops 不是数组或为空、未知 op、op 字段类型错、index 越界、blendKey 非法、target 找不到 / 不唯一 / 不是智能对象、图片引用对不上、edit 与图层类型不匹配(变形滤镜层传 points、普通层传 deform)、PSD 解析失败、output.format 不支持
413上传超限;画布或上传图片超过 FULLPSD_DOC_MAX_PIXELS
415Content-Type 不是那两种之一
422守门不过(stage = lint/parse/roundtrip),或放置块写回失败(code:"writeback")。不返回文件字节
429 / 504rpm 或并发闸 / 阶段超时
500这个端点明确区分输入错与服务端 bug:能归因于输入的一律 4xx,剩下的才 500。看到 500 请报 bug
bash · 混排 core op 与服务端 op

curl -s -X POST https://your-host/api/doc/edit \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -F psd=@tshirt.psd \
  -F image=@art.png \
  -F 'ops=[
    {"op":"replaceSmartObject","target":0,
     "image":{"file":"image","index":0},"fit":{"mode":"cover"}},
    {"op":"setText","index":2,"text":"新的文字"},
    {"op":"setOpacity","index":1,"opacity":180}
  ]' \
  -F 'output={"format":"psd"}' \
  -D headers.txt -o edited.psd

# 只要位图预览:output={"format":"jpg","quality":85,"maxSize":1600}

POST/api/doc/edit/plan在调试台里打开 →

dry-run:逐条判定 ops 可行性,不产出文件。给集成方「先校验再提交」。

鉴权
API key 或会话
Content-Type
只收 multipart/form-data(其它 415)
并发闸
不占

字段与文件模式相同(psd + ops),但图片可以不传 —— replaceSmartObject 只检查你是否声明了 image 引用。deform 则是拿真实放置块(KB 级)跑一次真写回,可行性判定与执行路径完全一致。

core op 的 index 漂移(deleteLayer / reorderLayers 会让后续下标变)已经模拟进去了;服务端 op 不改图层表,所以混排时 core op 的 index 口径在整串里是稳定的。plan 说不行的,真提交一定是 4xx。

响应字段

字段说明
okerrors 为空才 true
ops[]{ i, kind, op, alias, ok, affected, changed, note };服务端 op 另有 targetdeformFiltermeshmodeok:null = 前面的 op 已失败,这条没继续校验
errors[]{ opIndex, op, message }
layerCount全部 core op 跑完后的图层数
document{ psb, width, height, layerCount, smartObjectCount, textLayerCount }
input{ bytes, filename }
可行 真实响应

{
  "ok": true,
  "ops": [
    { "i": 0, "kind": "core", "op": "setLayerProps", "alias": "renameLayer", "ok": true,
      "affected": [2], "changed": ["name"], "note": "图层 2「无法无天」→ name" },
    { "i": 1, "kind": "server", "op": "replaceSmartObject", "alias": null, "ok": true,
      "affected": [1],
      "target": { "layerIndex": 1, "layerName": "img",
                  "bounds": { "top": 370, "left": 461, "bottom": 807, "right": 746 },
                  "isSmartObject": true, "smartObjectIndex": 0,
                  "smartObjectName": "img", "kind": "SoLd" },
      "deformFilter": null, "mesh": "4×4",
      "note": "换源图:智能对象 0「img」→ 图层 1" }
  ],
  "errors": [],
  "layerCount": 3,
  "document": { "psb": false, "width": 1200, "height": 1200,
                "layerCount": 3, "smartObjectCount": 1, "textLayerCount": 1 },
  "input": { "bytes": 8805986, "filename": "tshirt.psd" },
  "ms": 41
}
不可行(index 越界) 真实响应

{
  "ok": false,
  "ops": [ { "i": 0, "kind": "core", "op": "setLayerProps", "alias": "renameLayer", "ok": false,
             "affected": [], "changed": [],
             "note": "ops[0]「setLayerProps」:index=3 越界(本文档 3 个图层,合法范围 0…2)" } ],
  "errors": [ { "opIndex": 0, "op": "setLayerProps",
                "message": "ops[0]「setLayerProps」:index=3 越界(本文档 3 个图层,合法范围 0…2)" } ],
  "layerCount": 3,
  "document": { "psb": false, "width": 1200, "height": 1200,
                "layerCount": 3, "smartObjectCount": 1, "textLayerCount": 1 },
  "input": { "bytes": 8805986, "filename": "tshirt.psd" },
  "ms": 16
}

注意:dry-run 的 ok:false 仍然是 HTTP 200 —— 判定结果在响应体里。只有请求本身有问题(缺 psdops 不是数组、未知 op)才是 4xx。

bash

curl -s -X POST https://your-host/api/doc/edit/plan \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -F psd=@tshirt.psd \
  -F 'ops=[{"op":"renameLayer","index":2,"name":"A"},
           {"op":"replaceSmartObject","target":"img","image":{"file":"image","index":0}}]'

POST/api/doc/edit/incremental

只传要改的那几段字节,不上传整份 PSD。你在本地把片段切出来 → 服务端返回新片段 → 你在本地拼回。产物与整文件模式逐字节相同

鉴权
API key 或会话
Content-Type
只收 application/json(其它 415),上限 FULLPSD_EDIT_JSON_LIMIT(默认 32 mb)
并发闸
不占(KB 级、纯 CPU、无渲染)
响应头
Cache-Control: no-store
为什么要这条路:网格 / 变形 / 放置描述符的算法只能在服务端跑(加密下发),但为了这几 KB 把 8 MB 的 PSD 上传一遍、再回传一遍,纯属浪费。片段协议解决了智能对象那一块;这个端点把同样的思路推广到改文字、改图层属性、改名、各类变形写回、换智能对象源图的描述符部分。像素、内嵌源图这类 MB 级数据继续留在本地处理,不进这套协议。

片段类型

「片段」= 你在本地文件里切出来的一段连续字节 + 它的绝对偏移、长度、校验和。四种:

kind是哪段字节用于变长
layerBlend图层记录里定长的 8 字节:blendKey(4) opacity(1) clipping(1) flags(1) filler(1)可见性 / 不透明度 / 剪贴 / 混合模式否(必须等长)
layerName图层 extra 区里的 Pascal 名(含 4 字节对齐填充)改名(老工具与 PS 的 fallback 名)
layerBlock一个附加信息块整体8BIM + key(4) + len(4) + data + padluni(真名)· TySh(文字)
placedBlocks智能对象放置块的块数据SoLd/PlLd,不含块头),可多块变形写回 · 放置描述符

可用 op

op需要的片段说明
setLayerProps / setVisible / setOpacity / setBlendMode / setClippinglayerBlend字段与整文件模式同名同校验(opacity 0…255、blendKey 白名单)
renameLayer / setLayerProps(带 namelayerBlock:luni + layerName该层没有 luni 块时,给一个 length:0 的插入点片段(偏移取 extra 区末尾),服务端会新建
setTextlayerBlock:TySh同步改描述符 Txt 与 EngineData;响应会带一条 removeGlobalBlocks:["Txt2"]sideEffect,由你在本地执行(Txt2 可能是 MB 级,不值得往返)
deform / setMeshplacedBlockswarp · quiltWarp · 透视变形(714)· 操控变形(687),points / deform 形态与 /api/so/writebackedit 完全一致
setSmartObjectDescriptorplacedBlocks换源图的描述符部分linkIdIdnt)· placedIdplaced)· originalSizeSz )。像素与内嵌源图字节在本地换

不走这条路的:replaceLayerPixels(像素是 MB 级,本地做)· deleteLayer / reorderLayers(要重排整个层信息段,没有「一小段」可切)· setImageResource / removeImageResource(资源段有自己的对齐重试规则)· replaceSmartObject(要真渲染)。这些请继续用 /api/doc/edit 的文件模式,传错会 400 并在文案里说明。

请求体

字段类型说明
docobject{ psb, layerCount?, bytes? }没有文件字节psb 决定块长度字段宽度与填充粒度,必须给对
fragments[]array一次最多 64 个,见下
ops[]array一次最多 FULLPSD_DOC_MAX_OPS(默认 512)条,按数组顺序作用在同一批片段上
fragment 字段类型说明
idstring请求内唯一。约定写法 kind:layerIndex[:key:occurrence],op 按 (kind, layerIndex, key) 自动匹配
kindstring见上表四种
layerIndexint文档图层下标(与 /api/doc/inspectflat[].index 同一坐标系)
offset / lengthint该段在你本地文件里的绝对偏移与长度。服务端不解释它、只原样回带;length 与解码后的字节数对不上 → 400
checksumstring"sha256:<hex>",片段字节的哈希。服务端独立复算,对不上 → 400(切错区间不会被静默接受)。可省略,但强烈建议给
database64片段字节(placedBlocks 不用这个字段)
blocks[]arrayplacedBlocks{ key, occurrence, offset, length, data },最多 8 块。此时 checksum 算在各块字节拼接后的结果上
layerName / boundsplacedBlocks 需要(放置块的解析要用到),语义同片段协议
metaobjectlayerBlock{ key, occurrence } 指明是哪个块

响应字段

字段说明
fragments[]只含变了的片段{ id, kind, layerIndex, offset, length, checksum, newLength, newChecksum, data|blocks }offset/length/checksum请求里那一份,原样回带,方便你定位并再核一次
sideEffects[]要你在本地做的事,目前只有 { "type":"removeGlobalBlocks", "keys":["Txt2"] }必须在拼回片段之后执行,顺序与整文件模式一致
ops[]{ i, op, alias, ok, fragments:[id…], changedFields?, mode?, note }
bytes{ up, down, docBytes },片段净字节数(不含 base64 与 JSON 外壳)
warnings[] / ms同其它端点

拼回规则(自己实现客户端时看这里)

  • layerBlend:等长覆写,不动任何长度字段
  • layerName / layerBlock:把 [offset, offset+length) 换成新字节,然后依次修正图层 extraLen(恒 4 字节,PSB 也是)→ LayerInfo 段长 → Layer&Mask 段长,最后把 LayerInfo 段补零到 4 的倍数。
  • placedBlocks:块数据按 key 出现顺序对应,块长度字段写裸长度、块整体补到偶数(这与附加块 buildBlock 的 4 字节口径不同),三个外层长度字段同步、不重新对齐 LayerInfo —— 与 /api/so/writeback 的拼回完全一致。
  • 多个片段一起回来时按偏移从高到低逐条应用:高地址先写,低地址片段的偏移不受影响;且每条各自做一次 4 字节对齐 —— 合并成一次会少补零,产物就不再逐字节相同了。
  • 拼完请自己跑一次 lintPsd:服务端看不见整份文件,做不了整文件守门(它只自检产出的片段本身)。

core/psd-parse.js 里已经有现成的实现:buildIncrementalRequest(buffer, ops) 组请求、applyIncrementalResponse(buffer, res) 拼回、fragmentChecksum(bytes) 算校验和(纯 JS SHA-256,浏览器 / Node 同源)。web/api-client.jsincrementalEdit() 是完整例子。

错误

400 校验和不符(带 fragmentId)· 长度声明与实际字节数不符 · 未知 kind · 缺片段(带 need:[{kind,layerIndex,key?}] 告诉你还差哪几段)· op 参数非法 · op 不支持增量 · 片段区间重叠 · 413 请求体超限 · 415 Content-Type 不对 · 422 产出的片段自身解不回来(例如新 TySh 无法重新解析、新放置块无法 resolve),此时不返回任何字节 · 429 限流(scope 区分 key / global)。

传输量实测

操作语料增量上行增量下行整份往返
改不透明度 / 可见性 / 混合模式 / 剪贴multilayer-2.psd(8.6 KB)0.41 KB0.54 KB17.19 KB94.5%
改名(luni + Pascal 名)multilayer-2.psd0.75 KB0.98 KB17.19 KB89.9%
改文字point-text.psd(22 KB)11.57 KB11.69 KB44.34 KB47.6%
变形 warptshirt.psd(8.4 MB)4.80 KB4.12 KB16.80 MB99.95%
变形 quiltWarp(拆分加线)tshirt.psd6.28 KB6.60 KB16.80 MB99.93%
智能对象放置描述符tshirt.psd4.10 KB4.15 KB16.80 MB99.95%
透视变形(714)透视变形.psd(171 KB)6.48 KB5.80 KB341 KB96.4%
操控变形(687)操作变形.psd(246 KB)185 KB117 KB492 KB38.6%

真实 HTTP body 字节数(含 base64 与 JSON 外壳),由 server/test-incremental.js 每次跑测试时打印。「整份往返」= 上传一遍 + 回传一遍。文件越大、要改的越小,省得越多;反过来,操控变形的顶点数组本身就有 87 KB(占了 246 KB 文件的三分之一),这时候增量的意义就有限了 —— 判断标准是「片段 / 文件」的比例,不是文件绝对大小。

等价性是有测试锁住的:server/test-incremental.js 对上面每一种操作都跑两遍 —— 一遍整文件模式(applyEdits / applyDeform / applySmartObjectDescriptor),一遍增量往返,然后逐字节比较。任何一边的实现漂了,测试立刻红。
bash —— 改一个图层的不透明度(整份 8.6 KB 的文件,只上行 8 字节片段)

curl -s -X POST https://your-host/api/doc/edit/incremental \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "doc": { "psb": false, "bytes": 8802 },
    "fragments": [ { "id": "layerBlend:0", "kind": "layerBlend", "layerIndex": 0,
                     "offset": 112, "length": 8,
                     "checksum": "sha256:216e949ff3e…",
                     "data": "bm9ybf8AAAA=" } ],
    "ops": [ { "op": "setOpacity", "index": 0, "opacity": 128 } ]
  }'
响应

{ "ok": true,
  "fragments": [ { "id": "layerBlend:0", "kind": "layerBlend", "layerIndex": 0,
                   "offset": 112, "length": 8, "checksum": "sha256:216e949ff3e…",
                   "newLength": 8, "newChecksum": "sha256:…", "data": "bm9ybYAAAAA=" } ],
  "sideEffects": [],
  "ops": [ { "i": 0, "op": "setLayerProps", "alias": "setOpacity", "ok": true,
             "fragments": ["layerBlend:0"], "changedFields": ["opacity"], "note": "图层 0 → opacity" } ],
  "bytes": { "up": 8, "down": 8, "docBytes": 8802 }, "ms": 1 }
JavaScript —— 用 core 里现成的实现(浏览器 / Node 通用)

import { buildIncrementalRequest, applyIncrementalResponse } from './core/psd-parse.js';

const ops = [{ op: 'setText', index: 3, text: '新文字' },
             { op: 'deform', index: 5, points, xn, yn, sliceX, sliceY }];

// ① 本地切片段(整份 PSD 不上传)
const req = buildIncrementalRequest(psdBytes, ops);
const body = { doc: req.doc, ops: req.ops,
  fragments: req.fragments.map(f => ({ ...f,
    ...(f.blocks ? { blocks: f.blocks.map(b => ({ ...b, data: b64(b.data) })) } : { data: b64(f.data) }) })) };

// ② 服务端只见到这几 KB
const res = await (await fetch('/api/doc/edit/incremental', {
  method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': KEY },
  body: JSON.stringify(body) })).json();

// ③ 本地拼回(含 sideEffects,如删除全局 Txt2)
const { buffer } = applyIncrementalResponse(psdBytes, {
  sideEffects: res.sideEffects,
  fragments: res.fragments.map(f => ({ ...f,
    ...(f.blocks ? { blocks: f.blocks.map(b => ({ ...b, data: unb64(b.data) })) } : { data: unb64(f.data) }) })) });

// ④ 自己守门(服务端看不见整份文件)
if (!lintPsd(buffer).ok) throw new Error('拼回后 lint 不过');

POST/api/so/resolve在调试台里打开 →

放置块字节 → 可编辑的网格 / 变形结构。不传 PSD、不传像素,见片段协议

鉴权
API key 或会话
Content-Type
application/json(上限 FULLPSD_SO_JSON_LIMIT,默认 32 mb)
并发闸
不占
响应头
Cache-Control: no-store

请求体

单个 fragment,或 { "items": [fragment, …] }(一次最多 64 个)。

字段类型说明
layerNamestring图层名,超过 256 字符会被截断
boundsobject{ top, left, bottom, right },四个值都必须是有限数
kindstringSoLdPlLd
psbboolean源文件是不是 PSB(影响块长度字段宽度)
blocks[]array{ key:"SoLd"|"PlLd", data:<base64> }最多 8 块,单块 ≤ 8 MB;其它 key 直接报错

响应字段

字段说明
layerInfo渲染 / 编辑用的放置信息,见下
layerInfoBase仅变形滤镜层非空:「滤镜之前」的放置(重建 FEid 缓存要用);普通层为 null
summary{ kind, originalSize, hasWarp, isQuilt, meshPointCount, deformFilter, placedId, linkId }
ms耗时
layerInfo 的字段
字段说明
layerName / layType图层名 / 放置类型(如 normalImg
points[]Bezier 网格控制点 {x,y}文档坐标,共 xn*yn
xn / yn网格列数 / 行数(4×4 = 1 个面片)
sliceX / sliceY经/纬拆分线(quiltWarp 才非空)
sourceUV[]每个控制点对应的纹理坐标 {u,v}
width / height / originalSize设计区(智能对象原始)尺寸
warpBounds / rect / BoundingRectwarp 定义域 / 图层矩形 / 包围盒
Matrix3放置变换矩阵(9 个数,行主序)
warpStylewarpNone / warpCustom
deform / deformName变形滤镜结构(perspectiveWarp / puppetWarp)与中文名;普通层为 null
响应(裁剪) 真实响应

{
  "layerInfo": {
    "layerName": "img", "layType": "normalImg",
    "sliceX": [], "sliceY": [],
    "warpBounds": { "top": 0, "left": 0, "bottom": 800, "right": 800 },
    "originalSize": "800,800", "width": 800, "height": 800,
    "Matrix3": [0.45625000000000004, 0, 418,
                1.1102230246251565e-16, 0.45592642925714166, 417.9085020932757,
                0, 0, 1],
    "rect": [461, 370, 285, 437],
    "BoundingRect": [461, 370, 746, 807],
    "points": [ { "x": 418.00000000000006, "y": 417.9085020932756 },
                { "x": 539.6666666666667,  "y": 417.9085020932757 },
                { "x": 690.0781250000002,  "y": 311.00000000000006 } ],
    "sourceUV": [ { "u": 0, "v": 0 }, { "u": 0.3333333333333333, "v": 0 } ],
    "xn": 4, "yn": 4, "warpStyle": "warpCustom",
    "deform": null, "deformName": null
  },
  "layerInfoBase": null,
  "summary": { "kind": "SoLd", "originalSize": { "width": 800, "height": 800 },
               "hasWarp": true, "isQuilt": false, "meshPointCount": 16,
               "deformFilter": null,
               "placedId": "c2745df0-917e-0944-8e16-34f289987dd1",
               "linkId": "deeadabe-5ccd-2f49-9041-f8a33b59f73f" },
  "ms": 3
}
两种模式的失败语义不同(按状态码就能判):
  • 批量items):部分失败是正常情况 —— HTTP 恒 200,失败的条目变成 { "error": "…" },另给汇总 failed / ok 计数,不用遍历就知道有没有失败。
  • 单条(直接传 fragment):整个请求就这一件事,失败即请求失败 —— 返回 400 + { error },不再是 200 把错误藏在响应体里。
批量响应的汇总字段 真实响应

{ "items": [ { "layerInfo": { }, "layerInfoBase": null, "summary": { } },
             { "error": "放置块 key 只能是 SoLd / PlLd" } ],
  "failed": 1, "ok": 1, "ms": 4 }

错误

400 超过 64 个条目 · 单条模式解析失败(fragment 格式错、放置块无法解析、块过大 / key 不对 / bounds 无效;批量模式下同样的错落在条目的 error 里,HTTP 仍 200)· 413 JSON 体积超限。

bash

curl -s -X POST https://your-host/api/so/resolve \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -H "Content-Type: application/json" \
  -d @fragment.json

POST/api/so/writeback在调试台里打开 →

编辑后的网格 / 变形 → 新的放置块字节。客户端拿去替换原块即可。

鉴权
API key 或会话
Content-Type
application/json
并发闸
不占
等价端点
POST /api/doc/edit 的片段模式(同一实现,逐字节相同)

请求体

字段类型说明
fragmentobject/api/so/resolve 的 fragment
editobject二选一,见下
edit 形态字段用于
网格{ points:[{x,y}…], xn, yn, sliceX:[], sliceY:[] }普通 warp / quiltWarp。给了 xn/yn 时点数必须等于 xn*yn;点数上限 4096,坐标必须是有限数
变形滤镜{ deform: { kind:"perspectiveWarp"|"puppetWarp", … } }透视变形 / 操控变形,结构见变形能力
新建变形滤镜{ deform: { … }, create: true }这一层本来没有变形滤镜时,整条 filterFX 条目从零写进 SoLd。透视要带齐 vertices / warped / quadIdx,操控每片要带齐 orig / def / indices(可选 boundary 作为网格边界路径)。放置四点由服务端取自该层的 Trnf,不从请求里读

响应字段

字段说明
modewarp(原地改点)· quiltWarp(拆分过,走变长重写)· deform(改现有变形滤镜)· deformCreate(新建了一条变形滤镜)
blocks[]需要替换的块:{ key, data:<base64> }。没变的块不会出现
ms耗时
响应(base64 已省略) 真实响应

{ "mode": "warp",
  "blocks": [ { "key": "PlLd", "data": "cGxjTAAAAAMkZGVl…" },
              { "key": "SoLd", "data": "c29MRAAAAAUAAAAB…" } ],
  "ms": 2 }
类型校验会拦住「能过 lint 但效果不对」的写法:变形滤镜层传 points(它没有 Bezier 网格)、普通层传 deform 又不带 create、已经有变形滤镜还带 create(一层只保留一组变形),都会 400。当前不是规则网格、又要改点数时,必须同时给出 xn / yn

错误一律 400 + { error }(fragment 格式错、edit 缺失、控制点非法、未知变形类型、写回失败)。

bash

curl -s -X POST https://your-host/api/so/writeback \
  -H "X-Api-Key: $FULLPSD_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "fragment": { "layerName":"img", "kind":"SoLd", "psb":false,
                      "bounds":{"top":370,"left":461,"bottom":807,"right":746},
                      "blocks":[{"key":"SoLd","data":"…base64…"}] },
        "edit": { "points":[{"x":418,"y":417.9}], "xn":4, "yn":4,
                  "sliceX":[], "sliceY":[] } }'

ops 参考

ops 是一个有序数组,每条形如 { "op": "setLayerProps", "index": 3, … }。两类 op 可以任意混排:

  • core opcore/psd-edit.js)—— 纯格式知识,改图层属性 / 文字 / 像素 / 图像资源。定位用 index/api/doc/inspectflat[].index)。
  • 服务端 opserver/edit-ops.js)—— 换智能对象源图、变形写回。定位用 target
混排时的 index 口径:服务端 op 只改字节与放置块,不改图层表,所以 core op 的 index 在整串里是稳定的。反过来,deleteLayer / reorderLayers 会让后续 core op 的 index 漂移 —— /api/doc/edit/plan 已经把这个漂移模拟进去了,拿不准就先跑一次 dry-run。

全部 op 一览

op来自定位做什么
setLayerPropscoreindex改图层名 / 可见 / 不透明度 / 剪贴 / 混合模式
renameLayercoreindexsetLayerProps 的别名,只带 name
setVisiblecoreindex别名,只带 visible
setOpacitycoreindex别名,只带 opacity
setBlendModecoreindex别名,只带 blendKey
setClippingcoreindex别名,只带 clipping
setTextcoreindex改文字层内容(可换字体)
deleteLayercoreindex删图层 / 删分组
reorderLayerscoreorder重排,顺带批量删除
replaceLayerPixelscoreindex换整层像素
setImageResourcecoreid写入 / 替换图像资源块
removeImageResourcecoreid删除图像资源块
replaceSmartObjectservertarget换智能对象源图(别名 replaceSmartObjectImage
deformservertarget网格 / 变形写回(别名 setMesh

服务端 op 的 target 怎么写

写法含义
省略 / null第 0 个智能对象。文件里没有智能对象时 400
0"0"智能对象序号(与 /api/rendertarget 一致)
"img"图层名(unicodeName 优先)。必须唯一,重名会 400 并告诉你重在第几层;图层树里找不到时,会再在智能对象名单里按放置块里的名字找一次
{"layerIndex":1}文档图层下标(/api/doc/inspect 口径)
{"smartObjectIndex":0}智能对象序号(别名 smartObject / soIndex
{"name":"img"}同字符串写法

解析结果里两个下标同时给出(layerIndex + smartObjectIndex),互相打通用的是「名字 + 包围盒」打分;名字对不上就不认,避免张冠李戴。

core op 逐个说明

setLayerProps(及五个别名)

字段类型约束
indexinteger必填,越界 400
namestring非空、≤255 字符。同时写 luni 与 Pascal 名
unicodeNamestring非空。只写 luni(PS 显示的就是它)
visibleboolean
opacityinteger0…255(255 = 不透明)
clippingboolean | 0 | 1
blendKeystring恰好 4 个字符且在白名单里

至少给一个可改字段,否则 400。附加区被降级成原样字节(extraRaw)的图层不允许改名 —— 改了会被静默丢掉,所以直接拒绝。用 name 写非 ASCII 名字时,Pascal 名那份会按 ? 落地并给一条 warning,这是正常的(PS 读 luni)。

混合模式键(白名单)
模式模式模式模式
pass穿透(仅图层组)norm正常diss溶解dark变暗
mul 正片叠底idiv颜色加深lbrn线性加深dkCl深色
lite变亮scrn滤色div 颜色减淡lddg线性减淡
lgCl浅色over叠加sLit柔光hLit强光
vLit亮光lLit线性光pLit点光hMix实色混合
diff差值smud排除fsub减去fdiv划分
hue 色相sat 饱和度colr颜色lum 明度

不足 4 字符的键要补空格"mul ""div ""hue ""sat ""lum "。写进白名单以外的键,Photoshop 会当文件损坏,所以这里直接 400。

示例

[
  { "op": "setLayerProps", "index": 1, "name": "新图案", "opacity": 200, "blendKey": "mul " },
  { "op": "renameLayer",   "index": 2, "name": "新标题" },
  { "op": "setVisible",    "index": 0, "visible": false },
  { "op": "setClipping",   "index": 2, "clipping": true }
]

setText

字段类型说明
indexinteger必须是文字图层(有 TySh 块),否则 400
textstring必填
opts.fontstring换字体。走 setEngineFont,会同时改 EngineData 里的两份 FontSet 副本ResourceDictDocumentResources)—— 只改一份 PS 双击内嵌文档会崩
opts.keepTxt2boolean保留全局 Txt2。默认删掉
为什么要删 Txt2PS CC 2015+ 优先读全局 Txt2(里面是旧文字与旧字形),留着它 PS 就会继续显示旧文字。删掉之后 PS 打开会提示「某些文本图层可能需要更新…」,点「更新」即可 —— 这是预期行为。若显式 keepTxt2:trueTxt2 仍在,服务端会给一条 warning;非 keepTxt2 的情况下 Txt2 没删掉,会直接报错、不出文件。

结果里额外带 textChangedtxt2RemovedtextBeforetextAfter。两份 FontSet 对不上时会给 warning(条目数或字体名不同)。

示例

[ { "op": "setText", "index": 2, "text": "限时 8 折",
    "opts": { "font": "SourceHanSansSC-Bold" } } ]

deleteLayer

字段类型说明
indexinteger必填
keepChildrenboolean删分组时只拆外壳、保留子图层

删分组默认连内容一起删(分组头 + 内容 + 分隔层整段)。会删光全部图层时直接 400。16/32 位文档的图层记录在 Lr16/Lr32 全局块里,暂不支持结构性增删改(改名 / 属性 / 文字仍可用)。

reorderLayers

字段类型说明
orderinteger[]原始图层 index 的新顺序(底 = 0)。省略某个 index 就等于删除它

校验:不能越界、不能重复、不能为空、分组边界必须自洽。删掉文字图层但全局 Txt2 还在时会给 warning(建议同一批里再跑一次 setText)。

示例:把第 2 层挪到底,并删掉第 0 层

[ { "op": "reorderLayers", "order": [2, 1] } ]

replaceLayerPixels

字段类型说明
indexinteger必填
image引用上传的图片(见图片引用写法)。服务端用 sharp 解成 RGBA
rgbaobject直接给 { data, width, height }data 长度必须等于 width*height*4
boundsobject可选。四个整数,尺寸必须与图片一致。省略时以原图层左上角为锚点
mergedobject可选。整幅新合成图 RGBA(长度必须等于画布 W*H*4)。不传就保留原合成图,只改这一层

只支持 8 位 / 16 位文档。解出来不是 4 通道、或像素数超过 FULLPSD_DOC_MAX_PIXELS 会 400 / 413。结果里带 compositeUpdated 说明合成图有没有跟着重建。

示例

[ { "op": "replaceLayerPixels", "index": 0,
    "image": { "file": "image", "index": 0 } } ]

setImageResource / removeImageResource

字段类型说明
idinteger0…65535。常见:1005 分辨率、1036 缩略图、1060 XMP
datastring | number[]setImageResource:base64 字符串(接受 data: 前缀)或字节数组

资源已存在就替换、不存在就新增。删除不存在的资源不算错误,changedfalse

服务端 op 逐个说明

replaceSmartObject(别名 replaceSmartObjectImage

字段类型说明
target见上必须解析到智能对象,否则 400
image引用必填,见图片引用写法
fitobject覆盖模式,默认 {"mode":"cover"}。字段同 /api/render
meshobject顺带改网格:{points,xn,yn,sliceX,sliceY}{splits:{u,v}}变形滤镜层不接受
sourceName / filenamestring写进 PSD 的内嵌源图文件名;不给就用上传文件名
/api/render 逐字节相同:这个 op 复用的是 pipeline.js 抽出来的同一段 preparePsdRender + writePsdOutput,所以同参数下产物与 /api/renderoutput.format=psd逐字节一致 —— 这是结构性保证,不是巧合,server/test-edit-api.js 里有逐字节断言。

结果 metatargetwarpWrittensourceReplacedfilterCacheUpdateddeformKindbytes。三个「是否成功」字段可能是 true / null(不适用)/ 失败说明字符串 —— 服务端如实上报而不是假装成功。

deform(别名 setMesh

字段类型说明
target见上必须解析到智能对象
points + xn/yn + sliceX/sliceYBezier 网格写回
deformobject变形滤镜写回,见变形能力

内部先把文件切成 fragment,走与 /api/so/writeback 完全相同的那段代码,再 replacePlacedBlocks 拼回 —— 与「片段模式 + 客户端拼回」的产物逐字节相同。结果 metatargetmodeblocks[].bytesbytesDelta

示例:换图 + 拆一条经线

[
  { "op": "replaceSmartObject", "target": { "smartObjectIndex": 0 },
    "image": { "file": "image", "index": 0 },
    "fit": { "mode": "contain", "alignY": 0, "scale": 0.9 },
    "mesh": { "splits": { "u": [0.5] } },
    "sourceName": "design-v2.png" }
]

片段协议:为什么不用上传 PSD

编辑智能对象的网格 / 变形,需要的只是那一层的放置块字节SoLd / PlLd),通常 1–5 KB,操控变形大一些(约 100 KB)。像素、内嵌源图、其它图层,一个字节都不需要送上来。

浏览器(你的客户端) FullPsd 服务端 整份 PSD(可能几百 MB) smartObjectFragment() 切出放置块 replacePlacedBlocks() 拼回 同步修正各级长度字段 → 导出 resolveFragment() 放置块 → 网格 / 变形结构 writebackFragment() 编辑结果 → 新放置块字节 POST /api/so/resolve · KB 级 base64 POST /api/so/writeback · 只回变化的块 服务端不落盘 · 不留状态 · 看不到像素与源图
片段协议的字节流向。整份 PSD 始终留在客户端;跨网络的只有放置块。

三步

  1. —— 客户端从 PSD 里取出该智能对象的放置块,连同图层名与包围盒组成 fragment
  2. 问 / 写 —— /api/so/resolve 把放置块翻译成可编辑的网格与变形结构;编辑完再用 /api/so/writeback(或 /api/doc/edit 的片段模式)换回新块字节。
  3. —— 客户端把返回的块替换进原文件,并同步修正块长度、图层 extra 长度、层信息段与 LMI 长度。

两个端点都是纯函数:不保存任何客户数据、不需要像素、也不需要文件其余部分。

什么时候该用哪条路

片段协议文件模式(/api/doc/edit multipart)
上行数据KB 级放置块整份 PSD
谁来拼回客户端服务端
能做什么只有网格 / 变形写回全部 op(属性 / 文字 / 像素 / 换源图 / 变形)
并发闸不占
适合浏览器编辑器、隐私敏感场景、大文件服务端集成、批处理
两条路的产物逐字节相同。文件模式内部就是先切 fragment、走同一段 writebackFragment、再拼回 —— 不是两份实现。server/test-edit-api.js 对此有逐字节断言。

变形能力

Photoshop 里能对智能对象做的变形有两大类,落在 PSD 里的位置完全不同。FullPsd 四种都能读、能改、能写回,导出后在 PS 里仍可继续编辑(不是烘死的像素)。

类型PSD 里的位置可编辑的东西写回 mode
普通 warpSoLdwarp 描述符4×4 控制点(1 个面片)warp
quiltWarp(拆分网格)同上,变长重写任意 xn×yn 控制点 + 经纬拆分线quiltWarp
透视变形(智能滤镜 714filterFXperspectiveWarpTransform网格顶点(相邻面共用)deform
操控变形(智能滤镜 687filterFXrigidTransform图钉,其余顶点按 MLS 刚性变形跟随deform

网格(warp / quiltWarp)

控制点是文档坐标{x,y},按行主序排列,共 xn*yn 个。4×4 就是一个 Bezier 面片;每加一条经线 xn += 3,每加一条纬线 yn += 3

写回时服务端自己判断:点数 / 拆分线没变就原地改点(mode:"warp"),加过拆分线就走变长重写(mode:"quiltWarp")。两种在 PS 里都还能继续拖网格Ctrl/Cmd + T → 变形)。

edit:网格

{ "points": [ { "x": 418, "y": 417.9 }, { "x": 539.7, "y": 417.9 }, "…共 xn*yn 个…" ],
  "xn": 4, "yn": 4,
  "sliceX": [], "sliceY": [] }

约束:控制点数 1…4096,坐标必须是有限数;给了 xn/yn 时点数必须等于 xn*yn;当前不是规则网格又要改点数时,xn/yn 必须同时给。

透视变形(714)

edit.deform 结构(字段来自 deformToJSON结构示意

{ "deform": {
    "kind": "perspectiveWarp",
    "HplaceInv": [ "…3×3 放置逆矩阵…" ],
    "vertices": [ { "x": 0,   "y": 0 }, { "x": 800, "y": 0 } ],
    "warped":   [ { "x": 12,  "y": 30 }, { "x": 790, "y": 8 } ],
    "quadIdx":  [ [0, 1, 2, 3] ]
} }
字段说明
vertices原始顶点表(共享,相邻四边形引用同一批下标)
warped你要改的就是它 —— 拖动后的顶点位置,写回时落到 warpedVertices
quadIdx每个四边形引用的 4 个顶点下标
HplaceInv放置逆矩阵,用于文档坐标与滤镜坐标之间换算

操控变形(687)

edit.deform 结构 结构示意

{ "deform": {
    "kind": "puppetWarp",
    "HplaceInv": [ "…3×3…" ],
    "shapes": [ {
      "srcIndex": 0,
      "orig": [ { "x": 0, "y": 0 } ],
      "def":  [ { "x": 4, "y": -2 } ],
      "indices": [ 0, 1, 2 ],
      "pins": [ { "orig": { "x": 120, "y": 300 },
                  "pos":  { "x": 128, "y": 292 },
                  "vertex": 17 } ]
    } ]
} }
字段说明
shapes[].orig / def三角网的原始顶点 / 变形后顶点(写回时落到 deformedVertexArray
shapes[].indices三角形索引
shapes[].pins图钉:{ orig, pos, vertex }(写回时落到 posFinalPins

交互上只需要拖图钉:其余顶点按 MLS 刚性变形(Moving Least Squares,Schaefer 2006)跟随。写回时 defpins 两处必须一起更新 —— PS 两处都读。

FEid:为什么双击智能对象会「恢复原样」

带智能滤镜(透视 / 操控变形)的图层,Photoshop 重新求值时用的不是内嵌源图,而是全局 FEid 块里那份「滤镜之前」的放置栅格缓存。只换源图不更新这份缓存,双击进去再出来,PS 就会拿旧缓存重算 —— 看起来像「恢复原样」。

所以 /api/renderreplaceSmartObject 换源图时会同步重写 FEid。响应元数据里的 applied[].filterCacheUpdated / ops[].meta.filterCacheUpdated 就是这件事的回执:true 已更新 · null 这层没有变形滤镜、不适用 · 失败字符串则说明没更新成功(PS 双击后可能恢复原图)。

/api/so/resolve 返回的 layerInfoBase 就是这份「滤镜之前」的放置信息,重建 FEid 用得上。

不能做的事

  • 变形滤镜层没有 Bezier 网格:传 points / mesh 会 400,请传 deform
  • 普通图层上没有变形滤镜:传 deform 会 400,请传 points
  • 智能对象不是规则网格(points.length !== xn*yn)时无法渲染 —— /api/psd/infomesh.regular 会告诉你。
  • 外部链接的智能对象(非内嵌)无法替换源图。

一致性保证

PSD 是一个「长度字段套长度字段」的格式。只要有一处对不上,Photoshop 就只会说一句「意外地遇到文件尾」或者「程序错误」,不会告诉你哪里坏了。所以本平台的底线是:宁可不给文件,也不给一个坏文件。

出文件前必过的三道守门

  1. lintPsd —— 按长度字段逐段走完整个文件,任何一段对不上就失败。
  2. 能重新解析 —— 用 parsePsdDocument 把刚生成的字节再读一遍。读不回来的文件不发出去。
  3. 自身字节往返 —— serialize(parse(新文件)) === 新文件。新文件必须自洽,否则说明我们的模型对它已经不准了。

三道都过才返回文件字节。任何一道不过 → 422 + 结构化定位(stage 是哪一道、offset 首个差异偏移、ops 每条 op 的执行情况),响应体里没有文件

/api/renderoutput.format=psd)导出前同样会过 lintPsd,不过时也是 422stage:"lint" / code:"artifact_lint_failed")—— 「产物不合格」在所有出文件的端点上是同一个状态码,和「入参错了」(400)分得开。

以输入为基线

真实世界里有大量本来就体检不过的 PSD(我们自己的语料里就有一批)。如果一律拦下来,等于把别人的历史包袱算到本次编辑头上。

所以守门不过时会再体检一遍输入文件,只比对「错误种类」(偏移与图层名会随编辑漂移,先归一化再比):

  • 出现了输入文件没有的新错误 → 422,这是本次编辑的账;guard.fresh 里就是这些新错误。
  • 全是输入本来就有的问题 → 放行,降级成 warnings,并在里面写清楚「输入文件本身就没过体检,本次编辑没有新增毛病」。

guard.inputWasHealthy 会告诉你输入文件本身健不健康。这套口径在 core/psd-edit.jsserver/edit-api.js 里是同一套。

三条写回路径产物相同

这条路与这条路关系
/api/doc/editreplaceSmartObject/api/renderoutput.format=psd同参数下逐字节相同(复用同一段 preparePsdRender + writePsdOutput
/api/doc/editdeform(文件模式)/api/so/writeback + 客户端拼回逐字节相同(同一段 writebackFragment
/api/doc/edit 片段模式/api/so/writeback同一实现、同字段同顺序
/api/doc/edit/incremental(改文字 / 改属性 / 改名 / 变形 / 放置描述符)/api/doc/edit 文件模式的同名 op逐字节相同server/test-incremental.js 对每种操作跑两遍再逐字节比较)

这些不是「碰巧一样」,而是共用同一段代码带来的结构性保证;server/test-edit-api.js 里有逐字节断言。

解析层的红线

parsePsdDocument 把文件解析成七段语义树,每段都同时保留绝对偏移与 _raw 原始字节。未修改时 serializePsdDocument 与原文件逐字节相同 —— 读不懂的东西一律退回 _raw 原样保留,绝不为了语义牺牲往返。想自己验证任何一份文件,打 /api/doc/validate

兼容性与覆盖率

下面的数字来自 docs/psd-coverage.md(由 node tools/coverage.js 生成,报告时间 2026-09-07),语料共 167 个文件:ag-psd 写的特性矩阵、ImageMagick 写的色彩模式与位深矩阵、手写字节造的规范边界样本,外加真实 Photoshop 导出文件。

项目覆盖情况
字节级 round-trip167 / 167 全部通过
图像资源 ID语料里出现 41 个,其中 37 个走语义解析(其余原样往返)
附加图层信息 key注册表共 86 个;语料里出现 56 个,其中 50 个走语义解析
色彩模式全部覆盖(Bitmap / Grayscale / Indexed / RGB / CMYK / Multichannel / Duotone / Lab)
位深 × 压缩1/8/16/32 位 × RAW / RLE / ZIP 全部解码通过
混合模式未覆盖:
EngineData(文字引擎)9 / 9 段做到 serialize(parse(b)) === b

未覆盖项分三类原因

原因意思例子
规范未公开Adobe 没有公开这段格式,只能 _raw 原样保留资源 1001/1002(Mac 打印记录)、1089–1093/1096/1097(CC 之后新增、规范未收录)、双色调设定块
缺语料已实现或已知格式,但语料里没出现过,未经验证压缩方式 ZIP+prediction;描述符 OSType comp/type/GlbC;路径 selector OPEN_LENGTH
待实现能做、还没做报告会在重新生成时自动列出

还有一类是另有独立规范的外部数据流,不属于 PSD 语义范围,一律原样往返:1028 IPTC-NAA、1058 EXIF。对这些块只记录可复核的观察(长度、按 uint32 切开的字、是否全 0),不做语义猜测、不编造字段名

位图合成的兼容性提示

PSD 的「图层像素」并不总等于 Photoshop 的最终合成结果。服务端位图合成不会重现下面这些,遇到时会明确提示而不是默默给你一张看似成功、实则有偏差的图:

code说明
adjustment-layer调整图层(曲线、色阶…)不会被应用
fill-layer参数化填充图层(纯色 / 渐变 / 图案)可能无法重现
vector-mask矢量蒙版:只用已栅格化的图层数据
layer-effects图层样式不会被重绘
unsupported-blend-mode未实现的混合模式,按正常模式预览
group-isolation组不透明度 / 组混合模式:按子图层合成,可能与 PS 不同
thumbnail缩略图资源没能更新

这些出现在 /api/psd/infocompatibilityWarnings/api/renderX-Render-Meta.compatibilityWarnings 里(最多 20 条明细,另给总数)。PSD 输出不受影响 —— 那条路保留原始结构,这些提示只针对扁平化的位图预览。

自托管与环境变量

bash

npm ci                       # 根目录:构建工具
npm ci --prefix server       # 服务端依赖
npm run build                # 生成 web/dist 与 server/engine/engine.js
npm test
npm start                    # 默认 http://localhost:8787

站点路由:/ 官网 · /editor 编辑器 · /docs 本文档 · /console 调试台。旧地址 /api.html301/console。开发模式 npm run devFULLPSD_DEV=1 FULLPSD_AUTH=off)直接托管 web/ 源码与 core/,改完刷新即可。

环境变量

变量默认作用
PORT8787监听端口
MAX_UPLOAD_MB200单文件上传上限(multer)
FULLPSD_AUTH设为 off 关闭鉴权(仅限本机调试
FULLPSD_API_KEYS"key:名字:rpm,key2:名字2"
FULLPSD_API_KEYS_FILEserver/api-keys.jsonkey 的 JSON 文件路径
FULLPSD_EDITOR_KEY设为 1 时签发会话也要求 X-Api-Key
FULLPSD_SESSION_SECRET每次启动随机会话签名密钥。不设则重启后旧会话失效
FULLPSD_SESSION_TTL_HOURS12会话有效期
FULLPSD_SESSION_RPM240会话主体的每分钟请求数
FULLPSD_MAX_CONCURRENT2重接口全局并发上限(超出 429 scope:"global",不排队);每 key 的上限另在 api-keys.jsonconcurrency 配,两道叠加
FULLPSD_DOC_MAX_PIXELS40000000解像素的单图上限(超出 413)
FULLPSD_DOC_TIMEOUT_MS20000/api/doc/* 阶段耗时闸(超出 504)
FULLPSD_DOC_MAX_OPS512单次 ops 条数上限
FULLPSD_EDIT_TIMEOUT_MSmax(DOC_TIMEOUT, 60000)/api/doc/edit 阶段耗时闸
FULLPSD_EDIT_JSON_LIMIT32mb片段模式 JSON 上限
FULLPSD_SO_JSON_LIMIT32mb/api/so/* 的 JSON 上限(也是上一项的回退值)
FULLPSD_CORS_ORIGINS放行的来源列表,逗号分隔;* 放行任意来源
FULLPSD_TRUST_PROXY设为 1 时信任反向代理(trust proxy
FULLPSD_DEV设为 1 进开发模式:托管源码、错误响应附 stack
部署边界:server/private/server/engine/core/ 在任何模式下都不会被静态托管,web/dist 是唯一对外的前端目录。服务自带 key 鉴权、限流、并发闸与同源 CORS,建议仍放在反向代理之后(TLS、请求体上限、访问日志)。多租户配额、审计与持久化队列不在本项目范围内。

常见问题

Photoshop 打开我拿到的文件报错,怎么排查?

  1. 先把文件丢给 /api/doc/validatelint.errors 非空 = 长度字段不自洽,PS 大概率打不开。
  2. roundTripIdenticalfirstDiffOffset:不一致时那个偏移就是第一处分歧,报 bug 请带上它
  3. 如果文件是从本平台某个端点拿到的 —— 正常情况下不可能:出文件前的三道守门任何一道不过都会返回 422 而不是文件。把请求参数和 X-Doc-Meta 一起附上。
  4. 如果输入文件本身就体检不过(guard.inputWasHealthy:false / 响应 warnings 里有提示),先修输入。

PS 打开时提示「某些文本图层可能需要更新后才能用于基于矢量的输出」

这是预期行为,点「更新」即可。原因:改文字时会删掉全局 Txt2 块(PS CC 2015+ 优先读它,里面是旧文字与旧字形,留着就会继续显示旧文字),PS 于是按各层 EngineData 用真实字体重排并重建 Txt2

如果你确实要保留旧引擎文档,可以传 opts.keepTxt2:true,但那样 PS 打开很可能仍显示旧文字 —— 服务端会给你一条 warning。

双击智能对象进去再出来,主文档恢复原样了

这层多半带智能滤镜(透视变形 / 操控变形)。PS 重算用的是全局 FEid 块里「滤镜之前」的放置栅格缓存,不是内嵌源图。检查响应元数据里的 filterCacheUpdated

  • true —— 已同步,正常。
  • null —— 这层没有变形滤镜,不适用(那就是别的问题)。
  • 失败字符串("失败(…),PS 双击后可能恢复原图")—— 没更新成功,请把这条信息一起报上来。

另外确认 sourceReplacedtrue:它决定你双击进去看到的是不是新图。外部链接(非内嵌)的智能对象无法替换源图。

Finder / PS 打开对话框里的缩略图还是旧的

缩略图不在合成图里,而是图像资源 1036。走 PSD 输出的路径(/api/renderoutput.format=psd/api/doc/editreplaceSmartObject)会用新合成图重写 1036;重写失败时会在兼容性提示里给一条 code:"thumbnail"。如果你是自己拼回文件的(片段协议),缩略图需要你自己更新。

系统的缩略图缓存也可能滞后 —— 换个文件名或清一次缓存再看。

CMYK 出来的颜色不对

路径CMYK 支持
/api/render 位图output.icc(或上传 icc 文件)走 ICC 分色;不传则用 libvips 内置公式,颜色不准,只能预览(元数据里会有 iccWarning
/api/render PSD 输出整份文档转 CMYK:所有图层通道 + 合成图 + FEid 缓存一起分色,header 改成 mode=4 并嵌入 ICC。只支持 8 位的 RGB / 灰度源文档
/api/doc/edit 位图只走 libvips 内置公式,仅供预览;PNG / WebP 不支持 CMYK(400)

出印刷文件请用 /api/render 并传目标印刷条件的 ICC:把可分发的 profile 放进 server/profiles/,用 /api/profiles 里的名字引用(不接受路径),或者直接上传 icc 文件字段。attachIcc:false 可以只分色不嵌入。

index 到底是哪个 index?

  • core op 的 indexlayerIndexlayer-previewlayerIndex —— 都是文档图层下标,来自 /api/doc/inspectflat[].index(底 = 0,分组头与分隔层也各占一条)。
  • /api/rendertarget{"smartObjectIndex":n} —— 是智能对象序号,来自 /api/psd/infosmartObjects[].index
  • 拿不准就用对象写法显式区分,或先跑一次 /api/doc/edit/plantarget 解析成了什么。

收到 429 了怎么办

三种 429,看响应体就能区分,处理方式不同:

  • Retry-After: 10、错误信息是「超过速率限制(N/分钟)」(响应体没有 scope)—— 你的 rpm 用完了,按 Retry-After 退避。
  • "scope":"key" —— 撞上你这把 key 自己的并发上限(concurrency)。这是你自己的在途请求太多,减少并发即可,重试立刻能过;服务端不排队
  • "scope":"global" —— 撞上全局并发闸 FULLPSD_MAX_CONCURRENT,整机忙,别人的请求也在占。加抖动重试;持续出现就该扩容或调大上限。

两道并发闸都只作用在重接口上(/api/renderlayer-previewcomposite/api/doc/edit 文件模式);片段协议与结构类接口不占。

为什么我期待文件,却收到了 JSON?

因为出错了。成功时 Content-Type 一定是 image/*;出错时一律是 application/json集成时请先判状态码或 Content-Type,再决定是存文件还是读错误 —— 特别是 422,那意味着产物没过守门,服务端故意不给你文件。

响应头里的中文变成了 \u56fe\u5c42

HTTP 头只能放 Latin-1,所以 X-Render-Meta / X-Doc-Meta 里 U+0080 以上的字符被转成了 \uXXXX。转义后仍是合法 JSON,直接 JSON.parse(Python 里 json.loads)就能还原成中文,不需要额外处理。


文档对应的代码:server/index.js · server/auth.js · server/doc-api.js · server/edit-api.js · server/edit-ops.js · server/so-api.js · server/pipeline.js · core/psd-edit.js · core/psd-document.js · core/placement.js。覆盖率数字来自 docs/psd-coverage.md。有出入以代码为准,并请告诉我们。