修复图标规范上传类型合同

图标规范上传在项目资源与账号素材分支统一登记为 icon-spec。

补充上传后提交图集生成及两条持久化分支回归测试。

同步编辑器设计文档、外部 API Skill 与 Python helper 的 referenceId 合同。
This commit is contained in:
2026-08-07 11:15:11 +08:00
parent 5eee62cf39
commit f8bcf7cf54
9 changed files with 177 additions and 10 deletions
@@ -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.
@@ -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:
@@ -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 `<img>`, 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.
@@ -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
@@ -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"
@@ -25,7 +25,7 @@
- 点击后弹出菜单:`从画布中选择``新建图标规范``上传图片`
- `从画布中选择` 进入画布点选状态,只允许选择 `assetKind: "icon-spec"` 的图标规范图片;其它图片点击无效。
- `新建图标规范` 复用生成规范表单,规格类型为 `图标规范`,生成成功后图层标记为 `icon-spec`
- `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布。
- `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布;存在当前项目时创建 `assetKind: "icon-spec"` 的项目资源,不存在当前项目时创建同类型账号素材,提交图集生成时只使用持久化返回的 `resourceId``assetId`,不得按通用 `image` 类型登记
2. 第二模块为素材描述文本框。
- UI 复用角色形象生成面板同款单个文本输入框,让用户直接叙述多个素材。
- 默认按换行填入:`返回按钮``设置按钮``下一关按钮``提示按钮``原图按钮``冻结按钮`
@@ -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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
@@ -333,6 +333,18 @@ function UploadWorkflowHarness({
>
</button>
<button
type="button"
onClick={() =>
setGenerateDialog({
mode: 'icon',
prompt: '',
status: 'idle',
})
}
>
</button>
<button
type="button"
onClick={() =>
@@ -372,6 +384,12 @@ function UploadWorkflowHarness({
>
</button>
<button
type="button"
onClick={() => workflow.setUploadTarget('icon-spec')}
>
</button>
<button
type="button"
onClick={() => workflow.requestUpload('character-spec')}
@@ -753,6 +771,7 @@ describe('useImageCanvasUploadWorkflow', () => {
width: 420,
height: 315,
sourceType: 'uploaded',
assetKind: 'image',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
@@ -1146,6 +1165,57 @@ describe('useImageCanvasUploadWorkflow', () => {
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('persists uploaded icon specs as icon-spec project resources', async () => {
render(<UploadWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '准备图标素材生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [createTestFile('上传图标规范.png')],
},
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'icon:idle:-:0:上传图标规范.png',
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'project-1',
expect.objectContaining({
assetKind: 'icon-spec',
sourceType: 'uploaded',
}),
);
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('persists uploaded icon specs as icon-spec assets without a project', async () => {
render(<UploadWorkflowHarness projectId={null} />);
fireEvent.click(screen.getByRole('button', { name: '准备图标素材生成' }));
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
fireEvent.change(screen.getByLabelText('上传图片文件'), {
target: {
files: [createTestFile('素材库图标规范.png')],
},
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain(
'icon:idle:-:0:素材库图标规范.png',
);
});
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
assetKind: 'icon-spec',
sourceType: 'uploaded',
}),
);
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('reserves the single spec reference slot across overlapping uploads', async () => {
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
const uploadGate = createDeferred<void>();
@@ -21,6 +21,7 @@ import {
resolveUploadAssetFolder,
} from './ImageCanvasAssetLibraryModel';
import type {
CanvasAssetKind,
CanvasHistoryAction,
CanvasLayer,
CanvasMediaType,
@@ -293,6 +294,7 @@ export function useImageCanvasUploadWorkflow({
objectKey,
assetObjectId,
reservation,
authoritativeAssetKind,
}: {
idPrefix: string;
index?: number;
@@ -306,6 +308,7 @@ export function useImageCanvasUploadWorkflow({
objectKey?: string | null;
assetObjectId?: string | null;
reservation?: ImageReferenceUploadReservation;
authoritativeAssetKind?: CanvasAssetKind;
}): Promise<CharacterReferenceImage> => {
const resolvedMediaType = mediaType ?? 'image';
let persistedImageSrc = imageSrc;
@@ -330,7 +333,8 @@ export function useImageCanvasUploadWorkflow({
) {
throw new ImageReferenceCapacityChangedError();
}
const assetKind = resolveUploadedAssetKind(resolvedMediaType);
const assetKind =
authoritativeAssetKind ?? resolveUploadedAssetKind(resolvedMediaType);
const generationInputs = buildUploadedMediaGenerationInputs(
resolvedMediaType,
durationSeconds,
@@ -419,6 +423,7 @@ export function useImageCanvasUploadWorkflow({
durationSeconds?: number;
objectKey?: string | null;
assetObjectId?: string | null;
authoritativeAssetKind?: CanvasAssetKind;
}>,
reservation?: ImageReferenceUploadReservation,
) => {
@@ -1174,6 +1179,7 @@ export function useImageCanvasUploadWorkflow({
file: imageFile,
fallbackLabel: '图标规范',
imageSrc,
authoritativeAssetKind: 'icon-spec',
},
],
reservation,