新增 External v1 去背景生成链路
新增外部去背景 API、MCP 工具与异步队列契约 补齐来源归属、媒体类型、幂等重放和画布原子持久化校验 修复 provenance 重建、assetKindOverride 门禁与 revision retry 竞态 同步 Python helper、Skill、OpenAPI 及项目文档
This commit is contained in:
@@ -27,7 +27,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
|
||||
## Essential Invariants
|
||||
|
||||
- Authenticate MCP and business API calls with `Authorization: Bearer <tnr_sk_...>`. Never ask the user to paste a key into chat or place one in repository files.
|
||||
- All eight generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
|
||||
- All nine generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
|
||||
- Retry an uncertain submission only with the exact same body and the same idempotency key. A polling timeout is not permission to generate again.
|
||||
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
|
||||
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
|
||||
@@ -106,6 +106,21 @@ client.generate_image(
|
||||
)
|
||||
```
|
||||
|
||||
For background removal, pass a stable owner-scoped object key, project resource ID, or asset ID; the helper keeps the same asynchronous submission and polling contract:
|
||||
|
||||
```python
|
||||
session = client.prepare_canvas_session("去背景画布")
|
||||
client.remove_background(
|
||||
"editor-upload/object.png",
|
||||
source_width=720,
|
||||
source_height=1280,
|
||||
canvasSession=session,
|
||||
assetLabel="去背景结果",
|
||||
)
|
||||
```
|
||||
|
||||
Background removal preserves the source pixel size. When `canvasSession` is used, pass the real `source_width` and `source_height`, or provide both `canvasWidth` and `canvasHeight`; the helper rejects missing dimensions instead of guessing a square placeholder. `assetKind` may only describe a static image and must match the authoritative source record. Prefer a project resource ID or asset ID when the same object key has multiple semantic registrations; for a raw object key outside in-place replacement, pass `sourceResourceId` to disambiguate. Without `canvasCompletion`, `targetLayerId` may replace only the layer that points to the same authoritative object as the source, and the server durably binds a raw object key to that target resource for Worker revalidation.
|
||||
|
||||
Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`.
|
||||
|
||||
For character animation, pass the canvas session and asset label to `animate_character`. The helper submits asynchronously and returns the completed compact result containing the authoritative formal `resource` and `asset`; do not synthesize a library asset from the first frame.
|
||||
|
||||
@@ -51,6 +51,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| --- | --- | --- | --- |
|
||||
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
|
||||
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceLayout`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
|
||||
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
@@ -58,7 +59,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
|
||||
Poll all eight through:
|
||||
Poll all nine through:
|
||||
|
||||
```text
|
||||
GET /api/external/v1/generations/{operationId}
|
||||
@@ -72,6 +73,7 @@ Supply the `operationId` returned by submission. Poll no faster than `pollAfterM
|
||||
- Pass `assetFolderId` plus `assetLabel` for image, edit, icon spritesheet, video, sound effect, and BGM operations when supported.
|
||||
- UI extraction uses `assetFolderId` and `spritesheetLabel`.
|
||||
- Character animation accepts `assetFolderId` and `assetLabel`. Its completed compact result directly returns the final `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`; never create a duplicate first-frame resource or asset.
|
||||
- Background removal derives the final static-image `assetKind` from the authoritative source record. A conflicting request kind or any video, audio, animation, or image-sequence kind returns `400` before queueing. Without `canvasCompletion`, `targetLayerId` must point to the same authoritative object as `sourceImageSrc` (prefer `assetObjectId`, otherwise canonical bucket/object key).
|
||||
- If a caller must manually create a `character-animation` resource or asset, put the authoritative frames and total sequence duration in `imageSequenceFrames` and `imageSequenceDurationMs`. Keep `generationInputs` replayable: it must not contain legacy runtime fields such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, or `durationSeconds`.
|
||||
- Reload project/library state after completion when full current state is required.
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ Infer what is already clear and ask only for missing fields that block the selec
|
||||
| --- | --- |
|
||||
| Generate a background, character, spec, UI mockup, or publication image | Image generation |
|
||||
| Redraw, retouch, or replace an existing image | Image edit |
|
||||
| Remove the background from an existing image | Background removal |
|
||||
| Generate from a local reference | Upload and confirm the local file, then image generation or edit |
|
||||
| Build a reusable transparent icon/game atlas from a visual spec | Icon spritesheet generation |
|
||||
| Extract marked assets from an existing UI design | UI design asset extraction |
|
||||
|
||||
@@ -15,7 +15,7 @@ Use this reference to build generation payloads, carry canvas/library context, p
|
||||
|
||||
## Asynchronous Submission
|
||||
|
||||
All eight generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
|
||||
All nine generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -71,6 +71,8 @@ status = client.get_generation(operation_id)
|
||||
completed = client.wait_for_generation(operation_id)
|
||||
```
|
||||
|
||||
Background removal uses the same submission and polling state machine. `sourceImageSrc` must be a stable owner-scoped object key, project resource ID, or asset ID; never pass a Data URL, Blob URL, or expiring signed URL. An explicit resource ID or asset ID is resolved before any object-key fallback. If a raw object key has multiple registrations with conflicting authoritative metadata, pass `sourceResourceId` to disambiguate or the server returns `400`. Use `projectId + canvasCompletion` for normal canvas placement. When `canvasCompletion` is absent, `projectId + targetLayerId` replaces an existing resource-backed layer and is rejected before queueing if the target is invalid; for a raw object key, the target resource becomes the durable source binding rechecked by the Worker. If both placement fields are absent, the server does not add the result to the canvas. The completed compact result contains the stable output object key, dimensions, and persisted resource/asset references when requested.
|
||||
|
||||
## Canvas and Asset-Library Completion
|
||||
|
||||
For endpoints that support these fields, include:
|
||||
@@ -98,6 +100,8 @@ A minimal `canvasCompletion` is:
|
||||
|
||||
`dialogId` is optional. The placeholder supplies canvas placement and completion coordinates; it is not a final media pixel-size constraint. For successful pixel-art snapping, the result layer uses the final logical-grid PNG dimensions even when they differ from the placeholder. Do not reconstruct canvas state from completion results. Reload the project and asset library when complete authoritative snapshots are needed.
|
||||
|
||||
Background removal preserves the source image dimensions. The Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. Without `canvasCompletion`, an in-place `targetLayerId` must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order.
|
||||
|
||||
Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame.
|
||||
|
||||
For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence.
|
||||
|
||||
@@ -561,6 +561,51 @@ class GenarrativeExternalClient:
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
def remove_background(
|
||||
self,
|
||||
source_image_src: str,
|
||||
source_width: int | None = None,
|
||||
source_height: int | None = None,
|
||||
**fields: Any,
|
||||
) -> Any:
|
||||
source_image_src = normalize_optional_text(source_image_src)
|
||||
if not source_image_src:
|
||||
raise GenarrativeApiError(
|
||||
"source_image_src must be an owner-scoped object key, resource ID, or asset ID"
|
||||
)
|
||||
if (source_width is None) != (source_height is None):
|
||||
raise GenarrativeApiError("source_width and source_height must be provided together")
|
||||
if source_width is not None and (
|
||||
source_width <= 0 or source_height is None or source_height <= 0
|
||||
):
|
||||
raise GenarrativeApiError("source_width and source_height must be positive integers")
|
||||
session = fields.get("canvasSession")
|
||||
if session is None:
|
||||
session = fields.get("canvas_session")
|
||||
canvas_width = fields.get("canvasWidth")
|
||||
canvas_height = fields.get("canvasHeight")
|
||||
if (canvas_width is None) != (canvas_height is None):
|
||||
raise GenarrativeApiError("canvasWidth and canvasHeight must be provided together")
|
||||
if session is not None and canvas_width is None:
|
||||
if source_width is None or source_height is None:
|
||||
raise GenarrativeApiError(
|
||||
"remove_background requires source_width and source_height when canvasSession is used without canvasWidth/canvasHeight"
|
||||
)
|
||||
fields["canvasWidth"] = source_width
|
||||
fields["canvasHeight"] = source_height
|
||||
self._apply_canvas_session_fields(
|
||||
fields,
|
||||
fields.get("assetLabel", "去背景结果"),
|
||||
source_width or 1,
|
||||
source_height or 1,
|
||||
)
|
||||
idempotency_key = fields.pop("idempotencyKey", None)
|
||||
return self.submit_and_wait_generation(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
{"sourceImageSrc": source_image_src, **fields},
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
def generate_icon_spritesheet(
|
||||
self,
|
||||
reference_id: str,
|
||||
@@ -687,11 +732,14 @@ def _self_test() -> None:
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
|
||||
b"\x00\x00\x00\x02\x00\x00\x00\x03\x08\x06\x00\x00\x00"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(suffix="Hero Image.png") as fh:
|
||||
with tempfile.NamedTemporaryFile(suffix="Hero Image.png", delete=False) as fh:
|
||||
fh.write(png)
|
||||
fh.flush()
|
||||
assert image_dimensions(fh.name) == (2, 3)
|
||||
assert source_layer_id_from_path(fh.name).startswith("external-reference-")
|
||||
temp_path = fh.name
|
||||
try:
|
||||
assert image_dimensions(temp_path) == (2, 3)
|
||||
assert source_layer_id_from_path(temp_path).startswith("external-reference-")
|
||||
finally:
|
||||
Path(temp_path).unlink(missing_ok=True)
|
||||
assert unwrap_envelope({"ok": True, "data": {"upload": 1}}) == {"upload": 1}
|
||||
client = GenarrativeExternalClient(api_key="test")
|
||||
session = {"projectId": "proj-demo", "assetFolderId": "editor-asset-folder-demo"}
|
||||
@@ -773,6 +821,30 @@ def _self_test() -> None:
|
||||
assert len(result["asset"]["imageSequenceFrames"]) == 2
|
||||
assert result["asset"]["imageSequenceDurationMs"] == 4000
|
||||
calls.clear()
|
||||
background_result = client.remove_background(
|
||||
"uploads/source.png",
|
||||
720,
|
||||
1280,
|
||||
canvasSession=session,
|
||||
assetLabel="去背景结果",
|
||||
)
|
||||
assert background_result["taskId"] == "task-demo"
|
||||
assert calls[0]["path"] == "/api/external/v1/editor/images/background-removals"
|
||||
assert calls[0]["body"]["sourceImageSrc"] == "uploads/source.png"
|
||||
assert calls[0]["body"]["projectId"] == "proj-demo"
|
||||
assert calls[0]["body"]["assetFolderId"] == "editor-asset-folder-demo"
|
||||
assert calls[0]["body"]["assetLabel"] == "去背景结果"
|
||||
assert calls[0]["body"]["canvasCompletion"]["title"] == "去背景结果"
|
||||
assert calls[0]["body"]["canvasCompletion"]["placeholder"]["width"] == 720
|
||||
assert calls[0]["body"]["canvasCompletion"]["placeholder"]["height"] == 1280
|
||||
calls.clear()
|
||||
try:
|
||||
client.remove_background("uploads/source.png", canvasSession=session)
|
||||
except GenarrativeApiError as error:
|
||||
assert "source_width and source_height" in str(error)
|
||||
else:
|
||||
raise AssertionError("canvas background removal must not guess source dimensions")
|
||||
assert calls == []
|
||||
client.generate_icon_spritesheet(
|
||||
"editor-resource-spec",
|
||||
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ temp*build*/
|
||||
/apps/preview-deployer-web/dist/
|
||||
/apps/preview-deployer-web/node_modules/
|
||||
/server-rs/.spacetimedb/
|
||||
/server-rs/.data/
|
||||
**/server-rs/.data/
|
||||
/public/generated-animations
|
||||
/public/generated-character-drafts
|
||||
/public/generated-characters
|
||||
|
||||
@@ -1145,6 +1145,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/external/v1/editor/images/background-removals": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Editor Images"
|
||||
],
|
||||
"operationId": "removeExternalEditorImageBackground",
|
||||
"summary": "去除编辑器图片背景",
|
||||
"description": "提交已有静态图片素材的异步去背景任务。sourceImageSrc 只接受当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID;禁止 Data URL、Blob URL 和临时 signed URL。assetKind 只能表达静态图片,并且存在权威来源记录时必须与其类型一致;视频、音频、动画和图片序列在入队前返回 400。服务端固定使用 complex 去背景模式,不会在失败时切换到其它 provider。需要写入画布时提供 projectId 与 canvasCompletion;仅需原位替换既有图层时提供 projectId 与 targetLayerId,且来源与目标必须指向同一权威对象。",
|
||||
"security": [
|
||||
{
|
||||
"ExternalApiKey": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/IdempotencyKey"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/EditorBackgroundRemovalRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "去背景任务已持久化入队",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
},
|
||||
"502": {
|
||||
"$ref": "#/components/responses/UpstreamError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/external/v1/editor/icon-spritesheets/generations": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -2339,7 +2393,8 @@
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
],
|
||||
"description": "可选的来源项目资源 ID,用于在同一 objectKey 被多个资源以不同权威元数据登记时消歧。sourceImageSrc 已是项目资源 ID 或素材 ID 时按该显式 ID 定点解析;纯 objectKey 无法唯一确定权威来源时必须提供本字段,否则返回 400。原位替换使用纯 objectKey 且省略本字段时,服务端自动绑定 targetLayerId 对应的当前项目资源,并把该绑定写入队列供 Worker 复验。"
|
||||
},
|
||||
"assetKind": {
|
||||
"type": [
|
||||
@@ -3550,6 +3605,75 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EditorBackgroundRemovalRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sourceImageSrc"
|
||||
],
|
||||
"properties": {
|
||||
"sourceImageSrc": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID。禁止 Data URL、Blob URL 和临时 signed URL。"
|
||||
},
|
||||
"projectId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "可选项目上下文。提供 targetLayerId 时必须同时提供非空 projectId,否则在入队前返回 400。"
|
||||
},
|
||||
"targetLayerId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "提供时必须同时提供 projectId。仅在未提供 canvasCompletion 时用于原位替换既有画布图层,此时目标图层必须存在、关联当前项目静态图片资源;若图层提供 assetKindOverride,则以覆盖后的有效类型参与静态媒体门禁和来源类型比较。来源与目标必须指向同一权威对象:双方都有 assetObjectId 时按 ID 比较,任一缺失时回退 canonical bucket/objectKey;违反任一条件均在入队前返回 400。存在 canvasCompletion 时不参与原位替换。省略 targetLayerId 与 canvasCompletion 时只持久化请求指定的项目资源或素材库记录,不自动写入画布。"
|
||||
},
|
||||
"sourceResourceId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "可选的来源项目资源 ID,用于在同一 objectKey 被多个资源以不同权威元数据登记时消歧。sourceImageSrc 已是项目资源 ID 或素材 ID 时按该显式 ID 定点解析;纯 objectKey 无法唯一确定权威来源时必须提供本字段,否则返回 400。原位替换使用纯 objectKey 且省略本字段时,服务端自动绑定 targetLayerId 对应的当前项目资源,并把该绑定写入队列供 Worker 复验。"
|
||||
},
|
||||
"assetKind": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "可选静态图片语义类型。服务端优先采用来源项目资源或素材库记录的权威语义类型;资产对象存储类型只参与非静态媒体门禁,不覆盖语义类型。请求值与权威来源类型不一致,或任一记录表示 video、audio、animation、image-sequence、sound-effect、background-music 等非静态媒体时,在入队前返回 400。",
|
||||
"x-genarrative-media-family": "static-image"
|
||||
},
|
||||
"generationInputs": {
|
||||
"$ref": "#/components/schemas/JsonValue"
|
||||
},
|
||||
"assetFolderId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"assetLabel": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"canvasCompletion": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EditorCanvasGenerationCompletion"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "画布生成占位完成指令。提供时优先按生成完成链路写入结果,targetLayerId 不参与原位替换。"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EditorImageGenerationResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-23 External 去背景绑定权威静态来源与真实画布尺寸
|
||||
|
||||
- 背景:External v1 去背景曾把调用方 `assetKind` 原样带入持久化,并允许 `sourceImageSrc=A + targetLayerId=B` 覆盖不同资源;Python helper 又为所有画布完成请求固定生成 `1024×1024` 占位,导致非方形透明结果按占位尺寸拉伸。
|
||||
- 决策:入队前从当前 owner 的项目资源或素材库解析来源权威语义类型,资产对象存储类型只参与非静态媒体门禁;显式项目资源 ID / 素材 ID 优先于 objectKey 回退,同一纯 objectKey 对应的候选权威元数据不一致时返回 `400` 并要求用 `sourceResourceId` 或业务 ID 消歧,禁止按列表首条决定类型。请求类型冲突或任一记录属于视频、音频、动画、图片序列时返回 `400`,队列只保存服务端解析出的静态语义类型。无 `canvasCompletion` 的原位替换优先比较双方 `assetObjectId`,任一缺失时回退 canonical `(bucket, objectKey)`,并要求默认类型一致;纯 objectKey 省略 `sourceResourceId` 时自动绑定目标图层资源并写入队列,由 Worker 复验同一绑定。helper 使用画布会话时必须取得真实源宽高或显式 `canvasWidth + canvasHeight`,不再猜测方形尺寸。
|
||||
- 影响范围:External v1 去背景入队与 worker 复验、OpenAPI、Python helper、外部编辑器 skill 和相关契约测试;不修改 SpacetimeDB schema、BgFilter 协议或去背景输出尺寸语义。
|
||||
- 验证方式:覆盖非静态类型与权威类型冲突、来源/目标不同对象拒绝及同对象通过、非方形 helper completion;运行 api-server 定向测试、helper self-test、OpenAPI 解析、编码与 diff 门禁。
|
||||
|
||||
## 2026-08-20 UI Editor LLM 递归输出与参考图单文件限制
|
||||
|
||||
- 背景:结构识别、界面语义建议和多图合并直接把 LLM 工具 arguments 反序列化为递归树;结构识别与语义建议还在 async command 中同步读取并 base64 编码参考图。模型异常输出或过大图片可能造成不受控内存、栈和 async worker 占用。
|
||||
@@ -6492,6 +6499,7 @@
|
||||
- Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。
|
||||
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。
|
||||
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。
|
||||
- 2026-08-23 补充:新增 `POST /api/external/v1/editor/images/background-removals` 后,External v1 生成 POST 由八类增至九类;该入口继续使用 `editor:image-generate` scope、稳定 `Idempotency-Key`、`202 + operationId` 与统一查询合同。外部请求只允许 OpenAPI 声明的去背景字段,拒绝内部 `taskId` 和其它未声明字段;入队前按当前 owner 解析稳定来源并规范化为权威 objectKey,同时预检、规范化项目与素材目录目标。提供 `targetLayerId` 时始终必须同时提供 `projectId`;没有 `canvasCompletion` 时目标图层必须存在并关联当前项目资源,存在 `canvasCompletion` 时沿用生成完成链路且不执行原位替换;目标无效、引用未登记或越权时不创建任务。未提供任一画布完成字段时不自动写入画布;任务 ID 仅由服务端队列生成。
|
||||
|
||||
## 2026-08-04 图片画布 BGM Prompt 采用唯一可见规范化文本与面板级同步提交锁
|
||||
|
||||
@@ -13564,6 +13572,7 @@
|
||||
- Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。
|
||||
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。
|
||||
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。
|
||||
- 2026-08-23 补充:新增 `POST /api/external/v1/editor/images/background-removals` 后,External v1 生成 POST 由八类增至九类;该入口继续使用 `editor:image-generate` scope、稳定 `Idempotency-Key`、`202 + operationId` 与统一查询合同。外部请求只允许 OpenAPI 声明的去背景字段,拒绝内部 `taskId` 和其它未声明字段;入队前按当前 owner 解析稳定来源并规范化为权威 objectKey,同时预检、规范化项目与素材目录目标,未登记、越权引用和无效目标不创建任务;任务 ID 仅由服务端队列生成。托管 MCP 不再维护异步生成 operation 的幂等硬编码名单,而是从 OpenAPI operation/path 的 required `Idempotency-Key` header 自动生成 `idempotencyKey` 工具参数并转发同名 HTTP 头,避免新增 operation 只出现在 `tools/list` 却无法实际提交。
|
||||
|
||||
## 2026-08-04 图片画布 BGM Prompt 采用唯一可见规范化文本与面板级同步提交锁
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 背景
|
||||
|
||||
VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部调用方的 HTTP 请求长期等待上游、OSS 持久化和 SpacetimeDB 回写。站内保留受控 `inline` 排障模式;External v1 的八类生成则固定使用持久队列和统一查询接口,避免调用方超时后重复提交、重复扣费或丢失已完成结果。
|
||||
VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部调用方的 HTTP 请求长期等待上游、OSS 持久化和 SpacetimeDB 回写。站内保留受控 `inline` 排障模式;External v1 的九类生成则固定使用持久队列和统一查询接口,避免调用方超时后重复提交、重复扣费或丢失已完成结果。
|
||||
|
||||
## 目标
|
||||
|
||||
@@ -117,7 +117,7 @@ pending/running -> cancelled (预留)
|
||||
- `queue`:默认值,HTTP handler 入队 `external_generation_job`,由 `external-generation-worker` 角色 claim lease 后执行;生产、预发和压测默认使用该模式。
|
||||
- `inline`:HTTP handler 直接调用同一个 worker executor,同步等待 provider、OSS 和 SpacetimeDB 写回完成后返回 `operation.status = completed`;只用于本地或低并发排查,不提供队列持久化、lease 重领和 worker 横向扩容。
|
||||
|
||||
External v1 八类生成不读取上述模式分支:即使进程配置为 `inline`,External handler 仍只做校验、幂等入队并返回 HTTP `202`。调用方按 `/api/external/v1/generations/{operationId}` 查询;这条外部契约不能因部署环境不同而从异步退化为同步响应。
|
||||
External v1 九类生成不读取上述模式分支:即使进程配置为 `inline`,External handler 仍只做校验、幂等入队并返回 HTTP `202`。调用方按 `/api/external/v1/generations/{operationId}` 查询;这条外部契约不能因部署环境不同而从异步退化为同步响应。
|
||||
|
||||
同一个 Rust binary 通过 `GENARRATIVE_PROCESS_ROLE` 切换:
|
||||
|
||||
@@ -220,7 +220,7 @@ inline 完成结果与 External v1 completed compact result 继续使用结构
|
||||
|
||||
### External v1 异步提交与查询
|
||||
|
||||
External v1 复用上述九类 editor job kind 中除手动去背景外的八类生成 kind。外部 POST handler 只负责 API Key scope、owner、请求校验和入队,不调用 `*_for_owner` 同步执行函数:
|
||||
External v1 复用上述全部九类 editor job kind,包括手动去背景。外部 POST handler 只负责 API Key scope、owner、请求校验和入队,不调用 `*_for_owner` 同步执行函数;去背景在入队前还会解析当前 owner 的稳定来源,并预检、规范化项目与素材目录目标。提供 `targetLayerId` 时必须同时提供 `projectId`;没有 `canvasCompletion` 时还要确认目标图层存在并关联当前项目资源,存在 `canvasCompletion` 时继续按生成完成链路写入而不执行原位替换:
|
||||
|
||||
1. 每个生成 POST 必须携带 `Idempotency-Key`。服务端把 owner、job kind、稳定键和规范请求纳入 dedupe;未知结果重试必须复用原键。
|
||||
2. 成功入队返回 HTTP `202`、`operationId`、`statusUrl`、`pollAfterMs`,并设置 `Location` / `Retry-After`;不返回 project、asset 或媒体结果。
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ use axum::{
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::assets::EditorCanvasGenerationCompletionPayload;
|
||||
use shared_contracts::external_generation::{
|
||||
ExternalEditorGenerationJobResponse, ExternalEditorGenerationSubmissionResponse,
|
||||
ExternalGenerationJobStatus,
|
||||
@@ -33,12 +34,13 @@ use crate::{
|
||||
editor_project::{
|
||||
EDITOR_ASSET_FOLDER_ID_PREFIX, EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE,
|
||||
EDITOR_PROJECT_ID_PREFIX, EDITOR_RESOURCE_ID_PREFIX, EditorAssetFolderPayload,
|
||||
EditorAssetLibraryPayload, EditorAssetPayload, EditorCanvasViewportPayload,
|
||||
EditorGenerationCaller, EditorImageEditRequest, EditorImageGenerationRequest,
|
||||
EditorProjectPayload, EditorProjectResourcePayload, EditorUiDesignAssetExtractionRequest,
|
||||
current_utc_micros, editor_asset_folder_payload_from_record,
|
||||
editor_asset_library_payload_from_record, editor_asset_payload_from_record,
|
||||
editor_project_payload_from_record, editor_project_resource_payload_from_record,
|
||||
EditorAssetLibraryPayload, EditorAssetPayload, EditorBackgroundRemovalRequest,
|
||||
EditorCanvasViewportPayload, EditorGenerationCaller, EditorImageEditRequest,
|
||||
EditorImageGenerationRequest, EditorProjectPayload, EditorProjectResourcePayload,
|
||||
EditorUiDesignAssetExtractionRequest, current_utc_micros,
|
||||
editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record,
|
||||
editor_asset_payload_from_record, editor_project_payload_from_record,
|
||||
editor_project_resource_payload_from_record, enqueue_editor_background_removal_for_owner,
|
||||
enqueue_editor_image_edit_for_owner, enqueue_editor_image_generation_for_owner,
|
||||
enqueue_editor_ui_design_asset_extraction_for_owner,
|
||||
ensure_generic_editor_image_generation_contract, map_editor_project_error,
|
||||
@@ -161,6 +163,37 @@ pub struct ExternalEditorProjectResourceCreateRequest {
|
||||
image_sequence_duration_ms: Option<NonZeroU64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(crate) struct ExternalEditorBackgroundRemovalRequest {
|
||||
source_image_src: String,
|
||||
project_id: Option<String>,
|
||||
target_layer_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
asset_folder_id: Option<String>,
|
||||
asset_label: Option<String>,
|
||||
source_resource_id: Option<String>,
|
||||
canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
|
||||
}
|
||||
|
||||
impl From<ExternalEditorBackgroundRemovalRequest> for EditorBackgroundRemovalRequest {
|
||||
fn from(payload: ExternalEditorBackgroundRemovalRequest) -> Self {
|
||||
Self {
|
||||
source_image_src: payload.source_image_src,
|
||||
project_id: payload.project_id,
|
||||
target_layer_id: payload.target_layer_id,
|
||||
asset_kind: payload.asset_kind,
|
||||
generation_inputs: payload.generation_inputs,
|
||||
asset_folder_id: payload.asset_folder_id,
|
||||
asset_label: payload.asset_label,
|
||||
source_resource_id: payload.source_resource_id,
|
||||
task_id: None,
|
||||
canvas_completion: payload.canvas_completion,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorAssetUpdateRequest {
|
||||
@@ -774,6 +807,31 @@ pub async fn edit_external_editor_image(
|
||||
Ok(external_generation_accepted_response(&request_context, job))
|
||||
}
|
||||
|
||||
pub async fn remove_external_editor_image_background(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(principal): Extension<ExternalApiPrincipal>,
|
||||
headers: HeaderMap,
|
||||
payload: Result<Json<ExternalEditorBackgroundRemovalRequest>, JsonRejection>,
|
||||
) -> Result<Response, Response> {
|
||||
require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
||||
let idempotency_key = require_idempotency_key(&headers)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?;
|
||||
let payload = EditorBackgroundRemovalRequest::from(payload);
|
||||
let project_id = payload.project_id.clone();
|
||||
let job = enqueue_editor_background_removal_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
&editor_generation_caller(&principal, project_id),
|
||||
payload,
|
||||
Some(idempotency_key),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
Ok(external_generation_accepted_response(&request_context, job))
|
||||
}
|
||||
|
||||
pub async fn generate_external_editor_icon_spritesheet(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1737,6 +1795,214 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_background_removal_rejects_undocumented_fields_before_queueing() {
|
||||
let state = AppState::new(crate::config::AppConfig::default())
|
||||
.expect("external background removal test state should build");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
post(remove_external_editor_image_background),
|
||||
)
|
||||
.layer(Extension(request_context(false)))
|
||||
.layer(Extension(ExternalApiPrincipal::for_test(
|
||||
"user-external-background-removal",
|
||||
&[SCOPE_EDITOR_IMAGE_GENERATE],
|
||||
)))
|
||||
.with_state(state.clone());
|
||||
|
||||
for (case_name, extra_field) in [
|
||||
(
|
||||
"internal taskId",
|
||||
json!({"taskId": "caller-controlled-task"}),
|
||||
),
|
||||
("unknown field", json!({"unexpected": true})),
|
||||
] {
|
||||
let mut request_body = json!({"sourceImageSrc": "editor-upload/source.png"});
|
||||
request_body
|
||||
.as_object_mut()
|
||||
.expect("background removal body should be an object")
|
||||
.extend(
|
||||
extra_field
|
||||
.as_object()
|
||||
.expect("extra field fixture should be an object")
|
||||
.clone(),
|
||||
);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/external/v1/editor/images/background-removals")
|
||||
.header("content-type", "application/json")
|
||||
.header(IDEMPOTENCY_KEY_HEADER, "background-removal-contract-test")
|
||||
.body(Body::from(request_body.to_string()))
|
||||
.expect("external background removal request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("external background removal response should return");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{case_name}");
|
||||
assert_eq!(
|
||||
state.test_editor_generation_enqueue_attempts(),
|
||||
0,
|
||||
"{case_name} must fail before queueing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_background_removal_rejects_unstable_sources_before_queueing() {
|
||||
let state = AppState::new(crate::config::AppConfig::default())
|
||||
.expect("external background removal source test state should build");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
post(remove_external_editor_image_background),
|
||||
)
|
||||
.layer(Extension(request_context(false)))
|
||||
.layer(Extension(ExternalApiPrincipal::for_test(
|
||||
"user-external-background-removal-source",
|
||||
&[SCOPE_EDITOR_IMAGE_GENERATE],
|
||||
)))
|
||||
.with_state(state.clone());
|
||||
|
||||
for source_image_src in [
|
||||
"data:image/png;base64,AAAA",
|
||||
"blob:browser-only",
|
||||
"https://oss.example/private.png?signature=temporary",
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/external/v1/editor/images/background-removals")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
IDEMPOTENCY_KEY_HEADER,
|
||||
"background-removal-source-contract-test",
|
||||
)
|
||||
.body(Body::from(
|
||||
json!({"sourceImageSrc": source_image_src}).to_string(),
|
||||
))
|
||||
.expect("external background removal source request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("external background removal source response should return");
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"{source_image_src}"
|
||||
);
|
||||
assert_eq!(
|
||||
state.test_editor_generation_enqueue_attempts(),
|
||||
0,
|
||||
"unstable source must fail before queueing: {source_image_src}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_background_removal_rejects_non_static_asset_kind_before_queueing() {
|
||||
let state = AppState::new(crate::config::AppConfig::default())
|
||||
.expect("external background removal kind test state should build");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
post(remove_external_editor_image_background),
|
||||
)
|
||||
.layer(Extension(request_context(false)))
|
||||
.layer(Extension(ExternalApiPrincipal::for_test(
|
||||
"user-external-background-removal-kind",
|
||||
&[SCOPE_EDITOR_IMAGE_GENERATE],
|
||||
)))
|
||||
.with_state(state.clone());
|
||||
|
||||
for asset_kind in ["video", "audio", "character-animation", "image-sequence"] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/external/v1/editor/images/background-removals")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
IDEMPOTENCY_KEY_HEADER,
|
||||
format!("background-removal-kind-{asset_kind}"),
|
||||
)
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sourceImageSrc": "editor-upload/source.png",
|
||||
"assetKind": asset_kind,
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("external background removal kind request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("external background removal kind response should return");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{asset_kind}");
|
||||
assert_eq!(
|
||||
state.test_editor_generation_enqueue_attempts(),
|
||||
0,
|
||||
"non-static kind must fail before queueing: {asset_kind}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_background_removal_rejects_target_without_project_before_queueing() {
|
||||
let state = AppState::new(crate::config::AppConfig::default())
|
||||
.expect("external background removal target test state should build");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
post(remove_external_editor_image_background),
|
||||
)
|
||||
.layer(Extension(request_context(false)))
|
||||
.layer(Extension(ExternalApiPrincipal::for_test(
|
||||
"user-external-background-removal-target",
|
||||
&[SCOPE_EDITOR_IMAGE_GENERATE],
|
||||
)))
|
||||
.with_state(state.clone());
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/external/v1/editor/images/background-removals")
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
IDEMPOTENCY_KEY_HEADER,
|
||||
"background-removal-target-contract-test",
|
||||
)
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sourceImageSrc": "editor-upload/source.png",
|
||||
"targetLayerId": "layer-source",
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("external background removal target request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("external background removal target response should return");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
state.test_editor_generation_enqueue_attempts(),
|
||||
0,
|
||||
"target without project must fail before queueing",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_generic_image_generation_rejects_scene_asset_kind_before_queueing() {
|
||||
assert_external_generic_image_scene_bypass_is_rejected_before_queueing(
|
||||
@@ -1921,6 +2187,7 @@ mod tests {
|
||||
for path in [
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"/api/external/v1/editor/images/edits",
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
"/api/external/v1/editor/icon-spritesheets/generations",
|
||||
"/api/external/v1/editor/ui-designs/assets/extractions",
|
||||
"/api/external/v1/editor/character-animations/generations",
|
||||
@@ -2346,6 +2613,55 @@ mod tests {
|
||||
.get("/api/external/v1/editor/audios/background-music/generations")
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
parsed["paths"]
|
||||
.get("/api/external/v1/editor/images/background-removals")
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["paths"]["/api/external/v1/editor/images/background-removals"]["post"]["operationId"],
|
||||
"removeExternalEditorImageBackground"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["required"],
|
||||
json!(["sourceImageSrc"])
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["additionalProperties"],
|
||||
json!(false)
|
||||
);
|
||||
assert!(
|
||||
parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]["properties"]
|
||||
.get("taskId")
|
||||
.is_none()
|
||||
);
|
||||
let background_target_description = parsed["components"]["schemas"]
|
||||
["EditorBackgroundRemovalRequest"]["properties"]["targetLayerId"]["description"]
|
||||
.as_str()
|
||||
.expect("background removal targetLayerId should document placement semantics");
|
||||
assert!(background_target_description.contains("projectId"));
|
||||
assert!(background_target_description.contains("canvasCompletion"));
|
||||
assert!(background_target_description.contains("assetObjectId"));
|
||||
assert!(background_target_description.contains("bucket/objectKey"));
|
||||
assert!(background_target_description.contains("不自动写入画布"));
|
||||
let background_asset_kind = &parsed["components"]["schemas"]["EditorBackgroundRemovalRequest"]
|
||||
["properties"]["assetKind"];
|
||||
assert_eq!(
|
||||
background_asset_kind["x-genarrative-media-family"],
|
||||
"static-image"
|
||||
);
|
||||
let background_asset_kind_description = background_asset_kind["description"]
|
||||
.as_str()
|
||||
.expect("background removal assetKind should document authoritative static semantics");
|
||||
assert!(background_asset_kind_description.contains("权威来源类型"));
|
||||
assert!(background_asset_kind_description.contains("入队前返回 400"));
|
||||
let background_source_resource_description = parsed["components"]["schemas"]
|
||||
["EditorBackgroundRemovalRequest"]["properties"]["sourceResourceId"]["description"]
|
||||
.as_str()
|
||||
.expect("background removal sourceResourceId should document disambiguation");
|
||||
assert!(background_source_resource_description.contains("消歧"));
|
||||
assert!(background_source_resource_description.contains("targetLayerId"));
|
||||
assert!(background_source_resource_description.contains("Worker"));
|
||||
assert!(
|
||||
parsed["paths"]
|
||||
.get("/api/external/v1/editor/assets/library")
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::{Arc, LazyLock};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{
|
||||
Method, Request,
|
||||
HeaderMap, Method, Request,
|
||||
header::{AUTHORIZATION, CONTENT_TYPE},
|
||||
},
|
||||
};
|
||||
@@ -246,17 +246,8 @@ fn build_mcp_operations() -> Vec<McpOperation> {
|
||||
};
|
||||
let method = Method::from_bytes(method_name.to_ascii_uppercase().as_bytes())
|
||||
.expect("known HTTP method");
|
||||
let requires_idempotency_key = matches!(
|
||||
operation_id,
|
||||
"generateExternalEditorImage"
|
||||
| "editExternalEditorImage"
|
||||
| "generateExternalEditorIconSpritesheet"
|
||||
| "extractExternalEditorUiDesignAssets"
|
||||
| "generateExternalEditorCharacterAnimation"
|
||||
| "generateExternalEditorVideo"
|
||||
| "generateExternalEditorSoundEffect"
|
||||
| "generateExternalEditorBackgroundMusic"
|
||||
);
|
||||
let requires_idempotency_key =
|
||||
operation_requires_idempotency_key(&openapi, path_item, operation);
|
||||
let description = operation
|
||||
.get("description")
|
||||
.or_else(|| operation.get("summary"))
|
||||
@@ -287,6 +278,34 @@ fn build_mcp_operations() -> Vec<McpOperation> {
|
||||
operations
|
||||
}
|
||||
|
||||
fn operation_requires_idempotency_key(
|
||||
openapi: &Value,
|
||||
path_item: &Map<String, Value>,
|
||||
operation: &Map<String, Value>,
|
||||
) -> bool {
|
||||
path_item
|
||||
.get("parameters")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(
|
||||
operation
|
||||
.get("parameters")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
)
|
||||
.filter_map(|parameter| resolve_openapi_reference(openapi, parameter))
|
||||
.any(|parameter| {
|
||||
parameter.get("in").and_then(Value::as_str) == Some("header")
|
||||
&& parameter
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("Idempotency-Key"))
|
||||
&& parameter.get("required").and_then(Value::as_bool) == Some(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_operation_input_schema(
|
||||
openapi: &Value,
|
||||
path_item: &Map<String, Value>,
|
||||
@@ -513,18 +532,7 @@ async fn dispatch_operation(
|
||||
"application/json".parse().expect("valid content type"),
|
||||
);
|
||||
}
|
||||
if operation.requires_idempotency_key {
|
||||
let idempotency_key = arguments
|
||||
.get("idempotencyKey")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| json!({"error": "生成工具必须提供 idempotencyKey"}))?;
|
||||
request.headers_mut().insert(
|
||||
"idempotency-key",
|
||||
idempotency_key
|
||||
.parse()
|
||||
.map_err(|_| json!({"error": "idempotencyKey 不是合法 HTTP 头值"}))?,
|
||||
);
|
||||
}
|
||||
apply_operation_headers(operation, &arguments, request.headers_mut())?;
|
||||
|
||||
let response = modules::external_api::router(state.clone())
|
||||
.with_state(state)
|
||||
@@ -557,6 +565,27 @@ async fn dispatch_operation(
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_operation_headers(
|
||||
operation: &McpOperation,
|
||||
arguments: &Map<String, Value>,
|
||||
headers: &mut HeaderMap,
|
||||
) -> Result<(), Value> {
|
||||
if !operation.requires_idempotency_key {
|
||||
return Ok(());
|
||||
}
|
||||
let idempotency_key = arguments
|
||||
.get("idempotencyKey")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| json!({"error": "生成工具必须提供 idempotencyKey"}))?;
|
||||
headers.insert(
|
||||
"idempotency-key",
|
||||
idempotency_key
|
||||
.parse()
|
||||
.map_err(|_| json!({"error": "idempotencyKey 不是合法 HTTP 头值"}))?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_required_body(
|
||||
operation: &McpOperation,
|
||||
arguments: &Map<String, Value>,
|
||||
@@ -739,6 +768,7 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(names.len(), MCP_OPERATIONS.len());
|
||||
assert!(names.contains_key("generate_external_editor_image"));
|
||||
assert!(names.contains_key("remove_external_editor_image_background"));
|
||||
assert!(names.contains_key("get_external_editor_generation_job"));
|
||||
|
||||
let list_projects = MCP_OPERATIONS
|
||||
@@ -877,19 +907,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn generation_tools_require_idempotency_key() {
|
||||
let operation = MCP_OPERATIONS
|
||||
for tool_name in [
|
||||
"generate_external_editor_image",
|
||||
"remove_external_editor_image_background",
|
||||
] {
|
||||
let operation = MCP_OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.tool_name == tool_name)
|
||||
.unwrap_or_else(|| panic!("{tool_name} should exist"));
|
||||
assert!(operation.requires_idempotency_key, "{tool_name}");
|
||||
assert_eq!(
|
||||
operation.input_schema.get("required"),
|
||||
Some(&json!(["body", "idempotencyKey"])),
|
||||
"{tool_name}"
|
||||
);
|
||||
}
|
||||
|
||||
let image_generation = MCP_OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.tool_name == "generate_external_editor_image")
|
||||
.expect("image generation tool should exist");
|
||||
assert!(operation.requires_idempotency_key);
|
||||
assert_eq!(
|
||||
operation.input_schema.get("required"),
|
||||
Some(&json!(["body", "idempotencyKey"]))
|
||||
);
|
||||
assert_eq!(
|
||||
operation.input_schema["properties"]["body"]["properties"]["projectId"]["type"],
|
||||
image_generation.input_schema["properties"]["body"]["properties"]["projectId"]["type"],
|
||||
json!(["string", "null"])
|
||||
);
|
||||
|
||||
let background_removal = MCP_OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.tool_name == "remove_external_editor_image_background")
|
||||
.expect("background removal tool should exist");
|
||||
assert_eq!(
|
||||
background_removal.input_schema["properties"]["body"]["required"],
|
||||
json!(["sourceImageSrc"])
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
apply_operation_headers(
|
||||
background_removal,
|
||||
&Map::from_iter([("idempotencyKey".to_string(), json!("issue-178-request"))]),
|
||||
&mut headers,
|
||||
)
|
||||
.expect("background removal idempotency header should be forwarded");
|
||||
assert_eq!(
|
||||
headers
|
||||
.get("idempotency-key")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("issue-178-request")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1090,7 +1154,7 @@ mod tests {
|
||||
(
|
||||
4,
|
||||
SKILL_REQUESTS_AND_OUTPUTS_URI,
|
||||
"All eight generation POST routes require",
|
||||
"All nine generation POST routes require",
|
||||
),
|
||||
] {
|
||||
let request = Request::builder()
|
||||
|
||||
@@ -23,7 +23,8 @@ use crate::{
|
||||
generate_external_editor_video, get_external_editor_asset_library,
|
||||
get_external_editor_generation_job, get_external_editor_project,
|
||||
list_external_editor_projects, load_recent_external_editor_project, openapi_json,
|
||||
rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset,
|
||||
remove_external_editor_image_background, rename_external_editor_project,
|
||||
save_external_editor_canvas, update_external_editor_asset,
|
||||
update_external_editor_asset_folder,
|
||||
},
|
||||
external_mcp,
|
||||
@@ -186,6 +187,12 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_external_api_key,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
post(remove_external_editor_image_background).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_external_api_key),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/icon-spritesheets/generations",
|
||||
post(generate_external_editor_icon_spritesheet).route_layer(
|
||||
|
||||
Reference in New Issue
Block a user