收紧外部角色动作素材契约
复用按素材类型校验的生成输入序列化器,提前拒绝角色动作旧运行字段。 同步 External v1 OpenAPI 条件约束、契约测试和外部接入文档。 确认站内 Web、AI Game Creator 与 Python helper 无需调用迁移。
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user