新增编辑器生成规范、生成角色形象、生成图标素材等功能

新增编辑器生成规范、生成角色形象、生成图标素材等功能
This commit is contained in:
2026-06-16 14:47:13 +08:00
parent 0fd0a06387
commit 7eeff10c67
33 changed files with 8783 additions and 502 deletions

View File

@@ -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);
}
}