a2ee879fc8
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>
7312 lines
264 KiB
Rust
7312 lines
264 KiB
Rust
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('&', "&")
|
||
.replace('<', "<")
|
||
.replace('>', ">")
|
||
}
|
||
|
||
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;
|