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 1597a2a12..155022c7f 100644
--- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md
+++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md
@@ -44,7 +44,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` |
@@ -70,19 +70,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 1f3d04e3c..08f493498 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
@@ -167,6 +167,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 bce78e20e..e2f3a99e9 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,7 +577,7 @@ class GenarrativeExternalClient:
return self.submit_and_wait_generation(
"/api/external/v1/editor/icon-spritesheets/generations",
{
- "referenceImageSrc": reference_image_src,
+ "referenceId": reference_id,
"iconDescriptions": descriptions,
**fields,
},
@@ -768,7 +771,7 @@ def _self_test() -> None:
assetLabel="贪吃蛇透明图集",
)
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/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
index d01147a24..126ebfa2f 100644
--- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
+++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md
@@ -25,7 +25,7 @@
- 点击后弹出菜单:`从画布中选择`、`新建图标规范`、`上传图片`。
- `从画布中选择` 进入画布点选状态,只允许选择 `assetKind: "icon-spec"` 的图标规范图片;其它图片点击无效。
- `新建图标规范` 复用生成规范表单,规格类型为 `图标规范`,生成成功后图层标记为 `icon-spec`。
- - `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布。
+ - `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布;存在当前项目时创建 `assetKind: "icon-spec"` 的项目资源,不存在当前项目时创建同类型账号素材,提交图集生成时只使用持久化返回的 `resourceId` 或 `assetId`,不得按通用 `image` 类型登记。
2. 第二模块为素材描述文本框。
- UI 复用角色形象生成面板同款单个文本输入框,让用户直接叙述多个素材。
- 默认按换行填入:`返回按钮`、`设置按钮`、`下一关按钮`、`提示按钮`、`原图按钮`、`冻结按钮`。
diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx
index 6a22b4dbb..e39d77fdc 100644
--- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx
+++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx
@@ -3187,6 +3187,90 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.queryByAltText('画布图片:背包按钮')).toBeNull();
});
+ it('uploads an authoritative icon spec before submitting icon spritesheet generation', async () => {
+ uploadEditorMediaAssetFileMock.mockResolvedValueOnce({
+ objectKey: 'generated-character-drafts/editor/icon-specs/uploaded.png',
+ assetObjectId: 'asset-object-uploaded-icon-spec',
+ legacyPublicPath:
+ '/generated-character-drafts/editor/icon-specs/uploaded.png',
+ src: 'https://oss.example.com/icon-specs/uploaded.png',
+ });
+ createEditorProjectResourceMock.mockImplementationOnce(
+ async (projectId, input) => ({
+ resourceId: 'resource-uploaded-icon-spec',
+ projectId,
+ imageSrc: input.imageSrc,
+ objectKey: input.objectKey,
+ assetObjectId: input.assetObjectId,
+ width: input.width,
+ height: input.height,
+ sourceType: input.sourceType,
+ assetKind: input.assetKind,
+ generationInputs: input.generationInputs,
+ }),
+ );
+ generateEditorIconSpritesheetMock.mockResolvedValueOnce({
+ spritesheetImageSrc: 'data:image/png;base64,dXBsb2FkZWQtc2hlZXQ=',
+ spritesheetWidth: 512,
+ spritesheetHeight: 512,
+ iconImageSrcs: [],
+ prompt: '图标 prompt',
+ actualPrompt: '图标 prompt',
+ model: 'gemini-3.1-flash-image-preview',
+ provider: 'VectorEngine',
+ taskId: 'icon-task-uploaded-spec-1',
+ });
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
+ const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
+ fireEvent.click(
+ within(iconPanel).getByRole('button', { name: '图标规范' }),
+ );
+ fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
+ await userEvent.upload(
+ screen.getByLabelText('上传媒体文件'),
+ new File(['uploaded-icon-spec'], '上传图标规范.png', {
+ type: 'image/png',
+ }),
+ );
+
+ await waitFor(() => {
+ expect(
+ within(iconPanel).getByRole('button', { name: '上传图标规范.png' }),
+ ).toBeTruthy();
+ });
+ expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
+ 'editor-project-default',
+ expect.objectContaining({
+ objectKey: 'generated-character-drafts/editor/icon-specs/uploaded.png',
+ assetKind: 'icon-spec',
+ sourceType: 'uploaded',
+ }),
+ );
+
+ fireEvent.change(
+ within(iconPanel).getByRole('textbox', { name: '素材描述' }),
+ { target: { value: '上传后的返回按钮' } },
+ );
+ fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
+
+ await waitFor(() => {
+ expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ referenceId: 'resource-uploaded-icon-spec',
+ iconDescriptions: ['上传后的返回按钮'],
+ projectId: 'editor-project-default',
+ }),
+ );
+ });
+ expect(
+ createEditorProjectResourceMock.mock.invocationCallOrder[0],
+ ).toBeLessThan(
+ generateEditorIconSpritesheetMock.mock.invocationCallOrder[0]!,
+ );
+ });
+
it('exits character generation canvas picking with Escape', () => {
render();
diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx
index d9d398dcd..a28c9e0df 100644
--- a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx
+++ b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx
@@ -333,6 +333,18 @@ function UploadWorkflowHarness({
>
准备规范生成
+
+