diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index 83a847046..e6a9f55e7 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -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 `. 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. For normal canvas placement with `canvasSession`, 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. Passing `targetLayerId` selects in-place replacement: the helper retains the session's project/library context but does not inject `canvasCompletion`, and it rejects an explicit `canvasCompletion` combined with `targetLayerId`. The target layer must point 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. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index 44135c949..57e1ffa65 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -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. diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md index 21f881bc0..a94051f29 100644 --- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md +++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md @@ -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 | diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md index 234858b59..94dc6d622 100644 --- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -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. For normal canvas placement, 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. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target 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. diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index db4ebcb36..cc5a4a4b5 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -561,6 +561,58 @@ 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") + target_layer_id = normalize_optional_text(fields.get("targetLayerId")) + if target_layer_id and fields.get("canvasCompletion") is not None: + raise GenarrativeApiError( + "targetLayerId and canvasCompletion are mutually exclusive for background removal" + ) + 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 and not target_layer_id: + 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, + ) + if target_layer_id: + fields.pop("canvasCompletion", None) + 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 +739,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 +828,54 @@ 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() + client.remove_background( + "uploads/source.png", + canvasSession=session, + targetLayerId="layer-1", + assetLabel="原位去背景结果", + ) + 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"]["targetLayerId"] == "layer-1" + assert "canvasCompletion" not in calls[0]["body"] + calls.clear() + try: + client.remove_background( + "uploads/source.png", + canvasSession=session, + targetLayerId="layer-1", + canvasCompletion={"title": "冲突完成指令"}, + ) + except GenarrativeApiError as error: + assert "targetLayerId and canvasCompletion are mutually exclusive" in str(error) + else: + raise AssertionError("background removal must reject conflicting canvas placement modes") + assert calls == [] + 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", ["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"], diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 25cf472c1..809ff111d 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -1,7 +1,7 @@ { "name": "@genarrative/ai-game-creator-shell", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index c2a3783f0..1989106a5 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1733,12 +1733,12 @@ if ( } if ( - tauriConfig.version !== '0.1.1' || - packageConfig.version !== '0.1.1' || - cargoPackageVersion !== '0.1.1' + tauriConfig.version !== '0.1.2' || + packageConfig.version !== '0.1.2' || + cargoPackageVersion !== '0.1.2' ) { throw new Error( - 'AI game creator standard release must remain version 0.1.1 while game-chat uses its dedicated version', + 'AI game creator standard release must remain version 0.1.2 while game-chat uses its dedicated version', ); } diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 11f012de6..2c48043dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1695,7 +1695,7 @@ dependencies = [ [[package]] name = "genarrative-ai-game-creator-shell" -version = "0.1.1" +version = "0.1.2" dependencies = [ "agent-runtime-core", "axum", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 77d6b51f3..bd7a22b1e 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "genarrative-ai-game-creator-shell" -version = "0.1.1" +version = "0.1.2" edition = "2021" publish = false diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md index e22a0c104..f496d14b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -12,11 +12,12 @@ Let the client derive projections from real disk changes and trusted tool result 1. Write executable source to the current `game/` files and media to the relative paths returned by approved tools. 2. Before using or deriving an existing asset, call `agc_list_registered_assets` and select its `localAssetId`; never infer a source from a filename or submit a local path, platform ID, object key, operation ID, or idempotency key as a generation argument. 3. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. -4. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable. -5. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand. -6. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change. -7. Do not claim a resource or version is visible before the client projects it. If projection is missing, report the changed relative files and let the client re-read durable state. -8. Never move HTML, CSS, or JavaScript into documentation folders. They belong to the game-code projection; prose, design notes, and instructions remain documents. +4. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state. +5. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable. +6. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand. +7. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change. +8. Do not claim a resource or version is visible before the client projects it. If projection is missing, report the changed relative files and let the client re-read durable state. +9. Never move HTML, CSS, or JavaScript into documentation folders. They belong to the game-code projection; prose, design notes, and instructions remain documents. Call `agc_read_skill_resource` with `skillName="agc-client-projection"` and `relativePath="references/projection-contract.md"` when a request touches asset identity, revision behavior, or version history. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md index b7f754400..fd0d0f798 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -11,3 +11,5 @@ Do not collapse these facts. A playable file can exist before projection refresh `agc_list_registered_assets` is the only Direct read path for manifest resource identity. Its relative path and stable identifiers are evidence; omitted prompt, model, provider route, signed URL, host path, and credentials are intentionally not available to Codex. `agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity. + +`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index d04fdb6e3..7c9eb6730 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-24.1", + "version": "2026-08-24.2", "skills": [ { "name": "agc-project-structure", @@ -79,20 +79,22 @@ "triggers": [ "新增或替换游戏文件", "查询、创建或派生已登记媒体资源", + "去除已登记图片背景", "素材或版本未显示", "推理 revision 与版本关系" ], "requiredTools": [ "agc_tools.agc_read_skill_resource", "agc_tools.agc_list_registered_assets", - "agc_tools.agc_create_or_derive_resource" + "agc_tools.agc_create_or_derive_resource", + "agc_tools.agc_remove_background" ], "files": [ "SKILL.md", "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "4b49d54f430028839362a0accf1a5c83878fb1e4eb9b929e0c4912c17470f112" + "sha256": "07d64f118dc1ecb36c37b92f272cf237649aa7f53fb939197e705d6afa2de315" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index e1d4f12f7..fb1a193bf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1299,6 +1299,97 @@ async fn bridge_create_or_derive_resource( } } +async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value { + let result = async { + bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?; + enforce_project_permission_policy(&state.root, "canvas.asset_generate")?; + enforce_project_permission_policy(&state.root, "asset.register")?; + let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?; + let asset_name = bridge_bounded_string( + arguments, + "assetName", + DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS, + )?; + let manifest = read_existing_manifest_for_project(&state.root)?; + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == source_asset_id) + .ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string())?; + if !source_asset.media_type.starts_with("image/") { + return Err("抠图工具只接受当前项目已登记的图片资源".to_string()); + } + let source_resource_id = source_asset + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty() && !value.starts_with("local-asset:")) + .ok_or_else(|| "图片资源缺少可供抠图服务使用的正式 resourceId".to_string())? + .to_string(); + let (api_base_url, api_key, session) = resolve_canvas_sync_api_credentials(None, None)?; + let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, session.as_ref())?; + let client = reqwest::Client::new(); + let context = + prepare_external_canvas_generation_context(&state.root, &client, &access).await?; + let fingerprint = format!("{}\0{}", source_asset_id, asset_name); + let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; + let route = "/api/external/v1/editor/images/background-removals"; + let response = client + .post(format!("{}{}", api_base_url, route)) + .bearer_auth(api_key) + .header("Idempotency-Key", idempotency_key) + .json(&json!({ + "sourceImageSrc": source_resource_id, + "projectId": manifest.project_id, + "assetKind": source_asset.kind, + "assetFolderId": context.asset_folder_id, + "assetLabel": asset_name, + "sourceResourceId": source_resource_id, + })) + .send() + .await + .map_err(|error| format!("抠图服务提交失败:{error}"))?; + let status = response.status(); + let payload = response + .json::() + .await + .map_err(|error| format!("抠图服务响应无法解析:{error}"))?; + if !status.is_success() { + return Err(format!("抠图服务提交失败:HTTP {}", status.as_u16())); + } + let queue_state = external_editor_response_data(&payload).clone(); + Ok::<_, String>(json!({ + "status": "queued", + "sourceLocalAssetId": source_asset_id, + "assetName": asset_name, + "projectId": manifest.project_id, + "assetFolderId": context.asset_folder_id, + "queueState": bridge_safe_queue_state(queue_state), + })) + } + .await; + match result { + Ok(value) => bridge_tool_result(value.to_string(), Vec::new(), false), + Err(error) => bridge_tool_result( + redact_agent_runtime_error(&state.root, &error, 480), + Vec::new(), + true, + ), + } +} + +fn bridge_safe_queue_state(value: Value) -> Value { + let object = value.as_object(); + json!({ + "operationId": object.and_then(|value| value.get("operationId")).and_then(Value::as_str), + "status": object.and_then(|value| value.get("status")).and_then(Value::as_str), + "phaseLabel": object.and_then(|value| value.get("phaseLabel")).and_then(Value::as_str), + "progress": object.and_then(|value| value.get("progress")).and_then(Value::as_u64), + "updatedAtMicros": object.and_then(|value| value.get("updatedAtMicros")).and_then(Value::as_u64), + }) +} + fn bridge_art_resources( root: &Path, asset_paths: &[String], @@ -1548,6 +1639,7 @@ async fn handle_direct_tool_bridge( "agc_create_or_derive_resource" => { bridge_create_or_derive_resource(&state, &request.arguments).await } + "agc_remove_background" => bridge_remove_background(&state, &request.arguments).await, "agc_browser_playtest" => bridge_browser_playtest(&state.root, &request.arguments).await, "agc_web_search" => bridge_web_search(&state.root, &request.arguments).await, _ => bridge_tool_result("未知或未审核的客户端工具".to_string(), Vec::new(), true), @@ -1992,4 +2084,20 @@ mod tests { ); } } + + #[test] + fn bridge_background_removal_queue_projection_is_bounded() { + let projection = bridge_safe_queue_state(json!({ + "operationId": "background-removal-1", + "status": "queued", + "phaseLabel": "排队中", + "progress": 0, + "updatedAtMicros": 1, + "error": "private provider detail", + "signedUrl": "https://private.invalid/result" + })); + assert_eq!(projection["operationId"], "background-removal-1"); + assert!(projection.get("error").is_none()); + assert!(projection.get("signedUrl").is_none()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index c825cc377..605dad098 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -158,6 +158,28 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { "additionalProperties": false } }), + json!({ + "name": "agc_remove_background", + "description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。", + "inputSchema": { + "type": "object", + "properties": { + "sourceLocalAssetId": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "description": "必须来自 agc_list_registered_assets 返回的当前项目图片资源 localAssetId" + }, + "assetName": { + "type": "string", + "minLength": 1, + "maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS + } + }, + "required": ["sourceLocalAssetId", "assetName"], + "additionalProperties": false + } + }), json!({ "name": "agc_browser_playtest", "description": "使用当前客户端的受限 Chromium 对当前游戏执行真实 desktop/mobile 双视口运行、截图、控制台、网络、Canvas/WebGL 和有限交互探针。", @@ -380,6 +402,17 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin } } +fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> { + validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?; + bounded_tool_string(arguments, "sourceLocalAssetId", 80)?; + bounded_tool_string( + arguments, + "assetName", + DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS, + )?; + Ok(()) +} + fn tool_attempt(arguments: &Value) -> Result { let attempt = arguments .get("attempt") @@ -518,6 +551,13 @@ async fn call_agc_create_or_derive_resource(arguments: &Value) -> Value { call_client_tool_bridge("agc_create_or_derive_resource", arguments).await } +async fn call_agc_remove_background(arguments: &Value) -> Value { + if let Err(error) = validate_remove_background_arguments(arguments) { + return mcp_tool_result(error, Vec::new(), true); + } + call_client_tool_bridge("agc_remove_background", arguments).await +} + async fn call_agc_browser_playtest(arguments: &Value) -> Value { if let Err(error) = tool_attempt(arguments) { return mcp_tool_result(error, Vec::new(), true); @@ -589,6 +629,7 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option "agc_create_or_derive_resource" => { call_agc_create_or_derive_resource(&arguments).await } + "agc_remove_background" => call_agc_remove_background(&arguments).await, "agc_browser_playtest" => call_agc_browser_playtest(&arguments).await, "agc_web_search" => call_agc_web_search(&arguments).await, _ => mcp_tool_result("未知或未审核的 AGC 工具".to_string(), Vec::new(), true), @@ -713,6 +754,7 @@ mod tests { "taonier_prepare_game_art", "agc_list_registered_assets", "agc_create_or_derive_resource", + "agc_remove_background", "agc_browser_playtest" ] ); @@ -767,6 +809,7 @@ mod tests { "taonier_prepare_game_art", "agc_list_registered_assets", "agc_create_or_derive_resource", + "agc_remove_background", "agc_browser_playtest", "agc_web_search" ] @@ -795,6 +838,17 @@ mod tests { "assetName": "森林过场" })) .is_ok()); + assert!(validate_remove_background_arguments(&json!({ + "sourceLocalAssetId": "asset-image-1", + "assetName": "角色透明图" + })) + .is_ok()); + assert!(validate_remove_background_arguments(&json!({ + "sourceLocalAssetId": "asset-image-1", + "assetName": "角色透明图", + "projectPath": "/private" + })) + .is_err()); assert!(validate_resource_generation_arguments(&json!({ "kind": "character-animation", "mode": "derive", diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index 864b1d14d..bef133928 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Genarrative AI Game Creator", - "version": "0.1.1", + "version": "0.1.2", "identifier": "world.genarrative.ai-game-creator", "build": { "beforeDevCommand": "npm --prefix ../.. run agc:serve", diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 6a9322506..290a06cc1 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -8,6 +8,7 @@ import { } from 'react'; import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; +import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; import { clearStoredAuthAccessToken, getClientAuthErrorMessage, @@ -72,9 +73,12 @@ export class ClientRuntimeErrorBoundary extends Component< return this.props.children; } return ( -
+
- tn + 陶泥儿

客户端页面加载失败

{this.state.errorMessage}

@@ -389,9 +393,12 @@ export function AuthenticatedClient({ if (authStatus === 'checking') { return ( -
+
- tn + 陶泥儿

正在检查登录状态

@@ -400,9 +407,12 @@ export function AuthenticatedClient({ if (!authUser) { return ( -
+
- tn + 陶泥儿

登录陶泥儿 GameAgent

登录后进入首页和本地项目工作区

diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index d16e84a7d..590ece6b9 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -123,50 +123,73 @@ textarea { display: grid; min-height: 100vh; padding: 24px; - background: #f8fafc; - color: #111827; + overflow-x: hidden; + overflow-y: auto; + background: var(--platform-body-fill); + color: var(--platform-text-strong); place-items: center; } .client-auth-panel { + position: relative; display: grid; width: min(360px, 100%); gap: 16px; padding: 28px; - border: 1px solid #e5e7eb; - border-radius: 8px; - background: #fff; - box-shadow: 0 18px 52px rgb(15 23 42 / 12%); + overflow: hidden; + border: 1px solid var(--platform-surface-border); + border-radius: 24px; + background: var(--platform-panel-fill); + box-shadow: var(--platform-panel-shadow); +} + +.client-auth-panel::before { + position: absolute; + top: -94px; + right: -76px; + width: 204px; + height: 204px; + border-radius: 50%; + background: radial-gradient( + circle, + var(--platform-hero-glow-b), + transparent 68% + ); + content: ''; + pointer-events: none; } .client-auth-logo { - display: grid; - width: 34px; - height: 34px; - border-radius: 50%; - background: #101010; - color: #fff; - font-size: 12px; - font-weight: 800; - place-items: center; + position: relative; + z-index: 1; + width: 56px; + height: 56px; + object-fit: contain; + filter: drop-shadow(0 9px 15px rgb(182 98 63 / 18%)); } .client-auth-panel h1 { + position: relative; + z-index: 1; margin: 0; font-size: 24px; letter-spacing: 0; } .client-auth-panel p { + position: relative; + z-index: 1; margin: 6px 0 0; - color: #6b7280; + color: var(--platform-text-soft); font-size: 13px; } .client-auth-panel label { + position: relative; + z-index: 1; display: grid; gap: 7px; - color: #374151; + color: var(--platform-text-base); font-size: 13px; font-weight: 700; } @@ -175,42 +198,68 @@ textarea { height: 38px; min-width: 0; padding: 0 11px; - border: 1px solid #d1d5db; - border-radius: 8px; - background: #fff; - color: #111827; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; + background: var(--platform-input-fill); + color: var(--platform-text-strong); font: inherit; + transition: + border-color 160ms ease, + box-shadow 160ms ease, + background 160ms ease; } .client-auth-panel select { height: 38px; min-width: 0; padding: 0 11px; - border: 1px solid #d1d5db; - border-radius: 8px; - background: #fff; - color: #111827; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; + background: var(--platform-input-fill); + color: var(--platform-text-strong); font: inherit; + transition: + border-color 160ms ease, + box-shadow 160ms ease, + background 160ms ease; } .client-auth-panel select:focus { - border-color: #111827; - outline: 2px solid rgb(17 24 39 / 10%); + border-color: var(--platform-surface-hover-border); + outline: 2px solid var(--platform-input-focus-ring); + background: var(--platform-input-fill-focus); } .client-auth-panel input:focus { - border-color: #111827; - outline: 2px solid rgb(17 24 39 / 10%); + border-color: var(--platform-surface-hover-border); + outline: 2px solid var(--platform-input-focus-ring); + background: var(--platform-input-fill-focus); } .client-auth-panel button { + position: relative; + z-index: 1; height: 38px; - border: 0; - border-radius: 8px; - background: #111827; - color: #fff; + border: 1px solid var(--platform-button-primary-border); + border-radius: 12px; + background: var(--platform-button-primary-fill); + box-shadow: var(--platform-profile-action-shadow); + color: var(--platform-button-primary-text); font-weight: 700; cursor: pointer; + transition: + transform 160ms ease, + box-shadow 160ms ease, + filter 160ms ease; +} + +.client-auth-panel button:not(:disabled):hover { + filter: brightness(1.04); + transform: translateY(-1px); +} + +.client-auth-panel button:not(:disabled):active { + transform: translateY(0); } .client-auth-panel button:disabled { @@ -219,23 +268,29 @@ textarea { } .client-auth-tabs { + position: relative; + z-index: 1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 4px; - border: 1px solid #e5e7eb; - border-radius: 8px; - background: #f8fafc; + border: 1px solid var(--platform-subpanel-border); + border-radius: 14px; + background: var(--platform-subpanel-fill); } .client-auth-tabs button { + border-color: transparent; background: transparent; - color: #4b5563; + box-shadow: none; + color: var(--platform-text-soft); } .client-auth-tabs button.is-active { - background: #111827; - color: #fff; + border-color: var(--platform-button-primary-border); + background: var(--platform-button-primary-fill); + box-shadow: 0 8px 18px rgb(182 98 63 / 18%); + color: var(--platform-button-primary-text); } .client-auth-code-row { @@ -255,6 +310,17 @@ textarea { overflow-wrap: anywhere; } +@media (max-width: 480px) { + .client-auth-shell { + padding: 16px; + } + + .client-auth-panel { + padding: 24px; + border-radius: 20px; + } +} + .launcher-shell { --launcher-sidebar-width: 64px; display: grid; @@ -1604,8 +1670,7 @@ textarea { .launcher-development-canvas { display: grid; min-height: 300px; - background: - linear-gradient(#f3f4f6 1px, transparent 1px), + background: linear-gradient(#f3f4f6 1px, transparent 1px), linear-gradient(90deg, #f3f4f6 1px, transparent 1px), #fff; background-size: 24px 24px; place-items: center; @@ -2456,8 +2521,7 @@ textarea { min-width: 0; min-height: 0; overflow: hidden; - background: - linear-gradient(45deg, #101827 25%, transparent 25%), + background: linear-gradient(45deg, #101827 25%, transparent 25%), linear-gradient(-45deg, #101827 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #101827 75%), linear-gradient(-45deg, transparent 75%, #101827 75%), #0b1220; @@ -3032,28 +3096,24 @@ textarea { } .launcher-card-collage span:nth-child(1) { - background: - radial-gradient(circle at 45% 38%, #6b5c43 0 18%, transparent 19%), + background: radial-gradient(circle at 45% 38%, #6b5c43 0 18%, transparent 19%), radial-gradient(circle at 62% 54%, #2f2a23 0 25%, transparent 26%), linear-gradient(135deg, #91a17e, #2c3327); } .launcher-card-collage span:nth-child(2) { - background: - radial-gradient(circle at 58% 45%, #b28b5a 0 16%, transparent 17%), + background: radial-gradient(circle at 58% 45%, #b28b5a 0 16%, transparent 17%), radial-gradient(circle at 48% 55%, #302114 0 24%, transparent 25%), linear-gradient(135deg, #d9e8ef, #9fb0a0); } .launcher-card-collage span:nth-child(3) { - background: - radial-gradient(circle at 50% 54%, #eac9c0 0 22%, transparent 23%), + background: radial-gradient(circle at 50% 54%, #eac9c0 0 22%, transparent 23%), linear-gradient(135deg, #f7d9d5, #dab6aa); } .launcher-card-collage span:nth-child(4) { - background: - radial-gradient(circle at 56% 50%, #32271b 0 22%, transparent 23%), + background: radial-gradient(circle at 56% 50%, #32271b 0 22%, transparent 23%), linear-gradient(135deg, #8e6c4e, #2b211b); } @@ -3077,8 +3137,12 @@ textarea { .launcher-card-pattern > div { display: grid; grid-template-columns: repeat(2, 1fr); - background: - linear-gradient(45deg, transparent 46%, #fff 46% 54%, transparent 54%), + background: linear-gradient( + 45deg, + transparent 46%, + #fff 46% 54%, + transparent 54% + ), linear-gradient(135deg, #55405a, #e4b2a7); } @@ -5539,8 +5603,7 @@ iframe.preview-frame { .game-resource-card-visual, .game-resource-card[data-preview-kind='media-image'] .game-resource-card-visual, .game-resource-card[data-preview-kind='video'] .game-resource-card-visual { - background: - linear-gradient(45deg, #f1ebe7 25%, transparent 25%), + background: linear-gradient(45deg, #f1ebe7 25%, transparent 25%), linear-gradient(-45deg, #f1ebe7 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #f1ebe7 75%), linear-gradient(-45deg, transparent 75%, #f1ebe7 75%), #faf7f5; diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index fb2ce862c..496d891d2 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -35,6 +35,31 @@ export function registerAuthTests() { delete window.__TAURI__; }); + it('renders the unauthenticated client with the shared light platform theme and product image', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + if (String(input) === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + throw new Error(`unexpected fetch ${String(input)}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }), + ), + ); + + const login = await screen.findByRole('main', { name: '登录' }); + expect(login.className).toContain('platform-theme'); + expect(login.className).toContain('platform-theme--light'); + expect(screen.getByRole('img', { name: '陶泥儿' })).not.toBeNull(); + expect( + screen.getByRole('heading', { name: '登录陶泥儿 GameAgent' }), + ).not.toBeNull(); + }); + it('keeps the client at login when the native platform session cannot be installed', async () => { window.__TAURI__ = { core: { diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index c345984e3..70e2847f8 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -1170,6 +1170,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": [ @@ -2386,7 +2440,8 @@ "type": [ "string", "null" - ] + ], + "description": "可选的来源项目资源 ID,用于在同一 objectKey 被多个资源以不同权威元数据登记时消歧。sourceImageSrc 已是项目资源 ID 或素材 ID 时按该显式 ID 定点解析;纯 objectKey 无法唯一确定权威来源时必须提供本字段,否则返回 400。原位替换使用纯 objectKey 且省略本字段时,服务端自动绑定 targetLayerId 对应的当前项目资源,并把该绑定写入队列供 Worker 复验。" }, "assetKind": { "type": [ @@ -3597,6 +3652,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": [ diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 36ed05c68..a47d5b0da 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7,6 +7,13 @@ - 输出边界:资源查询和生成结果只投影相对路径、稳定 Canvas/resource/asset/task 身份、序列帧身份、pending 状态及脱敏告警;不返回完整 manifest、prompt、model、provider route、绝对路径、URL、Token、Cookie 或 API Key。角色动画及视频/音频新请求统一携带同名画布与素材目录上下文;已有冻结请求不迁移、不重写。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md`。 +## 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 占用。 @@ -6499,6 +6506,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 采用唯一可见规范化文本与面板级同步提交锁 @@ -13571,6 +13579,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 采用唯一可见规范化文本与面板级同步提交锁 @@ -14444,3 +14453,7 @@ - 严格图集崩溃收口:workflow 在严格图集调用前先持久化 `strictSpritesheetPending` 并冻结底层严格事务覆盖的九项旧合同身份;旧路径可精确冻结为缺失。Provider 完成结果先绑定原 retained stage ledger。恢复在同一项目锁内对账严格事务;只有新九项合同、规范图/背景图替换锚点与 retained spritesheet result 三者一致才补写 `completed`,旧九项合同才允许补偿。旧合同判定、写 `compensating`、恢复两项素材与登记、回读和清锚点必须在同一项目锁内,重启已有 `compensating` 也重新判定;第三种混合、漂移或 foreign result 状态进入 reconciliation。不能在主图集与四切片已整体提交后仍按两文件 rollback 制造混合包;若中断前阶段告警尚未进入 durable completed result,恢复结果追加“原阶段告警无法完整重放”的明确 warning,不静默清空。 - Direct 对话恢复从新到旧扫描全部合法 User 回合,遇到较新已回答回合继续向前,不得丢失更早未回答回合。成功返回时 Rust 已先持久化 assistant,前端冗余 append 失败也不得重跑 Provider;普通错误终态的显式 append 失败后,恢复 claim 必须保持到 React fallback writer 对同一稳定 assistant messageId 的写入明确成功或失败,不能在 writer 尚在途时按旧 `/history` 快照重跑。fallback 成功后释放 claim;fallback 失败时跳过该 writer 的无界迟到重试并释放 claim,后续显式 `/history` 才可复用原稳定 `clientTurnId`。终态收敛后删除 claim,避免长会话无界增长。 - 正式资源提交结算遵守同一顺序:阶段三 commit 成功后先持久化 `asset-commit-settlement-pending`,恢复器幂等补齐 `asset-durable-committed` 公开投影与 staging revision,再发布私有终态;公开投影已经存在时不得重复增加草稿 revision。恢复必须把私有回执与阶段三 commit ledger、transaction journal、manifest 资产和事件 payload 的完整身份绑定,任一错配都保留 pending 并失败关闭。回归同时覆盖三个 durable write cut,以及私有回执、commit ledger、journal 错配。 +## 2026-08-24 AGC Direct 抠图语义工具 + +- 决策:将 External v1 `/api/external/v1/editor/images/background-removals` 通过 `agc_remove_background` 加入受控 `agc_tools`。工具只接受当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;客户端负责正式 resourceId、画布/素材目录、稳定 operation/idempotency 身份、权限和错误脱敏,不向 Codex 暴露内部 BgFilter worker、凭据或任意 API。 +- 约束:异步结果只投影有界队列状态,不允许模型自行构造源 URL 或在不确定提交后更换请求身份;External v1 负责 API Key、幂等接收与统一 operation 查询,客户端不得绕过该契约。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 2dffcf68f..8865fce32 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -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 或媒体结果。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 09f5fc15a..560ae2b62 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -6,6 +6,7 @@ - 项目路径、projectId、当前 revision、源文件路径与媒体类型、operationId、Idempotency-Key、登录态、项目锁、付费提交、轮询恢复、下载校验与 manifest 事务全部由客户端持有。模型不能提交或覆盖这些字段。同一 Direct `clientTurnId + 规范语义参数` 生成稳定 UUID v4 身份;单回合同参重试复用原 operation,不同请求串行且最多四项。跨回合存在完全匹配的 pending 账本时优先恢复原 operation,不能换键重发。 - 资源查询同时投影未完成 operation 的安全状态。媒体工具成功只返回 operation、本地相对路径、资源类型、Canvas/resource/asset/task 身份、正式序列帧以及脱敏后的 `warnings / sliceWarnings`;错误继续使用统一脱敏边界。客户端资源账本持久化 completed 结果的两类告警,committed replay 不能把历史告警伪装成空集合。 - 角色动画、视频、音效和背景音乐在构造新的远端请求前统一准备当前项目同名画布与素材目录上下文,并在端点支持时携带 `projectId / assetFolderId / canvasCompletion`。角色动画 placeholder 使用源图片真实宽高,避免非方形角色进入画布时失真;正式 resource/asset 与序列帧继续直接复用 External 返回身份,不从首帧伪造重复资源。已有冻结 request body 或已受理 operation 保持不变,不因本次升级重建请求或重复扣费。 +- 抠图通过新增 `agc_remove_background` 语义工具开放:模型只提交当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;客户端解析稳定 `resourceId`,准备同名画布/素材目录并生成稳定 operation/idempotency 身份,调用 External v1 `/api/external/v1/editor/images/background-removals` 后只返回有界队列状态。抠图服务仍由客户端和服务端负责源校验、BgFilter、素材登记与画布事务,Codex 不获得内部 worker、凭据或任意 API 调用权。 ## 2026-08-23 AGC 资源生成补齐(视频 / 动画 / 音效 / 背景音乐) @@ -16,7 +17,7 @@ - 新建视频 / 音效 / 背景音乐时,先复用 `prepare_external_canvas_generation_context` 创建或匹配同名画板项目与素材库目录,并在生成请求中携带 `projectId / assetFolderId / canvasCompletion`,结果同时进入平台画布 / 素材库与本地 manifest。 - durable 账本新增 `remoteLegacyPublicPath` 字段(旧账本默认缺省),用于角色动画预览视频的换签下载与恢复;资源提交、轮询、下载、manifest 提交和恢复队列继续复用既有资源编辑事务链。 - 资源编辑请求指纹纳入 `generationMode`,同一 `operationId` 换生成模式重试时不再按旧账本模式恢复;旧账本继续通过 legacy 指纹兼容。 -- 去背景(`/api/editor/images/background-removals`)仍缺 External v1 契约,不在本次补齐范围;本次只补视频 / 角色动画 / 音效 / 背景音乐。 +- 去背景通过 External v1 语义工具开放;原始服务端 worker 和账号态内部路由仍不直接暴露给 Codex。 ## 2026-08-19 UI Editor 节点右键菜单 @@ -780,6 +781,7 @@ game-project/ - `apps/ai-game-creator-shell` 是独立 Tauri App,不复用 `apps/desktop-shell`。 - 独立客户端启动时先进入平台登录检查;未登录页默认展示手机号验证码登录,并保留密码登录切换。验证码登录调用平台后端 `/api/auth/phone/send-code` 与 `/api/auth/phone/login`,密码登录继续调用 `/api/auth/entry`;Tauri dev 下 `/api` 走本轮动态 AGC Vite 代理,普通发布版静态窗口下通过 Tauri 原生 HTTP transport 固定访问开发服务器 `https://dev.genarrative.world/api/*`,避开 WebView CORS 预检并持久化刷新 Cookie。原生 HTTP capability 只允许该 HTTPS origin 的 `/api/*`,客户端解析层也拒绝其它 release origin;game-chat release 继续不依赖平台登录和 `api-server`。网络层失败时展示登录服务不可达提示,不裸露 WebView 的 `Load failed`。 +- 登录检查、未登录和客户端页面加载失败三种认证外壳统一使用共享陶泥儿产品形象与平台浅色主题 token;登录卡片、输入框、方式切换和主按钮沿用平台暖色视觉,并在不超过 `480px` 的窄视口收紧留白。视觉优化不改变服务器选择、验证码、密码、登录态安装或错误恢复语义。 - `npm run agc` 的本地 SpacetimeDB owner identity 以独立 `spacetimeDataDir` 为作用域,不绑定可能漂移的监听端口;旧端口作用域记录仅在同一 data dir 下身份唯一时自动迁移,出现多个不同旧身份时失败关闭。`.app/dev-stack.json` 必须记录规范化 `spacetimeDataDir`,独立壳只复用数据库名和该目录同时匹配且健康的后端,旧 schema 状态或共享目录状态缺少此字段时不得复用。POSIX 子进程在 `spawn` 返回时立即登记 `error / exit` 生命周期、保存 detached leader 的 PGID 并把句柄交给外层;即使 direct leader 已先退出,也必须继续向负 PGID 发信号清理同组后代。后端 ready 前的 SIGINT、SIGTERM、超时或 ENOENT 都必须走同一进程组清理链路,不能遗留 npm、Cargo 或 SpacetimeDB。非 Linux Runtime 执行 `project.verify` 时,`npm run` 参数校验必须允许受控的 `--silent`、`--ignore-scripts` 位于脚本名前,并继续拒绝缺少真实脚本名的调用。 - Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`,Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`,Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,旧窗口兼容命令放在 `windows.rs`,Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。 - 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 86c10467b..65df438a8 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -245,7 +245,7 @@ npm run check:server-rs-ddd 6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。进入预扣或外部生成队列前,还必须调用只读 `preflight_editor_generation_target_and_return`,按认证 owner 校验可选 `projectId`,并校验归一化后的可选 `assetFolderId`。请求 JSON、纯本地格式、引用数量及 `data:` / `blob:` 稳定媒体门禁必须先于该数据库预检返回 4xx;目标预检随后执行,并且仍必须早于定价读取、引用 owner 解析、generation input rebuild、入队、扣费、provider 请求或 OSS 写入,不得为调整错误优先级把任何远端读取或副作用搬到预检前。`project`、任意旧 `folder-*` 与 owner 默认目录 ID 都统一指向当前 owner 的默认素材目录;角色图片、角色动作、图标 spritesheet 与 UI 提取在请求省略目录时也必须按最终真实写入的默认目录预检。尚未创建的默认目录允许通过,自定义目录必须已存在且归属当前 owner。预检 helper 必须返回同一份 canonical `projectId + assetFolderId`,调用方在入队、worker/provider 执行和原子结果准备中都复用这份值;禁止校验 trim/默认映射后的值却继续序列化或持久化原始请求。worker / inline 执行在首个 provider 或 OSS 写副作用前再次执行同一预检,不能只信任入队时结果;任一读取不可达、超时、项目或目录不匹配都失败关闭。该预检不创建锁或 reservation,最终资源 / 素材 procedure 仍必须重新校验归属,以处理预检后并发删除或转移。 7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。 8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。 -9. 编辑器进入外部生成持久队列的图片生成、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选稳定引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。图片修改主来源是明确例外:站内 `/api/editor/images/edits` 与 External v1 API 调用方只提交必填 `sourceReferenceId`,且只接受当前账号已登记的 `editor_project_resource.resource_id` 或 `editor_asset.asset_id`;objectKey、URL、Data URL、Blob URL、未登记 ID 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段必须在入队前返回 `400`。api-server 必须通过共享 SpacetimeDB 窄查询分别按资源 ID、素材 ID 主键定点解析,双表同时命中、未命中、跨 owner、缺失或越权 asset object、禁止或未知类型均失败关闭;objectKey 与素材类型只能来自服务端解析结果。提供 `targetLayerId` 时必须同步读取指定 `projectId`,校验目标图层关联资源、双方权威对象和默认类型;双方都有 `assetObjectId` 时必须比较 ID,任一方缺失时才回退 canonical `(bucket, objectKey)`。来源默认类型只参与同一权威对象的绑定一致性校验;快速编辑准入必须在解析目标图层后按 `assetKindOverride ?? resource.assetKind` 的有效类型判断,不能先按来源默认类型拒绝,因此默认 `icon` 资源覆盖为 `scene/spec` 时允许,未覆盖时仍拒绝。图片修改入队载荷保存版本化权威来源快照,worker 调用 provider 前必须按同一 `sourceReferenceId` 再次定点解析并拒绝身份或类型漂移;历史载荷只把非空旧资源 ID 或旧来源字符串本身作为业务 ID 尝试迁移,禁止按 objectKey 反查或信任旧 `assetKind`。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。其它候选引用的 objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再作为图片修改的辅助 `referenceImageSrcs` 入队,主来源仍使用原图业务 ID;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 +9. 编辑器进入外部生成持久队列的图片生成、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选稳定引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。图片修改主来源是明确例外:站内 `/api/editor/images/edits` 与 External v1 API 调用方只提交必填 `sourceReferenceId`,且只接受当前账号已登记的 `editor_project_resource.resource_id` 或 `editor_asset.asset_id`;objectKey、URL、Data URL、Blob URL、未登记 ID 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段必须在入队前返回 `400`。api-server 必须通过共享 SpacetimeDB 窄查询分别按资源 ID、素材 ID 主键定点解析,双表同时命中、未命中、跨 owner、缺失或越权 asset object、禁止或未知类型均失败关闭;objectKey 与素材类型只能来自服务端解析结果。提供 `targetLayerId` 时必须同步读取指定 `projectId`,校验目标图层关联资源、双方权威对象和默认类型;双方都有 `assetObjectId` 时必须比较 ID,任一方缺失时才回退 canonical `(bucket, objectKey)`。来源默认类型只参与同一权威对象的绑定一致性校验;快速编辑准入必须在解析目标图层后按 `assetKindOverride ?? resource.assetKind` 的有效类型判断,不能先按来源默认类型拒绝,因此默认 `icon` 资源覆盖为 `scene/spec` 时允许,未覆盖时仍拒绝。图片修改入队载荷保存版本化权威来源快照,worker 调用 provider 前必须按同一 `sourceReferenceId` 再次定点解析并拒绝身份或类型漂移;历史载荷只把非空旧资源 ID 或旧来源字符串本身作为业务 ID 尝试迁移,禁止按 objectKey 反查或信任旧 `assetKind`。去背景入口必须在入队前把 `assetKind` 收口为来源项目资源或素材库解析出的静态图片语义类型,资产对象存储类型只参与非静态媒体门禁:显式项目资源 ID / 素材 ID 必须优先于 objectKey 回退;纯 objectKey 对应多条且权威元数据不一致时必须返回 `400` 并要求 `sourceResourceId` 或业务 ID 消歧,不能按列表首条选择。请求值与权威类型冲突,或任一记录表示视频、音频、动画、图片序列时失败关闭;无 `canvasCompletion` 的 `targetLayerId` 还必须与来源优先按 `assetObjectId`、缺失时按 canonical `(bucket, objectKey)` 证明为同一对象,且默认类型一致,禁止用来源 A 覆盖目标 B。原位替换使用纯 objectKey 且省略 `sourceResourceId` 时,入队与 Worker 都以目标图层当前资源作为显式来源绑定并持续复验。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。其它候选引用的 objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再作为图片修改的辅助 `referenceImageSrcs` 入队,主来源仍使用原图业务 ID;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 - 图标规范结构化分析里位于 `` / `` XML 元素内的数据必须转义 `& < > " '`;玩法润色、美术风格润色、规范图生图和图标 spritesheet 等自然语言 prompt 必须保留已经过边界校验的原文。图标 spritesheet 的 `iconDescriptions` 在请求边界执行独立合同:原始数组满足 OpenAPI `1..100`,去空后至少保留 1 条;单条最多 `200` 个 Unicode 字符、拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节;只有 `ValidatedEditorIconSpritesheetPrompt` 能进入 prompt builder,External v1 超限同步返回 `400`。 10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。 11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。 @@ -620,7 +620,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ExternalApiKey` - 源码:`server-rs/crates/spacetime-module/src/external_api_key_storage.rs` -- 说明:外部 OpenAPI 调用使用的账号级 API Key 凭据表,只保存 key prefix、SHA-256 hash、作用域、撤销状态和使用时间;明文 Key 只在 `/api/profile/api-keys` 创建接口返回一次,不进入 SpacetimeDB,且 API Key 管理接口不写入外部 OpenAPI JSON。v1 默认作用域为 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。 +- 说明:外部 OpenAPI 调用使用的账号级 API Key 凭据表,只保存 key prefix、SHA-256 hash、作用域、撤销状态和使用时间;明文 Key 只在 `/api/profile/api-keys` 创建接口返回一次,不进入 SpacetimeDB,且 API Key 管理接口不写入外部 OpenAPI JSON。v1 默认作用域为 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、去背景、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。 - 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。 ### `admin_account` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index eaa416f16..41db70f4a 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -34,6 +34,7 @@ v1 只开放以下能力: - `DELETE /api/external/v1/editor/assets/{assetId}`:删除素材记录。 - `POST /api/external/v1/editor/images/generations`:异步提交编辑器图片素材生成;通过 `kind` 支持普通图、规范图 `spec`、角色图 `character`、快速编辑参考图 `quick-edit`、UI 设计图 `ui-design` 和宣发素材 `publication-material`。External v1 当前不开放结构化游戏场景生成,`kind = scene` 与 `assetKind = scene` 均在入队前返回 `400`。 - `POST /api/external/v1/editor/images/edits`:异步提交已有图片重绘 / 调整。 +- `POST /api/external/v1/editor/images/background-removals`:异步提交已有静态图片去背景;只接受当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID。可选 `assetKind` 必须与权威来源类型一致,视频、音频、动画和图片序列类型在入队前返回 `400`;拒绝 `taskId` 与其它未声明字段。 - `POST /api/external/v1/editor/icon-spritesheets/generations`:异步提交规范图驱动的图标 spritesheet 生成和拆分。 - `POST /api/external/v1/editor/ui-designs/assets/extractions`:异步提交 UI 设计图素材提取 / 拆分。 - `POST /api/external/v1/editor/character-animations/generations`:异步提交角色动画预览和帧序列生成。 @@ -47,7 +48,7 @@ v1 只开放以下能力: - `GET /api/external/v1/skill.zip`:公开下载完整 Skill 包。 - `POST /api/external/v1/mcp`:使用相同 Bearer API Key 的托管式 Streamable HTTP MCP;对外暴露本节 OpenAPI operation tools 以及使用说明、OpenAPI、Skill 入口 `SKILL.md` 和逐个 Skill reference 文档,不开放内部 SpacetimeDB MCP。 -八类生成 POST 全部要求 `Idempotency-Key`,成功只返回 HTTP `202 Accepted`、`operationId`、`kind`、`status`、`statusUrl`、`pollAfterMs` 和 `updatedAtMicros`。调用方不得把 `202` 当作媒体生成完成,也不得在网络结果不确定时换一个幂等键重新提交。 +九类生成 POST 全部要求 `Idempotency-Key`,成功只返回 HTTP `202 Accepted`、`operationId`、`kind`、`status`、`statusUrl`、`pollAfterMs` 和 `updatedAtMicros`。调用方不得把 `202` 当作媒体生成完成,也不得在网络结果不确定时换一个幂等键重新提交。 图片生成、图标 spritesheet 和 UI 素材提取的 completed compact `result` 可携带可选结构化 `warning { code, reason }`;任务查询顶层 `warning` 是可直接展示的有界摘要。外部 OpenAPI 当前公开四个稳定 `code`: @@ -81,7 +82,7 @@ provider 原图已保存但透明背景处理最终失败时,worker 保留原 MCP transport 的 DNS rebinding 防护必须同时允许正式入口 `www.genarrative.world` / `genarrative.world`、开发入口 `dev.genarrative.world` 和本机开发入口;对应 HTTPS Origin 也必须与公开环境同步登记。新增公开环境域名时,必须在发布前使用该域名的真实 `Host` 和 `Origin` 执行 `initialize` 回归,不能只用 `localhost` 单测证明端点可用。 -MCP tools 从同一份 OpenAPI operation 自动形成 snake_case 名称,并在进程内复用 External REST router,因此鉴权、scope、owner、入参、幂等、计费和结果查询契约只有一份。生成 tools 把 `idempotencyKey` 显式放进参数,因为 MCP transport 的 Authorization 头不能代替逐次业务幂等键。工具结果使用 `structuredContent`;业务失败使用 `isError=true` 的结构化安全错误,协议不可路由时才返回 JSON-RPC error。 +MCP tools 从同一份 OpenAPI operation 自动形成 snake_case 名称,并在进程内复用 External REST router,因此鉴权、scope、owner、入参、幂等、计费和结果查询契约只有一份。MCP bridge 从 operation 或 path 的 required `Idempotency-Key` header 参数自动推导 `idempotencyKey` 工具参数和转发头,不维护独立的生成 operation 白名单;因此新增异步生成 operation 时,OpenAPI 契约本身就是 MCP 幂等注册来源。MCP transport 的 Authorization 头不能代替逐次业务幂等键。工具结果使用 `structuredContent`;业务失败使用 `isError=true` 的结构化安全错误,协议不可路由时才返回 JSON-RPC error。 `list_editor_projects` 是项目选择工具,服务端固定以 `view=summary` 调用项目列表,不允许因 OpenAPI 的 REST 默认值退回完整视图。摘要逐项目只返回 `projectId`、`title`、`updatedAt` 和可空 `cover`,不携带 `canvas`、`viewport`、`layers`、`resources` 或图片正文;选定目标后再用 `get_editor_project` 读取完整权威状态。`cover` 只包含最新项目封面快照的 `resourceId`、稳定 `objectKey`、尺寸与 `updatedAt`,没有封面时为 `null`。需要展示封面时,以 `objectKey` 调用 `/api/external/v1/assets/read-url` 获取短期签名 URL;列表不得内嵌 Data URL、图片二进制或临时签名 URL,也不得把签名 URL 当作持久引用。 @@ -202,7 +203,7 @@ external_api_key - `name`:用户可识别名称。 - `key_prefix`:前缀片段,用于列表展示和排障。 - `key_hash`:完整 Key 的 SHA-256 十六进制摘要,唯一。 -- `scopes_json`:作用域 JSON,v1 固定包含 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`。其中 `editor:image-generate` 覆盖图片生成、重绘、规范图、宣发图、图标拆分、UI 素材拆分、角色动画、视频、音效和音乐生成。 +- `scopes_json`:作用域 JSON,v1 固定包含 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`。其中 `editor:image-generate` 覆盖图片生成、重绘、去背景、规范图、宣发图、图标拆分、UI 素材拆分、角色动画、视频、音效和音乐生成。 - `created_at` / `last_used_at` / `revoked_at` / `updated_at`。 SpacetimeDB procedure: @@ -216,7 +217,7 @@ SpacetimeDB procedure: 外部生成接口复用站内编辑器已有 DTO、入队器和 worker executor,不维护第二套生成语义: -- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations` 与 `/api/editor/images/edits` 的校验、模型归一、计费和持久化规则,但 External handler 固定只入队。External v1 图片修改必须提交当前账号已登记的项目资源 ID 或素材 ID 作为 `sourceReferenceId`;上传对象必须先登记为项目资源或素材。objectKey、URL、Data URL、Blob URL 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段均返回 `400`。服务端分别按资源 ID 与素材 ID 主键窄查,双表冲突、未命中、越权或对象记录无效均失败关闭,快速编辑的完整有效类型白名单为 `null / spec / character / icon-spritesheet / icon-spec / publication-material / ui-design / scene`,OpenAPI 的 `x-genarrative-allowed-effective-asset-kinds` 必须与后端白名单精确一致。请求带 `targetLayerId` 时必须同时带 `projectId`;目标图层必须关联有效项目资源,来源与目标优先比较 `assetObjectId`,缺失时比较 canonical `(bucket, objectKey)`,来源默认类型还必须与目标资源默认类型一致。入队载荷保存版本化权威快照,worker 执行前再次定点解析并拒绝身份或类型漂移;仅以素材 ID 编辑时不伪造项目资源关系。主站和 External 的通用图片入口共用场景专用合同边界校验,禁止用 `kind = scene` 或 `assetKind = scene` 绕过后端场景 Prompt 组装;场景专用 handler 自己构造规范请求,不受该通用入口校验影响。 +- 图片生成 / 重绘 / 去背景 / 规范图 / 宣发图 / UI 设计图复用站内编辑器的校验、模型归一、计费和持久化规则,但 External handler 固定只入队。External v1 图片修改必须提交当前账号已登记的项目资源 ID 或素材 ID 作为 `sourceReferenceId`;上传对象必须先登记为项目资源或素材。objectKey、URL、Data URL、Blob URL 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段均返回 `400`。服务端分别按资源 ID 与素材 ID 主键窄查,双表冲突、未命中、越权或对象记录无效均失败关闭,快速编辑的完整有效类型白名单为 `null / spec / character / icon-spritesheet / icon-spec / publication-material / ui-design / scene`,OpenAPI 的 `x-genarrative-allowed-effective-asset-kinds` 必须与后端白名单精确一致。请求带 `targetLayerId` 时必须同时带 `projectId`;目标图层必须关联有效项目资源,来源与目标优先比较 `assetObjectId`,缺失时比较 canonical `(bucket, objectKey)`,来源默认类型还必须与目标资源默认类型一致。入队载荷保存版本化权威快照,worker 执行前再次定点解析并拒绝身份或类型漂移;仅以素材 ID 编辑时不伪造项目资源关系。主站和 External 的通用图片入口共用场景专用合同边界校验,禁止用 `kind = scene` 或 `assetKind = scene` 绕过后端场景 Prompt 组装;场景专用 handler 自己构造规范请求,不受该通用入口校验影响。External v1 去背景接受稳定 objectKey、项目资源 ID 或素材 ID,入队前按当前 owner 解析并规范化为权威 objectKey,同时从项目资源或素材库读取权威语义类型,并只把资产对象存储类型用于非静态媒体门禁;显式项目资源 ID / 素材 ID 优先于 objectKey 匹配,纯 objectKey 对应多条且权威元数据不一致时返回 `400` 并要求 `sourceResourceId` 或业务 ID 消歧,禁止依赖项目列表顺序。请求 `assetKind` 与权威语义类型冲突,或任一记录表示视频、音频、动画、图片序列时返回 `400`,最终队列载荷只保存服务端解析出的静态语义类型。提供 `targetLayerId` 时始终必须同时提供 `projectId`;存在 `canvasCompletion` 时按生成完成链路写入画布,`targetLayerId` 不参与原位替换;没有 `canvasCompletion` 时,目标图层必须存在并关联当前项目静态图片资源,并与来源优先按 `assetObjectId`、缺失时按 canonical `(bucket, objectKey)` 证明为同一对象,默认权威类型也必须一致,否则在入队前返回 `400`;纯 objectKey 省略 `sourceResourceId` 时自动绑定目标图层资源,并把绑定写入队列供 Worker 再验证。两种画布字段都未提供时只持久化请求指定的项目资源或素材记录,不自动写入画布。URL、未登记或越权引用、无效目标均在入队前失败。外部 DTO 不暴露内部 `taskId`,也不接受 OpenAPI 未声明字段;任务 ID 只能由服务端队列生成。 - 图标 spritesheet 和 UI 设计图素材提取复用站内拆分逻辑,生成图集后按连通域切片,并把图集与切片都按请求写入项目资源和素材库。 - 角色动画、视频、音效和背景音乐复用站内编辑器生成链路;请求携带 `assetFolderId` 时按站内规则写入素材库,音频类外部调用使用 API Key 所属账号作为 asset owner。 - API Key 管理接口仍只属于登录态个人中心,不进入外部 OpenAPI JSON。 @@ -255,10 +256,10 @@ docs/openapi/genarrative-external-v1.openapi.json - API Key 创建只返回一次明文,列表不返回明文。 - 撤销后的 API Key 调用外部接口返回 `401`。 -- 八类外部生成 POST 缺少或携带非法 `Idempotency-Key` 时返回 `400`;同一 owner、请求和 key 重试只得到同一 operation。 +- 九类外部生成 POST 缺少或携带非法 `Idempotency-Key` 时返回 `400`;同一 owner、请求和 key 重试只得到同一 operation。 - External 通用图片生成携带 `kind = scene` 或 `assetKind = scene` 时均返回 `400`,且不得产生入队尝试。 -- 八类外部生成 POST 固定返回 `202`,查询能从 `queued/running` 收敛到 `completed/failed`;调用方超时后使用原 operationId 继续查询。 -- 外部图片生成、重绘、图标拆分、UI 素材拆分、角色动画、视频、音效和音乐 completed 后,生成结果按请求同时出现在画布资源和账号级素材库。 +- 九类外部生成 POST 固定返回 `202`,查询能从 `queued/running` 收敛到 `completed/failed`;调用方超时后使用原 operationId 继续查询。 +- 外部图片生成、重绘、去背景、图标拆分、UI 素材拆分、角色动画、视频、音效和音乐 completed 后,生成结果按请求同时出现在画布资源和账号级素材库。 - 角色图、图标 spritesheet 和 UI 素材提取的 completed result 允许携带 `EditorGenerationWarning`;provider 原图保留降级与自动拆分降级必须保持成功状态,并分别使用通用 `warning` 与兼容 `sliceWarning` 表达。 - 外部视频、角色动画、音效和音乐接口使用站内编辑器相同的请求校验、模型限制和价格校验。 - OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。 diff --git a/package-lock.json b/package-lock.json index 7cbef91d5..8d57584a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,7 +93,7 @@ }, "apps/ai-game-creator-shell": { "name": "@genarrative/ai-game-creator-shell", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@cubone/react-file-manager": "^1.35.0", "@genarrative/image-canvas-core": "0.1.0", diff --git a/scripts/check-npm-workspaces.mjs b/scripts/check-npm-workspaces.mjs index faee0f960..da9e6b6be 100644 --- a/scripts/check-npm-workspaces.mjs +++ b/scripts/check-npm-workspaces.mjs @@ -174,7 +174,7 @@ export function collectNpmWorkspaceErrors(rootDir) { ); } const expectedWorkspaceVersion = - workspacePath === 'apps/ai-game-creator-shell' ? '0.1.1' : '0.1.0'; + workspacePath === 'apps/ai-game-creator-shell' ? '0.1.2' : '0.1.0'; if (manifest.version !== expectedWorkspaceVersion) { errors.push( `${manifestPath}: workspace version must be ${expectedWorkspaceVersion}`, diff --git a/scripts/check-npm-workspaces.test.mjs b/scripts/check-npm-workspaces.test.mjs index 9b159ed56..1fcee9098 100644 --- a/scripts/check-npm-workspaces.test.mjs +++ b/scripts/check-npm-workspaces.test.mjs @@ -79,7 +79,7 @@ function createValidFixture() { name: workspaceNames[workspacePath], private: true, version: - workspacePath === 'apps/ai-game-creator-shell' ? '0.1.1' : '0.1.0', + workspacePath === 'apps/ai-game-creator-shell' ? '0.1.2' : '0.1.0', dependencies: localDependencies[workspacePath], }; writeJson(rootDir, `${workspacePath}/package.json`, manifest); diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 70f45dcf0..0fdb965f9 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -6,7 +6,10 @@ use shared_contracts::external_generation::{ ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, }; use shared_kernel::{build_prefixed_uuid_id, offset_datetime_to_unix_micros}; -use spacetime_client::{ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord}; +use spacetime_client::{ + ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobGetRecordInput, + ExternalGenerationJobRecord, SpacetimeClientError, +}; use crate::{http_error::AppError, request_context::RequestContext, state::AppState}; @@ -30,6 +33,7 @@ const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker"; const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024; const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation"; const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation"; +const EXTERNAL_API_REQUEST_FINGERPRINT_FIELD: &str = "_externalApiRequestFingerprint"; pub(crate) const GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX: &str = "game-creator-client-generation"; pub(crate) const GAME_CREATOR_CLIENT_GENERATION_SOURCE: &str = "ai-game-creator-client"; @@ -89,6 +93,186 @@ fn build_editor_generation_dedupe_key( format!("{namespace}:{job_kind}:{:x}", hasher.finalize()) } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ExternalApiEditorGenerationRequestIdentity { + job_id: String, + dedupe_key: String, + request_fingerprint: String, +} + +pub(crate) fn external_api_editor_generation_request_identity( + owner_user_id: &str, + job_kind: &str, + payload: &T, + idempotency_key: &str, +) -> Result +where + T: Serialize, +{ + let payload = serde_json::to_value(payload).map_err(payload_serialization_error)?; + let canonical_payload = canonicalize_external_api_request_value(payload); + let canonical_payload = + serde_json::to_vec(&canonical_payload).map_err(payload_serialization_error)?; + let mut request_hasher = Sha256::new(); + request_hasher.update(b"genarrative-external-api-request-v1\0"); + request_hasher.update(canonical_payload); + let request_fingerprint = format!("{:x}", request_hasher.finalize()); + let dedupe_key = build_editor_generation_dedupe_key( + EXTERNAL_API_GENERATION_DEDUPE_PREFIX, + owner_user_id, + job_kind, + idempotency_key, + ); + let mut job_hasher = Sha256::new(); + job_hasher.update(b"genarrative-external-api-operation-v1\0"); + job_hasher.update(dedupe_key.as_bytes()); + let job_digest = format!("{:x}", job_hasher.finalize()); + + Ok(ExternalApiEditorGenerationRequestIdentity { + job_id: format!("task-{}", &job_digest[..32]), + dedupe_key, + request_fingerprint, + }) +} + +fn canonicalize_external_api_request_value(value: Value) -> Value { + match value { + Value::Array(values) => Value::Array( + values + .into_iter() + .map(canonicalize_external_api_request_value) + .collect(), + ), + Value::Object(values) => { + let mut entries = values.into_iter().collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key, canonicalize_external_api_request_value(value))) + .collect(), + ) + } + other => other, + } +} + +pub(crate) async fn find_external_api_editor_generation_replay( + state: &AppState, + owner_user_id: &str, + job_kind: &str, + identity: &ExternalApiEditorGenerationRequestIdentity, +) -> Result, AppError> { + let job = match state + .spacetime_client() + .get_external_generation_job(ExternalGenerationJobGetRecordInput { + job_id: identity.job_id.clone(), + owner_user_id: owner_user_id.to_string(), + }) + .await + { + Ok(job) => job, + Err(SpacetimeClientError::Procedure(message)) + if message == "external_generation_job 不存在" => + { + return Ok(None); + } + Err(error) => { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": EDITOR_GENERATION_QUEUE_PROVIDER, + "message": format!("读取 External 幂等任务失败:{error}"), + })), + ); + } + }; + ensure_external_api_editor_generation_request_identity(job, owner_user_id, job_kind, identity) + .map(Some) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn enqueue_external_api_editor_generation_with_request_identity( + state: &AppState, + owner_user_id: &str, + job_kind: &str, + source_entity_id: impl Into, + request_label: impl Into, + price_mud_points: u64, + payload: &T, + identity: &ExternalApiEditorGenerationRequestIdentity, +) -> Result +where + T: Serialize, +{ + let request_payload_json = + serialize_external_api_editor_generation_payload_with_identity(payload, identity)?; + let job = enqueue_serialized_editor_generation_job_with_identity( + state, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + request_payload_json, + identity.job_id.clone(), + identity.dedupe_key.clone(), + ) + .await?; + ensure_external_api_editor_generation_request_identity(job, owner_user_id, job_kind, identity) +} + +fn ensure_external_api_editor_generation_request_identity( + job: ExternalGenerationJobRecord, + owner_user_id: &str, + job_kind: &str, + identity: &ExternalApiEditorGenerationRequestIdentity, +) -> Result { + let persisted_fingerprint = serde_json::from_str::(&job.request_payload_json) + .ok() + .and_then(|payload| { + payload + .get(EXTERNAL_API_REQUEST_FINGERPRINT_FIELD) + .and_then(Value::as_str) + .map(str::to_string) + }); + if job.job_id == identity.job_id + && job.dedupe_key == identity.dedupe_key + && job.job_kind == job_kind + && job.owner_user_id == owner_user_id + && persisted_fingerprint.as_deref() == Some(identity.request_fingerprint.as_str()) + { + return Ok(job); + } + Err( + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": EDITOR_GENERATION_QUEUE_PROVIDER, + "message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。", + })), + ) +} + +fn serialize_external_api_editor_generation_payload_with_identity( + payload: &T, + identity: &ExternalApiEditorGenerationRequestIdentity, +) -> Result +where + T: Serialize + ?Sized, +{ + let mut payload = serde_json::to_value(payload).map_err(payload_serialization_error)?; + payload + .as_object_mut() + .ok_or_else(|| { + payload_serialization_error(serde_json::Error::io(std::io::Error::other( + "编辑器生成任务参数必须是 JSON object", + ))) + })? + .insert( + EXTERNAL_API_REQUEST_FINGERPRINT_FIELD.to_string(), + Value::String(identity.request_fingerprint.clone()), + ); + serialize_editor_generation_job_payload(&payload) +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn enqueue_editor_generation_job_with_identity( state: &AppState, @@ -678,6 +862,163 @@ mod tests { ); } + #[test] + fn external_background_removal_operation_id_is_key_scoped_and_body_fingerprint_is_separate() { + let first_body = json!({ + "sourceImageSrc": "editor-upload/source-a.png", + "projectId": "project-1", + }); + let reordered_same_body = json!({ + "projectId": "project-1", + "sourceImageSrc": "editor-upload/source-a.png", + }); + let changed_body = json!({ + "sourceImageSrc": "editor-upload/source-b.png", + "projectId": "project-1", + }); + let spoofed_client_source = json!({ + "sourceImageSrc": "editor-upload/source-a.png", + "projectId": "project-1", + "generationInputs": { + "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE, + }, + }); + let first = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &first_body, + "stable-key", + ) + .expect("request identity should build"); + let reordered = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &reordered_same_body, + "stable-key", + ) + .expect("canonical request identity should build"); + let changed = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &changed_body, + "stable-key", + ) + .expect("changed request identity should build"); + let spoofed = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &spoofed_client_source, + "stable-key", + ) + .expect("external request identity must ignore payload-controlled namespaces"); + + assert_eq!(first.job_id, reordered.job_id); + assert_eq!(first.request_fingerprint, reordered.request_fingerprint); + assert_eq!(first.job_id, changed.job_id); + assert_ne!(first.request_fingerprint, changed.request_fingerprint); + assert_eq!(first.job_id, spoofed.job_id); + assert_eq!(first.dedupe_key, spoofed.dedupe_key); + assert_ne!(first.request_fingerprint, spoofed.request_fingerprint); + assert!(first.job_id.starts_with("task-")); + assert_ne!( + first.job_id, + external_api_editor_generation_request_identity( + "user-2", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &first_body, + "stable-key", + ) + .unwrap() + .job_id, + ); + assert_ne!( + first.job_id, + external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &first_body, + "other-key", + ) + .unwrap() + .job_id, + ); + } + + #[test] + fn external_background_removal_replay_compares_raw_request_before_canonical_worker_payload() { + let raw_request = json!({ + "sourceImageSrc": "resource-source", + "projectId": "project-output", + }); + let identity = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &raw_request, + "stable-key", + ) + .expect("request identity should build"); + let canonical_worker_payload = json!({ + "sourceImageSrc": "resource-source", + "projectId": "project-output", + "sourceResourceId": "resource-source", + "assetKind": "character", + }); + let persisted_payload = serialize_external_api_editor_generation_payload_with_identity( + &canonical_worker_payload, + &identity, + ) + .expect("canonical worker payload should serialize with its private request fingerprint"); + let mut job = queue_job_fixture("queued", None); + job.job_id = identity.job_id.clone(); + job.dedupe_key = identity.dedupe_key.clone(); + job.job_kind = EDITOR_BACKGROUND_REMOVAL_JOB_KIND.to_string(); + job.request_payload_json = persisted_payload.clone(); + + assert!( + ensure_external_api_editor_generation_request_identity( + job.clone(), + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &identity, + ) + .is_ok(), + "same raw request must replay even when its canonical worker payload came from mutable preflight", + ); + + let changed_identity = external_api_editor_generation_request_identity( + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &json!({ + "sourceImageSrc": "resource-other", + "projectId": "project-output", + }), + "stable-key", + ) + .expect("changed request identity should build"); + let error = ensure_external_api_editor_generation_request_identity( + job, + "user-1", + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &changed_identity, + ) + .expect_err("same key with a different raw request must conflict"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + + let restored: crate::editor_project::EditorBackgroundRemovalRequest = + serde_json::from_str(&persisted_payload) + .expect("worker must ignore the private queue fingerprint envelope field"); + let restored_payload = + serde_json::to_value(restored).expect("worker payload should encode"); + assert_eq!(restored_payload["sourceImageSrc"], json!("resource-source")); + assert_eq!(restored_payload["assetKind"], json!("character")); + assert!( + restored_payload + .get(EXTERNAL_API_REQUEST_FINGERPRINT_FIELD) + .is_none(), + "private replay identity must not enter generationInputs or worker provenance", + ); + } + #[test] fn external_api_dedupe_key_preserves_legacy_hash_bytes() { let dedupe_key = build_editor_generation_dedupe_key( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 4247f223c..0e60b3ea0 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -82,7 +82,10 @@ use crate::{ EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EditorGenerationQueuedResponse, GAME_CREATOR_CLIENT_GENERATION_SOURCE, editor_generation_client_source, editor_generation_queue_state, editor_generation_source_entity_id, - enqueue_editor_generation_job, enqueue_editor_generation_job_for_caller, + enqueue_editor_generation_job_for_caller, + enqueue_external_api_editor_generation_with_request_identity, + external_api_editor_generation_request_identity, + find_external_api_editor_generation_replay, }, editor_green_screen::{ EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause, @@ -820,6 +823,7 @@ pub(crate) enum EditorCanvasLayoutPlan { owner_user_id: String, project_id: Option, target_layer_id: Option, + expected_target: Option, resource: Option, replacement_title: Option, recenter_replacement: bool, @@ -848,6 +852,7 @@ impl EditorCanvasLayoutPlan { owner_user_id: &str, project_id: Option<&str>, target_layer_id: Option<&str>, + expected_target: Option<&EditorBackgroundRemovalTargetLayerSource>, resource: Option<&EditorProjectResourcePayload>, replacement_title: Option<&str>, recenter_replacement: bool, @@ -856,6 +861,7 @@ impl EditorCanvasLayoutPlan { owner_user_id: owner_user_id.to_string(), project_id: project_id.map(str::to_string), target_layer_id: target_layer_id.map(str::to_string), + expected_target: expected_target.cloned(), resource: resource.cloned(), replacement_title: replacement_title.map(str::to_string), recenter_replacement, @@ -889,6 +895,7 @@ impl EditorCanvasLayoutPlan { owner_user_id, project_id, target_layer_id, + expected_target, resource, replacement_title, recenter_replacement, @@ -898,6 +905,7 @@ impl EditorCanvasLayoutPlan { owner_user_id, project_id.as_deref(), target_layer_id.as_deref(), + expected_target.as_ref(), resource.as_ref(), replacement_title.as_deref(), *recenter_replacement, @@ -5144,17 +5152,24 @@ fn map_editor_pixel_art_snapper_error(error: platform_image::PixelArtSnapError) editor_pixel_art_snap_failure(status, error.to_string()) } +fn is_editor_non_static_asset_kind(asset_kind: &str) -> bool { + let normalized = asset_kind.trim().to_ascii_lowercase(); + normalized.contains("video") + || normalized.contains("audio") + || normalized.contains("animation") + || normalized.contains("image-sequence") + || normalized.contains("image_sequence") + || matches!( + normalized.as_str(), + "sound-effect" | "sound_effect" | "background-music" | "background_music" + ) +} + fn validate_editor_pixel_art_static_asset_kind(asset_kind: Option<&str>) -> Result<(), AppError> { let Some(asset_kind) = asset_kind.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(()); }; - let normalized = asset_kind.to_ascii_lowercase(); - let is_non_static = normalized.contains("video") - || normalized.contains("audio") - || normalized.contains("animation") - || normalized.contains("image-sequence") - || matches!(normalized.as_str(), "sound-effect" | "background-music"); - if !is_non_static { + if !is_editor_non_static_asset_kind(asset_kind) { return Ok(()); } Err( @@ -6156,10 +6171,21 @@ pub(crate) async fn edit_editor_image_for_owner_with_source_snapshot( canvas_item.map(|(_, layer_id)| layer_id), ) } else { + let expected_target = source.target_resource_id.as_ref().map(|resource_id| { + EditorBackgroundRemovalTargetLayerSource { + bucket: source.bucket.clone(), + object_key: source.object_key.clone(), + asset_object_id: source.asset_object_id.clone(), + resource_id: resource_id.clone(), + asset_kind: source.effective_asset_kind.clone(), + media_type: source.media_type.clone(), + } + }); EditorCanvasLayoutPlan::background_removal( owner_user_id.as_str(), payload.project_id.as_deref(), payload.target_layer_id.as_deref(), + expected_target.as_ref(), prepared_asset.record.resource.as_ref(), Some(asset_label.as_str()), true, @@ -6237,30 +6263,15 @@ pub async fn remove_editor_image_background( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, - Json(mut payload): Json, + Json(payload): Json, ) -> Result, AppError> { - payload.generation_inputs = - sanitize_editor_queued_generation_inputs(payload.generation_inputs.take()); let caller = EditorGenerationCaller::from_authenticated(&authenticated); - ensure_editor_reference_image_source_is_stable( - payload.source_image_src.as_str(), - "editor-background-removal", - "sourceImageSrc", - "待去除背景图片", - )?; - let source_entity_id = editor_generation_source_entity_id( - payload.project_id.as_deref(), - "editor-background-removal", - ); - let queue_job = enqueue_editor_generation_job( + let queue_job = enqueue_editor_background_removal_for_owner( &state, &request_context, - caller.owner_user_id.as_str(), - EDITOR_BACKGROUND_REMOVAL_JOB_KIND, - source_entity_id, - "图片画布去除背景", - 0, - &payload, + &caller, + payload, + None, ) .await?; Ok(json_success_body( @@ -6271,6 +6282,367 @@ pub async fn remove_editor_image_background( )) } +pub(crate) async fn enqueue_editor_background_removal_for_owner( + state: &AppState, + request_context: &RequestContext, + caller: &EditorGenerationCaller, + mut payload: EditorBackgroundRemovalRequest, + external_idempotency_key: Option<&str>, +) -> Result { + let external_request_identity = external_idempotency_key + .map(|idempotency_key| { + external_api_editor_generation_request_identity( + caller.owner_user_id.as_str(), + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + &payload, + idempotency_key, + ) + }) + .transpose()?; + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-background-removal", + "sourceImageSrc", + "待去除背景图片", + )?; + validate_editor_background_removal_static_asset_kind(payload.asset_kind.as_deref())?; + let canvas_target = resolve_editor_background_removal_canvas_target( + payload.project_id.as_deref(), + payload.target_layer_id.as_deref(), + payload.canvas_completion.is_some(), + )?; + if let Some(identity) = external_request_identity.as_ref() + && let Some(job) = find_external_api_editor_generation_replay( + state, + caller.owner_user_id.as_str(), + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + identity, + ) + .await? + { + return Ok(job); + } + payload.generation_inputs = + sanitize_editor_queued_generation_inputs(payload.generation_inputs.take()); + payload.target_layer_id = normalize_optional_string(payload.target_layer_id.take()); + let target = preflight_editor_billable_generation_target( + state, + caller.owner_user_id.as_str(), + payload.project_id.clone(), + payload.asset_folder_id.clone(), + ) + .await?; + payload.project_id = target.project_id; + payload.asset_folder_id = target.asset_folder_id; + let resolved_canvas_target = match canvas_target.as_ref() { + Some((project_id, target_layer_id)) => Some( + resolve_editor_background_removal_canvas_target_for_owner( + state, + caller.owner_user_id.as_str(), + project_id, + target_layer_id, + ) + .await?, + ), + None => None, + }; + payload.source_resource_id = resolve_editor_background_removal_source_resource_binding( + payload.source_image_src.as_str(), + payload.source_resource_id.take(), + resolved_canvas_target.as_ref(), + ); + let source = resolve_editor_background_removal_source( + state, + caller.owner_user_id.as_str(), + payload.project_id.as_deref(), + payload.source_resource_id.as_deref(), + payload.source_image_src.as_str(), + payload.asset_kind.as_deref(), + ) + .await?; + if let Some(target) = resolved_canvas_target.as_ref() { + ensure_editor_background_removal_target_matches_source(&source, target)?; + } + payload.source_image_src = canonicalize_editor_background_removal_queued_source( + payload.source_image_src.as_str(), + source.object_key.as_str(), + ); + payload.asset_kind = source.asset_kind.clone(); + let source_entity_id = editor_generation_source_entity_id( + payload.project_id.as_deref(), + "editor-background-removal", + ); + if let Some(identity) = external_request_identity.as_ref() { + enqueue_external_api_editor_generation_with_request_identity( + state, + caller.owner_user_id.as_str(), + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + source_entity_id, + "图片画布去除背景", + 0, + &payload, + identity, + ) + .await + } else { + enqueue_editor_generation_job_for_caller( + state, + request_context, + caller.owner_user_id.as_str(), + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, + source_entity_id, + "图片画布去除背景", + 0, + &payload, + None, + ) + .await + } +} + +fn resolve_editor_background_removal_canvas_target( + project_id: Option<&str>, + target_layer_id: Option<&str>, + has_canvas_completion: bool, +) -> Result, AppError> { + let Some(target_layer_id) = target_layer_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(None); + }; + let project_id = project_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "projectId", + "message": "提供 targetLayerId 时必须同时提供 projectId", + "targetLayerId": target_layer_id, + })) + })?; + if has_canvas_completion { + return Ok(None); + } + Ok(Some((project_id.to_string(), target_layer_id.to_string()))) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EditorBackgroundRemovalTargetLayerSource { + bucket: String, + object_key: String, + asset_object_id: Option, + resource_id: String, + asset_kind: Option, + media_type: String, +} + +fn resolve_editor_background_removal_source_resource_binding( + source_reference: &str, + source_resource_id: Option, + target: Option<&EditorBackgroundRemovalTargetLayerSource>, +) -> Option { + normalize_optional_string(source_resource_id).or_else(|| { + target + .filter(|_| normalize_editor_reference_object_key(source_reference).is_ok()) + .map(|target| target.resource_id.clone()) + }) +} + +fn canonicalize_editor_background_removal_queued_source( + source_reference: &str, + resolved_object_key: &str, +) -> String { + if normalize_editor_reference_object_key(source_reference).is_ok() { + resolved_object_key.to_string() + } else { + source_reference.trim().to_string() + } +} + +async fn resolve_editor_background_removal_canvas_target_for_owner( + state: &AppState, + owner_user_id: &str, + project_id: &str, + target_layer_id: &str, +) -> Result { + let bucket = state + .oss_client() + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })? + .config_bucket() + .to_string(); + let project = state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + }) + .await + .map_err(map_editor_project_error)?; + let project = editor_project_payload_from_record(project); + resolve_editor_background_removal_canvas_target_source( + bucket.as_str(), + &project.layers, + &project.resources, + target_layer_id, + ) +} + +fn resolve_editor_background_removal_canvas_target_source( + bucket: &str, + layers: &Value, + resources: &[EditorProjectResourcePayload], + target_layer_id: &str, +) -> Result { + let target_layer = layers + .as_array() + .and_then(|items| { + items + .iter() + .find(|item| item.get("layerId").and_then(Value::as_str) == Some(target_layer_id)) + }) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "targetLayerId", + "message": "去背景目标图层不存在", + "targetLayerId": target_layer_id, + })) + })?; + let media_type = match target_layer.get("mediaType") { + Some(Value::String(value)) if !value.trim().is_empty() => value.trim(), + Some(Value::Null) | None => "image", + Some(_) => { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "targetLayerId", + "message": "去背景目标图层的媒体类型无效", + "targetLayerId": target_layer_id, + })), + ); + } + }; + if media_type != "image" { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "targetLayerId", + "message": "去背景目标图层必须是静态图片", + "targetLayerId": target_layer_id, + "mediaType": media_type, + })), + ); + } + let asset_kind_override = match target_layer.get("assetKindOverride") { + Some(Value::String(value)) if !value.trim().is_empty() => Some(value.trim()), + Some(Value::Null) | None => None, + Some(_) => { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "targetLayerId", + "message": "去背景目标图层的素材类型覆盖无效", + "targetLayerId": target_layer_id, + })), + ); + } + }; + let source_resource_id = target_layer + .get("resourceId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "targetLayerId", + "message": "去背景目标图层没有关联权威项目资源", + "targetLayerId": target_layer_id, + })) + })?; + let resource = resources + .iter() + .find(|resource| resource.resource_id.trim() == source_resource_id) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "targetLayerId", + "message": "去背景目标图层关联的项目资源不存在", + "targetLayerId": target_layer_id, + "sourceResourceId": source_resource_id, + })) + })?; + let object_key = normalize_editor_record_object_key( + resource.object_key.as_deref(), + resource.image_src.as_str(), + ) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "targetLayerId", + "message": "去背景目标图层的项目资源没有可用的稳定图片引用", + "targetLayerId": target_layer_id, + "sourceResourceId": source_resource_id, + })) + })?; + let asset_kind = normalize_editor_image_edit_resolved_source_kind( + asset_kind_override.or(resource.asset_kind.as_deref()), + ); + validate_editor_background_removal_static_asset_kind(asset_kind.as_deref())?; + Ok(EditorBackgroundRemovalTargetLayerSource { + bucket: bucket.to_string(), + object_key, + asset_object_id: resource.asset_object_id.clone(), + resource_id: resource.resource_id.clone(), + asset_kind, + media_type: media_type.to_string(), + }) +} + +fn ensure_editor_background_removal_target_matches_source( + source: &ResolvedEditorBackgroundRemovalSource, + target: &EditorBackgroundRemovalTargetLayerSource, +) -> Result<(), AppError> { + let same_object = match ( + source.asset_object_id.as_deref(), + target.asset_object_id.as_deref(), + ) { + (Some(source_id), Some(target_id)) => source_id == target_id, + _ => source.bucket == target.bucket && source.object_key == target.object_key, + }; + if !same_object { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "targetLayerId", + "targetResourceId": target.resource_id.as_str(), + "sourceObjectKey": source.object_key.as_str(), + "targetObjectKey": target.object_key.as_str(), + "message": "sourceImageSrc 与去背景目标图层不是同一权威对象", + })), + ); + } + if source.asset_kind != target.asset_kind { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "assetKind", + "sourceAssetKind": source.asset_kind.as_deref(), + "targetAssetKind": target.asset_kind.as_deref(), + "message": "主来源与去背景目标资源对同一对象声明了冲突的权威素材类型", + })), + ); + } + Ok(()) +} + pub(crate) async fn remove_editor_image_background_for_owner( state: &AppState, request_context: &RequestContext, @@ -6279,27 +6651,66 @@ pub(crate) async fn remove_editor_image_background_for_owner( ) -> Result, AppError> { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + let started_at = Instant::now(); + caller.report_processing_phase(state).await?; + let resolved_canvas_target = if payload.canvas_completion.is_none() + && let Some(target_layer_id) = payload.target_layer_id.as_deref() + { + let project_id = payload.project_id.as_deref().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "projectId", + "message": "去背景原位替换缺少项目上下文", + "targetLayerId": target_layer_id, + })) + })?; + Some( + resolve_editor_background_removal_canvas_target_for_owner( + state, + caller.owner_user_id.as_str(), + project_id, + target_layer_id, + ) + .await?, + ) + } else { + None + }; + payload.source_resource_id = resolve_editor_background_removal_source_resource_binding( + payload.source_image_src.as_str(), + payload.source_resource_id.take(), + resolved_canvas_target.as_ref(), + ); + let generation_reference_source = payload + .source_resource_id + .as_deref() + .unwrap_or(payload.source_image_src.as_str()) + .to_string(); payload.generation_inputs = rebuild_editor_generation_input_references( state, caller.owner_user_id.as_str(), payload.generation_inputs.take(), build_editor_generation_reference_sources( - Some(("原图", payload.source_image_src.as_str())), + Some(("原图", generation_reference_source.as_str())), None, "参考图", ), ) .await?; - let started_at = Instant::now(); - caller.report_processing_phase(state).await?; let source = resolve_editor_background_removal_source( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.source_resource_id.as_deref(), payload.source_image_src.as_str(), + payload.asset_kind.as_deref(), ) .await?; + payload.source_image_src = source.object_key.clone(); + payload.asset_kind = source.asset_kind.clone(); + if let Some(target) = resolved_canvas_target.as_ref() { + ensure_editor_background_removal_target_matches_source(&source, target)?; + } validate_editor_background_removal_source(state, source.object_key.as_str()).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), @@ -6396,6 +6807,7 @@ pub(crate) async fn remove_editor_image_background_for_owner( caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.target_layer_id.as_deref(), + resolved_canvas_target.as_ref(), prepared_asset.record.resource.as_ref(), Some(asset_label.as_str()), false, @@ -10275,6 +10687,7 @@ pub(crate) async fn complete_editor_canvas_background_removal( owner_user_id, project_id, target_layer_id, + None, resource, replacement_title, recenter_replacement, @@ -10298,6 +10711,7 @@ async fn prepare_editor_canvas_background_removal_layout( owner_user_id: &str, project_id: Option<&str>, target_layer_id: Option<&str>, + expected_target: Option<&EditorBackgroundRemovalTargetLayerSource>, resource: Option<&EditorProjectResourcePayload>, replacement_title: Option<&str>, recenter_replacement: bool, @@ -10325,6 +10739,12 @@ async fn prepare_editor_canvas_background_removal_layout( let expected_revision = project.canvas.revision; let viewport = project.viewport.clone(); let project_payload = editor_project_payload_from_record(project); + ensure_editor_canvas_background_removal_target_resource_matches( + &project_payload.layers, + &project_payload.resources, + target_layer_id, + expected_target, + )?; let Some(layers) = replace_editor_canvas_layer_with_resource( project_payload.layers, target_layer_id, @@ -10345,6 +10765,63 @@ async fn prepare_editor_canvas_background_removal_layout( })) } +fn ensure_editor_canvas_background_removal_target_resource_matches( + layers: &Value, + resources: &[EditorProjectResourcePayload], + target_layer_id: &str, + expected_target: Option<&EditorBackgroundRemovalTargetLayerSource>, +) -> Result<(), AppError> { + let Some(expected_target) = expected_target else { + return Ok(()); + }; + let current_target = resolve_editor_background_removal_canvas_target_source( + expected_target.bucket.as_str(), + layers, + resources, + target_layer_id, + ) + .map_err(|_| { + editor_canvas_background_removal_target_changed(target_layer_id, expected_target) + })?; + let same_object = match ( + expected_target.asset_object_id.as_deref(), + current_target.asset_object_id.as_deref(), + ) { + (Some(expected_id), Some(current_id)) => expected_id == current_id, + _ => { + expected_target.bucket == current_target.bucket + && expected_target.object_key == current_target.object_key + } + }; + if expected_target.resource_id == current_target.resource_id + && expected_target.asset_kind == current_target.asset_kind + && expected_target.media_type == current_target.media_type + && same_object + { + return Ok(()); + } + Err(editor_canvas_background_removal_target_changed( + target_layer_id, + expected_target, + )) +} + +fn editor_canvas_background_removal_target_changed( + target_layer_id: &str, + expected_target: &EditorBackgroundRemovalTargetLayerSource, +) -> AppError { + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "editor-project", + "message": "图片画布版本冲突:去背景目标图层的当前资源或媒体语义已变化。", + "targetLayerId": target_layer_id, + "expectedTargetResourceId": expected_target.resource_id.as_str(), + "expectedTargetAssetKind": expected_target.asset_kind.as_deref(), + "expectedTargetMediaType": expected_target.media_type.as_str(), + "expectedTargetAssetObjectId": expected_target.asset_object_id.as_deref(), + "expectedTargetObjectKey": expected_target.object_key.as_str(), + })) +} + pub(crate) async fn save_editor_project_layout_with_revision_and_get( state: &AppState, project_id: &str, @@ -10452,6 +10929,7 @@ fn replace_editor_canvas_layer_with_resource( resource.generation_inputs.clone(), ); object.remove("sourceAssetId"); + object.remove("assetKindOverride"); object.remove("thumbnailSrc"); object.remove("imageSequenceFrames"); object.remove("previewVideoPath"); @@ -11930,14 +12408,16 @@ const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS: u64 = 300; const EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES: u64 = 32 * 1024 * 1024; const EDITOR_BACKGROUND_REMOVAL_SOURCE_PROBE_BYTES: u64 = 16; -fn is_editor_inline_media_source(source: &str) -> bool { - source - .trim_start() - .as_bytes() - .get(..5) - .is_some_and(|prefix| { - prefix.eq_ignore_ascii_case(b"data:") || prefix.eq_ignore_ascii_case(b"blob:") - }) +fn is_editor_unstable_media_source(source: &str) -> bool { + let source = source.trim_start().as_bytes(); + source.get(..5).is_some_and(|prefix| { + prefix.eq_ignore_ascii_case(b"data:") || prefix.eq_ignore_ascii_case(b"blob:") + }) || source + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"http://")) + || source + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"https://")) } fn ensure_editor_reference_image_source_is_stable( @@ -11946,7 +12426,7 @@ fn ensure_editor_reference_image_source_is_stable( field: &str, label: &str, ) -> Result<(), AppError> { - if !is_editor_inline_media_source(source) { + if !is_editor_unstable_media_source(source) { return Ok(()); } Err( @@ -12087,9 +12567,13 @@ pub(crate) async fn resolve_editor_reference_object_key_for_owner( Ok(object_key) } +#[derive(Clone, Debug, PartialEq, Eq)] struct ResolvedEditorBackgroundRemovalSource { + bucket: String, object_key: String, model: Option, + asset_object_id: Option, + asset_kind: Option, } async fn resolve_editor_background_removal_source( @@ -12098,6 +12582,7 @@ async fn resolve_editor_background_removal_source( project_id: Option<&str>, source_resource_id: Option<&str>, source_reference: &str, + requested_asset_kind: Option<&str>, ) -> Result { ensure_editor_reference_image_source_is_stable( source_reference, @@ -12128,11 +12613,12 @@ async fn resolve_editor_background_removal_source( ) .ok_or(error)?, }; - if !editor_reference_object_key_is_registered_for_owner( + let source_is_registered = editor_reference_object_key_is_registered_for_owner( projects.as_slice(), library.assets.as_slice(), source_object_key.as_str(), - ) { + ); + if !source_is_registered { ensure_editor_reference_asset_object_owned( state, owner_user_id, @@ -12140,7 +12626,7 @@ async fn resolve_editor_background_removal_source( ) .await?; } - let model = resolve_editor_background_removal_source_model_from_records( + let metadata = resolve_editor_background_removal_source_metadata_from_records( projects.as_slice(), library.assets.as_slice(), project_id, @@ -12148,12 +12634,389 @@ async fn resolve_editor_background_removal_source( source_reference, source_object_key.as_str(), )?; + let mut discovered_asset_kinds = projects + .iter() + .flat_map(|project| project.resources.iter()) + .filter(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + source_object_key.as_str(), + ) + }) + .filter_map(editor_background_removal_resource_asset_kind) + .collect::>(); + discovered_asset_kinds.extend( + library + .assets + .iter() + .filter(|asset| { + editor_record_object_key_matches( + asset.object_key.as_deref(), + asset.image_src.as_str(), + source_object_key.as_str(), + ) + }) + .filter_map(editor_background_removal_asset_kind), + ); + discovered_asset_kinds.sort(); + discovered_asset_kinds.dedup(); + + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let bucket = oss_client.config_bucket().to_string(); + let asset_object = state + .spacetime_client() + .get_asset_object_by_location(module_assets::AssetObjectLocationInput { + bucket: bucket.clone(), + object_key: source_object_key.clone(), + }) + .await + .map_err(map_editor_project_error)?; + if !source_is_registered && let Some(asset_object) = asset_object.as_ref() { + validate_editor_reference_asset_object( + asset_object, + owner_user_id, + state, + source_object_key.as_str(), + )?; + } + let storage_asset_kinds = asset_object + .as_ref() + .map(|asset_object| vec![asset_object.asset_kind.clone()]) + .unwrap_or_default(); + let asset_kind = resolve_editor_background_removal_asset_kind( + requested_asset_kind, + metadata.asset_kind.as_deref(), + discovered_asset_kinds.as_slice(), + storage_asset_kinds.as_slice(), + )?; Ok(ResolvedEditorBackgroundRemovalSource { + bucket, object_key: source_object_key, - model, + model: metadata.model, + asset_object_id: metadata + .asset_object_id + .or_else(|| asset_object.map(|asset_object| asset_object.asset_object_id)), + asset_kind, }) } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct EditorBackgroundRemovalSourceMetadata { + model: Option, + asset_object_id: Option, + asset_kind: Option, +} + +fn merge_editor_background_removal_source_metadata_field( + current: &mut Option, + incoming: Option, + field: &str, + source_object_key: &str, +) -> Result<(), AppError> { + let Some(incoming) = incoming else { + return Ok(()); + }; + if let Some(current) = current.as_ref() { + if current != &incoming { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "sourceImageSrc", + "conflictingField": field, + "objectKey": source_object_key, + "message": "同一 objectKey 对应多个不同的权威来源记录,请提供 sourceResourceId,或直接使用项目资源 ID / 素材 ID。", + })), + ); + } + return Ok(()); + } + *current = Some(incoming); + Ok(()) +} + +fn merge_editor_background_removal_source_metadata( + candidates: impl IntoIterator, + source_object_key: &str, +) -> Result { + let mut merged = EditorBackgroundRemovalSourceMetadata::default(); + for candidate in candidates { + merge_editor_background_removal_source_metadata_field( + &mut merged.model, + candidate.model, + "model", + source_object_key, + )?; + merge_editor_background_removal_source_metadata_field( + &mut merged.asset_object_id, + candidate.asset_object_id, + "assetObjectId", + source_object_key, + )?; + merge_editor_background_removal_source_metadata_field( + &mut merged.asset_kind, + candidate.asset_kind, + "assetKind", + source_object_key, + )?; + } + Ok(merged) +} + +fn editor_background_removal_resource_asset_kind( + resource: &EditorProjectResourceRecord, +) -> Option { + normalize_editor_image_edit_resolved_source_kind( + resolve_editor_asset_kind( + resource.asset_kind.clone(), + resource.image_src.as_str(), + resource.object_key.as_deref(), + ) + .as_deref(), + ) +} + +fn editor_background_removal_asset_kind(asset: &EditorAssetRecord) -> Option { + normalize_editor_image_edit_resolved_source_kind( + resolve_editor_asset_kind( + asset.asset_kind.clone(), + asset.image_src.as_str(), + asset.object_key.as_deref(), + ) + .as_deref(), + ) +} + +fn validate_editor_background_removal_static_asset_kind( + asset_kind: Option<&str>, +) -> Result<(), AppError> { + let Some(asset_kind) = asset_kind.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(()); + }; + if !is_editor_non_static_asset_kind(asset_kind) { + return Ok(()); + } + Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "assetKind", + "assetKind": asset_kind, + "message": "去背景只支持静态图片素材。", + })), + ) +} + +fn resolve_editor_background_removal_asset_kind( + requested_asset_kind: Option<&str>, + preferred_source_asset_kind: Option<&str>, + discovered_source_asset_kinds: &[String], + storage_asset_kinds: &[String], +) -> Result, AppError> { + let requested_asset_kind = + normalize_editor_image_edit_resolved_source_kind(requested_asset_kind); + let preferred_source_asset_kind = + normalize_editor_image_edit_resolved_source_kind(preferred_source_asset_kind); + for asset_kind in preferred_source_asset_kind + .iter() + .chain(discovered_source_asset_kinds.iter()) + .chain(storage_asset_kinds.iter()) + { + validate_editor_background_removal_static_asset_kind(Some(asset_kind.as_str()))?; + } + let authoritative_asset_kind = + preferred_source_asset_kind.or_else(|| discovered_source_asset_kinds.first().cloned()); + if authoritative_asset_kind.is_some() + && requested_asset_kind.is_some() + && authoritative_asset_kind != requested_asset_kind + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "assetKind", + "requestedAssetKind": requested_asset_kind.as_deref(), + "sourceAssetKind": authoritative_asset_kind.as_deref(), + "message": "assetKind 与来源素材权威类型不一致。", + })), + ); + } + let asset_kind = authoritative_asset_kind.or(requested_asset_kind); + validate_editor_background_removal_static_asset_kind(asset_kind.as_deref())?; + Ok(asset_kind) +} + +fn resolve_editor_background_removal_source_metadata_from_records( + projects: &[EditorProjectRecord], + assets: &[EditorAssetRecord], + project_id: Option<&str>, + source_resource_id: Option<&str>, + source_reference: &str, + source_object_key: &str, +) -> Result { + let project_id = project_id.map(str::trim).filter(|value| !value.is_empty()); + let source_resource_id = source_resource_id + .map(str::trim) + .filter(|value| !value.is_empty()); + let source_reference = source_reference.trim(); + + let explicit_resource = + find_editor_background_removal_resource(projects, None, source_reference); + let explicit_asset = assets + .iter() + .find(|asset| asset.asset_id.trim() == source_reference); + if explicit_resource.is_some() && explicit_asset.is_some() { + return Err(editor_background_removal_source_identity_conflict( + source_reference, + source_resource_id, + )); + } + + if let Some(resource) = explicit_resource { + if source_resource_id.is_some_and(|resource_id| resource_id != resource.resource_id.trim()) + { + return Err(editor_background_removal_source_identity_conflict( + source_reference, + source_resource_id, + )); + } + return Ok(EditorBackgroundRemovalSourceMetadata { + model: resolve_editor_background_removal_resource_model(projects, resource, project_id), + asset_object_id: resource.asset_object_id.clone(), + asset_kind: editor_background_removal_resource_asset_kind(resource), + }); + } + + if let Some(asset) = explicit_asset { + if let Some(source_resource_id) = source_resource_id { + let asset_source_resource_id = asset + .source_resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let matching_resource = + find_editor_background_removal_resource(projects, None, source_resource_id); + let binding_matches = asset_source_resource_id == Some(source_resource_id) + && matching_resource.is_some_and(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + source_object_key, + ) && match ( + asset.asset_object_id.as_deref(), + resource.asset_object_id.as_deref(), + ) { + (Some(asset_object_id), Some(resource_object_id)) => { + asset_object_id == resource_object_id + } + _ => true, + } + }); + if !binding_matches { + return Err(editor_background_removal_source_identity_conflict( + source_reference, + Some(source_resource_id), + )); + } + } + return Ok(EditorBackgroundRemovalSourceMetadata { + model: sanitize_editor_user_model(asset.model.clone()).or_else(|| { + asset.source_resource_id.as_deref().and_then(|resource_id| { + find_editor_background_removal_resource(projects, None, resource_id).and_then( + |resource| { + resolve_editor_background_removal_resource_model( + projects, resource, project_id, + ) + }, + ) + }) + }), + asset_object_id: asset.asset_object_id.clone(), + asset_kind: editor_background_removal_asset_kind(asset), + }); + } + + if let Some(source_resource_id) = source_resource_id { + let resource = find_editor_background_removal_resource(projects, None, source_resource_id) + .ok_or_else(|| editor_reference_image_forbidden(source_object_key))?; + if !editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + source_object_key, + ) { + return Err(editor_background_removal_source_identity_conflict( + source_reference, + Some(source_resource_id), + )); + } + return Ok(EditorBackgroundRemovalSourceMetadata { + model: resolve_editor_background_removal_resource_model(projects, resource, project_id), + asset_object_id: resource.asset_object_id.clone(), + asset_kind: editor_background_removal_resource_asset_kind(resource), + }); + } + + let matching_resources = projects + .iter() + .flat_map(|project| project.resources.iter()) + .filter(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + source_object_key, + ) + }); + let resource_metadata = + matching_resources.map(|resource| EditorBackgroundRemovalSourceMetadata { + model: resolve_editor_background_removal_resource_model(projects, resource, project_id), + asset_object_id: resource.asset_object_id.clone(), + asset_kind: editor_background_removal_resource_asset_kind(resource), + }); + let asset_metadata = assets + .iter() + .filter(|asset| { + editor_record_object_key_matches( + asset.object_key.as_deref(), + asset.image_src.as_str(), + source_object_key, + ) + }) + .map(|asset| EditorBackgroundRemovalSourceMetadata { + model: sanitize_editor_user_model(asset.model.clone()).or_else(|| { + asset.source_resource_id.as_deref().and_then(|resource_id| { + find_editor_background_removal_resource(projects, project_id, resource_id) + .and_then(|resource| { + resolve_editor_background_removal_resource_model( + projects, resource, project_id, + ) + }) + }) + }), + asset_object_id: asset.asset_object_id.clone(), + asset_kind: editor_background_removal_asset_kind(asset), + }); + merge_editor_background_removal_source_metadata( + resource_metadata.chain(asset_metadata), + source_object_key, + ) +} + +fn editor_background_removal_source_identity_conflict( + source_reference: &str, + source_resource_id: Option<&str>, +) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-background-removal", + "field": "sourceResourceId", + "sourceImageSrc": source_reference, + "sourceResourceId": source_resource_id, + "message": "sourceImageSrc 的显式资源身份与 sourceResourceId 冲突。", + })) +} + fn resolve_editor_background_removal_source_model_from_records( projects: &[EditorProjectRecord], assets: &[EditorAssetRecord], @@ -12162,95 +13025,17 @@ fn resolve_editor_background_removal_source_model_from_records( source_reference: &str, source_object_key: &str, ) -> Result, AppError> { - let project_id = project_id.map(str::trim).filter(|value| !value.is_empty()); - let source_resource_id = source_resource_id - .map(str::trim) - .filter(|value| !value.is_empty()); - let source_reference = source_reference.trim(); - - if let Some(source_resource_id) = source_resource_id { - let resource = if let Some(project_id) = project_id { - projects - .iter() - .find(|project| project.project_id.trim() == project_id) - .and_then(|project| { - project - .resources - .iter() - .find(|resource| resource.resource_id.trim() == source_resource_id) - }) - } else { - find_editor_background_removal_resource(projects, None, source_resource_id) - } - .ok_or_else(|| editor_reference_image_forbidden(source_object_key))?; - if !editor_record_object_key_matches( - resource.object_key.as_deref(), - resource.image_src.as_str(), + Ok( + resolve_editor_background_removal_source_metadata_from_records( + projects, + assets, + project_id, + source_resource_id, + source_reference, source_object_key, - ) { - return Err(editor_reference_image_forbidden(source_object_key)); - } - return Ok(resolve_editor_background_removal_resource_model( - projects, resource, project_id, - )); - } - - let resource = project_id - .and_then(|project_id| { - projects - .iter() - .find(|project| project.project_id.trim() == project_id) - }) - .and_then(|project| { - project.resources.iter().find(|resource| { - resource.resource_id.trim() == source_reference - || editor_record_object_key_matches( - resource.object_key.as_deref(), - resource.image_src.as_str(), - source_object_key, - ) - }) - }) - .or_else(|| { - projects - .iter() - .flat_map(|project| project.resources.iter()) - .find(|resource| { - resource.resource_id.trim() == source_reference - || editor_record_object_key_matches( - resource.object_key.as_deref(), - resource.image_src.as_str(), - source_object_key, - ) - }) - }); - if let Some(resource) = resource { - return Ok(resolve_editor_background_removal_resource_model( - projects, resource, project_id, - )); - } - - let asset = assets.iter().find(|asset| { - asset.asset_id.trim() == source_reference - || editor_record_object_key_matches( - asset.object_key.as_deref(), - asset.image_src.as_str(), - source_object_key, - ) - }); - Ok(asset.and_then(|asset| { - sanitize_editor_user_model(asset.model.clone()).or_else(|| { - asset.source_resource_id.as_deref().and_then(|resource_id| { - find_editor_background_removal_resource(projects, project_id, resource_id).and_then( - |resource| { - resolve_editor_background_removal_resource_model( - projects, resource, project_id, - ) - }, - ) - }) - }) - })) + )? + .model, + ) } fn find_editor_background_removal_resource<'a>( @@ -13633,6 +14418,11 @@ mod tests { !audio_source.contains("is_editor_audio_canvas_revision_conflict"), "audio must not keep its former broad conflict classifier", ); + assert!( + editor_source.contains("expected_target") + && editor_source.contains("去背景目标图层的当前资源或媒体语义已变化"), + "background replacement retry must revalidate the original target identity and media semantics", + ); } #[test] @@ -14404,7 +15194,259 @@ mod tests { } #[test] - fn background_removal_source_model_rejects_mismatched_project_resource() { + fn background_removal_source_metadata_keeps_authoritative_kind_and_object_identity() { + let source_key = "generated-character-drafts/editor/character.png"; + let mut resource = test_editor_project_resource_record( + "resource-character", + "project-1", + source_key, + Some("gpt-image-2"), + None, + ); + resource.asset_object_id = Some("asset-object-character".to_string()); + resource.asset_kind = Some("character".to_string()); + let projects = vec![test_editor_project_record("project-1", vec![resource])]; + + let metadata = resolve_editor_background_removal_source_metadata_from_records( + projects.as_slice(), + &[], + Some("project-1"), + Some("resource-character"), + "resource-character", + source_key, + ) + .expect("registered resource metadata should resolve"); + + assert_eq!(metadata.model.as_deref(), Some("gpt-image-2")); + assert_eq!( + metadata.asset_object_id.as_deref(), + Some("asset-object-character") + ); + assert_eq!(metadata.asset_kind.as_deref(), Some("character")); + assert!( + resolve_editor_background_removal_asset_kind( + Some("spec"), + metadata.asset_kind.as_deref(), + &["character".to_string()], + &[], + ) + .is_err(), + "request kind must not override the resource's authoritative kind" + ); + } + + #[test] + fn background_removal_source_metadata_prefers_explicit_ids_over_object_key_matches() { + let source_key = "generated-character-drafts/editor/shared.png"; + let mut first_resource = test_editor_project_resource_record( + "resource-character", + "project-1", + source_key, + Some("gpt-image-2"), + None, + ); + first_resource.asset_kind = Some("character".to_string()); + let mut selected_resource = test_editor_project_resource_record( + "resource-spec", + "project-1", + source_key, + Some("nanobanana2"), + None, + ); + selected_resource.asset_kind = Some("spec".to_string()); + let projects = vec![test_editor_project_record( + "project-1", + vec![first_resource, selected_resource], + )]; + + let resource_metadata = resolve_editor_background_removal_source_metadata_from_records( + projects.as_slice(), + &[], + Some("project-1"), + None, + "resource-spec", + source_key, + ) + .expect("explicit resource ID must win over an earlier objectKey match"); + assert_eq!(resource_metadata.model.as_deref(), Some("nanobanana2")); + assert_eq!(resource_metadata.asset_kind.as_deref(), Some("spec")); + + let mut selected_asset = + test_editor_asset_record("asset-spec", source_key, Some("nanobanana2"), None); + selected_asset.asset_kind = Some("spec".to_string()); + let asset_metadata = resolve_editor_background_removal_source_metadata_from_records( + projects.as_slice(), + std::slice::from_ref(&selected_asset), + Some("project-1"), + None, + "asset-spec", + source_key, + ) + .expect("explicit asset ID must win over project resource objectKey matches"); + assert_eq!(asset_metadata.model.as_deref(), Some("nanobanana2")); + assert_eq!(asset_metadata.asset_kind.as_deref(), Some("spec")); + } + + #[test] + fn background_removal_explicit_source_id_rejects_conflicting_source_resource_id() { + let source_key = "generated-character-drafts/editor/source-a.png"; + let other_key = "generated-character-drafts/editor/source-b.png"; + let projects = vec![test_editor_project_record( + "project-1", + vec![ + test_editor_project_resource_record( + "resource-a", + "project-1", + source_key, + Some("gpt-image-2"), + None, + ), + test_editor_project_resource_record( + "resource-b", + "project-1", + other_key, + Some("nanobanana2"), + None, + ), + ], + )]; + + let error = resolve_editor_background_removal_source_metadata_from_records( + projects.as_slice(), + &[], + Some("project-1"), + Some("resource-b"), + "resource-a", + source_key, + ) + .expect_err("sourceImageSrc business ID must not inherit another resource's provenance"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("显式资源身份")); + } + + #[test] + fn background_removal_source_metadata_rejects_ambiguous_object_key() { + let source_key = "generated-character-drafts/editor/shared.png"; + let mut character = test_editor_project_resource_record( + "resource-character", + "project-1", + source_key, + Some("gpt-image-2"), + None, + ); + character.asset_kind = Some("character".to_string()); + let mut spec = test_editor_project_resource_record( + "resource-spec", + "project-1", + source_key, + Some("gpt-image-2"), + None, + ); + spec.asset_kind = Some("spec".to_string()); + let projects = vec![test_editor_project_record( + "project-1", + vec![character, spec], + )]; + + let error = resolve_editor_background_removal_source_metadata_from_records( + projects.as_slice(), + &[], + Some("project-1"), + None, + source_key, + source_key, + ) + .expect_err("ambiguous objectKey metadata must not depend on record order"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("sourceResourceId")); + } + + #[test] + fn background_removal_raw_object_key_detects_owner_wide_conflicts_across_projects() { + let source_key = "generated-character-drafts/editor/shared-across-projects.png"; + let mut target_project_resource = test_editor_project_resource_record( + "resource-character", + "project-target", + source_key, + Some("gpt-image-2"), + None, + ); + target_project_resource.asset_kind = Some("character".to_string()); + let mut other_project_resource = test_editor_project_resource_record( + "resource-spec", + "project-source", + source_key, + Some("nanobanana2"), + None, + ); + other_project_resource.asset_kind = Some("spec".to_string()); + let projects = vec![ + test_editor_project_record("project-target", vec![target_project_resource]), + test_editor_project_record("project-source", vec![other_project_resource]), + ]; + + let error = resolve_editor_background_removal_source_metadata_from_records( + projects.as_slice(), + &[], + Some("project-target"), + None, + source_key, + source_key, + ) + .expect_err("output projectId must not hide conflicting owner-wide source registrations"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("sourceResourceId")); + + let selected = resolve_editor_background_removal_source_metadata_from_records( + projects.as_slice(), + &[], + Some("project-target"), + Some("resource-spec"), + source_key, + source_key, + ) + .expect("an explicit sourceResourceId should disambiguate across output projects"); + assert_eq!(selected.asset_kind.as_deref(), Some("spec")); + assert_eq!(selected.model.as_deref(), Some("nanobanana2")); + } + + #[test] + fn background_removal_source_metadata_rejects_cross_table_identity_conflict() { + let source_key = "generated-character-drafts/editor/shared-cross-table.png"; + let mut resource = test_editor_project_resource_record( + "resource-character", + "project-1", + source_key, + Some("gpt-image-2"), + None, + ); + resource.asset_object_id = Some("asset-object-resource".to_string()); + resource.asset_kind = Some("character".to_string()); + + let mut asset = + test_editor_asset_record("asset-character", source_key, Some("gpt-image-2"), None); + asset.asset_object_id = Some("asset-object-library".to_string()); + asset.asset_kind = Some("character".to_string()); + + let projects = vec![test_editor_project_record("project-1", vec![resource])]; + let error = resolve_editor_background_removal_source_metadata_from_records( + projects.as_slice(), + std::slice::from_ref(&asset), + Some("project-1"), + None, + source_key, + source_key, + ) + .expect_err("project resource and asset metadata must be merged for raw objectKey lookup"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("sourceResourceId")); + } + + #[test] + fn background_removal_source_model_allows_source_from_another_owned_project() { let projects = vec![ test_editor_project_record( "project-1", @@ -14428,7 +15470,7 @@ mod tests { ), ]; - let error = resolve_editor_background_removal_source_model_from_records( + let model = resolve_editor_background_removal_source_model_from_records( projects.as_slice(), &[], Some("project-1"), @@ -14436,9 +15478,9 @@ mod tests { "resource-2", "generated-character-drafts/editor/project-2.png", ) - .expect_err("project and resource must belong to the same source record"); + .expect("projectId is the output target and must not scope an owned source resource"); - assert_eq!(error.status_code(), StatusCode::FORBIDDEN); + assert_eq!(model.as_deref(), Some("gpt-image-2")); } #[test] @@ -17314,7 +18356,8 @@ mod tests { "zIndex": 6, "locked": true, "sourceAssetId": "asset-old", - "thumbnailSrc": "/old-thumb.png" + "thumbnailSrc": "/old-thumb.png", + "assetKindOverride": "icon" }, { "layerId": "layer-other", @@ -17395,6 +18438,7 @@ mod tests { assert_eq!(layer["title"], json!("源图")); assert!(layer.get("sourceAssetId").is_none()); assert!(layer.get("thumbnailSrc").is_none()); + assert!(layer.get("assetKindOverride").is_none()); let recentered = replace_editor_canvas_layer_with_resource( layers, @@ -20116,6 +21160,355 @@ mod tests { ); } + #[test] + fn editor_background_removal_canvas_target_requires_project_without_completion() { + let error = + resolve_editor_background_removal_canvas_target(None, Some("layer-source"), false) + .expect_err("target replacement must require project context"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("projectId")); + + assert!( + resolve_editor_background_removal_canvas_target(None, Some("layer-source"), true,) + .is_err(), + "targetLayerId always requires project context", + ); + assert_eq!( + resolve_editor_background_removal_canvas_target( + Some("editor-project-1"), + Some("layer-source"), + true, + ) + .expect("canvas completion should take precedence after target context validation"), + None, + ); + assert_eq!( + resolve_editor_background_removal_canvas_target( + Some(" editor-project-1 "), + Some(" layer-source "), + false, + ) + .expect("target replacement should normalize identifiers"), + Some(("editor-project-1".to_string(), "layer-source".to_string(),)), + ); + } + + #[test] + fn editor_background_removal_canvas_target_requires_existing_resource_layer() { + let resource = + editor_project_resource_for_canvas_test("resource-source", "character", 320, 240); + let layers = json!([{ + "layerId": "layer-source", + "resourceId": "resource-source", + }]); + + let target = resolve_editor_background_removal_canvas_target_source( + "bucket", + &layers, + std::slice::from_ref(&resource), + "layer-source", + ) + .expect("registered project resource layer should be valid"); + assert_eq!(target.object_key, resource.object_key.clone().unwrap()); + assert_eq!(target.asset_object_id, resource.asset_object_id); + assert_eq!(target.asset_kind.as_deref(), Some("character")); + + let missing_layer = resolve_editor_background_removal_canvas_target_source( + "bucket", + &layers, + std::slice::from_ref(&resource), + "layer-missing", + ) + .expect_err("missing target layer must fail before queueing"); + assert_eq!(missing_layer.status_code(), StatusCode::BAD_REQUEST); + + let missing_resource = resolve_editor_background_removal_canvas_target_source( + "bucket", + &layers, + &[], + "layer-source", + ) + .expect_err("target layer without a current project resource must fail"); + assert_eq!(missing_resource.status_code(), StatusCode::BAD_REQUEST); + + let video_layers = json!([{ + "layerId": "layer-source", + "resourceId": "resource-source", + "mediaType": "video", + }]); + assert!( + resolve_editor_background_removal_canvas_target_source( + "bucket", + &video_layers, + std::slice::from_ref(&resource), + "layer-source", + ) + .is_err(), + "non-image canvas targets must fail before queueing" + ); + + let video_override_layers = json!([{ + "layerId": "layer-source", + "resourceId": "resource-source", + "assetKindOverride": "video", + }]); + assert!( + resolve_editor_background_removal_canvas_target_source( + "bucket", + &video_override_layers, + std::slice::from_ref(&resource), + "layer-source", + ) + .is_err(), + "non-static assetKindOverride must fail before queueing" + ); + + let invalid_override_layers = json!([{ + "layerId": "layer-source", + "resourceId": "resource-source", + "assetKindOverride": 42, + }]); + assert!( + resolve_editor_background_removal_canvas_target_source( + "bucket", + &invalid_override_layers, + std::slice::from_ref(&resource), + "layer-source", + ) + .is_err(), + "invalid assetKindOverride must fail before queueing" + ); + } + + #[test] + fn editor_background_removal_rejects_non_static_and_conflicting_asset_kinds() { + for asset_kind in ["video", "audio", "character-animation", "image-sequence"] { + let error = + resolve_editor_background_removal_asset_kind(Some(asset_kind), None, &[], &[]) + .expect_err("non-static output kind must fail before queueing"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{asset_kind}"); + } + + let error = resolve_editor_background_removal_asset_kind( + Some("spec"), + Some("character"), + &["character".to_string()], + &[], + ) + .expect_err("request kind must not override authoritative source kind"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("权威类型")); + + assert_eq!( + resolve_editor_background_removal_asset_kind( + None, + Some("character"), + &["character".to_string()], + &["editor_generation_reference_image".to_string()], + ) + .expect("static authoritative source kind should be retained") + .as_deref(), + Some("character") + ); + } + + #[test] + fn editor_background_removal_target_must_match_source_object() { + let source = ResolvedEditorBackgroundRemovalSource { + bucket: "bucket".to_string(), + object_key: "generated-character-drafts/editor/source.png".to_string(), + model: None, + asset_object_id: Some("object-source".to_string()), + asset_kind: Some("character".to_string()), + }; + let target = EditorBackgroundRemovalTargetLayerSource { + bucket: "bucket".to_string(), + object_key: "generated-character-drafts/editor/source.png".to_string(), + asset_object_id: Some("object-source".to_string()), + resource_id: "resource-source".to_string(), + asset_kind: Some("character".to_string()), + media_type: "image".to_string(), + }; + ensure_editor_background_removal_target_matches_source(&source, &target) + .expect("same authoritative object should allow in-place replacement"); + assert_eq!( + resolve_editor_background_removal_source_resource_binding( + source.object_key.as_str(), + None, + Some(&target), + ) + .as_deref(), + Some("resource-source"), + "raw objectKey should inherit the target layer's explicit resource binding", + ); + assert_eq!( + resolve_editor_background_removal_source_resource_binding( + "resource-explicit", + None, + Some(&target), + ), + None, + "registered IDs must keep their own exact metadata lookup", + ); + assert_eq!( + resolve_editor_background_removal_source_resource_binding( + source.object_key.as_str(), + Some(" resource-explicit ".to_string()), + Some(&target), + ) + .as_deref(), + Some("resource-explicit"), + "caller-provided sourceResourceId must take precedence over target inference", + ); + + let mut different_object = target.clone(); + different_object.asset_object_id = Some("object-other".to_string()); + assert!( + ensure_editor_background_removal_target_matches_source(&source, &different_object) + .is_err(), + "matching objectKey must not override conflicting assetObjectId" + ); + + let mut source_without_object_id = source.clone(); + source_without_object_id.asset_object_id = None; + ensure_editor_background_removal_target_matches_source(&source_without_object_id, &target) + .expect("missing identity on either side should fall back to canonical location"); + + let mut different_location = target.clone(); + different_location.object_key = "generated/other.png".to_string(); + assert!( + ensure_editor_background_removal_target_matches_source( + &source_without_object_id, + &different_location, + ) + .is_err() + ); + + let mut conflicting_kind = target; + conflicting_kind.asset_kind = Some("spec".to_string()); + assert!( + ensure_editor_background_removal_target_matches_source(&source, &conflicting_kind) + .is_err(), + "same object with conflicting authoritative kinds must fail closed" + ); + } + + #[test] + fn editor_background_removal_revision_retry_rejects_changed_target_identity_or_semantics() { + let original_resource = + editor_project_resource_for_canvas_test("resource-original", "character", 320, 240); + let original_layers = json!([{ + "layerId": "layer-source", + "resourceId": "resource-original" + }]); + let expected = resolve_editor_background_removal_canvas_target_source( + "bucket", + &original_layers, + std::slice::from_ref(&original_resource), + "layer-source", + ) + .expect("original target snapshot should resolve"); + + let replacement_resource = + editor_project_resource_for_canvas_test("resource-new", "character", 320, 240); + let changed_resource_layers = json!([{ + "layerId": "layer-source", + "resourceId": "resource-new" + }]); + let error = ensure_editor_canvas_background_removal_target_resource_matches( + &changed_resource_layers, + std::slice::from_ref(&replacement_resource), + "layer-source", + Some(&expected), + ) + .expect_err("revision retry must not replace a changed target resource"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + assert!(error.body_text().contains("媒体语义已变化")); + + ensure_editor_canvas_background_removal_target_resource_matches( + &original_layers, + std::slice::from_ref(&original_resource), + "layer-source", + Some(&expected), + ) + .expect("unchanged target resource should remain replaceable"); + + let changed_override_layers = json!([{ + "layerId": "layer-source", + "resourceId": "resource-original", + "assetKindOverride": "spec" + }]); + let error = ensure_editor_canvas_background_removal_target_resource_matches( + &changed_override_layers, + std::slice::from_ref(&original_resource), + "layer-source", + Some(&expected), + ) + .expect_err("revision retry must freeze the effective assetKindOverride"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + + let changed_media_layers = json!([{ + "layerId": "layer-source", + "resourceId": "resource-original", + "mediaType": "video" + }]); + let error = ensure_editor_canvas_background_removal_target_resource_matches( + &changed_media_layers, + std::slice::from_ref(&original_resource), + "layer-source", + Some(&expected), + ) + .expect_err("revision retry must freeze the target mediaType"); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + } + + #[test] + fn editor_background_removal_queue_source_preserves_explicit_reference_ids() { + let object_key = "generated-character-drafts/editor/source.png"; + assert_eq!( + canonicalize_editor_background_removal_queued_source(object_key, object_key), + object_key, + "raw objectKey should be canonicalized for queue replay" + ); + assert_eq!( + canonicalize_editor_background_removal_queued_source( + "/generated-character-drafts/editor/source.png", + object_key, + ), + object_key, + "raw objectKey with a leading slash should be canonicalized" + ); + assert_eq!( + canonicalize_editor_background_removal_queued_source("resource-source", object_key,), + "resource-source", + "explicit project resource ID must survive queue serialization" + ); + assert_eq!( + canonicalize_editor_background_removal_queued_source("asset-source", object_key), + "asset-source", + "explicit asset ID must survive queue serialization" + ); + } + + #[test] + fn editor_background_removal_rebuilds_references_after_authoritative_source_binding() { + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); + assert_function_contains_in_order( + source, + "pub(crate) async fn remove_editor_image_background_for_owner", + "struct EditorBackgroundRemovalImage", + &[ + "payload.source_resource_id = resolve_editor_background_removal_source_resource_binding(", + "let generation_reference_source = payload", + "rebuild_editor_generation_input_references(", + "let source = resolve_editor_background_removal_source(", + ], + ); + } + #[test] fn editor_image_edit_can_complete_by_replacing_target_layer() { let source = concat!( @@ -21736,6 +23129,13 @@ mod tests { "resolve_editor_image_edit_source", "Ok(EditorImageEditQueuePayload", ), + ( + "pub(crate) async fn enqueue_editor_background_removal_for_owner", + "pub(crate) async fn remove_editor_image_background_for_owner", + "ensure_editor_reference_image_source_is_stable", + "resolve_editor_background_removal_source", + "enqueue_editor_generation_job_for_caller", + ), ( "pub(crate) async fn edit_editor_image_for_owner", "pub async fn remove_editor_image_background", @@ -21787,6 +23187,19 @@ mod tests { ); } + assert_function_contains_in_order( + source, + "pub(crate) async fn enqueue_editor_background_removal_for_owner", + "pub(crate) async fn remove_editor_image_background_for_owner", + &[ + "resolve_editor_background_removal_source(", + "ensure_editor_background_removal_target_matches_source", + "payload.source_image_src = canonicalize_editor_background_removal_queued_source(", + "payload.asset_kind = source.asset_kind.clone();", + "enqueue_editor_generation_job_for_caller(", + ], + ); + assert_function_contains_in_order( source, "pub(crate) async fn enqueue_editor_image_edit_for_owner", diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index e52daf493..0a15d26ef 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -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,13 +34,14 @@ 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_idempotent_create_id, editor_project_payload_from_record, - editor_project_resource_payload_from_record, enqueue_editor_image_edit_for_owner, + 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_idempotent_create_id, + 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, @@ -163,6 +165,37 @@ pub struct ExternalEditorProjectResourceCreateRequest { image_sequence_duration_ms: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ExternalEditorBackgroundRemovalRequest { + source_image_src: String, + project_id: Option, + target_layer_id: Option, + asset_kind: Option, + generation_inputs: Option, + asset_folder_id: Option, + asset_label: Option, + source_resource_id: Option, + canvas_completion: Option, +} + +impl From 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 { @@ -812,6 +845,39 @@ 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, + Extension(request_context): Extension, + Extension(principal): Extension, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result { + 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(); + #[cfg(test)] + if let Some(job) = state.intercept_test_external_background_removal_enqueue( + principal.owner_user_id(), + payload.source_image_src.as_str(), + idempotency_key, + ) { + return Ok(external_generation_accepted_response(&request_context, job)); + } + 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, Extension(request_context): Extension, @@ -1797,6 +1863,315 @@ 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_background_removal_route_accepts_a_valid_submission_once() { + const OWNER_USER_ID: &str = "user-external-background-removal-success"; + const SOURCE_IMAGE_SRC: &str = "editor-upload/background-removal-source.png"; + const IDEMPOTENCY_KEY: &str = "background-removal-success-contract-test"; + const OPERATION_ID: &str = "task-external-background-removal-success"; + + let state = AppState::new(crate::config::AppConfig::default()) + .expect("external background removal success test state should build"); + let mut queued_job = external_generation_job_fixture("pending"); + queued_job.job_id = OPERATION_ID.to_string(); + queued_job.job_kind = "editor_background_removal".to_string(); + queued_job.owner_user_id = OWNER_USER_ID.to_string(); + state.set_test_external_background_removal_enqueue( + OWNER_USER_ID, + SOURCE_IMAGE_SRC, + IDEMPOTENCY_KEY, + queued_job, + ); + let request_body = json!({"sourceImageSrc": SOURCE_IMAGE_SRC}).to_string(); + + let without_scope = 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( + OWNER_USER_ID, + &[], + ))) + .with_state(state.clone()); + let forbidden = without_scope + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/api/external/v1/editor/images/background-removals") + .header("content-type", "application/json") + .header(IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_KEY) + .body(Body::from(request_body.clone())) + .expect("external background removal forbidden request should build"), + ) + .await + .expect("external background removal forbidden response should return"); + assert_eq!(forbidden.status(), StatusCode::FORBIDDEN); + assert_eq!(state.test_editor_generation_enqueue_attempts(), 0); + + 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( + OWNER_USER_ID, + &[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, IDEMPOTENCY_KEY) + .body(Body::from(request_body)) + .expect("external background removal success request should build"), + ) + .await + .expect("external background removal success response should return"); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()), + Some("/api/external/v1/generations/task-external-background-removal-success") + ); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("external background removal success body should collect"); + let payload: Value = serde_json::from_slice(&body) + .expect("external background removal success body should be JSON"); + assert_eq!(payload["operationId"], json!(OPERATION_ID)); + assert_eq!(payload["status"], json!("queued")); + assert_eq!( + payload["statusUrl"], + json!(format!("/api/external/v1/generations/{OPERATION_ID}")) + ); + assert_eq!( + payload["pollAfterMs"], + json!(EXTERNAL_GENERATION_POLL_AFTER_MS) + ); + assert_eq!( + state.test_editor_generation_enqueue_attempts(), + 1, + "valid External background removal should enqueue exactly once", + ); + } + #[tokio::test] async fn external_generic_image_generation_rejects_scene_asset_kind_before_queueing() { assert_external_generic_image_scene_bypass_is_rejected_before_queueing( @@ -2018,6 +2393,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", @@ -2443,6 +2819,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") diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs index a01707084..5ee492476 100644 --- a/server-rs/crates/api-server/src/external_mcp.rs +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -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 { }; 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 { operations } +fn operation_requires_idempotency_key( + openapi: &Value, + path_item: &Map, + operation: &Map, +) -> 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, @@ -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, + 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, @@ -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() diff --git a/server-rs/crates/api-server/src/modules/external_api.rs b/server-rs/crates/api-server/src/modules/external_api.rs index ac2fbf362..9da0014fb 100644 --- a/server-rs/crates/api-server/src/modules/external_api.rs +++ b/server-rs/crates/api-server/src/modules/external_api.rs @@ -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 { 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( diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 12ee233b7..501182c91 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -27,6 +27,8 @@ use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider, OpenAiChatTokenB use platform_matting::{MattingClient, MattingConfig}; use platform_oss::{OssClient, OssConfig, OssError}; use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient}; +#[cfg(test)] +use spacetime_client::ExternalGenerationJobRecord; use spacetime_client::{ EditorGenerationModelPricingRecord, EditorGenerationPricingConfigRecord, EditorGenerationPricingConfigUpsertRecordInput, EditorGenerationPricingTierRecord, @@ -128,6 +130,15 @@ impl BackpressureState { #[derive(Clone)] pub struct AppState(Arc); +#[cfg(test)] +#[derive(Clone)] +struct TestExternalBackgroundRemovalEnqueue { + expected_owner_user_id: String, + expected_source_image_src: String, + expected_idempotency_key: String, + job: ExternalGenerationJobRecord, +} + impl fmt::Debug for AppState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_tuple("AppState").field(self.0.as_ref()).finish() @@ -256,6 +267,9 @@ pub struct AppStateInner { test_editor_generation_enqueue_attempts: AtomicUsize, #[cfg(test)] test_fail_editor_generation_enqueue: AtomicBool, + #[cfg(test)] + test_external_background_removal_enqueue: + Arc>>, oss_client: Option, #[cfg_attr(test, allow(dead_code))] auth_store: InMemoryAuthStore, @@ -611,6 +625,8 @@ impl AppState { test_editor_generation_enqueue_attempts: AtomicUsize::new(0), #[cfg(test)] test_fail_editor_generation_enqueue: AtomicBool::new(false), + #[cfg(test)] + test_external_background_removal_enqueue: Arc::new(Mutex::new(None)), oss_client, auth_store, password_entry_service, @@ -810,6 +826,46 @@ impl AppState { .load(Ordering::Acquire) } + #[cfg(test)] + pub(crate) fn set_test_external_background_removal_enqueue( + &self, + expected_owner_user_id: impl Into, + expected_source_image_src: impl Into, + expected_idempotency_key: impl Into, + job: ExternalGenerationJobRecord, + ) { + *self + .test_external_background_removal_enqueue + .lock() + .expect("test external background removal enqueue should lock") = + Some(TestExternalBackgroundRemovalEnqueue { + expected_owner_user_id: expected_owner_user_id.into(), + expected_source_image_src: expected_source_image_src.into(), + expected_idempotency_key: expected_idempotency_key.into(), + job, + }); + } + + #[cfg(test)] + pub(crate) fn intercept_test_external_background_removal_enqueue( + &self, + owner_user_id: &str, + source_image_src: &str, + idempotency_key: &str, + ) -> Option { + let fixture = self + .test_external_background_removal_enqueue + .lock() + .expect("test external background removal enqueue should lock") + .clone()?; + assert_eq!(owner_user_id, fixture.expected_owner_user_id); + assert_eq!(source_image_src, fixture.expected_source_image_src); + assert_eq!(idempotency_key, fixture.expected_idempotency_key); + self.test_editor_generation_enqueue_attempts + .fetch_add(1, Ordering::AcqRel); + Some(fixture.job) + } + #[cfg(any())] pub async fn upsert_creation_entry_type_config( &self,