diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md
index d8aa83ba8..350dca833 100644
--- a/.codex/skills/genarrative-external-editor-api/SKILL.md
+++ b/.codex/skills/genarrative-external-editor-api/SKILL.md
@@ -17,7 +17,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
2. Before the first generation in a new conversation, obtain a canvas name unless an existing `projectId` and `assetFolderId` were supplied. Create or reuse a project and a same-name asset-library folder. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec.
3. Normalize art requests into a reusable spec. Ask only for missing values that block the selected operation. Reuse the spec until the user changes its style, subject family, palette, format, or constraints.
4. Infer the operation from the user's intent. Do not ask the user to select an API unless two operations would produce materially different artifacts.
-5. If a reference exists only as a local file, upload and confirm it first. Pass the stable returned `objectKey` to generation; never substitute a temporary signed URL.
+5. If a reference exists only as a local file, upload and confirm it first. Pass the stable returned `objectKey` to operations that accept object references; never substitute a temporary signed URL. For an icon-spritesheet primary spec, additionally create a project resource or asset record with `assetKind="icon-spec"`, then pass the returned resource or asset ID as `referenceId`.
6. For generation endpoints that support the fields, include `projectId`, `assetFolderId`, an asset label, and `canvasCompletion` so the result enters both the canvas and its same-name library folder.
7. Treat every generation POST as asynchronous. Send one stable `Idempotency-Key` per logical request, retain the returned `operationId`, and poll the returned `statusUrl` or `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`.
8. Consume `result` only after `status=completed`. On `failed`, surface the safe error. On a client timeout or lost response, retain the operation/key; do not create a replacement request.
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 7d72793d1..30d486c6f 100644
--- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md
+++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md
@@ -51,7 +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`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` |
-| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
+| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `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` |
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
@@ -77,19 +77,21 @@ Supply the `operationId` returned by submission. Poll no faster than `pollAfterM
## Reference Field Mapping
-After confirming a local upload, pass its stable `objectKey` into:
+After confirming a local upload, pass its stable `objectKey` into operations that accept object references:
| Target capability | Field |
| --- | --- |
| Image generation | `referenceImageSrcs` |
| Image edit/redraw | `sourceImageSrc`; additional references in `referenceImageSrcs` |
-| Icon spritesheet | `referenceImageSrc`; additional style references in `referenceImageSrcs` |
+| Icon spritesheet | Register the primary spec as an `assetKind="icon-spec"` project resource or asset, then pass its returned ID as `referenceId`; additional style references remain in `referenceImageSrcs` |
| UI design extraction | `sourceImageSrc`; additional references in `referenceImageSrcs` |
| Character animation | `sourceImageSrc` |
| Video with image references | `referenceImageSrcs` |
Use video/audio reference arrays only with models that support them. Do not pass an expiring signed read URL as a generation reference.
+The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL.
+
## Common Values
Use OpenAPI as the final authority; these common values are a routing aid:
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 82e70f48d..855cc0f16 100644
--- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
+++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
@@ -78,7 +78,7 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
-3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceImageSrc` plus concrete `iconDescriptions`.
+3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`.
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `
`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
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 2a2bf4abf..03c69a94f 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
@@ -169,6 +169,8 @@ client.generate_image(
)
```
+Icon spritesheet generation has a stricter primary-spec contract. After upload confirmation, create a project resource or asset record with `assetKind: "icon-spec"`, retain its returned `resourceId` or `assetId`, and pass that ID as `referenceId`. The primary spec does not accept the uploaded `objectKey` directly; only additional style references may continue to use stable object keys in `referenceImageSrcs`.
+
For character animation from a local-only source, use actual dimensions and a stable synthetic layer ID:
```json
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 8cdb21e49..3d3f29440 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
@@ -560,10 +560,13 @@ class GenarrativeExternalClient:
def generate_icon_spritesheet(
self,
- reference_image_src: str,
+ reference_id: str,
icon_descriptions: list[str],
**fields: Any,
) -> Any:
+ reference_id = normalize_optional_text(reference_id)
+ if not reference_id:
+ raise GenarrativeApiError("reference_id must be a registered icon-spec resource or asset ID")
descriptions = [item.strip() for item in icon_descriptions if item.strip()]
if not descriptions:
raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item")
@@ -574,9 +577,9 @@ class GenarrativeExternalClient:
return self.submit_and_wait_generation(
"/api/external/v1/editor/icon-spritesheets/generations",
{
- "referenceImageSrc": reference_image_src,
- "iconDescriptions": descriptions,
**fields,
+ "referenceId": reference_id,
+ "iconDescriptions": descriptions,
},
idempotency_key=idempotency_key,
)
@@ -772,9 +775,11 @@ def _self_test() -> None:
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
canvasSession=session,
assetLabel="贪吃蛇透明图集",
+ referenceId="must-not-override-explicit-reference",
+ iconDescriptions=["不得覆盖显式图标描述"],
)
assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations"
- assert calls[0]["body"]["referenceImageSrc"] == "editor-resource-spec"
+ assert calls[0]["body"]["referenceId"] == "editor-resource-spec"
assert calls[0]["body"]["screenColor"] == "auto"
assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上"
assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo"
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
index 5c9613bdd..ffa9d901e 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
@@ -1240,13 +1240,13 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
.then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id))
.transpose()?;
let (endpoint, request_body) = if is_canonical_art_spritesheet {
- let reference_image_src = canonical_reference
+ let reference_id = canonical_reference
.as_deref()
.ok_or_else(|| "透明美术图集缺少规范图引用".to_string())?;
(
"/api/external/v1/editor/icon-spritesheets/generations",
serde_json::json!({
- "referenceImageSrc": reference_image_src,
+ "referenceId": reference_id,
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
"screenColor": "auto",
"aspectRatio": options.aspect_ratio,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs
index 0de156f4f..b1e1759c9 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs
@@ -344,11 +344,10 @@ pub(super) fn platform_art_generation_runtime_request_snapshot(
(generation_kind, reference_resource_ids)
}
"/api/external/v1/editor/icon-spritesheets/generations" => {
- let reference_resource_id = json_string_field(&request_body, "referenceImageSrc")
+ let reference_resource_id = json_string_field(&request_body, "referenceId")
+ .or_else(|| json_string_field(&request_body, "referenceImageSrc"))
.filter(|value| !value.trim().is_empty())
- .ok_or_else(|| {
- "External Editor 图集生成账本请求缺少 referenceImageSrc".to_string()
- })?;
+ .ok_or_else(|| "External Editor 图集生成账本请求缺少 referenceId".to_string())?;
("icon-spritesheet".to_string(), vec![reference_resource_id])
}
_ => return Err("External Editor 生成账本 endpoint 不受支持".to_string()),
@@ -868,6 +867,35 @@ mod external_generation_state_tests {
)
.expect("prepare generation ledger");
assert!(created);
+ let mut spritesheet_state = prepared.clone();
+ spritesheet_state.endpoint =
+ "/api/external/v1/editor/icon-spritesheets/generations".to_string();
+ spritesheet_state.request_body_json = serde_json::json!({
+ "referenceId": "resource-icon-spec",
+ "iconDescriptions": ["玩家主体"],
+ "projectId": "canvas-project",
+ "assetFolderId": "asset-folder"
+ })
+ .to_string();
+ assert_eq!(
+ platform_art_generation_runtime_request_snapshot(&spritesheet_state)
+ .expect("parse current icon spritesheet ledger")
+ .reference_resource_ids,
+ vec!["resource-icon-spec"]
+ );
+ spritesheet_state.request_body_json = serde_json::json!({
+ "referenceImageSrc": "legacy-resource-icon-spec",
+ "iconDescriptions": ["玩家主体"],
+ "projectId": "canvas-project",
+ "assetFolderId": "asset-folder"
+ })
+ .to_string();
+ assert_eq!(
+ platform_art_generation_runtime_request_snapshot(&spritesheet_state)
+ .expect("parse already persisted legacy icon spritesheet ledger")
+ .reference_resource_ids,
+ vec!["legacy-resource-icon-spec"]
+ );
let mut invalid_key = prepared.clone();
invalid_key.idempotency_key = "invalid key".to_string();
assert!(
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs
index 7a5dc9cc4..1f3c3d5cd 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs
@@ -519,7 +519,7 @@ fn game_creator_art_asset_plan_tool_plan_prompt(
);
}
format!(
- "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。"
+ "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceId,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。"
)
}
@@ -1224,7 +1224,7 @@ mod tests {
assert!(with_canvas.contains("由 Runtime 形成 iconDescriptions"));
assert!(with_canvas.contains("玩家主体及朝向/状态"));
assert!(with_canvas.contains("不得假设为塔防"));
- assert!(with_canvas.contains("权威 resourceId 作为 referenceImageSrc"));
+ assert!(with_canvas.contains("权威 resourceId 作为 referenceId"));
assert!(with_canvas.contains("POST /api/external/v1/editor/icon-spritesheets/generations"));
assert!(with_canvas.contains("不得回退普通生图或 UI extraction"));
assert!(with_canvas.contains("screenColor=auto"));
diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs
index 64e1785db..86e58bedc 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs
@@ -103,7 +103,7 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() {
"assetKind=art-spritesheet",
"用 asset.list 确认 assets/art-spec.png 已登记",
"由 Runtime 形成 iconDescriptions",
- "权威 resourceId 作为 referenceImageSrc",
+ "权威 resourceId 作为 referenceId",
"POST /api/external/v1/editor/icon-spritesheets/generations",
"screenColor=auto",
"不得回退普通生图",
diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs
index 5595b49cb..71e092fb7 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs
@@ -845,7 +845,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
"fixture should exercise queued, running, and completed states"
);
for expected in [
- r#""referenceImageSrc":"resource-icon-spec""#,
+ r#""referenceId":"resource-icon-spec""#,
r#""iconDescriptions":"#,
r#""screenColor":"auto""#,
r#""projectId":"canvas-project-1""#,
@@ -859,6 +859,10 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
"generation request missing {expected}: {generation_request}"
);
}
+ assert!(
+ !generation_request.contains(r#""referenceImageSrc":"#),
+ "generation request must not send the retired primary reference field: {generation_request}"
+ );
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
index 628a40d29..c52a060e3 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
@@ -7313,11 +7313,14 @@ export function registerProjectSupervisorSurfaceTests() {
await Promise.resolve();
});
- await waitFor(() => {
- expect(within(surface).getByLabelText('最新状态').textContent).toContain(
- '项目权限策略拒绝执行:preview.start',
- );
- });
+ await waitFor(
+ () => {
+ expect(within(surface).getByLabelText('最新状态').textContent).toContain(
+ '项目权限策略拒绝执行:preview.start',
+ );
+ },
+ { timeout: 2500 },
+ );
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json
index 74a73eba0..e1dad4a14 100644
--- a/docs/openapi/genarrative-external-v1.openapi.json
+++ b/docs/openapi/genarrative-external-v1.openapi.json
@@ -3650,29 +3650,32 @@
"EditorIconSpritesheetGenerationRequest": {
"type": "object",
"required": [
- "referenceImageSrc",
+ "referenceId",
"iconDescriptions"
],
"properties": {
- "referenceImageSrc": {
+ "referenceId": {
"type": "string",
- "description": "图标规范的稳定引用:当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。"
+ "description": "当前账号中已登记为 icon-spec 的项目资源 ID 或素材 ID。只接受 ID,不接受 objectKey、图片 URL、Data URL 或 Blob URL。"
},
"referenceImageSrcs": {
"type": "array",
"items": {
"type": "string",
- "description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
+ "description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceId 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
},
"maxItems": 8
},
"iconDescriptions": {
"type": "array",
- "description": "图标生成需求文本数组,供 prompt 组装使用;数组长度不控制自动切片数量。画布前端把完整用户提示词作为唯一数组元素提交;其它调用方可继续提交 1 到 100 条非空文本。",
+ "description": "图标生成需求文本数组,供 prompt 组装使用;数组长度不控制自动切片数量。画布前端把完整用户提示词作为唯一数组元素提交;其它调用方可继续提交 1 到 100 条非空文本。服务端去空并以换行拼接后,单条最多 200 个 Unicode 字符,合计最多 2000 个 Unicode 字符且不超过 6144 个 UTF-8 字节;超限同步返回 400,不会入队或计费。",
"minItems": 1,
"maxItems": 100,
+ "x-genarrative-maxTotalCharacters": 2000,
+ "x-genarrative-maxTotalUtf8Bytes": 6144,
"items": {
- "type": "string"
+ "type": "string",
+ "maxLength": 200
}
},
"screenColor": {
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 623089891..b42d1a4b5 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -5837,7 +5837,7 @@
## 2026-07-28 AI 游戏创作正式视觉规范与透明 spritesheet DAG
- 16-task 边界:继续复用现有 seed manifest 的 `art-director / design-foundation / art-asset-plan` 三个任务,不新增平行任务、会话或素材系统。`art-director` 是正式视觉规范前置:先通过 `/api/external/v1/editor/images/generations` 的 `kind=spec` 生成 `assets/art-spec.png`,并登记为 `assetKind=icon-spec`。`generationInputs.artSpec` 只是辅助结构化上下文,不能替代这张真实规范图。
-- 路由与依赖:`design-foundation` 以已登记 `assets/art-spec.png` 的 External Editor 稳定资源 ID 作为 `referenceImageSrcs` 中的视觉规范参考,通过 `/api/external/v1/editor/images/generations` 的 `kind=ui-design` 生成完整 `assets/ui-prototype.png`;`art-asset-plan` 以同一 art-spec 资源 ID 作为必填 `referenceImageSrc`,调用 `/api/external/v1/editor/icon-spritesheets/generations`,提交具体 `iconDescriptions` 与 `screenColor=auto` 生成透明 `assets/art-spritesheet.png`。严禁把 `assets/ui-prototype.png` 当作规范图引用;`art-spec.png` 缺失、不是当前画布的 `icon-spec` 或缺少稳定 `resourceId` 时,两个下游任务都必须等待 `art-director`,不得把本地路径、Data URL / Blob URL 当成稳定引用,也不得退回普通生图。单波最多 `3` 个静态职责的资源上限保持不变,调度只调整现有任务的依赖边和就绪顺序。
+- 路由与依赖:`design-foundation` 以已登记 `assets/art-spec.png` 的 External Editor 稳定资源 ID 作为 `referenceImageSrcs` 中的视觉规范参考,通过 `/api/external/v1/editor/images/generations` 的 `kind=ui-design` 生成完整 `assets/ui-prototype.png`;`art-asset-plan` 以同一 art-spec 资源 ID 作为必填 `referenceId`,调用 `/api/external/v1/editor/icon-spritesheets/generations`,提交具体 `iconDescriptions` 与 `screenColor=auto` 生成透明 `assets/art-spritesheet.png`。严禁把 `assets/ui-prototype.png` 当作规范图引用;`art-spec.png` 缺失、不是当前画布的 `icon-spec` 或缺少稳定 `resourceId` 时,两个下游任务都必须等待 `art-director`,不得把本地路径、Data URL / Blob URL 当成稳定引用,也不得退回普通生图。单波最多 `3` 个静态职责的资源上限保持不变,调度只调整现有任务的依赖边和就绪顺序。
- UI extraction 边界:`/api/external/v1/editor/ui-designs/assets/extractions` 只适用于已有且带红框标注的 UI 设计图,不是 UI 设计图生成接口,也不进入本次 canonical DAG。后续若要生成独立 UI spritesheet,必须先补红框源图生成与正式产物合同,不得直接对无标注 `ui-prototype.png` 调用 extraction。
- 完成门禁:External Editor 2xx 只表示生成请求完成。通用 `warning` 优先于 `sliceWarning`;`postprocess-failed-source-preserved` 表示 provider 源图是唯一权威结果,但不满足透明图集合同,客户端保留服务端事实并失败关闭,不登记本地正式 spritesheet、不伪造切片、不自动重跑。仅 `sliceWarning` 时完整透明图集有效,Runtime 把原始 reason 写入私有审计与 Agent observation,但不得声称独立切片存在。下载结果还必须解码并至少包含一个真实透明像素,纯 RGB 或全不透明 RGBA 一律拒绝落盘和 manifest 登记。
- OpenAPI 同步事实:2026-07-28 从 `https://www.genarrative.world/api/external/v1/openapi.json` 获取的线上合同与 `docs/openapi/genarrative-external-v1.openapi.json` 原始 SHA-256 均为 `00fa39ea8781605b895b331a579fbf097eea7892962350cb2a82bed7e1024135`,逐字节一致,因此不制造无意义 JSON diff;实现按现有公开 `screenColor`、`assetLabel`、`warning` 与 `sliceWarning` 契约更新。
@@ -6763,6 +6763,21 @@
- 升级前 External 幂等任务可能仍在 payload 中保留客户端 references;重放比较只对白名单内已迁移的图片生成、图片修改、去背景、图标图集和 UI 提取任务,在旧侧有 references、当前侧已删除时移除旧字段,其他字段变化仍返回 `409`。音频 / 视频 / 角色动作等未迁移 job kind 始终完整比较,不能扩大兼容面。
- 本次复用既有 `external_generation_job.dedupe_key` 唯一索引和 `spacetime-client` 查询,不改 SpacetimeDB schema、迁移或 bindings。
+## 2026-08-05 图标规范生成拆分请求预检与最终执行,图集规范引用改为窄事务查询
+
+- 图标规范 HTTP handler 在调用文本 LLM 前,先通过可复用图片请求预检完成参考图稳定性、owner 授权、Provider 配置与运行时定价校验;随后补齐 `ExtraParam` 与最终 prompt,并继续走既有 `editor_image_generation` inline / queue 分流,不新增图标规范专用 worker job。最终执行仍重新校验请求,以处理排队期间发生的权限或资源变化。
+- 图标图集主规范引用由通用 SpacetimeDB procedure `resolve_editor_reference_and_return` 在同一事务快照内解析和校验 owner。ID 使用资源 / 素材主键;objectKey 按规范化 `image_src="/"` 索引读取唯一行,并使用 `asset_object(bucket, object_key)` 复合索引校验对象 owner。procedure 不接收业务 / 存储类型 allowlist,复用既有 `EditorProjectResourceSnapshot` / `EditorAssetSnapshot` 返回完整单行,不新增图标专属 DTO,也不再拉取完整项目和素材库;`icon-spec` 业务类型与游戏类型由 API 业务代码校验和提取。缺失引用、跨 owner、asset object 不匹配、业务类型错误、元数据解析与数据库错误全部失败关闭;仅“合法记录没有 genre”允许返回 `None`。
+- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
+
+## 2026-08-06 图标规范与图标图集使用独立任务及 ID-only 主规范引用
+
+- 本条取代上一条“不新增图标规范专用 worker job”和“图标图集主规范允许 objectKey”的结论。图标规范生成使用独立 `editor_icon_spec_generation` job kind;队列保存原始强类型业务参数,worker 在统一计费操作预扣成功后执行 ExtraParam LLM、构建最终 prompt,再进入共享图片生成和既有后处理 / 持久化链。SpacetimeDB 编辑器生成结果 operation kind 白名单必须显式包含该独立 job kind,确保 Provider 成功后可以进入统一 durable receipt 原子提交;新增正式生成 job kind 时必须同步扩展白名单和模块回归。余额不足不调用文本 LLM,执行失败沿用统一退款;普通 `editor_image_generation` 的 DTO、payload 与执行行为保持不变。
+- `editor_project_icon.rs` 独立承接图标规范、图标 spritesheet、自动切片和手工拆分。普通图片模块只暴露可复用的 prompt builder 执行入口与强类型 provider 请求分流;nanobanana、无参考图 generation、有参考图 edit 的选择集中在同一个 helper,图标图集复用该 helper,不复制 provider 分支。
+- 图标规范生成的可选参考图与图标图集的必选主规范统一使用 `referenceId`,只接受当前 owner 的项目资源 ID 或账号素材 ID,不接受 objectKey、URL 或临时 key。SpacetimeDB `resolve_editor_reference_and_return` 因而只接收 `reference_id` 并按两张表主键查询,删除 `image_src` 二级索引。图标图集的普通附加参考图 `referenceImageSrcs` 仍允许 owned objectKey、项目资源 ID 或素材 ID,并继续走既有 owner 校验与真实图片下载链。
+- External v1 图标图集请求与 OpenAPI 同步改为必填 `referenceId`。前端和 Editor Agent 优先传正式 resource ID,其次传 asset ID;本地临时 ID、objectKey 和图片地址不得回退成主规范引用,未登记时明确失败并要求先上传或登记。
+- `resolve_editor_reference_and_return` 的歧义只在当前 owner 范围内判断:两张表先按 `owner_user_id` 过滤,同名但属于其它账号的行不阻断合法引用。记录存在 `asset_object_id` 时按该 ID 定位并同时核对 bucket、object key 与 owner,只有缺少 ID 的兼容旧行才按位置查询。图标规范入队 / inline 预检只验证这组行与对象元数据,最终共享图片执行器再下载一次参考图正文,避免同一引用在入队、worker 预检和生成阶段重复下载。
+- 关联:`server-rs/crates/api-server/src/editor_project_icon.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`docs/openapi/genarrative-external-v1.openapi.json`。
+
## 2026-08-06 音效与背景音乐恢复共享音频 Composer
- 决策:图片画布的 `audio-sound-effect` 与 `audio-background-music` 只保留一个 `ImageCanvasAudioGenerationComposerView`,组件内以 `isSoundEffect = dialog.mode === 'audio-sound-effect'` 分流。撤销的是完整 `ImageCanvasBackgroundMusicGenerationComposerView` 这一层视图拆分,不撤销 BGM Prompt 纯模型、助手 controller、预设模型或预设跑马灯的独立职责。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index c77ee2053..68dd5e621 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -397,7 +397,7 @@
- 现象:画板生成、快速编辑、图标素材或 UI 素材提取如果允许直接提交 generated objectKey,用户只要知道其他账号的私有 objectKey,就可能让 api-server 签名读取并送给外部生成供应商。
- 原因:Data URL/Blob URL 只允许停留在浏览器临时态,正式编辑器引用必须先上传并经统一 resolver 校验归属。
-- 处理:所有私有对象引用在读取字节或签发 URL 前统一走 `resolve_editor_reference_object_key_for_owner(state, owner_user_id, source)`,先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key。只有确实需要图片字节的入口(生成 / 重绘 / 图标 / UI 提取等交给 provider 的路径)再走 `parse_editor_reference_image`(内部仍先 resolve,再下载 OSS 字节);手动去背景等只签发短期 URL 的入口不要 `parse` 整图。手动去背景还要恢复源模型时,object key 解析、所有权校验和源模型回溯必须复用同一轮账号项目 / 素材快照,项目与素材快照各最多读取一次;不得先走通用 resolver 全量读取,再为模型回溯重复拉取完整画布和素材库。图标素材等额外参考图必须真实传到 provider,不只写 metadata;图片快速编辑当前不开放额外参考图,若后续重开入口也必须沿用同一归属校验。
+- 处理:普通图片、重绘、图标图集附加参考图和 UI 提取等允许 objectKey 的私有对象引用,在读取字节或签发 URL 前统一走 `resolve_editor_reference_object_key_for_owner(state, owner_user_id, source)`,先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key。只有确实需要图片字节的入口再走 `parse_editor_reference_image`(内部仍先 resolve,再下载 OSS 字节);手动去背景等只签发短期 URL 的入口不要 `parse` 整图。图标规范生成的可选参考图与图标图集的主规范引用是更窄的业务契约:只接受正式 `referenceId`(项目资源 ID / 素材 ID),不得回退 objectKey、URL 或临时 key;图标图集的额外 `referenceImageSrcs` 才继续沿用通用 owned objectKey 规则。手动去背景还要恢复源模型时,object key 解析、所有权校验和源模型回溯必须复用同一轮账号项目 / 素材快照,项目与素材快照各最多读取一次;不得先走通用 resolver 全量读取,再为模型回溯重复拉取完整画布和素材库。图标素材等额外参考图必须真实传到 provider,不只写 metadata;图片快速编辑当前不开放额外参考图,若后续重开入口也必须沿用同一归属校验。
- 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入图标生成请求;若快速编辑重开额外参考图,再补对应请求覆盖。
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`。
@@ -4012,7 +4012,7 @@
## 画布 Agent 的规划请求不能关闭瞬时失败重试
- 现象:美术 Agent 对话返回红色错误气泡 `completion error: LLM 请求超时,累计尝试 1 次`;HTTP 本身仍返回 200,前端 20 分钟 transport timeout 没有触发。
-- 原因:规划请求虽然有 Agent 专用单次 timeout,但 `editor_agent_llm_client` 把 `max_retries` 硬编码为 0;VectorEngine `gpt-5.4-mini` 的偶发长尾、连接超时或可重试上游状态会在第一次失败后直接持久化成 system error。framework 的英文 `completion error` 前缀也被原样暴露给用户。
+- 原因:规划请求虽然有 Agent 专用单次 timeout,但 `vector_engine_llm_client` 把 `max_retries` 硬编码为 0;VectorEngine `gpt-5.4-mini` 的偶发长尾、连接超时或可重试上游状态会在第一次失败后直接持久化成 system error。framework 的英文 `completion error` 前缀也被原样暴露给用户。
- 处理:120 秒改为前端软提示阈值:POST 仍 pending 时显示不入库的“仍在处理中,请耐心等待”;provider 明确断开/失败才写正式错误。专用 provider 单 attempt 使用 8 分钟 hard timeout,请求发起阶段读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次且重试退避最多 60 秒。不要只计算单次 complete 的最坏时间:runner 还可因非法 JSON/工具校验失败进入后续轮次,必须从 handler 入口开始计算 18 分钟总 deadline,进入 `agent.prompt(...)` 时扣除会话锁/上下文准备已用时间,为持久化和前端 20 分钟 timeout 留出余量。响应头后的体读取/解析错误按明确失败收口,必须使用真实 attempt 计数;规划、配置和定价错误对用户统一为中文,原始诊断只记后端日志。重试发生在任何生成工具执行前,不会重复提交生成任务或扣费,不要通过提高前端 timeout 或 runner `max_turns` 掩盖 provider 重试缺失。
- 验证:`platform-editor-agent` 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;`platform-llm` 回归用例锁定第二次 attempt 成功响应头后的 body timeout 仍报累计 2 次;`api-server` 测试锁定专用 client retry、18 分钟整体 deadline 与中文直达错误。运行态排障按同一 request id 对齐 `platform_llm` failure stage 与 `/messages` 总耗时,并确认仍 pending 的请求不再在 120 秒形成错误气泡。
- 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs`、`server-rs/crates/platform-agent-harness/src/error.rs`、`server-rs/crates/api-server/src/state.rs`、`src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts`、`src/components/image-editor/EditorAgentConversation/MessageBubble.tsx`、`src/services/image-editor/editorAgentClient.ts`。
diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
index b06bc3a5a..1fdb9bd7a 100644
--- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
+++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
@@ -28,6 +28,7 @@
- 手动去背景、角色动作透明化、音效和背景音乐生成失败时,前端只消费后端按任务类型返回的稳定业务文案;不得从队列 `lastErrorMessage`、HTTP error details 或图层状态恢复和展示 BgFilter、分割模型、provider、请求端点、传输错误或上游状态等内部诊断。
- BgFilter 单次真实 provider attempt 不再使用独立固定 timeout,而由父子共同按 `attempt = N × est × 2` 运行时派生;一次逻辑调用的 `callBudgetMs = 2 × attempt + 1s`,从子 worker 取得 provider permit 后才开始计时。当前冻结 `N=16 / est=5000ms` 时为 `160s / 321s`。父侧继续管理父 job / request 总预算,为每个内部 RPC 单独派生 `maxQueueWaitMs`;排队只消耗该字段,不侵蚀 `callBudgetMs`,flat 还需预留阿里云和本地键色 fallback 时间。角色动作不再按本次实际帧数增加 attempt,`32 / 40 / 48` 帧使用同一公式。角色动画继续用 `buffer_unordered(frame_count.max(1))` 同时提交单帧逻辑调用,由唯一子 worker 保证健康进程内实际在飞的 provider 请求不超过 `N`、admission 不超过默认保险丝 `Q=2048`;父流程仍按“对应绿幕源图上传 OSS 并释放原帧字节 → 以 object key 调内部 worker / 按 object key 降级 → 父侧完成透明帧处理并落 OSS”连续组成无序在途流水线,允许响应乱序,并在收口时 collect / drain 全部已提交 frame future、按 `frameIndex` 恢复顺序。任一帧最终失败时仍先排空全部已启动请求,再使整个动作任务失败退款,不发布缺帧动画;最终图片处理、OSS、画布写回和计费始终属于父流程。
- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS。透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,三类任务同时把 provider 原图作为第二个图层放在透明主结果右侧,图标和 UI 的实际拆分素材从 provider 原图右侧开始放置。透明背景处理最终失败时,只把已保存的原图作为唯一主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因。它与 `sliceWarning` 只在 `postprocess-failed-source-preserved` 这一条上互斥(透明背景最终失败不会进入拆分);风格归一化和像素规整产生的通用 `warning` 可与 `sliceWarning` 并存,此时 inline 与队列两条链路都必须按“通用在前、拆分在后”拼成同一条提示展示,不得只取其一。既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把归一后的字符串交给 BFF `warning` 由前端直接展示。无项目上下文时不创建项目资源或画布图层。
+- 单产物画布生成同样以后端项目布局为唯一真相:普通图片、图标规范、视频和音频在项目画布生成器中提交 `canvasCompletion` 后,前端只应用即时响应携带的项目快照,或在队列完成后重新加载项目。即时响应没有携带项目快照时,前端把对应生成器恢复为可继续操作状态,但不得再用响应媒体本地补建结果图层;上述分支只有在没有项目上下文的临时画布生成中才允许走本地落图回退。
- 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。完整图标图集 `icon-spritesheet` 支持快速编辑,拆分后的单个 `icon` 不提供该入口,前后端必须使用同一素材类型规则。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并在错误红框中显示具体错误文案。
- 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。
- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或手动去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。
@@ -114,6 +115,14 @@
- 角色、图标 spritesheet 与 UI 素材提取共用 provider 原图 source-only 收口:BgFilter 最终失败、Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败或透明图完整解码失败时,只把已保存 provider 原图作为唯一主图完成占位,以 `completed + warning` 收口且不退款;图标 / UI 固定 `iconImageSrcs=[]`、`sliceWarning=null`,不写透明图、不拆分,也不创建透明图或切片画布层。provider 原图本身无法完整解码时在首次持久化前失败,不得用 `512×512` 伪造元数据。图标自动拆分、手动拆分和 UI 提取共用有界处理链:全部 flood-fill 原始连通域最多 `4096` 个,辅助部件合并使用空间网格邻近候选,`maxOutputSlices=64` 和所有 padding 后 crop 的总像素预算都在首片 PNG 编码前检查。prepare 只保留一张 RGBA 与排好序的 bounds,不再一次返回最多 `64` 份 PNG;api-server 按需编码并以容量 `2` 的有界管线上传,使单个图集同时只保留整图和最多两份切片 PNG。CPU 工作继续受 2 路 semaphore、30 秒本地上限与请求 deadline 保护;独立内存 admission 从 prepare 持有到最后一片上传结束,慢 OSS 不得占用 CPU permit,也不得绕过内存限制堆积新批次。自动超限保留可信透明整图、返回稳定 `sliceWarning` 且不写任何切片;手动超限在首次持久化前返回 `422`。
- `GET /api/editor/projects/recent`:读取当前用户最近编辑的图片画布工程,没有则返回 `project: null`。
+- 图标规范专用链路(2026-08-04)取代本文旧的前端 prompt / `ui` 规范分支口径。前端规范类型只允许 `character / icon / custom`;图标规范表单状态和请求字段使用 `playSetting / artStyle`,对应 Rust 字段为 `play_setting / art_style`,界面与 `generationInputs.fields[]` 标题继续使用「玩法设定 / 美术风格」。恢复历史画布快照时必须把旧 `specType="ui"` 迁移为 `icon`,运行时类型守卫不得继续把 `ui` 当成现役类型。
+- `POST /api/editor/llm/icon-specs/refine-game-play` 与 `POST /api/editor/llm/icon-specs/refine-art-style`:分别接收 `{ playSetting }` / `{ artStyle }`,由 api-server 使用 VectorEngine LLM client 构造业务 prompt。两个字段各自维护处理中和单次撤销快照;一项处理中不锁另一项,但任一项处理中禁止提交生成。活动生成对象的请求身份只在 React effect 提交后更新,并在 effect cleanup 中失效,丢弃的并发 render 不得改变当前请求归属。
+- 图标规范的 `playSetting / artStyle` 单字段上限统一为 `200` 个 Unicode 字符。浏览器原生 `maxLength` 按 UTF-16 码元计数,与该业务口径不一致,因此图标规范文本域不设置 `maxLength`,只通过按 Unicode 字符截断的 `onChange` 和提交校验限制输入;非法恢复态禁用优化与生成。editor client 在优化请求、优化响应和最终生成提交前再次拒绝超长值;api-server 对两个优化入口和最终图标规范生成入口都执行同一上限校验。LLM 优化请求固定 `1024` 输出 token 上限,容纳推理开销同时限制最多三次调用的输出成本;平台层返回 `EmptyResponse` 时按瞬态空结果进入同一有界重试。LLM 优化结果必须是无标题、解释、Markdown 或 JSON 的可直接使用纯文本,允许分段和换行;非法格式与空文本、超长文本一样作为非法模型输出重试,第三次仍非法返回 `502`。
+- 上述两个 refine 调用与生成前的 `ExtraParam` 补全均最多执行 3 次完整 LLM 尝试;空文本、格式非法或补全结果非法 JSON 在次数内重试。调用错误只对 timeout、connectivity、transport、上游 `408 / 429 / 5xx` 重试,配置、请求、上游其它 `4xx` 等永久错误立即返回。业务层执行重试时关闭 `LlmClient` 自身的内层重试,避免配置重试与业务重试相乘。
+- `ExtraParam` 补全 prompt 只把 `playSetting / artStyle` 作为待分析数据,要求 LLM 直接返回且只返回 `{ genre, theme, useCase, targetUser }` JSON 对象。`genre` 必须取 `GameGenre::as_slug()` 定义的 19 个中文值之一,`GameGenre` 的自定义 Serde 也统一按该中文值读写,不接受英文枚举名;`theme` 是可组合、可扩展的中文题材;`useCase` 表示 `PC / mobile / console / Web / handheld` 等实际调用平台;`targetUser` 为结合玩法与美术推断的自由中文用户描述。四项均为非空且不超过 `200` 个 Unicode 字符的字符串,解析后去除首尾空白,不接受 Markdown、数组、`null` 或额外字段。
+- `POST /api/editor/icon-specs/generations`:业务字段只有 `playSetting / artStyle`;可选参考图使用 `referenceId`,只接受当前 owner 的项目资源 ID 或素材 ID,项目、素材文件夹和 `canvasCompletion` 继续使用统一生成包络。前端不得提交最终 prompt、`kind` 或 `assetKind`。inline 路径在校验业务字段和 `referenceId` 后补全 `ExtraParam`、构造最终 prompt,再固定以 `kind=spec / assetKind=icon-spec / gpt-image-2 / 16:9·2K` 进入共享图片生成执行器。queue 路径在同样的请求与引用预校验后,按 `gpt-image-2 / 2K` 运行时定价冻结入队价格,并使用独立 `editor_icon_spec_generation` job kind 保存原始业务载荷;worker 在执行时重新解析该载荷、校验当前 owner 与引用事实,再补全 `ExtraParam` 并进入同一共享图片生成执行器;worker 计费上下文使用入队时冻结的价格与当前 claim attempt,不在执行时因定价配置变化改价。服务端重建 `generationInputs.fields[]` 为「玩法设定 / 美术风格 / 游戏类型」,其中游戏类型保存 `GameGenre::as_slug()` 返回的中文值;`theme / useCase / targetUser` 只参与 prompt,不进入 metadata。
+- 上述图标规范 refine、参数补全、最终图片 prompt 和 genre 映射的准确业务文本仍是延期输入。在文本到位前,未完成的 prompt builder 必须直接使用 Rust `todo!()` 标记,不得自造 fallback 或伪错误协议;其它类型、路由、队列、UI 和测试框架继续保持可验证。
+- 图标 spritesheet 的主规范必须通过 `referenceId` 提交,只接受当前 owner 的项目资源 ID 或素材 ID,不接受 `objectKey`、URL、临时 key 或 `referenceImageSrc` 作为主规范引用。服务端只按 ID 窄查询匹配当前账号 `assetKind=icon-spec` 的项目资源或素材,并且只读取其 `generationInputs.fields[]` 中标题精确等于「游戏类型」的 genre slug;不读取其它玩法字段,不沿 `sourceResourceId` 链推断。prompt 必须使用本次实际键色限制主体描边、底板、投影、发光和反光,不得硬编码绿色;按用户描述顺序一一生成,不要求图标数为二的幂,也不得遗漏或补充;相邻图标之间必须保持空白,禁止描边、底板、阴影、装饰或特效连接,以保留可拆分边界。普通附加参考图仍使用独立的 `referenceImageSrcs`,不与主规范字段混用。
- `GET /api/editor/projects`:读取当前用户所有图片画布工程,按更新时间倒序返回。
- `POST /api/editor/projects`:创建图片画布工程。
- `GET /api/editor/projects/{projectId}`:读取指定工程及资源列表。
@@ -135,7 +144,7 @@
- `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程在持久化带纯色背景原图前先将回图归一到业务交付尺寸,再以该原图的 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。带背景原图和透明结果必须使用同一实际像素尺寸,1K 的长边固定为 `1024`;若 provider 回图不允许无放大地恢复到业务尺寸,两张图一同保留 provider 实际尺寸并返回通用 `warning`。透明处理结果发生尺寸漂移时,只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并应用回已归一原图 RGB;蒙版比例超限、回贴失败或尺寸验证失败时不保存透明图,只以已保存原图和同时保留尺寸原因的通用 `warning` 完成画布。最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。
- `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。
-- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和完整用户需求生成 spritesheet;为兼容现有契约,画布前端把完整文本作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。切片只在有界管线中按需编码,共享单个 HTTP client 并以最多 `2` 路并发执行 OSS `PUT + HEAD`;client 的连接与单请求超时分别固定为 `10s / 60s`,手动入口在下载最大 `32 MiB` 来源对象前取得 memory admission,上传收齐后立即释放整图 admission,不跨数据库等待持有。所有对象验证通过后,由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中批量确认 `asset_object`、创建 project resource / account asset 并写入 cohort 完成事实,不得逐片发起三组 procedure 或在部分素材落库后伪造完整批次。resource / asset ID 由 owner、task 与切片序号稳定派生;同一批次不确定结果后重放只能复用内容完全一致的素材,冲突内容必须拒绝,来源资源还必须存在且与派生资源属于同一 owner / project。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。
+- `POST /api/editor/icon-spritesheets/generations`:主图标规范使用必填 `referenceId`,只接受当前 owner 的项目资源 ID 或素材 ID,不接受 `objectKey`、URL、临时 key 或 `referenceImageSrc` 作为主规范引用;普通附加参考图仍可使用独立 `referenceImageSrcs`。画布前端把完整用户需求作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。切片只在有界管线中按需编码,共享单个 HTTP client 并以最多 `2` 路并发执行 OSS `PUT + HEAD`;client 的连接与单请求超时分别固定为 `10s / 60s`,手动入口在下载最大 `32 MiB` 来源对象前取得 memory admission,上传收齐后立即释放整图 admission,不跨数据库等待持有。所有对象验证通过后,由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中批量确认 `asset_object`、创建 project resource / account asset 并写入 cohort 完成事实,不得逐片发起三组 procedure 或在部分素材落库后伪造完整批次。resource / asset ID 由 owner、task 与切片序号稳定派生;同一批次不确定结果后重放只能复用内容完全一致的素材,冲突内容必须拒绝,来源资源还必须存在且与派生资源属于同一 owner / project。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`projectId`、`assetFolderId` 和 `generationInputs`,不接受客户端 `priceMudPoints`;后端按归一化后的模型和尺寸从运行时定价配置计算价格,queue 入队时冻结该价格,worker 的预扣、退款和结果投影均使用同一入队价格;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。
- `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。
- `POST /api/editor/images/pixel-art-snaps`:对已登记的静态图片执行免费的同步完美像素化。请求使用 `sourceImageSrc` 承载当前 owner 可读取的 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,`sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel` 可选;拒绝内联媒体、未登记对象和非静态栅格素材。客户端提交的 `generationInputs` 必须与其余生成入口一样先经 `sanitize_editor_client_generation_inputs` 剥离 `screenColorHex / mattingProvider / mattingModel` 三个服务端保留审计字段,再进入任何 IO——这三项是背景色决策与 bgfilter 实际执行后由服务端写入的处理事实,不接受客户端声明;本端点是纯几何规整、不抠图,任何 matting 元数据出现在这类记录上本身就是伪造。源图已有正式 project resource 时前端应带上 `sourceResourceId`:该资源随 owner-scoped 项目读取一并鉴权,服务端可直接取用其 objectKey,省去按注册 ID 的全账号项目与素材库扫描;此时 `sourceImageSrc` 应传该 objectKey 或同一个 `resourceId`,两者指向不同图片会被直接拒绝。不带 `sourceResourceId` 时仍需按注册 ID 解析,但全账号项目与素材库只取一次快照,注册 ID 解析、归属校验和跨记录 `assetKind` 收集全部在该快照上用 `_from_records` 纯函数完成,命中已登记记录即短路、两份记录都查不到才回落 asset object 点查;不得再调用内部自带两轮扫描的 `resolve_editor_reference_object_key_for_owner`。`get_editor_project` 到来源解析结束整体套同一份绝对处理预算,超时返回 `504` 且文案指向归属校验——预算从 handler 入口起算不等于覆盖该阶段,裸 `await` 会让请求一路走到下载才发现预算耗尽,并全程占用端点准入名额。像素处理使用 strict 失败语义且不进入外部生成队列;成功时只持久化一张最终 PNG,并返回对应 project / resource / asset 快照。服务端把规范化 `canvasCompletion.dialogId` 作为 operationId,以 owner / project 共同限定作用域,并从该 operation 稳定派生 task、asset object、resource、asset 身份;请求 fingerprint 覆盖来源 object key、来源与输出字节摘要、来源资源、素材类型、规范目录 / 标签、规范 generationInputs、completion 和算法版本。OSS PUT / HEAD 之后只调用一次原子 SpacetimeDB procedure;权威 dialog 仍存在时在源图右侧完成占位,已删除时只提交 object / resource / asset 而不推进 canvas revision。完整同内容重放返回 `AlreadyApplied`,同 operation 输入漂移或只有部分记录存在返回幂等冲突。
- `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。
@@ -173,7 +182,7 @@
- 画布多选语义必须同时覆盖普通图层和仍显示占位框的生成器对象:Shift 点选或框选可把生成器加入当前选择;拖动任一已选图层或生成器时,所有已选普通图层和生成器占位框同步移动;删除 / Backspace / Delete 作用于完整选择集合,移除所有已选图层和生成器对象。生成器对象在选择集合中使用稳定 `generation-dialog:` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。角色形象生成面板每次成功绑定角色规范后,在当前编辑器生命周期内缓存为上一张角色规范;再次新建角色形象时自动带入该缓存。图标素材和 UI 设计图面板每次成功绑定图标规范后,同样缓存为上一张图标规范;再次新建需要图标规范的素材时自动带入该缓存。生成规范菜单里的图标规范对象自身只把首行参考图作为可选参考,不要求必须先绑定图标规范。
- 所有挂到 `document.body` 的 `.image-canvas-editor__portal-menu` 必须放在同步当前 `platform-theme--light / platform-theme--dark` 的 `.image-canvas-editor__portal-theme` 桥接层下,并与 `.image-canvas-editor` 共用完整的 `--image-canvas-brand-*` token 声明。portal 内的比例 / 清晰度选中态、视频和音频 slider、静音开关、时长文字与模型选中勾选继续消费同一组主题变量;不得用 `#00ff00` 等硬编码颜色绕过变量作用域,也不得只修单个控件而让其它 portal 选中态继续退回无效声明。
-- 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板参数控件,提交到 `/api/editor/images/generations` 时也固定携带这些参数。
+- 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板参数控件。角色 / 自定义规范继续提交 `/api/editor/images/generations`;图标规范改交 `/api/editor/icon-specs/generations`,固定图片参数只由后端注入。
- 快速编辑面板底部只显示模型选择和 `修改` 按钮;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。快速编辑请求只把原图或红框序号标注图作为 `sourceImageSrc` 直接提交,信息面板输入快照只展示用户填写的快速编辑提示词。
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。
diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
index 50d111c2c..201117451 100644
--- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
+++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
@@ -857,7 +857,7 @@ game-project/
- 自动验收现在严格要求 manifest 恰好包含固定 16 个不重复 task ID 且全部为 `completed`,并逐任务核对当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物存在并满足文件 / JSON / 非占位入口检查,配置画布 API Key 时再增加 `art-spec / ui-prototype / art-spritesheet` 三张图片。PNG 验收不止检查 magic / IHDR / 比例,还会校验 chunk CRC、zlib 解压、scanline 长度、索引色 PLTE 和未知 critical chunk。Runtime 根 Supervisor 的完成合同已升级为 `game-creator-autonomous-completion-contract.v2`,`baselineArtifacts` 必填并纳入指纹,旧 v1 或缺基线合同失败关闭;最终门禁要求最后一次验证工具是 `game.static_smoke`、状态通过且 `verifiedRevision == currentRevision`。`preview.validate` 回执必须绑定同一 Agent、run、current revision、当前 `game/index.html` 摘要、固定试玩场景、持久浏览器报告以及 desktop / mobile 两张截图的路径、摘要和 PNG 身份,任一证据缺失、变化、过期或来自其它 run / revision 都阻止最终回复。旧两图合同的确定性证据不替代新三图 DAG 验收;新合同实现后必须新起独立单轮。
- `design-foundation` 已增加专属职责边界:项目文件只允许写 `memory/project.md` 与 `game/game_design.md`;配置 External Editor API Key 且合同要求界面原型时,只额外允许固定 `assets/ui-prototype.png`。它不得创建、修改、删除或补丁 `game/index.html`,不得改动其它程序实现、发布、音频或美术素材,也不得调用 `preview.start`、`preview.validate`、`game.static_smoke`,或借 `command.exec / command.start / command.run_limited` 启动预览服务、浏览器、Playwright 和桌面 / 移动试玩。程序和质量 Agent 的共享 Runtime 工具合同不因此缩减;有 / 无画布配置和其它 Agent 不受影响的聚焦回归为 `3/3` 通过。
- `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `art-director` / `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。
-- 2026-07-28 起,在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 先通过 `/api/external/v1/editor/images/generations` 的 `kind=spec` 生成 `assets/art-spec.png`,并登记为 `assetKind=icon-spec`;`design-foundation` 使用该规范图的 External Editor 稳定资源 ID 作为 `referenceImageSrcs` 中的视觉规范参考,再通过同一 images 接口的 `kind=ui-design` 生成完整 `assets/ui-prototype.png`;`art-asset-plan` 以同一 `assets/art-spec.png` 资源 ID 作为必填 `referenceImageSrc` 调用 `/api/external/v1/editor/icon-spritesheets/generations`,传入具体 `iconDescriptions`、`screenColor=auto`、同名画布 / 素材库与 `canvasCompletion`,生成透明 `assets/art-spritesheet.png`。`generationInputs.artSpec` 只是辅助结构化上下文,不能代替真实 `art-spec.png`;严禁把 `assets/ui-prototype.png` 当作图集规范图。规范图缺失、未登记为当前画布的 `icon-spec` 或缺少稳定资源 ID 时,两个下游任务均等待 `art-director`,不得退回普通生图。UI extraction 只适用于已有且带红框标注的 UI 设计图,不用于生成完整 UI,也不进入本次 canonical DAG。单波最多 `3` 个静态职责的资源上限保持不变,只调整现有任务的依赖边与就绪顺序。图集返回 `warning` 时以 `postprocess-failed-source-preserved` 源图保留语义失败关闭,不把不透明源图登记为正式 spritesheet,也不自动重试;仅有 `sliceWarning` 时完整透明图集仍可登记,但必须原样保留切片失败原因。客户端下载后还要解码 PNG 并确认至少存在一个 alpha 小于 255 的像素,未形成真实透明像素时拒绝落盘和 manifest 登记。
+- 2026-07-28 起,在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 先通过 `/api/external/v1/editor/images/generations` 的 `kind=spec` 生成 `assets/art-spec.png`,并登记为 `assetKind=icon-spec`;`design-foundation` 使用该规范图的 External Editor 稳定资源 ID 作为 `referenceImageSrcs` 中的视觉规范参考,再通过同一 images 接口的 `kind=ui-design` 生成完整 `assets/ui-prototype.png`;`art-asset-plan` 以同一 `assets/art-spec.png` 资源 ID 作为必填 `referenceId` 调用 `/api/external/v1/editor/icon-spritesheets/generations`,传入具体 `iconDescriptions`、`screenColor=auto`、同名画布 / 素材库与 `canvasCompletion`,生成透明 `assets/art-spritesheet.png`。`generationInputs.artSpec` 只是辅助结构化上下文,不能代替真实 `art-spec.png`;严禁把 `assets/ui-prototype.png` 当作图集规范图。规范图缺失、未登记为当前画布的 `icon-spec` 或缺少稳定资源 ID 时,两个下游任务均等待 `art-director`,不得退回普通生图。UI extraction 只适用于已有且带红框标注的 UI 设计图,不用于生成完整 UI,也不进入本次 canonical DAG。单波最多 `3` 个静态职责的资源上限保持不变,只调整现有任务的依赖边与就绪顺序。图集返回 `warning` 时以 `postprocess-failed-source-preserved` 源图保留语义失败关闭,不把不透明源图登记为正式 spritesheet,也不自动重试;仅有 `sliceWarning` 时完整透明图集仍可登记,但必须原样保留切片失败原因。客户端下载后还要解码 PNG 并确认至少存在一个 alpha 小于 255 的像素,未形成真实透明像素时拒绝落盘和 manifest 登记。
- 旧项目已有同路径派生图但缺少上述 provenance 时,一律标记为 legacy,不得只因文件、kind 或通用视觉检查存在就完成。原位替换仍走显式 repair:`design-foundation` 与 `art-asset-plan` 先在同一 Supervisor 批次分别建立 owner 精确原合同并交付 `needs-repair`,父 run 认领后再在同一批次分别发起各自唯一 repair;两个 repair 合称一个显式视觉返工阶段。`art-director` 不得跨 owner 声明或替换 UI / spritesheet,Runtime 在委派落盘前就拒绝这类合同,不再等到生图阶段才失败。
- 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。
- 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env` 或 `game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index cf736d951..4245c5bb3 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -83,6 +83,7 @@ npm run check:server-rs-ddd
- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或规划不可解析时,必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡,面向用户的错误正文使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价错误;原始诊断只写后端结构化日志。后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。普通 JSON POST 尚未结束不形成持久化消息;工具失败同样必须形成可回读记录,不能只返回瞬时错误。
- 画布 Agent 的 `gpt-5.4-mini` Chat Completions 规划使用 1024 生成 token 预算;VectorEngine 专用 client 显式发送当前字段 `max_completion_tokens`,其预算包含可见输出和隐藏 reasoning token。通用 OpenAI-compatible client 默认保留旧 `max_tokens`,只有确认 endpoint 能力后才 opt-in,禁止按模型名猜测或在 `400` 后自动重放。前端在 POST pending 120 秒后显示不入库的耐心等待提示;provider request future 明确返回 connect/timeout/HTTP/transport 错误时立即进入正式失败,尚未返回则继续等待。专用 provider 单 attempt hard timeout 为 8 分钟;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,显式配置 0 仍可关闭,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时只使用剩余预算;该 deadline 覆盖会话锁/上下文准备与最多 3 轮规划,并为错误持久化/HTTP 返回预留约 2 分钟,不允许多轮规划绕过前端 20 分钟 timeout。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,并使用该成功响应所属的真实 attempt 记录错误。重试只包围 LLM 规划请求并发生在任何待确认工具执行之前,因此不会重复提交生成任务或扣费。
- 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。
+- 工具上下文不得把 OSS 消息附件当作 `assetKind` 真相;每次规划和每次确认都按附件 `source + referenceId` 经 `spacetime-client` 重新读取当前工程资源或账号素材库,只把权威 `asset_kind` 放入服务端内存 `ImageMetadata`。图标 spritesheet 的主参考必须精确为 `icon-spec`,普通图片或风格参考图只能作为额外参考;主参考类型缺失、已删除或不是 `icon-spec` 时必须在生成任务入队和用户确认生效前失败关闭,并提示重新选择图标规范。
- `edit-image` 只接受当前图片上下文中的 `object_image_id`;`source_image_id` 不是现役 schema 字段,prompt、tool args、确认执行和测试中都不得生成或兼容该字段。
- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。
- api-server 对 `PromptRunError` 的持久化顺序固定为:先按 `partial_outputs` 原顺序映射已成功工具,将其保存为 `status=not_completed` 且无 `externalJobId` 的待确认消息;再在同一会话增量末尾追加 `ERROR ` terminal system 消息并整体写入 OSS。后续规划失败不得吞掉失败前已执行的成功工具结果;结构化 `ToolFailed` 可用于调用方诊断与流程决策,但画布确认面不得把它伪装成成功待确认卡。
@@ -243,7 +244,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 对生成目标执行上一条只读 owner / project / asset folder 预检,但参考媒体的登记状态和归属仍由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通参考字符串可以入队,但必须在签名和 provider 调用前失败;若以后要求无效参考引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 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 对生成目标执行上一条只读 owner / project / asset folder 预检,但参考媒体的登记状态和归属仍由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通参考字符串可以入队,但必须在签名和 provider 调用前失败;若以后要求无效参考引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标规范结构化分析里位于 `` / `` XML 元素内的数据必须转义 `& < > " '`;玩法润色、美术风格润色、规范图生图和图标 spritesheet 等自然语言 prompt 则必须保留已经过边界校验的原文,不得把 `R&B`、引号或尖括号改写成 XML entity。图标 spritesheet 的 `iconDescriptions` 还必须在请求边界执行独立合同:原始数组先满足 OpenAPI `1..100`,再去空且至少保留 1 条;单条最多 `200` 个 Unicode 字符、换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节;只有 `ValidatedEditorIconSpritesheetPrompt` 能进入 prompt builder,External v1 超限同步返回 `400`。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
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 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。
12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值,检测到 owner 项目中已存在同一稳定 task/resource 的历史结果时则复用该服务端既存 metadata 完成精确 compare-and-return。
@@ -739,7 +740,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- Rust 结构体:`EditorGenerationOperation`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
-- 说明:编辑器生成结果的私有 durable commit receipt,queue 与受控 inline 路径共用。主键 `operation_key` 由 owner 与 operation ID 稳定派生,同 owner 不得跨 kind 复用 operation ID;`operation_fingerprint` 绑定规范用户请求,`commit_sha256` 绑定全部 slot 候选、object/resource/asset/binding、可选 canvas 候选与 job completion。表内另固化 owner/kind/ID、可选 `project_id`、queue 的 `job_id / job_worker_id / job_lease_token / job_result_payload_sha256` 和首次提交时间;重放从已完成 job 回读权威 compact result 并核对摘要,不在 receipt 复制正文。它只证明一笔业务结果已原子提交,不是第二套 job 状态或 resource/asset/canvas read model,不保存大快照。
+- 说明:编辑器生成结果的私有 durable commit receipt,queue 与受控 inline 路径共用。主键 `operation_key` 由 owner 与 operation ID 稳定派生,同 owner 不得跨 kind 复用 operation ID;`operation_fingerprint` 绑定规范用户请求,`commit_sha256` 绑定全部 slot 候选、object/resource/asset/binding、可选 canvas 候选与 job completion。表内另固化 owner/kind/ID、可选 `project_id`、queue 的 `job_id / job_worker_id / job_lease_token / job_result_payload_sha256` 和首次提交时间;重放从已完成 job 回读权威 compact result 并核对摘要,不在 receipt 复制正文。它只证明一笔业务结果已原子提交,不是第二套 job 状态或 resource/asset/canvas read model,不保存大快照。结果提交的 operation kind 白名单必须覆盖所有进入统一持久化的正式 job kind,其中包括独立图标规范任务 `editor_icon_spec_generation`;新增 job kind 时必须在同一变更中同步白名单和模块回归。
- 重放:receipt 存在时必须核对全部绑定和权威记录,完全一致才返回 `AlreadyApplied`;同 operation 的请求或提交摘要漂移、project/job 绑定漂移、receipt 缺失但稳定 resource/asset/binding 等业务记录已存在均失败关闭。事务前单独确认的 asset object 只在全部字段与稳定候选完全一致时允许复用,不能据此补造 receipt。重放不更新 receipt 时间,不重复 job/binding 事件,不推进 canvas revision。
- 时间:`completed_at_micros` 必须为正数并固化为 receipt 完成时间;object/resource/asset/binding/canvas 候选的原时间字段与它一起进入 commit SHA-256,重放必须复用原 prepared commit 而不得重新取时。queue job 完成时间与完成事件仍使用 SpacetimeDB `ctx.timestamp`,不信任调用方时钟。
- 索引:主键 `operation_key`;`by_editor_generation_operation_owner(owner_user_id, operation_id)` 仅用于受控定位和诊断,不允许同 owner 跨 operation kind 复用同一 operation ID。
diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md
index 7daef9a08..45bab1d1f 100644
--- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md
+++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md
@@ -38,10 +38,12 @@
- 生成角色:`你希望角色如何设计?`
- 生成 UI:`你希望这个 UI 长什么样?`
- 生成视频:`你希望生成什么视频?`
-8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
+8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框;该完整文本按 Unicode 字符限制为 `200`,输入时按 code point 截断,不能用 UTF-16 `maxLength` 误截 emoji。
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
-10. 图片快速编辑不展示额外参考图入口;原图或绘制了红框和序号的标注图始终作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。
-11. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。
+10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填,客户端提交前统一 trim 并拒绝空白值;每项独立支持一键优化、处理中锁定自身、成功后单次撤销,操作行最右侧按 Unicode 字符实时显示 `当前数/200`。撤销必须恢复优化前的原始输入(包括首尾空白);手工编辑后立即清除该字段已经失效的撤销快照,失败只保留当前文本与仍然有效的旧撤销快照。LLM 返回空文本、超长文本、Markdown / 结构化内容,或 finish reason 明确表示截断、过滤、失败时,后续有界重试必须携带上次无效输出和对应修正要求,不能把未完成前缀当作成功结果。优化请求必须绑定发起时的生成对象 ID 和请求代次;活动对象身份只在 React effect 提交后更新,并在 cleanup 中失效,丢弃的并发 render 不得改变请求归属;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。字段标题使用真实 label 关联 textarea,不得把优化 / 撤销按钮包进 label。控件继续使用平台默认样式,不新增图标规范专属 CSS。
+11. 图标规范最终生成改走 `POST /api/editor/icon-specs/generations`。前端只提交业务字段和统一参考图 / 项目完成包络,不拼最终 prompt,不提交 `kind / assetKind / ExtraParam`;可选参考图字段为 `referenceId`,只允许当前 owner 的项目资源 ID 或素材 ID。后端固定以 `kind=spec / assetKind=icon-spec / gpt-image-2 / 16:9·2K` 执行图片生成。inline 路径校验业务字段和 `referenceId` 后,补全 `ExtraParam` 与最终 prompt 并交给共享图片生成执行器;queue 路径在预校验后按 `gpt-image-2 / 2K` 运行时定价冻结价格,再以独立 `editor_icon_spec_generation` job kind 入队原始业务载荷。worker 使用入队价格和当前 claim attempt 的计费上下文,重新解析载荷、校验当前 owner 与引用事实,再补全 `ExtraParam` 并调用同一共享图片生成执行器,避免排队期间状态变化产生 TOCTOU。
+12. 图片快速编辑不展示额外参考图入口;原图或绘制了红框和序号的标注图始终作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。
+13. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。
## 参数交互
@@ -77,7 +79,7 @@
- 本次消耗泥点必须显示在生成按钮内部。
- 生成按钮内明确显示 `N泥点`,例如 `生成12泥点`、`生成40泥点`;不使用泥点图标替代文字。
- 画板内所有会提交外部生成任务的按钮,展示价格都必须从模型定价配置函数推导,不允许在按钮文案中散落固定泥点数字;生成请求不提交 `priceMudPoints`,修改后端模型定价配置后,后端实际扣费和前端下一次拉取到的按钮展示应同步变化。
-- 后端所有编辑器外部生成入口必须按运行时模型定价配置计算价格后进入 `execute_billable_asset_operation_with_cost`:`/api/editor/images/generations`、`/api/editor/images/edits`、`/api/editor/icon-spritesheets/generations`、`/api/editor/ui-designs/assets/extractions`、`/api/editor/videos/generations`、`/api/editor/character-animations/generations`、`/api/editor/audios/sound-effects/generations`、`/api/editor/audios/background-music/generations` 都不能只展示价格而不真实预扣钱包。
+- 后端所有编辑器外部生成入口必须按运行时模型定价配置计算价格后进入 `execute_billable_asset_operation_with_cost`:`/api/editor/icon-specs/generations`、`/api/editor/images/generations`、`/api/editor/images/edits`、`/api/editor/icon-spritesheets/generations`、`/api/editor/ui-designs/assets/extractions`、`/api/editor/videos/generations`、`/api/editor/character-animations/generations`、`/api/editor/audios/sound-effects/generations`、`/api/editor/audios/background-music/generations` 都不能只展示价格而不真实预扣钱包。
- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动作走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。这些函数启动时会被后端下发配置覆盖,接口失败时才使用内置兜底。定价配置只按模型区分,不按图片 / 规范、视频 / 动作用途拆分;图片类价格必须同时传入模型和 `imageSize`,规范固定读取 `gpt-image-2` 的 `2K` 定价。
- 泥点配置默认值独立收口到 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,JSON 结构为 `models[model] = { unit, price | prices }`;后台“模型定价”页面通过 `POST /admin/api/editor-generation-pricing` 保存完整配置到 SpacetimeDB `editor_generation_pricing_config` 全局表,主站通过 `GET /api/editor/generation-pricing` 动态读取当前配置。后台必须展示定价单位:`perGeneration` 显示“按次”,`perSecond` 显示“按秒”。
- 生成图标素材、生成视频、角色动画、音效和背景音乐请求只提交生成参数,不提交价格字段;后端按归一后的模型、清晰度、时长或音频模型重新计算并扣费。
diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
index 25459c1a8..2b9545e5a 100644
--- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
+++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
@@ -2,7 +2,7 @@
日期:`2026-06-15`
-更新时间:`2026-07-29`
+更新时间:`2026-08-07`
## 背景
@@ -16,7 +16,7 @@
- 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。
- 透明背景处理正常成功后删除占位态:透明 spritesheet 作为主图(`assetKind: "icon-spritesheet"`,`generatedLayerId` 锚点)放入画布,provider 带背景原图作为第二个同类型图层放在透明主图右侧,按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材从原图右侧继续铺放;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。
- 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。
-- 用户把现有图层手动标记为“图集”时,必须先持久化一条 `assetKind: "icon-spritesheet"` 的项目资源并把返回的 `resourceId` 写回图层;项目资源只能在媒体来源和 `assetKind` 都相同时复用,不得因同源图片而返回旧类型资源。持久化完成前必须禁用“拆分图集”,持久化失败时回滚到上一个已确认的素材标签和资源引用,并失效该轮未确认的标签撤销记录。
+- 用户把现有图层手动标记为“图集”时,必须先持久化一条 `assetKind: "icon-spritesheet"` 的项目资源并把返回的 `resourceId` 写回图层;项目资源只能在媒体来源和 `assetKind` 都相同时复用,不得因同源图片而返回旧类型资源。持久化完成前必须禁用“拆分图集”,持久化失败时回滚到上一个已确认的素材标签和资源引用,并失效该轮未确认的标签撤销记录。后端拆分授权只信任该项目资源的 `assetKind`,图层 `assetKindOverride` 仅用于展示,不能把其它资源提升为可拆分图集。
- 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
## 面板结构
@@ -25,7 +25,7 @@
- 点击后弹出菜单:`从画布中选择`、`新建图标规范`、`上传图片`。
- `从画布中选择` 进入画布点选状态,只允许选择 `assetKind: "icon-spec"` 的图标规范图片;其它图片点击无效。
- `新建图标规范` 复用生成规范表单,规格类型为 `图标规范`,生成成功后图层标记为 `icon-spec`。
- - `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布。
+ - `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布;存在当前项目时创建 `assetKind: "icon-spec"` 的项目资源,不存在当前项目时创建同类型账号素材,提交图集生成时只使用持久化返回的 `resourceId` 或 `assetId`,不得按通用 `image` 类型登记。
2. 第二模块为素材描述文本框。
- UI 复用角色形象生成面板同款单个文本输入框,让用户直接叙述多个素材。
- 默认按换行填入:`返回按钮`、`设置按钮`、`下一关按钮`、`提示按钮`、`原图按钮`、`冻结按钮`。
@@ -40,14 +40,21 @@
## 生成契约
- 前端提交到 `POST /api/editor/icon-spritesheets/generations`。
+- 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。
+- 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。
+- queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。
+- queued 图标规范生成在本地文本门禁后、参考资源 owner 解析和定价之前执行目标预检,并把 canonical `projectId + assetFolderId` 写入任务 payload;不存在、越权或目录不匹配必须由提交请求同步失败,不得入队后再变成 worker 失败。
+- spritesheet 队列提交对主图标规范只运行 metadata-only resolver,校验 owner、`assetKind="icon-spec"`、对象元数据和保存的游戏类型,不读取 OSS object body;worker 执行时复用同一 metadata resolver 重新确认当前事实,并在通过后只下载一次图片正文。
+- inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:请求数组原始长度先满足 OpenAPI `1..100`,不得通过丢弃空白项绕过 `maxItems`;随后去除空白项仍须至少保留 1 条,单条最多 `200` 个 Unicode 字符,以换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节。请求边界校验成功后生成 `ValidatedEditorIconSpritesheetPrompt`,后续 prompt builder 不接受裸字符串。队列入口必须在引用解析、定价和任务持久化前同步拒绝可预测错误,不能把无效任务留给 worker 延迟失败。
+- worker 解析主 `referenceId` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:该字段只接受当前 owner 的项目资源 ID 或素材 ID,并只按两张表的主键查询;不接受 `objectKey`、`image_src`、URL 或临时 key 作为主规范引用。两张表先按 `owner_user_id` 筛选候选,再判断同一 ID 是否在当前 owner 范围内同时命中;其它账号的同名 ID 不得制造歧义或阻断当前账号的合法引用。记录带 `asset_object_id` 时必须按该 ID 读取对象并同时核对 bucket、object key 与 owner,只有明确缺少 `asset_object_id` 的兼容旧行才允许按对象位置查询。procedure 复用既有 `EditorProjectResourceSnapshot` 或 `EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"` 与 `genre` 都由图标图集业务代码从返回行校验和提取。入队与 inline 预检只核对引用行和 asset object 元数据,不下载图片正文;最终执行重新解析当前事实并只下载一次实际参考图。解析成功后的 `objectKey` 是服务端内部存储事实,不是该请求的输入协议。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`。
- 请求字段:
- - `referenceImageSrc`:图标规范的稳定引用(当前账号的 `objectKey`、项目资源 ID 或素材 ID);本地临时图必须先上传 OSS,禁止 Data URL / Blob URL。
- - `iconDescriptions`:兼容现有接口的图标需求数组,`1..100`;当前画布前端固定把完整文本作为唯一数组元素提交。数组长度只表达请求文本,不作为自动拆分数量。
+ - `referenceId`:图标主规范的正式引用,必填且只允许当前 owner 的项目资源 ID 或素材 ID。本地临时图必须先按 `assetKind="icon-spec"` 上传并登记为项目资源或账号素材,再提交返回的 `resourceId` 或 `assetId`;禁止提交 `objectKey`、URL、Data URL、Blob URL 或临时 key。
+ - `iconDescriptions`:兼容现有接口的图标需求数组,`1..100`;当前画布前端固定把完整文本作为唯一数组元素提交。数组长度只表达请求文本,不作为自动拆分数量;单项、聚合字符和 UTF-8 字节上限按上一条 prompt 合同执行。
- `model`:支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。
- `aspectRatio`:按 `x:y` 展示,选项跟随模型。
- `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。
- `style`:可选生成风格,同时影响提交给 provider 的提示词和回图后的像素规整;未勾选像素艺术时传 `"none"`,勾选时传 `"pixelArt"`。
- - `priceMudPoints`:按当前模型和尺寸从编辑器生成计费配置计算;`nanobanana2 1K` 为 `12`,`gpt-image-2 1K` 为 `3`、`gpt-image-2 2K` 为 `5`。前端只提交配置函数计算值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死价格。
+ - 计费不属于客户端请求字段:前端不提交 `priceMudPoints`,后端按归一化后的模型和尺寸从运行时编辑器生成定价配置计算价格。队列模式在入队时冻结该价格,worker 的预扣、退款、响应 `priceMudPoints` 和资产 `generationCostMudPoints` 都使用同一入队价格;定价配置变更只影响之后入队的任务。
- 模型与尺寸选项:
- `nanobanana2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig`;`0.5K` 按 VectorEngine 文档传 `"512"`。
- `gpt-image-2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标规范图作为 multipart `image`。K 档按最长边计算,并转换为 provider 可直接生成的合法像素:`1K` 的 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9` 分别为 `1024x1024 / 1024x768 / 1024x688 / 688x1024 / 608x1088 / 1088x608`;`2K` 分别为 `2048x2048 / 2048x1536 / 2048x1376 / 1376x2048 / 1152x2048 / 2048x1152`。其中 9:16 的 1K 尺寸按 provider 最小总像素和 16 对齐约束修正。禁止把 2K 竖图回落为 1K 请求,也禁止在回图后放大伪造所选 K 档。
@@ -61,6 +68,8 @@
<完整用户需求>
```
+上述最终 spritesheet prompt、玩法润色 prompt、美术风格润色 prompt 和规范图生图 prompt 都是自然语言文本,必须保留已经过长度与空白校验的用户原文;不得把 `R&B`、引号或尖括号改写成 XML entity。只有 `build_extra_param_prompt` 中真正位于 `` / `` XML 元素内的数据执行 XML 转义。
+
## 像素风格后处理
- 图标素材面板增加紧凑的 `像素艺术` 勾选项。选择保存于现有生成器快照,并可随现有请求和队列 payload 传递;不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。
@@ -95,8 +104,8 @@
- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。
- 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。
- 图标素材面板可选择 `style: "none" | "pixelArt"`;`none` 完整保持原处理路径,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「每个图标素材均为像素风格」并在 Alpha 回贴后、自动拆分前执行内存像素规整,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。
-- 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`。
+- 图标素材生成可以上传普通附加参考图;提交时图标主规范图走 `referenceId`,且只提交当前 owner 的项目资源 ID 或素材 ID。普通附加参考图单独走 `referenceImageSrcs`,继续允许稳定 `objectKey` / 项目资源 ID / 素材 ID,但禁止 Data URL / Blob URL。两类引用都写入 `generationInputs.references`,不得用普通附加参考图协议放宽主规范边界。
- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的全部有效连通域图标图层,图标依次命名为 `素材 N`;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。
- 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。
- 把同源派生图层从其它标签改为“图集”时,在项目资源返回新 `resourceId` 前“拆分图集”保持禁用;持久化成功后拆分请求必须指向 `assetKind: "icon-spritesheet"` 的新资源,失败时标签回滚且不发起拆分请求。
-- 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
+- 生成图标素材的提交体不包含 `priceMudPoints`;后端必须按归一化后的模型和尺寸计算价格,不信任客户端声明。queue 任务的计费、退款和结果投影使用入队时冻结的同一价格。
diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs
index 8cdf30516..11e7369eb 100644
--- a/server-rs/crates/api-server/src/asset_billing.rs
+++ b/server-rs/crates/api-server/src/asset_billing.rs
@@ -929,19 +929,44 @@ mod tests {
fn every_provider_inline_route_defers_billing_until_durable_completion() {
for (source, expected_calls) in [
(include_str!("editor_project.rs"), 4),
+ (include_str!("editor_project_icon.rs"), 2),
(include_str!("character_animation_assets.rs"), 2),
(
include_str!("vector_engine_audio_generation/generation.rs"),
2,
),
] {
+ let production_source = source
+ .rsplit_once("\n#[cfg(test)]\nmod tests")
+ .map(|(production_source, _)| production_source)
+ .unwrap_or(source);
assert_eq!(
- source
+ production_source
.matches("with_editor_generation_durable_billing_boundary(")
.count(),
expected_calls,
);
}
+
+ let editor_source = include_str!("editor_project.rs");
+ let scene_route = editor_source
+ .split_once("pub async fn generate_editor_scene(")
+ .and_then(|(_, tail)| {
+ tail.split_once("pub async fn generate_editor_image(")
+ .map(|(body, _)| body)
+ })
+ .expect("scene route");
+ let inline_operation = scene_route
+ .find(".with_inline_operation(")
+ .expect("scene inline operation");
+ let billing_boundary = scene_route
+ .find("with_editor_generation_durable_billing_boundary(")
+ .expect("scene durable billing boundary");
+ let provider_execution = scene_route
+ .rfind("generate_editor_image_for_owner(")
+ .expect("scene provider execution");
+ assert!(inline_operation < billing_boundary);
+ assert!(billing_boundary < provider_execution);
}
#[test]
diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs
index 5f40575ec..a4ae3d50d 100644
--- a/server-rs/crates/api-server/src/character_animation_assets.rs
+++ b/server-rs/crates/api-server/src/character_animation_assets.rs
@@ -821,9 +821,9 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
// 背景色决策:预扣泥点之后才发起(显式传入 → 视觉 LLM 按源图主体配色自动决策 → 默认色)。
// 决策失败会让本 future 返回 Err,走 execute_billable 的失败退款,用户不损失泥点。
let screen_background_decision = resolve_editor_screen_background_color(
- state.llm_client(),
- state.editor_agent_llm_client(),
- EditorScreenBackgroundDecisionInput {
+ state.llm_client(),
+ state.vector_engine_llm_client(),
+ EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::CharacterAnimation,
screen_color: payload.screen_color.clone(),
prompt: payload.prompt_text.clone(),
@@ -831,7 +831,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
reference_count: 1,
source_image_data_url: Some(source_data_url.clone()),
},
- Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
+ Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
state: &state,
context: crate::external_api_audit::ExternalApiAuditContext {
user_id: Some(owner_user_id.clone()),
diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs
index aea2045e9..7b72bfda2 100644
--- a/server-rs/crates/api-server/src/editor_agent/api.rs
+++ b/server-rs/crates/api-server/src/editor_agent/api.rs
@@ -197,11 +197,12 @@ pub async fn editor_agent_message(
// Tool calls and attachment bookkeeping are separate system messages.
let previous_messages = build_prompt_memory(&document, history_end);
- // Build tool context from document
- let tool_context = context::build_tool_context(&document);
+ // Re-read authoritative resource and asset metadata for every planning turn. The message
+ // document only identifies attachments; it is not the source of truth for asset kind.
+ let tool_context = context::build_tool_context(&state, &conversation, &document).await?;
// Build and run agent
- let Some(llm_client) = state.editor_agent_llm_client() else {
+ let Some(llm_client) = state.vector_engine_llm_client() else {
tracing::warn!(
conversation_id = %conversation.conversation_id,
"美术 Agent LLM 客户端未配置"
@@ -1046,7 +1047,7 @@ pub async fn confirm_editor_agent_tool_call(
}))
})?;
let project = load_editor_agent_project(&state, &conversation).await?;
- let context = context::build_tool_context(&document);
+ let context = context::build_tool_context(&state, &conversation, &document).await?;
let tool = editor_agent_tool(tool_name.as_str(), &context)
.ok_or_else(|| editor_agent_bad_request(format!("unsupported tool: {tool_name}")))?;
let normalized_args = tool
diff --git a/server-rs/crates/api-server/src/editor_agent/context.rs b/server-rs/crates/api-server/src/editor_agent/context.rs
index 8b86d0cb9..8de569578 100644
--- a/server-rs/crates/api-server/src/editor_agent/context.rs
+++ b/server-rs/crates/api-server/src/editor_agent/context.rs
@@ -1,10 +1,82 @@
use crate::editor_agent::utils::IntoDataKey;
+use crate::editor_project::{current_utc_micros, map_editor_project_error};
+use crate::http_error::AppError;
+use crate::state::AppState;
use platform_editor_agent::agent::asset::{ImageId, ImageMetadata};
use platform_editor_agent::agent::tools::context::EditorToolContext;
-use shared_contracts::editor_agent::EditorAgentConversationMessagesDocument;
+use shared_contracts::editor_agent::{
+ EditorAgentAttachmentSource, EditorAgentConversationMessagesDocument,
+};
+use spacetime_client::{
+ EditorAgentConversationRecord, EditorAssetLibraryRecord, EditorProjectGetRecordInput,
+ EditorProjectRecord,
+};
use std::collections::HashMap;
-pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> EditorToolContext {
+pub async fn build_tool_context(
+ state: &AppState,
+ conversation: &EditorAgentConversationRecord,
+ document: &EditorAgentConversationMessagesDocument,
+) -> Result {
+ let needs_project = document.messages.iter().any(|message| {
+ message
+ .attachments
+ .iter()
+ .any(|attachment| attachment.source == EditorAgentAttachmentSource::CanvasResource)
+ || message.tool_call.as_ref().is_some_and(|tool_call| {
+ tool_call.images.iter().any(|image| {
+ image
+ .resource_id
+ .as_deref()
+ .is_some_and(|id| !id.trim().is_empty())
+ })
+ })
+ });
+ let needs_library = document.messages.iter().any(|message| {
+ message
+ .attachments
+ .iter()
+ .any(|attachment| attachment.source == EditorAgentAttachmentSource::LibraryAsset)
+ });
+
+ let project = if needs_project {
+ Some(
+ state
+ .spacetime_client()
+ .get_editor_project(EditorProjectGetRecordInput {
+ project_id: conversation.project_id.clone(),
+ owner_user_id: conversation.owner_user_id.clone(),
+ })
+ .await
+ .map_err(map_editor_project_error)?,
+ )
+ } else {
+ None
+ };
+ let library = if needs_library {
+ Some(
+ state
+ .spacetime_client()
+ .get_editor_asset_library(conversation.owner_user_id.clone(), current_utc_micros())
+ .await
+ .map_err(map_editor_project_error)?,
+ )
+ } else {
+ None
+ };
+
+ Ok(build_tool_context_from_authoritative_records(
+ document,
+ project.as_ref(),
+ library.as_ref(),
+ ))
+}
+
+fn build_tool_context_from_authoritative_records(
+ document: &EditorAgentConversationMessagesDocument,
+ project: Option<&EditorProjectRecord>,
+ library: Option<&EditorAssetLibraryRecord>,
+) -> EditorToolContext {
let mut images: HashMap = HashMap::new();
for msg in document.messages.iter().rev() {
@@ -13,6 +85,25 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) ->
let image_id = ImageId::from_data_key(&data_key);
let metadata = ImageMetadata {
data_key,
+ reference_id: Some(a.reference_id.clone()),
+ asset_kind: match a.source {
+ EditorAgentAttachmentSource::CanvasResource => project
+ .and_then(|project| {
+ project
+ .resources
+ .iter()
+ .find(|resource| resource.resource_id == a.reference_id)
+ })
+ .and_then(|resource| resource.asset_kind.clone()),
+ EditorAgentAttachmentSource::LibraryAsset => library
+ .and_then(|library| {
+ library
+ .assets
+ .iter()
+ .find(|asset| asset.asset_id == a.reference_id)
+ })
+ .and_then(|asset| asset.asset_kind.clone()),
+ },
image_src: a.image_src.clone(),
object_key: a.object_key.clone(),
thumbnail_src: a.thumbnail_src.clone(),
@@ -29,6 +120,17 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) ->
let image_id = ImageId::from_data_key(&data_key);
let metadata = ImageMetadata {
data_key,
+ reference_id: img.resource_id.clone(),
+ asset_kind: img.resource_id.as_deref().and_then(|resource_id| {
+ project
+ .and_then(|project| {
+ project
+ .resources
+ .iter()
+ .find(|resource| resource.resource_id == resource_id)
+ })
+ .and_then(|resource| resource.asset_kind.clone())
+ }),
image_src: img.image_src.clone(),
object_key: img.object_key.clone(),
thumbnail_src: img.thumbnail_src.clone(),
@@ -47,10 +149,17 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) ->
#[cfg(test)]
mod tests {
use super::*;
+ use crate::editor_agent::tool::editor_agent_tool;
+ use platform_editor_agent::agent::tools::generate_icon_spritesheet::GenerateIconSpritesheetTool;
+ use platform_editor_agent::framework::tool::Tool;
+ use serde_json::json;
use shared_contracts::editor_agent::{
EditorAgentAttachmentRef, EditorAgentAttachmentSource, EditorAgentMessage,
EditorAgentMessageRole,
};
+ use spacetime_client::{
+ EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord,
+ };
fn message(id: usize, label: &str, image_src: &str) -> EditorAgentMessage {
EditorAgentMessage {
@@ -73,6 +182,59 @@ mod tests {
}
}
+ fn project_with_resource_asset_kind(asset_kind: &str) -> EditorProjectRecord {
+ let viewport = EditorCanvasViewportRecord {
+ x: 0.0,
+ y: 0.0,
+ scale: 1.0,
+ };
+ EditorProjectRecord {
+ project_id: "project-1".to_string(),
+ owner_user_id: "user-1".to_string(),
+ title: "测试工程".to_string(),
+ canvas: EditorCanvasRecord {
+ canvas_id: "canvas-1".to_string(),
+ project_id: "project-1".to_string(),
+ title: "测试画布".to_string(),
+ viewport: viewport.clone(),
+ layers: json!([]),
+ revision: 0,
+ layout_storage_version: 2,
+ background_color: None,
+ created_at: "2026-08-08T00:00:00Z".to_string(),
+ updated_at: "2026-08-08T00:00:00Z".to_string(),
+ },
+ viewport,
+ layers: json!([]),
+ resources: vec![EditorProjectResourceRecord {
+ resource_id: "resource-1".to_string(),
+ project_id: "project-1".to_string(),
+ owner_user_id: "user-1".to_string(),
+ asset_object_id: Some("object-1".to_string()),
+ image_src: "/api/assets/read/current.png".to_string(),
+ object_key: Some("generated/reference.png".to_string()),
+ width: 640,
+ height: 480,
+ source_type: "generated".to_string(),
+ prompt: None,
+ actual_prompt: None,
+ model: None,
+ provider: None,
+ task_id: None,
+ source_resource_id: None,
+ asset_kind: Some(asset_kind.to_string()),
+ generation_inputs: None,
+ public_showcase_enabled: false,
+ created_at: "2026-08-08T00:00:00Z".to_string(),
+ updated_at: "2026-08-08T00:00:00Z".to_string(),
+ image_sequence_frames: None,
+ image_sequence_duration_ms: None,
+ }],
+ created_at: "2026-08-08T00:00:00Z".to_string(),
+ updated_at: "2026-08-08T00:00:00Z".to_string(),
+ }
+ }
+
#[test]
fn tool_context_keeps_complete_metadata_from_the_latest_image_reference() {
let document = EditorAgentConversationMessagesDocument {
@@ -84,7 +246,7 @@ mod tests {
],
};
- let context = build_tool_context(&document);
+ let context = build_tool_context_from_authoritative_records(&document, None, None);
let image_id = ImageId::from_data_key("generated/reference.png");
let metadata = context
.image_metadata(&image_id)
@@ -96,4 +258,38 @@ mod tests {
assert_eq!(metadata.width, Some(640));
assert_eq!(metadata.height, Some(480));
}
+
+ #[test]
+ fn icon_spritesheet_validation_uses_authoritative_project_asset_kind() {
+ let document = EditorAgentConversationMessagesDocument {
+ version: 2,
+ conversation_id: "conversation-1".to_string(),
+ messages: vec![message(0, "主规范", "/api/assets/read/current.png")],
+ };
+ let args = json!({
+ "reference_image_id": ImageId::from_data_key("generated/reference.png").id,
+ "icon_descriptions": ["背包"]
+ });
+
+ let ordinary_project = project_with_resource_asset_kind("image");
+ let ordinary_context =
+ build_tool_context_from_authoritative_records(&document, Some(&ordinary_project), None);
+ let ordinary_tool = editor_agent_tool(GenerateIconSpritesheetTool::NAME, &ordinary_context)
+ .expect("icon spritesheet tool should resolve");
+ let error = ordinary_tool
+ .validate_args(&args)
+ .expect_err("ordinary registered image must be rejected before confirmation");
+ assert!(error.to_string().contains("icon-spec"));
+
+ let icon_spec_project = project_with_resource_asset_kind("icon-spec");
+ let icon_spec_context = build_tool_context_from_authoritative_records(
+ &document,
+ Some(&icon_spec_project),
+ None,
+ );
+ let icon_spec_tool =
+ editor_agent_tool(GenerateIconSpritesheetTool::NAME, &icon_spec_context)
+ .expect("icon spritesheet tool should resolve");
+ assert!(icon_spec_tool.validate_args(&args).is_ok());
+ }
}
diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs
index cb61ab1dc..cef77e705 100644
--- a/server-rs/crates/api-server/src/editor_agent/tool.rs
+++ b/server-rs/crates/api-server/src/editor_agent/tool.rs
@@ -53,9 +53,8 @@ use crate::editor_generation_queue::{
EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND,
EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND,
};
-use crate::editor_project::{
- EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest,
-};
+use crate::editor_project::{EditorImageEditRequest, EditorImageGenerationRequest};
+use crate::editor_project_icon::EditorIconSpritesheetGenerationRequest;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum EditorAgentToolError {
@@ -756,11 +755,37 @@ impl EditorAgentTool for EditImageTool {
}
}
+fn resolve_icon_spec_reference_id<'a>(
+ context: &'a EditorToolContext,
+ image_id: &ImageId,
+) -> Result<&'a str, EditorAgentToolError> {
+ let metadata = context.image_metadata(image_id).ok_or_else(|| {
+ EditorAgentToolError::invalid_args("reference image is not available in tool context")
+ })?;
+ let reference_id = metadata
+ .reference_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|reference_id| !reference_id.is_empty())
+ .ok_or_else(|| {
+ EditorAgentToolError::invalid_args(
+ "reference image is not a registered resource or asset",
+ )
+ })?;
+ if metadata.asset_kind.as_deref().map(str::trim) != Some("icon-spec") {
+ return Err(EditorAgentToolError::invalid_args(
+ "reference image must be a registered icon-spec; select an icon specification image and try again",
+ ));
+ }
+ Ok(reference_id)
+}
+
impl EditorAgentTool for GenerateIconSpritesheetTool {
fn validate_args(&self, args: &Value) -> Result {
let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?;
- GenerateIconSpritesheetTool::validate_args(self, &args)
+ let args = GenerateIconSpritesheetTool::normalize_args(self, args)
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
+ resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?;
serialize_normalized_args(Self::NAME, &args)
}
@@ -828,14 +853,11 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
) -> Result {
let price_mud_points = self.pricing(context.pricing, args)?;
let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?;
- let reference_image_src = self
- .context
- .image_data_key(&args.reference_image_id)
- .ok_or_else(|| EditorAgentToolError::invalid_args("reference image not found"))?
- .to_string();
+ let reference_id =
+ resolve_icon_spec_reference_id(&self.context, &args.reference_image_id)?.to_string();
let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?;
let payload = EditorIconSpritesheetGenerationRequest {
- reference_image_src,
+ reference_id,
reference_image_srcs: Some(reference_image_srcs),
icon_descriptions: args.icon_descriptions,
style: None,
@@ -1233,6 +1255,8 @@ mod tests {
},
ImageMetadata {
data_key: format!("generated/{image_id}.png"),
+ reference_id: Some(format!("resource-{image_id}")),
+ asset_kind: Some("icon-spec".to_string()),
image_src: format!("/api/assets/read/generated/{image_id}.png"),
object_key: Some(format!("generated/{image_id}.png")),
thumbnail_src: Some(format!("/api/assets/read/generated/{image_id}-thumb.png")),
@@ -1426,6 +1450,40 @@ mod tests {
let missing = editor_agent_tool(EditImageTool::NAME, &EditorToolContext::default())
.expect("edit tool should resolve");
assert!(missing.validate_args(&normalized).is_err());
+
+ let registered_icon = editor_agent_tool(
+ GenerateIconSpritesheetTool::NAME,
+ &context_with_image("image-1"),
+ )
+ .expect("icon spritesheet tool should resolve");
+ assert!(
+ registered_icon
+ .validate_args(&json!({
+ "reference_image_id": "image-1",
+ "icon_descriptions": ["背包"]
+ }))
+ .is_ok()
+ );
+
+ let mut unregistered_context = context_with_image("image-1");
+ unregistered_context
+ .images
+ .get_mut(&ImageId {
+ id: "image-1".to_string(),
+ })
+ .expect("test image should exist")
+ .reference_id = None;
+ let unregistered_icon =
+ editor_agent_tool(GenerateIconSpritesheetTool::NAME, &unregistered_context)
+ .expect("icon spritesheet tool should resolve");
+ assert!(
+ unregistered_icon
+ .validate_args(&json!({
+ "reference_image_id": "image-1",
+ "icon_descriptions": ["背包"]
+ }))
+ .is_err()
+ );
}
#[test]
diff --git a/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs b/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs
index 60483181e..f7deb1cee 100644
--- a/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs
+++ b/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs
@@ -136,7 +136,7 @@ fn background_music_prompt_bad_request(message: impl Into) -> AppError {
}
fn require_background_music_prompt_llm_client(state: &AppState) -> Result<&LlmClient, AppError> {
- state.editor_agent_llm_client().ok_or_else(|| {
+ state.vector_engine_llm_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("服务端尚未配置可用的编辑器 LLM")
.with_details(json!({
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 91095fdef..11d31b12b 100644
--- a/server-rs/crates/api-server/src/editor_generation_queue.rs
+++ b/server-rs/crates/api-server/src/editor_generation_queue.rs
@@ -11,6 +11,7 @@ use spacetime_client::{ExternalGenerationJobEnqueueRecordInput, ExternalGenerati
use crate::{http_error::AppError, request_context::RequestContext, state::AppState};
pub(crate) const EDITOR_IMAGE_GENERATION_JOB_KIND: &str = "editor_image_generation";
+pub(crate) const EDITOR_ICON_SPEC_GENERATION_JOB_KIND: &str = "editor_icon_spec_generation";
pub(crate) const EDITOR_IMAGE_EDIT_JOB_KIND: &str = "editor_image_edit";
pub(crate) const EDITOR_BACKGROUND_REMOVAL_JOB_KIND: &str = "editor_background_removal";
pub(crate) const EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND: &str =
diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs
index d53ec71d8..fad7fc03b 100644
--- a/server-rs/crates/api-server/src/editor_project.rs
+++ b/server-rs/crates/api-server/src/editor_project.rs
@@ -1,6 +1,7 @@
use std::{
borrow::Cow,
collections::{BTreeMap, HashSet},
+ future::Future,
io::Cursor,
sync::{
Arc, LazyLock,
@@ -18,13 +19,7 @@ use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectUpsertInput,
build_asset_object_upsert_input, generate_asset_object_id,
};
-use platform_image::{
- DownloadedImage,
- generated_asset_sheets::{
- GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetConnectedIconPlan,
- GeneratedAssetSheetError, prepare_generated_icon_spritesheet_all_by_connected_components,
- },
-};
+use platform_image::DownloadedImage;
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest,
};
@@ -49,18 +44,18 @@ use spacetime_client::editor_project::{
EditorGenerationResultPersistRecordInput, EditorGenerationTargetPreflightRecordInput,
EditorPixelArtCanvasCompletionRecordInput, EditorPixelArtCanvasPlaceholderRecordInput,
EditorPixelArtResultPersistRecordInput, EditorPixelArtResultPreflightRecordInput,
- EditorSpritesheetSliceBatchPersistRecordInput, EditorSpritesheetSlicePersistItemRecordInput,
};
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord,
- EditorAssetFolderUpdateRecordInput, EditorAssetGroupSourceLookupRecordInput,
- EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord,
- EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord,
- EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
+ EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord,
+ EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput,
+ EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
+ EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput,
EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput,
+ EditorReferenceRecord, EditorReferenceResolveRecordInput,
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput,
EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord,
@@ -72,7 +67,9 @@ use spacetime_client::{
use crate::{
api_response::json_success_body,
asset_billing::{
- begin_editor_generation_commit_attempt, editor_generation_commit_attempt_was_dispatched,
+ begin_editor_generation_commit_attempt,
+ current_external_generation_billing_price_mud_points,
+ editor_generation_commit_attempt_was_dispatched,
execute_billable_asset_operation_with_cost, mark_editor_generation_commit_dispatched,
mark_editor_generation_commit_outcome_confirmed,
with_editor_generation_durable_billing_boundary,
@@ -86,9 +83,14 @@ use crate::{
enqueue_editor_generation_job, enqueue_editor_generation_job_for_caller,
},
editor_green_screen::{
- EditorScreenBackgroundColor, editor_green_screen_asset_prompt_clause,
- editor_green_screen_character_prompt_clause, editor_ui_design_asset_extraction_prompt,
- remove_editor_generated_green_screen_background,
+ EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause,
+ editor_ui_design_asset_extraction_prompt, remove_editor_generated_green_screen_background,
+ },
+ editor_project_icon::{
+ EditorIconSpritesheetGenerationResponse, EditorIconSpritesheetIconResponse,
+ PersistEditorSpritesheetSlicesInput, editor_icon_spritesheet_slice_warning_from_error,
+ editor_icon_spritesheet_warning_after_persist_error,
+ prepare_editor_spritesheet_slices_for_generation, slice_editor_icon_spritesheet_all,
},
editor_screen_background_decision::{
EditorScreenBackgroundDecision, EditorScreenBackgroundDecisionInput,
@@ -104,8 +106,9 @@ use crate::{
},
http_error::AppError,
openai_image_generation::{
- DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiReferenceImage,
- build_openai_image_http_client, create_openai_image_edit_with_references_and_model,
+ DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages, OpenAiImageSettings,
+ OpenAiReferenceImage, build_openai_image_http_client,
+ create_openai_image_edit_with_references_and_model,
create_openai_image_generation_with_model, create_openai_nanobanana_generate_content,
require_openai_image_settings,
},
@@ -116,6 +119,11 @@ use crate::{
work_author::{ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID},
};
+#[cfg(test)]
+use crate::editor_project_icon::*;
+#[cfg(test)]
+use platform_image::generated_asset_sheets::GeneratedAssetSheetError;
+
const EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE: usize = 36;
const MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES: usize = 512 * 1024;
const MAX_EDITOR_GENERATION_WARNING_CHARS: usize = 2_048;
@@ -133,29 +141,17 @@ const EDITOR_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024;
pub(crate) const EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES: usize = 4 * 1024 * 1024;
pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
const EDITOR_ASSET_METADATA_MAX_BYTES: usize = 64 * 1024;
-const EDITOR_ASSET_DEFAULT_FOLDER_ID: &str = "project";
+pub(crate) const EDITOR_ASSET_DEFAULT_FOLDER_ID: &str = "project";
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
-const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
+pub(crate) const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
-const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
-const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096;
-const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048;
-const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 64;
-const EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY: usize = 2;
-const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2;
-const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2;
-const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 = EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4;
-const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
-const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
-const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30);
const EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT: usize = 5;
const EDITOR_QUICK_EDIT_REFERENCE_LIMIT: usize = 9;
const EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT: usize = 8;
-const EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT: usize = 8;
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5;
-fn editor_provider_reference_limit(model: &str) -> usize {
+pub(crate) fn editor_provider_reference_limit(model: &str) -> usize {
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
14
} else {
@@ -164,22 +160,13 @@ fn editor_provider_reference_limit(model: &str) -> usize {
}
const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image";
const EDITOR_CHARACTER_ANIMATION_CONTRACT_KIND: &str = "character-animation";
-const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project";
+pub(crate) const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project";
const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character";
const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image";
const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image";
const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image";
const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image";
const EDITOR_IMAGE_EDIT_ASSET_KIND: &str = "editor_image_edit";
-const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet";
-const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice";
-const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components";
-const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed";
-const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT: &str = "raw-component-limit-exceeded";
-const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT: &str = "output-slice-limit-exceeded";
-const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT: &str = "merge-candidate-limit-exceeded";
-const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT: &str = "crop-pixel-limit-exceeded";
-const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT: &str = "slice-processing-timeout";
const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved";
const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback";
const EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE: &str = "unsupported-image-style";
@@ -229,23 +216,11 @@ static EDITOR_PIXEL_ART_SNAP_LIMITER: LazyLock> = La
))
});
static EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH: AtomicUsize = AtomicUsize::new(0);
-static EDITOR_ICON_SPRITESHEET_CPU_LIMITER: LazyLock> =
- LazyLock::new(|| {
- Arc::new(tokio::sync::Semaphore::new(
- EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY,
- ))
- });
-static EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER: LazyLock> =
- LazyLock::new(|| {
- Arc::new(tokio::sync::Semaphore::new(
- EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY,
- ))
- });
const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1;
const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet";
const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset";
const EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source";
-const EDITOR_PROVIDER_SOURCE_SLOT: &str = "provider_source";
+pub(crate) const EDITOR_PROVIDER_SOURCE_SLOT: &str = "provider_source";
pub(crate) const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet";
const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg";
const EDITOR_BGFILTER_MATTING_PROVIDER: &str = "BgFilter";
@@ -383,6 +358,69 @@ pub struct EditorImageGenerationRequest {
pub(crate) canvas_completion: Option,
}
+pub(crate) struct EditorImagePromptBuildOutput {
+ pub(crate) prompt: String,
+ pub(crate) generation_inputs: Option,
+}
+
+pub(crate) struct EditorImageProviderRequest<'a> {
+ pub(crate) model: &'a str,
+ pub(crate) prompt: &'a str,
+ pub(crate) negative_prompt: Option<&'a str>,
+ pub(crate) size: &'a str,
+ pub(crate) aspect_ratio: &'a str,
+ pub(crate) image_size: &'a str,
+ pub(crate) reference_images: &'a [OpenAiReferenceImage],
+ pub(crate) failure_context: &'a str,
+}
+
+pub(crate) async fn request_editor_generated_images(
+ http_client: &reqwest::Client,
+ settings: &OpenAiImageSettings,
+ request: EditorImageProviderRequest<'_>,
+) -> Result {
+ if request.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
+ create_openai_nanobanana_generate_content(
+ http_client,
+ settings,
+ request.model,
+ request.prompt,
+ request.negative_prompt,
+ request.aspect_ratio,
+ request.image_size,
+ request.reference_images,
+ request.failure_context,
+ )
+ .await
+ } else if request.reference_images.is_empty() {
+ create_openai_image_generation_with_model(
+ http_client,
+ settings,
+ request.model,
+ request.prompt,
+ request.negative_prompt,
+ request.size,
+ 1,
+ &[],
+ request.failure_context,
+ )
+ .await
+ } else {
+ create_openai_image_edit_with_references_and_model(
+ http_client,
+ settings,
+ request.model,
+ request.prompt,
+ request.negative_prompt,
+ request.size,
+ 1,
+ request.reference_images,
+ request.failure_context,
+ )
+ .await
+ }
+}
+
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorImageEditRequest {
@@ -431,36 +469,6 @@ pub struct EditorPixelArtSnapRequest {
pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest,
}
-#[derive(Clone, Debug, Deserialize, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct EditorIconSpritesheetGenerationRequest {
- pub(crate) reference_image_src: String,
- pub(crate) reference_image_srcs: Option>,
- pub(crate) icon_descriptions: Vec,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub(crate) style: Option,
- pub(crate) model: Option,
- pub(crate) screen_color: Option,
- pub(crate) seg_model: Option,
- pub(crate) aspect_ratio: Option,
- pub(crate) image_size: Option,
- pub(crate) project_id: Option,
- pub(crate) generation_inputs: Option,
- pub(crate) asset_folder_id: Option,
- pub(crate) asset_label: Option,
- pub(crate) canvas_completion: Option,
-}
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct EditorIconSpritesheetSliceRequest {
- pub(crate) project_id: String,
- pub(crate) source_layer_id: String,
- pub(crate) source_resource_id: String,
- pub(crate) asset_folder_id: Option,
- pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest,
-}
-
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorUiDesignAssetExtractionRequest {
@@ -552,7 +560,7 @@ pub(crate) struct EditorGenerationCaller {
}
impl EditorGenerationCaller {
- fn from_authenticated(authenticated: &AuthenticatedAccessToken) -> Self {
+ pub(crate) fn from_authenticated(authenticated: &AuthenticatedAccessToken) -> Self {
let owner_user_id = authenticated.claims().user_id().to_string();
Self {
audit_subject_user_id: Some(owner_user_id.clone()),
@@ -563,7 +571,7 @@ impl EditorGenerationCaller {
}
}
- fn with_inline_operation(
+ pub(crate) fn with_inline_operation(
mut self,
request_context: &RequestContext,
operation_kind: &str,
@@ -589,7 +597,7 @@ impl EditorGenerationCaller {
Ok(self)
}
- async fn report_processing_phase(&self, state: &AppState) -> Result<(), AppError> {
+ pub(crate) async fn report_processing_phase(&self, state: &AppState) -> Result<(), AppError> {
if let Some(reporter) = self.phase_reporter.as_ref() {
reporter.report_processing(state).await?;
}
@@ -1052,7 +1060,7 @@ fn is_editor_canvas_revision_conflict(error: &AppError) -> bool {
.is_some_and(|message| message.starts_with("图片画布版本冲突:"))
}
-fn editor_generation_job_result_payload_json(
+pub(crate) fn editor_generation_job_result_payload_json(
caller: &EditorGenerationCaller,
compact_result: Value,
) -> Option {
@@ -1502,7 +1510,7 @@ struct EditorGeneratedImageStorageProfile {
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-enum EditorImageGenerationStyle {
+pub(crate) enum EditorImageGenerationStyle {
#[default]
None,
PixelArt,
@@ -1545,7 +1553,7 @@ fn editor_postprocess_fallback_warning(reason: &'static str) -> EditorGeneration
}
}
-fn editor_pixel_art_fallback_warning() -> EditorGenerationWarningResponse {
+pub(crate) fn editor_pixel_art_fallback_warning() -> EditorGenerationWarningResponse {
editor_postprocess_fallback_warning("像素规整未完成,已保留原始生成结果。")
}
@@ -1582,7 +1590,7 @@ fn editor_unsupported_image_style_warning() -> EditorGenerationWarningResponse {
}
}
-fn merge_editor_generation_warnings(
+pub(crate) fn merge_editor_generation_warnings(
current: Option,
next: Option,
) -> Option {
@@ -1601,7 +1609,7 @@ fn merge_editor_generation_warnings(
}
}
-fn normalize_editor_image_generation_style(
+pub(crate) fn normalize_editor_image_generation_style(
value: Option<&str>,
pixel_art_supported: bool,
) -> (
@@ -1690,52 +1698,6 @@ pub struct EditorPixelArtSnapResponse {
project: Option,
}
-#[derive(Debug, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct EditorIconSpritesheetIconResponse {
- name: String,
- image_src: String,
- width: u32,
- height: u32,
- resource: Option,
- asset: Option,
-}
-
-#[derive(Debug, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct EditorIconSpritesheetSliceWarningResponse {
- code: &'static str,
- reason: String,
-}
-
-#[derive(Debug, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct EditorIconSpritesheetGenerationResponse {
- spritesheet_image_src: String,
- spritesheet_width: u32,
- spritesheet_height: u32,
- icon_image_srcs: Vec,
- #[serde(skip_serializing_if = "Option::is_none")]
- slice_warning: Option,
- prompt: String,
- actual_prompt: Option,
- model: String,
- task_id: String,
- price_mud_points: u32,
- spritesheet_resource: Option,
- spritesheet_asset: Option,
- project: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- warning: Option,
-}
-
-#[derive(Debug, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct EditorIconSpritesheetSliceResponse {
- icon_image_srcs: Vec,
- project: EditorProjectPayload,
-}
-
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectPayload {
@@ -2511,15 +2473,14 @@ fn build_editor_scene_generation_inputs(
json!({ "title": "清晰度", "value": generation_options.image_size }),
json!({ "title": "生成模型", "value": generation_options.model }),
];
- if payload.style_preset.trim() == "custom" {
- if let Some(custom_style) = payload
+ if payload.style_preset.trim() == "custom"
+ && let Some(custom_style) = payload
.custom_style
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
- {
- fields.push(json!({ "title": "自定义画风", "value": custom_style }));
- }
+ {
+ fields.push(json!({ "title": "自定义画风", "value": custom_style }));
}
json!({ "fields": fields, "references": references })
}
@@ -2546,14 +2507,6 @@ fn normalize_editor_scene_generation_options(
)
}
-fn editor_image_generation_request_label(kind: Option<&str>) -> &'static str {
- if kind.is_some_and(|kind| kind.trim() == "scene") {
- "图片画布生成游戏场景"
- } else {
- "图片画布生成图片"
- }
-}
-
fn normalize_editor_scene_asset_label(asset_label: Option) -> String {
resolve_editor_generated_asset_label(asset_label, "游戏场景")
}
@@ -2620,29 +2573,18 @@ pub async fn generate_editor_scene(
},
));
}
- generate_editor_image_for_owner(&state, &request_context, caller, image_payload).await
-}
-
-pub(crate) fn ensure_generic_editor_image_generation_contract(
- payload: &EditorImageGenerationRequest,
-) -> Result<(), AppError> {
- if payload
- .kind
- .as_deref()
- .is_some_and(|kind| kind.trim() == "scene")
- || payload
- .asset_kind
- .as_deref()
- .is_some_and(|asset_kind| asset_kind.trim() == "scene")
- {
- return Err(
- AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
- "provider": "editor-image-generation",
- "message": "游戏场景必须通过 /api/editor/scenes/generations 提交结构化场景意图",
- })),
- );
- }
- Ok(())
+ let caller = caller.with_inline_operation(
+ &request_context,
+ EDITOR_IMAGE_GENERATION_JOB_KIND,
+ &image_payload,
+ )?;
+ with_editor_generation_durable_billing_boundary(generate_editor_image_for_owner(
+ &state,
+ &request_context,
+ caller,
+ image_payload,
+ ))
+ .await
}
pub async fn generate_editor_image(
@@ -2691,15 +2633,16 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner(
mut payload: EditorImageGenerationRequest,
external_idempotency_key: Option<&str>,
) -> Result {
+ validate_editor_image_generation_prompt(payload.prompt.as_str())?;
payload.generation_inputs =
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take());
- let normalized_kind = payload.kind.as_deref().map(str::trim);
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-image-generation",
"referenceImageSrcs",
"生成参考图",
)?;
+ let normalized_kind = payload.kind.as_deref().map(str::trim);
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
let is_publication_material_generation =
matches!(normalized_kind, Some("publication-material"));
@@ -2729,10 +2672,10 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner(
state,
caller.owner_user_id.as_str(),
payload.project_id.clone(),
- payload.asset_folder_id.clone().or_else(|| {
- matches!(normalized_kind, Some("character"))
- .then(|| EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())
- }),
+ resolve_editor_image_generation_target_folder_id(
+ payload.asset_folder_id.clone(),
+ normalized_kind,
+ ),
)
.await?;
payload.project_id = target.project_id;
@@ -2763,7 +2706,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner(
caller.owner_user_id.as_str(),
EDITOR_IMAGE_GENERATION_JOB_KIND,
source_entity_id,
- editor_image_generation_request_label(normalized_kind),
+ "图片画布生成图片",
price_mud_points,
&payload,
external_idempotency_key,
@@ -2771,23 +2714,106 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner(
.await
}
-pub(crate) async fn generate_editor_image_for_owner(
+/// Runs the non-generating validation and authorization required before a caller performs
+/// preparatory provider work such as icon-spec metadata completion. Final dispatch deliberately
+/// validates again because queued execution can happen later in another process.
+pub(crate) async fn validate_editor_image_generation_parameters_for_owner(
state: &AppState,
request_context: &RequestContext,
- caller: EditorGenerationCaller,
- mut payload: EditorImageGenerationRequest,
-) -> Result, AppError> {
- payload.generation_inputs =
- sanitize_editor_client_generation_inputs(payload.generation_inputs.take());
- let normalized_kind = payload.kind.as_deref().map(str::trim);
+ caller: &EditorGenerationCaller,
+ payload: &EditorImageGenerationRequest,
+) -> Result<(), AppError> {
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-image-generation",
"referenceImageSrcs",
"生成参考图",
)?;
- let role_setting = payload.prompt.trim().to_string();
- if role_setting.is_empty() {
+ let normalized_kind = payload.kind.as_deref().map(str::trim);
+ if matches!(normalized_kind, Some("character")) {
+ parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
+ }
+ let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
+ let is_publication_material_generation =
+ matches!(normalized_kind, Some("publication-material"));
+ let generation_options = normalize_editor_generation_options(
+ if is_ui_design_generation || is_publication_material_generation {
+ Some(GPT_IMAGE_2_MODEL)
+ } else {
+ payload.model.as_deref()
+ },
+ payload.aspect_ratio.as_deref(),
+ payload.image_size.as_deref(),
+ );
+ let reference_limit = if matches!(normalized_kind, Some("quick-edit")) {
+ EDITOR_QUICK_EDIT_REFERENCE_LIMIT
+ .min(editor_provider_reference_limit(generation_options.model))
+ } else {
+ EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT
+ };
+ ensure_editor_reference_image_source_limit(
+ payload.reference_image_srcs.as_deref(),
+ reference_limit,
+ "editor-image-generation",
+ "referenceImageSrcs",
+ "生成参考图",
+ )?;
+ for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref())
+ {
+ parse_editor_reference_image(state, caller.owner_user_id.as_str(), source).await?;
+ }
+
+ let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
+ request_context,
+ caller.audit_subject_user_id.clone(),
+ caller
+ .audit_project_id
+ .clone()
+ .or_else(|| payload.project_id.clone()),
+ );
+ build_openai_image_http_client(&settings)?;
+ state
+ .editor_generation_pricing()
+ .await
+ .map_err(|error| {
+ AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
+ "provider": "editor-generation-pricing",
+ "message": error.to_string(),
+ }))
+ })?
+ .image_generation_mud_points(
+ normalized_kind,
+ Some(generation_options.model),
+ Some(generation_options.image_size),
+ );
+ Ok(())
+}
+
+pub(crate) async fn generate_editor_image_for_owner(
+ state: &AppState,
+ request_context: &RequestContext,
+ caller: EditorGenerationCaller,
+ payload: EditorImageGenerationRequest,
+) -> Result, AppError> {
+ validate_editor_image_generation_prompt(payload.prompt.as_str())?;
+ let prompt = payload.prompt.clone();
+ generate_editor_image_for_owner_with_prompt_builder(
+ state,
+ request_context,
+ caller,
+ payload,
+ move |generation_inputs| async move {
+ Ok(EditorImagePromptBuildOutput {
+ prompt,
+ generation_inputs,
+ })
+ },
+ )
+ .await
+}
+
+fn validate_editor_image_generation_prompt(prompt: &str) -> Result<(), AppError> {
+ if prompt.trim().is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-generation",
@@ -2795,7 +2821,29 @@ pub(crate) async fn generate_editor_image_for_owner(
})),
);
}
+ Ok(())
+}
+pub(crate) async fn generate_editor_image_for_owner_with_prompt_builder(
+ state: &AppState,
+ request_context: &RequestContext,
+ caller: EditorGenerationCaller,
+ mut payload: EditorImageGenerationRequest,
+ prompt_builder: F,
+) -> Result, AppError>
+where
+ F: FnOnce(Option) -> Fut,
+ Fut: Future