合并主分支并同步角色动作资产修复
同步角色动作前端展示、下载、后端数据结构与精选链路修复 保留画布 Agent 图片编辑素材类型约束 整合前后端手动换签组合校验,避免非法类型调用错误接口
This commit is contained in:
@@ -33,13 +33,14 @@ use shared_contracts::admin::{
|
||||
AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, AdminDebugHeaderInput,
|
||||
AdminDebugHttpRequest, AdminDebugHttpResponse, AdminDirectUploadTicketPayload,
|
||||
AdminEditorAssetListQuery, AdminEditorAssetListResponse, AdminEditorAssetPayload,
|
||||
AdminEditorShowcaseAssetPayload, AdminEditorShowcaseAssetResponse,
|
||||
AdminEditorShowcaseCampaignPayload, AdminEditorShowcaseCampaignResponse,
|
||||
AdminEditorShowcaseDisplayRequest, AdminEditorShowcaseListQuery,
|
||||
AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest,
|
||||
AdminFeatureGateConfigPayload, AdminFeatureGateConfigResponse, AdminLoginRequest,
|
||||
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
|
||||
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventKeyListResponse,
|
||||
AdminEditorImageSequenceFramePayload, AdminEditorShowcaseAssetPayload,
|
||||
AdminEditorShowcaseAssetResponse, AdminEditorShowcaseCampaignPayload,
|
||||
AdminEditorShowcaseCampaignResponse, AdminEditorShowcaseDisplayRequest,
|
||||
AdminEditorShowcaseListQuery, AdminEditorShowcaseListResponse,
|
||||
AdminEditorShowcaseReviewRequest, AdminFeatureGateConfigPayload,
|
||||
AdminFeatureGateConfigResponse, AdminLoginRequest, AdminLoginResponse, AdminMeResponse,
|
||||
AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload,
|
||||
AdminTrackingEventEntryPayload, AdminTrackingEventKeyListResponse,
|
||||
AdminTrackingEventKeyPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse,
|
||||
AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest,
|
||||
};
|
||||
@@ -77,7 +78,10 @@ use crate::{
|
||||
create_direct_upload_ticket_for_owner, get_asset_read_url_with_query,
|
||||
},
|
||||
editor_generation_config::EditorGenerationPricingConfig,
|
||||
editor_project::current_utc_micros,
|
||||
editor_project::{
|
||||
current_utc_micros, normalize_editor_image_sequence_frames_value,
|
||||
resolve_editor_asset_kind, sanitize_editor_generation_inputs,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::{AdminRuntime, AppState},
|
||||
@@ -1051,11 +1055,27 @@ fn map_admin_feature_gate_config(
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_admin_editor_image_sequence_frames(
|
||||
frames: &Value,
|
||||
) -> Option<Vec<AdminEditorImageSequenceFramePayload>> {
|
||||
let normalized = normalize_editor_image_sequence_frames_value(frames)?;
|
||||
serde_json::from_value(normalized).ok()
|
||||
}
|
||||
|
||||
fn admin_editor_showcase_asset_payload_from_record(
|
||||
state: &AppState,
|
||||
record: EditorShowcaseAssetRecord,
|
||||
) -> AdminEditorShowcaseAssetPayload {
|
||||
let author = resolve_work_author_by_user_id(state, &record.owner_user_id, None, None);
|
||||
let formal_image_sequence_frames = record
|
||||
.image_sequence_frames
|
||||
.as_ref()
|
||||
.and_then(normalize_admin_editor_image_sequence_frames);
|
||||
let asset_kind = resolve_editor_asset_kind(
|
||||
record.asset_kind,
|
||||
record.image_src.as_str(),
|
||||
record.object_key.as_deref(),
|
||||
);
|
||||
AdminEditorShowcaseAssetPayload {
|
||||
showcase_id: record.showcase_id,
|
||||
asset_id: record.asset_id,
|
||||
@@ -1072,8 +1092,8 @@ fn admin_editor_showcase_asset_payload_from_record(
|
||||
model: record.model,
|
||||
provider: record.provider,
|
||||
task_id: record.task_id,
|
||||
asset_kind: record.asset_kind,
|
||||
generation_inputs: record.generation_inputs,
|
||||
asset_kind,
|
||||
generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs),
|
||||
thumbnail_src: record.thumbnail_src,
|
||||
generation_cost_mud_points: record.generation_cost_mud_points,
|
||||
refund_mud_points: record.refund_mud_points,
|
||||
@@ -1091,6 +1111,8 @@ fn admin_editor_showcase_asset_payload_from_record(
|
||||
rejected_at: record.rejected_at,
|
||||
updated_at: record.updated_at,
|
||||
showcase_category: record.showcase_category,
|
||||
image_sequence_frames: formal_image_sequence_frames,
|
||||
image_sequence_duration_ms: record.image_sequence_duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1193,6 +1215,15 @@ fn admin_editor_asset_payload_from_record(
|
||||
) -> AdminEditorAssetPayload {
|
||||
let author = resolve_work_author_by_user_id(state, &record.owner_user_id, None, None);
|
||||
let generator = resolve_admin_editor_asset_generator(&record);
|
||||
let formal_image_sequence_frames = record
|
||||
.image_sequence_frames
|
||||
.as_ref()
|
||||
.and_then(normalize_admin_editor_image_sequence_frames);
|
||||
let asset_kind = resolve_editor_asset_kind(
|
||||
record.asset_kind,
|
||||
record.image_src.as_str(),
|
||||
record.object_key.as_deref(),
|
||||
);
|
||||
AdminEditorAssetPayload {
|
||||
asset_id: record.asset_id,
|
||||
owner_user_id: record.owner_user_id,
|
||||
@@ -1212,8 +1243,8 @@ fn admin_editor_asset_payload_from_record(
|
||||
provider: record.provider,
|
||||
task_id: record.task_id,
|
||||
group_task_id: record.group_task_id,
|
||||
asset_kind: record.asset_kind,
|
||||
generation_inputs: record.generation_inputs,
|
||||
asset_kind,
|
||||
generation_inputs: sanitize_editor_generation_inputs(record.generation_inputs),
|
||||
source_resource_id: record.source_resource_id,
|
||||
thumbnail_src: record.thumbnail_src,
|
||||
generation_cost_mud_points: record.generation_cost_mud_points,
|
||||
@@ -1223,6 +1254,8 @@ fn admin_editor_asset_payload_from_record(
|
||||
task_cost_mud_points: record.generation_cost_mud_points,
|
||||
generator,
|
||||
children: Vec::new(),
|
||||
image_sequence_frames: formal_image_sequence_frames,
|
||||
image_sequence_duration_ms: record.image_sequence_duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4118,6 +4151,8 @@ mod tests {
|
||||
rejected_at: None,
|
||||
updated_at: "2026-07-04T10:00:00.000Z".to_string(),
|
||||
showcase_category: None,
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4152,6 +4187,8 @@ mod tests {
|
||||
generation_cost_mud_points,
|
||||
created_at: "2026-07-04T10:00:00Z".to_string(),
|
||||
updated_at: "2026-07-04T10:00:00Z".to_string(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4316,6 +4353,76 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_editor_showcase_payload_uses_formal_action_fields_only() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let formal_frames = json!([
|
||||
{
|
||||
"imageSrc": "/generated/action/formal-01.png",
|
||||
"objectKey": "generated/action/formal-01.png",
|
||||
"assetObjectId": "asset-object-formal-01",
|
||||
"width": 192,
|
||||
"height": 256
|
||||
},
|
||||
{
|
||||
"imageSrc": "/generated/action/formal-02.png",
|
||||
"objectKey": "generated/action/formal-02.png",
|
||||
"assetObjectId": "asset-object-formal-02",
|
||||
"width": 192,
|
||||
"height": 256
|
||||
}
|
||||
]);
|
||||
let mut record = test_admin_editor_showcase_asset_record("user-author-1");
|
||||
record.asset_kind = Some("character-animation".to_string());
|
||||
record.image_sequence_frames = Some(formal_frames.clone());
|
||||
record.image_sequence_duration_ms = Some(6_000);
|
||||
record.generation_inputs = Some(json!({
|
||||
"fields": [{ "title": "动作", "value": "挥手" }]
|
||||
}));
|
||||
|
||||
let payload = admin_editor_showcase_asset_payload_from_record(&state, record);
|
||||
|
||||
assert_eq!(payload.asset_kind.as_deref(), Some("character-animation"));
|
||||
assert_eq!(
|
||||
serde_json::to_value(payload.image_sequence_frames)
|
||||
.expect("formal frames should serialize"),
|
||||
formal_frames
|
||||
);
|
||||
assert_eq!(payload.image_sequence_duration_ms, Some(6_000));
|
||||
assert_eq!(
|
||||
payload.generation_inputs,
|
||||
Some(json!({ "fields": [{ "title": "动作", "value": "挥手" }] }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_editor_asset_payload_preserves_formal_character_action_metadata() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let frames = json!([
|
||||
{ "imageSrc": "/generated/action/frame-01.png", "objectKey": "generated/action/frame-01.png", "assetObjectId": "asset-object-frame-01", "width": 192, "height": 256 },
|
||||
{ "imageSrc": "/generated/action/frame-02.png", "objectKey": "generated/action/frame-02.png", "assetObjectId": "asset-object-frame-02", "width": 192, "height": 256 }
|
||||
]);
|
||||
let mut record = test_admin_editor_asset_record(
|
||||
"action-asset",
|
||||
"action-task",
|
||||
"角色动作",
|
||||
"generated/action/frame-01.png",
|
||||
12,
|
||||
);
|
||||
record.asset_kind = Some("character-animation".to_string());
|
||||
record.image_sequence_frames = Some(frames.clone());
|
||||
record.image_sequence_duration_ms = Some(5_000);
|
||||
|
||||
let payload = admin_editor_asset_payload_from_record(&state, record);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(payload.image_sequence_frames)
|
||||
.expect("formal frames should serialize"),
|
||||
frames
|
||||
);
|
||||
assert_eq!(payload.image_sequence_duration_ms, Some(5_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_editor_asset_group_nests_each_intermediate_output_under_final_asset() {
|
||||
let final_output = test_admin_editor_asset_record(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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_untrusted_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,
|
||||
@@ -54,6 +54,7 @@ use crate::{
|
||||
enqueue_editor_sound_effect_generation_for_owner,
|
||||
},
|
||||
};
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
const EXTERNAL_EDITOR_PROVIDER: &str = "external-editor-api";
|
||||
const SCOPE_EDITOR_PROJECT: &str = "editor:project";
|
||||
@@ -117,6 +118,8 @@ pub struct ExternalEditorAssetCreateRequest {
|
||||
task_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
image_sequence_frames: Option<Value>,
|
||||
image_sequence_duration_ms: Option<NonZeroU64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -136,6 +139,8 @@ pub struct ExternalEditorProjectResourceCreateRequest {
|
||||
source_resource_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
generation_inputs: Option<Value>,
|
||||
image_sequence_frames: Option<Value>,
|
||||
image_sequence_duration_ms: Option<NonZeroU64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -479,8 +484,12 @@ 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 =
|
||||
serialize_external_editor_image_sequence_frames(payload.image_sequence_frames)?;
|
||||
let object_key = normalize_optional_string(payload.object_key);
|
||||
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
|
||||
let asset = state
|
||||
@@ -509,6 +518,8 @@ pub async fn create_external_editor_asset(
|
||||
thumbnail_src: None,
|
||||
group_task_id: None,
|
||||
group_task_expected_asset_count: None,
|
||||
image_sequence_frames_json,
|
||||
image_sequence_duration_ms: payload.image_sequence_duration_ms.map(NonZeroU64::get),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
@@ -581,8 +592,12 @@ 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 =
|
||||
serialize_external_editor_image_sequence_frames(payload.image_sequence_frames)?;
|
||||
let object_key = normalize_optional_string(payload.object_key);
|
||||
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
|
||||
let resource = state
|
||||
@@ -606,6 +621,8 @@ pub async fn create_external_editor_project_resource(
|
||||
asset_kind: normalize_optional_string(payload.asset_kind),
|
||||
generation_inputs_json,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
image_sequence_frames_json,
|
||||
image_sequence_duration_ms: payload.image_sequence_duration_ms.map(NonZeroU64::get),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
@@ -989,17 +1006,30 @@ 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_untrusted_generation_inputs(
|
||||
generation_inputs,
|
||||
))
|
||||
serialize_editor_generation_inputs(
|
||||
asset_kind,
|
||||
sanitize_editor_untrusted_generation_inputs(generation_inputs),
|
||||
)
|
||||
}
|
||||
|
||||
fn serialize_external_editor_image_sequence_frames(
|
||||
frames: Option<Value>,
|
||||
) -> Result<Option<String>, AppError> {
|
||||
crate::editor_project::serialize_editor_image_sequence_frames(frames)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS: [&str; 2] = [
|
||||
"ExternalEditorAssetCreateRequest",
|
||||
"ExternalEditorProjectResourceCreateRequest",
|
||||
];
|
||||
|
||||
fn external_generation_job_fixture(status: &str) -> ExternalGenerationJobRecord {
|
||||
ExternalGenerationJobRecord {
|
||||
job_id: "task-external-test".to_string(),
|
||||
@@ -1060,32 +1090,124 @@ 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",
|
||||
"references": [{
|
||||
"title": "伪造引用",
|
||||
"label": "其他用户素材",
|
||||
"refType": "asset",
|
||||
"refId": "asset-forged"
|
||||
}],
|
||||
"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",
|
||||
"references": [{
|
||||
"title": "伪造引用",
|
||||
"label": "其他用户素材",
|
||||
"refType": "asset",
|
||||
"refId": "asset-forged"
|
||||
}],
|
||||
"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());
|
||||
assert!(parsed.get("references").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_editor_sequence_frames_reject_temporary_signed_urls_without_stable_references() {
|
||||
let error = serialize_external_editor_image_sequence_frames(Some(json!([
|
||||
{
|
||||
"imageSrc": "https://example.invalid/frame-01.png?Expires=60&Signature=temporary-1",
|
||||
"width": 192,
|
||||
"height": 256
|
||||
},
|
||||
{
|
||||
"imageSrc": "https://example.invalid/frame-02.png?Expires=60&Signature=temporary-2",
|
||||
"width": 192,
|
||||
"height": 256
|
||||
}
|
||||
])))
|
||||
.expect_err("External v1 不能把仅含临时签名 URL 的帧持久化为正式动作");
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
error.details().and_then(|details| details.get("fields")),
|
||||
Some(&json!(["objectKey", "assetObjectId"]))
|
||||
);
|
||||
assert!(error.body_text().contains("临时签名 URL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_editor_sequence_frames_derive_persistent_paths_from_object_keys() {
|
||||
let serialized = serialize_external_editor_image_sequence_frames(Some(json!([
|
||||
{
|
||||
"imageSrc": "https://example.invalid/frame-01.png?Expires=60&Signature=temporary-1",
|
||||
"objectKey": "/generated/action/frame-01.png",
|
||||
"assetObjectId": "asset-object-frame-01",
|
||||
"width": 192,
|
||||
"height": 256
|
||||
},
|
||||
{
|
||||
"imageSrc": "https://example.invalid/frame-02.png?Expires=60&Signature=temporary-2",
|
||||
"objectKey": "generated/action/frame-02.png",
|
||||
"assetObjectId": "asset-object-frame-02",
|
||||
"width": 192,
|
||||
"height": 256
|
||||
}
|
||||
])))
|
||||
.expect("带稳定对象引用的 External v1 正式帧应可持久化")
|
||||
.expect("正式帧序列应产生 JSON");
|
||||
let frames: Value = serde_json::from_str(&serialized).expect("正式帧序列应为合法 JSON");
|
||||
|
||||
assert_eq!(frames[0]["imageSrc"], "/generated/action/frame-01.png");
|
||||
assert_eq!(frames[1]["imageSrc"], "/generated/action/frame-02.png");
|
||||
assert!(frames.as_array().is_some_and(|frames| {
|
||||
frames.iter().all(|frame| {
|
||||
!frame["imageSrc"]
|
||||
.as_str()
|
||||
.is_some_and(|image_src| image_src.contains("Signature="))
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_generation_requires_bounded_printable_idempotency_key() {
|
||||
let missing = HeaderMap::new();
|
||||
@@ -1197,11 +1319,111 @@ mod tests {
|
||||
assert!(!unavailable.body_text().contains("ConnectDropped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_openapi_character_animation_create_requires_complete_sequence_metadata() {
|
||||
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
|
||||
|
||||
for request_schema_name in EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS {
|
||||
let request_schema = &parsed["components"]["schemas"][request_schema_name];
|
||||
let conditional = &request_schema["allOf"][0];
|
||||
assert_eq!(
|
||||
conditional["then"]["required"],
|
||||
json!(["imageSequenceFrames", "imageSequenceDurationMs"]),
|
||||
"{request_schema_name}"
|
||||
);
|
||||
assert_eq!(
|
||||
request_schema["properties"]["imageSequenceFrames"]["minItems"],
|
||||
json!(2),
|
||||
"{request_schema_name}"
|
||||
);
|
||||
let frame_schema = &parsed["components"]["schemas"]["EditorImageSequenceFrame"];
|
||||
assert_eq!(
|
||||
frame_schema["required"],
|
||||
json!(["imageSrc", "objectKey", "assetObjectId", "width", "height"]),
|
||||
"{request_schema_name}"
|
||||
);
|
||||
for stable_field in ["objectKey", "assetObjectId"] {
|
||||
assert_eq!(
|
||||
frame_schema["properties"][stable_field]["type"],
|
||||
json!("string"),
|
||||
"{request_schema_name}.{stable_field}"
|
||||
);
|
||||
assert_eq!(
|
||||
frame_schema["properties"][stable_field]["minLength"],
|
||||
json!(1),
|
||||
"{request_schema_name}.{stable_field}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
request_schema["properties"]["imageSequenceDurationMs"]["minimum"],
|
||||
json!(1),
|
||||
"{request_schema_name}"
|
||||
);
|
||||
assert_eq!(
|
||||
conditional["then"]["properties"]["generationInputs"]["$ref"],
|
||||
"#/components/schemas/ExternalCharacterAnimationGenerationInputs",
|
||||
"{request_schema_name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_openapi_non_animation_create_forbids_sequence_metadata() {
|
||||
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
|
||||
|
||||
for request_schema_name in EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS {
|
||||
let request_schema = &parsed["components"]["schemas"][request_schema_name];
|
||||
let conditional = &request_schema["allOf"][0];
|
||||
assert_eq!(
|
||||
conditional["if"]["required"],
|
||||
json!(["assetKind"]),
|
||||
"{request_schema_name}"
|
||||
);
|
||||
let forbidden_fields = conditional["else"]["not"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("非角色动作分支必须逐项禁止序列字段")
|
||||
.iter()
|
||||
.filter_map(|schema| schema["required"][0].as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
forbidden_fields,
|
||||
["imageSequenceFrames", "imageSequenceDurationMs"],
|
||||
"{request_schema_name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_openapi_json_contains_external_editor_routes_and_security() {
|
||||
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 EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS {
|
||||
assert_eq!(
|
||||
parsed["components"]["schemas"][request_schema]["allOf"][0]["if"]["properties"]["assetKind"]
|
||||
["const"],
|
||||
"character-animation"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
parsed["paths"]
|
||||
.get("/api/external/v1/editor/projects")
|
||||
|
||||
@@ -1396,7 +1396,7 @@ fn compact_external_api_generation_result(result: Value) -> Value {
|
||||
frame.retain(|key, _| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"frameIndex" | "imageSrc" | "objectKey" | "width" | "height"
|
||||
"frameIndex" | "imageSrc" | "objectKey" | "assetObjectId" | "width" | "height"
|
||||
)
|
||||
});
|
||||
remove_unstable_external_generation_media_fields(frame);
|
||||
@@ -2365,6 +2365,23 @@ mod tests {
|
||||
"height": 1024,
|
||||
"generationInputs": {"private": true}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"imageSrc": "/generated/action/frame-01.png",
|
||||
"objectKey": "generated/action/frame-01.png",
|
||||
"assetObjectId": "asset-object-frame-01",
|
||||
"width": 192,
|
||||
"height": 256,
|
||||
"provider": "internal-provider-must-not-persist"
|
||||
},
|
||||
{
|
||||
"imageSrc": "/generated/action/frame-02.png",
|
||||
"objectKey": "generated/action/frame-02.png",
|
||||
"assetObjectId": "asset-object-frame-02",
|
||||
"width": 192,
|
||||
"height": 256
|
||||
}
|
||||
],
|
||||
"project": {
|
||||
"projectId": "project-1",
|
||||
"canvas": {"layers": ["large-layout-must-not-persist"]}
|
||||
@@ -2421,6 +2438,16 @@ mod tests {
|
||||
);
|
||||
assert!(result["asset"].get("thumbnailSrc").is_none());
|
||||
assert!(result["asset"].get("generationInputs").is_none());
|
||||
assert_eq!(
|
||||
result["frames"][0],
|
||||
json!({
|
||||
"imageSrc": "/generated/action/frame-01.png",
|
||||
"objectKey": "generated/action/frame-01.png",
|
||||
"assetObjectId": "asset-object-frame-01",
|
||||
"width": 192,
|
||||
"height": 256
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
result["warning"],
|
||||
json!({
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::{
|
||||
editor_project::{
|
||||
EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest,
|
||||
build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items,
|
||||
persist_editor_generated_media_asset,
|
||||
persist_editor_generated_media_asset, with_editor_media_duration_generation_input,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
@@ -220,6 +220,10 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let normalized = normalize_editor_sound_effect_request_with_pricing(payload, &pricing)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let generation_inputs = with_editor_media_duration_generation_input(
|
||||
generation_inputs,
|
||||
u32::from(normalized.duration),
|
||||
);
|
||||
let settings = require_vector_engine_audio_settings(&state)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
|
||||
@@ -284,6 +288,8 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
|
||||
generation_inputs: generation_inputs.clone(),
|
||||
thumbnail_src: None,
|
||||
generation_cost_mud_points: u64::from(normalized.price_mud_points),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -301,7 +307,6 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
|
||||
resource_id,
|
||||
title: completion.title.trim().to_string(),
|
||||
src: audio_src.clone(),
|
||||
media_type: Some("audio".to_string()),
|
||||
thumbnail_src: None,
|
||||
original_width: EDITOR_AUDIO_WIDTH,
|
||||
original_height: EDITOR_AUDIO_HEIGHT,
|
||||
@@ -315,12 +320,9 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
|
||||
task_id: Some(generated.task_id.clone()),
|
||||
object_key: generated.object_key.clone(),
|
||||
asset_object_id: generated.asset_object_id.clone(),
|
||||
duration_seconds: Some(u32::from(normalized.duration)),
|
||||
source_resource_id: None,
|
||||
asset_kind: Some("sound-effect".to_string()),
|
||||
generation_inputs,
|
||||
image_sequence_frames: None,
|
||||
preview_video_path: None,
|
||||
},
|
||||
);
|
||||
complete_editor_canvas_generation_with_items(
|
||||
@@ -521,6 +523,8 @@ pub(crate) async fn generate_editor_background_music_for_owner(
|
||||
generation_inputs: generation_inputs.clone(),
|
||||
thumbnail_src: None,
|
||||
generation_cost_mud_points: u64::from(normalized.price_mud_points),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -542,7 +546,6 @@ pub(crate) async fn generate_editor_background_music_for_owner(
|
||||
}),
|
||||
title: completion.title.trim().to_string(),
|
||||
src: audio_src.clone(),
|
||||
media_type: Some("audio".to_string()),
|
||||
thumbnail_src: None,
|
||||
original_width: EDITOR_AUDIO_WIDTH,
|
||||
original_height: EDITOR_AUDIO_HEIGHT,
|
||||
@@ -556,12 +559,9 @@ pub(crate) async fn generate_editor_background_music_for_owner(
|
||||
task_id: Some(generated.task_id.clone()),
|
||||
object_key: generated.object_key.clone(),
|
||||
asset_object_id: generated.asset_object_id.clone(),
|
||||
duration_seconds: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: Some("background-music".to_string()),
|
||||
generation_inputs,
|
||||
image_sequence_frames: None,
|
||||
preview_video_path: None,
|
||||
},
|
||||
);
|
||||
complete_editor_canvas_generation_with_items(
|
||||
|
||||
Reference in New Issue
Block a user