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 37647c6f6..be8e1dcf6 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 @@ -134,7 +134,7 @@ A minimal `canvasCompletion` is: Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order. -Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume the returned animation artifacts and persisted identities; do not synthesize a duplicate animation asset from the first frame. Use complete project/library records when complete persisted state is needed. +Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. In a completed polling response, use `result.frames` for the ordered frame artifacts and `result.durationSeconds` for the duration in seconds. The nested `result.resource` and `result.asset` are compact references, not complete records; they do not include `imageSequenceFrames` or `imageSequenceDurationMs`. Read the complete project (`GET /api/external/v1/editor/projects/{projectId}`) or asset library (`GET /api/external/v1/editor/assets/library`) and match `resourceId` or `assetId` to obtain those formal sequence fields, whose duration is in milliseconds. MCP equivalents are `find_assets/get_project_resources` and `find_assets/list_library`. Do not synthesize a duplicate animation asset from the first frame. For the lower-level asset/resource creation endpoints, `generationInputs` follows the metadata and media-runtime boundaries described in [Generation Inputs Metadata](#generation-inputs-metadata). 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 bc270fe38..4d4422275 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 @@ -795,25 +795,20 @@ def _self_test() -> None: "model": "seedance2.0-fast", "prompt": "角色呼吸", "previewVideoPath": "/generated/preview.mp4", - "frames": [{"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}], + "frames": [ + {"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}, + {"imageSrc": "/generated/frame02.png", "width": 512, "height": 768}, + ], + "frameCount": 2, + "durationSeconds": 4, "resource": { "resourceId": "editor-resource-demo", "assetKind": "character-animation", "sourceResourceId": "editor-resource-preview-demo", - "imageSequenceFrames": [ - {"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}, - {"imageSrc": "/generated/frame02.png", "width": 512, "height": 768}, - ], - "imageSequenceDurationMs": 4000, }, "asset": { "assetId": "editor-asset-demo", "assetKind": "character-animation", - "imageSequenceFrames": [ - {"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}, - {"imageSrc": "/generated/frame02.png", "width": 512, "height": 768}, - ], - "imageSequenceDurationMs": 4000, }, } if method == "POST": @@ -841,8 +836,13 @@ def _self_test() -> None: assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo" assert result["asset"]["assetId"] == "editor-asset-demo" assert result["asset"]["assetKind"] == "character-animation" - assert len(result["asset"]["imageSequenceFrames"]) == 2 - assert result["asset"]["imageSequenceDurationMs"] == 4000 + assert result["resource"]["resourceId"] == "editor-resource-demo" + assert result["frames"] == [ + {"imageSrc": "/generated/frame01.png", "width": 512, "height": 768}, + {"imageSrc": "/generated/frame02.png", "width": 512, "height": 768}, + ] + assert result["frameCount"] == 2 + assert result["durationSeconds"] == 4 calls.clear() background_result = client.remove_background( "uploads/source.png", diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index b85e6c217..0d3f2dee1 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -4114,113 +4114,6 @@ "additionalProperties": false, "description": "图片序列帧。数组位置是唯一播放顺序,不携带额外序号字段;每帧必须同时携带 objectKey 与 assetObjectId,imageSrc 按 objectKey 规范化为持久站内路径。" }, - "EditorCharacterAnimationGenerationResponse": { - "type": "object", - "required": [ - "ok", - "taskId", - "model", - "prompt", - "previewVideoPath", - "frames", - "frameCount", - "durationSeconds", - "frameWidth", - "frameHeight", - "fps", - "priceMudPoints" - ], - "properties": { - "ok": { - "type": "boolean" - }, - "taskId": { - "type": "string" - }, - "model": { - "type": "string" - }, - "prompt": { - "type": "string" - }, - "previewVideoPath": { - "type": "string" - }, - "frames": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EditorImageSequenceFrame" - } - }, - "frameCount": { - "type": "integer", - "minimum": 1 - }, - "durationSeconds": { - "type": "integer", - "minimum": 1 - }, - "frameWidth": { - "type": "integer", - "minimum": 1 - }, - "frameHeight": { - "type": "integer", - "minimum": 1 - }, - "fps": { - "type": "integer", - "minimum": 1 - }, - "priceMudPoints": { - "type": "integer", - "minimum": 0 - }, - "project": { - "anyOf": [ - { - "$ref": "#/components/schemas/EditorProject" - }, - { - "type": "null" - } - ], - "description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。" - }, - "resource": { - "anyOf": [ - { - "$ref": "#/components/schemas/EditorProjectResource" - }, - { - "type": "null" - } - ], - "description": "最终透明角色动作对应的项目资源。携带 projectId 时,画布图层必须直接引用其 resourceId,不得再次创建重复资源。" - }, - "asset": { - "anyOf": [ - { - "$ref": "#/components/schemas/EditorAsset" - }, - { - "type": "null" - } - ], - "description": "最终透明角色动作素材。assetKind 为 character-animation,并直接包含序列帧字段。" - }, - "queueState": { - "anyOf": [ - { - "$ref": "#/components/schemas/ExternalGenerationJobStatusRecord" - }, - { - "type": "null" - } - ] - } - } - }, "EditorVideoGenerationRequest": { "type": "object", "required": [ @@ -4776,7 +4669,7 @@ } }, "additionalProperties": true, - "description": "其它生成类型或历史结果的兼容 compact result。背景音乐(audioKind=background-music)与不带 audioKind 的图片、视频、图标序列帧、角色动作和 UI 拆解结果都落在这里。pixelArt 图片的 width/height、图标结果的 spritesheetWidth/spritesheetHeight 及关联 resource/asset 尺寸均表示最终逻辑分辨率 PNG 的实际值,不保证等于请求档位、provider 回图或画布 placeholder。该 fallback 明确排除 audioKind=sound-effect,避免吞掉 SFX 专用分支。" + "description": "其它生成类型或历史结果的兼容 compact result。背景音乐(audioKind=background-music)与不带 audioKind 的图片、视频、图标序列帧、角色动作和 UI 拆解结果都落在这里。角色动作使用顶层 frames(有序帧产物)与 durationSeconds(秒级时长);内嵌 resource/asset 是精简引用,不包含 imageSequenceFrames/imageSequenceDurationMs。需要正式序列记录时,按返回的 resourceId/assetId 查询 GET /api/external/v1/editor/projects/{projectId} 或 GET /api/external/v1/editor/assets/library,正式 imageSequenceDurationMs 单位为毫秒。pixelArt 图片的 width/height、图标结果的 spritesheetWidth/spritesheetHeight 及关联 resource/asset 尺寸均表示最终逻辑分辨率 PNG 的实际值,不保证等于请求档位、provider 回图或画布 placeholder。该 fallback 明确排除 audioKind=sound-effect,避免吞掉 SFX 专用分支。" }, "ExternalEditorGenerationCompletedResult": { "oneOf": [ diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d9ef19fba..b9481e7fd 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,11 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## External 动画轮询的精简引用不等于完整序列记录 + +- 动画 completed 结果通过顶层 `frames` 和 `durationSeconds` 提供有序帧与秒级时长;内嵌 `resource/asset` 只提供精简引用。正式 `imageSequenceFrames/imageSequenceDurationMs` 从完整项目或素材库按返回的 ID 读取,后者时长单位为毫秒。内嵌引用没有这两个字段不能据此判定持久化丢帧,也不能用首帧重复创建动画素材。 +- 排查时分别核对真实精简函数、正式记录和 [External v1 OpenAPI](../../openapi/genarrative-external-v1.openapi.json);Python helper 的模拟响应必须遵循同一结构,不能用虚构的嵌套完整记录证明轮询契约成立。 + ## 2026-10-05 generationInputs 的保存与复用不等于自动生效或完整透传 - **现象 / 根因**:调用方把构图等要求只写入 `generationInputs.artSpec`,但实际生图输入没有这些要求;把 `JsonValue` 和“可复用规范”误读为服务端会自动组装提示词、完整保留全部元数据或自动用于下一次生成。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 89956eb4e..d469e511e 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -41,6 +41,8 @@ VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部 External API job 复用同一个 `result_payload_json` 列,但只额外保存 `result` compact 引用:允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、大布局、Data URL、Blob URL、临时 signed URL、provider 原始响应和 lease/fencing 控制字段。普通站内 job 继续保持原 payload 语义,不能为了 External 查询把所有队列结果扩成第二套资产 read model。 +角色动画的 completed compact result 通过顶层 `frames` 保留有序帧产物,通过 `durationSeconds` 保留秒级时长;内嵌 `resource/asset` 仍是精简引用,不包含 `imageSequenceFrames/imageSequenceDurationMs`。调用方需要正式序列记录时,按返回的 `resourceId/assetId` 从完整项目或素材库读取,正式时长单位为毫秒;不得把精简引用当完整记录,或根据首帧重复创建动画素材。OpenAPI、Skill 与 helper 自测共同遵循此读路径。 + 不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。 幂等读取回归使用 `npm run check:editor-idempotency-procedures`:隔离 standalone 验证 pending、running、completed 的原始请求身份、同键重放唯一任务及跨 owner / 不存在任务拒绝,不调用生成 Provider。脚本仅在临时构建目录注入公开测试 bootstrap hash;若设置 `GENARRATIVE_EDITOR_IDEMPOTENCY_SMOKE_WASM` 复用预编译模块,该模块也必须使用脚本中的测试 hash 构建,且不得用于部署。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 602771e28..71f740c73 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -19846,6 +19846,51 @@ mod tests { } } + #[test] + fn compact_external_character_animation_result_keeps_frames_duration_and_references() { + let frames = json!([ + { + "imageSrc": "/generated/animation/frame01.png", + "objectKey": "generated/animation/frame01.png", + "assetObjectId": "asset-object-frame01", + "width": 192, + "height": 256 + }, + { + "imageSrc": "/generated/animation/frame02.png", + "objectKey": "generated/animation/frame02.png", + "assetObjectId": "asset-object-frame02", + "width": 256, + "height": 192 + } + ]); + let result = compact_external_api_generation_result(json!({ + "ok": true, + "durationSeconds": 4, + "frameCount": 2, + "frames": frames, + "resource": { + "resourceId": "resource-animation", + "objectKey": "generated/animation/frame01.png", + "assetObjectId": "asset-object-frame01" + }, + "asset": { + "assetId": "asset-animation", + "objectKey": "generated/animation/frame01.png", + "assetObjectId": "asset-object-frame01" + }, + "provider": "internal-provider" + })); + + assert_eq!(result["frames"], frames); + assert_eq!(result["durationSeconds"], 4); + assert_eq!(result["frameCount"], 2); + assert_eq!(result["resource"]["resourceId"], "resource-animation"); + assert_eq!(result["asset"]["assetId"], "asset-animation"); + assert_eq!(result["resource"]["assetObjectId"], "asset-object-frame01"); + assert_eq!(result["asset"]["assetObjectId"], "asset-object-frame01"); + } + #[test] fn game_creator_completed_jobs_keep_stable_results_for_all_current_media_kinds() { let fixtures = [