新增编辑器生成规范、生成角色形象、生成图标素材等功能
新增编辑器生成规范、生成角色形象、生成图标素材等功能
This commit is contained in:
@@ -41,6 +41,8 @@ use shared_contracts::assets::{
|
||||
CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse,
|
||||
CharacterVisualDraftPayload, CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload,
|
||||
CharacterWorkflowCacheSaveRequest, CharacterWorkflowCacheSaveResponse,
|
||||
EditorCharacterAnimationFramePayload, EditorCharacterAnimationGenerateRequest,
|
||||
EditorCharacterAnimationGenerateResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
@@ -82,6 +84,9 @@ const FIXED_ARK_CHARACTER_VIDEO_RESOLUTION: &str = "480p";
|
||||
const FIXED_ARK_CHARACTER_VIDEO_RATIO: &str = "1:1";
|
||||
const FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS: u32 = 4;
|
||||
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
|
||||
const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0";
|
||||
const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "editor_character_animation";
|
||||
const EDITOR_CHARACTER_ANIMATION_PROMPT_PREFIX: &str = "生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。\n动作描述:";
|
||||
|
||||
const BUILT_IN_MOTION_TEMPLATES: [MotionTemplate; 4] = [
|
||||
MotionTemplate {
|
||||
@@ -489,6 +494,87 @@ pub async fn generate_character_animation(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_editor_character_animation(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<EditorCharacterAnimationGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
character_animation_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-character-animation",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let normalized = normalize_editor_character_animation_request(payload)
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
let settings = require_editor_character_animation_settings(&state, &normalized)
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
let extraction_settings = resolve_backend_frame_extraction_settings(&state);
|
||||
let http_client = build_upstream_http_client(settings.ark.request_timeout_ms)
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
let owner_user_id = "editor-character-animation".to_string();
|
||||
let task_id = generate_ai_task_id(current_utc_micros());
|
||||
|
||||
let result = async {
|
||||
let source_data_url = resolve_media_source_as_data_url(
|
||||
&state,
|
||||
&http_client,
|
||||
normalized.source_image_src.as_str(),
|
||||
"sourceImageSrc",
|
||||
)
|
||||
.await?;
|
||||
let generated = request_editor_character_animation_preview(
|
||||
&state,
|
||||
&http_client,
|
||||
&settings,
|
||||
owner_user_id.as_str(),
|
||||
normalized.source_layer_id.as_str(),
|
||||
task_id.as_str(),
|
||||
normalized.prompt.as_str(),
|
||||
source_data_url.as_str(),
|
||||
)
|
||||
.await?;
|
||||
let frames = extract_and_persist_editor_character_animation_frames(
|
||||
&state,
|
||||
owner_user_id.as_str(),
|
||||
normalized.source_layer_id.as_str(),
|
||||
task_id.as_str(),
|
||||
generated.preview_video_path.as_str(),
|
||||
&normalized,
|
||||
&extraction_settings,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<_, AppError>((generated, frames))
|
||||
}
|
||||
.await;
|
||||
|
||||
let (generated, frames) = result
|
||||
.map_err(|error| character_animation_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorCharacterAnimationGenerateResponse {
|
||||
ok: true,
|
||||
task_id,
|
||||
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
|
||||
prompt: generated.submitted_prompt,
|
||||
preview_video_path: generated.preview_video_path,
|
||||
frame_count: frames.len() as u32,
|
||||
duration_seconds: normalized.duration_seconds,
|
||||
frame_width: normalized.frame_width,
|
||||
frame_height: normalized.frame_height,
|
||||
fps: normalized.fps,
|
||||
price_mud_points: normalized.price_mud_points,
|
||||
frames,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_character_animation_job(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1248,6 +1334,167 @@ async fn put_generated_preview_video(
|
||||
Ok(put_result.legacy_public_path)
|
||||
}
|
||||
|
||||
async fn request_editor_character_animation_preview(
|
||||
state: &AppState,
|
||||
http_client: &reqwest::Client,
|
||||
settings: &EditorCharacterAnimationSettings,
|
||||
owner_user_id: &str,
|
||||
source_layer_id: &str,
|
||||
task_id: &str,
|
||||
prompt: &str,
|
||||
source_frame_data_url: &str,
|
||||
) -> Result<GeneratedAnimationPreview, AppError> {
|
||||
let upstream_task_id =
|
||||
create_editor_ark_image_to_video_task(http_client, settings, prompt, source_frame_data_url)
|
||||
.await?;
|
||||
let video_url =
|
||||
wait_for_ark_content_generation_task(http_client, &settings.ark, upstream_task_id.as_str())
|
||||
.await?;
|
||||
let preview_payload =
|
||||
download_generated_video(http_client, video_url.as_str(), "下载画板角色动画视频失败。")
|
||||
.await?;
|
||||
let preview_video_path = put_generated_preview_video(
|
||||
state,
|
||||
owner_user_id,
|
||||
source_layer_id,
|
||||
"editor-character-animation",
|
||||
task_id,
|
||||
preview_payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(GeneratedAnimationPreview {
|
||||
preview_video_path,
|
||||
upstream_task_id,
|
||||
submitted_prompt: prompt.to_string(),
|
||||
moderation_fallback_applied: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_editor_ark_image_to_video_task(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &EditorCharacterAnimationSettings,
|
||||
prompt: &str,
|
||||
source_frame_data_url: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let response = http_client
|
||||
.post(format!("{}/contents/generations/tasks", settings.ark.base_url))
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.ark.api_key),
|
||||
)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.json(&json!({
|
||||
"model": settings.ark.model,
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": source_frame_data_url,
|
||||
},
|
||||
"role": "first_frame",
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": source_frame_data_url,
|
||||
},
|
||||
"role": "last_frame",
|
||||
}
|
||||
],
|
||||
"resolution": settings.resolution,
|
||||
"ratio": settings.ratio,
|
||||
"duration": settings.duration_seconds,
|
||||
"watermark": false,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_character_animation_upstream_error(format!("请求 Ark 视频服务失败:{error}"))
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.map_err(|error| {
|
||||
map_character_animation_upstream_error(format!("读取 Ark 视频任务响应失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(parse_animation_upstream_error(
|
||||
body.as_str(),
|
||||
"创建画板角色动画视频任务失败。",
|
||||
));
|
||||
}
|
||||
let payload = parse_animation_json_payload(body.as_str(), "创建画板角色动画视频任务失败。")?;
|
||||
extract_animation_task_id(&payload.payload).ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": "画板角色动画视频任务未返回任务 id。",
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn extract_and_persist_editor_character_animation_frames(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
source_layer_id: &str,
|
||||
task_id: &str,
|
||||
preview_video_path: &str,
|
||||
request: &NormalizedEditorCharacterAnimationRequest,
|
||||
extraction_settings: &BackendFrameExtractionSettings,
|
||||
) -> Result<Vec<EditorCharacterAnimationFramePayload>, AppError> {
|
||||
let plan = AnimationFrameExtractionPlan {
|
||||
frame_count: request.frame_count,
|
||||
apply_chroma_key: true,
|
||||
sample_start_ratio: 0.0,
|
||||
sample_end_ratio: 1.0,
|
||||
};
|
||||
let finalized_frames = extract_animation_frames_from_preview_video(
|
||||
state,
|
||||
preview_video_path,
|
||||
request.frame_width,
|
||||
request.frame_height,
|
||||
extraction_settings,
|
||||
&plan,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut frame_payloads = Vec::with_capacity(finalized_frames.len());
|
||||
for (index, frame) in finalized_frames.into_iter().enumerate() {
|
||||
let put_result = put_character_animation_object(
|
||||
state,
|
||||
LegacyAssetPrefix::Animations,
|
||||
vec![
|
||||
"editor".to_string(),
|
||||
sanitize_storage_segment(source_layer_id, "layer"),
|
||||
task_id.to_string(),
|
||||
],
|
||||
format!("frame{:02}.{}", index + 1, frame.extension),
|
||||
frame.mime_type,
|
||||
frame.bytes,
|
||||
build_asset_metadata(
|
||||
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
|
||||
owner_user_id,
|
||||
"editor_layer",
|
||||
source_layer_id,
|
||||
"animation_frame",
|
||||
"editor-character-animation",
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
frame_payloads.push(EditorCharacterAnimationFramePayload {
|
||||
frame_index: index as u32 + 1,
|
||||
image_src: put_result.legacy_public_path,
|
||||
width: request.frame_width,
|
||||
height: request.frame_height,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(frame_payloads)
|
||||
}
|
||||
|
||||
async fn publish_animation_set(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -1864,6 +2111,235 @@ fn resolve_character_animation_model(payload: &CharacterAnimationGenerateRequest
|
||||
normalize_required_text(candidate, CHARACTER_ANIMATION_MODEL)
|
||||
}
|
||||
|
||||
fn normalize_editor_character_animation_request(
|
||||
payload: EditorCharacterAnimationGenerateRequest,
|
||||
) -> Result<NormalizedEditorCharacterAnimationRequest, AppError> {
|
||||
let source_layer_id = normalize_required_text(payload.source_layer_id.as_str(), "");
|
||||
if source_layer_id.is_empty() {
|
||||
return Err(editor_character_animation_bad_request(
|
||||
"sourceLayerId 不能为空。",
|
||||
));
|
||||
}
|
||||
let source_image_src = trim_optional_text(Some(payload.source_image_src.as_str()))
|
||||
.ok_or_else(|| editor_character_animation_bad_request("sourceImageSrc 不能为空。"))?;
|
||||
let prompt_text = payload.prompt_text.trim().chars().take(4000).collect::<String>();
|
||||
if prompt_text.is_empty() {
|
||||
return Err(editor_character_animation_bad_request("动画描述不能为空。"));
|
||||
}
|
||||
let resolution = normalize_editor_character_animation_resolution(payload.resolution.as_str())?;
|
||||
let ratio = normalize_editor_character_animation_ratio(payload.ratio.as_str())?;
|
||||
let frame_count = normalize_editor_character_animation_frame_count(payload.frame_count)?;
|
||||
let duration_seconds =
|
||||
normalize_editor_character_animation_duration(payload.duration_seconds, frame_count)?;
|
||||
let expected_price = calculate_editor_character_animation_price(resolution, duration_seconds);
|
||||
if payload.price_mud_points != expected_price {
|
||||
return Err(editor_character_animation_bad_request(format!(
|
||||
"priceMudPoints 与分辨率和时长不一致,应为 {expected_price}。"
|
||||
)));
|
||||
}
|
||||
if payload.model.trim() != EDITOR_CHARACTER_ANIMATION_MODEL {
|
||||
return Err(editor_character_animation_bad_request(
|
||||
"model 必须固定为 seedance2.0。",
|
||||
));
|
||||
}
|
||||
let (frame_width, frame_height) = resolve_editor_character_animation_frame_size(
|
||||
payload.source_width,
|
||||
payload.source_height,
|
||||
ratio,
|
||||
resolution,
|
||||
);
|
||||
let prompt = build_editor_character_animation_prompt(prompt_text.as_str());
|
||||
|
||||
Ok(NormalizedEditorCharacterAnimationRequest {
|
||||
source_layer_id,
|
||||
source_image_src,
|
||||
prompt,
|
||||
resolution: resolution.to_string(),
|
||||
ratio: resolve_editor_character_animation_provider_ratio(
|
||||
ratio,
|
||||
payload.source_width,
|
||||
payload.source_height,
|
||||
),
|
||||
frame_count,
|
||||
duration_seconds,
|
||||
price_mud_points: expected_price,
|
||||
frame_width,
|
||||
frame_height,
|
||||
fps: (frame_count / duration_seconds).max(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn require_editor_character_animation_settings(
|
||||
state: &AppState,
|
||||
request: &NormalizedEditorCharacterAnimationRequest,
|
||||
) -> Result<EditorCharacterAnimationSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.ark_character_video_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "ark",
|
||||
"reason": "ARK_CHARACTER_VIDEO_BASE_URL 未配置",
|
||||
})),
|
||||
);
|
||||
}
|
||||
let api_key = state
|
||||
.config
|
||||
.ark_character_video_api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "ark",
|
||||
"reason": "ARK_CHARACTER_VIDEO_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
// 中文注释:画板角色动画入口产品侧固定为 seedance2.0,不继承通用角色视频环境模型覆盖。
|
||||
Ok(EditorCharacterAnimationSettings {
|
||||
ark: ArkVideoSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.config.ark_character_video_request_timeout_ms.max(1),
|
||||
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
|
||||
},
|
||||
resolution: request.resolution.clone(),
|
||||
ratio: request.ratio.clone(),
|
||||
duration_seconds: request.duration_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_editor_character_animation_prompt(prompt_text: &str) -> String {
|
||||
format!(
|
||||
"{}\n{}",
|
||||
EDITOR_CHARACTER_ANIMATION_PROMPT_PREFIX,
|
||||
prompt_text.trim()
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_editor_character_animation_resolution(value: &str) -> Result<&'static str, AppError> {
|
||||
match value.trim() {
|
||||
"480p" => Ok("480p"),
|
||||
"720p" => Ok("720p"),
|
||||
_ => Err(editor_character_animation_bad_request(
|
||||
"resolution 只支持 480p 或 720p。",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_character_animation_ratio(value: &str) -> Result<&'static str, AppError> {
|
||||
match value.trim() {
|
||||
"same" => Ok("same"),
|
||||
"1:1" => Ok("1:1"),
|
||||
"4:3" => Ok("4:3"),
|
||||
"16:9" => Ok("16:9"),
|
||||
"9:16" => Ok("9:16"),
|
||||
"3:4" => Ok("3:4"),
|
||||
_ => Err(editor_character_animation_bad_request(
|
||||
"ratio 只支持 same、1:1、4:3、16:9、9:16、3:4。",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_character_animation_frame_count(value: u32) -> Result<u32, AppError> {
|
||||
match value {
|
||||
32 | 40 | 48 => Ok(value),
|
||||
_ => Err(editor_character_animation_bad_request(
|
||||
"frameCount 只支持 32、40、48。",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_character_animation_duration(
|
||||
value: u32,
|
||||
frame_count: u32,
|
||||
) -> Result<u32, AppError> {
|
||||
let expected = match frame_count {
|
||||
32 => 4,
|
||||
40 => 5,
|
||||
48 => 6,
|
||||
_ => 0,
|
||||
};
|
||||
if value == expected {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(editor_character_animation_bad_request(
|
||||
"durationSeconds 必须与帧数组合为 32/4、40/5 或 48/6。",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_editor_character_animation_price(resolution: &str, duration_seconds: u32) -> u32 {
|
||||
let per_second = if resolution == "720p" { 20 } else { 10 };
|
||||
per_second * duration_seconds
|
||||
}
|
||||
|
||||
fn resolve_editor_character_animation_provider_ratio(
|
||||
ratio: &str,
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
) -> String {
|
||||
if ratio != "same" {
|
||||
return ratio.to_string();
|
||||
}
|
||||
if source_width == 0 || source_height == 0 {
|
||||
return "1:1".to_string();
|
||||
}
|
||||
let normalized = source_width as f32 / source_height as f32;
|
||||
[
|
||||
("1:1", 1.0f32),
|
||||
("4:3", 4.0 / 3.0),
|
||||
("16:9", 16.0 / 9.0),
|
||||
("9:16", 9.0 / 16.0),
|
||||
("3:4", 3.0 / 4.0),
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|(_, left), (_, right)| {
|
||||
(normalized - *left)
|
||||
.abs()
|
||||
.partial_cmp(&(normalized - *right).abs())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|(value, _)| value.to_string())
|
||||
.unwrap_or_else(|| "1:1".to_string())
|
||||
}
|
||||
|
||||
fn resolve_editor_character_animation_frame_size(
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
ratio: &str,
|
||||
resolution: &str,
|
||||
) -> (u32, u32) {
|
||||
let long_edge = if resolution == "720p" { 720 } else { 480 };
|
||||
let (ratio_width, ratio_height) = match ratio {
|
||||
"same" if source_width > 0 && source_height > 0 => (source_width, source_height),
|
||||
"4:3" => (4, 3),
|
||||
"16:9" => (16, 9),
|
||||
"9:16" => (9, 16),
|
||||
"3:4" => (3, 4),
|
||||
_ => (1, 1),
|
||||
};
|
||||
if ratio_width >= ratio_height {
|
||||
let height = ((long_edge as f32 * ratio_height as f32 / ratio_width as f32).round() as u32)
|
||||
.max(1);
|
||||
(long_edge, height)
|
||||
} else {
|
||||
let width = ((long_edge as f32 * ratio_width as f32 / ratio_height as f32).round() as u32)
|
||||
.max(1);
|
||||
(width, long_edge)
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_character_animation_bad_request(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-character-animation",
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_animation_generate_result_payload(generated: &CharacterAnimationGeneratedDraft) -> Value {
|
||||
match generated.preview_video_path.as_ref() {
|
||||
Some(preview_video_path) => json!({
|
||||
@@ -3353,6 +3829,28 @@ struct ArkVideoSettings {
|
||||
model: String,
|
||||
}
|
||||
|
||||
struct EditorCharacterAnimationSettings {
|
||||
ark: ArkVideoSettings,
|
||||
resolution: String,
|
||||
ratio: String,
|
||||
duration_seconds: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NormalizedEditorCharacterAnimationRequest {
|
||||
source_layer_id: String,
|
||||
source_image_src: String,
|
||||
prompt: String,
|
||||
resolution: String,
|
||||
ratio: String,
|
||||
frame_count: u32,
|
||||
duration_seconds: u32,
|
||||
price_mud_points: u32,
|
||||
frame_width: u32,
|
||||
frame_height: u32,
|
||||
fps: u32,
|
||||
}
|
||||
|
||||
struct GeneratedAnimationPreview {
|
||||
preview_video_path: String,
|
||||
upstream_task_id: String,
|
||||
@@ -3548,4 +4046,73 @@ mod tests {
|
||||
|
||||
assert_eq!(resolve_character_animation_model(&payload), "wan-move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_normalizes_seedance_request_contract() {
|
||||
let normalized =
|
||||
normalize_editor_character_animation_request(EditorCharacterAnimationGenerateRequest {
|
||||
source_layer_id: " layer-hero ".to_string(),
|
||||
source_image_src: "/generated-characters/hero/master.png".to_string(),
|
||||
source_width: 768,
|
||||
source_height: 1024,
|
||||
prompt_text: "待机呼吸,轻微摆动。".to_string(),
|
||||
resolution: "720p".to_string(),
|
||||
ratio: "same".to_string(),
|
||||
frame_count: 48,
|
||||
duration_seconds: 6,
|
||||
price_mud_points: 120,
|
||||
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
|
||||
})
|
||||
.expect("editor request should normalize");
|
||||
|
||||
assert_eq!(normalized.source_layer_id, "layer-hero");
|
||||
assert_eq!(normalized.frame_count, 48);
|
||||
assert_eq!(normalized.duration_seconds, 6);
|
||||
assert_eq!(normalized.price_mud_points, 120);
|
||||
assert_eq!(normalized.fps, 8);
|
||||
assert_eq!(normalized.frame_width, 540);
|
||||
assert_eq!(normalized.frame_height, 720);
|
||||
assert_eq!(normalized.ratio, "3:4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_rejects_invalid_frame_duration_pair() {
|
||||
let error =
|
||||
normalize_editor_character_animation_request(EditorCharacterAnimationGenerateRequest {
|
||||
source_layer_id: "layer-hero".to_string(),
|
||||
source_image_src: "/generated-characters/hero/master.png".to_string(),
|
||||
source_width: 1024,
|
||||
source_height: 1024,
|
||||
prompt_text: "奔跑".to_string(),
|
||||
resolution: "480p".to_string(),
|
||||
ratio: "1:1".to_string(),
|
||||
frame_count: 48,
|
||||
duration_seconds: 4,
|
||||
price_mud_points: 40,
|
||||
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
|
||||
})
|
||||
.expect_err("invalid frame/duration pair should fail");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.body_text()
|
||||
.contains("durationSeconds 必须与帧数组合")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_builds_required_green_screen_prompt() {
|
||||
let prompt = build_editor_character_animation_prompt("行走两步后回到站姿。");
|
||||
|
||||
assert!(prompt.contains("生成游戏角色动画"));
|
||||
assert!(prompt.contains("参考图作为首帧和尾帧"));
|
||||
assert!(prompt.contains("背景固定为纯绿色绿幕"));
|
||||
assert!(prompt.contains("动作描述:\n行走两步后回到站姿。"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_character_animation_price_depends_on_resolution_and_duration() {
|
||||
assert_eq!(calculate_editor_character_animation_price("480p", 4), 40);
|
||||
assert_eq!(calculate_editor_character_animation_price("720p", 6), 120);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user