From ff582404d1cab575f01cadb12df8e00723284680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 5 Aug 2026 13:56:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=A4=96=E9=83=A8=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E5=8A=A8=E4=BD=9C=E7=B4=A0=E6=9D=90=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用按素材类型校验的生成输入序列化器,提前拒绝角色动作旧运行字段。 同步 External v1 OpenAPI 条件约束、契约测试和外部接入文档。 确认站内 Web、AI Game Creator 与 Python helper 无需调用迁移。 --- .../references/api-operations.md | 1 + .../references/requests-and-outputs.md | 2 + .../genarrative-external-v1.openapi.json | 91 ++++++++++++++- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 2 + .../crates/api-server/src/editor_project.rs | 2 +- .../api-server/src/external_editor_api.rs | 106 +++++++++++++++--- 6 files changed, 186 insertions(+), 18 deletions(-) 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 de572f79b..1597a2a12 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -65,6 +65,7 @@ Supply the `operationId` returned by submission. Poll no faster than `pollAfterM - Pass `assetFolderId` plus `assetLabel` for image, edit, icon spritesheet, video, sound effect, and BGM operations when supported. - UI extraction uses `assetFolderId` and `spritesheetLabel`. - Character animation accepts `assetFolderId` and `assetLabel`. Its completed compact result directly returns the final `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`; never create a duplicate first-frame resource or asset. +- If a caller must manually create a `character-animation` resource or asset, put the authoritative frames and total sequence duration in `imageSequenceFrames` and `imageSequenceDurationMs`. Keep `generationInputs` replayable: it must not contain legacy runtime fields such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, or `durationSeconds`. - Reload project/library state after completion when full current state is required. ## Reference Field Mapping 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 bfbf6e452..5207d416c 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 @@ -100,6 +100,8 @@ A minimal `canvasCompletion` is: Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame. +For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence. + ## Art Spec and Image Request Carry the current art spec in `generationInputs.artSpec` and reflect important constraints in the prompt: diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index af4db27ed..df02f1edd 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -2051,6 +2051,27 @@ }, "ExternalEditorAssetCreateRequest": { "type": "object", + "allOf": [ + { + "if": { + "properties": { + "assetKind": { + "const": "character-animation" + } + }, + "required": [ + "assetKind" + ] + }, + "then": { + "properties": { + "generationInputs": { + "$ref": "#/components/schemas/ExternalCharacterAnimationGenerationInputs" + } + } + } + } + ], "required": [ "folderId", "label", @@ -2147,13 +2168,35 @@ "description": "assetKind=character-animation 时完整图片序列播放一次的毫秒时长;与音频、视频生成参数 durationSeconds 无关。" }, "generationInputs": { - "$ref": "#/components/schemas/JsonValue" + "$ref": "#/components/schemas/JsonValue", + "description": "可重放的生成输入。assetKind=character-animation 时不得包含 characterAnimation、frames、previewVideoPath、frameCount、fps 或 durationSeconds 等旧运行字段;正式媒体数据必须写入 imageSequenceFrames 和 imageSequenceDurationMs。服务端会移除 screenColorHex、mattingProvider、mattingModel 等内部处理审计字段。" } }, "additionalProperties": false }, "ExternalEditorProjectResourceCreateRequest": { "type": "object", + "allOf": [ + { + "if": { + "properties": { + "assetKind": { + "const": "character-animation" + } + }, + "required": [ + "assetKind" + ] + }, + "then": { + "properties": { + "generationInputs": { + "$ref": "#/components/schemas/ExternalCharacterAnimationGenerationInputs" + } + } + } + } + ], "required": [ "imageSrc", "width", @@ -2242,11 +2285,55 @@ "description": "assetKind=character-animation 时完整图片序列播放一次的毫秒时长;与音频、视频生成参数 durationSeconds 无关。" }, "generationInputs": { - "$ref": "#/components/schemas/JsonValue" + "$ref": "#/components/schemas/JsonValue", + "description": "可重放的生成输入。assetKind=character-animation 时不得包含 characterAnimation、frames、previewVideoPath、frameCount、fps 或 durationSeconds 等旧运行字段;正式媒体数据必须写入 imageSequenceFrames 和 imageSequenceDurationMs。服务端会移除 screenColorHex、mattingProvider、mattingModel 等内部处理审计字段。" } }, "additionalProperties": false }, + "ExternalCharacterAnimationGenerationInputs": { + "not": { + "anyOf": [ + { + "type": "object", + "required": [ + "characterAnimation" + ] + }, + { + "type": "object", + "required": [ + "frames" + ] + }, + { + "type": "object", + "required": [ + "previewVideoPath" + ] + }, + { + "type": "object", + "required": [ + "frameCount" + ] + }, + { + "type": "object", + "required": [ + "fps" + ] + }, + { + "type": "object", + "required": [ + "durationSeconds" + ] + } + ] + }, + "description": "assetKind=character-animation 时 generationInputs 不得包含旧运行字段;正式媒体数据使用 imageSequenceFrames 和 imageSequenceDurationMs。" + }, "ExternalEditorAssetUpdateRequest": { "type": "object", "properties": { diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index fbac43ea3..936e5ec48 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -56,6 +56,8 @@ v1 只开放以下能力: - `unsupported-image-style`:请求的图片后处理风格未知或不适用于当前生成类型,接口按无风格继续生成。 - `multiple-generation-warnings`:同一成功响应合并了不同 `code` 的多条非阻断告警,具体原因按顺序拼接在 `reason`。 +手工调用 `POST /api/external/v1/editor/assets` 或 `POST /api/external/v1/editor/projects/{projectId}/resources` 创建 `assetKind=character-animation` 记录时,`generationInputs` 只保存可重放的生成输入,不接受 `characterAnimation`、`frames`、`previewVideoPath`、`frameCount`、`fps`、`durationSeconds` 等旧运行字段;完整正式帧序列和总时长必须分别写入 `imageSequenceFrames` 与 `imageSequenceDurationMs`。`screenColorHex`、`mattingProvider`、`mattingModel` 属于内部处理审计字段,External v1 会在持久化前移除。角色动作生成接口已经直接返回正式 resource / asset,正常调用方不应再手工复制第一帧创建重复记录。 + provider 原图已保存但透明背景处理最终失败时,worker 保留原图稳定引用,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回。调用方应展示告警,但不得把 completed 任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍使任务失败。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`。通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥;风格归一化或像素规整产生的通用 `warning` 可以与 `sliceWarning` 并存,compact result 不得丢弃任一条。 ## 异步提交、查询与幂等 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 023a2c3fa..92f7ad77a 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -8502,7 +8502,7 @@ pub(crate) fn serialize_editor_asset_metadata( Ok(Some(payload)) } -fn serialize_editor_generation_inputs( +pub(crate) fn serialize_editor_generation_inputs( asset_kind: Option<&str>, metadata: Option, ) -> Result, AppError> { diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index ceec9b19b..c0076d7d4 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -42,7 +42,7 @@ use crate::{ enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, parse_editor_generation_json_payload, sanitize_editor_client_generation_inputs, - save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata, + save_editor_project_layout_with_revision_and_get, serialize_editor_generation_inputs, }, external_api_auth::ExternalApiPrincipal, external_generation::map_external_generation_job_status_detail, @@ -484,8 +484,10 @@ pub async fn create_external_editor_asset( Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; - let generation_inputs_json = - serialize_external_editor_generation_inputs(payload.generation_inputs.clone())?; + let generation_inputs_json = serialize_external_editor_generation_inputs( + payload.asset_kind.as_deref(), + payload.generation_inputs.clone(), + )?; let image_sequence_frames_json = crate::editor_project::serialize_editor_image_sequence_frames( payload.image_sequence_frames, )?; @@ -591,8 +593,10 @@ pub async fn create_external_editor_project_resource( Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; - let generation_inputs_json = - serialize_external_editor_generation_inputs(payload.generation_inputs.clone())?; + let generation_inputs_json = serialize_external_editor_generation_inputs( + payload.asset_kind.as_deref(), + payload.generation_inputs.clone(), + )?; let image_sequence_frames_json = crate::editor_project::serialize_editor_image_sequence_frames( payload.image_sequence_frames, )?; @@ -1004,9 +1008,13 @@ fn normalize_project_title(title: Option) -> String { } fn serialize_external_editor_generation_inputs( + asset_kind: Option<&str>, generation_inputs: Option, ) -> Result, AppError> { - serialize_editor_asset_metadata(sanitize_editor_client_generation_inputs(generation_inputs)) + serialize_editor_generation_inputs( + asset_kind, + sanitize_editor_client_generation_inputs(generation_inputs), + ) } #[cfg(test)] @@ -1073,25 +1081,60 @@ mod tests { } #[test] - fn external_editor_generation_inputs_cannot_forge_internal_audit_fields() { - let serialized = serialize_external_editor_generation_inputs(Some(json!({ - "fields": [{"label": "角色设定", "value": "骑士"}], - "screenColorHex": "#00FF00", - "mattingProvider": "forged-provider", - "mattingModel": "forged-model", - "characterAnimation": {"durationSeconds": 4} - }))) + fn external_editor_generation_inputs_sanitize_audit_fields_for_regular_assets() { + let serialized = serialize_external_editor_generation_inputs( + Some("image"), + Some(json!({ + "fields": [{"label": "角色设定", "value": "骑士"}], + "screenColorHex": "#00FF00", + "mattingProvider": "forged-provider", + "mattingModel": "forged-model", + "extension": {"durationSeconds": 4} + })), + ) .expect("外部编辑器生成输入应可序列化") .expect("非空生成输入应保留"); let parsed: Value = serde_json::from_str(&serialized).expect("生成输入应为合法 JSON"); assert_eq!(parsed["fields"][0]["value"], "骑士"); - assert_eq!(parsed["characterAnimation"]["durationSeconds"], 4); + assert_eq!(parsed["extension"]["durationSeconds"], 4); assert!(parsed.get("screenColorHex").is_none()); assert!(parsed.get("mattingProvider").is_none()); assert!(parsed.get("mattingModel").is_none()); } + #[test] + fn external_editor_character_animation_generation_inputs_reject_legacy_runtime_fields() { + for (field, value) in [ + ("characterAnimation", json!({ "durationSeconds": 4 })), + ("frames", json!([])), + ("previewVideoPath", json!("/generated/action/preview.mp4")), + ("frameCount", json!(32)), + ("fps", json!(8)), + ("durationSeconds", json!(4)), + ] { + let mut generation_inputs = serde_json::Map::new(); + generation_inputs.insert("fields".to_string(), json!([])); + generation_inputs.insert(field.to_string(), value); + + let error = serialize_external_editor_generation_inputs( + Some("character-animation"), + Some(Value::Object(generation_inputs)), + ) + .expect_err("External v1 不应再接受角色动作旧运行字段"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + error + .details() + .and_then(|details| details["fields"][0].as_str()), + Some(field) + ); + assert!(error.body_text().contains("imageSequenceFrames")); + assert!(error.body_text().contains("imageSequenceDurationMs")); + } + } + #[test] fn external_generation_requires_bounded_printable_idempotency_key() { let missing = HeaderMap::new(); @@ -1208,6 +1251,39 @@ mod tests { let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse"); assert_eq!(parsed["openapi"], "3.1.0"); + let legacy_runtime_fields = [ + "characterAnimation", + "frames", + "previewVideoPath", + "frameCount", + "fps", + "durationSeconds", + ]; + let forbidden_generation_inputs = &parsed["components"]["schemas"]["ExternalCharacterAnimationGenerationInputs"] + ["not"]["anyOf"]; + assert_eq!( + forbidden_generation_inputs + .as_array() + .expect("角色动作 generationInputs 约束必须是数组") + .iter() + .filter_map(|schema| schema["required"][0].as_str()) + .collect::>(), + legacy_runtime_fields + ); + for request_schema in [ + "ExternalEditorAssetCreateRequest", + "ExternalEditorProjectResourceCreateRequest", + ] { + let conditional = &parsed["components"]["schemas"][request_schema]["allOf"][0]; + assert_eq!( + conditional["if"]["properties"]["assetKind"]["const"], + "character-animation" + ); + assert_eq!( + conditional["then"]["properties"]["generationInputs"]["$ref"], + "#/components/schemas/ExternalCharacterAnimationGenerationInputs" + ); + } assert!( parsed["paths"] .get("/api/external/v1/editor/projects")