Files
Genarrative/server-rs/crates/shared-contracts/src/assets.rs
T
lhk229 dea6576f89
Project CI / Repository checks (pull_request) Successful in 1m6s
Project CI / Frontend tests (pull_request) Successful in 2m52s
Project CI / Backend tests (pull_request) Failing after 3m1s
Project CI / Native shell tests (pull_request) Failing after 9m54s
修复音频生成响应泄露 Provider
收紧音频公开 DTO 与前端响应类型

统一清理 inline 和 Agent 队列结果中的 Provider

覆盖嵌套快照脱敏与历史 Agent 回填兼容
2026-08-08 12:26:41 +00:00

2060 lines
75 KiB
Rust

use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::external_generation::ExternalGenerationJobStatusRecord;
pub const EDITOR_ASSET_LABEL_MAX_CHARS: usize = 80;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectUploadTicketRequest {
pub legacy_prefix: String,
#[serde(default)]
pub path_segments: Vec<String>,
pub file_name: String,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub access: Option<DirectUploadObjectAccess>,
#[serde(default)]
pub metadata: BTreeMap<String, String>,
#[serde(default)]
pub max_size_bytes: Option<u64>,
#[serde(default)]
pub expire_seconds: Option<u64>,
#[serde(default)]
pub success_action_status: Option<u16>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct GetReadUrlQuery {
#[serde(default)]
pub object_key: Option<String>,
#[serde(default)]
pub legacy_public_path: Option<String>,
#[serde(default)]
pub expire_seconds: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConfirmAssetObjectAccessPolicy {
Private,
PublicRead,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DirectUploadObjectAccess {
Public,
Private,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmAssetObjectRequest {
#[serde(default)]
pub bucket: Option<String>,
pub object_key: String,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub content_length: Option<u64>,
#[serde(default)]
pub content_hash: Option<String>,
pub asset_kind: String,
#[serde(default)]
pub access_policy: Option<ConfirmAssetObjectAccessPolicy>,
#[serde(default)]
pub source_job_id: Option<String>,
#[serde(default)]
pub owner_user_id: Option<String>,
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default)]
pub entity_id: Option<String>,
}
#[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<String>,
#[serde(default)]
pub profile_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AssetHistoryQuery {
pub kind: String,
#[serde(default)]
pub limit: Option<u32>,
}
#[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<String>,
pub owner_label: String,
pub profile_id: Option<String>,
pub entity_id: Option<String>,
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<AssetHistoryEntryPayload>,
}
#[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<String>,
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<CharacterVisualDraftPayload>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strategy: Option<String>,
pub model: String,
pub prompt: String,
pub created_at: String,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
}
#[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<String>,
pub selected_preview_source: String,
#[serde(default)]
pub preview_sources: Vec<String>,
pub width: u32,
pub height: u32,
#[serde(default)]
pub update_character_override: Option<bool>,
}
#[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<CharacterAnimationTemplatePayload>,
}
#[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<String>,
}
#[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<String>,
#[serde(default)]
pub action_template_id: Option<String>,
pub visual_source: String,
#[serde(default)]
pub reference_image_data_urls: Vec<String>,
#[serde(default)]
pub reference_video_data_urls: Vec<String>,
#[serde(default)]
pub last_frame_image_data_url: Option<String>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preview_video_path: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCharacterAnimationGenerateRequest {
pub source_layer_id: String,
pub source_image_src: String,
pub source_width: u32,
pub source_height: u32,
pub prompt_text: String,
/// 纯色抠像背景色(hex)。None/auto 走自动决策;通常继承源角色图的背景色。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screen_color: Option<String>,
pub resolution: String,
pub ratio: String,
pub frame_count: u32,
pub duration_seconds: u32,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_inputs: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_resource_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_folder_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_label: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCharacterAnimationFramePayload {
pub image_src: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_object_id: Option<String>,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCharacterAnimationGenerateResponse {
pub ok: bool,
pub task_id: String,
pub model: String,
pub prompt: String,
pub preview_video_path: String,
pub frames: Vec<EditorCharacterAnimationFramePayload>,
pub frame_count: u32,
pub duration_seconds: u32,
pub frame_width: u32,
pub frame_height: u32,
pub fps: u32,
pub price_mud_points: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorSceneGenerateRequest {
pub scene_content: String,
pub style_preset: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_style: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_size: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reference_image_srcs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_inputs: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_folder_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorVideoGenerateRequest {
pub prompt: String,
pub model: String,
pub aspect_ratio: String,
pub duration_seconds: u32,
pub resolution: String,
pub mode: String,
pub sound: String,
#[serde(default = "default_editor_video_web_search_enabled")]
pub web_search_enabled: bool,
#[serde(default)]
pub reference_image_srcs: Vec<String>,
#[serde(default)]
pub reference_video_srcs: Vec<String>,
#[serde(default)]
pub reference_audio_srcs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_inputs: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_resource_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_folder_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_label: Option<String>,
}
fn default_editor_video_web_search_enabled() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorVideoGenerateResponse {
pub ok: bool,
pub video_src: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail_src: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub asset_object_id: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_prompt: Option<String>,
pub model: String,
pub task_id: String,
pub duration_seconds: u32,
pub resolution: String,
pub price_mud_points: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCanvasGenerationPlaceholderPayload {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
pub original_width: f64,
pub original_height: f64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCanvasGenerationCompletionPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dialog_id: Option<String>,
pub title: String,
pub placeholder: EditorCanvasGenerationPlaceholderPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetGenerateRequest {
pub reference_image_src: String,
#[serde(default)]
pub reference_image_srcs: Vec<String>,
pub icon_descriptions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_size: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetIconPayload {
pub name: String,
pub image_src: String,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetSliceWarningPayload {
pub code: String,
pub reason: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetGenerateResponse {
pub spritesheet_image_src: String,
pub spritesheet_width: u32,
pub spritesheet_height: u32,
pub icon_image_srcs: Vec<EditorIconSpritesheetIconPayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slice_warning: Option<EditorIconSpritesheetSliceWarningPayload>,
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_prompt: Option<String>,
pub model: String,
pub task_id: String,
pub price_mud_points: u32,
}
pub const EDITOR_SOUND_EFFECT_MODEL: &str = "eleven_text_to_sound_v2";
pub fn canonicalize_editor_sound_effect_model(
value: Option<&str>,
) -> Result<&'static str, &'static str> {
let normalized = value
.map(|value| value.trim_matches(char::is_whitespace))
.filter(|value| !value.is_empty());
match normalized {
None => Ok(EDITOR_SOUND_EFFECT_MODEL),
Some(EDITOR_SOUND_EFFECT_MODEL) => Ok(EDITOR_SOUND_EFFECT_MODEL),
Some(_) => Err("model 只支持 eleven_text_to_sound_v2"),
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum EditorSoundEffectDurationMode {
Auto,
Manual,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorSoundEffectGenerationMetadataV2 {
schema_version: u8,
user_prompt: String,
actual_prompt: String,
model: String,
duration_mode: EditorSoundEffectDurationMode,
requested_duration_seconds: Option<f64>,
actual_duration_seconds: f64,
#[serde(rename = "loop")]
loop_enabled: bool,
}
impl EditorSoundEffectGenerationMetadataV2 {
pub fn try_new(
user_prompt: String,
actual_prompt: String,
duration_mode: EditorSoundEffectDurationMode,
requested_duration_seconds: Option<f64>,
actual_duration_seconds: f64,
loop_enabled: bool,
) -> Result<Self, &'static str> {
let requested_duration_is_valid = match duration_mode {
EditorSoundEffectDurationMode::Auto => requested_duration_seconds.is_none(),
EditorSoundEffectDurationMode::Manual => requested_duration_seconds
.is_some_and(|duration| duration.is_finite() && (0.5..=30.0).contains(&duration)),
};
if !requested_duration_is_valid {
return Err("SFX V2 metadata 的时长模式与请求时长不一致");
}
if !actual_duration_seconds.is_finite()
|| actual_duration_seconds <= 0.0
|| actual_duration_seconds > 600.0
{
return Err("SFX V2 metadata 的实际时长无效");
}
Ok(Self {
schema_version: 2,
user_prompt,
actual_prompt,
model: EDITOR_SOUND_EFFECT_MODEL.to_string(),
duration_mode,
requested_duration_seconds,
actual_duration_seconds,
loop_enabled,
})
}
pub const fn schema_version(&self) -> u8 {
self.schema_version
}
pub fn user_prompt(&self) -> &str {
self.user_prompt.as_str()
}
pub fn actual_prompt(&self) -> &str {
self.actual_prompt.as_str()
}
pub fn model(&self) -> &str {
self.model.as_str()
}
pub const fn duration_mode(&self) -> EditorSoundEffectDurationMode {
self.duration_mode
}
pub const fn requested_duration_seconds(&self) -> Option<f64> {
self.requested_duration_seconds
}
pub const fn actual_duration_seconds(&self) -> f64 {
self.actual_duration_seconds
}
pub const fn loop_enabled(&self) -> bool {
self.loop_enabled
}
}
impl<'de> Deserialize<'de> for EditorSoundEffectGenerationMetadataV2 {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Payload {
schema_version: u8,
user_prompt: String,
actual_prompt: String,
model: String,
duration_mode: EditorSoundEffectDurationMode,
requested_duration_seconds: Option<f64>,
actual_duration_seconds: f64,
#[serde(rename = "loop")]
loop_enabled: bool,
}
let payload = Payload::deserialize(deserializer)?;
if payload.schema_version != 2 {
return Err(D::Error::custom("SFX metadata 只支持 schemaVersion 2"));
}
if payload.model != EDITOR_SOUND_EFFECT_MODEL {
return Err(D::Error::custom(format!(
"SFX V2 metadata 只支持模型 {EDITOR_SOUND_EFFECT_MODEL}"
)));
}
Self::try_new(
payload.user_prompt,
payload.actual_prompt,
payload.duration_mode,
payload.requested_duration_seconds,
payload.actual_duration_seconds,
payload.loop_enabled,
)
.map_err(D::Error::custom)
}
}
/// OpenAPI 对该请求体声明了 `additionalProperties: false`,实现必须同样拒绝未知字段:
/// 否则 `loopEnabled` 这类拼错的字段会被静默丢弃,按 `loop=false` 入队并计费,
/// 客户端拿不到 400,只能从生成结果反推自己写错了参数。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EditorSoundEffectGenerateRequest {
pub prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default)]
pub duration: Option<f64>,
#[serde(default, rename = "loop")]
pub loop_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_inputs: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_folder_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_label: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorBackgroundMusicGenerateRequest {
pub gpt_description_prompt: String,
pub make_instrumental: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_inputs: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_folder_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_label: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BackgroundMusicPromptAssistRequest {
pub current_prompt: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundMusicPromptAssistResponse {
pub prompt: String,
pub char_count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SoundEffectPromptOptimizeRequest {
pub current_prompt: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SoundEffectPromptOptimizeResponse {
pub prompt: String,
pub char_count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorAudioGenerateResponse {
pub ok: bool,
pub audio_src: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub asset_object_id: Option<String>,
pub width: u32,
pub height: u32,
pub source_type: String,
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_prompt: Option<String>,
pub model: String,
pub task_id: String,
pub price_mud_points: u32,
pub audio_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_seconds: Option<f64>,
#[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
pub loop_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAnimationDraftPayload {
#[serde(default)]
pub frames_data_urls: Vec<String>,
pub fps: u32,
#[serde(rename = "loop")]
pub loop_: bool,
pub frame_width: u32,
pub frame_height: u32,
#[serde(default)]
pub frame_count: Option<u32>,
#[serde(default)]
pub apply_chroma_key: Option<bool>,
#[serde(default)]
pub sample_start_ratio: Option<f32>,
#[serde(default)]
pub sample_end_ratio: Option<f32>,
#[serde(default)]
pub preview_video_path: Option<String>,
}
#[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<String, CharacterAnimationDraftPayload>,
#[serde(default)]
pub update_character_override: Option<bool>,
}
#[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<String>,
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<String, String>,
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
pub selected_visual_draft_id: String,
pub selected_animation: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_src: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generated_visual_asset_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generated_animation_set_id: Option<String>,
#[serde(default)]
pub animation_map: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterWorkflowCacheSaveRequest {
pub character_id: String,
#[serde(default)]
pub cache_scope_id: Option<String>,
#[serde(default)]
pub visual_prompt_text: Option<String>,
#[serde(default)]
pub animation_prompt_text: Option<String>,
#[serde(default)]
pub animation_prompt_text_by_key: BTreeMap<String, String>,
#[serde(default)]
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
#[serde(default)]
pub selected_visual_draft_id: Option<String>,
#[serde(default)]
pub selected_animation: Option<String>,
#[serde(default)]
pub image_src: Option<String>,
#[serde(default)]
pub generated_visual_asset_id: Option<String>,
#[serde(default)]
pub generated_animation_set_id: Option<String>,
#[serde(default)]
pub animation_map: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterWorkflowCacheGetResponse {
pub ok: bool,
pub cache: Option<CharacterWorkflowCachePayload>,
}
#[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<String>,
#[serde(default)]
pub action_description: Option<String>,
#[serde(default)]
pub scene_visual_description: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub backstory: Option<String>,
#[serde(default)]
pub personality: Option<String>,
#[serde(default)]
pub motivation: Option<String>,
#[serde(default)]
pub combat_style: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub image_src: Option<String>,
#[serde(default)]
pub generated_visual_asset_id: Option<String>,
#[serde(default)]
pub generated_animation_set_id: Option<String>,
#[serde(default)]
pub animation_map: Option<Value>,
}
#[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<String, String>,
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
pub selected_visual_draft_id: String,
pub selected_animation: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_src: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generated_visual_asset_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generated_animation_set_id: Option<String>,
#[serde(default)]
pub animation_map: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterRoleAssetWorkflowResolveRequest {
#[serde(default)]
pub cache_scope_id: Option<String>,
pub role: CharacterAssetRolePromptInput,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterRoleAssetWorkflowResponse {
pub ok: bool,
pub cache: Option<CharacterWorkflowCachePayload>,
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<String>,
pub access: DirectUploadObjectAccess,
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 = "x-oss-signature-version")]
pub signature_version: String,
#[serde(rename = "x-oss-credential")]
pub credential: String,
#[serde(rename = "x-oss-date")]
pub date: String,
#[serde(rename = "x-oss-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<String>,
#[serde(rename = "Cache-Control", skip_serializing_if = "Option::is_none")]
pub cache_control: Option<String>,
#[serde(flatten)]
pub metadata: BTreeMap<String, String>,
}
#[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<String>,
pub content_length: u64,
#[serde(default)]
pub content_hash: Option<String>,
pub version: u32,
#[serde(default)]
pub source_job_id: Option<String>,
#[serde(default)]
pub owner_user_id: Option<String>,
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default)]
pub entity_id: Option<String>,
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<String>,
#[serde(default)]
pub profile_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[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 {
signature_version: "v4".to_string(),
provider: "aliyun-oss".to_string(),
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: DirectUploadObjectAccess::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: DirectUploadTicketFormFields {
key: "generated-characters/hero/master.png".to_string(),
policy: "policy".to_string(),
signature_version: "OSS4-HMAC-SHA256".to_string(),
credential: "ak/20260507/cn-shanghai/oss/aliyun_v4_request".to_string(),
date: "20260507T120000Z".to_string(),
signature: "sig".to_string(),
success_action_status: "200".to_string(),
content_type: Some("image/png".to_string()),
cache_control: Some("public, max-age=31536000, immutable".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!("v4"));
assert_eq!(
payload["upload"]["formFields"]["x-oss-signature-version"],
json!("OSS4-HMAC-SHA256")
);
assert_eq!(
payload["upload"]["formFields"]["x-oss-credential"],
json!("ak/20260507/cn-shanghai/oss/aliyun_v4_request")
);
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 editor_character_animation_request_uses_expected_camel_case_shape() {
let payload = serde_json::to_value(EditorCharacterAnimationGenerateRequest {
source_layer_id: "layer-1".to_string(),
source_image_src: "/generated-characters/hero/master.png".to_string(),
source_width: 768,
source_height: 1024,
prompt_text: "待机呼吸".to_string(),
screen_color: None,
resolution: "720p".to_string(),
ratio: "same".to_string(),
frame_count: 48,
duration_seconds: 6,
model: "seedance2.0-fast".to_string(),
project_id: Some("editor-project-1".to_string()),
canvas_completion: Some(EditorCanvasGenerationCompletionPayload {
dialog_id: Some("dialog-1".to_string()),
title: "角色动作".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 10.0,
y: 20.0,
width: 420.0,
height: 420.0,
original_width: 1024.0,
original_height: 1024.0,
},
}),
generation_inputs: None,
source_resource_id: Some("resource-source".to_string()),
asset_folder_id: Some("project".to_string()),
asset_label: Some("勇者奔跑".to_string()),
})
.expect("request should serialize");
assert_eq!(payload["sourceLayerId"], json!("layer-1"));
assert_eq!(
payload["sourceImageSrc"],
json!("/generated-characters/hero/master.png")
);
assert!(payload.get("priceMudPoints").is_none());
assert_eq!(payload["model"], json!("seedance2.0-fast"));
assert_eq!(payload["projectId"], json!("editor-project-1"));
assert_eq!(payload["canvasCompletion"]["dialogId"], json!("dialog-1"));
assert_eq!(payload["sourceResourceId"], json!("resource-source"));
assert_eq!(payload["assetFolderId"], json!("project"));
assert_eq!(payload["assetLabel"], json!("勇者奔跑"));
}
#[test]
fn editor_character_animation_response_includes_frames_and_preview_video() {
let payload = serde_json::to_value(EditorCharacterAnimationGenerateResponse {
ok: true,
task_id: "task-1".to_string(),
model: "seedance2.0-fast".to_string(),
prompt: "生成游戏角色动画".to_string(),
preview_video_path: "/generated-character-drafts/editor/layer/preview.mp4".to_string(),
frames: vec![EditorCharacterAnimationFramePayload {
image_src: "/generated-animations/editor/layer/frame01.png".to_string(),
object_key: Some("generated-animations/editor/layer/frame01.png".to_string()),
asset_object_id: Some("asset-object-frame01".to_string()),
width: 768,
height: 1024,
}],
frame_count: 1,
duration_seconds: 4,
frame_width: 768,
frame_height: 1024,
fps: 8,
price_mud_points: 40,
project: None,
resource: Some(json!({
"resourceId": "editor-resource-action-1",
"assetKind": "character-animation",
})),
asset: Some(json!({
"assetId": "editor-asset-action-1",
"assetKind": "character-animation",
"frameCount": 2,
})),
queue_state: None,
})
.expect("response should serialize");
assert_eq!(payload["asset"]["assetId"], json!("editor-asset-action-1"));
assert_eq!(
payload["resource"]["resourceId"],
json!("editor-resource-action-1")
);
assert_eq!(payload["asset"]["assetKind"], json!("character-animation"));
assert_eq!(payload["asset"]["frameCount"], json!(2));
assert_eq!(
payload["previewVideoPath"],
json!("/generated-character-drafts/editor/layer/preview.mp4")
);
assert_eq!(
payload["frames"][0]["imageSrc"],
json!("/generated-animations/editor/layer/frame01.png")
);
assert_eq!(
payload["frames"][0]["objectKey"],
json!("generated-animations/editor/layer/frame01.png")
);
assert_eq!(
payload["frames"][0]["assetObjectId"],
json!("asset-object-frame01")
);
assert_eq!(payload["fps"], json!(8));
}
#[test]
fn editor_video_request_supports_lovart_model_contract() {
let payload = serde_json::to_value(EditorVideoGenerateRequest {
prompt: "让角色向镜头挥手".to_string(),
model: "kling3.0-omni".to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 5,
resolution: "480p".to_string(),
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: Vec::new(),
project_id: None,
canvas_completion: None,
generation_inputs: None,
source_resource_id: None,
asset_kind: None,
asset_folder_id: None,
asset_label: None,
})
.expect("request should serialize");
assert_eq!(payload["aspectRatio"], json!("16:9"));
assert_eq!(payload["durationSeconds"], json!(5));
assert!(payload.get("priceMudPoints").is_none());
assert_eq!(payload["model"], json!("kling3.0-omni"));
assert_eq!(payload["webSearchEnabled"], json!(true));
}
#[test]
fn editor_video_request_supports_stable_seedance_multimodal_references() {
let payload = serde_json::to_value(EditorVideoGenerateRequest {
prompt: "参考素材生成视频".to_string(),
model: "seedance2.0-fast".to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 5,
resolution: "720p".to_string(),
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
reference_image_srcs: vec![
"generated-character-drafts/editor/seedance-references/image/demo.png".to_string(),
],
reference_video_srcs: vec![
"generated-character-drafts/editor/seedance-references/video/demo.mp4".to_string(),
],
reference_audio_srcs: vec![
"generated-character-drafts/editor/seedance-references/audio/demo.mp3".to_string(),
],
project_id: None,
canvas_completion: None,
generation_inputs: None,
source_resource_id: None,
asset_kind: None,
asset_folder_id: None,
asset_label: None,
})
.expect("request should serialize");
assert_eq!(
payload["referenceImageSrcs"],
json!(["generated-character-drafts/editor/seedance-references/image/demo.png"])
);
assert_eq!(
payload["referenceVideoSrcs"],
json!(["generated-character-drafts/editor/seedance-references/video/demo.mp4"])
);
assert_eq!(
payload["referenceAudioSrcs"],
json!(["generated-character-drafts/editor/seedance-references/audio/demo.mp3"])
);
}
#[test]
fn editor_video_response_uses_canvas_video_shape() {
let payload = serde_json::to_value(EditorVideoGenerateResponse {
ok: true,
video_src: "/generated-editor-videos/task-1/preview.mp4".to_string(),
thumbnail_src: Some("/generated-editor-videos/task-1/cover.png".to_string()),
object_key: Some(
"generated-character-drafts/editor-videos/task-1/preview.mp4".to_string(),
),
asset_object_id: Some("assetobj_video_1".to_string()),
width: 1280,
height: 720,
source_type: "generated".to_string(),
prompt: "让角色向镜头挥手".to_string(),
actual_prompt: Some("让角色向镜头挥手".to_string()),
model: "kling3.0-omni".to_string(),
task_id: "task-1".to_string(),
duration_seconds: 5,
resolution: "480p".to_string(),
price_mud_points: 100,
project: None,
resource: None,
asset: None,
queue_state: None,
})
.expect("response should serialize");
assert_eq!(
payload["videoSrc"],
json!("/generated-editor-videos/task-1/preview.mp4")
);
assert_eq!(
payload["thumbnailSrc"],
json!("/generated-editor-videos/task-1/cover.png")
);
assert_eq!(
payload["objectKey"],
json!("generated-character-drafts/editor-videos/task-1/preview.mp4")
);
assert_eq!(payload["assetObjectId"], json!("assetobj_video_1"));
assert_eq!(payload["sourceType"], json!("generated"));
assert_eq!(payload["durationSeconds"], json!(5));
assert!(payload.get("provider").is_none());
}
#[test]
fn editor_icon_spritesheet_request_omits_price_and_response_includes_price() {
let request_payload = serde_json::to_value(EditorIconSpritesheetGenerateRequest {
reference_image_src: "generated-images/editor/spec.png".to_string(),
reference_image_srcs: vec!["generated-images/editor/style.png".to_string()],
icon_descriptions: vec!["返回按钮".to_string(), "设置按钮".to_string()],
model: Some("gemini-3.1-flash-image-preview".to_string()),
aspect_ratio: Some("1:1".to_string()),
image_size: Some("1K".to_string()),
})
.expect("request should serialize");
assert_eq!(
request_payload["referenceImageSrc"],
json!("generated-images/editor/spec.png")
);
assert_eq!(
request_payload["referenceImageSrcs"],
json!(["generated-images/editor/style.png"])
);
assert!(request_payload.get("priceMudPoints").is_none());
let response_payload = serde_json::to_value(EditorIconSpritesheetGenerateResponse {
spritesheet_image_src: "/generated-images/editor/sheet.png".to_string(),
spritesheet_width: 512,
spritesheet_height: 512,
icon_image_srcs: Vec::new(),
slice_warning: Some(EditorIconSpritesheetSliceWarningPayload {
code: "insufficient-connected-components".to_string(),
reason: "连通域数量不足".to_string(),
}),
prompt: "图标 prompt".to_string(),
actual_prompt: Some("图标 prompt".to_string()),
model: "gemini-3.1-flash-image-preview".to_string(),
task_id: "task-icons".to_string(),
price_mud_points: 12,
})
.expect("response should serialize");
assert_eq!(response_payload["priceMudPoints"], json!(12));
assert_eq!(response_payload["iconImageSrcs"], json!([]));
assert!(response_payload.get("provider").is_none());
assert_eq!(
response_payload["sliceWarning"],
json!({
"code": "insufficient-connected-components",
"reason": "连通域数量不足",
})
);
}
#[test]
fn editor_audio_requests_and_response_use_canvas_audio_shape() {
let sound_payload = serde_json::to_value(EditorSoundEffectGenerateRequest {
prompt: "金币掉落叮当声".to_string(),
model: Some(EDITOR_SOUND_EFFECT_MODEL.to_string()),
duration: Some(7.5),
loop_enabled: true,
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: Some("project".to_string()),
asset_label: Some("游戏音效 1".to_string()),
})
.expect("sound request should serialize");
assert_eq!(sound_payload["prompt"], json!("金币掉落叮当声"));
assert_eq!(sound_payload["model"], json!(EDITOR_SOUND_EFFECT_MODEL));
assert_eq!(sound_payload["duration"], json!(7.5));
assert_eq!(sound_payload["loop"], json!(true));
assert_eq!(sound_payload["assetFolderId"], json!("project"));
assert_eq!(sound_payload["assetLabel"], json!("游戏音效 1"));
assert!(sound_payload.get("priceMudPoints").is_none());
assert!(sound_payload.get("sound").is_none());
assert!(sound_payload.get("type").is_none());
assert!(sound_payload.get("tempo").is_none());
let parsed_sound_payload: EditorSoundEffectGenerateRequest =
serde_json::from_value(sound_payload)
.expect("sound request with duration should deserialize");
assert_eq!(parsed_sound_payload.prompt, "金币掉落叮当声");
assert_eq!(parsed_sound_payload.duration, Some(7.5));
assert!(parsed_sound_payload.loop_enabled);
let unset_model_payload = serde_json::to_value(EditorSoundEffectGenerateRequest {
prompt: "按钮确认短促音".to_string(),
model: None,
duration: None,
loop_enabled: false,
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect("sound request with unset model should serialize");
assert!(unset_model_payload.get("model").is_none());
assert_eq!(unset_model_payload["duration"], json!(null));
assert_eq!(unset_model_payload["loop"], json!(false));
for nullable_payload in [
json!({ "prompt": "按钮确认短促音" }),
json!({
"prompt": "按钮确认短促音",
"model": null,
"duration": null,
}),
] {
let parsed: EditorSoundEffectGenerateRequest = serde_json::from_value(nullable_payload)
.expect("omitted and explicit null fields should deserialize");
assert_eq!(parsed.model, None);
assert_eq!(parsed.duration, None);
assert!(!parsed.loop_enabled);
}
// OpenAPI 声明 additionalProperties: false,实现必须拒绝而不是静默丢弃。
// loopEnabled 是最容易写错的那个:丢弃后会按 loop=false 入队并照常计费。
for unknown_field_payload in [
json!({ "prompt": "按钮确认短促音", "loopEnabled": true }),
json!({ "prompt": "按钮确认短促音", "tempo": 120 }),
json!({ "prompt": "按钮确认短促音", "priceMudPoints": 0 }),
] {
assert!(
serde_json::from_value::<EditorSoundEffectGenerateRequest>(unknown_field_payload)
.is_err(),
"sound effect requests must reject unknown fields"
);
}
let music_payload = serde_json::to_value(EditorBackgroundMusicGenerateRequest {
gpt_description_prompt: "森林冒险背景音乐".to_string(),
make_instrumental: true,
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect("background music request should serialize");
assert_eq!(
music_payload["gptDescriptionPrompt"],
json!("森林冒险背景音乐")
);
assert_eq!(music_payload["makeInstrumental"], json!(true));
let response_payload = serde_json::to_value(EditorAudioGenerateResponse {
ok: true,
audio_src: "/generated-character-drafts/editor-audios/sfx.mp3".to_string(),
object_key: Some("generated-character-drafts/editor-audios/sfx.mp3".to_string()),
asset_object_id: Some("assetobj_audio_1".to_string()),
width: 420,
height: 120,
source_type: "generated".to_string(),
prompt: "金币掉落叮当声".to_string(),
actual_prompt: Some("金币掉落叮当声".to_string()),
model: EDITOR_SOUND_EFFECT_MODEL.to_string(),
task_id: "sound-task-1".to_string(),
price_mud_points: 10,
audio_kind: "sound-effect".to_string(),
duration_seconds: Some(7.42),
loop_enabled: Some(true),
project: None,
resource: None,
asset: Some(json!({
"assetId": "asset-audio-1",
"folderId": "user-1:asset-folder:project",
"label": "游戏音效 1",
"imageSrc": "/generated-character-drafts/editor-audios/sfx.mp3",
"width": 420,
"height": 120,
"sourceType": "generated",
"assetKind": "sound-effect"
})),
queue_state: None,
})
.expect("audio response should serialize");
assert_eq!(
response_payload["audioSrc"],
json!("/generated-character-drafts/editor-audios/sfx.mp3")
);
assert_eq!(
response_payload["objectKey"],
json!("generated-character-drafts/editor-audios/sfx.mp3")
);
assert_eq!(response_payload["assetObjectId"], json!("assetobj_audio_1"));
assert_eq!(response_payload["audioKind"], json!("sound-effect"));
assert_eq!(response_payload["durationSeconds"], json!(7.42));
assert_eq!(response_payload["loop"], json!(true));
assert!(response_payload.get("provider").is_none());
assert_eq!(
response_payload["asset"]["assetKind"],
json!("sound-effect")
);
let mut legacy_payload = response_payload;
legacy_payload["provider"] = json!("elevenlabs");
let parsed: EditorAudioGenerateResponse = serde_json::from_value(legacy_payload)
.expect("legacy audio results with provider should remain readable");
assert_eq!(parsed.audio_kind, "sound-effect");
}
#[test]
fn editor_sound_effect_model_canonicalizer_freezes_external_v1_inputs() {
for accepted in [
None,
Some(""),
Some(" \t\r\n"),
Some("\u{0085}\u{2003}\u{00a0}"),
Some(EDITOR_SOUND_EFFECT_MODEL),
Some("\u{0085} eleven_text_to_sound_v2 \u{2003}"),
] {
assert_eq!(
canonicalize_editor_sound_effect_model(accepted)
.expect("accepted model form should canonicalize"),
EDITOR_SOUND_EFFECT_MODEL,
"accepted={accepted:?}"
);
}
for rejected in [
"audio1.0",
"AUDIO1.0",
"Eleven_text_to_sound_v2",
"eleven_text_to_sound_v3",
"\u{200b}",
"\u{feff}",
] {
assert!(canonicalize_editor_sound_effect_model(Some(rejected)).is_err());
}
}
#[test]
fn editor_sound_effect_v2_metadata_uses_the_frozen_camel_case_shape() {
let metadata = EditorSoundEffectGenerationMetadataV2::try_new(
"金币落地,轻快可爱".to_string(),
"A bright, cute coin landing chime".to_string(),
EditorSoundEffectDurationMode::Manual,
Some(5.0),
5.12,
false,
)
.expect("valid SFX V2 metadata should construct");
assert_eq!(metadata.schema_version(), 2);
assert_eq!(metadata.model(), EDITOR_SOUND_EFFECT_MODEL);
assert_eq!(metadata.user_prompt(), "金币落地,轻快可爱");
assert_eq!(
metadata.actual_prompt(),
"A bright, cute coin landing chime"
);
assert_eq!(
metadata.duration_mode(),
EditorSoundEffectDurationMode::Manual
);
assert_eq!(metadata.requested_duration_seconds(), Some(5.0));
assert_eq!(metadata.actual_duration_seconds(), 5.12);
assert!(!metadata.loop_enabled());
let payload = serde_json::to_value(&metadata).expect("metadata should serialize");
assert_eq!(
payload,
json!({
"schemaVersion": 2,
"userPrompt": "金币落地,轻快可爱",
"actualPrompt": "A bright, cute coin landing chime",
"model": "eleven_text_to_sound_v2",
"durationMode": "manual",
"requestedDurationSeconds": 5.0,
"actualDurationSeconds": 5.12,
"loop": false,
})
);
assert_eq!(
serde_json::from_value::<EditorSoundEffectGenerationMetadataV2>(payload.clone())
.expect("metadata should deserialize"),
metadata
);
for invalid in [
json!({ "schemaVersion": 1 }),
json!({ "model": "audio1.0" }),
json!({
"durationMode": "auto",
"requestedDurationSeconds": 5.0,
}),
json!({
"durationMode": "manual",
"requestedDurationSeconds": null,
}),
json!({ "actualDurationSeconds": 600.1 }),
] {
let mut invalid_payload = payload.clone();
invalid_payload
.as_object_mut()
.expect("metadata payload should be an object")
.extend(
invalid
.as_object()
.expect("invalid override should be an object")
.clone(),
);
assert!(
serde_json::from_value::<EditorSoundEffectGenerationMetadataV2>(invalid_payload)
.is_err(),
"invalid metadata override should be rejected: {invalid}"
);
}
}
#[test]
fn sound_effect_prompt_optimize_contract_uses_the_minimal_camel_case_shape() {
let request = SoundEffectPromptOptimizeRequest {
current_prompt: "\n 金币落地,轻快可爱 ".to_string(),
};
let request_payload = serde_json::to_value(&request)
.expect("sound effect prompt optimize request should serialize");
assert_eq!(
request_payload,
json!({
"currentPrompt": "\n 金币落地,轻快可爱 ",
})
);
assert_eq!(
serde_json::from_value::<SoundEffectPromptOptimizeRequest>(request_payload)
.expect("sound effect prompt optimize request should deserialize"),
request
);
assert!(
serde_json::from_value::<SoundEffectPromptOptimizeRequest>(json!({
"currentPrompt": "金币落地",
"targetChars": 2048,
}))
.is_err(),
"clients must not control sound effect optimization targets"
);
let response = SoundEffectPromptOptimizeResponse {
prompt: "金币落地时清脆明亮、轻快可爱的金属叮当声".to_string(),
char_count: 23,
};
let response_payload = serde_json::to_value(&response)
.expect("sound effect prompt optimize response should serialize");
assert_eq!(
response_payload,
json!({
"prompt": "金币落地时清脆明亮、轻快可爱的金属叮当声",
"charCount": 23,
})
);
assert_eq!(
serde_json::from_value::<SoundEffectPromptOptimizeResponse>(response_payload)
.expect("sound effect prompt optimize response should deserialize"),
response
);
}
#[test]
fn background_music_prompt_assist_contract_uses_camel_case_shape() {
let request = BackgroundMusicPromptAssistRequest {
current_prompt: "\n 森林冒险背景音乐 ".to_string(),
};
let request_payload = serde_json::to_value(&request)
.expect("background music prompt assist request should serialize");
assert_eq!(
request_payload,
json!({
"currentPrompt": "\n 森林冒险背景音乐 ",
})
);
assert_eq!(
serde_json::from_value::<BackgroundMusicPromptAssistRequest>(request_payload)
.expect("background music prompt assist request should deserialize"),
request
);
assert!(
serde_json::from_value::<BackgroundMusicPromptAssistRequest>(json!({
"currentPrompt": "森林冒险背景音乐",
"targetChars": 170,
}))
.is_err(),
"clients must not control prompt-assist targets"
);
let response = BackgroundMusicPromptAssistResponse {
prompt: "森林冒险背景音乐,温和推进并适合自然循环".to_string(),
char_count: 20,
};
let response_payload = serde_json::to_value(&response)
.expect("background music prompt assist response should serialize");
assert_eq!(
response_payload,
json!({
"prompt": "森林冒险背景音乐,温和推进并适合自然循环",
"charCount": 20,
})
);
assert_eq!(
serde_json::from_value::<BackgroundMusicPromptAssistResponse>(response_payload)
.expect("background music prompt assist response should deserialize"),
response
);
}
#[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::<CharacterAnimationDraftPayload>(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));
}
}