收紧外部角色动作素材契约

复用按素材类型校验的生成输入序列化器,提前拒绝角色动作旧运行字段。

同步 External v1 OpenAPI 条件约束、契约测试和外部接入文档。

确认站内 Web、AI Game Creator 与 Python helper 无需调用迁移。
This commit is contained in:
2026-08-05 13:56:09 +08:00
parent d297c8e268
commit ff582404d1
6 changed files with 186 additions and 18 deletions
@@ -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
@@ -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:
@@ -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": {
@@ -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 不得丢弃任一条。
## 异步提交、查询与幂等
@@ -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<Value>,
) -> Result<Option<String>, AppError> {
@@ -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<ExternalEditorAssetCreateRequest>,
) -> Result<Json<Value>, 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<ExternalEditorProjectResourceCreateRequest>,
) -> Result<Json<Value>, 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>) -> String {
}
fn serialize_external_editor_generation_inputs(
asset_kind: Option<&str>,
generation_inputs: Option<Value>,
) -> Result<Option<String>, 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::<Vec<_>>(),
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")