Files
Genarrative/server-rs/crates/api-server/src/character_animation_assets.rs
T
lhk229 a2ee879fc8
Project CI / Repository checks (push) Successful in 1m3s
Project CI / Frontend tests (push) Successful in 2m0s
Project CI / Native shell tests (push) Successful in 2m26s
Project CI / Backend tests (push) Successful in 3m1s
添加gfilter专用worker (#103)
Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/103
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-07-23 18:06:29 +08:00

7312 lines
264 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
use axum::{
Json,
extract::{Extension, Path as AxumPath, Query, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
use image::{
ColorType, ImageEncoder, ImageFormat, Rgba, RgbaImage, codecs::png::PngEncoder,
imageops::FilterType,
};
use module_ai::{
AiStageCompletionInput, AiTaskCreateInput, AiTaskKind, AiTaskServiceError, AiTaskSnapshot,
AiTaskStageKind, AiTaskStatus, generate_ai_task_id,
};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssRequestAttemptContext, OssSignedGetObjectUrlRequest,
};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::assets::{
CharacterAnimationDraftPayload, CharacterAnimationGenerateRequest,
CharacterAnimationGenerateResponse, CharacterAnimationImportVideoRequest,
CharacterAnimationImportVideoResponse, CharacterAnimationPublishRequest,
CharacterAnimationPublishResponse, CharacterAnimationStrategy,
CharacterAnimationTemplatePayload, CharacterAnimationTemplatesResponse,
CharacterAssetJobStatusPayload, CharacterAssetJobStatusText, CharacterVisualDraftPayload,
CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload,
CharacterWorkflowCacheSaveRequest, CharacterWorkflowCacheSaveResponse,
EDITOR_ASSET_LABEL_MAX_CHARS, EditorCharacterAnimationFramePayload,
EditorCharacterAnimationGenerateRequest, EditorCharacterAnimationGenerateResponse,
EditorVideoGenerateRequest, EditorVideoGenerateResponse,
};
#[cfg(any())]
use shared_contracts::assets::{
CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse,
};
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation_with_cost,
auth::AuthenticatedAccessToken,
custom_world_asset_prompts::{
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
},
editor_generation_config::EditorGenerationPricingConfig,
editor_generation_queue::{
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND,
EditorGenerationQueuedResponse, editor_generation_queue_state,
editor_generation_source_entity_id, enqueue_editor_generation_job,
},
editor_green_screen::{
EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause,
},
editor_project::{
EDITOR_ASSET_ID_PREFIX, EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_RESOURCE_ID_PREFIX,
EditorCanvasGeneratedLayerInput, EditorGenerationPhaseReporter,
PersistEditorGeneratedAssetRequest,
apply_editor_screen_background_decision_to_generation_inputs,
build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items,
persist_editor_generated_media_asset,
remove_editor_generated_screen_background_with_bgfilter,
resolve_editor_reference_object_key_for_owner,
},
editor_screen_background_decision::{
EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind,
resolve_editor_screen_background_color,
},
http_error::AppError,
platform_errors::map_oss_error,
prompt::character_animation::normalize_animation_prompt_text_by_key,
request_context::RequestContext,
state::AppState,
};
use tokio::time::sleep;
const CHARACTER_ANIMATION_MODEL: &str = "doubao-seedance-2-0-fast-260128";
const SEEDANCE_2_STANDARD_MODEL: &str = "doubao-seedance-2-0-260128";
const CHARACTER_ANIMATION_ASSET_KIND: &str = "character_animation";
const CHARACTER_ANIMATION_REFERENCE_ASSET_KIND: &str = "character_animation_reference_video";
const CHARACTER_WORKFLOW_CACHE_ASSET_KIND: &str = "character_workflow_cache";
const CHARACTER_ANIMATION_ENTITY_KIND: &str = "character";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CharacterWorkflowCacheQuery {
#[serde(default)]
pub cache_scope_id: Option<String>,
}
const CHARACTER_ANIMATION_SLOT: &str = "animation_set";
const CHARACTER_ANIMATION_REFERENCE_SLOT: &str = "animation_reference_video";
const CHARACTER_WORKFLOW_CACHE_SLOT: &str = "workflow_cache";
const CHARACTER_ANIMATION_DRAFT_SLOT: &str = "animation_draft";
const CHARACTER_ANIMATION_PREVIEW_SLOT: &str = "animation_preview";
const DEFAULT_ANIMATION_FRAME_WIDTH: u32 = 192;
const DEFAULT_ANIMATION_FRAME_HEIGHT: u32 = 256;
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 SOURCE_IMAGE_RESOLVE_TIMEOUT_MS: u64 = 30_000;
const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0-fast";
const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "editor_character_animation";
const EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND: &str = "character-animation";
const EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT: &str = "provider_source";
const EDITOR_VIDEO_ASSET_KIND: &str = "editor_video";
const EDITOR_VIDEO_ENTITY_KIND: &str = "editor_canvas";
const EDITOR_VIDEO_SLOT: &str = "video_preview";
const EDITOR_VIDEO_COVER_SLOT: &str = "video_cover";
const EDITOR_VIDEO_MODEL_SEEDANCE_2: &str = "seedance2.0";
const EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST: &str = "seedance2.0-fast";
const EDITOR_VIDEO_MODEL_KLING_3: &str = "kling3.0";
const EDITOR_VIDEO_MODEL_KLING_3_OMNI: &str = "kling3.0-omni";
const EDITOR_VIDEO_MODEL_VEO_3_1: &str = "veo3.1";
const EDITOR_VIDEO_MODEL_VEO_3_1_FAST: &str = "veo3.1-fast";
const EDITOR_VIDEO_REFERENCE_IMAGE_LIMIT: usize = 9;
const EDITOR_VIDEO_REFERENCE_VIDEO_LIMIT: usize = 3;
const EDITOR_VIDEO_REFERENCE_AUDIO_LIMIT: usize = 3;
const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES: usize = 256 * 1024;
const EDITOR_VIDEO_REFERENCE_READ_EXPIRE_SECONDS: u64 = 60 * 60;
const BUILT_IN_MOTION_TEMPLATES: [MotionTemplate; 4] = [
MotionTemplate {
id: "idle_loop",
label: "待机循环",
animation: "idle",
prompt_suffix: "保持呼吸感和轻微重心起伏。",
notes: "适合方案三的默认待机模板。",
},
MotionTemplate {
id: "run_side",
label: "奔跑侧移",
animation: "run",
prompt_suffix: "保持平稳横向移动,脚步连续。",
notes: "适合横版角色的标准奔跑模板。",
},
MotionTemplate {
id: "attack_slash",
label: "横斩攻击",
animation: "attack",
prompt_suffix: "短促前踏后横斩,收招干净。",
notes: "适合近战角色的基础攻击模板。",
},
MotionTemplate {
id: "die_fall",
label: "倒地死亡",
animation: "die",
prompt_suffix: "失衡倒地,动作完整结束。",
notes: "适合终结动作模板。",
},
];
struct PersistedEditorVideo {
legacy_public_path: String,
object_key: String,
asset_object_id: String,
thumbnail_src: Option<String>,
}
struct PersistedEditorCharacterAnimationPreview {
legacy_public_path: String,
object_key: String,
asset_object_id: String,
}
pub async fn list_character_animation_templates(
Extension(request_context): Extension<RequestContext>,
) -> Json<Value> {
json_success_body(
Some(&request_context),
CharacterAnimationTemplatesResponse {
ok: true,
templates: BUILT_IN_MOTION_TEMPLATES
.iter()
.map(MotionTemplate::to_payload)
.collect(),
},
)
}
pub async fn import_character_animation_video(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<CharacterAnimationImportVideoRequest>, 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": "character-animation",
"message": error.body_text(),
})),
)
})?;
// 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属。
let owner_user_id = "asset-tool".to_string();
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let animation = normalize_required_text(payload.animation.as_str(), "clip");
let source_label = normalize_required_text(
payload.source_label.as_deref().unwrap_or("imported-video"),
"imported-video",
);
let parsed_video = parse_video_data_url(payload.video_source.as_str()).ok_or_else(|| {
character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-animation",
"message": "videoSource 当前只支持 data:video/*;base64,...",
})),
)
})?;
let draft_id = format!("animation-import-{}", current_utc_millis());
let put_result = put_imported_video_object(
&state,
&owner_user_id,
character_id.as_str(),
animation.as_str(),
draft_id.as_str(),
source_label.as_str(),
parsed_video,
)
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
CharacterAnimationImportVideoResponse {
ok: true,
imported_video_path: put_result.legacy_public_path,
draft_id,
save_message: "参考视频已导入 OSS 草稿区。".to_string(),
},
))
}
pub async fn generate_character_animation(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<CharacterAnimationGenerateRequest>, 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": "character-animation",
"message": error.body_text(),
})),
)
})?;
if payload.visual_source.trim().is_empty() {
return Err(character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-animation",
"message": "请先准备主形象,再生成动作。",
})),
));
}
// 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属。
let owner_user_id = "asset-tool".to_string();
let task_id = generate_ai_task_id(current_utc_micros());
let strategy = payload.strategy.clone();
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let animation = normalize_required_text(payload.animation.as_str(), "idle");
let prompt = build_character_animation_prompt(
&strategy,
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
payload.action_template_id.as_deref(),
animation.as_str(),
payload.frame_count,
payload.fps,
payload.duration_seconds,
payload.loop_,
payload.use_chroma_key,
);
let model = resolve_character_animation_model(&payload);
let created = create_animation_task(
&state,
task_id.as_str(),
owner_user_id.as_str(),
character_id.as_str(),
animation.as_str(),
&strategy,
model.as_str(),
prompt.as_str(),
)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let result = async {
state
.ai_task_service()
.start_task(task_id.as_str(), current_utc_micros())
.map_err(map_ai_task_error)?;
state
.ai_task_service()
.start_stage(
task_id.as_str(),
AiTaskStageKind::PreparePrompt,
current_utc_micros(),
)
.map_err(map_ai_task_error)?;
state
.ai_task_service()
.complete_stage(AiStageCompletionInput {
task_id: task_id.clone(),
stage_kind: AiTaskStageKind::PreparePrompt,
text_output: Some(prompt.clone()),
structured_payload_json: Some(
json!({
"characterId": character_id,
"animation": animation,
"strategy": strategy,
"referenceImageCount": payload.reference_image_data_urls.len(),
"referenceVideoCount": payload.reference_video_data_urls.len(),
})
.to_string(),
),
warning_messages: Vec::new(),
completed_at_micros: current_utc_micros(),
})
.map_err(map_ai_task_error)?;
state
.ai_task_service()
.start_stage(
task_id.as_str(),
AiTaskStageKind::RequestModel,
current_utc_micros(),
)
.map_err(map_ai_task_error)?;
let generated = match strategy {
CharacterAnimationStrategy::ImageToVideo => {
let settings = require_ark_video_settings(&state, &payload)?;
let http_client = build_upstream_http_client(settings.request_timeout_ms)?;
let visual_data_url = resolve_media_source_as_data_url(
&state,
&http_client,
payload.visual_source.as_str(),
"visualSource",
)
.await?;
let last_frame_data_url = resolve_media_source_as_data_url(
&state,
&http_client,
payload
.last_frame_image_data_url
.as_deref()
.unwrap_or(payload.visual_source.as_str()),
"lastFrameImageDataUrl",
)
.await?;
let fallback_prompt = build_fallback_moderation_safe_animation_prompt(
animation.as_str(),
payload.loop_,
payload.use_chroma_key,
);
let generated = request_image_to_video_preview(
&state,
&http_client,
&settings,
owner_user_id.as_str(),
character_id.as_str(),
animation.as_str(),
task_id.as_str(),
prompt.as_str(),
fallback_prompt.as_str(),
visual_data_url.as_str(),
last_frame_data_url.as_str(),
)
.await?;
state
.ai_task_service()
.complete_stage(AiStageCompletionInput {
task_id: task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
text_output: Some(generated.submitted_prompt.clone()),
structured_payload_json: Some(
json!({
"provider": "ark",
"taskId": generated.upstream_task_id,
"model": settings.model,
"moderationFallbackApplied": generated.moderation_fallback_applied,
})
.to_string(),
),
warning_messages: Vec::new(),
completed_at_micros: current_utc_micros(),
})
.map_err(map_ai_task_error)?;
CharacterAnimationGeneratedDraft {
image_sources: Vec::new(),
preview_video_path: Some(generated.preview_video_path),
}
}
CharacterAnimationStrategy::ImageSequence => {
let image_sources = persist_animation_draft_frames(
&state,
owner_user_id.as_str(),
character_id.as_str(),
animation.as_str(),
task_id.as_str(),
prompt.as_str(),
normalize_frame_count(payload.frame_count),
)
.await?;
state
.ai_task_service()
.complete_stage(AiStageCompletionInput {
task_id: task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
text_output: Some("当前仍使用 Stage 1 序列帧占位链。".to_string()),
structured_payload_json: Some(
json!({
"provider": "character-animation",
"mode": "stage1-image-sequence-placeholder",
})
.to_string(),
),
warning_messages: Vec::new(),
completed_at_micros: current_utc_micros(),
})
.map_err(map_ai_task_error)?;
CharacterAnimationGeneratedDraft {
image_sources,
preview_video_path: None,
}
}
_ => {
let preview_video_path = persist_animation_preview_video(
&state,
owner_user_id.as_str(),
character_id.as_str(),
animation.as_str(),
task_id.as_str(),
payload
.reference_video_data_urls
.first()
.map(String::as_str),
)
.await?;
state
.ai_task_service()
.complete_stage(AiStageCompletionInput {
task_id: task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
text_output: Some("当前仍使用 Stage 1 视频占位链。".to_string()),
structured_payload_json: Some(
json!({
"provider": "character-animation",
"mode": "stage1-video-placeholder",
"strategy": strategy,
})
.to_string(),
),
warning_messages: Vec::new(),
completed_at_micros: current_utc_micros(),
})
.map_err(map_ai_task_error)?;
CharacterAnimationGeneratedDraft {
image_sources: Vec::new(),
preview_video_path: Some(preview_video_path),
}
}
};
let result_payload = build_animation_generate_result_payload(&generated);
state
.ai_task_service()
.start_stage(
task_id.as_str(),
AiTaskStageKind::NormalizeResult,
current_utc_micros(),
)
.map_err(map_ai_task_error)?;
state
.ai_task_service()
.complete_stage(AiStageCompletionInput {
task_id: task_id.clone(),
stage_kind: AiTaskStageKind::NormalizeResult,
text_output: None,
structured_payload_json: Some(result_payload.to_string()),
warning_messages: Vec::new(),
completed_at_micros: current_utc_micros(),
})
.map_err(map_ai_task_error)?;
state
.ai_task_service()
.complete_stage(AiStageCompletionInput {
task_id: task_id.clone(),
stage_kind: AiTaskStageKind::PersistResult,
text_output: Some("角色动作草稿已写入 OSS。".to_string()),
structured_payload_json: Some(result_payload.to_string()),
warning_messages: Vec::new(),
completed_at_micros: current_utc_micros(),
})
.map_err(map_ai_task_error)?;
state
.ai_task_service()
.complete_task(task_id.as_str(), current_utc_micros())
.map_err(map_ai_task_error)?;
Ok::<_, AppError>(generated)
}
.await;
let generated = match result {
Ok(generated) => generated,
Err(error) => {
let _ = state.ai_task_service().fail_task(
created.task_id.as_str(),
error.message().to_string(),
current_utc_micros(),
);
return Err(character_animation_error_response(&request_context, error));
}
};
Ok(json_success_body(
Some(&request_context),
CharacterAnimationGenerateResponse {
ok: true,
task_id,
strategy,
model,
prompt,
image_sources: generated.image_sources,
preview_video_path: generated.preview_video_path,
},
))
}
pub async fn generate_editor_character_animation(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
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 owner_user_id = authenticated.claims().user_id().to_string();
if matches_inline_media_source(payload.source_image_src.as_str()) {
return Err(character_animation_error_response(
&request_context,
editor_character_animation_bad_request(
"sourceImageSrc 必须先上传 OSS,并使用 objectKey 或画板资源引用。",
),
));
}
if !state.config.external_generation_mode.is_inline() {
let pricing = state.editor_generation_pricing().await.map_err(|error| {
character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
})),
)
})?;
// 队列路径只取定价,背景色决策留到实际执行时再做,这里用默认色占位。
let normalized = normalize_editor_character_animation_request_with_pricing(
payload.clone(),
&pricing,
crate::editor_green_screen::default_editor_screen_background_color(),
)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
payload.source_layer_id.as_str(),
);
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
owner_user_id.as_str(),
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成角色动作",
u64::from(normalized.price_mud_points),
&payload,
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
generate_editor_character_animation_for_owner(
state,
request_context,
owner_user_id,
Ok(Json(payload)),
None,
)
.await
}
pub(crate) async fn generate_editor_character_animation_for_owner(
state: AppState,
request_context: RequestContext,
owner_user_id: String,
payload: Result<Json<EditorCharacterAnimationGenerateRequest>, JsonRejection>,
phase_reporter: Option<EditorGenerationPhaseReporter>,
) -> 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 project_id = payload.project_id.clone();
let canvas_completion = payload.canvas_completion.clone();
let generation_inputs = payload.generation_inputs.clone();
let source_resource_id = payload.source_resource_id.clone();
let asset_folder_id = payload.asset_folder_id.clone();
let asset_label =
resolve_editor_character_animation_asset_label(payload.asset_label.as_deref());
if matches_inline_media_source(payload.source_image_src.as_str()) {
return Err(character_animation_error_response(
&request_context,
editor_character_animation_bad_request(
"sourceImageSrc 必须先上传 OSS,并使用 objectKey 或画板资源引用。",
),
));
}
let pricing = state.editor_generation_pricing().await.map_err(|error| {
character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
})),
)
})?;
// 先解析源角色图:图生视频的主体颜色完全由源图决定,背景色决策必须看到这张图。
let source_resolve_client = build_upstream_http_client(SOURCE_IMAGE_RESOLVE_TIMEOUT_MS)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let source_object_key = resolve_editor_reference_object_key_for_owner(
&state,
owner_user_id.as_str(),
payload.source_image_src.trim(),
)
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
let source_data_url = resolve_media_source_as_data_url(
&state,
&source_resolve_client,
source_object_key.as_str(),
"sourceImageSrc",
)
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
// 定价 / 上游设置先于扣费,且都不依赖背景色:用默认色占位归一化(与队列路径一致),
// 把背景色决策挪到预扣泥点之后,避免余额不足 / 生成注定失败时仍白发一次 gpt-5-mini 决策。
let priced = normalize_editor_character_animation_request_with_pricing(
payload.clone(),
&pricing,
crate::editor_green_screen::default_editor_screen_background_color(),
)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let settings = require_editor_character_animation_settings(&state, &priced)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let http_client = build_upstream_http_client(settings.ark.request_timeout_ms)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let extraction_settings = resolve_backend_frame_extraction_settings(&state);
let task_id = generate_ai_task_id(current_utc_micros());
// 抠图供应商失败审计上下文:逐帧阿里云抠图失败即使兜底成功,也要进 OTLP + tracking_event。
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: Some(owner_user_id.clone()),
profile_id: project_id.clone(),
request_id: Some(request_context.request_id().to_string()),
external_call_deadline: request_context.external_call_deadline(),
};
let result = execute_billable_asset_operation_with_cost(
&state,
owner_user_id.as_str(),
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
task_id.as_str(),
u64::from(priced.price_mud_points),
async {
// 背景色决策:预扣泥点之后才发起(显式传入 → 视觉 LLM 按源图主体配色自动决策 → 默认色)。
// 决策失败会让本 future 返回 Err,走 execute_billable 的失败退款,用户不损失泥点。
let screen_background_decision = resolve_editor_screen_background_color(
state.llm_client(),
state.editor_agent_llm_client(),
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::CharacterAnimation,
screen_color: payload.screen_color.clone(),
prompt: payload.prompt_text.clone(),
icon_descriptions: Vec::new(),
reference_count: 1,
source_image_data_url: Some(source_data_url.clone()),
},
Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
state: &state,
context: crate::external_api_audit::ExternalApiAuditContext {
user_id: Some(owner_user_id.clone()),
profile_id: project_id.clone(),
request_id: Some(request_context.request_id().to_string()),
external_call_deadline: request_context.external_call_deadline(),
},
}),
)
.await?;
// 把实际选定的背景色落进资产记录,供后续以该资产为参考图时反查旧背景色。
let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs(
generation_inputs,
Some(&screen_background_decision),
);
// 用决策色重新归一化以拿到最终提示词(价格 / 校验已在预扣前用占位色完成)。
let normalized = normalize_editor_character_animation_request_with_pricing(
payload,
&pricing,
screen_background_decision.color,
)?;
// 透明源图先合成到选定背景色:让视频背景确定性地等于抠图键色,而不是赌模型服从提示词。
let source_data_url = composite_source_image_onto_screen_color(
source_data_url.as_str(),
screen_background_decision.color,
)
.unwrap_or(source_data_url);
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?;
// 扣费契约(【编辑器】画板角色形象生成入口设计-2026-06-15):角色动作的交付物是
// 画布可用的透明序列帧,预览视频本身不是可用结果;因此预览落库、phase 上报、抽帧、
// 逐帧抠图、首帧落库全部留在计费闭包内,任一步失败都走 execute_billable 的失败退款。
// 有别于角色生图 / 编辑器视频(provider 产物即交付物,provider 成功即收口扣费)。
let source_record = persist_editor_generated_media_asset(
&state,
PersistEditorGeneratedAssetRequest {
project_id: project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: asset_folder_id
.clone()
.or_else(|| Some("project".to_string())),
label: editor_character_animation_source_asset_label(asset_label.as_str()),
image_src: generated.preview_video_path.clone(),
object_key: generated.object_key.clone(),
asset_object_id: generated.asset_object_id.clone(),
width: normalized.frame_width,
height: normalized.frame_height,
prompt: normalized.prompt.clone(),
actual_prompt: Some(generated.submitted_prompt.clone()),
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
provider: "Ark".to_string(),
task_id: task_id.clone(),
source_resource_id: source_resource_id.clone(),
asset_kind: Some(
EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string(),
),
generation_inputs: generation_inputs.clone(),
thumbnail_src: None,
generation_cost_mud_points: u64::from(normalized.price_mud_points),
},
)
.await?;
if let Some(reporter) = phase_reporter.as_ref() {
reporter.report_processing(&state).await?;
}
let provider_source_resource_id = source_record
.0
.as_ref()
.map(|resource| resource.resource_id.clone());
let persisted_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,
&matting_audit,
)
.await?;
let frames = persisted_frames.frames;
let generation_inputs = attach_editor_character_animation_result_metadata(
generation_inputs,
generated.preview_video_path.as_str(),
frames.as_slice(),
normalized.fps,
normalized.duration_seconds,
);
let first_frame = frames.first().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-character-animation",
"message": "角色动作抽帧完成但没有可保存的序列帧。",
}))
})?;
persist_editor_generated_media_asset(
&state,
PersistEditorGeneratedAssetRequest {
project_id: None,
owner_user_id: owner_user_id.clone(),
folder_id: asset_folder_id
.clone()
.or_else(|| Some("project".to_string())),
label: asset_label.clone(),
image_src: first_frame.image_src.clone(),
object_key: Some(persisted_frames.first_frame_object_key),
asset_object_id: Some(persisted_frames.first_frame_asset_object_id),
width: first_frame.width,
height: first_frame.height,
prompt: normalized.prompt.clone(),
actual_prompt: Some(generated.submitted_prompt.clone()),
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
provider: "Ark".to_string(),
task_id: task_id.clone(),
source_resource_id: provider_source_resource_id,
asset_kind: Some(
EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND.to_string(),
),
generation_inputs: generation_inputs.clone(),
thumbnail_src: Some(first_frame.image_src.clone()),
generation_cost_mud_points: 0,
},
)
.await?;
Ok::<_, AppError>((generated, frames, normalized, generation_inputs))
},
)
.await;
let (generated, frames, normalized, generation_inputs) =
result.map_err(|error| character_animation_error_response(&request_context, error))?;
let completed_project = if let Some(completion) = canvas_completion.as_ref() {
let frame_payload_json = serde_json::to_value(&frames).map_err(|error| {
character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-character-animation",
"message": format!("角色动作序列帧无法写入画布:{error}"),
})),
)
})?;
let preview_frame = frames.first();
let layer_id = format!("layer-editor-character-animation-{task_id}");
let item = build_editor_canvas_generated_layer_item(
completion,
EditorCanvasGeneratedLayerInput {
layer_id: layer_id.clone(),
resource_id: format!("local-resource-editor-character-animation-{task_id}"),
title: completion.title.trim().to_string(),
src: preview_frame
.map(|frame| frame.image_src.clone())
.unwrap_or_else(|| generated.preview_video_path.clone()),
media_type: Some("image-sequence".to_string()),
thumbnail_src: preview_frame.map(|frame| frame.image_src.clone()),
original_width: preview_frame
.map(|frame| frame.width)
.unwrap_or(normalized.frame_width),
original_height: preview_frame
.map(|frame| frame.height)
.unwrap_or(normalized.frame_height),
display_width: completion.placeholder.width,
display_height: completion.placeholder.height,
source_type: "generated".to_string(),
prompt: Some(generated.submitted_prompt.clone()),
actual_prompt: Some(generated.submitted_prompt.clone()),
model: Some(EDITOR_CHARACTER_ANIMATION_MODEL.to_string()),
provider: Some("ark".to_string()),
task_id: Some(task_id.clone()),
object_key: None,
asset_object_id: None,
duration_seconds: Some(normalized.duration_seconds),
source_resource_id,
asset_kind: Some("character-animation".to_string()),
generation_inputs,
image_sequence_frames: Some(frame_payload_json),
preview_video_path: Some(generated.preview_video_path.clone()),
},
);
complete_editor_canvas_generation_with_items(
&state,
owner_user_id.as_str(),
project_id.as_deref(),
canvas_completion.as_ref(),
vec![item],
Some(layer_id),
)
.await
.map_err(|error| character_animation_error_response(&request_context, error))?
} else {
None
};
let completed_project =
completed_project.and_then(|project| serde_json::to_value(project).ok());
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,
project: completed_project,
queue_state: None,
},
))
}
pub async fn generate_editor_video(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<EditorVideoGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
editor_video_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-video",
"message": error.body_text(),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
if !state.config.external_generation_mode.is_inline() {
let pricing = state.editor_generation_pricing().await.map_err(|error| {
editor_video_error_response(
&request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
})),
)
})?;
let normalized = normalize_editor_video_request_with_pricing(payload.clone(), &pricing)
.map_err(|error| editor_video_error_response(&request_context, error))?;
let source_entity_id =
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-video");
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
owner_user_id.as_str(),
EDITOR_VIDEO_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成视频",
u64::from(normalized.price_mud_points),
&payload,
)
.await
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
generate_editor_video_for_owner(state, request_context, owner_user_id, Ok(Json(payload))).await
}
pub(crate) async fn generate_editor_video_for_owner(
state: AppState,
request_context: RequestContext,
owner_user_id: String,
payload: Result<Json<EditorVideoGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
editor_video_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-video",
"message": error.body_text(),
})),
)
})?;
let project_id = payload.project_id.clone();
let canvas_completion = payload.canvas_completion.clone();
let generation_inputs = payload.generation_inputs.clone();
let source_resource_id = payload.source_resource_id.clone();
let asset_kind = payload
.asset_kind
.clone()
.unwrap_or_else(|| "video".to_string());
let asset_folder_id = payload.asset_folder_id.clone();
let asset_label = payload.asset_label.clone();
let pricing = state.editor_generation_pricing().await.map_err(|error| {
editor_video_error_response(
&request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
})),
)
})?;
let normalized = normalize_editor_video_request_with_pricing(payload, &pricing)
.map_err(|error| editor_video_error_response(&request_context, error))?;
let settings = require_editor_video_settings(&state, normalized.model.as_str())
.map_err(|error| editor_video_error_response(&request_context, error))?;
let http_client = build_upstream_http_client(settings.ark.request_timeout_ms)
.map_err(|error| editor_video_error_response(&request_context, error))?;
let task_id = generate_ai_task_id(current_utc_micros());
let generated = execute_billable_asset_operation_with_cost(
&state,
owner_user_id.as_str(),
EDITOR_VIDEO_ASSET_KIND,
task_id.as_str(),
u64::from(normalized.price_mud_points),
request_editor_video_preview(
&state,
&http_client,
&settings,
owner_user_id.as_str(),
task_id.as_str(),
&normalized,
),
)
.await
.map_err(|error| editor_video_error_response(&request_context, error))?;
let image_src = generated.preview_video_path.clone();
let (resource, asset) = persist_editor_generated_media_asset(
&state,
PersistEditorGeneratedAssetRequest {
project_id: project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: asset_folder_id,
label: asset_label
.and_then(|value| {
let trimmed = value.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
})
.unwrap_or_else(|| "生成视频".to_string()),
image_src: image_src.clone(),
object_key: generated.object_key.clone(),
asset_object_id: generated.asset_object_id.clone(),
width: normalized.width,
height: normalized.height,
prompt: normalized.prompt.clone(),
actual_prompt: Some(generated.submitted_prompt.clone()),
model: normalized.model.clone(),
provider: "VectorEngine".to_string(),
task_id: task_id.clone(),
source_resource_id: source_resource_id.clone(),
asset_kind: Some(asset_kind.clone()),
generation_inputs: generation_inputs.clone(),
thumbnail_src: generated.thumbnail_src.clone(),
generation_cost_mud_points: u64::from(normalized.price_mud_points),
},
)
.await
.map_err(|error| editor_video_error_response(&request_context, error))?;
let completed_project = if let Some(completion) = canvas_completion.as_ref() {
let layer_id = format!("layer-editor-video-{task_id}");
let resource_id = resource
.as_ref()
.map(|resource| resource.resource_id.clone())
.unwrap_or_else(|| format!("local-resource-editor-video-{task_id}"));
let item = build_editor_canvas_generated_layer_item(
completion,
EditorCanvasGeneratedLayerInput {
layer_id: layer_id.clone(),
resource_id,
title: completion.title.trim().to_string(),
src: image_src.clone(),
media_type: Some("video".to_string()),
thumbnail_src: generated.thumbnail_src.clone(),
original_width: normalized.width,
original_height: normalized.height,
display_width: completion.placeholder.width,
display_height: completion.placeholder.height,
source_type: "generated".to_string(),
prompt: Some(normalized.prompt.clone()),
actual_prompt: Some(generated.submitted_prompt.clone()),
model: Some(normalized.model.clone()),
provider: Some("VectorEngine".to_string()),
task_id: Some(task_id.clone()),
object_key: generated.object_key.clone(),
asset_object_id: generated.asset_object_id.clone(),
duration_seconds: Some(normalized.duration_seconds),
source_resource_id,
asset_kind: Some(asset_kind.clone()),
generation_inputs,
image_sequence_frames: None,
preview_video_path: None,
},
);
complete_editor_canvas_generation_with_items(
&state,
owner_user_id.as_str(),
project_id.as_deref(),
canvas_completion.as_ref(),
vec![item],
Some(layer_id),
)
.await
.map_err(|error| editor_video_error_response(&request_context, error))?
} else {
None
};
let completed_project =
completed_project.and_then(|project| serde_json::to_value(project).ok());
let resource = resource.and_then(|resource| serde_json::to_value(resource).ok());
let asset = asset.and_then(|asset| serde_json::to_value(asset).ok());
Ok(json_success_body(
Some(&request_context),
EditorVideoGenerateResponse {
ok: true,
video_src: generated.preview_video_path,
thumbnail_src: generated.thumbnail_src,
object_key: generated.object_key,
asset_object_id: generated.asset_object_id,
width: normalized.width,
height: normalized.height,
source_type: "generated".to_string(),
prompt: normalized.prompt,
actual_prompt: Some(generated.submitted_prompt),
model: normalized.model,
provider: "VectorEngine".to_string(),
task_id,
duration_seconds: normalized.duration_seconds,
resolution: normalized.resolution,
price_mud_points: normalized.price_mud_points,
project: completed_project,
resource,
asset,
queue_state: None,
},
))
}
pub async fn get_character_animation_job(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
AxumPath(task_id): AxumPath<String>,
) -> Result<Json<Value>, Response> {
let task = state
.ai_task_service()
.get_task(task_id.as_str())
.map_err(map_ai_task_error)
.map_err(|error| character_animation_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
build_character_animation_job_payload(task),
))
}
pub async fn publish_character_animation(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<CharacterAnimationPublishRequest>, 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": "character-animation",
"message": error.body_text(),
})),
)
})?;
// 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属。
let owner_user_id = "asset-tool".to_string();
let character_id = normalize_required_text(payload.character_id.as_str(), "");
let visual_asset_id = normalize_required_text(payload.visual_asset_id.as_str(), "");
if character_id.is_empty() {
return Err(character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-animation",
"message": "characterId is required.",
})),
));
}
if visual_asset_id.is_empty() {
return Err(character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-animation",
"message": "visualAssetId is required.",
})),
));
}
if payload.animations.is_empty() {
return Err(character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-animation",
"message": "animations is required.",
})),
));
}
let animation_set_id = format!("animation-set-{}", current_utc_millis());
let published = publish_animation_set(
&state,
owner_user_id.as_str(),
character_id.as_str(),
visual_asset_id.as_str(),
animation_set_id.as_str(),
payload.animations,
)
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
CharacterAnimationPublishResponse {
ok: true,
animation_set_id,
override_map: json!({}),
animation_map: published.animation_map,
save_message: if payload.update_character_override == Some(false) {
"基础动作资源已写入 OSS 并绑定当前角色,可直接写回当前自定义世界角色。".to_string()
} else {
"基础动作资源已写入 OSS 并绑定当前角色;Rust 后端不再写本地角色覆盖文件。"
.to_string()
},
},
))
}
pub async fn get_character_workflow_cache(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
AxumPath(character_id): AxumPath<String>,
Query(query): Query<CharacterWorkflowCacheQuery>,
) -> Result<Json<Value>, Response> {
let character_id = normalize_required_text(character_id.as_str(), "");
if character_id.is_empty() {
return Err(character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-workflow-cache",
"message": "characterId is required.",
})),
));
}
let cache_scope_id = trim_optional_text(query.cache_scope_id.as_deref());
let cache = load_workflow_cache(&state, character_id.as_str(), cache_scope_id.as_deref())
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
CharacterWorkflowCacheGetResponse { ok: true, cache },
))
}
pub async fn save_character_workflow_cache(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<CharacterWorkflowCacheSaveRequest>, 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": "character-workflow-cache",
"message": error.body_text(),
})),
)
})?;
let character_id = normalize_required_text(payload.character_id.as_str(), "");
if character_id.is_empty() {
return Err(character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-workflow-cache",
"message": "characterId is required.",
})),
));
}
let cache = normalize_workflow_cache_payload(payload, current_utc_iso_text());
save_workflow_cache(&state, cache.clone())
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
CharacterWorkflowCacheSaveResponse {
ok: true,
cache,
save_message: "角色形象生成缓存已更新到 OSS。".to_string(),
},
))
}
#[cfg(any())]
pub async fn resolve_role_asset_workflow(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
AxumPath(character_id): AxumPath<String>,
payload: Result<Json<CharacterRoleAssetWorkflowResolveRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let character_id = normalize_required_text(character_id.as_str(), "");
if character_id.is_empty() {
return Err(character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "role-asset-workflow",
"message": "characterId is required.",
})),
));
}
let Json(payload) = payload.map_err(|error| {
character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "role-asset-workflow",
"message": error.body_text(),
})),
)
})?;
let cache_scope_id = trim_optional_text(payload.cache_scope_id.as_deref());
let cache =
match load_workflow_cache(&state, character_id.as_str(), cache_scope_id.as_deref()).await {
Ok(cache) => cache,
Err(error) if is_missing_oss_config_error(&error) => None,
Err(error) => return Err(character_animation_error_response(&request_context, error)),
};
let workflow = build_role_asset_workflow(payload.role, cache.as_ref());
Ok(json_success_body(
Some(&request_context),
CharacterRoleAssetWorkflowResponse {
ok: true,
cache,
workflow,
},
))
}
#[cfg(any())]
pub async fn put_role_asset_workflow(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
AxumPath(character_id): AxumPath<String>,
payload: Result<Json<CharacterWorkflowCacheSaveRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let character_id = normalize_required_text(character_id.as_str(), "");
if character_id.is_empty() {
return Err(character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "role-asset-workflow",
"message": "characterId is required.",
})),
));
}
let Json(mut payload) = payload.map_err(|error| {
character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "role-asset-workflow",
"message": error.body_text(),
})),
)
})?;
payload.character_id = character_id;
let cache = normalize_workflow_cache_payload(payload, current_utc_iso_text());
save_workflow_cache(&state, cache.clone())
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
CharacterWorkflowCacheSaveResponse {
ok: true,
cache,
save_message: "角色资产工坊缓存已更新到 OSS。".to_string(),
},
))
}
fn create_animation_task(
state: &AppState,
task_id: &str,
owner_user_id: &str,
character_id: &str,
animation: &str,
strategy: &CharacterAnimationStrategy,
model: &str,
prompt: &str,
) -> Result<AiTaskSnapshot, AppError> {
state
.ai_task_service()
.create_task(AiTaskCreateInput {
task_id: task_id.to_string(),
task_kind: AiTaskKind::CustomWorldGeneration,
owner_user_id: owner_user_id.to_string(),
request_label: "生成角色动作草稿".to_string(),
source_module: "assets.character_animation".to_string(),
source_entity_id: Some(character_id.to_string()),
request_payload_json: Some(
json!({
"characterId": character_id,
"animation": animation,
"strategy": strategy,
"model": model,
"prompt": prompt,
})
.to_string(),
),
stages: AiTaskKind::CustomWorldGeneration.default_stage_blueprints(),
created_at_micros: current_utc_micros(),
})
.map_err(map_ai_task_error)
}
async fn persist_animation_draft_frames(
state: &AppState,
owner_user_id: &str,
character_id: &str,
animation: &str,
task_id: &str,
prompt: &str,
frame_count: u32,
) -> Result<Vec<String>, AppError> {
let mut image_sources = Vec::with_capacity(frame_count as usize);
for index in 0..frame_count {
let file_name = format!("frame-{:02}.svg", index + 1);
let body = build_animation_frame_svg(
animation,
prompt,
index,
frame_count,
DEFAULT_ANIMATION_FRAME_WIDTH,
DEFAULT_ANIMATION_FRAME_HEIGHT,
)
.into_bytes();
let put_result = put_character_animation_object(
state,
LegacyAssetPrefix::CharacterDrafts,
vec![
sanitize_storage_segment(character_id, "character"),
"animation".to_string(),
sanitize_storage_segment(animation, "clip"),
task_id.to_string(),
],
file_name,
"image/svg+xml".to_string(),
body,
build_asset_metadata(
CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
CHARACTER_ANIMATION_ENTITY_KIND,
character_id,
CHARACTER_ANIMATION_DRAFT_SLOT,
animation,
),
)
.await?;
image_sources.push(put_result.legacy_public_path);
}
Ok(image_sources)
}
async fn persist_animation_preview_video(
state: &AppState,
owner_user_id: &str,
character_id: &str,
animation: &str,
task_id: &str,
reference_video_data_url: Option<&str>,
) -> Result<String, AppError> {
let preview_payload = match reference_video_data_url.and_then(parse_video_data_url) {
Some(parsed_video) => MediaPayload {
mime_type: parsed_video.mime_type,
extension: parsed_video.extension,
bytes: parsed_video.bytes,
},
None => {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-animation",
"message": "当前策略需要真实生成视频结果,不再支持回退到仓库占位预览视频。",
})),
);
}
};
let put_result = put_character_animation_object(
state,
LegacyAssetPrefix::CharacterDrafts,
vec![
sanitize_storage_segment(character_id, "character"),
"animation".to_string(),
sanitize_storage_segment(animation, "clip"),
task_id.to_string(),
],
format!("preview.{}", preview_payload.extension),
preview_payload.mime_type,
preview_payload.bytes,
build_asset_metadata(
CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
CHARACTER_ANIMATION_ENTITY_KIND,
character_id,
CHARACTER_ANIMATION_PREVIEW_SLOT,
animation,
),
)
.await?;
Ok(put_result.legacy_public_path)
}
fn require_ark_video_settings(
state: &AppState,
payload: &CharacterAnimationGenerateRequest,
) -> Result<ArkVideoSettings, 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 未配置",
}))
})?;
let requested_model = normalize_required_text(
payload.video_model.as_str(),
state.config.ark_character_video_model.as_str(),
);
Ok(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: requested_model,
})
}
fn build_upstream_http_client(timeout_ms: u64) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "character-animation",
"message": format!("构造上游 HTTP 客户端失败:{error}"),
}))
})
}
async fn resolve_media_source_as_data_url(
state: &AppState,
http_client: &reqwest::Client,
source: &str,
field: &str,
) -> Result<String, AppError> {
let payload = load_media_source_payload(state, source).await?;
if !(payload.mime_type.starts_with("image/") || payload.mime_type.starts_with("video/")) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-animation",
"field": field,
"message": "媒体资源必须是图片或视频。",
})),
);
}
let _ = http_client;
Ok(format!(
"data:{};base64,{}",
payload.mime_type,
encode_base64(payload.bytes.as_slice())
))
}
async fn request_image_to_video_preview(
state: &AppState,
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
owner_user_id: &str,
character_id: &str,
animation: &str,
task_id: &str,
prompt: &str,
fallback_prompt: &str,
first_frame_data_url: &str,
last_frame_data_url: &str,
) -> Result<GeneratedAnimationPreview, AppError> {
let (submitted_prompt, upstream_task_id, moderation_fallback_applied) =
create_ark_image_to_video_task(
http_client,
settings,
prompt,
fallback_prompt,
first_frame_data_url,
last_frame_data_url,
)
.await?;
let video_url =
wait_for_ark_content_generation_task(http_client, settings, 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,
character_id,
animation,
task_id,
preview_payload,
)
.await?;
Ok(GeneratedAnimationPreview {
preview_video_path,
object_key: None,
asset_object_id: None,
thumbnail_src: None,
upstream_task_id,
submitted_prompt,
moderation_fallback_applied,
})
}
async fn create_ark_image_to_video_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
prompt: &str,
fallback_prompt: &str,
first_frame_data_url: &str,
last_frame_data_url: &str,
) -> Result<(String, String, bool), AppError> {
let first_try = send_ark_image_to_video_request(
http_client,
settings,
prompt,
first_frame_data_url,
last_frame_data_url,
)
.await?;
if first_try.status().is_success() {
let body = first_try.text().await.map_err(|error| {
map_character_animation_upstream_error(format!("读取 Ark 视频任务响应失败:{error}"))
})?;
let payload = parse_animation_json_payload(body.as_str(), "创建图生视频任务失败。")?;
let task_id = extract_animation_task_id(&payload.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "图生视频任务未返回任务 id。",
}))
})?;
return Ok((prompt.to_string(), task_id, false));
}
let error_text = first_try.text().await.map_err(|error| {
map_character_animation_upstream_error(format!("读取 Ark 视频错误响应失败:{error}"))
})?;
if fallback_prompt.trim().is_empty()
|| fallback_prompt.trim() == prompt.trim()
|| !is_inappropriate_content_message(error_text.as_str())
{
return Err(parse_animation_upstream_error(
error_text.as_str(),
"创建图生视频任务失败。",
));
}
let second_try = send_ark_image_to_video_request(
http_client,
settings,
fallback_prompt,
first_frame_data_url,
last_frame_data_url,
)
.await?;
let second_status = second_try.status();
let second_text = second_try.text().await.map_err(|error| {
map_character_animation_upstream_error(format!("读取 Ark 视频重试响应失败:{error}"))
})?;
if !second_status.is_success() {
return Err(parse_animation_upstream_error(
second_text.as_str(),
"创建图生视频任务失败。",
));
}
let second_payload =
parse_animation_json_payload(second_text.as_str(), "创建图生视频任务失败。")?;
let task_id = extract_animation_task_id(&second_payload.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "图生视频任务未返回任务 id。",
}))
})?;
Ok((fallback_prompt.to_string(), task_id, true))
}
async fn send_ark_image_to_video_request(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
prompt: &str,
first_frame_data_url: &str,
last_frame_data_url: &str,
) -> Result<reqwest::Response, AppError> {
http_client
.post(format!("{}/contents/generations/tasks", settings.base_url))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": settings.model,
"content": [
{
"type": "text",
"text": prompt,
},
{
"type": "image_url",
"image_url": {
"url": first_frame_data_url,
},
"role": "first_frame",
},
{
"type": "image_url",
"image_url": {
"url": last_frame_data_url,
},
"role": "last_frame",
}
],
"resolution": FIXED_ARK_CHARACTER_VIDEO_RESOLUTION,
"ratio": FIXED_ARK_CHARACTER_VIDEO_RATIO,
"duration": FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS,
"watermark": false,
}))
.send()
.await
.map_err(|error| {
map_character_animation_upstream_error(format!("请求 Ark 视频服务失败:{error}"))
})
}
async fn wait_for_ark_content_generation_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
task_id: &str,
) -> Result<String, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let response = http_client
.get(format!(
"{}/contents/generations/tasks/{}",
settings.base_url, task_id
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_character_animation_upstream_error(format!("查询 Ark 视频任务失败:{error}"))
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_character_animation_upstream_error(format!("读取 Ark 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_animation_upstream_error(
text.as_str(),
"查询视频生成任务失败。",
));
}
let payload = parse_animation_json_payload(text.as_str(), "查询视频生成任务失败。")?;
let normalized_status = normalize_generation_task_status(
extract_generation_task_status(&payload.payload).as_str(),
);
if let Some(video_url) = extract_video_url(&payload.payload) {
return Ok(video_url);
}
if is_completed_generation_task_status(normalized_status.as_str()) {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "视频任务完成但没有返回 video_url。",
"taskId": task_id,
})),
);
}
if is_failed_generation_task_status(normalized_status.as_str()) {
return Err(parse_animation_upstream_error(
text.as_str(),
"视频生成任务执行失败。",
));
}
sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "视频生成任务执行超时,请稍后重试。",
"taskId": task_id,
})),
)
}
async fn download_generated_video(
http_client: &reqwest::Client,
video_url: &str,
fallback_message: &str,
) -> Result<MediaPayload, AppError> {
let response = http_client.get(video_url).send().await.map_err(|error| {
map_character_animation_upstream_error(format!("{fallback_message}:{error}"))
})?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("video/mp4")
.to_string();
let bytes = response.bytes().await.map_err(|error| {
map_character_animation_upstream_error(format!("{fallback_message}:{error}"))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": fallback_message,
"status": status.as_u16(),
})),
);
}
Ok(MediaPayload {
mime_type: content_type.clone(),
extension: mime_to_extension(content_type.as_str()).to_string(),
bytes: bytes.to_vec(),
})
}
async fn put_generated_preview_video(
state: &AppState,
owner_user_id: &str,
character_id: &str,
animation: &str,
task_id: &str,
preview_payload: MediaPayload,
) -> Result<String, AppError> {
let put_result = put_character_animation_object(
state,
LegacyAssetPrefix::CharacterDrafts,
vec![
sanitize_storage_segment(character_id, "character"),
"animation".to_string(),
sanitize_storage_segment(animation, "clip"),
task_id.to_string(),
],
format!("preview.{}", preview_payload.extension),
preview_payload.mime_type,
preview_payload.bytes,
build_asset_metadata(
CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
CHARACTER_ANIMATION_ENTITY_KIND,
character_id,
CHARACTER_ANIMATION_PREVIEW_SLOT,
animation,
),
)
.await?;
Ok(put_result.legacy_public_path)
}
async fn put_generated_editor_character_animation_preview(
state: &AppState,
owner_user_id: &str,
source_layer_id: &str,
task_id: &str,
preview_payload: MediaPayload,
) -> Result<PersistedEditorCharacterAnimationPreview, AppError> {
let content_type = preview_payload.mime_type.clone();
let put_result = put_character_animation_object(
state,
LegacyAssetPrefix::CharacterDrafts,
vec![
sanitize_storage_segment(source_layer_id, "layer"),
"animation".to_string(),
"editor-character-animation".to_string(),
task_id.to_string(),
],
format!("preview.{}", preview_payload.extension),
preview_payload.mime_type,
preview_payload.bytes,
build_asset_metadata(
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
"editor_layer",
source_layer_id,
EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT,
"editor-character-animation",
),
)
.await?;
let confirmed = confirm_editor_character_animation_source_asset_object(
state,
owner_user_id,
source_layer_id,
task_id,
put_result.object_key.clone(),
content_type,
)
.await?;
Ok(PersistedEditorCharacterAnimationPreview {
legacy_public_path: put_result.legacy_public_path,
object_key: put_result.object_key,
asset_object_id: confirmed.record.asset_object_id,
})
}
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 persisted = put_generated_editor_character_animation_preview(
state,
owner_user_id,
source_layer_id,
task_id,
preview_payload,
)
.await?;
Ok(GeneratedAnimationPreview {
preview_video_path: persisted.legacy_public_path,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
thumbnail_src: None,
upstream_task_id,
submitted_prompt: prompt.to_string(),
moderation_fallback_applied: false,
})
}
async fn request_editor_video_preview(
state: &AppState,
http_client: &reqwest::Client,
settings: &EditorVideoSettings,
owner_user_id: &str,
task_id: &str,
request: &NormalizedEditorVideoRequest,
) -> Result<GeneratedAnimationPreview, AppError> {
let upstream_task_id =
create_editor_text_to_video_task(state, http_client, settings, owner_user_id, request)
.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 persisted =
put_generated_editor_video(state, owner_user_id, task_id, request, preview_payload).await?;
Ok(GeneratedAnimationPreview {
preview_video_path: persisted.legacy_public_path,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
thumbnail_src: persisted.thumbnail_src,
upstream_task_id,
submitted_prompt: request.prompt.clone(),
moderation_fallback_applied: false,
})
}
async fn create_editor_text_to_video_task(
state: &AppState,
http_client: &reqwest::Client,
settings: &EditorVideoSettings,
owner_user_id: &str,
request: &NormalizedEditorVideoRequest,
) -> Result<String, AppError> {
let request_body = build_editor_video_ark_request_body(state, owner_user_id, request).await?;
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(&request_body)
.send()
.await
.map_err(|error| {
map_character_animation_upstream_error(format!("请求画板视频服务失败:{error}"))
})?;
let status = response.status();
let body = response.text().await.map_err(|error| {
map_character_animation_upstream_error(format!("读取画板视频任务响应失败:{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": "editor-video",
"message": "画板视频任务未返回任务 id。",
}))
})
}
async fn put_generated_editor_video(
state: &AppState,
owner_user_id: &str,
task_id: &str,
request: &NormalizedEditorVideoRequest,
preview_payload: MediaPayload,
) -> Result<PersistedEditorVideo, AppError> {
let content_type = preview_payload.mime_type.clone();
let put_result = put_character_animation_object(
state,
LegacyAssetPrefix::CharacterDrafts,
vec![
"editor-videos".to_string(),
sanitize_storage_segment(request.model.as_str(), "model"),
task_id.to_string(),
],
format!("preview.{}", preview_payload.extension),
preview_payload.mime_type.clone(),
preview_payload.bytes.clone(),
build_asset_metadata(
EDITOR_VIDEO_ASSET_KIND,
owner_user_id,
EDITOR_VIDEO_ENTITY_KIND,
task_id,
EDITOR_VIDEO_SLOT,
request.model.as_str(),
),
)
.await?;
let confirmed = confirm_editor_video_asset_object(
state,
owner_user_id,
task_id,
put_result.object_key.clone(),
content_type,
)
.await?;
let thumbnail_src = match put_generated_editor_video_cover(
state,
owner_user_id,
task_id,
request,
&preview_payload,
)
.await
{
Ok(thumbnail_src) => Some(thumbnail_src),
Err(error) => {
tracing::warn!(
provider = "editor-video",
operation = "persist_cover",
task_id = %task_id,
error = %error,
"画板视频主文件已持久化,但首帧封面生成失败"
);
None
}
};
Ok(PersistedEditorVideo {
legacy_public_path: put_result.legacy_public_path,
object_key: put_result.object_key,
asset_object_id: confirmed.record.asset_object_id,
thumbnail_src,
})
}
async fn put_generated_editor_video_cover(
state: &AppState,
owner_user_id: &str,
task_id: &str,
request: &NormalizedEditorVideoRequest,
preview_payload: &MediaPayload,
) -> Result<String, AppError> {
let temp_dir = create_animation_temp_dir()?;
let input_path = temp_dir.join(format!("preview.{}", preview_payload.extension));
let output_path = temp_dir.join("cover.png");
fs::write(&input_path, &preview_payload.bytes).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-video",
"message": format!("写入画板视频封面临时视频失败:{error}"),
}))
})?;
let extraction_result = (|| {
let settings = resolve_backend_frame_extraction_settings(state);
extract_video_frame_to_png(&input_path, &output_path, 0.0, &settings)?;
let cover_bytes = fs::read(&output_path).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-video",
"message": format!("读取画板视频首帧封面失败:{error}"),
}))
})?;
Ok::<_, AppError>(cover_bytes)
})();
let _ = fs::remove_dir_all(&temp_dir);
let cover_bytes = extraction_result?;
let put_result = put_character_animation_object(
state,
LegacyAssetPrefix::CharacterDrafts,
vec![
"editor-videos".to_string(),
sanitize_storage_segment(request.model.as_str(), "model"),
task_id.to_string(),
],
"cover.png".to_string(),
"image/png".to_string(),
cover_bytes,
build_asset_metadata(
EDITOR_VIDEO_ASSET_KIND,
owner_user_id,
EDITOR_VIDEO_ENTITY_KIND,
task_id,
EDITOR_VIDEO_COVER_SLOT,
request.model.as_str(),
),
)
.await?;
Ok(put_result.legacy_public_path)
}
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,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<PersistedEditorCharacterAnimationFrameSet, AppError> {
let plan = AnimationFrameExtractionPlan {
frame_count: request.frame_count,
apply_chroma_key: false,
prepare_for_bgfilter_input: 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,
Some(request.duration_seconds as f64),
)
.await?;
use futures_util::StreamExt as _;
let frame_count = finalized_frames.len();
let frame_results = futures_util::stream::iter(finalized_frames.into_iter().enumerate().map(
|(frame_index, frame)| async move {
process_and_persist_editor_character_animation_frame(
state,
owner_user_id,
source_layer_id,
task_id,
frame_index,
frame,
request.frame_width,
request.frame_height,
request.screen_color,
audit,
)
.await
.map_err(|error| (frame_index, error))
},
))
// 中文注释:动作帧上限固定为 48。全部帧连续进入在途集合,由唯一内部
// bgfilter-worker 的 admission 与 provider semaphore 统一排队和限流;父流程不因返回乱序产生队头阻塞。
.buffer_unordered(frame_count.max(1))
// 中文注释:不能 try_collect 提前取消。已经发出的请求必须全部排空,避免服务端完成计算后无人接收。
.collect::<Vec<_>>()
.await;
let mut frame_payloads = Vec::with_capacity(frame_count);
let mut frame_errors = Vec::new();
for frame_result in frame_results {
match frame_result {
Ok(frame_payload) => frame_payloads.push(frame_payload),
Err(frame_error) => frame_errors.push(frame_error),
}
}
frame_payloads.sort_by_key(|frame| frame.payload.frame_index);
frame_errors.sort_by_key(|(frame_index, _)| *frame_index);
if let Some((frame_index, error)) = frame_errors.into_iter().next() {
tracing::warn!(
frame_index = frame_index + 1,
completed_frames = frame_payloads.len(),
expected_frames = frame_count,
error = %error,
"editor_animation_frame_pipeline_failed_after_drain"
);
return Err(error);
}
let (first_frame_object_key, first_frame_asset_object_id) = frame_payloads
.first()
.map(|frame| (frame.object_key.clone(), frame.asset_object_id.clone()))
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-character-animation",
"message": "角色动作抽帧完成但没有生成首帧对象。",
}))
})?;
Ok(PersistedEditorCharacterAnimationFrameSet {
first_frame_object_key,
first_frame_asset_object_id,
frames: frame_payloads
.into_iter()
.map(|frame| frame.payload)
.collect(),
})
}
async fn process_and_persist_editor_character_animation_frame(
state: &AppState,
owner_user_id: &str,
source_layer_id: &str,
task_id: &str,
frame_index: usize,
frame: FinalizedAnimationFrame,
frame_width: u32,
frame_height: u32,
screen_color: EditorScreenBackgroundColor,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<ProcessedEditorCharacterAnimationFrame, AppError> {
// 中文注释:每一帧只要求自己的绿幕源图先落 OSS,不再等待整批源图全部上传完成。
let source_put = put_character_animation_frame_object(
state,
frame_index + 1,
"source_put",
LegacyAssetPrefix::Animations,
vec![
"editor".to_string(),
sanitize_storage_segment(source_layer_id, "layer"),
task_id.to_string(),
],
format!(
"green-screen-frame{:02}.{}",
frame_index + 1,
frame.extension
),
frame.mime_type.clone(),
frame.bytes,
build_asset_metadata(
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
"editor_layer",
source_layer_id,
EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT,
"editor-character-animation",
),
)
.await?;
// 排队与调用预算由共享 helper 按 N/est 公式和父剩余预算派生,动画帧不再携带
// 任何按帧数放大的 timeout。
let removed = remove_editor_generated_screen_background_with_bgfilter(
state,
source_put.object_key.as_str(),
screen_color,
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
audit,
)
.await?;
tracing::debug!(
frame_index,
screen_color = screen_color.hex,
"editor_animation_frame_background_removed"
);
let finalized = finalize_animation_frame_payload(
removed.image.bytes.as_slice(),
removed.image.mime_type.as_str(),
frame_width,
frame_height,
false,
)?;
let content_type = finalized.mime_type.clone();
let put_result = put_character_animation_frame_object(
state,
frame_index + 1,
"final_put",
LegacyAssetPrefix::Animations,
vec![
"editor".to_string(),
sanitize_storage_segment(source_layer_id, "layer"),
task_id.to_string(),
],
format!("frame{:02}.{}", frame_index + 1, finalized.extension),
finalized.mime_type,
finalized.bytes,
build_asset_metadata(
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
"editor_layer",
source_layer_id,
"animation_frame",
"editor-character-animation",
),
)
.await?;
let confirmed = confirm_editor_character_animation_frame_asset_object(
state,
owner_user_id,
source_layer_id,
task_id,
put_result.object_key.clone(),
content_type,
frame_index + 1,
)
.await?;
Ok(ProcessedEditorCharacterAnimationFrame {
payload: EditorCharacterAnimationFramePayload {
frame_index: frame_index as u32 + 1,
image_src: put_result.legacy_public_path,
width: frame_width,
height: frame_height,
},
object_key: put_result.object_key,
asset_object_id: confirmed.record.asset_object_id,
})
}
async fn publish_animation_set(
state: &AppState,
owner_user_id: &str,
character_id: &str,
visual_asset_id: &str,
animation_set_id: &str,
animations: BTreeMap<String, CharacterAnimationDraftPayload>,
) -> Result<PublishedAnimationSet, AppError> {
let mut action_manifests = Vec::new();
let mut animation_map = serde_json::Map::new();
let extraction_settings = resolve_backend_frame_extraction_settings(state);
for (action, draft) in animations {
let published = publish_single_animation_action(
state,
owner_user_id,
character_id,
visual_asset_id,
animation_set_id,
action.as_str(),
draft,
&extraction_settings,
)
.await?;
animation_map.insert(action, published.animation_config);
action_manifests.push(published.manifest);
}
if action_manifests.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-animation",
"message": "animations 至少需要包含一个有效动作帧。",
})),
);
}
let manifest_body = serde_json::to_vec_pretty(&json!({
"animationSetId": animation_set_id,
"characterId": character_id,
"visualAssetId": visual_asset_id,
"actions": action_manifests,
"animationMap": animation_map.clone(),
}))
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "character-animation",
"message": format!("序列化角色动作总 manifest 失败:{error}"),
}))
})?;
let put_result = put_character_animation_object(
state,
LegacyAssetPrefix::Animations,
vec![
sanitize_storage_segment(character_id, "character"),
animation_set_id.to_string(),
],
"manifest.json".to_string(),
"application/json; charset=utf-8".to_string(),
manifest_body,
build_asset_metadata(
CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
CHARACTER_ANIMATION_ENTITY_KIND,
character_id,
CHARACTER_ANIMATION_SLOT,
"animation-set",
),
)
.await?;
let confirmed = confirm_character_animation_asset_object(
state,
owner_user_id,
character_id,
animation_set_id,
put_result.object_key,
"application/json; charset=utf-8".to_string(),
)
.await?;
bind_character_animation_asset(
state,
owner_user_id,
character_id,
confirmed.record.asset_object_id,
)
.await?;
Ok(PublishedAnimationSet {
animation_map: Value::Object(animation_map),
})
}
async fn publish_single_animation_action(
state: &AppState,
owner_user_id: &str,
character_id: &str,
visual_asset_id: &str,
animation_set_id: &str,
action: &str,
draft: CharacterAnimationDraftPayload,
extraction_settings: &BackendFrameExtractionSettings,
) -> Result<PublishedAnimationAction, AppError> {
let action_segment = sanitize_storage_segment(action, "clip");
let frame_width = normalize_dimension(draft.frame_width, DEFAULT_ANIMATION_FRAME_WIDTH);
let frame_height = normalize_dimension(draft.frame_height, DEFAULT_ANIMATION_FRAME_HEIGHT);
let fps = draft.fps.clamp(1, 60);
let preview_video_path = draft.preview_video_path.clone();
let loop_ = draft.loop_;
let frame_plan = normalize_animation_frame_extraction_plan(&draft);
let finalized_frames = if draft.frames_data_urls.is_empty() {
let preview_video_path = preview_video_path.as_deref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-animation",
"message": format!("动作 {action} 缺少 framesDataUrls 与 previewVideoPath,无法发布。"),
}))
})?;
extract_animation_frames_from_preview_video(
state,
preview_video_path,
frame_width,
frame_height,
extraction_settings,
&frame_plan,
None,
)
.await?
} else {
let mut frames = Vec::with_capacity(draft.frames_data_urls.len());
for frame_source in &draft.frames_data_urls {
let frame_payload = load_media_source_payload(state, frame_source.as_str()).await?;
let finalized = finalize_animation_frame_payload(
frame_payload.bytes.as_slice(),
frame_payload.mime_type.as_str(),
frame_width,
frame_height,
frame_plan.apply_chroma_key,
)?;
frames.push(finalized);
}
frames
};
let mut frame_paths = Vec::with_capacity(finalized_frames.len());
let mut frame_extension = "png".to_string();
for (index, frame_payload) in finalized_frames.iter().enumerate() {
frame_extension = frame_payload.extension.clone();
let frame_file_name = format!("frame{:02}.{}", index + 1, frame_payload.extension);
let put_result = put_character_animation_object(
state,
LegacyAssetPrefix::Animations,
vec![
sanitize_storage_segment(character_id, "character"),
animation_set_id.to_string(),
action_segment.clone(),
],
frame_file_name,
frame_payload.mime_type.clone(),
frame_payload.bytes.clone(),
build_asset_metadata(
CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
CHARACTER_ANIMATION_ENTITY_KIND,
character_id,
CHARACTER_ANIMATION_SLOT,
action,
),
)
.await?;
frame_paths.push(put_result.legacy_public_path);
}
let base_path = format!(
"/generated-animations/{}/{}/{}",
sanitize_storage_segment(character_id, "character"),
animation_set_id,
action_segment
);
let manifest = json!({
"id": format!("{animation_set_id}-{action_segment}"),
"animationSetId": animation_set_id,
"characterId": character_id,
"visualAssetId": visual_asset_id,
"action": action,
"frameCount": frame_paths.len(),
"fps": fps,
"loop": loop_,
"frameWidth": frame_width,
"frameHeight": frame_height,
"previewVideoPath": preview_video_path,
"framePaths": frame_paths,
});
let manifest_body = serde_json::to_vec_pretty(&manifest).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "character-animation",
"message": format!("序列化角色动作 manifest 失败:{error}"),
}))
})?;
put_character_animation_object(
state,
LegacyAssetPrefix::Animations,
vec![
sanitize_storage_segment(character_id, "character"),
animation_set_id.to_string(),
action_segment,
],
"manifest.json".to_string(),
"application/json; charset=utf-8".to_string(),
manifest_body,
build_asset_metadata(
CHARACTER_ANIMATION_ASSET_KIND,
owner_user_id,
CHARACTER_ANIMATION_ENTITY_KIND,
character_id,
CHARACTER_ANIMATION_SLOT,
action,
),
)
.await?;
let mut animation_config = serde_json::Map::from_iter([
("folder".to_string(), json!(action)),
("prefix".to_string(), json!("frame")),
("frames".to_string(), json!(frame_paths.len())),
("startFrame".to_string(), json!(1)),
("extension".to_string(), json!(frame_extension)),
("basePath".to_string(), json!(base_path)),
("frameWidth".to_string(), json!(frame_width)),
("frameHeight".to_string(), json!(frame_height)),
("fps".to_string(), json!(fps)),
("loop".to_string(), json!(loop_)),
]);
if let Some(preview_video_path) = preview_video_path {
animation_config.insert("previewVideoPath".to_string(), json!(preview_video_path));
}
Ok(PublishedAnimationAction {
manifest,
animation_config: Value::Object(animation_config),
})
}
async fn put_character_animation_frame_object(
state: &AppState,
frame_index: usize,
operation: &'static str,
prefix: LegacyAssetPrefix,
path_segments: Vec<String>,
file_name: String,
content_type: String,
body: Vec<u8>,
metadata: BTreeMap<String, String>,
) -> Result<platform_oss::OssPutObjectResponse, AppError> {
require_oss_client(state)?
.put_object_with_retry(
state.character_animation_oss_http_client(),
OssPutObjectRequest {
prefix,
path_segments,
file_name,
content_type: Some(content_type),
access: OssObjectAccess::Private,
metadata,
body,
},
state.character_animation_oss_io_limiter(),
OssRequestAttemptContext {
frame_index,
operation,
},
)
.await
.map_err(map_character_animation_oss_error)
}
async fn put_character_animation_object(
state: &AppState,
prefix: LegacyAssetPrefix,
path_segments: Vec<String>,
file_name: String,
content_type: String,
body: Vec<u8>,
metadata: BTreeMap<String, String>,
) -> Result<platform_oss::OssPutObjectResponse, AppError> {
require_oss_client(state)?
.put_object(
&reqwest::Client::new(),
OssPutObjectRequest {
prefix,
path_segments,
file_name,
content_type: Some(content_type),
access: OssObjectAccess::Private,
metadata,
body,
},
)
.await
.map_err(map_character_animation_oss_error)
}
async fn confirm_character_animation_asset_object(
state: &AppState,
owner_user_id: &str,
character_id: &str,
source_job_id: &str,
object_key: String,
content_type: String,
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
let oss_client = require_oss_client(state)?;
let head = oss_client
.head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key })
.await
.map_err(map_character_animation_oss_error)?;
let now_micros = current_utc_micros();
let record = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(content_type)),
head.content_length,
head.etag,
CHARACTER_ANIMATION_ASSET_KIND.to_string(),
Some(source_job_id.to_string()),
Some(owner_user_id.to_string()),
None,
Some(character_id.to_string()),
now_micros,
)
.map_err(map_asset_object_prepare_error)?,
)
.await
.map_err(map_character_animation_spacetime_error)?;
Ok(module_assets::ConfirmAssetObjectResult { record })
}
async fn confirm_editor_video_asset_object(
state: &AppState,
owner_user_id: &str,
task_id: &str,
object_key: String,
content_type: String,
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
let oss_client = require_oss_client(state)?;
let head = oss_client
.head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key })
.await
.map_err(map_character_animation_oss_error)?;
let now_micros = current_utc_micros();
let record = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(content_type)),
head.content_length,
head.etag,
EDITOR_VIDEO_ASSET_KIND.to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
None,
Some(task_id.to_string()),
now_micros,
)
.map_err(map_asset_object_prepare_error)?,
)
.await
.map_err(map_character_animation_spacetime_error)?;
Ok(module_assets::ConfirmAssetObjectResult { record })
}
async fn confirm_editor_character_animation_source_asset_object(
state: &AppState,
owner_user_id: &str,
source_layer_id: &str,
task_id: &str,
object_key: String,
content_type: String,
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
confirm_editor_character_animation_asset_object(
state,
owner_user_id,
source_layer_id,
task_id,
object_key,
content_type,
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
)
.await
}
async fn confirm_editor_character_animation_frame_asset_object(
state: &AppState,
owner_user_id: &str,
source_layer_id: &str,
task_id: &str,
object_key: String,
content_type: String,
frame_index: usize,
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
let oss_client = require_oss_client(state)?;
let head = oss_client
.head_object_with_retry(
state.character_animation_oss_http_client(),
OssHeadObjectRequest { object_key },
state.character_animation_oss_io_limiter(),
OssRequestAttemptContext {
frame_index,
operation: "final_head",
},
)
.await
.map_err(map_character_animation_oss_error)?;
confirm_editor_character_animation_asset_object_from_head(
state,
owner_user_id,
source_layer_id,
task_id,
head,
content_type,
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
)
.await
}
async fn confirm_editor_character_animation_asset_object(
state: &AppState,
owner_user_id: &str,
source_layer_id: &str,
task_id: &str,
object_key: String,
content_type: String,
asset_kind: &str,
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
let oss_client = require_oss_client(state)?;
let head = oss_client
.head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key })
.await
.map_err(map_character_animation_oss_error)?;
confirm_editor_character_animation_asset_object_from_head(
state,
owner_user_id,
source_layer_id,
task_id,
head,
content_type,
asset_kind,
)
.await
}
async fn confirm_editor_character_animation_asset_object_from_head(
state: &AppState,
owner_user_id: &str,
source_layer_id: &str,
task_id: &str,
head: platform_oss::OssHeadObjectResponse,
content_type: String,
asset_kind: &str,
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
let now_micros = current_utc_micros();
let record = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(content_type)),
head.content_length,
head.etag,
asset_kind.to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
None,
Some(source_layer_id.to_string()),
now_micros,
)
.map_err(map_asset_object_prepare_error)?,
)
.await
.map_err(map_character_animation_spacetime_error)?;
Ok(module_assets::ConfirmAssetObjectResult { record })
}
async fn bind_character_animation_asset(
state: &AppState,
owner_user_id: &str,
character_id: &str,
asset_object_id: String,
) -> Result<(), AppError> {
let now_micros = current_utc_micros();
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object_id,
CHARACTER_ANIMATION_ENTITY_KIND.to_string(),
character_id.to_string(),
CHARACTER_ANIMATION_SLOT.to_string(),
CHARACTER_ANIMATION_ASSET_KIND.to_string(),
Some(owner_user_id.to_string()),
None,
now_micros,
)
.map_err(map_asset_binding_prepare_error)?,
)
.await
.map_err(map_character_animation_spacetime_error)?;
Ok(())
}
async fn put_imported_video_object(
state: &AppState,
owner_user_id: &str,
character_id: &str,
animation: &str,
draft_id: &str,
source_label: &str,
parsed_video: ParsedVideoDataUrl,
) -> Result<platform_oss::OssPutObjectResponse, AppError> {
let oss_client = require_oss_client(state)?;
let file_name = format!(
"{}.{}",
sanitize_storage_segment(source_label, "imported-video"),
parsed_video.extension
);
oss_client
.put_object(
&reqwest::Client::new(),
OssPutObjectRequest {
prefix: LegacyAssetPrefix::CharacterDrafts,
path_segments: vec![
sanitize_storage_segment(character_id, "character"),
"animation".to_string(),
sanitize_storage_segment(animation, "clip"),
draft_id.to_string(),
],
file_name,
content_type: Some(parsed_video.mime_type),
access: OssObjectAccess::Private,
metadata: build_asset_metadata(
CHARACTER_ANIMATION_REFERENCE_ASSET_KIND,
owner_user_id,
CHARACTER_ANIMATION_ENTITY_KIND,
character_id,
CHARACTER_ANIMATION_REFERENCE_SLOT,
animation,
),
body: parsed_video.bytes,
},
)
.await
.map_err(map_character_animation_oss_error)
}
async fn load_workflow_cache(
state: &AppState,
character_id: &str,
cache_scope_id: Option<&str>,
) -> Result<Option<CharacterWorkflowCachePayload>, AppError> {
let oss_client = require_oss_client(state)?;
let object_key = workflow_cache_object_key(character_id, cache_scope_id);
let signed = match oss_client.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key,
expire_seconds: Some(60),
}) {
Ok(signed) => signed,
Err(error) if error.kind() == platform_oss::OssErrorKind::ObjectNotFound => {
return Ok(None);
}
Err(error) => return Err(map_character_animation_oss_error(error)),
};
let response = reqwest::Client::new()
.get(signed.signed_url)
.send()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取角色工作流缓存失败:{error}"),
}))
})?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let response = response.error_for_status().map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取角色工作流缓存失败:{error}"),
}))
})?;
let cache = response
.json::<CharacterWorkflowCachePayload>()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("解析角色工作流缓存失败:{error}"),
}))
})?;
if cache.character_id == character_id && cache.cache_scope_id.as_deref() == cache_scope_id {
Ok(Some(cache))
} else {
Ok(None)
}
}
async fn save_workflow_cache(
state: &AppState,
cache: CharacterWorkflowCachePayload,
) -> Result<(), AppError> {
let oss_client = require_oss_client(state)?;
let body = serde_json::to_vec_pretty(&cache).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "character-workflow-cache",
"message": format!("序列化角色工作流缓存失败:{error}"),
}))
})?;
oss_client
.put_object(
&reqwest::Client::new(),
OssPutObjectRequest {
prefix: LegacyAssetPrefix::CharacterDrafts,
path_segments: workflow_cache_path_segments(&cache),
file_name: "workflow-cache.json".to_string(),
content_type: Some("application/json; charset=utf-8".to_string()),
access: OssObjectAccess::Private,
metadata: build_workflow_cache_metadata(
"asset-tool",
cache.character_id.as_str(),
cache.cache_scope_id.as_deref(),
),
body,
},
)
.await
.map_err(map_character_animation_oss_error)?;
Ok(())
}
fn normalize_workflow_cache_payload(
payload: CharacterWorkflowCacheSaveRequest,
updated_at: String,
) -> CharacterWorkflowCachePayload {
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let cache_scope_id = trim_optional_text(payload.cache_scope_id.as_deref());
CharacterWorkflowCachePayload {
character_id: character_id.clone(),
cache_scope_id,
visual_prompt_text: clamp_prompt_seed_text(payload.visual_prompt_text.as_deref()),
animation_prompt_text: clamp_prompt_seed_text(payload.animation_prompt_text.as_deref()),
animation_prompt_text_by_key: normalize_animation_prompt_text_by_key(
payload.animation_prompt_text_by_key,
),
visual_drafts: normalize_visual_drafts(character_id.as_str(), payload.visual_drafts),
selected_visual_draft_id: trim_optional_text(payload.selected_visual_draft_id.as_deref())
.unwrap_or_default(),
selected_animation: trim_optional_text(payload.selected_animation.as_deref())
.unwrap_or_else(|| "idle".to_string()),
image_src: trim_optional_text(payload.image_src.as_deref()),
generated_visual_asset_id: trim_optional_text(payload.generated_visual_asset_id.as_deref()),
generated_animation_set_id: trim_optional_text(
payload.generated_animation_set_id.as_deref(),
),
animation_map: payload.animation_map.filter(Value::is_object),
updated_at: Some(updated_at),
}
}
fn normalize_visual_drafts(
character_id: &str,
visual_drafts: Vec<CharacterVisualDraftPayload>,
) -> Vec<CharacterVisualDraftPayload> {
visual_drafts
.into_iter()
.enumerate()
.filter_map(|(index, draft)| {
let image_src = trim_optional_text(Some(draft.image_src.as_str()))?;
Some(CharacterVisualDraftPayload {
id: trim_optional_text(Some(draft.id.as_str()))
.unwrap_or_else(|| format!("{character_id}-draft-{}", index + 1)),
label: trim_optional_text(Some(draft.label.as_str()))
.unwrap_or_else(|| format!("候选 {}", index + 1)),
image_src,
width: if draft.width == 0 { 1024 } else { draft.width },
height: if draft.height == 0 {
1536
} else {
draft.height
},
})
})
.collect()
}
fn workflow_cache_path_segments(cache: &CharacterWorkflowCachePayload) -> Vec<String> {
let character_segment = sanitize_storage_segment(cache.character_id.as_str(), "character");
if let Some(cache_scope_id) = cache.cache_scope_id.as_deref() {
vec![
sanitize_storage_segment(cache_scope_id, "world"),
character_segment,
"workflow-cache".to_string(),
]
} else {
vec![character_segment, "workflow-cache".to_string()]
}
}
fn workflow_cache_object_key(character_id: &str, cache_scope_id: Option<&str>) -> String {
if let Some(cache_scope_id) = cache_scope_id.and_then(|value| trim_optional_text(Some(value))) {
format!(
"generated-character-drafts/{}/{}/workflow-cache/workflow-cache.json",
sanitize_storage_segment(cache_scope_id.as_str(), "world"),
sanitize_storage_segment(character_id, "character")
)
} else {
format!(
"generated-character-drafts/{}/workflow-cache/workflow-cache.json",
sanitize_storage_segment(character_id, "character")
)
}
}
fn build_character_animation_job_payload(task: AiTaskSnapshot) -> CharacterAssetJobStatusPayload {
let request_payload = task
.request_payload_json
.as_deref()
.and_then(|value| serde_json::from_str::<Value>(value).ok())
.unwrap_or_else(|| json!({}));
let result = task
.latest_structured_payload_json
.as_deref()
.and_then(|value| serde_json::from_str::<Value>(value).ok());
CharacterAssetJobStatusPayload {
task_id: task.task_id,
kind: "animation".to_string(),
status: match task.status {
AiTaskStatus::Pending => CharacterAssetJobStatusText::Queued,
AiTaskStatus::Running => CharacterAssetJobStatusText::Running,
AiTaskStatus::Completed => CharacterAssetJobStatusText::Completed,
AiTaskStatus::Failed | AiTaskStatus::Cancelled => CharacterAssetJobStatusText::Failed,
},
character_id: request_payload
.get("characterId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
animation: request_payload
.get("animation")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
strategy: request_payload
.get("strategy")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
model: request_payload
.get("model")
.and_then(Value::as_str)
.unwrap_or(CHARACTER_ANIMATION_MODEL)
.to_string(),
prompt: request_payload
.get("prompt")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
created_at: format_utc_micros(task.created_at_micros),
updated_at: format_utc_micros(task.updated_at_micros),
result,
error_message: task.failure_message,
}
}
pub(crate) fn find_motion_template(id: &str) -> Option<&'static MotionTemplate> {
BUILT_IN_MOTION_TEMPLATES
.iter()
.find(|template| template.id == id.trim())
}
fn resolve_character_animation_model(payload: &CharacterAnimationGenerateRequest) -> String {
let candidate = match payload.strategy {
CharacterAnimationStrategy::ImageSequence => payload.image_sequence_model.as_str(),
CharacterAnimationStrategy::ImageToVideo => payload.video_model.as_str(),
CharacterAnimationStrategy::MotionTransfer => payload.motion_transfer_model.as_str(),
CharacterAnimationStrategy::ReferenceToVideo => payload.reference_video_model.as_str(),
};
normalize_required_text(candidate, CHARACTER_ANIMATION_MODEL)
}
#[cfg(test)]
fn normalize_editor_character_animation_request(
payload: EditorCharacterAnimationGenerateRequest,
) -> Result<NormalizedEditorCharacterAnimationRequest, AppError> {
let pricing = crate::editor_generation_config::load_editor_generation_pricing_from_paths(None)
.expect("默认模型定价配置必须合法");
normalize_editor_character_animation_request_with_pricing(
payload,
&pricing,
crate::editor_green_screen::default_editor_screen_background_color(),
)
}
fn normalize_editor_character_animation_request_with_pricing(
payload: EditorCharacterAnimationGenerateRequest,
pricing: &EditorGenerationPricingConfig,
screen_color: EditorScreenBackgroundColor,
) -> 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 不能为空。",
));
}
// 源图在 handler 里已提前解析(决策要看图);这里保留空值校验,队列路径入队前也走这份校验。
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 = pricing.character_animation_model_mud_points(
Some(payload.model.as_str()),
resolution,
duration_seconds,
);
if payload.model.trim() != EDITOR_CHARACTER_ANIMATION_MODEL {
return Err(editor_character_animation_bad_request(
"model 必须固定为 seedance2.0-fast。",
));
}
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(), screen_color);
Ok(NormalizedEditorCharacterAnimationRequest {
source_layer_id,
prompt,
screen_color,
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 未配置",
}))
})?;
// 中文注释:画板角色动画入口产品侧固定为 Seedance 2.0 Fast,不继承通用角色视频环境模型覆盖。
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: CHARACTER_ANIMATION_MODEL.to_string(),
},
resolution: request.resolution.clone(),
ratio: request.ratio.clone(),
duration_seconds: request.duration_seconds,
})
}
#[cfg(test)]
fn normalize_editor_video_request(
payload: EditorVideoGenerateRequest,
) -> Result<NormalizedEditorVideoRequest, AppError> {
let pricing = crate::editor_generation_config::load_editor_generation_pricing_from_paths(None)
.expect("默认模型定价配置必须合法");
normalize_editor_video_request_with_pricing(payload, &pricing)
}
fn normalize_editor_video_request_with_pricing(
payload: EditorVideoGenerateRequest,
pricing: &EditorGenerationPricingConfig,
) -> Result<NormalizedEditorVideoRequest, AppError> {
let prompt = payload.prompt.trim().chars().take(4000).collect::<String>();
if prompt.is_empty() {
return Err(editor_video_bad_request("视频描述不能为空。"));
}
let model = normalize_editor_video_model(payload.model.as_str())?;
let aspect_ratio = normalize_editor_video_aspect_ratio(payload.aspect_ratio.as_str())?;
let resolution = normalize_editor_video_resolution(payload.resolution.as_str())?;
let duration_seconds = normalize_editor_video_duration(payload.duration_seconds)?;
if payload.mode.trim() != "std" {
return Err(editor_video_bad_request("mode 只支持 std。"));
}
let generate_audio = normalize_editor_video_sound(payload.sound.as_str())?;
if model == EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST && resolution == "1080p" {
return Err(editor_video_bad_request(
"seedance2.0-fast 不支持 1080p 清晰度。",
));
}
let expected_price = pricing.video_model_mud_points(Some(model), resolution, duration_seconds);
let references = normalize_editor_video_references(
model,
payload.reference_image_srcs,
payload.reference_video_srcs,
payload.reference_audio_srcs,
)?;
let (width, height) = resolve_editor_video_size(aspect_ratio, resolution);
Ok(NormalizedEditorVideoRequest {
prompt,
model: model.to_string(),
provider_model: resolve_editor_video_provider_model(model).to_string(),
aspect_ratio: aspect_ratio.to_string(),
resolution: resolution.to_string(),
duration_seconds,
generate_audio,
price_mud_points: expected_price,
width,
height,
reference_image_srcs: references.reference_image_srcs,
reference_video_srcs: references.reference_video_srcs,
reference_audio_srcs: references.reference_audio_srcs,
})
}
async fn build_editor_video_ark_request_body(
state: &AppState,
owner_user_id: &str,
request: &NormalizedEditorVideoRequest,
) -> Result<Value, AppError> {
let content = build_editor_video_ark_content(state, owner_user_id, request).await?;
Ok(json!({
"model": request.provider_model,
"content": content,
"resolution": request.resolution,
"ratio": request.aspect_ratio,
"duration": request.duration_seconds,
"generate_audio": request.generate_audio,
"watermark": false,
}))
}
async fn build_editor_video_ark_content(
state: &AppState,
owner_user_id: &str,
request: &NormalizedEditorVideoRequest,
) -> Result<Vec<Value>, AppError> {
let mut content = Vec::new();
if !request.prompt.trim().is_empty() {
content.push(json!({
"type": "text",
"text": request.prompt,
}));
}
let reference_image_urls = resolve_editor_video_reference_urls(
state,
owner_user_id,
request.reference_image_srcs.as_slice(),
"参考图片",
)
.await?;
let reference_video_urls = resolve_editor_video_reference_urls(
state,
owner_user_id,
request.reference_video_srcs.as_slice(),
"参考视频",
)
.await?;
let reference_audio_urls = resolve_editor_video_reference_urls(
state,
owner_user_id,
request.reference_audio_srcs.as_slice(),
"参考音频",
)
.await?;
content.extend(reference_image_urls.iter().map(|url| {
json!({
"type": "image_url",
"image_url": { "url": url },
"role": "reference_image",
})
}));
content.extend(reference_video_urls.iter().map(|url| {
json!({
"type": "video_url",
"video_url": { "url": url },
"role": "reference_video",
})
}));
content.extend(reference_audio_urls.iter().map(|url| {
json!({
"type": "audio_url",
"audio_url": { "url": url },
"role": "reference_audio",
})
}));
Ok(content)
}
async fn resolve_editor_video_reference_urls(
state: &AppState,
owner_user_id: &str,
sources: &[String],
label: &str,
) -> Result<Vec<String>, AppError> {
let mut resolved = Vec::with_capacity(sources.len());
for source in sources {
resolved.push(
resolve_editor_video_reference_url(state, owner_user_id, source.as_str(), label)
.await?,
);
}
Ok(resolved)
}
async fn resolve_editor_video_reference_url(
state: &AppState,
owner_user_id: &str,
source: &str,
label: &str,
) -> Result<String, AppError> {
let value = source.trim();
if value.starts_with("data:") {
return Err(editor_video_bad_request(format!(
"{label}格式不支持,请使用官方支持的 URL、素材 ID 或画板资源路径。"
)));
}
if value.starts_with("asset://") {
return Ok(value.to_string());
}
if value.starts_with("http://") || value.starts_with("https://") {
return Ok(value.to_string());
}
let object_key = resolve_editor_reference_object_key_for_owner(state, owner_user_id, value)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-video",
"field": label,
"message": format!(
"{label}必须是当前账号的 objectKey、项目资源 ID、素材 ID,或官方支持的公网 URL。"
),
}))
})?;
sign_editor_video_object_key_url(state, object_key.as_str())
}
fn sign_editor_video_object_key_url(
state: &AppState,
object_key: &str,
) -> Result<String, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置,无法为画板参考素材生成可访问 URL。",
}))
})?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: object_key.to_string(),
expire_seconds: Some(EDITOR_VIDEO_REFERENCE_READ_EXPIRE_SECONDS),
})
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
Ok(signed.signed_url)
}
struct NormalizedEditorVideoReferences {
reference_image_srcs: Vec<String>,
reference_video_srcs: Vec<String>,
reference_audio_srcs: Vec<String>,
}
fn normalize_editor_video_references(
model: &str,
image_srcs: Vec<String>,
video_srcs: Vec<String>,
audio_srcs: Vec<String>,
) -> Result<NormalizedEditorVideoReferences, AppError> {
let is_seedance =
model == EDITOR_VIDEO_MODEL_SEEDANCE_2 || model == EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST;
let image_srcs = normalize_reference_srcs(image_srcs);
let video_srcs = normalize_reference_srcs(video_srcs);
let audio_srcs = normalize_reference_srcs(audio_srcs);
if !is_seedance {
if image_srcs.is_empty() && video_srcs.is_empty() && audio_srcs.is_empty() {
return Ok(NormalizedEditorVideoReferences {
reference_image_srcs: Vec::new(),
reference_video_srcs: Vec::new(),
reference_audio_srcs: Vec::new(),
});
}
return Err(editor_video_bad_request(
"参考图片、参考视频和参考音频仅支持 Seedance 2.0 系列模型。",
));
}
if image_srcs.len() > EDITOR_VIDEO_REFERENCE_IMAGE_LIMIT {
return Err(editor_video_bad_request("参考图片最多 9 张。"));
}
if video_srcs.len() > EDITOR_VIDEO_REFERENCE_VIDEO_LIMIT {
return Err(editor_video_bad_request("参考视频最多 3 个。"));
}
if audio_srcs.len() > EDITOR_VIDEO_REFERENCE_AUDIO_LIMIT {
return Err(editor_video_bad_request("参考音频最多 3 个。"));
}
if !audio_srcs.is_empty() && image_srcs.is_empty() && video_srcs.is_empty() {
return Err(editor_video_bad_request(
"参考音频必须搭配参考图片或参考视频。",
));
}
for src in &image_srcs {
validate_editor_video_reference_src(
src,
"参考图片",
&["http://", "https://", "asset://"],
true,
"URL、素材 ID 或画板资源路径",
)?;
}
for src in &video_srcs {
validate_editor_video_reference_src(
src,
"参考视频",
&["http://", "https://", "asset://"],
true,
"URL、素材 ID 或画板资源路径",
)?;
}
for src in &audio_srcs {
validate_editor_video_reference_src(
src,
"参考音频",
&["http://", "https://", "asset://"],
true,
"URL、素材 ID 或画板资源路径",
)?;
}
let request_bytes = image_srcs
.iter()
.chain(video_srcs.iter())
.chain(audio_srcs.iter())
.map(String::len)
.sum::<usize>();
if request_bytes > EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES {
return Err(editor_video_bad_request(
"参考素材引用字段总长度不能超过 256KB。",
));
}
Ok(NormalizedEditorVideoReferences {
reference_image_srcs: image_srcs,
reference_video_srcs: video_srcs,
reference_audio_srcs: audio_srcs,
})
}
fn normalize_reference_srcs(values: Vec<String>) -> Vec<String> {
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect()
}
fn matches_inline_media_source(value: &str) -> bool {
let value = value.trim_start().to_ascii_lowercase();
value.starts_with("data:") || value.starts_with("blob:")
}
fn validate_editor_video_reference_src(
value: &str,
label: &str,
allowed_prefixes: &[&str],
allow_generated_object_key: bool,
format_hint: &str,
) -> Result<(), AppError> {
if allowed_prefixes
.iter()
.any(|prefix| value.starts_with(prefix))
{
return Ok(());
}
if allow_generated_object_key
&& LegacyAssetPrefix::from_object_key(value.trim_start_matches('/')).is_some()
{
return Ok(());
}
if value.starts_with(EDITOR_RESOURCE_ID_PREFIX) || value.starts_with(EDITOR_ASSET_ID_PREFIX) {
return Ok(());
}
Err(editor_video_bad_request(format!(
"{label}格式不支持,请使用官方支持的{format_hint}。"
)))
}
fn normalize_editor_video_model(value: &str) -> Result<&'static str, AppError> {
match value.trim() {
EDITOR_VIDEO_MODEL_SEEDANCE_2 => Ok(EDITOR_VIDEO_MODEL_SEEDANCE_2),
EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST => Ok(EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST),
EDITOR_VIDEO_MODEL_KLING_3 => Ok(EDITOR_VIDEO_MODEL_KLING_3),
EDITOR_VIDEO_MODEL_KLING_3_OMNI => Ok(EDITOR_VIDEO_MODEL_KLING_3_OMNI),
EDITOR_VIDEO_MODEL_VEO_3_1 => Ok(EDITOR_VIDEO_MODEL_VEO_3_1),
EDITOR_VIDEO_MODEL_VEO_3_1_FAST => Ok(EDITOR_VIDEO_MODEL_VEO_3_1_FAST),
_ => Err(editor_video_bad_request(
"model 只支持 seedance2.0、seedance2.0-fast、kling3.0、kling3.0-omni、veo3.1、veo3.1-fast。",
)),
}
}
fn resolve_editor_video_provider_model(model: &str) -> &str {
match model {
// 中文注释:Seedance 2.0 与 Seedance 2.0 Fast 必须映射到不同上游模型,避免生成视频误把标准版发到 fast 模型。
EDITOR_VIDEO_MODEL_SEEDANCE_2 => SEEDANCE_2_STANDARD_MODEL,
// 中文注释:Seedance 2.0 Fast 继续复用平台已有 fast seedance 上游模型。
EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST => CHARACTER_ANIMATION_MODEL,
_ => model,
}
}
fn normalize_editor_video_aspect_ratio(value: &str) -> Result<&'static str, AppError> {
match value.trim() {
"16:9" => Ok("16:9"),
"9:16" => Ok("9:16"),
"1:1" => Ok("1:1"),
"4:3" => Ok("4:3"),
"3:4" => Ok("3:4"),
"21:9" => Ok("21:9"),
_ => Err(editor_video_bad_request(
"aspectRatio 只支持 16:9、9:16、1:1、4:3、3:4 或 21:9。",
)),
}
}
fn normalize_editor_video_resolution(value: &str) -> Result<&'static str, AppError> {
match value.trim() {
"480p" => Ok("480p"),
"720p" => Ok("720p"),
"1080p" => Ok("1080p"),
_ => Err(editor_video_bad_request(
"resolution 只支持 480p、720p 或 1080p。",
)),
}
}
fn normalize_editor_video_duration(value: u32) -> Result<u32, AppError> {
if (4..=15).contains(&value) {
Ok(value)
} else {
Err(editor_video_bad_request(
"durationSeconds 只支持 4 到 15 秒的整数。",
))
}
}
fn normalize_editor_video_sound(value: &str) -> Result<bool, AppError> {
match value.trim() {
"on" => Ok(true),
"off" => Ok(false),
_ => Err(editor_video_bad_request("sound 只支持 on 或 off。")),
}
}
fn resolve_editor_video_size(aspect_ratio: &str, resolution: &str) -> (u32, u32) {
let height = match resolution {
"1080p" => 1080,
"720p" => 720,
_ => 480,
};
let (ratio_width, ratio_height) = match aspect_ratio {
"9:16" => (9, 16),
"1:1" => (1, 1),
"4:3" => (4, 3),
"3:4" => (3, 4),
"21:9" => (21, 9),
_ => (16, 9),
};
let width = ((height * ratio_width) + ratio_height / 2) / ratio_height;
let even_width = if width % 2 == 0 { width } else { width + 1 };
(even_width, height)
}
fn require_editor_video_settings(
state: &AppState,
model: &str,
) -> Result<EditorVideoSettings, 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 未配置",
}))
})?;
Ok(EditorVideoSettings {
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: resolve_editor_video_provider_model(model).to_string(),
},
})
}
fn editor_video_bad_request(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-video",
"message": message.into(),
}))
}
fn build_editor_character_animation_prompt(
prompt_text: &str,
screen_color: EditorScreenBackgroundColor,
) -> String {
format!(
"生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。{};禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。\n动作描述:\n{}",
editor_green_screen_character_prompt_clause(screen_color),
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 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!({
"previewVideoPath": preview_video_path,
}),
None => json!({
"imageSources": generated.image_sources,
}),
}
}
fn resolve_backend_frame_extraction_settings(state: &AppState) -> BackendFrameExtractionSettings {
BackendFrameExtractionSettings {
ffmpeg_path: normalize_required_text(
state.config.character_animation_ffmpeg_path.as_str(),
"ffmpeg",
),
ffprobe_path: normalize_required_text(
state.config.character_animation_ffprobe_path.as_str(),
"ffprobe",
),
timeout_ms: state
.config
.character_animation_frame_extract_timeout_ms
.max(1_000),
}
}
fn normalize_animation_frame_extraction_plan(
draft: &CharacterAnimationDraftPayload,
) -> AnimationFrameExtractionPlan {
let frame_count = normalize_frame_count(draft.frame_count.unwrap_or(8));
let apply_chroma_key = draft.apply_chroma_key.unwrap_or(true);
let default_start_ratio: f32 = if draft.loop_ { 0.12 } else { 0.0 };
let default_end_ratio: f32 = if draft.loop_ { 0.94 } else { 1.0 };
let sample_start_ratio = normalize_sample_ratio(draft.sample_start_ratio, default_start_ratio);
let sample_end_ratio = normalize_sample_ratio(
draft.sample_end_ratio,
default_end_ratio.max(sample_start_ratio + 0.05),
)
.max(sample_start_ratio + 0.05)
.min(1.0);
AnimationFrameExtractionPlan {
frame_count,
apply_chroma_key,
prepare_for_bgfilter_input: false,
sample_start_ratio,
sample_end_ratio,
}
}
fn normalize_sample_ratio(value: Option<f32>, fallback: f32) -> f32 {
let candidate = value.unwrap_or(fallback);
if candidate.is_finite() {
candidate.clamp(0.0, 1.0)
} else {
fallback.clamp(0.0, 1.0)
}
}
async fn extract_animation_frames_from_preview_video(
state: &AppState,
preview_video_path: &str,
frame_width: u32,
frame_height: u32,
extraction_settings: &BackendFrameExtractionSettings,
plan: &AnimationFrameExtractionPlan,
known_duration_seconds: Option<f64>,
) -> Result<Vec<FinalizedAnimationFrame>, AppError> {
let preview_payload = load_media_source_payload(state, preview_video_path).await?;
if !preview_payload.mime_type.starts_with("video/") {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-animation",
"message": "previewVideoPath 必须指向视频资源。",
})),
);
}
let temp_dir = create_animation_temp_dir()?;
let input_path = temp_dir.join(format!("preview.{}", preview_payload.extension));
fs::write(&input_path, preview_payload.bytes).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "character-animation",
"message": format!("写入动作抽帧临时视频失败:{error}"),
}))
})?;
let extraction_result = (|| {
let duration_seconds = probe_video_duration_seconds_or_known(
&input_path,
extraction_settings,
known_duration_seconds,
)?
.max(0.001);
let mut target_seconds = Vec::with_capacity(plan.frame_count as usize);
let mut raw_frame_paths = Vec::with_capacity(plan.frame_count as usize);
for frame_index in 0..plan.frame_count {
target_seconds.push(compute_sample_time_seconds(
duration_seconds,
frame_index,
plan.frame_count,
plan.sample_start_ratio,
plan.sample_end_ratio,
plan.frame_count > 1 && plan.sample_end_ratio < 1.0,
));
raw_frame_paths.push(temp_dir.join(format!("raw-frame-{:02}.png", frame_index + 1)));
}
// 中文注释:整批采样点只进入一个 FFmpeg filter graph,避免逐帧重启进程并重复解码同一视频。
extract_video_frames_to_png(
&input_path,
raw_frame_paths.as_slice(),
target_seconds.as_slice(),
extraction_settings,
)?;
let mut finalized_frames = Vec::with_capacity(raw_frame_paths.len());
for raw_frame_path in raw_frame_paths {
let frame_bytes = fs::read(&raw_frame_path).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": format!("读取动作抽帧结果失败:{error}"),
}))
})?;
let finalized_frame = if plan.prepare_for_bgfilter_input {
prepare_editor_character_animation_bgfilter_input(
frame_bytes.as_slice(),
"image/png",
frame_width,
frame_height,
)?
} else {
finalize_animation_frame_payload(
frame_bytes.as_slice(),
"image/png",
frame_width,
frame_height,
plan.apply_chroma_key,
)?
};
finalized_frames.push(finalized_frame);
}
Ok::<_, AppError>(finalized_frames)
})();
let _ = fs::remove_dir_all(&temp_dir);
extraction_result
}
fn create_animation_temp_dir() -> Result<PathBuf, AppError> {
let temp_dir = std::env::temp_dir().join(format!(
"genarrative-character-animation-{}",
current_utc_micros()
));
fs::create_dir_all(&temp_dir).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "character-animation",
"message": format!("创建动作抽帧临时目录失败:{error}"),
}))
})?;
Ok(temp_dir)
}
fn probe_video_duration_seconds(
input_path: &Path,
extraction_settings: &BackendFrameExtractionSettings,
) -> Result<f64, AppError> {
let output = run_process_with_timeout(
&extraction_settings.ffprobe_path,
&[
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
input_path.to_string_lossy().as_ref(),
],
extraction_settings.timeout_ms,
"探测动作预览视频时长失败",
)?;
let duration_text = String::from_utf8_lossy(&output.stdout).trim().to_string();
duration_text.parse::<f64>().map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": format!("解析动作预览视频时长失败:{error}"),
}))
})
}
fn probe_video_duration_seconds_or_known(
input_path: &Path,
extraction_settings: &BackendFrameExtractionSettings,
known_duration_seconds: Option<f64>,
) -> Result<f64, AppError> {
match probe_video_duration_seconds(input_path, extraction_settings) {
Ok(duration_seconds) => Ok(duration_seconds),
Err(error) if is_ffprobe_start_error(&error, extraction_settings.ffprobe_path.as_str()) => {
if let Some(duration_seconds) = known_duration_seconds
.filter(|duration_seconds| duration_seconds.is_finite() && *duration_seconds > 0.0)
{
return Ok(duration_seconds);
}
Err(error)
}
Err(error) => Err(error),
}
}
fn is_ffprobe_start_error(error: &AppError, ffprobe_path: &str) -> bool {
error.status_code() == StatusCode::SERVICE_UNAVAILABLE
&& error.body_text().contains("无法启动进程")
&& error.body_text().contains(ffprobe_path)
}
fn compute_sample_time_seconds(
duration_seconds: f64,
frame_index: u32,
frame_count: u32,
sample_start_ratio: f32,
sample_end_ratio: f32,
loop_mode: bool,
) -> f64 {
let duration_seconds = duration_seconds.max(0.001);
let sample_start = duration_seconds * sample_start_ratio as f64;
let sample_end = duration_seconds * sample_end_ratio as f64;
let sample_window = (sample_end - sample_start).max(0.001);
let frame_count_f64 = frame_count.max(1) as f64;
let progress = if loop_mode || frame_count <= 1 || sample_end_ratio >= 1.0 {
frame_index as f64 / frame_count_f64
} else {
frame_index as f64 / frame_count.saturating_sub(1).max(1) as f64
};
let frame_margin_seconds = (sample_window / frame_count_f64)
.max(0.05)
.min(sample_window);
let safe_sample_end = (sample_end - frame_margin_seconds)
.max(sample_start)
.min((duration_seconds - 0.001).max(0.0));
(sample_start + sample_window * progress).min(safe_sample_end)
}
fn build_batch_frame_filter_graph(target_seconds: &[f64]) -> String {
let split_outputs = (0..target_seconds.len())
.map(|index| format!("[s{index}]"))
.collect::<String>();
let selectors = target_seconds
.iter()
.enumerate()
.map(|(index, target_seconds)| {
format!("[s{index}]select=gte(t\\,{target_seconds:.3})[f{index}]")
})
.collect::<Vec<_>>()
.join(";");
format!(
"[0:v]setpts=PTS-STARTPTS,split={}{split_outputs};{selectors}",
target_seconds.len()
)
}
fn extract_video_frames_to_png(
input_path: &Path,
output_paths: &[PathBuf],
target_seconds: &[f64],
extraction_settings: &BackendFrameExtractionSettings,
) -> Result<(), AppError> {
if output_paths.is_empty()
|| output_paths.len() != target_seconds.len()
|| target_seconds
.iter()
.any(|target_seconds| !target_seconds.is_finite() || *target_seconds < 0.0)
{
return Err(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "character-animation",
"message": "动作视频批量抽帧计划无效。",
"outputCount": output_paths.len(),
"targetCount": target_seconds.len(),
})),
);
}
let filter_graph = build_batch_frame_filter_graph(target_seconds);
let mut owned_args = vec![
"-hide_banner".to_string(),
"-loglevel".to_string(),
"error".to_string(),
"-y".to_string(),
"-i".to_string(),
input_path.to_string_lossy().into_owned(),
"-filter_complex".to_string(),
filter_graph,
];
for (frame_index, output_path) in output_paths.iter().enumerate() {
owned_args.extend([
"-map".to_string(),
format!("[f{frame_index}]"),
"-frames:v".to_string(),
"1".to_string(),
"-f".to_string(),
"image2".to_string(),
output_path.to_string_lossy().into_owned(),
]);
}
let args = owned_args.iter().map(String::as_str).collect::<Vec<_>>();
let fallback_message = if output_paths.len() == 1 {
"抽取动作视频帧失败"
} else {
"批量抽取动作视频帧失败"
};
let output = run_process_with_timeout(
&extraction_settings.ffmpeg_path,
args.as_slice(),
extraction_settings.timeout_ms,
fallback_message,
)?;
let missing_frames = output_paths
.iter()
.enumerate()
.filter(|(_, output_path)| !output_path.is_file())
.collect::<Vec<_>>();
if let Some((first_missing_index, first_missing_path)) = missing_frames.first().copied() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let missing_frame_details = missing_frames
.into_iter()
.map(|(frame_index, output_path)| {
json!({
"frameIndex": frame_index + 1,
"targetSeconds": target_seconds[frame_index],
"outputPath": output_path.to_string_lossy(),
})
})
.collect::<Vec<_>>();
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": "ffmpeg 已执行但未产出动作帧文件。",
"targetSeconds": target_seconds[first_missing_index],
"outputPath": first_missing_path.to_string_lossy(),
"missingFrames": missing_frame_details,
"stderr": stderr,
"stdout": stdout,
})),
);
}
Ok(())
}
fn extract_video_frame_to_png(
input_path: &Path,
output_path: &Path,
target_seconds: f64,
extraction_settings: &BackendFrameExtractionSettings,
) -> Result<(), AppError> {
let output_paths = [output_path.to_path_buf()];
let target_seconds = [target_seconds];
extract_video_frames_to_png(
input_path,
output_paths.as_slice(),
target_seconds.as_slice(),
extraction_settings,
)
}
fn run_process_with_timeout(
program: &str,
args: &[&str],
timeout_ms: u64,
fallback_message: &str,
) -> Result<std::process::Output, AppError> {
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "character-animation",
"message": format!("{fallback_message}:无法启动进程 {program}:{error}"),
}))
})?;
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
loop {
if let Some(status) = child.try_wait().map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": format!("{fallback_message}:等待进程状态失败:{error}"),
}))
})? {
let output = child.wait_with_output().map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": format!("{fallback_message}:读取进程输出失败:{error}"),
}))
})?;
if !status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let detail = if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
format!("{program} 返回非零退出码:{status}")
};
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": format!("{fallback_message}:{detail}"),
})),
);
}
return Ok(output);
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(
AppError::from_status(StatusCode::GATEWAY_TIMEOUT).with_details(json!({
"provider": "character-animation",
"message": format!("{fallback_message}:执行超时,已等待 {} ms。", timeout_ms),
})),
);
}
thread::sleep(Duration::from_millis(20));
}
}
/// BgFilter 只接受不透明 RGB 输入;直接丢弃 alpha 会让全透明像素下未定义的
/// RGB 值以杂色进入抠图,这里先按白底合成再转 RGB。
fn flatten_alpha_onto_white_rgb(image: image::DynamicImage) -> image::RgbImage {
if !image.color().has_alpha() {
return image.to_rgb8();
}
let rgba = image.to_rgba8();
let mut flattened = image::RgbImage::new(rgba.width(), rgba.height());
for (source, target) in rgba.pixels().zip(flattened.pixels_mut()) {
let [red, green, blue, alpha] = source.0;
let opacity = f32::from(alpha) / 255.0;
let blend = |channel: u8| -> u8 {
(f32::from(channel) * opacity + 255.0 * (1.0 - opacity)).round() as u8
};
target.0 = [blend(red), blend(green), blend(blue)];
}
flattened
}
fn prepare_editor_character_animation_bgfilter_input(
source: &[u8],
mime_type: &str,
target_width: u32,
target_height: u32,
) -> Result<FinalizedAnimationFrame, AppError> {
let image = match image_format_from_mime(mime_type) {
Some(format) => image::load_from_memory_with_format(source, format),
None => image::load_from_memory(source),
}
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": format!("解析 BgFilter 输入动作帧图片失败:{error}"),
}))
})?;
let image = flatten_alpha_onto_white_rgb(image);
let (draw_width, draw_height) =
compute_contain_dimensions(image.width(), image.height(), target_width, target_height);
let resized = if (draw_width, draw_height) == (image.width(), image.height()) {
image
} else {
image::imageops::resize(&image, draw_width, draw_height, FilterType::Triangle)
};
let mut encoded = Vec::new();
let encoder = PngEncoder::new(&mut encoded);
encoder
.write_image(
resized.as_raw(),
resized.width(),
resized.height(),
ColorType::Rgb8.into(),
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "character-animation",
"message": format!("编码 BgFilter 输入动作帧 PNG 失败:{error}"),
}))
})?;
Ok(FinalizedAnimationFrame {
bytes: encoded,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
fn finalize_animation_frame_payload(
source: &[u8],
mime_type: &str,
frame_width: u32,
frame_height: u32,
apply_chroma_key: bool,
) -> Result<FinalizedAnimationFrame, AppError> {
let mut image = match image_format_from_mime(mime_type) {
Some(format) => image::load_from_memory_with_format(source, format),
None => image::load_from_memory(source),
}
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": format!("解析动作帧图片失败:{error}"),
}))
})?
.to_rgba8();
let (source_width, source_height) = image.dimensions();
if apply_chroma_key {
remove_background_from_rgba(
image.as_mut(),
source_width as usize,
source_height as usize,
);
}
let normalized = contain_rgba_image(&image, frame_width.max(1), frame_height.max(1));
let mut encoded = Vec::new();
let encoder = PngEncoder::new(&mut encoded);
encoder
.write_image(
normalized.as_raw(),
normalized.width(),
normalized.height(),
ColorType::Rgba8.into(),
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "character-animation",
"message": format!("编码动作帧 PNG 失败:{error}"),
}))
})?;
Ok(FinalizedAnimationFrame {
bytes: encoded,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
fn contain_rgba_image(source: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage {
let mut canvas = RgbaImage::from_pixel(target_width, target_height, Rgba([0, 0, 0, 0]));
let (draw_width, draw_height) =
compute_contain_dimensions(source.width(), source.height(), target_width, target_height);
let offset_x = ((target_width - draw_width) / 2) as i64;
let offset_y = ((target_height - draw_height) / 2) as i64;
if (draw_width, draw_height) == (source.width(), source.height()) {
image::imageops::overlay(&mut canvas, source, offset_x, offset_y);
} else {
let resized =
image::imageops::resize(source, draw_width, draw_height, FilterType::Triangle);
image::imageops::overlay(&mut canvas, &resized, offset_x, offset_y);
}
canvas
}
fn image_format_from_mime(mime_type: &str) -> Option<ImageFormat> {
match mime_type {
"image/png" => Some(ImageFormat::Png),
"image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg),
"image/webp" => Some(ImageFormat::WebP),
_ => None,
}
}
fn compute_contain_dimensions(
source_width: u32,
source_height: u32,
target_width: u32,
target_height: u32,
) -> (u32, u32) {
let target_width = target_width.max(1);
let target_height = target_height.max(1);
let source_width = source_width.max(1);
let source_height = source_height.max(1);
let scale = (target_width as f32 / source_width as f32)
.min(target_height as f32 / source_height as f32);
let draw_width = ((source_width as f32 * scale).round() as u32)
.max(1)
.min(target_width);
let draw_height = ((source_height as f32 * scale).round() as u32)
.max(1)
.min(target_height);
(draw_width, draw_height)
}
async fn load_media_source_payload(
state: &AppState,
source: &str,
) -> Result<MediaPayload, AppError> {
if let Some(payload) = parse_media_data_url(source) {
return Ok(payload);
}
let object_key = resolve_object_key_from_legacy_path(source, "framesDataUrls")?;
let oss_client = require_oss_client(state)?;
let head = oss_client
.head_object(
&reqwest::Client::new(),
OssHeadObjectRequest {
object_key: object_key.clone(),
},
)
.await
.map_err(map_character_animation_oss_error)?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key,
expire_seconds: Some(60),
})
.map_err(map_character_animation_oss_error)?;
let bytes = reqwest::Client::new()
.get(signed.signed_url)
.send()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取角色动作帧失败:{error}"),
}))
})?
.error_for_status()
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取角色动作帧失败:{error}"),
}))
})?
.bytes()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取角色动作帧内容失败:{error}"),
}))
})?
.to_vec();
let mime_type = head
.content_type
.unwrap_or_else(|| "application/octet-stream".to_string());
let extension = mime_to_extension(mime_type.as_str()).to_string();
Ok(MediaPayload {
mime_type,
extension,
bytes,
})
}
fn parse_media_data_url(value: &str) -> Option<MediaPayload> {
let body = value.trim().strip_prefix("data:")?;
let (mime_type, data) = body.split_once(";base64,")?;
let mime_type = mime_type.trim();
if !(mime_type.starts_with("image/") || mime_type.starts_with("video/")) {
return None;
}
let bytes = decode_base64(data)?;
if bytes.is_empty() {
return None;
}
Some(MediaPayload {
mime_type: mime_type.to_string(),
extension: mime_to_extension(mime_type).to_string(),
bytes,
})
}
/// 把带透明通道的源图合成到选定背景色上,返回新的 PNG data URL。
/// 视频模型不支持 alpha:透明源图会被按白底理解,且图生视频里源图背景的视觉证据
/// 权重高于文字提示,导致提示词指定的背景色被无视。先填成实色让源图和提示词一致,
/// 视频背景就是抠图键色。源图本身不透明或无法解码时返回 None(沿用原图)。
fn composite_source_image_onto_screen_color(
source_data_url: &str,
screen_color: EditorScreenBackgroundColor,
) -> Option<String> {
let payload = parse_media_data_url(source_data_url)?;
if !payload.mime_type.starts_with("image/") {
return None;
}
let Ok(image) = image::load_from_memory(payload.bytes.as_slice()) else {
tracing::warn!("character_animation_source_composite_skip_undecodable_image");
return None;
};
let rgba = image.to_rgba8();
if rgba.pixels().all(|pixel| pixel.0[3] == u8::MAX) {
return None;
}
let background = [
f32::from(screen_color.red),
f32::from(screen_color.green),
f32::from(screen_color.blue),
];
let mut composited = RgbaImage::new(rgba.width(), rgba.height());
for (x, y, pixel) in rgba.enumerate_pixels() {
let [red, green, blue, alpha] = pixel.0;
let opacity = f32::from(alpha) / 255.0;
let blend = |source: u8, background: f32| -> u8 {
(f32::from(source) * opacity + background * (1.0 - opacity)).round() as u8
};
composited.put_pixel(
x,
y,
Rgba([
blend(red, background[0]),
blend(green, background[1]),
blend(blue, background[2]),
u8::MAX,
]),
);
}
let mut bytes = Vec::new();
if PngEncoder::new(&mut bytes)
.write_image(
composited.as_raw(),
composited.width(),
composited.height(),
ColorType::Rgba8.into(),
)
.is_err()
{
tracing::warn!("character_animation_source_composite_skip_encode_failed");
return None;
}
Some(format!(
"data:image/png;base64,{}",
encode_base64(bytes.as_slice())
))
}
fn resolve_object_key_from_legacy_path(value: &str, field: &str) -> Result<String, AppError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-animation",
"field": field,
"message": "媒体资源路径不能为空。",
})),
);
}
if trimmed.starts_with("data:") {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "character-animation",
"field": field,
"message": "无法解析的 data URL 媒体资源。",
})),
);
}
Ok(trimmed.trim_start_matches('/').to_string())
}
fn parse_video_data_url(value: &str) -> Option<ParsedVideoDataUrl> {
let body = value.trim().strip_prefix("data:")?;
let (mime_type, data) = body.split_once(";base64,")?;
let mime_type = mime_type.trim();
if !mime_type.starts_with("video/") {
return None;
}
let bytes = decode_base64(data)?;
if bytes.is_empty() {
return None;
}
Some(ParsedVideoDataUrl {
mime_type: mime_type.to_string(),
extension: mime_to_extension(mime_type).to_string(),
bytes,
})
}
fn encode_base64(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b0 = *chunk.first().unwrap_or(&0);
let b1 = *chunk.get(1).unwrap_or(&0);
let b2 = *chunk.get(2).unwrap_or(&0);
let packed = ((b0 as u32) << 16) | ((b1 as u32) << 8) | b2 as u32;
output.push(TABLE[((packed >> 18) & 0x3f) as usize] as char);
output.push(TABLE[((packed >> 12) & 0x3f) as usize] as char);
output.push(if chunk.len() > 1 {
TABLE[((packed >> 6) & 0x3f) as usize] as char
} else {
'='
});
output.push(if chunk.len() > 2 {
TABLE[(packed & 0x3f) as usize] as char
} else {
'='
});
}
output
}
fn mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/svg+xml" => "svg",
"image/png" => "png",
"image/jpeg" => "jpg",
"image/jpg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"video/mp4" => "mp4",
"video/quicktime" => "mov",
"video/x-msvideo" => "avi",
"video/webm" => "webm",
_ => "bin",
}
}
fn decode_base64(value: &str) -> Option<Vec<u8>> {
let cleaned = value.trim().replace(char::is_whitespace, "");
let mut output = Vec::with_capacity(cleaned.len() * 3 / 4);
let mut buffer = 0u32;
let mut bits = 0u8;
for byte in cleaned.bytes() {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' => break,
_ => return None,
} as u32;
buffer = (buffer << 6) | value;
bits += 6;
while bits >= 8 {
bits -= 8;
output.push(((buffer >> bits) & 0xFF) as u8);
}
}
Some(output)
}
fn parse_animation_json_payload(
raw_text: &str,
fallback_message: &str,
) -> Result<ParsedAnimationJsonPayload, AppError> {
serde_json::from_str::<Value>(raw_text)
.map(|payload| ParsedAnimationJsonPayload { payload })
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": format!("{fallback_message}:解析响应失败:{error}"),
}))
})
}
fn extract_animation_task_id(payload: &Value) -> Option<String> {
payload
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| find_first_string_by_key(payload, "task_id"))
}
fn extract_video_url(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "video_url")
.or_else(|| find_first_string_by_key(payload, "url"))
}
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
match value {
Value::Array(entries) => {
for entry in entries {
collect_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key
&& let Some(text) = nested_value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
continue;
}
collect_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_strings_by_key(value, target_key, &mut results);
results.into_iter().next()
}
fn extract_generation_task_status(payload: &Value) -> String {
payload
.get("status")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| find_first_string_by_key(payload, "task_status"))
.or_else(|| find_first_string_by_key(payload, "status"))
.unwrap_or_default()
}
fn normalize_generation_task_status(value: &str) -> String {
value.trim().to_ascii_lowercase().replace(' ', "_")
}
fn is_completed_generation_task_status(status: &str) -> bool {
matches!(
status,
"completed" | "complete" | "done" | "finished" | "success" | "succeeded" | "succeed"
)
}
fn is_failed_generation_task_status(status: &str) -> bool {
matches!(
status,
"failed"
| "canceled"
| "cancelled"
| "error"
| "aborted"
| "rejected"
| "expired"
| "unknown"
)
}
fn parse_animation_api_error_message(raw_text: &str, fallback_message: &str) -> String {
if raw_text.trim().is_empty() {
return fallback_message.to_string();
}
if let Ok(parsed) = serde_json::from_str::<Value>(raw_text) {
if let Some(message) = parsed
.pointer("/error/message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return localize_animation_api_error_message(message);
}
if let Some(message) = parsed
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return localize_animation_api_error_message(message);
}
}
localize_animation_api_error_message(raw_text.trim())
}
fn localize_animation_api_error_message(message: &str) -> String {
let normalized = message.to_ascii_lowercase();
if normalized.contains("output video")
&& normalized.contains("copyright")
&& normalized.contains("restriction")
{
return "生成失败:输出视频可能涉及版权限制,请调整提示词或更换参考素材后重试。"
.to_string();
}
if normalized.contains("copyright") && normalized.contains("restriction") {
return "生成失败:内容可能涉及版权限制,请调整提示词或更换参考素材后重试。".to_string();
}
message.to_string()
}
fn parse_animation_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": parse_animation_api_error_message(raw_text, fallback_message),
}))
}
fn map_character_animation_upstream_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-animation",
"message": message,
}))
}
fn is_inappropriate_content_message(value: &str) -> bool {
let normalized = value.to_ascii_lowercase();
normalized.contains("inappropriate content")
|| normalized.contains("finappropriate-content")
|| value.contains("不适当内容")
|| value.contains("违规内容")
}
fn build_asset_metadata(
asset_kind: &str,
owner_user_id: &str,
entity_kind: &str,
entity_id: &str,
slot: &str,
animation: &str,
) -> BTreeMap<String, String> {
BTreeMap::from([
("asset_kind".to_string(), asset_kind.to_string()),
("owner_user_id".to_string(), owner_user_id.to_string()),
("entity_kind".to_string(), entity_kind.to_string()),
("entity_id".to_string(), entity_id.to_string()),
("slot".to_string(), slot.to_string()),
("animation".to_string(), animation.to_string()),
])
}
fn build_workflow_cache_metadata(
owner_user_id: &str,
character_id: &str,
cache_scope_id: Option<&str>,
) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::from([
(
"asset_kind".to_string(),
CHARACTER_WORKFLOW_CACHE_ASSET_KIND.to_string(),
),
("owner_user_id".to_string(), owner_user_id.to_string()),
(
"entity_kind".to_string(),
CHARACTER_ANIMATION_ENTITY_KIND.to_string(),
),
("entity_id".to_string(), character_id.to_string()),
(
"slot".to_string(),
CHARACTER_WORKFLOW_CACHE_SLOT.to_string(),
),
]);
if let Some(cache_scope_id) = cache_scope_id.and_then(|value| trim_optional_text(Some(value))) {
metadata.insert("cache_scope_id".to_string(), cache_scope_id);
}
metadata
}
fn require_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> {
state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})
}
#[cfg(any())]
fn is_missing_oss_config_error(error: &AppError) -> bool {
error.status_code() == StatusCode::SERVICE_UNAVAILABLE
&& error.details().is_some_and(|details| {
details.get("provider").and_then(Value::as_str) == Some("aliyun-oss")
&& details.get("reason").and_then(Value::as_str) == Some("OSS 未完成环境变量配置")
})
}
fn normalize_required_text(value: &str, fallback: &str) -> String {
let normalized = value
.trim()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(180)
.collect::<String>()
.trim()
.to_string();
if normalized.is_empty() {
fallback.to_string()
} else {
normalized
}
}
fn trim_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn resolve_editor_character_animation_asset_label(value: Option<&str>) -> String {
trim_optional_text(value)
.unwrap_or_else(|| "角色动作".to_string())
.chars()
.take(EDITOR_ASSET_LABEL_MAX_CHARS)
.collect()
}
fn editor_character_animation_source_asset_label(asset_label: &str) -> String {
let suffix = "(原始视频)";
let base_char_limit = EDITOR_ASSET_LABEL_MAX_CHARS.saturating_sub(suffix.chars().count());
let base = asset_label
.trim()
.chars()
.take(base_char_limit)
.collect::<String>();
format!("{base}{suffix}")
}
fn attach_editor_character_animation_result_metadata(
generation_inputs: Option<Value>,
preview_video_path: &str,
frames: &[EditorCharacterAnimationFramePayload],
fps: u32,
duration_seconds: u32,
) -> Option<Value> {
let mut object = match generation_inputs {
Some(Value::Object(object)) => object,
Some(value) => serde_json::Map::from_iter([("sourceGenerationInputs".to_string(), value)]),
None => serde_json::Map::new(),
};
object.insert(
"characterAnimation".to_string(),
json!({
"previewVideoPath": preview_video_path,
"frames": frames,
"frameCount": frames.len(),
"fps": fps,
"durationSeconds": duration_seconds,
}),
);
Some(Value::Object(object))
}
fn clamp_prompt_seed_text(value: Option<&str>) -> String {
trim_optional_text(value)
.unwrap_or_default()
.chars()
.take(280)
.collect()
}
fn normalize_frame_count(value: u32) -> u32 {
value.clamp(2, 16)
}
fn normalize_dimension(value: u32, fallback: u32) -> u32 {
if value == 0 {
fallback
} else {
value.min(4096)
}
}
fn build_animation_frame_svg(
animation: &str,
prompt: &str,
frame_index: u32,
frame_count: u32,
width: u32,
height: u32,
) -> String {
let progress = if frame_count <= 1 {
0.0
} else {
frame_index as f32 / (frame_count - 1) as f32
};
let wave = (progress * std::f32::consts::TAU).sin();
let body_offset_x = (wave * 12.0).round() as i32;
let body_offset_y = (wave.abs() * -10.0).round() as i32;
let slash_alpha = if animation.contains("attack") || animation.contains("skill") {
(0.25 + progress * 0.5).min(0.75)
} else {
0.12
};
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
<rect width="100%" height="100%" fill="#00ff00"/>
<ellipse cx="{shadow_x}" cy="{shadow_y}" rx="{shadow_rx}" ry="{shadow_ry}" fill="rgba(0,0,0,0.24)"/>
<g transform="translate({body_offset_x} {body_offset_y})">
<path d="M {body_x} {body_y} C {body_c1x} {body_c1y}, {body_c2x} {body_c2y}, {body_x2} {body_y2} L {leg_x} {leg_y} L {leg2_x} {leg_y} Z" fill="#1f2937"/>
<circle cx="{head_x}" cy="{head_y}" r="{head_r}" fill="#f8d7b0"/>
<path d="M {arm_x} {arm_y} L {arm_x2} {arm_y2}" stroke="#e5e7eb" stroke-width="6" stroke-linecap="round"/>
</g>
<path d="M {slash_x} {slash_y} C {slash_cx} {slash_cy}, {slash_x2} {slash_y2}, {slash_x3} {slash_y3}" fill="none" stroke="#fef3c7" stroke-width="8" stroke-linecap="round" opacity="{slash_alpha}"/>
<text x="50%" y="{title_y}" text-anchor="middle" fill="#052e16" font-size="14" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
<text x="50%" y="{frame_y}" text-anchor="middle" fill="#052e16" font-size="11" font-family="Microsoft YaHei, PingFang SC, sans-serif">frame {frame}</text>
</svg>"##,
width = width,
height = height,
shadow_x = width / 2,
shadow_y = height * 91 / 100,
shadow_rx = width / 5,
shadow_ry = height / 26,
body_offset_x = body_offset_x,
body_offset_y = body_offset_y,
body_x = width * 43 / 100,
body_y = height * 34 / 100,
body_c1x = width * 36 / 100,
body_c1y = height * 55 / 100,
body_c2x = width * 43 / 100,
body_c2y = height * 78 / 100,
body_x2 = width * 57 / 100,
body_y2 = height * 78 / 100,
leg_x = width * 47 / 100,
leg_y = height * 88 / 100,
leg2_x = width * 63 / 100,
head_x = width * 54 / 100,
head_y = height * 24 / 100,
head_r = (width.min(height) / 9).max(16),
arm_x = width * 57 / 100,
arm_y = height * 45 / 100,
arm_x2 = width * 72 / 100,
arm_y2 = height * 35 / 100,
slash_x = width * 62 / 100,
slash_y = height * 28 / 100,
slash_cx = width * 80 / 100,
slash_cy = height * 42 / 100,
slash_x2 = width * 74 / 100,
slash_y2 = height * 62 / 100,
slash_x3 = width * 55 / 100,
slash_y3 = height * 72 / 100,
slash_alpha = slash_alpha,
title_y = height * 7 / 100,
frame_y = height * 13 / 100,
title = escape_svg_text(&format!(
"{animation} {}",
prompt.chars().take(12).collect::<String>()
)),
frame = frame_index + 1,
)
}
fn sanitize_storage_segment(value: &str, fallback: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|character| match character {
'a'..='z' | '0'..='9' | '-' | '_' => character,
'A'..='Z' => character.to_ascii_lowercase(),
_ => '-',
})
.collect::<String>();
let normalized = collapse_dashes(&normalized);
if normalized.is_empty() {
fallback.to_string()
} else {
normalized
}
}
fn collapse_dashes(value: &str) -> String {
value
.chars()
.fold(
(String::new(), false),
|(mut output, last_is_dash), character| {
let is_dash = character == '-';
if is_dash && last_is_dash {
return (output, true);
}
output.push(character);
(output, is_dash)
},
)
.0
.trim_matches('-')
.to_string()
}
fn current_utc_millis() -> i64 {
current_utc_micros() / 1_000
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
fn current_utc_iso_text() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| format!("{}.000000Z", current_utc_millis()))
}
fn format_utc_micros(micros: i64) -> String {
module_runtime::format_utc_micros(micros)
}
fn escape_svg_text(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn clamp01(value: f32) -> f32 {
value.clamp(0.0, 1.0)
}
fn lerp(from: f32, to: f32, t: f32) -> f32 {
from + (to - from) * clamp01(t)
}
fn compute_green_background_score(red: u8, green: u8, blue: u8, alpha: u8) -> f32 {
if alpha == 0 {
return 1.0;
}
let green = green as f32;
let red = red as f32;
let blue = blue as f32;
let green_lead = green - red.max(blue);
if green < 52.0 || green_lead <= 8.0 {
return 0.0;
}
let green_ratio = green / (red + blue).max(1.0);
if green_ratio <= 0.52 {
return 0.0;
}
clamp01(
((green - 52.0) / 168.0) * 0.22
+ ((green_lead - 8.0) / 96.0) * 0.53
+ ((green_ratio - 0.52) / 0.82) * 0.25,
)
}
fn compute_white_background_score(red: u8, green: u8, blue: u8, alpha: u8) -> f32 {
if alpha == 0 {
return 1.0;
}
let red = red as f32;
let green = green as f32;
let blue = blue as f32;
let max_channel = red.max(green).max(blue);
let min_channel = red.min(green).min(blue);
let average = (red + green + blue) / 3.0;
if average < 188.0 || min_channel < 168.0 {
return 0.0;
}
let spread = max_channel - min_channel;
let neutrality = 1.0 - clamp01((spread - 6.0) / 34.0);
let brightness = clamp01((average - 188.0) / 55.0);
let floor = clamp01((min_channel - 168.0) / 60.0);
clamp01(neutrality * (brightness * 0.85 + floor * 0.15))
}
fn collect_foreground_neighbor_color(
pixels: &[u8],
width: usize,
height: usize,
x: usize,
y: usize,
background_mask: &[u8],
background_hints: &[f32],
) -> Option<(u8, u8, u8)> {
let mut total_weight = 0.0f32;
let mut total_red = 0.0f32;
let mut total_green = 0.0f32;
let mut total_blue = 0.0f32;
for offset_y in -2i32..=2 {
for offset_x in -2i32..=2 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 {
continue;
}
let next_pixel_index = next_y as usize * width + next_x as usize;
if background_mask[next_pixel_index] != 0 {
continue;
}
if background_hints[next_pixel_index] >= 0.18 {
continue;
}
let next_offset = next_pixel_index * 4;
let next_alpha = pixels[next_offset + 3];
if next_alpha < 96 {
continue;
}
let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs();
let weight = (next_alpha as f32 / 255.0)
* if distance <= 1 {
1.8
} else if distance == 2 {
1.2
} else {
0.7
};
total_weight += weight;
total_red += pixels[next_offset] as f32 * weight;
total_green += pixels[next_offset + 1] as f32 * weight;
total_blue += pixels[next_offset + 2] as f32 * weight;
}
}
if total_weight <= 0.0 {
return None;
}
Some((
(total_red / total_weight).round() as u8,
(total_green / total_weight).round() as u8,
(total_blue / total_weight).round() as u8,
))
}
fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -> bool {
const SOFT_EDGE_ALPHA_THRESHOLD: u8 = 224;
const FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD: u8 = 96;
let pixel_count = width * height;
if pixel_count == 0 {
return false;
}
let mut background_mask = vec![0u8; pixel_count];
let mut green_scores = vec![0.0f32; pixel_count];
let mut white_scores = vec![0.0f32; pixel_count];
let mut background_hints = vec![0.0f32; pixel_count];
let mut queue = Vec::<usize>::new();
let mut queue_index = 0usize;
let mut changed = false;
for pixel_index in 0..pixel_count {
let offset = pixel_index * 4;
let red = pixels[offset];
let green = pixels[offset + 1];
let blue = pixels[offset + 2];
let alpha = pixels[offset + 3];
let green_score = compute_green_background_score(red, green, blue, alpha);
let white_score = compute_white_background_score(red, green, blue, alpha);
let transparency_hint = clamp01((56.0 - alpha as f32) / 56.0) * 0.75;
green_scores[pixel_index] = green_score;
white_scores[pixel_index] = white_score;
background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint);
}
let try_seed_background =
|pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec<usize>| {
if background_mask[pixel_index] != 0 {
return;
}
let offset = pixel_index * 4;
let alpha = pixels[offset + 3];
let strong_candidate =
alpha < 40 || green_scores[pixel_index] > 0.12 || white_scores[pixel_index] > 0.32;
if !strong_candidate {
return;
}
background_mask[pixel_index] = 1;
queue.push(pixel_index);
};
for x in 0..width {
try_seed_background(x, &mut background_mask, &mut queue);
try_seed_background((height - 1) * width + x, &mut background_mask, &mut queue);
}
for y in 1..height.saturating_sub(1) {
try_seed_background(y * width, &mut background_mask, &mut queue);
try_seed_background(y * width + width - 1, &mut background_mask, &mut queue);
}
while queue_index < queue.len() {
let pixel_index = queue[queue_index];
queue_index += 1;
let x = pixel_index % width;
let y = pixel_index / width;
let neighbor_indexes = [
if x > 0 { Some(pixel_index - 1) } else { None },
if x + 1 < width {
Some(pixel_index + 1)
} else {
None
},
if y > 0 {
Some(pixel_index - width)
} else {
None
},
if y + 1 < height {
Some(pixel_index + width)
} else {
None
},
];
for next_pixel_index in neighbor_indexes.into_iter().flatten() {
if background_mask[next_pixel_index] != 0 {
continue;
}
let next_offset = next_pixel_index * 4;
let next_alpha = pixels[next_offset + 3];
let next_green_score = green_scores[next_pixel_index];
let next_white_score = white_scores[next_pixel_index];
let next_hint = background_hints[next_pixel_index];
let reachable_soft_edge = next_hint > 0.08
&& next_alpha < SOFT_EDGE_ALPHA_THRESHOLD
&& (next_green_score > 0.04 || next_white_score > 0.08 || next_alpha < 180);
if next_alpha < 40
|| next_green_score > 0.12
|| next_white_score > 0.32
|| reachable_soft_edge
{
background_mask[next_pixel_index] = 1;
queue.push(next_pixel_index);
}
}
}
for _ in 0..2 {
let mut expanded_mask = background_mask.clone();
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if expanded_mask[pixel_index] != 0 {
continue;
}
let alpha = pixels[pixel_index * 4 + 3];
let hint = background_hints[pixel_index];
if alpha >= SOFT_EDGE_ALPHA_THRESHOLD || hint <= 0.06 {
continue;
}
let mut adjacent_background_count = 0usize;
for offset_y in -1i32..=1 {
for offset_x in -1i32..=1 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0
|| next_x >= width as i32
|| next_y < 0
|| next_y >= height as i32
{
continue;
}
if background_mask[next_y as usize * width + next_x as usize] != 0 {
adjacent_background_count += 1;
}
}
}
if adjacent_background_count >= 2 || (adjacent_background_count >= 1 && hint > 0.18)
{
expanded_mask[pixel_index] = 1;
}
}
}
background_mask = expanded_mask;
}
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if background_mask[pixel_index] == 0 {
continue;
}
let offset = pixel_index * 4;
let alpha = pixels[offset + 3];
if alpha == 0 {
continue;
}
let matte_score = background_hints[pixel_index]
.max(green_scores[pixel_index])
.max(white_scores[pixel_index]);
let mut foreground_support = 0usize;
for offset_y in -1i32..=1 {
for offset_x in -1i32..=1 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32
{
continue;
}
let next_pixel_index = next_y as usize * width + next_x as usize;
if background_mask[next_pixel_index] != 0 {
continue;
}
let next_alpha = pixels[next_pixel_index * 4 + 3];
if next_alpha >= FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD {
foreground_support += 1;
}
}
}
let next_alpha = if matte_score > 0.9 || foreground_support == 0 {
0
} else if matte_score > 0.72 && foreground_support <= 1 {
((alpha as f32) * 0.08).round() as u8
} else {
((alpha as f32) * (0.08f32.max(1.0 - matte_score * 0.95))).round() as u8
};
let mut next_alpha = next_alpha;
if foreground_support >= 3 && matte_score < 0.55 {
next_alpha = next_alpha.max(((alpha as f32) * 0.22).round() as u8);
}
if next_alpha < 10 {
next_alpha = 0;
}
if next_alpha != alpha {
pixels[offset + 3] = next_alpha;
changed = true;
}
}
}
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
let offset = pixel_index * 4;
let alpha = pixels[offset + 3];
if alpha == 0 {
continue;
}
let mut touches_transparent_edge = false;
for offset_y in -1i32..=1 {
for offset_x in -1i32..=1 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32
{
touches_transparent_edge = true;
continue;
}
let next_pixel_index = next_y as usize * width + next_x as usize;
if background_mask[next_pixel_index] != 0
|| pixels[next_pixel_index * 4 + 3] < 16
{
touches_transparent_edge = true;
}
}
}
if !touches_transparent_edge {
continue;
}
let green_score = green_scores[pixel_index];
let white_score = white_scores[pixel_index];
let contamination = green_score
.max(white_score)
.max(if background_mask[pixel_index] != 0 {
0.35
} else {
0.0
})
.max(if alpha < 220 {
((220 - alpha) as f32 / 220.0) * 0.25
} else {
0.0
});
if contamination < 0.06 {
continue;
}
let mut red = pixels[offset] as f32;
let mut green = pixels[offset + 1] as f32;
let mut blue = pixels[offset + 2] as f32;
let sample = collect_foreground_neighbor_color(
pixels,
width,
height,
x,
y,
&background_mask,
&background_hints,
);
let blend =
clamp01(contamination.max(if touches_transparent_edge { 0.22 } else { 0.0 }));
if let Some((sample_red, sample_green, sample_blue)) = sample {
red = lerp(red, sample_red as f32, blend);
green = lerp(green, sample_green as f32, blend);
blue = lerp(blue, sample_blue as f32, blend);
if green_score > 0.04 {
green = green.min(sample_green as f32 + 18.0);
}
if white_score > 0.1 {
red = red.min(sample_red as f32 + 26.0);
green = green.min(sample_green as f32 + 26.0);
blue = blue.min(sample_blue as f32 + 26.0);
}
} else {
if green_score > 0.04 {
green = green
.max(red.max(blue))
.max((green - (green - red.max(blue)) * 0.78).round());
}
if white_score > 0.12 {
let spread = red.max(green).max(blue) - red.min(green).min(blue);
if spread < 20.0 {
let toned_value = ((red + green + blue) / 3.0 * 0.88).round();
red = red.min(toned_value);
green = green.min(toned_value);
blue = blue.min(toned_value);
}
}
}
let mut next_alpha = alpha;
let edge_fade = (green_score * 0.35).max(white_score * 0.28);
if edge_fade > 0.08 {
next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8;
if next_alpha < 10 {
next_alpha = 0;
}
}
let next_red = red.round() as u8;
let next_green = green.round() as u8;
let next_blue = blue.round() as u8;
if next_red != pixels[offset]
|| next_green != pixels[offset + 1]
|| next_blue != pixels[offset + 2]
|| next_alpha != alpha
{
pixels[offset] = next_red;
pixels[offset + 1] = next_green;
pixels[offset + 2] = next_blue;
pixels[offset + 3] = next_alpha;
changed = true;
}
}
}
changed
}
fn map_ai_task_error(error: AiTaskServiceError) -> AppError {
let status = match error {
AiTaskServiceError::TaskNotFound => StatusCode::NOT_FOUND,
AiTaskServiceError::TaskAlreadyExists => StatusCode::CONFLICT,
AiTaskServiceError::Field(_) | AiTaskServiceError::StageNotFound => StatusCode::BAD_REQUEST,
AiTaskServiceError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
AppError::from_status(status).with_details(json!({
"provider": "ai-task",
"message": error.to_string(),
}))
}
fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
}))
}
fn map_character_animation_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn map_character_animation_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}
fn character_animation_error_response(
request_context: &RequestContext,
error: AppError,
) -> Response {
error.into_response_with_context(Some(request_context))
}
fn editor_video_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
pub(crate) struct MotionTemplate {
pub(crate) id: &'static str,
pub(crate) label: &'static str,
pub(crate) animation: &'static str,
pub(crate) prompt_suffix: &'static str,
pub(crate) notes: &'static str,
}
impl MotionTemplate {
fn to_payload(&self) -> CharacterAnimationTemplatePayload {
CharacterAnimationTemplatePayload {
id: self.id.to_string(),
label: self.label.to_string(),
animation: self.animation.to_string(),
prompt_suffix: self.prompt_suffix.to_string(),
notes: self.notes.to_string(),
}
}
}
struct ParsedVideoDataUrl {
mime_type: String,
extension: String,
bytes: Vec<u8>,
}
struct ParsedAnimationJsonPayload {
payload: Value,
}
struct ArkVideoSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
model: String,
}
struct EditorCharacterAnimationSettings {
ark: ArkVideoSettings,
resolution: String,
ratio: String,
duration_seconds: u32,
}
struct EditorVideoSettings {
ark: ArkVideoSettings,
}
#[derive(Debug)]
struct NormalizedEditorCharacterAnimationRequest {
source_layer_id: 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,
screen_color: EditorScreenBackgroundColor,
}
#[derive(Debug)]
struct NormalizedEditorVideoRequest {
prompt: String,
model: String,
provider_model: String,
aspect_ratio: String,
resolution: String,
duration_seconds: u32,
generate_audio: bool,
price_mud_points: u32,
width: u32,
height: u32,
reference_image_srcs: Vec<String>,
reference_video_srcs: Vec<String>,
reference_audio_srcs: Vec<String>,
}
struct GeneratedAnimationPreview {
preview_video_path: String,
object_key: Option<String>,
asset_object_id: Option<String>,
thumbnail_src: Option<String>,
upstream_task_id: String,
submitted_prompt: String,
moderation_fallback_applied: bool,
}
struct BackendFrameExtractionSettings {
ffmpeg_path: String,
ffprobe_path: String,
timeout_ms: u64,
}
struct AnimationFrameExtractionPlan {
frame_count: u32,
apply_chroma_key: bool,
prepare_for_bgfilter_input: bool,
sample_start_ratio: f32,
sample_end_ratio: f32,
}
struct FinalizedAnimationFrame {
bytes: Vec<u8>,
mime_type: String,
extension: String,
}
struct PersistedEditorCharacterAnimationFrameSet {
frames: Vec<EditorCharacterAnimationFramePayload>,
first_frame_object_key: String,
first_frame_asset_object_id: String,
}
struct ProcessedEditorCharacterAnimationFrame {
payload: EditorCharacterAnimationFramePayload,
object_key: String,
asset_object_id: String,
}
// 统一收口动作生成阶段返回的草稿载荷,避免图片序列和视频预览分支在 handler 层分叉太散。
struct CharacterAnimationGeneratedDraft {
image_sources: Vec<String>,
preview_video_path: Option<String>,
}
// 统一描述从 data URL、OSS 或仓库内占位资源读取后的媒体对象。
struct MediaPayload {
mime_type: String,
extension: String,
bytes: Vec<u8>,
}
// 发布整套动作后,当前阶段只需要把旧前端依赖的 animationMap 返回出去。
struct PublishedAnimationSet {
animation_map: Value,
}
// 发布单个动作时,同时产出动作级 manifest 与前端直接消费的 animationMap 配置。
struct PublishedAnimationAction {
manifest: Value,
animation_config: Value,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppConfig;
#[test]
fn parse_video_data_url_accepts_mp4_payload() {
let parsed =
parse_video_data_url("data:video/mp4;base64,aGVsbG8=").expect("video should parse");
assert_eq!(parsed.mime_type, "video/mp4");
assert_eq!(parsed.extension, "mp4");
assert_eq!(parsed.bytes, b"hello".to_vec());
}
#[test]
fn parse_video_data_url_rejects_image_payload() {
assert!(parse_video_data_url("data:image/png;base64,aGVsbG8=").is_none());
}
fn encode_rgba_png_data_url(image: &RgbaImage) -> String {
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
.write_image(
image.as_raw(),
image.width(),
image.height(),
ColorType::Rgba8.into(),
)
.expect("test image should encode");
format!("data:image/png;base64,{}", encode_base64(bytes.as_slice()))
}
#[test]
fn composites_transparent_source_onto_screen_color() {
let mut image = RgbaImage::from_pixel(8, 8, Rgba([0, 0, 0, 0]));
image.put_pixel(4, 4, Rgba([10, 20, 30, 255]));
let screen_color = crate::editor_green_screen::EDITOR_SCREEN_BACKGROUND_COLORS[0];
let composited = composite_source_image_onto_screen_color(
&encode_rgba_png_data_url(&image),
screen_color,
)
.expect("透明源图应被合成");
let payload = parse_media_data_url(&composited).expect("合成结果应是图片 data URL");
let output = image::load_from_memory(payload.bytes.as_slice())
.expect("合成结果应可解码")
.to_rgba8();
assert_eq!(
output.get_pixel(0, 0).0,
[screen_color.red, screen_color.green, screen_color.blue, 255],
"透明像素应填成背景色"
);
assert_eq!(
output.get_pixel(4, 4).0,
[10, 20, 30, 255],
"不透明像素应保持原色"
);
}
#[test]
fn opaque_source_image_skips_compositing() {
let image = RgbaImage::from_pixel(4, 4, Rgba([1, 2, 3, 255]));
let screen_color = crate::editor_green_screen::EDITOR_SCREEN_BACKGROUND_COLORS[0];
assert!(
composite_source_image_onto_screen_color(
&encode_rgba_png_data_url(&image),
screen_color
)
.is_none(),
"不透明源图应沿用原图"
);
}
#[test]
fn sanitize_storage_segment_falls_back_for_chinese_label() {
assert_eq!(
sanitize_storage_segment("参考视频", "imported-video"),
"imported-video"
);
}
#[test]
fn normalize_workflow_cache_payload_keeps_legacy_shape() {
let cache = normalize_workflow_cache_payload(
CharacterWorkflowCacheSaveRequest {
character_id: "hero".to_string(),
cache_scope_id: None,
visual_prompt_text: Some("主形象".to_string()),
animation_prompt_text: Some("待机".to_string()),
animation_prompt_text_by_key: BTreeMap::from([(
"run".to_string(),
"奔跑".to_string(),
)]),
visual_drafts: vec![CharacterVisualDraftPayload {
id: "".to_string(),
label: "".to_string(),
image_src: " /generated-character-drafts/hero/candidate.svg ".to_string(),
width: 0,
height: 0,
}],
selected_visual_draft_id: None,
selected_animation: None,
image_src: Some("".to_string()),
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: Some(json!({ "idle": { "frames": 4 } })),
},
"2026-04-22T12:00:00Z".to_string(),
);
assert_eq!(cache.character_id, "hero");
assert_eq!(cache.selected_animation, "idle");
assert_eq!(cache.animation_prompt_text_by_key["run"], "奔跑");
assert_eq!(cache.visual_drafts[0].id, "hero-draft-1");
assert_eq!(cache.visual_drafts[0].width, 1024);
assert_eq!(cache.image_src, None);
assert!(cache.animation_map.is_some());
}
#[test]
fn workflow_cache_object_key_uses_character_drafts_prefix() {
assert_eq!(
workflow_cache_object_key("Hero 01", None),
"generated-character-drafts/hero-01/workflow-cache/workflow-cache.json"
);
}
#[test]
fn workflow_cache_object_key_can_scope_by_world() {
assert_eq!(
workflow_cache_object_key("Hero 01", Some("World 99")),
"generated-character-drafts/world-99/hero-01/workflow-cache/workflow-cache.json"
);
}
#[test]
fn build_animation_generate_result_payload_keeps_image_sequence_shape() {
let payload = build_animation_generate_result_payload(&CharacterAnimationGeneratedDraft {
image_sources: vec![
"/generated-character-drafts/hero/animation/idle/task/frame-01.svg".to_string(),
],
preview_video_path: None,
});
assert_eq!(
payload,
json!({
"imageSources": [
"/generated-character-drafts/hero/animation/idle/task/frame-01.svg"
]
})
);
}
#[test]
fn build_animation_generate_result_payload_keeps_video_shape() {
let payload = build_animation_generate_result_payload(&CharacterAnimationGeneratedDraft {
image_sources: Vec::new(),
preview_video_path: Some(
"/generated-character-drafts/hero/animation/idle/task/preview.mp4".to_string(),
),
});
assert_eq!(
payload,
json!({
"previewVideoPath": "/generated-character-drafts/hero/animation/idle/task/preview.mp4"
})
);
}
#[test]
fn resolve_character_animation_model_uses_strategy_specific_field() {
let payload = CharacterAnimationGenerateRequest {
character_id: "hero".to_string(),
strategy: CharacterAnimationStrategy::MotionTransfer,
animation: "attack".to_string(),
prompt_text: "横斩".to_string(),
character_brief_text: None,
action_template_id: None,
visual_source: "/generated-characters/hero/master.svg".to_string(),
reference_image_data_urls: Vec::new(),
reference_video_data_urls: Vec::new(),
last_frame_image_data_url: None,
frame_count: 8,
fps: 8,
duration_seconds: 4,
loop_: false,
use_chroma_key: true,
resolution: "480p".to_string(),
ratio: "1:1".to_string(),
image_sequence_model: "wan-seq".to_string(),
video_model: "wan-video".to_string(),
reference_video_model: "wan-r2v".to_string(),
motion_transfer_model: "wan-move".to_string(),
};
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(),
screen_color: None,
resolution: "720p".to_string(),
ratio: "same".to_string(),
frame_count: 48,
duration_seconds: 6,
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
project_id: None,
canvas_completion: None,
generation_inputs: None,
source_resource_id: None,
asset_folder_id: None,
asset_label: Some(" 勇者待机 ".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");
assert_eq!(
resolve_editor_character_animation_asset_label(Some(" 勇者待机 ")),
"勇者待机"
);
assert_eq!(
resolve_editor_character_animation_asset_label(Some(" ")),
"角色动作"
);
assert_eq!(
resolve_editor_character_animation_asset_label(Some("动".repeat(81).as_str()))
.chars()
.count(),
EDITOR_ASSET_LABEL_MAX_CHARS
);
assert_eq!(
editor_character_animation_source_asset_label("勇者待机"),
"勇者待机(原始视频)"
);
let long_source_label =
editor_character_animation_source_asset_label("动".repeat(80).as_str());
assert_eq!(
long_source_label.chars().count(),
EDITOR_ASSET_LABEL_MAX_CHARS
);
assert!(long_source_label.ends_with("(原始视频)"));
}
#[test]
fn editor_character_animation_uses_fast_seedance_provider_model() {
let config = AppConfig {
ark_character_video_base_url: "https://ark.example.com/api/v3".to_string(),
ark_character_video_api_key: Some("ark-key".to_string()),
ark_character_video_request_timeout_ms: 420_000,
..AppConfig::default()
};
let state = AppState::new(config).expect("state should build");
let request =
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(),
screen_color: None,
resolution: "480p".to_string(),
ratio: "same".to_string(),
frame_count: 32,
duration_seconds: 4,
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
project_id: None,
canvas_completion: None,
generation_inputs: None,
source_resource_id: None,
asset_folder_id: None,
asset_label: None,
})
.expect("editor request should normalize");
let settings = require_editor_character_animation_settings(&state, &request)
.expect("settings should normalize");
assert_eq!(settings.ark.model, CHARACTER_ANIMATION_MODEL);
}
#[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(),
screen_color: None,
resolution: "480p".to_string(),
ratio: "1:1".to_string(),
frame_count: 48,
duration_seconds: 4,
model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(),
project_id: None,
canvas_completion: None,
generation_inputs: None,
source_resource_id: None,
asset_folder_id: None,
asset_label: None,
})
.expect_err("invalid frame/duration pair should fail");
assert!(error.body_text().contains("durationSeconds 必须与帧数组合"));
}
#[test]
fn editor_character_animation_builds_required_screen_background_prompt() {
let color = crate::editor_green_screen::default_editor_screen_background_color();
let prompt = build_editor_character_animation_prompt("行走两步后回到站姿。", color);
assert!(prompt.contains("生成游戏角色动画"));
assert!(prompt.contains("参考图作为首帧和尾帧"));
assert!(prompt.contains(color.hex));
assert!(prompt.contains("纯色背景必须平整无纹理、无渐变、无阴影"));
assert!(
prompt.contains("角色主体及其服饰、道具的颜色必须与背景色明显区分,不得带与背景色相同或相近的描边、投影或反光")
);
assert!(prompt.contains("动作描述:\n行走两步后回到站姿。"));
}
#[test]
fn editor_character_animation_price_depends_on_resolution_and_duration() {
assert_eq!(
crate::editor_generation_config::editor_character_animation_model_mud_points(
Some(EDITOR_CHARACTER_ANIMATION_MODEL),
"480p",
4,
),
40
);
assert_eq!(
crate::editor_generation_config::editor_character_animation_model_mud_points(
Some(EDITOR_CHARACTER_ANIMATION_MODEL),
"720p",
6,
),
120
);
}
#[test]
fn editor_character_animation_and_video_generation_are_billable() {
let source = include_str!("character_animation_assets.rs");
assert_function_contains(
source,
"pub(crate) async fn generate_editor_character_animation_for_owner",
"pub async fn generate_editor_video",
&[
"execute_billable_asset_operation_with_cost",
"EDITOR_CHARACTER_ANIMATION_ASSET_KIND",
"normalized.price_mud_points",
],
);
assert_function_contains(
source,
"pub(crate) async fn generate_editor_video_for_owner",
"pub async fn get_character_animation_job",
&[
"execute_billable_asset_operation_with_cost",
"EDITOR_VIDEO_ASSET_KIND",
"normalized.price_mud_points",
],
);
}
#[test]
fn editor_character_animation_rejects_inline_media_before_queueing() {
let source = include_str!("character_animation_assets.rs");
assert_function_contains_in_order(
source,
"pub async fn generate_editor_character_animation",
"pub(crate) async fn generate_editor_character_animation_for_owner",
&[
"matches_inline_media_source",
"enqueue_editor_generation_job",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn generate_editor_character_animation_for_owner",
"pub async fn generate_editor_video",
&[
"resolve_editor_reference_object_key_for_owner",
"resolve_media_source_as_data_url",
],
);
assert!(matches_inline_media_source("data:image/png;base64,AAAA"));
assert!(matches_inline_media_source(" BLOB:local-preview"));
assert!(!matches_inline_media_source(
"/generated-images/editor/character.png"
));
}
#[test]
fn editor_character_animation_reports_processing_after_video_generation() {
let source = include_str!("character_animation_assets.rs");
// 扣费契约:角色动作的交付物是透明序列帧,预览落库、phase 上报、抽帧、首帧落库
// 全部在计费闭包内收口之前完成,任一步失败必须触发退款。
assert_function_contains_in_order(
source,
"pub(crate) async fn generate_editor_character_animation_for_owner",
"pub async fn generate_editor_video",
&[
"execute_billable_asset_operation_with_cost",
"request_editor_character_animation_preview",
"persist_editor_generated_media_asset",
"reporter.report_processing(&state).await?",
"extract_and_persist_editor_character_animation_frames",
"Ok::<_, AppError>((generated, frames, normalized, generation_inputs))",
],
);
}
#[test]
fn editor_character_animation_bgfilter_frames_share_budget_helper_without_per_frame_timeout() {
let source = include_str!("character_animation_assets.rs");
// 动画帧不携带任何自定 timeout:预算全部由共享 helper 按 N/est 公式派生。
assert_function_contains(
source,
"async fn extract_and_persist_editor_character_animation_frames",
"async fn process_and_persist_editor_character_animation_frame",
&["process_and_persist_editor_character_animation_frame"],
);
assert!(!source.contains(concat!(
"EDITOR_CHARACTER_ANIMATION_BGFILTER_TIMEOUT_",
"PER_FRAME_MS"
)));
assert!(!source.contains(concat!(
"editor_character_animation_bgfilter_",
"request_timeout_ms"
)));
assert!(!source.contains(concat!("bgfilter_request_", "timeout_ms =")));
}
#[test]
fn editor_character_animation_bgfilter_input_is_rgb_without_padding() {
let source = image::RgbImage::from_pixel(560, 752, image::Rgb([17, 99, 201]));
let mut source_png = Vec::new();
PngEncoder::new(&mut source_png)
.write_image(
source.as_raw(),
source.width(),
source.height(),
ColorType::Rgb8.into(),
)
.expect("source RGB frame should encode");
let prepared = prepare_editor_character_animation_bgfilter_input(
source_png.as_slice(),
"image/png",
323,
480,
)
.expect("BgFilter input should be prepared");
let decoded =
image::load_from_memory_with_format(prepared.bytes.as_slice(), ImageFormat::Png)
.expect("prepared BgFilter input should decode");
assert_eq!(prepared.mime_type, "image/png");
assert_eq!(prepared.extension, "png");
assert_eq!(decoded.width(), 323);
assert_eq!(decoded.height(), 434);
assert_eq!(decoded.color(), ColorType::Rgb8);
assert!(
decoded
.to_rgb8()
.pixels()
.all(|pixel| pixel.0 == [17, 99, 201])
);
}
#[test]
fn editor_character_animation_bgfilter_input_flattens_alpha_onto_white() {
let mut source = RgbaImage::from_pixel(8, 8, Rgba([17, 99, 201, 255]));
source.put_pixel(0, 0, Rgba([255, 0, 0, 0]));
source.put_pixel(1, 0, Rgba([0, 0, 0, 127]));
let mut source_png = Vec::new();
PngEncoder::new(&mut source_png)
.write_image(
source.as_raw(),
source.width(),
source.height(),
ColorType::Rgba8.into(),
)
.expect("source RGBA frame should encode");
let prepared = prepare_editor_character_animation_bgfilter_input(
source_png.as_slice(),
"image/png",
8,
8,
)
.expect("BgFilter input should be prepared");
let decoded =
image::load_from_memory_with_format(prepared.bytes.as_slice(), ImageFormat::Png)
.expect("prepared BgFilter input should decode")
.to_rgb8();
assert_eq!(decoded.get_pixel(0, 0).0, [255, 255, 255]);
assert_eq!(decoded.get_pixel(1, 0).0, [128, 128, 128]);
assert_eq!(decoded.get_pixel(2, 0).0, [17, 99, 201]);
}
#[test]
fn editor_character_animation_final_frame_adds_transparent_vertical_padding() {
let source = RgbaImage::from_pixel(323, 434, Rgba([31, 127, 223, 191]));
let mut source_png = Vec::new();
PngEncoder::new(&mut source_png)
.write_image(
source.as_raw(),
source.width(),
source.height(),
ColorType::Rgba8.into(),
)
.expect("source RGBA frame should encode");
let finalized =
finalize_animation_frame_payload(source_png.as_slice(), "image/png", 323, 480, false)
.expect("final transparent frame should be finalized");
let decoded =
image::load_from_memory_with_format(finalized.bytes.as_slice(), ImageFormat::Png)
.expect("final transparent frame should decode");
assert_eq!(decoded.width(), 323);
assert_eq!(decoded.height(), 480);
assert_eq!(decoded.color(), ColorType::Rgba8);
let output = decoded.to_rgba8();
for (x, y, pixel) in output.enumerate_pixels() {
let expected = if (23..457).contains(&y) {
[31, 127, 223, 191]
} else {
[0, 0, 0, 0]
};
assert_eq!(pixel.0, expected, "unexpected pixel at ({x}, {y})");
}
}
#[test]
fn editor_character_animation_frames_use_three_stage_matting_fallback() {
let source = include_str!("character_animation_assets.rs");
assert_function_contains(
source,
"async fn extract_and_persist_editor_character_animation_frames",
"async fn publish_animation_set",
&[
"apply_chroma_key: false",
"prepare_for_bgfilter_input: true",
"process_and_persist_editor_character_animation_frame",
".buffer_unordered(frame_count.max(1))",
".collect::<Vec<_>>()",
"frame_payloads.sort_by_key",
"frame_errors.sort_by_key",
],
);
assert_function_contains(
source,
"fn normalize_animation_frame_extraction_plan",
"fn normalize_sample_ratio",
&["apply_chroma_key", "prepare_for_bgfilter_input: false"],
);
assert_function_contains_in_order(
source,
"async fn extract_animation_frames_from_preview_video",
"fn create_animation_temp_dir",
&[
"plan.prepare_for_bgfilter_input",
"prepare_editor_character_animation_bgfilter_input",
"finalize_animation_frame_payload",
],
);
assert_function_contains_in_order(
source,
"async fn process_and_persist_editor_character_animation_frame",
"async fn publish_animation_set",
&[
"put_character_animation_frame_object",
"green-screen-frame",
"remove_editor_generated_screen_background_with_bgfilter",
"source_put.object_key.as_str()",
"finalize_animation_frame_payload",
"put_character_animation_frame_object",
"animation_frame",
],
);
assert_function_contains(
source,
"async fn process_and_persist_editor_character_animation_frame",
"async fn publish_animation_set",
&[
"EDITOR_CHARACTER_ANIMATION_ASSET_KIND",
"EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT",
"green-screen-frame",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_DEFAULT_SEG_MODEL",
"EDITOR_BGFILTER_CROSS_CHECK_ENABLED",
"finalize_animation_frame_payload",
],
);
let frame_pipeline = source
.split_once("async fn process_and_persist_editor_character_animation_frame")
.and_then(|(_, tail)| tail.split_once("async fn publish_animation_set"))
.map(|(body, _)| body)
.expect("frame pipeline function should exist");
assert!(!frame_pipeline.contains("frame.bytes.clone()"));
assert!(!frame_pipeline.contains("DownloadedOpenAiImage"));
}
#[test]
fn editor_character_animation_result_metadata_keeps_the_whole_frame_set() {
let frames = vec![
EditorCharacterAnimationFramePayload {
frame_index: 1,
image_src: "/generated-animations/editor/layer/task/frame01.png".to_string(),
width: 192,
height: 256,
},
EditorCharacterAnimationFramePayload {
frame_index: 2,
image_src: "/generated-animations/editor/layer/task/frame02.png".to_string(),
width: 192,
height: 256,
},
];
let metadata = attach_editor_character_animation_result_metadata(
Some(json!({
"fields": [{ "title": "动作", "value": "待机" }],
"references": [],
})),
"/generated-character-drafts/editor/layer/task/preview.mp4",
frames.as_slice(),
8,
4,
)
.expect("character animation metadata should exist");
assert_eq!(metadata["fields"][0]["value"], "待机");
assert_eq!(metadata["characterAnimation"]["frameCount"], 2);
assert_eq!(metadata["characterAnimation"]["fps"], 8);
assert_eq!(metadata["characterAnimation"]["durationSeconds"], 4);
assert_eq!(
metadata["characterAnimation"]["previewVideoPath"],
"/generated-character-drafts/editor/layer/task/preview.mp4"
);
assert_eq!(
metadata["characterAnimation"]["frames"][1]["imageSrc"],
"/generated-animations/editor/layer/task/frame02.png"
);
}
#[test]
fn editor_character_animation_persists_one_final_asset_after_frame_extraction() {
let source = include_str!("character_animation_assets.rs");
assert_function_contains_in_order(
source,
"pub(crate) async fn generate_editor_character_animation_for_owner",
"pub async fn generate_editor_video",
&[
"let persisted_frames = extract_and_persist_editor_character_animation_frames",
"attach_editor_character_animation_result_metadata",
"project_id: None",
"label: asset_label.clone()",
"EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND",
"generation_cost_mud_points: 0",
],
);
assert_function_contains(
source,
"async fn extract_and_persist_editor_character_animation_frames",
"async fn process_and_persist_editor_character_animation_frame",
&["first_frame_object_key", "first_frame_asset_object_id"],
);
assert_function_contains(
source,
"async fn process_and_persist_editor_character_animation_frame",
"async fn confirm_editor_character_animation_frame_asset_object",
&["confirm_editor_character_animation_frame_asset_object"],
);
assert_function_contains(
source,
"async fn confirm_editor_character_animation_frame_asset_object",
"async fn bind_character_animation_asset",
&["EDITOR_CHARACTER_ANIMATION_ASSET_KIND"],
);
}
#[test]
fn editor_video_intermediate_outputs_are_registered_before_derivatives() {
let source = include_str!("character_animation_assets.rs");
assert_function_contains_in_order(
source,
"pub(crate) async fn generate_editor_character_animation_for_owner",
"pub async fn generate_editor_video",
&[
"request_editor_character_animation_preview",
"persist_editor_generated_media_asset",
"extract_and_persist_editor_character_animation_frames",
],
);
assert_function_contains(
source,
"pub(crate) async fn generate_editor_character_animation_for_owner",
"pub async fn generate_editor_video",
&[
"or_else(|| Some(\"project\".to_string()))",
"EDITOR_CHARACTER_ANIMATION_RESOURCE_ASSET_KIND",
"generation_cost_mud_points: u64::from(normalized.price_mud_points)",
],
);
assert_function_contains_in_order(
source,
"async fn put_generated_editor_character_animation_preview",
"async fn request_editor_character_animation_preview",
&[
"put_character_animation_object",
"confirm_editor_character_animation_source_asset_object",
],
);
assert_function_contains_in_order(
source,
"async fn put_generated_editor_video",
"async fn put_generated_editor_video_cover",
&[
"put_character_animation_object",
"confirm_editor_video_asset_object",
"match put_generated_editor_video_cover",
"None",
],
);
}
#[test]
fn editor_character_animation_samples_last_frame_before_decode_tail() {
let last_frame_seconds = compute_sample_time_seconds(4.0, 31, 32, 0.0, 1.0, false);
assert!((last_frame_seconds - 3.875).abs() < f64::EPSILON);
}
#[test]
fn editor_character_animation_builds_one_filter_graph_for_all_samples() {
let filter_graph = build_batch_frame_filter_graph(&[0.0, 1.0, 2.0, 3.875]);
assert_eq!(
filter_graph,
"[0:v]setpts=PTS-STARTPTS,split=4[s0][s1][s2][s3];[s0]select=gte(t\\,0.000)[f0];[s1]select=gte(t\\,1.000)[f1];[s2]select=gte(t\\,2.000)[f2];[s3]select=gte(t\\,3.875)[f3]"
);
}
#[test]
fn editor_character_animation_batch_extracts_all_samples_from_short_video() {
if Command::new("ffmpeg").arg("-version").output().is_err() {
return;
}
let temp_dir = create_animation_temp_dir().expect("temp dir should be created");
let input_path = temp_dir.join("short-preview.mp4");
let make_video_status = Command::new("ffmpeg")
.args([
"-y",
"-f",
"lavfi",
"-i",
"testsrc=size=160x120:rate=8:duration=6",
"-pix_fmt",
"yuv420p",
input_path.to_string_lossy().as_ref(),
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("ffmpeg should start");
if !make_video_status.success() {
let _ = fs::remove_dir_all(&temp_dir);
return;
}
let target_seconds = (0..48)
.map(|frame_index| compute_sample_time_seconds(6.0, frame_index, 48, 0.0, 1.0, false))
.collect::<Vec<_>>();
let output_paths = (0..48)
.map(|frame_index| temp_dir.join(format!("raw-frame-{:02}.png", frame_index + 1)))
.collect::<Vec<_>>();
let settings = BackendFrameExtractionSettings {
ffmpeg_path: "ffmpeg".to_string(),
ffprobe_path: "ffprobe".to_string(),
timeout_ms: 10_000,
};
let result = extract_video_frames_to_png(
&input_path,
output_paths.as_slice(),
target_seconds.as_slice(),
&settings,
);
let output_count = output_paths
.iter()
.filter(|output_path| output_path.is_file())
.count();
let missing_output_paths = [
temp_dir.join("missing-check-frame-01.png"),
temp_dir.join("missing-check-frame-02.png"),
];
let missing_target_seconds = [0.0, 60.0];
let missing_error = extract_video_frames_to_png(
&input_path,
missing_output_paths.as_slice(),
missing_target_seconds.as_slice(),
&settings,
)
.expect_err("a successful ffmpeg exit with a missing frame should still fail the batch");
let missing_details = missing_error
.details()
.cloned()
.expect("missing frame error should keep diagnostics");
let _ = fs::remove_dir_all(&temp_dir);
result.expect("one ffmpeg process should extract every safe sample");
assert_eq!(output_count, 48);
assert_eq!(
missing_error.body_text(),
"ffmpeg 已执行但未产出动作帧文件。"
);
assert_eq!(missing_details["targetSeconds"], 60.0);
assert_eq!(missing_details["missingFrames"][0]["frameIndex"], 2);
assert_eq!(missing_details["missingFrames"][0]["targetSeconds"], 60.0);
}
#[test]
fn editor_character_animation_uses_known_duration_when_ffprobe_is_missing() {
let settings = BackendFrameExtractionSettings {
ffmpeg_path: "ffmpeg".to_string(),
ffprobe_path: "__missing_ffprobe_for_editor_animation_test__".to_string(),
timeout_ms: 1_000,
};
let duration =
probe_video_duration_seconds_or_known(Path::new("preview.mp4"), &settings, Some(6.0))
.expect("known duration should cover a missing ffprobe binary");
assert_eq!(duration, 6.0);
}
#[test]
fn editor_character_animation_keeps_ffprobe_error_without_known_duration() {
let settings = BackendFrameExtractionSettings {
ffmpeg_path: "ffmpeg".to_string(),
ffprobe_path: "__missing_ffprobe_for_editor_animation_test__".to_string(),
timeout_ms: 1_000,
};
let error =
probe_video_duration_seconds_or_known(Path::new("preview.mp4"), &settings, None)
.expect_err("missing ffprobe should still fail without a trusted duration");
assert!(error.body_text().contains("无法启动进程"));
}
#[test]
fn editor_video_normalizes_lovart_model_contract() {
let normalized = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: " 让角色向镜头挥手 ".to_string(),
model: EDITOR_VIDEO_MODEL_KLING_3_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("editor video request should normalize");
assert_eq!(normalized.prompt, "让角色向镜头挥手");
assert_eq!(normalized.model, EDITOR_VIDEO_MODEL_KLING_3_OMNI);
assert_eq!(normalized.provider_model, EDITOR_VIDEO_MODEL_KLING_3_OMNI);
assert_eq!(normalized.width, 854);
assert_eq!(normalized.height, 480);
assert_eq!(normalized.price_mud_points, 100);
}
#[test]
fn editor_video_seedance_fast_uses_existing_fast_seedance_model() {
let normalized = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "快速生成镜头推进。".to_string(),
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 4,
resolution: "720p".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("seedance fast request should normalize");
assert_eq!(normalized.provider_model, CHARACTER_ANIMATION_MODEL);
assert_eq!(normalized.width, 1280);
assert_eq!(normalized.height, 720);
}
#[test]
fn editor_video_seedance_standard_uses_non_fast_seedance_model() {
let normalized = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "生成更稳定的电影感镜头。".to_string(),
model: EDITOR_VIDEO_MODEL_SEEDANCE_2.to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 4,
resolution: "720p".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("seedance standard request should normalize");
assert_eq!(normalized.model, EDITOR_VIDEO_MODEL_SEEDANCE_2);
assert_eq!(normalized.provider_model, "doubao-seedance-2-0-260128");
assert_ne!(normalized.provider_model, CHARACTER_ANIMATION_MODEL);
}
#[test]
fn editor_video_supports_extra_ratios_duration_range_and_non_fast_1080p() {
let normalized = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "生成竖版电影感镜头。".to_string(),
model: EDITOR_VIDEO_MODEL_SEEDANCE_2.to_string(),
aspect_ratio: "9:16".to_string(),
duration_seconds: 15,
resolution: "1080p".to_string(),
mode: "std".to_string(),
sound: "on".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("seedance standard 1080p request should normalize");
assert_eq!(normalized.aspect_ratio, "9:16");
assert_eq!(normalized.width, 608);
assert_eq!(normalized.height, 1080);
assert!(normalized.generate_audio);
}
#[test]
fn editor_video_rejects_seedance_fast_1080p() {
let error = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "快速生成 1080p 镜头。".to_string(),
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 4,
resolution: "1080p".to_string(),
mode: "std".to_string(),
sound: "on".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_err("seedance fast should reject 1080p");
assert!(error.body_text().contains("seedance2.0-fast 不支持 1080p"));
}
#[test]
fn editor_video_normalizes_seedance_multimodal_references() {
let normalized = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "参考素材生成视频".to_string(),
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_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("seedance multimodal request should normalize");
assert_eq!(
normalized.reference_image_srcs,
vec!["generated-character-drafts/editor/seedance-references/image/demo.png"]
);
assert_eq!(
normalized.reference_video_srcs,
vec!["generated-character-drafts/editor/seedance-references/video/demo.mp4"]
);
assert_eq!(
normalized.reference_audio_srcs,
vec!["generated-character-drafts/editor/seedance-references/audio/demo.mp3"]
);
}
#[test]
fn editor_video_rejects_seedance_video_data_urls() {
let error = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "参考视频生成视频".to_string(),
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 4,
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!["data:video/mp4;base64,video".to_string()],
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_err("Seedance reference videos must not accept Base64 data URLs");
assert!(error.body_text().contains("参考视频格式不支持"));
}
#[test]
fn editor_video_rejects_seedance_image_and_audio_data_urls() {
for (image_srcs, audio_srcs, expected_message) in [
(
vec!["data:image/png;base64,image".to_string()],
Vec::new(),
"参考图片格式不支持",
),
(
vec!["generated-character-drafts/editor/reference.png".to_string()],
vec!["data:audio/mpeg;base64,audio".to_string()],
"参考音频格式不支持",
),
] {
let error = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "参考素材生成视频".to_string(),
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 4,
resolution: "480p".to_string(),
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
reference_image_srcs: image_srcs,
reference_video_srcs: Vec::new(),
reference_audio_srcs: audio_srcs,
project_id: None,
canvas_completion: None,
generation_inputs: None,
source_resource_id: None,
asset_kind: None,
asset_folder_id: None,
asset_label: None,
})
.expect_err("Seedance references must not accept data URLs");
assert!(error.body_text().contains(expected_message));
}
}
#[tokio::test]
async fn editor_video_ark_body_sets_generate_audio_false_without_mode() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "参考素材生成视频".to_string(),
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 4,
resolution: "480p".to_string(),
mode: "std".to_string(),
sound: "off".to_string(),
web_search_enabled: true,
reference_image_srcs: vec!["https://assets.example.com/reference.png".to_string()],
reference_video_srcs: vec!["https://assets.example.com/reference.mp4".to_string()],
reference_audio_srcs: vec!["https://assets.example.com/reference.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("seedance multimodal request should normalize");
let body = build_editor_video_ark_request_body(&state, "video-owner", &request)
.await
.expect("ark body should build");
assert_eq!(body["generate_audio"], json!(false));
assert!(body.get("mode").is_none());
assert_eq!(body["content"][1]["role"], json!("reference_image"));
assert_eq!(body["content"][2]["role"], json!("reference_video"));
assert_eq!(body["content"][3]["role"], json!("reference_audio"));
assert_eq!(
body["content"][2]["video_url"]["url"],
json!("https://assets.example.com/reference.mp4")
);
}
#[test]
fn editor_video_reference_resolution_checks_owner_before_signing() {
let state = AppState::new(AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
})
.expect("state should build with OSS");
let object_key = "generated-character-drafts/editor/seedance-references/video/demo.mp4";
let signed_url = sign_editor_video_object_key_url(&state, object_key)
.expect("validated object keys should be signable");
assert!(signed_url.starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/"));
assert!(signed_url.contains(object_key));
assert!(signed_url.contains("x-oss-signature="));
let registered_reference = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "参考视频生成视频".to_string(),
model: EDITOR_VIDEO_MODEL_SEEDANCE_2_FAST.to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 4,
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!["editor-resource-video-reference".to_string()],
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("registered editor resource IDs should normalize");
assert_eq!(
registered_reference.reference_video_srcs,
vec!["editor-resource-video-reference"]
);
let source = include_str!("character_animation_assets.rs");
assert_function_contains_in_order(
source,
"async fn resolve_editor_video_reference_url",
"fn sign_editor_video_object_key_url",
&[
"resolve_editor_reference_object_key_for_owner",
"sign_editor_video_object_key_url",
],
);
}
#[test]
fn editor_video_rejects_audio_only_seedance_references() {
let error = normalize_editor_video_request(EditorVideoGenerateRequest {
prompt: "根据旁白生成视频".to_string(),
model: EDITOR_VIDEO_MODEL_SEEDANCE_2.to_string(),
aspect_ratio: "16:9".to_string(),
duration_seconds: 4,
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!["data:audio/wav;base64,audio".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_err("audio-only references should fail");
assert!(
error
.body_text()
.contains("参考音频必须搭配参考图片或参考视频")
);
}
#[test]
fn seedance_copyright_restriction_error_is_localized() {
let raw = json!({
"error": {
"message": "The request failed because the output video may be related to copyright restrictions. Request id: 0217817823139540000000000000000000ffffac191c1d86f05"
}
})
.to_string();
let message = parse_animation_api_error_message(&raw, "创建画板视频任务失败。");
assert_eq!(
message,
"生成失败:输出视频可能涉及版权限制,请调整提示词或更换参考素材后重试。"
);
assert!(!message.contains("copyright"));
assert!(!message.contains("Request id"));
}
#[test]
fn raw_seedance_copyright_restriction_error_is_localized() {
let message = parse_animation_api_error_message(
"The request failed because the output video may be related to copyright restrictions. Request id: abc",
"创建画板视频任务失败。",
);
assert_eq!(
message,
"生成失败:输出视频可能涉及版权限制,请调整提示词或更换参考素材后重试。"
);
}
fn assert_function_contains(source: &str, start: &str, end: &str, snippets: &[&str]) {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let tail = &source[start_index..];
let end_index = tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &tail[..end_index];
for snippet in snippets {
assert!(
body.contains(snippet),
"function body starting with {start} should contain {snippet}"
);
}
}
fn assert_function_contains_in_order(source: &str, start: &str, end: &str, snippets: &[&str]) {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let tail = &source[start_index..];
let end_index = tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &tail[..end_index];
let mut cursor = 0;
for snippet in snippets {
let next = body[cursor..]
.find(snippet)
.unwrap_or_else(|| panic!("function body starting with {start} should contain {snippet} after offset {cursor}"));
cursor += next + snippet.len();
}
}
}
#[cfg(any())]
use crate::prompt::role_asset_studio::build_role_asset_workflow;