统一画布快速编辑白名单与入口行为 #140
@@ -29,7 +29,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
|
||||
- Authenticate MCP and business API calls with `Authorization: Bearer <tnr_sk_...>`. Never ask the user to paste a key into chat or place one in repository files.
|
||||
- All eight generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
|
||||
- Retry an uncertain submission only with the exact same body and the same idempotency key. A polling timeout is not permission to generate again.
|
||||
- Use stable references such as `objectKey`, project resource ID, or asset ID in generation requests. Use `/assets/read-url` only for temporary preview/download access.
|
||||
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
|
||||
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
|
||||
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
|
||||
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
|
||||
|
||||
@@ -50,7 +50,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Capability | POST path | Required body fields | Common optional body fields |
|
||||
| --- | --- | --- | --- |
|
||||
| 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` |
|
||||
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `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` |
|
||||
@@ -82,13 +82,13 @@ After confirming a local upload, pass its stable `objectKey` into operations tha
|
||||
| Target capability | Field |
|
||||
| --- | --- |
|
||||
| Image generation | `referenceImageSrcs` |
|
||||
| Image edit/redraw | `sourceImageSrc`; additional references in `referenceImageSrcs` |
|
||||
| Image edit/redraw | `sourceReferenceId` must be a registered project resource ID or asset ID; additional references remain 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.
|
||||
For image edit/redraw, confirming an upload is not sufficient: create a project resource or asset-library record first, then pass that record's ID as `sourceReferenceId`. The main source never accepts objectKey, URL, Data URL, or Blob URL. 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.
|
||||
|
||||
|
||||
@@ -169,6 +169,8 @@ client.generate_image(
|
||||
)
|
||||
```
|
||||
|
||||
Image edit/redraw has a stricter main-source identity rule. After upload confirmation, create either a project resource or an asset-library record and pass its `resourceId` or `assetId` as `sourceReferenceId`. Do not pass the uploaded objectKey as the main source; objectKey remains valid only for auxiliary `referenceImageSrcs` where the OpenAPI permits it.
|
||||
|
||||
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:
|
||||
|
||||
@@ -548,13 +548,16 @@ class GenarrativeExternalClient:
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
def edit_image(self, prompt: str, source_image_src: str, **fields: Any) -> Any:
|
||||
def edit_image(self, prompt: str, source_reference_id: str, **fields: Any) -> Any:
|
||||
source_reference_id = source_reference_id.strip()
|
||||
if not source_reference_id:
|
||||
raise GenarrativeApiError("source_reference_id must be a registered resource or asset ID")
|
||||
self._apply_canvas_session_fields(fields, prompt, 1024, 1024)
|
||||
prompt = self._apply_art_spec(fields, prompt)
|
||||
idempotency_key = fields.pop("idempotencyKey", None)
|
||||
return self.submit_and_wait_generation(
|
||||
"/api/external/v1/editor/images/edits",
|
||||
{"prompt": prompt, "sourceImageSrc": source_image_src, **fields},
|
||||
{"prompt": prompt, "sourceReferenceId": source_reference_id, **fields},
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "陶泥儿外部编辑器 OpenAPI",
|
||||
"version": "1.0.0",
|
||||
"description": "外部系统调用陶泥儿图片画布项目、画布布局、素材库,以及图片、视频、音效、音乐等编辑器素材生成/编辑能力的 v1 契约。全部生成 POST 都是异步提交:必须携带 Idempotency-Key,收到 202 后使用 operationId 查询统一生成状态。支持远程 MCP 的 Agent 可连接 /api/external/v1/mcp;不支持 MCP 的 Agent 可从 /api/external/v1/skill.zip 下载完整 Skill 包。新建 projectId 使用 proj- 前缀,新建 taskId / operationId 使用 task- 前缀;历史 editor-project-*、aitask_*、extgen-* ID 仍可作为既有资源标识传入。\n\n兼容性说明:v1 当前处于无外部存量调用方阶段,正式对外发放 API Key 之前,契约可能在不升 info.version、不设弃用期的情况下发生包含字段移除在内的破坏性变更。生成客户端时请勿假定本文档已冻结。"
|
||||
"description": "外部系统调用陶泥儿图片画布项目、画布布局、素材库,以及图片、视频、音效、音乐等编辑器素材生成/编辑能力的 v1 契约。全部生成 POST 都是异步提交:必须携带 Idempotency-Key,收到 202 后使用 operationId 查询统一生成状态。支持远程 MCP 的 Agent 可连接 /api/external/v1/mcp;不支持 MCP 的 Agent 可从 /api/external/v1/skill.zip 下载完整 Skill 包。新建 projectId 使用 proj- 前缀,新建 taskId / operationId 使用 task- 前缀;历史 editor-project-*、aitask_*、extgen-* ID 仍可作为既有资源标识传入。\n\n兼容性说明:截至 2026-08-08,v1 经当前线上 API Key 与调用方状态确认仍无外部存量调用方;正式对外发放 API Key 之前,契约可能依据明确决策在不升 info.version、不设弃用期的情况下发生包含字段移除在内的破坏性变更。生成客户端时请勿假定本文档已冻结。"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -1088,6 +1088,17 @@
|
||||
],
|
||||
"operationId": "editExternalEditorImage",
|
||||
"summary": "重绘/调整编辑器图片素材",
|
||||
"description": "主来源只接受当前账号已登记的项目资源 ID 或素材 ID(sourceReferenceId);objectKey、URL、Data URL 与 Blob URL 即使归属当前账号也返回 400。服务端从命中的业务记录派生 canonical objectKey、assetObjectId 与权威类型;快速编辑的完整有效类型白名单为普通静态图片(类型为 null)、spec、character、icon-spritesheet、icon-spec、publication-material、ui-design 和 scene,其他及未知类型返回 400。提供 targetLayerId 时必须同时提供 projectId,目标图层必须关联有效项目资源;双方都有 assetObjectId 时必须相同,否则回退比较 canonical bucket/objectKey。同一对象的来源默认类型与目标资源默认类型冲突、目标有效类型或媒体类型不允许、来源或目标不存在/越权/缺少对象时均返回 400,任务不会入队。referenceImageSrcs 仍只作为辅助参考图。",
|
||||
|
k88936 marked this conversation as resolved
Outdated
|
||||
"x-genarrative-allowed-effective-asset-kinds": [
|
||||
null,
|
||||
"spec",
|
||||
"character",
|
||||
"icon-spritesheet",
|
||||
"icon-spec",
|
||||
"publication-material",
|
||||
"ui-design",
|
||||
"scene"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ExternalApiKey": []
|
||||
@@ -3450,28 +3461,24 @@
|
||||
"type": "object",
|
||||
|
k88936 marked this conversation as resolved
Outdated
kdletters
commented
[P1] 不能在 External v1 中直接替换必填请求字段 本次把既有必填 **[P1] 不能在 External v1 中直接替换必填请求字段**
本次把既有必填 `sourceImageSrc` 删除并改成新的必填 `sourceReferenceId`,严格客户端会立即失败;这正属于现役版本策略列出的 breaking change。文档中的唯一历史豁免只基于 2026-07-31 当时没有外部第三方调用方,不能自动覆盖今天的新改动。请保留并弃用兼容字段、开 `/v2`,或先用当前线上 API Key/调用证据重新确认豁免仍成立并形成明确的新决策记录;同时修正本 schema 下游仍称 `sourceImageSrc` 占用 provider 容量的陈旧说明。
|
||||
"required": [
|
||||
"prompt",
|
||||
"sourceImageSrc"
|
||||
"sourceReferenceId"
|
||||
],
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sourceImageSrc": {
|
||||
"sourceReferenceId": {
|
||||
"type": "string",
|
||||
"description": "待重绘/调整图片的稳定引用:当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。"
|
||||
"minLength": 1,
|
||||
"description": "待编辑主来源的业务 ID,只接受当前账号已登记的项目资源 ID 或素材 ID。objectKey、普通 URL、签名 URL、Data URL、Blob URL 和未登记上传对象均返回 400;上传对象必须先登记为项目资源或素材。服务端从命中记录派生 canonical objectKey、assetObjectId 与权威类型。"
|
||||
},
|
||||
"projectId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"assetKind": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
],
|
||||
"description": "项目上下文。提供 targetLayerId 时必须同时提供非空 projectId,否则返回 400。"
|
||||
},
|
||||
"generationInputs": {
|
||||
"$ref": "#/components/schemas/JsonValue"
|
||||
@@ -3488,18 +3495,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sourceResourceId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"targetLayerId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "带 projectId 且未提供 canvasCompletion 时,服务端用生成结果替换该画布图层。"
|
||||
"description": "目标画布图层。提供时必须同时提供 projectId,且图层必须关联有效项目资源。来源与目标都有 assetObjectId 时按 ID 比较;任一缺失时回退比较 canonical bucket/objectKey。来源记录默认类型必须与目标资源默认类型一致,最终类型取 assetKindOverride 或目标资源类型,且媒体类型必须为图片;违反任一条件返回 400。未提供 canvasCompletion 时,生成结果替换该图层。"
|
||||
},
|
||||
"size": {
|
||||
"type": "string"
|
||||
@@ -3531,7 +3532,7 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
|
||||
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceReferenceId 对应的主来源原图占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
|
||||
},
|
||||
"maxItems": 8
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -848,7 +848,7 @@
|
||||
|
||||
- 现象:用户点击图片素材的“快速编辑”后,画布上额外出现 `Quick Edit Generator` 占位,像是新建了一个生成器;但用户预期是在原图下方框选区域、填写一个提示词和模型,然后直接修改当前图。
|
||||
- 原因:快速编辑入口和提交链路误用了 `createQuickEditGenerationDialogDraft(...)` / `CanvasGenerationDialogState`,把“覆盖源图”的快速编辑伪装成会产出新图层的生成器占位。
|
||||
- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,把当前图片或带编号标注的图片作为 `sourceImageSrc`,成功后覆盖源图,失败时保留快速编辑面板。快速编辑任务进入 `generating` 后必须移除框选工具和覆盖层,禁止继续新增框选;失败恢复面板后可继续调整框选再重试。图片重绘、去背景、视频快速编辑等会产出新图层或异步占位的入口仍可走 generation dialog / placement 链路。
|
||||
- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,主来源始终使用当前图片已登记的 `resourceId` 或 `sourceAssetId`。带编号标注的图片上传后只作为辅助 `referenceImageSrcs`,不能替换主来源身份;成功后覆盖源图,失败时保留快速编辑面板。快速编辑任务进入 `generating` 后必须移除框选工具和覆盖层,禁止继续新增框选;失败恢复面板后可继续调整框选再重试。图片重绘、去背景、视频快速编辑等会产出新图层或异步占位的入口仍可走 generation dialog / placement 链路。
|
||||
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx -- --runInBand`,以及按需运行 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "快速编辑|quick edit" -- --runInBand`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorImageReference.ts`。
|
||||
|
||||
@@ -895,11 +895,19 @@
|
||||
## 图片画布快速编辑元数据必须记录原图引用
|
||||
|
||||
- 现象:快速编辑生成的新图可以替换画布,但打开图片信息时“生成输入”里看不到被修改的原图。
|
||||
- 原因:信息面板直接渲染 `generationInputs.references`;快速编辑虽然把原图作为 `sourceImageSrc` 传给 provider,但如果 `buildQuickEditGenerationInputs(...)` 不把源图写成引用,后端资源和画布层都没有可展示的原图引用。
|
||||
- 原因:信息面板直接渲染 `generationInputs.references`;快速编辑虽然以 `sourceReferenceId` 指定原图,但如果 `buildQuickEditGenerationInputs(...)` 不把该业务 ID 写成引用,后端资源和画布层都没有可展示的原图引用。
|
||||
- 处理:快速编辑的 `generationInputs.references` 必须始终包含 `原图`,再追加用户额外参考图;关闭额外参考图入口时也不能删除这条源图引用。
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx -- --runInBand`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts`、`src/components/image-editor/ImageCanvasMetadataModalView.tsx`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`。
|
||||
|
||||
## 图片编辑主来源不能接受 objectKey 或请求类型
|
||||
|
||||
- 现象:调用方可把 objectKey、URL 或 Data URL 当作主来源,再用请求 `assetKind` 或另一个允许编辑的目标图层为禁止类型“借壳”;无目标图层时,后端还会扫描账号全部项目和素材库。
|
||||
- 原因:HTTP DTO 同时承担外部请求与队列载荷,来源身份、存储定位和类型真相混在 `sourceImageSrc/sourceResourceId/assetKind` 中;worker 没有按业务 ID 复核入队后的身份漂移。
|
||||
- 处理:站内与 External v1 API 调用方只提交必填 `sourceReferenceId`,且只接受当前账号项目资源 ID 或素材 ID;上传对象必须先登记。后端按两张表主键分别窄查,双表同 ID 时失败关闭,objectKey 仅作为服务端解析结果。目标绑定优先比较双方 `assetObjectId`,缺失才比较 canonical `(bucket, objectKey)`,并校验双方默认类型一致。队列保存版本化解析快照,worker 执行前再次定点解析;旧任务只把既有资源 ID 或旧来源字符串本身作为业务 ID 尝试迁移,禁止 objectKey 反查和旧 `assetKind` 真相回退。
|
||||
- 验证:覆盖资源 ID、素材 ID、双表冲突、跨账号、raw objectKey/URL/Data URL/Blob URL、旧字段、禁止类型、目标对象与类型冲突、快照漂移、旧任务迁移、红框图辅助引用,以及 Canvas Agent 缺少 `reference_id`。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`docs/openapi/genarrative-external-v1.openapi.json`。
|
||||
|
||||
## 图片画布生成完成应用项目快照后也要刷新素材库
|
||||
|
||||
- 现象:部分素材生成成功后画布上已经出现结果,但左侧素材库没有立刻出现新素材,刷新页面后才显示。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -89,6 +89,8 @@ canvasCompletion?
|
||||
|
||||
场景参考图沿用普通图片生成的客户端前置门禁,最多 5 张;超限时不得发送 HTTP 请求。场景生成 POST 使用生成专用零重试策略,避免 inline 响应丢失后重复调用 Provider。
|
||||
|
||||
生成完成后的 `scene` 是静态图片素材:它属于画布素材标签的图片媒体族,允许用户把图片图层覆盖标记为 `scene`,并支持既有图片快速编辑链路。前端标签菜单与 SpacetimeDB 结构化布局白名单、前端快速编辑入口与 api-server 图片编辑白名单必须分别成对同步。该编辑能力不改变通用图片生成接口对 `kind = scene` / `assetKind = scene` 的拒绝;新场景仍必须从本节的结构化专用接口生成。
|
||||
|
||||
后端对模型、比例和清晰度先沿用 `normalize_editor_generation_options` 标准化,再使用标准化比例决定画幅描述和入队价格。
|
||||
|
||||
## 5. Prompt
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -133,7 +133,7 @@ DELETE /api/profile/api-keys/{keyId}
|
||||
|
||||
### 当前状态:v1 尚无外部存量调用方
|
||||
|
||||
截至 2026-07-31,`external_api_key` 表内没有属于外部第三方的存量调用方,v1 处于「已发布但无存量集成」阶段。本节记录的豁免只在该前提成立时有效。
|
||||
截至 2026-08-08,已按当前线上 API Key 与调用方状态再次确认没有外部第三方存量调用方,v1 仍处于「已发布但无存量集成」阶段。本节记录的豁免只在该前提成立时有效;本次确认不自动延续到今后的 breaking change,每次仍需重新取得当日线上状态并形成明确决策。
|
||||
|
||||
### 已接受的未版本化 breaking change
|
||||
|
||||
@@ -148,6 +148,8 @@ DELETE /api/profile/api-keys/{keyId}
|
||||
|
||||
2026-07-31 同一豁免还覆盖了「八类生成从同步成功响应切换为 `202 + operationId`,新增统一查询接口」这一 breaking change。旧调用方若仍把生成 POST 响应当作媒体结果会立即失败;接受原地修改 v1 的唯一依据同样是上线前已确认没有外部第三方存量调用方。托管 MCP、集成 manifest 与 Skill archive 均为新增入口,不产生既有客户端兼容债务。
|
||||
|
||||
2026-08-08「收紧图片编辑主来源契约」把 `POST /api/external/v1/editor/images/edits` 的既有必填 `sourceImageSrc` 替换为新的必填 `sourceReferenceId`,并移除可选 `sourceResourceId / assetKind`;`info.version` 继续为 `1.0.0`,路径继续为 `/api/external/v1`。严格客户端会因必填字段改名、旧字段被 `additionalProperties: false` 拒绝而立即失败,这属于本节定义的 breaking change。产品负责人已于 2026-08-08 根据当前线上 API Key 与调用方状态确认仍无外部调用方,因此明确接受本次不增加兼容字段、不新开 `/v2`、不设弃用期的原地变更。该豁免只覆盖本次字段替换,不得被后续 breaking change 自动引用。
|
||||
|
||||
### 豁免的失效条件
|
||||
|
||||
API Key 由用户在个人中心自助发放,因此「无外部调用方」不是受控状态,可能在无人决策的情况下变为假。本节豁免在下列任一条件出现后立即失效:
|
||||
@@ -214,7 +216,7 @@ SpacetimeDB procedure:
|
||||
|
||||
外部生成接口复用站内编辑器已有 DTO、入队器和 worker executor,不维护第二套生成语义:
|
||||
|
||||
- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations` 与 `/api/editor/images/edits` 的校验、模型归一、计费和持久化规则,但 External handler 固定只入队。主站和 External 的通用图片入口共用场景专用合同边界校验,禁止用 `kind = scene` 或 `assetKind = scene` 绕过后端场景 Prompt 组装;场景专用 handler 自己构造规范请求,不受该通用入口校验影响。
|
||||
- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations` 与 `/api/editor/images/edits` 的校验、模型归一、计费和持久化规则,但 External handler 固定只入队。External v1 图片修改必须提交当前账号已登记的项目资源 ID 或素材 ID 作为 `sourceReferenceId`;上传对象必须先登记为项目资源或素材。objectKey、URL、Data URL、Blob URL 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段均返回 `400`。服务端分别按资源 ID 与素材 ID 主键窄查,双表冲突、未命中、越权或对象记录无效均失败关闭,快速编辑的完整有效类型白名单为 `null / spec / character / icon-spritesheet / icon-spec / publication-material / ui-design / scene`,OpenAPI 的 `x-genarrative-allowed-effective-asset-kinds` 必须与后端白名单精确一致。请求带 `targetLayerId` 时必须同时带 `projectId`;目标图层必须关联有效项目资源,来源与目标优先比较 `assetObjectId`,缺失时比较 canonical `(bucket, objectKey)`,来源默认类型还必须与目标资源默认类型一致。入队载荷保存版本化权威快照,worker 执行前再次定点解析并拒绝身份或类型漂移;仅以素材 ID 编辑时不伪造项目资源关系。主站和 External 的通用图片入口共用场景专用合同边界校验,禁止用 `kind = scene` 或 `assetKind = scene` 绕过后端场景 Prompt 组装;场景专用 handler 自己构造规范请求,不受该通用入口校验影响。
|
||||
- 图标 spritesheet 和 UI 设计图素材提取复用站内拆分逻辑,生成图集后按连通域切片,并把图集与切片都按请求写入项目资源和素材库。
|
||||
- 角色动画、视频、音效和背景音乐复用站内编辑器生成链路;请求携带 `assetFolderId` 时按站内规则写入素材库,音频类外部调用使用 API Key 所属账号作为 asset owner。
|
||||
- API Key 管理接口仍只属于登录态个人中心,不进入外部 OpenAPI JSON。
|
||||
|
||||
@@ -94,6 +94,8 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去
|
||||
|
||||
角色动作正式字段收口使用 `node scripts/spacetime-normalize-editor-character-actions.mjs --database <database> --server-url <url>`,且同样只能由已授权 migration operator 执行。必须先发布包含 normalization cursor 索引和 `normalize_editor_character_animation_metadata_and_return` 的 SpacetimeDB 模块,在 API / worker 仍处于维护模式时先运行默认全量 dry-run;脚本固定按 `asset → project-resource → showcase → canvas` 扫描,普通 scope 每批最多 25 行,canvas 每批最多 5 行。全量 dry-run 会在不写库的情况下把 asset 计划结果投影给同 owner / task / 首帧对象精确匹配的 project-resource,再把前置 scope 的计划结果投影给 canvas 检查;因此同 task 的误标预览 MP4 会先按权威视频对象排除,最终图片序列会逐帧核对并补齐精确 `asset_object` 身份。canvas 中仍引用误标 preview resource 的普通 video layer 会按 project-resource 计划态 `video` 跳过,只有 layout 明确声明动作却指向视频,或资源规划本身失败时才形成 blocker。apply 时仍要求前置 scope 已按顺序物理完成,不能跳过 asset 直接让 project-resource 借未落库结果。历史 canvas 复制的 `sourceResourceId` 不是迁移证据,不要因它仍指向原角色而手工改库,补建资源会采用最终账号素材的 DB 血缘。出现 blocker 时脚本会打印 ID、原因、owner、project、task、对象身份和来源资源;先据此区分最终候选为零 / 多个、正式与旧版冲突、帧对象不匹配或缺失资源,不得跳过 scope。确认 dry-run 后追加 `--apply`,脚本会对每批重新 dry-run、携带该批 SHA-256 apply,并在最后从头要求四个 scope 均为零匹配、零 blocker。只有该复核通过后才发布移除 action fallback 的 API / Web。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-normalize-editor-character-actions.mjs` 与 `scripts/spacetime-migration-common.mjs`。本地切换分支时若要避免 dev publish 因 schema 冲突使用 `-c=on-conflict` 清库,启动命令必须追加 `--preserve-database`,让冲突直接失败。
|
||||
|
||||
普通图片错误素材类型清理使用 `npm run spacetime:editor-image-asset-kind:clean -- --database <database> --server-url <url>`,只能由已授权 migration operator 执行。先进入维护模式并发布包含 `clean_editor_image_asset_kind_and_return` 的 SpacetimeDB module,并保持旧版本 API / controller / worker 停止;随后运行默认全量 dry-run,核对 `asset → project-resource → showcase → canvas` 各 scope 的扫描数、命中行数、字段数和 blocker 均符合预期,再追加 `--apply`。脚本对每批重新 dry-run、绑定包含画布迁移摘要、结构化 layer 与 generation-dialog 权威 JSON 的 SHA-256,最后自动从头复核零命中;任一画布数据异常都会只输出哈希化 ID、scope 与原因并停止,不能跳过。清理只处理精确业务旧值,不修改 `asset_object.asset_kind`、MIME 或媒体类型;project-resource scope 在清行前验证同工程 migration 并将其状态纳入批次 hash,layout version 0 的 legacy 画布可以没有 migration,但 structured 画布缺 migration 必须立即形成 blocker,资源行不得先被清空;清行后能保持原 status 不变量时立即刷新摘要,否则只允许留给后续精确 canvas 字段清理收口。canvas scope 在任何布局写入前再次按 active / backfilled / rolled_back 状态验证原 migration 凭证和双份 legacy / structured 不变量,将 `editor_canvas_generation_dialog.dialog_json` 与 layer rows 一并扫描并在同一事务 patch;只允许本批资源清零及精确字段删除造成的差异,写入后从全部结构化权威行重建 layout、再次复核新状态才受控重签摘要,同时保持业务 revision、migration status 与全部时间戳不变。新版本 API、SpacetimeDB storage 创建入口、legacy 画布元数据提取和项目资源落表边界都会将 trim 后精确等于 `image` 的 `assetKind` 归一为 `NULL`,防止旧页面、滞留请求或 legacy 保存重新制造废弃值。完成零残留复核,并分别确认 cleaned backfilled 可激活、active 可继续保存、rolled_back 可重复复检后恢复应用版本,最后退出维护。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-clean-editor-image-asset-kind.mjs` 与 `scripts/spacetime-migration-common.mjs`。
|
||||
|
||||
自 2026-07-11 起,`Genarrative-Full-Build-And-Deploy` 的每日 04:00 timer 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,不得依赖下游 Job 默认值或提前各自发布;统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。上文“定时构建缺少审批人时失败”的旧口径不再作为当前 dev 定时发布行为。
|
||||
|
||||
Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
|
||||
@@ -597,7 +599,7 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit.
|
||||
|
||||
同一 API release 随包依赖还必须包含 `scripts/check-pingora-release-readiness.mjs` 与 `scripts/check-pingora-canary-live.mjs`。前者在 current release 上以 `--release-runtime-only` 汇总运行时复核,后者支撑目标 Nginx canary live smoke;缺少任一脚本时不能进入直连切换窗口。
|
||||
|
||||
`Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build/<version>/spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`scripts/spacetime-migration-common.mjs`、`scripts/spacetime-maintain-external-generation-jobs.mjs`、`scripts/spacetime-normalize-editor-character-actions.mjs`、`scripts/spacetime-migrate-editor-canvas-layout.mjs` 和 `scripts/database-backup-to-oss.mjs`,不得包含 `migration-bootstrap-secret.txt` 或任何原始 bootstrap secret。`Genarrative-Stdb-Module-Build` 只接受 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 指向的受保护 Jenkins Secret File:构建 shell 从临时文件读取原始值,强制校验为 64 位十六进制,计算 SHA-256,随后只通过 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256` 注入 Rust 编译;WASM 因而只包含摘要,不包含可下载的原文,Stdb `release-manifest.json` 以 `migration_bootstrap_secret_sha256` 记录该非敏感摘要。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制上述非敏感产物,不在目标机器 checkout Git,并在发布阶段用同一个凭据 ID 再次挂载 Secret File;publish 必须再次校验 64 位十六进制、重算 SHA-256,并与 manifest 的 `migration_bootstrap_secret_sha256` 强制匹配后才可发布。Full Build 必须保证 Stdb Build / Publish 的 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 完全相同并把同一个 ID 同时透传,不能从构建 artifact 传 secret;ID 不同、manifest 缺摘要或摘要不匹配都必须在发布前失败。
|
||||
`Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build/<version>/spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`scripts/spacetime-migration-common.mjs`、`scripts/spacetime-maintain-external-generation-jobs.mjs`、`scripts/spacetime-clean-editor-image-asset-kind.mjs`、`scripts/spacetime-normalize-editor-character-actions.mjs`、`scripts/spacetime-migrate-editor-canvas-layout.mjs` 和 `scripts/database-backup-to-oss.mjs`,不得包含 `migration-bootstrap-secret.txt` 或任何原始 bootstrap secret。`Genarrative-Stdb-Module-Build` 只接受 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 指向的受保护 Jenkins Secret File:构建 shell 从临时文件读取原始值,强制校验为 64 位十六进制,计算 SHA-256,随后只通过 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256` 注入 Rust 编译;WASM 因而只包含摘要,不包含可下载的原文,Stdb `release-manifest.json` 以 `migration_bootstrap_secret_sha256` 记录该非敏感摘要。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制上述非敏感产物,不在目标机器 checkout Git,并在发布阶段用同一个凭据 ID 再次挂载 Secret File;publish 必须再次校验 64 位十六进制、重算 SHA-256,并与 manifest 的 `migration_bootstrap_secret_sha256` 强制匹配后才可发布。Full Build 必须保证 Stdb Build / Publish 的 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 完全相同并把同一个 ID 同时透传,不能从构建 artifact 传 secret;ID 不同、manifest 缺摘要或摘要不匹配都必须在发布前失败。
|
||||
|
||||
三个 SCM Jenkinsfile 将 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 默认固定为 `genarrative-spacetime-bootstrap-secret-dev-file`。Secret File 的原文只存在于 Jenkins Credentials;credential ID、参数默认值和定时 / 发布行为以仓库 Jenkinsfile 为事实源,不能只改 Job UI,因为 Declarative Pipeline 下一次载入会重写参数定义。旧 Secret Text `genarrative-spacetime-bootstrap-secret-dev` 继续保留给 Database Import / Export,不得原地改类型或删除。
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- `快速编辑`
|
||||
|
||||
`角色动画生成面板` 同步纳入本次生成类面板交互统一:点击角色图只聚焦图层,不自动弹出底部重绘或角色动画面板;点击 `生成动画` 后像新建图片一样创建 `角色动作` 画布占位,面板跟随占位底部,参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。
|
||||
`快速编辑` 由选中图片后的浮动工具栏显式打开,图片类素材统一进入框选区域 + 单提示词 + 比例 / 尺寸 + 模型选择的修改面板,不再恢复原来源生成器,也不展示额外参考图。`icon` 与 `icon-spritesheet` 图标类素材不支持快速编辑,`icon-spec` 图标规范仍按普通图片支持快速编辑。
|
||||
`快速编辑` 由选中图片后的浮动工具栏显式打开,图片类素材统一进入框选区域 + 单提示词 + 比例 / 尺寸 + 模型选择的修改面板,不再恢复原来源生成器,也不展示额外参考图。单个拆分 `icon` 不支持快速编辑,完整 `icon-spritesheet` 与 `icon-spec` 图标规范仍按普通图片支持快速编辑。
|
||||
|
||||
## 统一布局
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
|
||||
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`。
|
||||
12. 图片快速编辑不展示用户可添加的额外参考图入口;原图已登记的 `resourceId` 或 `sourceAssetId` 始终作为 `/api/editor/images/edits` 的 `sourceReferenceId`,绘制了红框和序号的标注图上传后只作为辅助 `referenceImageSrcs`。从生成型图片编辑 V2 快照恢复且在面板中可见的附加参考图也继续作为辅助引用,不得替代主来源身份。
|
||||
13. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。
|
||||
|
||||
## 参数交互
|
||||
@@ -113,7 +113,7 @@
|
||||
- 占位图的生成器名称 / 原始尺寸、图片图层右上角素材类型标签、查看信息按钮和悬浮尺寸标签都按 viewport 反向缩放,画布缩小时保持屏幕可读尺寸。
|
||||
- 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。
|
||||
- 视频 / 角色 / 角色动作 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。
|
||||
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。
|
||||
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。快速编辑使用统一正向白名单,只支持普通静态图片、角色图、规范图、完整图标图集、UI 设计图、宣发图和视频;单个拆分图标(backend reject)、角色动作 / 序列帧、音效与背景音乐不显示快速编辑入口,提交层也必须拒绝绕过入口的调用。
|
||||
- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。
|
||||
- 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。
|
||||
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。服务端以实际媒体时长覆盖 V2 配方时必须保留 `fields[id="durationSeconds"]`,并写入归一后的有限数值,不能改写为无 `id` 的 legacy 展示字符串。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 也始终按 capability 保留改造入口,source 缺失时点击后显示不可替换原因并拒绝改造,运行期间来源变化时仍必须复检并拒绝。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频快速编辑必须把实际送入请求的源视频同步保存为 `references[id="videoReference"]`,不能只依赖 `sourceResourceId`;视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version` 或 `action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。
|
||||
@@ -191,10 +191,10 @@
|
||||
- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。
|
||||
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
|
||||
- 生成规范类图片底部展示禁用态参数按钮 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。
|
||||
- 图片快速编辑底部左侧展示比例 / 尺寸组合选择,右侧展示模型选择和 `修改` 按钮;原图或红框序号标注图作为 `sourceImageSrc` 直接编辑,不展示额外参考图条。图标与图集素材不展示快速编辑入口,图标规范仍可快速编辑。
|
||||
- 图片快速编辑底部左侧展示比例 / 尺寸组合选择,右侧展示模型选择和 `修改` 按钮;原图已登记业务 ID 作为 `sourceReferenceId`,红框序号标注图作为内部辅助 `referenceImageSrcs`,不展示额外参考图条。单个拆分图标不展示快速编辑入口,完整图标图集与图标规范仍可快速编辑。
|
||||
- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。
|
||||
- 快速编辑提交前保留提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用,不再改写成 `图N`。
|
||||
- 普通快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`;从生成型图片编辑 V2 快照恢复且在面板中可见的附加参考图除外。
|
||||
- 快速编辑提交给后端时主来源只使用原图已登记的 `sourceReferenceId`;已绘制红框和序号的标注图,以及从生成型图片编辑 V2 快照恢复且在面板中可见的附加参考图,均作为辅助 `referenceImageSrcs`,不得冒充目标图层主来源。
|
||||
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
|
||||
- 快速编辑不创建生成中占位图;提交后当前面板显示修改中,异步结果只允许回填到源图。
|
||||
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
|
||||
|
||||
@@ -154,7 +154,7 @@ pipeline {
|
||||
|
||||
stage('Archive') {
|
||||
steps {
|
||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true
|
||||
archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-clean-editor-image-asset-kind.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ pipeline {
|
||||
copyArtifacts(
|
||||
projectName: params.BUILD_JOB_NAME,
|
||||
selector: specific(params.BUILD_NUMBER_TO_DEPLOY),
|
||||
filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs",
|
||||
filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-clean-editor-image-asset-kind.mjs,scripts/spacetime-normalize-editor-character-actions.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs",
|
||||
target: '.',
|
||||
fingerprintArtifacts: true
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"admin-web:preview": "npm --prefix apps/admin-web run preview --",
|
||||
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
|
||||
"spacetime:external-generation:maintain": "node scripts/spacetime-maintain-external-generation-jobs.mjs",
|
||||
"spacetime:editor-image-asset-kind:clean": "node scripts/spacetime-clean-editor-image-asset-kind.mjs",
|
||||
"spacetime:editor-canvas-layout:migrate": "node scripts/spacetime-migrate-editor-canvas-layout.mjs",
|
||||
"spacetime:editor-canvas-resources:repair": "node scripts/spacetime-repair-editor-canvas-resources.mjs",
|
||||
"spacetime:wechat-virtual-payment:reconcile": "node scripts/reconcile-wechat-virtual-payment-order.mjs",
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { EditorSoundEffectGenerationMetadataV2 } from '../../shared/src/con
|
||||
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
|
||||
|
||||
export type CanvasAssetKind =
|
||||
| 'image'
|
||||
| 'audio'
|
||||
| 'spec'
|
||||
| 'character'
|
||||
|
||||
@@ -558,6 +558,7 @@ copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-maintain-external-generation-jobs.mjs" "${TARGET_DIR}/scripts/spacetime-maintain-external-generation-jobs.mjs" "外部生成任务维护脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-clean-editor-image-asset-kind.mjs" "${TARGET_DIR}/scripts/spacetime-clean-editor-image-asset-kind.mjs" "普通图片素材类型清理脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-normalize-editor-character-actions.mjs" "${TARGET_DIR}/scripts/spacetime-normalize-editor-character-actions.mjs" "角色动作元数据规范化脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
|
||||
|
||||
@@ -651,6 +651,21 @@ const checks = [
|
||||
includes: "await scanScopes(options, { verifyZero: true })",
|
||||
reason: '角色动作规范化 apply 后必须执行全量零匹配复核。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
|
||||
includes: "const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas']",
|
||||
reason: '普通图片错误素材类型必须覆盖三张业务表和持久化画布副本。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
|
||||
includes: 'expectedBatchSha256: dryRun.batch_sha256',
|
||||
reason: '普通图片素材类型 apply 必须绑定同批 dry-run 返回的摘要。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
|
||||
includes: "await scanScopes(options, { verifyZero: true })",
|
||||
reason: '普通图片素材类型清理 apply 后必须执行全量零匹配复核。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
|
||||
includes: 'buildProcedureInput(canvas, updatedAtMicros, !options.apply)',
|
||||
@@ -686,6 +701,16 @@ const checks = [
|
||||
includes: 'scripts/spacetime-normalize-editor-character-actions.mjs',
|
||||
reason: 'Stdb Publish 必须从同一上游制品复制角色动作元数据规范化脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
|
||||
reason: 'Stdb Build 必须归档普通图片素材类型清理脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
|
||||
reason: 'Stdb Publish 必须从同一上游制品复制普通图片素材类型清理脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import {
|
||||
callSpacetimeProcedure,
|
||||
callSpacetimeProcedureViaCli,
|
||||
encodeSpacetimeCliOption,
|
||||
ensureProcedureOk,
|
||||
parsePositiveInteger,
|
||||
} from './spacetime-migration-common.mjs';
|
||||
|
||||
const PROCEDURE_NAME = 'clean_editor_image_asset_kind_and_return';
|
||||
const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas'];
|
||||
const DEFAULT_CHUNK_SIZE = 25;
|
||||
const CANVAS_MAX_CHUNK_SIZE = 5;
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
||||
|
||||
function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return `用法:
|
||||
node scripts/spacetime-clean-editor-image-asset-kind.mjs \\
|
||||
--database <name> --server <name-or-url> [--chunk-size <1-25>] [--apply]
|
||||
|
||||
默认按 asset、project-resource、showcase、canvas 的固定顺序执行全量 dry-run,不修改数据。
|
||||
脚本只把业务 assetKind 精确等于 "image" 的旧值清为空;不会修改 MIME、媒体类型或 asset_object.asset_kind。
|
||||
追加 --apply 后,每批仍会先 dry-run;只有 blocker 为零,才携带该批返回的 SHA-256 立即 apply。
|
||||
apply 完成后脚本会再次从头 dry-run,要求四个 scope 的 matched/blocker 均为零。
|
||||
必须使用已授权 database migration operator 的 spacetime CLI 登录态,并显式指定 server。`;
|
||||
}
|
||||
|
||||
export function parseOptions(argv, env = process.env) {
|
||||
const options = {
|
||||
apply: false,
|
||||
chunkSize: DEFAULT_CHUNK_SIZE,
|
||||
database: env.GENARRATIVE_SPACETIME_DATABASE || '',
|
||||
passthrough: [],
|
||||
server: env.GENARRATIVE_SPACETIME_SERVER || '',
|
||||
serverUrl: env.GENARRATIVE_SPACETIME_SERVER_URL || '',
|
||||
token: env.GENARRATIVE_SPACETIME_TOKEN || '',
|
||||
useHttp: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const readValue = () => {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${arg} 缺少参数值。`);
|
||||
}
|
||||
index += 1;
|
||||
return value.trim();
|
||||
};
|
||||
if (arg === '--database') {
|
||||
options.database = readValue();
|
||||
} else if (arg === '--server') {
|
||||
options.server = readValue();
|
||||
} else if (arg === '--server-url') {
|
||||
options.serverUrl = readValue();
|
||||
} else if (arg === '--token') {
|
||||
options.token = readValue();
|
||||
} else if (arg === '--chunk-size') {
|
||||
options.chunkSize = parsePositiveInteger(readValue(), arg);
|
||||
} else if (arg === '--apply') {
|
||||
options.apply = true;
|
||||
} else if (arg === '--use-http') {
|
||||
options.useHttp = true;
|
||||
} else if (arg === '--no-config' || arg === '--anonymous') {
|
||||
options.passthrough.push(arg);
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (options.chunkSize > DEFAULT_CHUNK_SIZE) {
|
||||
throw new Error(`--chunk-size 不能超过 ${DEFAULT_CHUNK_SIZE}。`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function buildCleanupInput({
|
||||
scope,
|
||||
cursor = null,
|
||||
limit,
|
||||
dryRun,
|
||||
expectedBatchSha256 = null,
|
||||
}) {
|
||||
if (!SCOPES.includes(scope)) {
|
||||
throw new Error(`未知普通图片 assetKind 清理 scope: ${scope}`);
|
||||
}
|
||||
if (!Number.isInteger(limit) || limit < 1) {
|
||||
throw new Error('普通图片 assetKind 清理 limit 必须是正整数。');
|
||||
}
|
||||
if (scope === 'canvas' && limit > CANVAS_MAX_CHUNK_SIZE) {
|
||||
throw new Error(`canvas scope limit 不能超过 ${CANVAS_MAX_CHUNK_SIZE}。`);
|
||||
}
|
||||
if (!dryRun && !SHA256_PATTERN.test(expectedBatchSha256 || '')) {
|
||||
throw new Error('apply 必须绑定 dry-run 返回的 64 位 batch SHA-256。');
|
||||
}
|
||||
return {
|
||||
scope,
|
||||
cursor: encodeSpacetimeCliOption(cursor),
|
||||
limit,
|
||||
dry_run: dryRun,
|
||||
expected_batch_sha_256: encodeSpacetimeCliOption(
|
||||
dryRun ? null : expectedBatchSha256,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function scopeLimit(scope, chunkSize) {
|
||||
return scope === 'canvas'
|
||||
? Math.min(chunkSize, CANVAS_MAX_CHUNK_SIZE)
|
||||
: chunkSize;
|
||||
}
|
||||
|
||||
function assertSafeBatch(result, scope) {
|
||||
ensureProcedureOk(result);
|
||||
if (result.scope !== scope) {
|
||||
throw new Error(`procedure 返回 scope ${result.scope},预期为 ${scope}。`);
|
||||
}
|
||||
if (result.blocker_count !== 0 || result.blocker_samples.length !== 0) {
|
||||
throw new Error(`${scope} scope 存在 ${result.blocker_count} 个 blocker。`);
|
||||
}
|
||||
if (!SHA256_PATTERN.test(result.batch_sha256 || '')) {
|
||||
throw new Error(`${scope} scope 未返回有效的 batch SHA-256。`);
|
||||
}
|
||||
}
|
||||
|
||||
async function callBatch(options, input) {
|
||||
return options.useHttp
|
||||
? callSpacetimeProcedure(options, PROCEDURE_NAME, input)
|
||||
: callSpacetimeProcedureViaCli(options, PROCEDURE_NAME, input);
|
||||
}
|
||||
|
||||
export async function scanScopes(
|
||||
options,
|
||||
{ apply = false, verifyZero = false, callProcedure = callBatch } = {},
|
||||
) {
|
||||
const summaries = [];
|
||||
for (const scope of SCOPES) {
|
||||
let cursor = null;
|
||||
const seenCursors = new Set();
|
||||
const summary = {
|
||||
scope,
|
||||
scanned_count: 0,
|
||||
matched_count: 0,
|
||||
updated_count: 0,
|
||||
cleaned_field_count: 0,
|
||||
batches: 0,
|
||||
};
|
||||
do {
|
||||
const limit = scopeLimit(scope, options.chunkSize);
|
||||
const dryRun = await callProcedure(
|
||||
options,
|
||||
buildCleanupInput({ scope, cursor, limit, dryRun: true }),
|
||||
);
|
||||
assertSafeBatch(dryRun, scope);
|
||||
summary.scanned_count += dryRun.scanned_count;
|
||||
summary.matched_count += dryRun.matched_count;
|
||||
summary.cleaned_field_count += dryRun.cleaned_field_count;
|
||||
summary.batches += 1;
|
||||
|
||||
if (verifyZero && dryRun.matched_count !== 0) {
|
||||
throw new Error(
|
||||
`${scope} scope apply 后复核仍有 ${dryRun.matched_count} 行待清理。`,
|
||||
);
|
||||
}
|
||||
if (apply && dryRun.matched_count > 0) {
|
||||
const applied = await callProcedure(
|
||||
options,
|
||||
buildCleanupInput({
|
||||
scope,
|
||||
cursor,
|
||||
limit,
|
||||
dryRun: false,
|
||||
expectedBatchSha256: dryRun.batch_sha256,
|
||||
}),
|
||||
);
|
||||
assertSafeBatch(applied, scope);
|
||||
if (applied.batch_sha256 !== dryRun.batch_sha256) {
|
||||
throw new Error(`${scope} scope apply 返回的 batch SHA-256 与 dry-run 不一致。`);
|
||||
}
|
||||
if (applied.updated_count !== dryRun.matched_count) {
|
||||
throw new Error(
|
||||
`${scope} scope apply 更新 ${applied.updated_count} 行,dry-run 匹配 ${dryRun.matched_count} 行。`,
|
||||
);
|
||||
}
|
||||
if (applied.cleaned_field_count !== dryRun.cleaned_field_count) {
|
||||
throw new Error(`${scope} scope apply 返回的清理字段数与 dry-run 不一致。`);
|
||||
}
|
||||
summary.updated_count += applied.updated_count;
|
||||
}
|
||||
const nextCursor = dryRun.has_more ? dryRun.next_cursor : null;
|
||||
if (dryRun.has_more && !nextCursor) {
|
||||
throw new Error(`${scope} scope 声明 has_more 但未返回 next_cursor。`);
|
||||
}
|
||||
if (nextCursor && seenCursors.has(nextCursor)) {
|
||||
throw new Error(
|
||||
`${scope} scope 返回了重复的 next_cursor(SHA-256: ${sha256(nextCursor)})。`,
|
||||
);
|
||||
}
|
||||
if (nextCursor) {
|
||||
seenCursors.add(nextCursor);
|
||||
}
|
||||
cursor = nextCursor;
|
||||
} while (cursor);
|
||||
summaries.push(summary);
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const options = parseOptions(argv);
|
||||
if (options.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
if (!options.database) {
|
||||
throw new Error('必须显式传入 --database。');
|
||||
}
|
||||
if (!options.server && !options.serverUrl) {
|
||||
throw new Error('必须显式传入 --server / --server-url,不使用默认 cloud target。');
|
||||
}
|
||||
if (options.useHttp && !options.token) {
|
||||
throw new Error('--use-http 需要通过 --token 或 GENARRATIVE_SPACETIME_TOKEN 提供身份。');
|
||||
}
|
||||
|
||||
const migration = await scanScopes(options, { apply: options.apply });
|
||||
const verification = options.apply
|
||||
? await scanScopes(options, { verifyZero: true })
|
||||
: null;
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
procedure: PROCEDURE_NAME,
|
||||
applied: options.apply,
|
||||
scope_order: SCOPES,
|
||||
migration,
|
||||
verification,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (!options.apply) {
|
||||
console.log('全量 dry-run 已通过;确认输出后追加 --apply 重跑。');
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
`[spacetime:editor-image-asset-kind:clean] ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCleanupInput,
|
||||
parseOptions,
|
||||
scanScopes,
|
||||
} from './spacetime-clean-editor-image-asset-kind.mjs';
|
||||
|
||||
describe('普通图片 assetKind 清理脚本', () => {
|
||||
it('默认 dry-run 并要求调用方显式选择 apply', () => {
|
||||
expect(
|
||||
parseOptions(
|
||||
['--database', 'genarrative-prod', '--server', 'prod'],
|
||||
{},
|
||||
),
|
||||
).toMatchObject({
|
||||
apply: false,
|
||||
chunkSize: 25,
|
||||
database: 'genarrative-prod',
|
||||
server: 'prod',
|
||||
});
|
||||
});
|
||||
|
||||
it('编码 cursor、限制 canvas 批量并要求 apply hash', () => {
|
||||
expect(
|
||||
buildCleanupInput({
|
||||
scope: 'asset',
|
||||
cursor: 'asset-25',
|
||||
limit: 25,
|
||||
dryRun: true,
|
||||
}),
|
||||
).toEqual({
|
||||
scope: 'asset',
|
||||
cursor: [0, 'asset-25'],
|
||||
limit: 25,
|
||||
dry_run: true,
|
||||
expected_batch_sha_256: null,
|
||||
});
|
||||
expect(() =>
|
||||
buildCleanupInput({ scope: 'canvas', limit: 6, dryRun: true }),
|
||||
).toThrow('canvas scope limit');
|
||||
expect(() =>
|
||||
buildCleanupInput({ scope: 'showcase', limit: 25, dryRun: false }),
|
||||
).toThrow('batch SHA-256');
|
||||
});
|
||||
|
||||
it('按固定 scope 顺序执行 hash 绑定 apply 并保留字段计数', async () => {
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
const callProcedure = async (
|
||||
_options: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
) => {
|
||||
calls.push(input);
|
||||
const dryRun = input.dry_run === true;
|
||||
const scope = String(input.scope);
|
||||
const hashDigit = {
|
||||
asset: 'a',
|
||||
'project-resource': 'b',
|
||||
showcase: 'c',
|
||||
canvas: 'd',
|
||||
}[scope]!;
|
||||
return {
|
||||
ok: true,
|
||||
scope,
|
||||
dry_run: dryRun,
|
||||
scanned_count: 1,
|
||||
matched_count: 1,
|
||||
updated_count: dryRun ? 0 : 1,
|
||||
cleaned_field_count: scope === 'canvas' ? 3 : 1,
|
||||
blocker_count: 0,
|
||||
blocker_samples: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
batch_sha256: hashDigit.repeat(64),
|
||||
error_message: null,
|
||||
};
|
||||
};
|
||||
|
||||
const summaries = await scanScopes(
|
||||
{ chunkSize: 25 },
|
||||
{ apply: true, callProcedure },
|
||||
);
|
||||
|
||||
expect(summaries.map((summary) => summary.scope)).toEqual([
|
||||
'asset',
|
||||
'project-resource',
|
||||
'showcase',
|
||||
'canvas',
|
||||
]);
|
||||
expect(calls.map((call) => `${call.scope}:${call.dry_run}`)).toEqual([
|
||||
'asset:true',
|
||||
'asset:false',
|
||||
'project-resource:true',
|
||||
'project-resource:false',
|
||||
'showcase:true',
|
||||
'showcase:false',
|
||||
'canvas:true',
|
||||
'canvas:false',
|
||||
]);
|
||||
expect(summaries.at(-1)?.cleaned_field_count).toBe(3);
|
||||
expect(calls.at(-2)?.limit).toBe(5);
|
||||
for (let index = 1; index < calls.length; index += 2) {
|
||||
expect(calls[index]?.expected_batch_sha_256).toEqual([
|
||||
0,
|
||||
String(calls[index - 1]?.scope === 'asset'
|
||||
? 'a'
|
||||
: calls[index - 1]?.scope === 'project-resource'
|
||||
? 'b'
|
||||
: calls[index - 1]?.scope === 'showcase'
|
||||
? 'c'
|
||||
: 'd').repeat(64),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('拒绝 apply 后计数漂移与复核残留', async () => {
|
||||
const driftingCall = async (
|
||||
_options: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
) => ({
|
||||
ok: true,
|
||||
scope: input.scope,
|
||||
dry_run: input.dry_run,
|
||||
scanned_count: 1,
|
||||
matched_count: 1,
|
||||
updated_count: input.dry_run ? 0 : 1,
|
||||
cleaned_field_count: input.dry_run ? 2 : 1,
|
||||
blocker_count: 0,
|
||||
blocker_samples: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
batch_sha256: 'a'.repeat(64),
|
||||
error_message: null,
|
||||
});
|
||||
await expect(
|
||||
scanScopes(
|
||||
{ chunkSize: 25 },
|
||||
{ apply: true, callProcedure: driftingCall },
|
||||
),
|
||||
).rejects.toThrow('清理字段数');
|
||||
|
||||
const residualCall = async (
|
||||
_options: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
) => ({
|
||||
ok: true,
|
||||
scope: input.scope,
|
||||
dry_run: true,
|
||||
scanned_count: 1,
|
||||
matched_count: 1,
|
||||
updated_count: 0,
|
||||
cleaned_field_count: 1,
|
||||
blocker_count: 0,
|
||||
blocker_samples: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
batch_sha256: 'b'.repeat(64),
|
||||
error_message: null,
|
||||
});
|
||||
await expect(
|
||||
scanScopes(
|
||||
{ chunkSize: 25 },
|
||||
{ verifyZero: true, callProcedure: residualCall },
|
||||
),
|
||||
).rejects.toThrow('apply 后复核仍有');
|
||||
});
|
||||
|
||||
it('报告游标循环时不泄露原始标识', async () => {
|
||||
const privateCursor = 'private-asset-id';
|
||||
const loopingCall = async (
|
||||
_options: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
) => ({
|
||||
ok: true,
|
||||
scope: input.scope,
|
||||
dry_run: true,
|
||||
scanned_count: 1,
|
||||
matched_count: 0,
|
||||
updated_count: 0,
|
||||
cleaned_field_count: 0,
|
||||
blocker_count: 0,
|
||||
blocker_samples: [],
|
||||
next_cursor: privateCursor,
|
||||
has_more: true,
|
||||
batch_sha256: 'c'.repeat(64),
|
||||
error_message: null,
|
||||
});
|
||||
|
||||
let message = '';
|
||||
try {
|
||||
await scanScopes(
|
||||
{ chunkSize: 25 },
|
||||
{ callProcedure: loopingCall },
|
||||
);
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
expect(message).toContain('SHA-256');
|
||||
expect(message).not.toContain(privateCursor);
|
||||
});
|
||||
});
|
||||
@@ -294,8 +294,10 @@ function normalizeProcedureResult(value, procedureName) {
|
||||
function normalizeSatsObject(value, procedureName) {
|
||||
const normalized = normalizeSatsValue(value);
|
||||
if (
|
||||
procedureName !==
|
||||
'normalize_editor_character_animation_metadata_and_return' ||
|
||||
![
|
||||
'normalize_editor_character_animation_metadata_and_return',
|
||||
'clean_editor_image_asset_kind_and_return',
|
||||
].includes(procedureName) ||
|
||||
!normalized ||
|
||||
typeof normalized !== 'object' ||
|
||||
Array.isArray(normalized)
|
||||
@@ -311,6 +313,27 @@ function normalizeSatsObject(value, procedureName) {
|
||||
}
|
||||
|
||||
function normalizeSatsProduct(value, procedureName) {
|
||||
if (
|
||||
procedureName === 'clean_editor_image_asset_kind_and_return' &&
|
||||
value.length === 13
|
||||
) {
|
||||
return {
|
||||
ok: normalizeSatsValue(value[0]),
|
||||
scope: normalizeSatsValue(value[1]),
|
||||
dry_run: normalizeSatsValue(value[2]),
|
||||
scanned_count: normalizeSatsValue(value[3]),
|
||||
matched_count: normalizeSatsValue(value[4]),
|
||||
updated_count: normalizeSatsValue(value[5]),
|
||||
cleaned_field_count: normalizeSatsValue(value[6]),
|
||||
blocker_count: normalizeSatsValue(value[7]),
|
||||
blocker_samples: normalizeSatsValue(value[8]),
|
||||
next_cursor: normalizeSatsOption(value[9]),
|
||||
has_more: normalizeSatsValue(value[10]),
|
||||
batch_sha256: normalizeSatsValue(value[11]),
|
||||
error_message: normalizeSatsOption(value[12]),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
procedureName === 'normalize_editor_character_animation_metadata_and_return' &&
|
||||
value.length === 19
|
||||
|
||||
@@ -174,4 +174,53 @@ describe('SpacetimeDB CLI SATS option encoding', () => {
|
||||
});
|
||||
expect(result).not.toHaveProperty('batch_sha_256');
|
||||
});
|
||||
|
||||
it('normalizes ordinary image assetKind cleanup results', () => {
|
||||
const tupleResult = parseProcedureResult(
|
||||
JSON.stringify([
|
||||
true,
|
||||
'canvas',
|
||||
true,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
[],
|
||||
[0, 'canvas-5'],
|
||||
true,
|
||||
'c'.repeat(64),
|
||||
[1],
|
||||
]),
|
||||
'clean_editor_image_asset_kind_and_return',
|
||||
);
|
||||
expect(tupleResult).toMatchObject({
|
||||
scope: 'canvas',
|
||||
cleaned_field_count: 4,
|
||||
next_cursor: 'canvas-5',
|
||||
batch_sha256: 'c'.repeat(64),
|
||||
error_message: null,
|
||||
});
|
||||
|
||||
const objectResult = parseProcedureResult(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
scope: 'asset',
|
||||
dry_run: true,
|
||||
scanned_count: 1,
|
||||
matched_count: 1,
|
||||
updated_count: 0,
|
||||
cleaned_field_count: 1,
|
||||
blocker_count: 0,
|
||||
blocker_samples: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
batch_sha_256: 'd'.repeat(64),
|
||||
error_message: null,
|
||||
}),
|
||||
'clean_editor_image_asset_kind_and_return',
|
||||
);
|
||||
expect(objectResult.batch_sha256).toBe('d'.repeat(64));
|
||||
expect(objectResult).not.toHaveProperty('batch_sha_256');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2374,7 +2374,7 @@ mod tests {
|
||||
let app = build_router(state);
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": "快速编辑图片",
|
||||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||||
"sourceReferenceId": "data:image/png;base64,AAAA",
|
||||
"size": "1024x1024",
|
||||
"model": "gpt-image-2"
|
||||
})
|
||||
@@ -2402,7 +2402,7 @@ mod tests {
|
||||
.to_bytes();
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
body_text.contains("先上传 OSS"),
|
||||
body_text.contains("只接受已登记的项目资源 ID 或素材 ID"),
|
||||
"handler should reject inline editor edit sources: {body_text}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::{Extension, Json};
|
||||
use module_editor_agent::{
|
||||
EDITOR_AGENT_CONVERSATION_ID_PREFIX, EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE,
|
||||
@@ -42,8 +43,13 @@ use crate::editor_agent::utils::{
|
||||
};
|
||||
use crate::editor_agent::{context, reconcile};
|
||||
use crate::editor_generation_config::EditorGenerationPricingConfig;
|
||||
use crate::editor_generation_queue::enqueue_editor_generation_job_with_identity;
|
||||
use crate::editor_project::{current_utc_micros, map_editor_project_error};
|
||||
use crate::editor_generation_queue::{
|
||||
EDITOR_IMAGE_EDIT_JOB_KIND, enqueue_editor_generation_job_with_identity,
|
||||
};
|
||||
use crate::editor_project::{
|
||||
EditorImageEditRequest, current_utc_micros, map_editor_project_error,
|
||||
prepare_editor_image_edit_queue_payload_for_owner,
|
||||
};
|
||||
use crate::http_error::AppError;
|
||||
use crate::request_context::RequestContext;
|
||||
use crate::state::AppState;
|
||||
@@ -712,6 +718,25 @@ mod tests {
|
||||
"ERROR 美术 Agent 规划轮数已达上限:3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_image_edit_is_wrapped_before_queueing() {
|
||||
let source = include_str!("api.rs");
|
||||
let start = source
|
||||
.rfind("pub async fn confirm_editor_agent_tool_call")
|
||||
.expect("confirm endpoint should exist");
|
||||
let body = &source[start..];
|
||||
let prepare = body
|
||||
.find("prepare_editor_image_edit_queue_payload_for_owner")
|
||||
.expect("image edit should build its versioned queue payload");
|
||||
let enqueue = body
|
||||
.find("enqueue_editor_generation_job_with_identity")
|
||||
.expect("confirmed job should be queued");
|
||||
|
||||
assert!(body[..prepare].contains("job_kind == EDITOR_IMAGE_EDIT_JOB_KIND"));
|
||||
assert!(body[prepare..enqueue].contains("serde_json::to_value(queue_payload)"));
|
||||
assert!(prepare < enqueue);
|
||||
}
|
||||
}
|
||||
fn build_delta_messages(
|
||||
outputs: Vec<PromptOutput>,
|
||||
@@ -1067,7 +1092,29 @@ pub async fn confirm_editor_agent_tool_call(
|
||||
let job_kind = prepared_job.job_kind;
|
||||
let request_label = prepared_job.request_label;
|
||||
let price_mud_points = prepared_job.price_mud_points;
|
||||
let payload = prepared_job.payload;
|
||||
let payload = if job_kind == EDITOR_IMAGE_EDIT_JOB_KIND {
|
||||
let request = serde_json::from_value::<EditorImageEditRequest>(prepared_job.payload)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "editor-agent",
|
||||
"message": format!("画布 Agent 图片编辑任务参数无效:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let queue_payload = prepare_editor_image_edit_queue_payload_for_owner(
|
||||
&state,
|
||||
conversation.owner_user_id.as_str(),
|
||||
request,
|
||||
)
|
||||
.await?;
|
||||
serde_json::to_value(queue_payload).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "editor-agent",
|
||||
"message": format!("画布 Agent 图片编辑任务载荷序列化失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
} else {
|
||||
prepared_job.payload
|
||||
};
|
||||
let (job_id, dedupe_key) = editor_agent_tool_job_identity(
|
||||
conversation.conversation_id.as_str(),
|
||||
message_id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user
[P1] External v1 允许类型说明漏掉
scene当前 head 已允许
scene图片快速编辑,但这里的权威契约仍只列到ui-design,并明确称其他类型返回 400。依赖 OpenAPI/MCP 的调用方会把实际合法请求判成非法。请把scene同步进允许列表,并让契约测试精确校验完整白名单,避免目前只检查若干描述关键词而漏掉枚举漂移。