use std::collections::BTreeMap; use platform_oss::{ OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse, }; use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CreateDirectUploadTicketRequest { pub legacy_prefix: String, #[serde(default)] pub path_segments: Vec, pub file_name: String, #[serde(default)] pub content_type: Option, #[serde(default)] pub access: Option, #[serde(default)] pub metadata: BTreeMap, #[serde(default)] pub max_size_bytes: Option, #[serde(default)] pub expire_seconds: Option, #[serde(default)] pub success_action_status: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct GetReadUrlQuery { #[serde(default)] pub object_key: Option, #[serde(default)] pub legacy_public_path: Option, #[serde(default)] pub expire_seconds: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ConfirmAssetObjectAccessPolicy { Private, PublicRead, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ConfirmAssetObjectRequest { #[serde(default)] pub bucket: Option, pub object_key: String, #[serde(default)] pub content_type: Option, #[serde(default)] pub content_length: Option, #[serde(default)] pub content_hash: Option, pub asset_kind: String, #[serde(default)] pub access_policy: Option, #[serde(default)] pub source_job_id: Option, #[serde(default)] pub owner_user_id: Option, #[serde(default)] pub profile_id: Option, #[serde(default)] pub entity_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct BindAssetObjectRequest { pub asset_object_id: String, pub entity_kind: String, pub entity_id: String, pub slot: String, pub asset_kind: String, #[serde(default)] pub owner_user_id: Option, #[serde(default)] pub profile_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AssetHistoryQuery { pub kind: String, #[serde(default)] pub limit: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AssetHistoryEntryPayload { pub asset_object_id: String, pub asset_kind: String, pub image_src: String, pub owner_user_id: Option, pub owner_label: String, pub profile_id: Option, pub entity_id: Option, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AssetHistoryListResponse { pub assets: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum CharacterVisualSourceMode { TextToImage, ImageToImage, Upload, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CharacterVisualGenerateRequest { pub character_id: String, pub source_mode: CharacterVisualSourceMode, pub prompt_text: String, #[serde(default)] pub reference_image_data_urls: Vec, pub candidate_count: u32, pub image_model: String, pub size: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CharacterVisualDraftPayload { pub id: String, pub label: String, pub image_src: String, pub width: u32, pub height: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CharacterVisualGenerateResponse { pub ok: bool, pub task_id: String, pub model: String, pub prompt: String, pub drafts: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum CharacterAssetJobStatusText { Queued, Running, Completed, Failed, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterAssetJobStatusPayload { pub task_id: String, pub kind: String, pub status: CharacterAssetJobStatusText, pub character_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub animation: Option, #[serde(skip_serializing_if = "Option::is_none")] pub strategy: Option, pub model: String, pub prompt: String, pub created_at: String, pub updated_at: String, #[serde(skip_serializing_if = "Option::is_none")] pub result: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error_message: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CharacterVisualPublishRequest { pub character_id: String, pub source_mode: CharacterVisualSourceMode, #[serde(default)] pub prompt_text: Option, pub selected_preview_source: String, #[serde(default)] pub preview_sources: Vec, pub width: u32, pub height: u32, #[serde(default)] pub update_character_override: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterVisualPublishResponse { pub ok: bool, pub asset_id: String, pub portrait_path: String, pub override_map: Value, pub save_message: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CharacterAnimationTemplatePayload { pub id: String, pub label: String, pub animation: String, pub prompt_suffix: String, pub notes: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CharacterAnimationTemplatesResponse { pub ok: bool, pub templates: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CharacterAnimationImportVideoRequest { pub character_id: String, pub animation: String, pub video_source: String, #[serde(default)] pub source_label: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CharacterAnimationImportVideoResponse { pub ok: bool, pub imported_video_path: String, pub draft_id: String, pub save_message: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum CharacterAnimationStrategy { ImageSequence, ImageToVideo, MotionTransfer, ReferenceToVideo, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CharacterAnimationGenerateRequest { pub character_id: String, pub strategy: CharacterAnimationStrategy, pub animation: String, pub prompt_text: String, #[serde(default)] pub character_brief_text: Option, #[serde(default)] pub action_template_id: Option, pub visual_source: String, #[serde(default)] pub reference_image_data_urls: Vec, #[serde(default)] pub reference_video_data_urls: Vec, #[serde(default)] pub last_frame_image_data_url: Option, pub frame_count: u32, pub fps: u32, pub duration_seconds: u32, #[serde(rename = "loop")] pub loop_: bool, pub use_chroma_key: bool, pub resolution: String, pub ratio: String, pub image_sequence_model: String, pub video_model: String, pub reference_video_model: String, pub motion_transfer_model: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CharacterAnimationGenerateResponse { pub ok: bool, pub task_id: String, pub strategy: CharacterAnimationStrategy, pub model: String, pub prompt: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub image_sources: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub preview_video_path: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterAnimationDraftPayload { #[serde(default)] pub frames_data_urls: Vec, pub fps: u32, #[serde(rename = "loop")] pub loop_: bool, pub frame_width: u32, pub frame_height: u32, #[serde(default)] pub frame_count: Option, #[serde(default)] pub apply_chroma_key: Option, #[serde(default)] pub sample_start_ratio: Option, #[serde(default)] pub sample_end_ratio: Option, #[serde(default)] pub preview_video_path: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterAnimationPublishRequest { pub character_id: String, pub visual_asset_id: String, pub animations: BTreeMap, #[serde(default)] pub update_character_override: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterAnimationPublishResponse { pub ok: bool, pub animation_set_id: String, pub override_map: Value, pub animation_map: Value, pub save_message: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterWorkflowCachePayload { pub character_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub cache_scope_id: Option, pub visual_prompt_text: String, pub animation_prompt_text: String, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub animation_prompt_text_by_key: BTreeMap, pub visual_drafts: Vec, pub selected_visual_draft_id: String, pub selected_animation: String, #[serde(skip_serializing_if = "Option::is_none")] pub image_src: Option, #[serde(skip_serializing_if = "Option::is_none")] pub generated_visual_asset_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub generated_animation_set_id: Option, #[serde(default)] pub animation_map: Option, #[serde(skip_serializing_if = "Option::is_none")] pub updated_at: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterWorkflowCacheSaveRequest { pub character_id: String, #[serde(default)] pub cache_scope_id: Option, #[serde(default)] pub visual_prompt_text: Option, #[serde(default)] pub animation_prompt_text: Option, #[serde(default)] pub animation_prompt_text_by_key: BTreeMap, #[serde(default)] pub visual_drafts: Vec, #[serde(default)] pub selected_visual_draft_id: Option, #[serde(default)] pub selected_animation: Option, #[serde(default)] pub image_src: Option, #[serde(default)] pub generated_visual_asset_id: Option, #[serde(default)] pub generated_animation_set_id: Option, #[serde(default)] pub animation_map: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterWorkflowCacheGetResponse { pub ok: bool, pub cache: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterAssetRolePromptInput { pub id: String, #[serde(default)] pub name: String, #[serde(default)] pub title: String, #[serde(default)] pub role: String, #[serde(default)] pub visual_description: Option, #[serde(default)] pub action_description: Option, #[serde(default)] pub scene_visual_description: Option, #[serde(default)] pub description: Option, #[serde(default)] pub backstory: Option, #[serde(default)] pub personality: Option, #[serde(default)] pub motivation: Option, #[serde(default)] pub combat_style: Option, #[serde(default)] pub tags: Vec, #[serde(default)] pub image_src: Option, #[serde(default)] pub generated_visual_asset_id: Option, #[serde(default)] pub generated_animation_set_id: Option, #[serde(default)] pub animation_map: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CharacterRolePromptBundlePayload { pub visual_prompt_text: String, pub animation_prompt_text: String, pub scene_prompt_text: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterRoleAssetWorkflowPayload { pub role: CharacterAssetRolePromptInput, pub default_prompt_bundle: CharacterRolePromptBundlePayload, pub visual_prompt_text: String, pub animation_prompt_text: String, pub animation_prompt_text_by_key: BTreeMap, pub visual_drafts: Vec, pub selected_visual_draft_id: String, pub selected_animation: String, #[serde(skip_serializing_if = "Option::is_none")] pub image_src: Option, #[serde(skip_serializing_if = "Option::is_none")] pub generated_visual_asset_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub generated_animation_set_id: Option, #[serde(default)] pub animation_map: Option, #[serde(skip_serializing_if = "Option::is_none")] pub updated_at: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterRoleAssetWorkflowResolveRequest { #[serde(default)] pub cache_scope_id: Option, pub role: CharacterAssetRolePromptInput, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterRoleAssetWorkflowResponse { pub ok: bool, pub cache: Option, pub workflow: CharacterRoleAssetWorkflowPayload, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CharacterWorkflowCacheSaveResponse { pub ok: bool, pub cache: CharacterWorkflowCachePayload, pub save_message: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CreateDirectUploadTicketResponse { pub upload: DirectUploadTicketPayload, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DirectUploadTicketPayload { pub signature_version: String, pub provider: String, pub bucket: String, pub endpoint: String, pub host: String, pub object_key: String, pub legacy_public_path: String, #[serde(default)] pub content_type: Option, pub access: OssObjectAccess, pub key_prefix: String, pub expires_at: String, pub max_size_bytes: u64, pub success_action_status: u16, pub form_fields: DirectUploadTicketFormFields, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct DirectUploadTicketFormFields { pub key: String, pub policy: String, #[serde(rename = "OSSAccessKeyId")] pub oss_access_key_id: String, #[serde(rename = "Signature")] pub signature: String, #[serde(rename = "success_action_status")] pub success_action_status: String, #[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")] pub content_type: Option, #[serde(flatten)] pub metadata: BTreeMap, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct GetAssetReadUrlResponse { pub read: AssetReadUrlPayload, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AssetReadUrlPayload { pub provider: String, pub bucket: String, pub endpoint: String, pub host: String, pub object_key: String, pub expires_at: String, pub signed_url: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ConfirmAssetObjectResponse { pub asset_object: AssetObjectPayload, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AssetObjectPayload { pub asset_object_id: String, pub bucket: String, pub object_key: String, pub access_policy: String, #[serde(default)] pub content_type: Option, pub content_length: u64, #[serde(default)] pub content_hash: Option, pub version: u32, #[serde(default)] pub source_job_id: Option, #[serde(default)] pub owner_user_id: Option, #[serde(default)] pub profile_id: Option, #[serde(default)] pub entity_id: Option, pub asset_kind: String, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct BindAssetObjectResponse { pub asset_binding: AssetBindingPayload, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AssetBindingPayload { pub binding_id: String, pub asset_object_id: String, pub entity_kind: String, pub entity_id: String, pub slot: String, pub asset_kind: String, #[serde(default)] pub owner_user_id: Option, #[serde(default)] pub profile_id: Option, pub created_at: String, pub updated_at: String, } impl From for DirectUploadTicketFormFields { fn from(value: OssPostObjectFormFields) -> Self { Self { key: value.key, policy: value.policy, oss_access_key_id: value.oss_access_key_id, signature: value.signature, success_action_status: value.success_action_status, content_type: value.content_type, metadata: value.metadata, } } } impl From for DirectUploadTicketPayload { fn from(value: OssPostObjectResponse) -> Self { Self { signature_version: value.signature_version.to_string(), provider: value.provider.to_string(), bucket: value.bucket, endpoint: value.endpoint, host: value.host, object_key: value.object_key, legacy_public_path: value.legacy_public_path, content_type: value.content_type, access: value.access, key_prefix: value.key_prefix, expires_at: value.expires_at, max_size_bytes: value.max_size_bytes, success_action_status: value.success_action_status, form_fields: value.form_fields.into(), } } } impl From for AssetReadUrlPayload { fn from(value: OssSignedGetObjectUrlResponse) -> Self { Self { provider: value.provider.to_string(), bucket: value.bucket, endpoint: value.endpoint, host: value.host, object_key: value.object_key, expires_at: value.expires_at, signed_url: value.signed_url, } } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn confirm_asset_object_access_policy_uses_snake_case() { let payload = serde_json::to_value(ConfirmAssetObjectAccessPolicy::PublicRead) .expect("payload should serialize"); assert_eq!(payload, json!("public_read")); } #[test] fn bind_asset_object_request_uses_camel_case_fields() { let payload = serde_json::to_value(BindAssetObjectRequest { asset_object_id: "assetobj_1".to_string(), entity_kind: "character".to_string(), entity_id: "npc_1".to_string(), slot: "primary_visual".to_string(), asset_kind: "character_visual".to_string(), owner_user_id: Some("user_1".to_string()), profile_id: Some("profile_1".to_string()), }) .expect("payload should serialize"); assert_eq!( payload, json!({ "assetObjectId": "assetobj_1", "entityKind": "character", "entityId": "npc_1", "slot": "primary_visual", "assetKind": "character_visual", "ownerUserId": "user_1", "profileId": "profile_1" }) ); } #[test] fn direct_upload_ticket_response_keeps_form_fields_shape() { let payload = serde_json::to_value(CreateDirectUploadTicketResponse { upload: DirectUploadTicketPayload::from(OssPostObjectResponse { signature_version: "v1", provider: "aliyun-oss", bucket: "genarrative-assets".to_string(), endpoint: "oss-cn-shanghai.aliyuncs.com".to_string(), host: "https://genarrative-assets.oss-cn-shanghai.aliyuncs.com".to_string(), object_key: "generated-characters/hero/master.png".to_string(), legacy_public_path: "/generated-characters/hero/master.png".to_string(), content_type: Some("image/png".to_string()), access: OssObjectAccess::Private, key_prefix: "generated-characters/hero".to_string(), expires_at: "2026-04-21T00:00:00Z".to_string(), max_size_bytes: 1024, success_action_status: 200, form_fields: OssPostObjectFormFields { key: "generated-characters/hero/master.png".to_string(), policy: "policy".to_string(), oss_access_key_id: "ak".to_string(), signature: "sig".to_string(), success_action_status: "200".to_string(), content_type: Some("image/png".to_string()), metadata: BTreeMap::from([( "x-oss-meta-asset-kind".to_string(), "character_visual".to_string(), )]), }, }), }) .expect("payload should serialize"); assert_eq!(payload["upload"]["signatureVersion"], json!("v1")); assert_eq!( payload["upload"]["formFields"]["OSSAccessKeyId"], json!("ak") ); assert_eq!( payload["upload"]["formFields"]["x-oss-meta-asset-kind"], json!("character_visual") ); } #[test] fn confirm_asset_object_response_uses_camel_case_fields() { let payload = serde_json::to_value(ConfirmAssetObjectResponse { asset_object: AssetObjectPayload { asset_object_id: "assetobj_1".to_string(), bucket: "genarrative-assets".to_string(), object_key: "generated-characters/hero/master.png".to_string(), access_policy: "private".to_string(), content_type: Some("image/png".to_string()), content_length: 1024, content_hash: Some("etag-1".to_string()), version: 1, source_job_id: Some("job_1".to_string()), owner_user_id: Some("user_1".to_string()), profile_id: Some("profile_1".to_string()), entity_id: Some("entity_1".to_string()), asset_kind: "character_visual".to_string(), created_at: "1.000000Z".to_string(), updated_at: "1.000000Z".to_string(), }, }) .expect("payload should serialize"); assert_eq!(payload["assetObject"]["assetObjectId"], json!("assetobj_1")); assert_eq!(payload["assetObject"]["accessPolicy"], json!("private")); assert_eq!(payload["assetObject"]["contentLength"], json!(1024)); } #[test] fn character_visual_source_mode_uses_legacy_kebab_case() { let payload = serde_json::to_value(CharacterVisualSourceMode::ImageToImage) .expect("source mode should serialize"); assert_eq!(payload, json!("image-to-image")); } #[test] fn character_visual_generate_response_keeps_legacy_shape() { let payload = serde_json::to_value(CharacterVisualGenerateResponse { ok: true, task_id: "visual_1".to_string(), model: "rust-svg-character-visual".to_string(), prompt: "角色提示词".to_string(), drafts: vec![CharacterVisualDraftPayload { id: "candidate-1".to_string(), label: "候选 1".to_string(), image_src: "/generated-character-drafts/hero/visual/visual_1/candidate-01.svg" .to_string(), width: 1024, height: 1024, }], }) .expect("response should serialize"); assert_eq!(payload["ok"], json!(true)); assert_eq!(payload["taskId"], json!("visual_1")); assert_eq!( payload["drafts"][0]["imageSrc"], json!("/generated-character-drafts/hero/visual/visual_1/candidate-01.svg") ); } #[test] fn character_animation_templates_response_keeps_legacy_shape() { let payload = serde_json::to_value(CharacterAnimationTemplatesResponse { ok: true, templates: vec![CharacterAnimationTemplatePayload { id: "idle_loop".to_string(), label: "待机循环".to_string(), animation: "idle".to_string(), prompt_suffix: "保持呼吸感。".to_string(), notes: "默认待机模板。".to_string(), }], }) .expect("response should serialize"); assert_eq!(payload["ok"], json!(true)); assert_eq!(payload["templates"][0]["id"], json!("idle_loop")); assert_eq!( payload["templates"][0]["promptSuffix"], json!("保持呼吸感。") ); } #[test] fn character_animation_import_video_response_keeps_legacy_shape() { let payload = serde_json::to_value(CharacterAnimationImportVideoResponse { ok: true, imported_video_path: "/generated-character-drafts/hero/animation/idle/import-1/reference.mp4" .to_string(), draft_id: "animation-import-1".to_string(), save_message: "参考视频已导入 OSS 草稿区。".to_string(), }) .expect("response should serialize"); assert_eq!( payload["importedVideoPath"], json!("/generated-character-drafts/hero/animation/idle/import-1/reference.mp4") ); assert_eq!(payload["draftId"], json!("animation-import-1")); } #[test] fn character_workflow_cache_response_keeps_legacy_shape() { let payload = serde_json::to_value(CharacterWorkflowCacheSaveResponse { ok: true, cache: CharacterWorkflowCachePayload { character_id: "hero".to_string(), cache_scope_id: Some("world-01".to_string()), visual_prompt_text: "主形象".to_string(), animation_prompt_text: "待机".to_string(), animation_prompt_text_by_key: BTreeMap::from([( "idle".to_string(), "待机".to_string(), )]), visual_drafts: vec![CharacterVisualDraftPayload { id: "draft-1".to_string(), label: "候选 1".to_string(), image_src: "/generated-character-drafts/hero/visual/job/candidate.svg" .to_string(), width: 1024, height: 1536, }], selected_visual_draft_id: "draft-1".to_string(), selected_animation: "idle".to_string(), image_src: Some("/generated-characters/hero/master.png".to_string()), generated_visual_asset_id: None, generated_animation_set_id: None, animation_map: Some(json!({ "idle": { "frames": 4 } })), updated_at: Some("2026-04-22T12:00:00Z".to_string()), }, save_message: "角色形象生成缓存已更新。".to_string(), }) .expect("response should serialize"); assert_eq!(payload["ok"], json!(true)); assert_eq!(payload["cache"]["characterId"], json!("hero")); assert_eq!(payload["cache"]["cacheScopeId"], json!("world-01")); assert_eq!( payload["cache"]["animationPromptTextByKey"]["idle"], json!("待机") ); assert_eq!( payload["cache"]["visualDrafts"][0]["imageSrc"], json!("/generated-character-drafts/hero/visual/job/candidate.svg") ); assert_eq!(payload["cache"]["animationMap"]["idle"]["frames"], json!(4)); } #[test] fn character_animation_strategy_uses_legacy_kebab_case() { let payload = serde_json::to_value(CharacterAnimationStrategy::MotionTransfer) .expect("strategy should serialize"); assert_eq!(payload, json!("motion-transfer")); } #[test] fn character_animation_generate_response_keeps_image_sequence_shape() { let payload = serde_json::to_value(CharacterAnimationGenerateResponse { ok: true, task_id: "animation_1".to_string(), strategy: CharacterAnimationStrategy::ImageSequence, model: "rust-svg-animation-sequence".to_string(), prompt: "待机动作".to_string(), image_sources: vec![ "/generated-character-drafts/hero/animation/idle/job/frame-01.svg".to_string(), ], preview_video_path: None, }) .expect("response should serialize"); assert_eq!(payload["ok"], json!(true)); assert_eq!(payload["taskId"], json!("animation_1")); assert_eq!(payload["strategy"], json!("image-sequence")); assert_eq!( payload["imageSources"][0], json!("/generated-character-drafts/hero/animation/idle/job/frame-01.svg") ); } #[test] fn character_animation_publish_response_keeps_legacy_shape() { let payload = serde_json::to_value(CharacterAnimationPublishResponse { ok: true, animation_set_id: "animation-set-1".to_string(), override_map: json!({}), animation_map: json!({ "idle": { "folder": "idle", "prefix": "frame", "frames": 2, "startFrame": 1, "extension": "svg", "basePath": "/generated-animations/hero/animation-set-1/idle", "frameWidth": 192, "frameHeight": 256, "fps": 8, "loop": true } }), save_message: "基础动作资源已写入 OSS 并绑定当前角色。".to_string(), }) .expect("response should serialize"); assert_eq!(payload["animationSetId"], json!("animation-set-1")); assert_eq!(payload["animationMap"]["idle"]["frames"], json!(2)); } #[test] fn character_animation_draft_payload_accepts_backend_extraction_fields() { let payload = serde_json::from_value::(json!({ "fps": 8, "loop": true, "frameWidth": 192, "frameHeight": 256, "frameCount": 8, "applyChromaKey": true, "sampleStartRatio": 0.12, "sampleEndRatio": 0.94, "previewVideoPath": "/generated-character-drafts/hero/animation/idle/task/preview.mp4" })) .expect("draft payload should deserialize without framesDataUrls"); assert!(payload.frames_data_urls.is_empty()); assert_eq!(payload.frame_count, Some(8)); assert_eq!(payload.apply_chroma_key, Some(true)); assert_eq!(payload.sample_start_ratio, Some(0.12)); assert_eq!(payload.sample_end_ratio, Some(0.94)); } }