1
This commit is contained in:
@@ -28,7 +28,9 @@ use webp::Encoder as WebpEncoder;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
asset_billing::execute_billable_asset_operation,
|
||||
asset_billing::{
|
||||
execute_billable_asset_operation, execute_billable_asset_operation_with_cost,
|
||||
},
|
||||
auth::AuthenticatedAccessToken,
|
||||
custom_world_result_prompts::{
|
||||
build_result_entity_system_prompt, build_result_entity_user_prompt,
|
||||
@@ -115,6 +117,12 @@ pub(crate) struct CustomWorldCoverUploadRequest {
|
||||
crop_rect: CustomWorldCoverCropRect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CustomWorldOpeningCgGenerateRequest {
|
||||
profile: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeneratedAssetResponse {
|
||||
@@ -133,6 +141,38 @@ struct GeneratedAssetResponse {
|
||||
actual_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeneratedOpeningCgResponse {
|
||||
opening_cg: CustomWorldOpeningCgProfileResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CustomWorldOpeningCgProfileResponse {
|
||||
id: String,
|
||||
status: &'static str,
|
||||
storyboard_image_src: String,
|
||||
storyboard_asset_id: String,
|
||||
video_src: String,
|
||||
video_asset_id: String,
|
||||
poster_image_src: Option<String>,
|
||||
poster_asset_id: Option<String>,
|
||||
storyboard_prompt: String,
|
||||
video_prompt: String,
|
||||
image_model: &'static str,
|
||||
video_model: String,
|
||||
aspect_ratio: &'static str,
|
||||
image_size: &'static str,
|
||||
video_resolution: &'static str,
|
||||
duration_seconds: u32,
|
||||
point_cost: u64,
|
||||
estimated_wait_minutes: u32,
|
||||
generated_at: String,
|
||||
updated_at: String,
|
||||
error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct GeneratedCustomWorldSceneImage {
|
||||
pub image_src: String,
|
||||
@@ -317,6 +357,22 @@ struct DownloadedRemoteImage {
|
||||
}
|
||||
|
||||
const RPG_SCENE_IMAGE_MODEL: &str = GPT_IMAGE_2_MODEL;
|
||||
const OPENING_CG_POINTS_COST: u64 = 80;
|
||||
const OPENING_CG_ESTIMATED_WAIT_MINUTES: u32 = 10;
|
||||
const OPENING_CG_IMAGE_SIZE_LABEL: &str = "2k";
|
||||
const OPENING_CG_STORYBOARD_IMAGE_SIZE: &str = "2048x1152";
|
||||
const OPENING_CG_VIDEO_PROMPT: &str = "利用参考图作为故事板,生成一段连贯的动画,没有旁白";
|
||||
const OPENING_CG_VIDEO_RESOLUTION: &str = "480p";
|
||||
const OPENING_CG_VIDEO_RATIO: &str = "16:9";
|
||||
const OPENING_CG_VIDEO_DURATION_SECONDS: u32 = 15;
|
||||
const OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS: u64 = 600_000;
|
||||
const OPENING_CG_ASPECT_RATIO: &str = "16:9";
|
||||
const OPENING_CG_STORYBOARD_ASSET_KIND: &str = "custom_world_opening_cg_storyboard";
|
||||
const OPENING_CG_VIDEO_ASSET_KIND: &str = "custom_world_opening_cg_video";
|
||||
const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile";
|
||||
const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard";
|
||||
const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video";
|
||||
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
|
||||
|
||||
struct CoverPromptContext {
|
||||
opening_act_title: String,
|
||||
@@ -336,6 +392,39 @@ struct NormalizedSceneImageRequest {
|
||||
reference_image_src: Option<String>,
|
||||
}
|
||||
|
||||
struct NormalizedOpeningCgRequest {
|
||||
profile_id: Option<String>,
|
||||
world_name: String,
|
||||
opening_cg_id: String,
|
||||
storyboard_prompt: String,
|
||||
video_prompt: String,
|
||||
player_role_image_src: String,
|
||||
opening_scene_image_src: String,
|
||||
}
|
||||
|
||||
struct ArkVideoSettings {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
request_timeout_ms: u64,
|
||||
model: String,
|
||||
}
|
||||
|
||||
struct GeneratedOpeningCgStoryboard {
|
||||
image_src: String,
|
||||
asset_id: String,
|
||||
}
|
||||
|
||||
struct GeneratedOpeningCgVideo {
|
||||
video_src: String,
|
||||
asset_id: String,
|
||||
}
|
||||
|
||||
struct DownloadedRemoteVideo {
|
||||
mime_type: String,
|
||||
extension: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NormalizedCropRect {
|
||||
left: u32,
|
||||
@@ -884,6 +973,119 @@ pub async fn upload_custom_world_cover_image(
|
||||
Ok(json_success_body(Some(&request_context), asset))
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_opening_cg(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<CustomWorldOpeningCgGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
custom_world_ai_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-opening-cg",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let normalized = normalize_opening_cg_request(&payload.profile)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
require_openai_image_settings(&state)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
require_ark_video_settings(&state)
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
|
||||
let opening_cg_id = normalized.opening_cg_id.clone();
|
||||
let generated = execute_billable_asset_operation_with_cost(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
"custom_world_opening_cg",
|
||||
opening_cg_id.as_str(),
|
||||
OPENING_CG_POINTS_COST,
|
||||
async {
|
||||
let image_settings = require_openai_image_settings(&state)?;
|
||||
let image_http_client = build_openai_image_http_client(&image_settings)?;
|
||||
let video_settings = require_ark_video_settings(&state)?;
|
||||
let video_http_client = build_upstream_http_client(video_settings.request_timeout_ms)?;
|
||||
let player_role_reference = resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&image_http_client,
|
||||
normalized.player_role_image_src.as_str(),
|
||||
"playerRoleImageSrc",
|
||||
)
|
||||
.await?;
|
||||
let opening_scene_reference = resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&image_http_client,
|
||||
normalized.opening_scene_image_src.as_str(),
|
||||
"openingSceneImageSrc",
|
||||
)
|
||||
.await?;
|
||||
let storyboard = generate_opening_cg_storyboard(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&image_http_client,
|
||||
&image_settings,
|
||||
&normalized,
|
||||
&[player_role_reference, opening_scene_reference],
|
||||
)
|
||||
.await?;
|
||||
let storyboard_reference = resolve_reference_image_as_data_url(
|
||||
&state,
|
||||
&image_http_client,
|
||||
storyboard.image_src.as_str(),
|
||||
"storyboardImageSrc",
|
||||
)
|
||||
.await?;
|
||||
let video = generate_opening_cg_video(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&video_http_client,
|
||||
&video_settings,
|
||||
&normalized,
|
||||
storyboard_reference.as_str(),
|
||||
)
|
||||
.await?;
|
||||
let generated_at = current_utc_iso_text();
|
||||
|
||||
Ok(CustomWorldOpeningCgProfileResponse {
|
||||
id: opening_cg_id.clone(),
|
||||
status: "ready",
|
||||
storyboard_image_src: storyboard.image_src,
|
||||
storyboard_asset_id: storyboard.asset_id,
|
||||
video_src: video.video_src,
|
||||
video_asset_id: video.asset_id,
|
||||
poster_image_src: None,
|
||||
poster_asset_id: None,
|
||||
storyboard_prompt: normalized.storyboard_prompt.clone(),
|
||||
video_prompt: normalized.video_prompt.clone(),
|
||||
image_model: GPT_IMAGE_2_MODEL,
|
||||
video_model: video_settings.model,
|
||||
aspect_ratio: OPENING_CG_ASPECT_RATIO,
|
||||
image_size: OPENING_CG_IMAGE_SIZE_LABEL,
|
||||
video_resolution: OPENING_CG_VIDEO_RESOLUTION,
|
||||
duration_seconds: OPENING_CG_VIDEO_DURATION_SECONDS,
|
||||
point_cost: OPENING_CG_POINTS_COST,
|
||||
estimated_wait_minutes: OPENING_CG_ESTIMATED_WAIT_MINUTES,
|
||||
generated_at: generated_at.clone(),
|
||||
updated_at: generated_at,
|
||||
error_message: None,
|
||||
})
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
GeneratedOpeningCgResponse {
|
||||
opening_cg: generated,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn persist_custom_world_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -974,6 +1176,337 @@ async fn persist_custom_world_asset(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn generate_opening_cg_storyboard(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
http_client: &reqwest::Client,
|
||||
settings: &crate::openai_image_generation::OpenAiImageSettings,
|
||||
normalized: &NormalizedOpeningCgRequest,
|
||||
reference_images: &[String],
|
||||
) -> Result<GeneratedOpeningCgStoryboard, AppError> {
|
||||
let generated = create_openai_image_generation(
|
||||
http_client,
|
||||
settings,
|
||||
normalized.storyboard_prompt.as_str(),
|
||||
None,
|
||||
OPENING_CG_STORYBOARD_IMAGE_SIZE,
|
||||
1,
|
||||
reference_images,
|
||||
"开局 CG 故事板生成失败",
|
||||
)
|
||||
.await?;
|
||||
let downloaded = generated
|
||||
.images
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(downloaded_openai_to_custom_world_image)
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "apimart",
|
||||
"message": "开局 CG 故事板生成成功但未返回图片。",
|
||||
}))
|
||||
})?;
|
||||
let asset_id = format!("opening-cg-storyboard-{}", current_utc_millis());
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(
|
||||
normalized
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.unwrap_or(normalized.world_name.as_str()),
|
||||
"world",
|
||||
),
|
||||
"opening-cg".to_string(),
|
||||
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
|
||||
],
|
||||
file_name: format!("storyboard.{}", downloaded.extension),
|
||||
content_type: downloaded.mime_type,
|
||||
body: downloaded.bytes,
|
||||
asset_kind: OPENING_CG_STORYBOARD_ASSET_KIND,
|
||||
entity_kind: OPENING_CG_ENTITY_KIND,
|
||||
entity_id: normalized
|
||||
.profile_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| normalized.world_name.clone()),
|
||||
profile_id: normalized.profile_id.clone(),
|
||||
slot: OPENING_CG_STORYBOARD_SLOT,
|
||||
source_job_id: Some(generated.task_id.clone()),
|
||||
};
|
||||
let asset = persist_custom_world_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src: String::new(),
|
||||
asset_id: asset_id.clone(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some(GPT_IMAGE_2_MODEL.to_string()),
|
||||
size: Some(OPENING_CG_STORYBOARD_IMAGE_SIZE.to_string()),
|
||||
task_id: Some(generated.task_id.clone()),
|
||||
prompt: Some(normalized.storyboard_prompt.clone()),
|
||||
actual_prompt: generated.actual_prompt,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(GeneratedOpeningCgStoryboard {
|
||||
image_src: asset.image_src,
|
||||
asset_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn generate_opening_cg_video(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
http_client: &reqwest::Client,
|
||||
settings: &ArkVideoSettings,
|
||||
normalized: &NormalizedOpeningCgRequest,
|
||||
storyboard_reference_data_url: &str,
|
||||
) -> Result<GeneratedOpeningCgVideo, AppError> {
|
||||
let upstream_task_id = create_ark_storyboard_to_video_task(
|
||||
http_client,
|
||||
settings,
|
||||
normalized.video_prompt.as_str(),
|
||||
storyboard_reference_data_url,
|
||||
)
|
||||
.await?;
|
||||
let video_url =
|
||||
wait_for_ark_content_generation_task(http_client, settings, upstream_task_id.as_str())
|
||||
.await?;
|
||||
let downloaded =
|
||||
download_generated_video(http_client, video_url.as_str(), "下载开局 CG 视频失败").await?;
|
||||
let asset_id = format!("opening-cg-video-{}", current_utc_millis());
|
||||
let video_src = persist_opening_cg_video_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
normalized,
|
||||
asset_id.as_str(),
|
||||
Some(upstream_task_id.clone()),
|
||||
downloaded,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(GeneratedOpeningCgVideo {
|
||||
video_src,
|
||||
asset_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn persist_opening_cg_video_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
normalized: &NormalizedOpeningCgRequest,
|
||||
asset_id: &str,
|
||||
source_job_id: Option<String>,
|
||||
video: DownloadedRemoteVideo,
|
||||
) -> Result<String, AppError> {
|
||||
let upload = PreparedAssetUpload {
|
||||
prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
path_segments: vec![
|
||||
sanitize_storage_segment(
|
||||
normalized
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.unwrap_or(normalized.world_name.as_str()),
|
||||
"world",
|
||||
),
|
||||
"opening-cg".to_string(),
|
||||
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
|
||||
],
|
||||
file_name: format!("opening.{}", video.extension),
|
||||
content_type: video.mime_type,
|
||||
body: video.bytes,
|
||||
asset_kind: OPENING_CG_VIDEO_ASSET_KIND,
|
||||
entity_kind: OPENING_CG_ENTITY_KIND,
|
||||
entity_id: normalized
|
||||
.profile_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| normalized.world_name.clone()),
|
||||
profile_id: normalized.profile_id.clone(),
|
||||
slot: OPENING_CG_VIDEO_SLOT,
|
||||
source_job_id,
|
||||
};
|
||||
let asset = persist_custom_world_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
upload,
|
||||
GeneratedAssetResponse {
|
||||
image_src: String::new(),
|
||||
asset_id: asset_id.to_string(),
|
||||
source_type: "generated".to_string(),
|
||||
model: Some("ark-seedance".to_string()),
|
||||
size: Some(format!(
|
||||
"{}:{}:{}s",
|
||||
OPENING_CG_VIDEO_RESOLUTION,
|
||||
OPENING_CG_VIDEO_RATIO,
|
||||
OPENING_CG_VIDEO_DURATION_SECONDS
|
||||
)),
|
||||
task_id: None,
|
||||
prompt: Some(normalized.video_prompt.clone()),
|
||||
actual_prompt: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(asset.image_src)
|
||||
}
|
||||
|
||||
async fn create_ark_storyboard_to_video_task(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &ArkVideoSettings,
|
||||
prompt: &str,
|
||||
storyboard_reference_data_url: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let response = 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": storyboard_reference_data_url,
|
||||
},
|
||||
"role": "reference_image",
|
||||
}
|
||||
],
|
||||
"resolution": OPENING_CG_VIDEO_RESOLUTION,
|
||||
"ratio": OPENING_CG_VIDEO_RATIO,
|
||||
"duration": OPENING_CG_VIDEO_DURATION_SECONDS,
|
||||
"watermark": false,
|
||||
"audio": true,
|
||||
"generate_audio": true,
|
||||
"web_search": true,
|
||||
"enable_web_search": true,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_ark_video_request_error(format!("请求 Seedance 视频服务失败:{error}")))?;
|
||||
let status = response.status();
|
||||
let text = response.text().await.map_err(|error| {
|
||||
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(parse_ark_video_upstream_error(
|
||||
text.as_str(),
|
||||
"创建开局 CG 视频任务失败。",
|
||||
));
|
||||
}
|
||||
let payload = parse_ark_video_json_payload(text.as_str(), "创建开局 CG 视频任务失败。")?;
|
||||
extract_ark_task_id(&payload.payload).ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": "开局 CG 视频任务未返回任务 id。",
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
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_ark_video_request_error(format!("查询 Seedance 视频任务失败:{error}")))?;
|
||||
let status = response.status();
|
||||
let text = response.text().await.map_err(|error| {
|
||||
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(parse_ark_video_upstream_error(
|
||||
text.as_str(),
|
||||
"查询开局 CG 视频任务失败。",
|
||||
));
|
||||
}
|
||||
let payload = parse_ark_video_json_payload(text.as_str(), "查询开局 CG 视频任务失败。")?;
|
||||
if let Some(video_url) = extract_video_url(&payload.payload) {
|
||||
return Ok(video_url);
|
||||
}
|
||||
let normalized_status = normalize_generation_task_status(
|
||||
extract_generation_task_status(&payload.payload).as_str(),
|
||||
);
|
||||
if is_completed_generation_task_status(normalized_status.as_str()) {
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": "开局 CG 视频任务完成但没有返回 video_url。",
|
||||
"taskId": task_id,
|
||||
})));
|
||||
}
|
||||
if is_failed_generation_task_status(normalized_status.as_str()) {
|
||||
return Err(parse_ark_video_upstream_error(
|
||||
text.as_str(),
|
||||
"开局 CG 视频任务执行失败。",
|
||||
));
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
|
||||
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": "开局 CG 视频生成超时,请稍后重试。",
|
||||
"taskId": task_id,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn download_generated_video(
|
||||
http_client: &reqwest::Client,
|
||||
video_url: &str,
|
||||
fallback_message: &str,
|
||||
) -> Result<DownloadedRemoteVideo, AppError> {
|
||||
let response = http_client
|
||||
.get(video_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_ark_video_request_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 body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}:{error}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": fallback_message,
|
||||
"status": status.as_u16(),
|
||||
})));
|
||||
}
|
||||
let normalized_mime_type = normalize_downloaded_video_mime_type(content_type.as_str());
|
||||
|
||||
Ok(DownloadedRemoteVideo {
|
||||
extension: video_mime_to_extension(normalized_mime_type.as_str()).to_string(),
|
||||
mime_type: normalized_mime_type,
|
||||
bytes: body.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_asset_metadata(
|
||||
asset_kind: &str,
|
||||
owner_user_id: &str,
|
||||
@@ -1225,6 +1758,176 @@ fn normalize_scene_image_request(
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_opening_cg_request(profile: &Value) -> Result<NormalizedOpeningCgRequest, AppError> {
|
||||
let object = profile.as_object().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-opening-cg",
|
||||
"message": "profile 必须是 JSON object",
|
||||
}))
|
||||
})?;
|
||||
let world_name = read_string_field(object, "name").unwrap_or_else(|| "未命名世界".to_string());
|
||||
let profile_id = read_string_field(object, "id");
|
||||
let world_tone = read_string_field(object, "tone").ok_or_else(|| {
|
||||
missing_opening_cg_field_error("世界基调缺失,无法生成开局 CG。")
|
||||
})?;
|
||||
let world_summary = read_string_field(object, "summary").ok_or_else(|| {
|
||||
missing_opening_cg_field_error("世界概述缺失,无法生成开局 CG。")
|
||||
})?;
|
||||
let core_conflicts = read_string_array_field(object, "coreConflicts");
|
||||
if core_conflicts.is_empty() {
|
||||
return Err(missing_opening_cg_field_error(
|
||||
"核心冲突缺失,无法生成开局 CG。",
|
||||
));
|
||||
}
|
||||
let player_role = object
|
||||
.get("playableNpcs")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|roles| roles.first())
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| missing_opening_cg_field_error("缺少玩家扮演角色。"))?;
|
||||
let player_role_image_src = read_string_field(player_role, "imageSrc").ok_or_else(|| {
|
||||
missing_opening_cg_field_error("玩家扮演角色缺少角色参考图。")
|
||||
})?;
|
||||
let player_role_brief = build_opening_cg_player_role_brief(player_role);
|
||||
let opening_scene_image_src = profile
|
||||
.pointer("/sceneChapterBlueprints/0/acts/0/backgroundImageSrc")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
missing_opening_cg_field_error("首个场景第一幕背景图缺失,无法生成开局 CG。")
|
||||
})?;
|
||||
let opening_cg_id = format!("opening-cg-{}", current_utc_millis());
|
||||
let storyboard_prompt = build_opening_cg_storyboard_prompt(
|
||||
world_tone.as_str(),
|
||||
player_role_brief.as_str(),
|
||||
world_summary.as_str(),
|
||||
core_conflicts.as_slice(),
|
||||
);
|
||||
|
||||
Ok(NormalizedOpeningCgRequest {
|
||||
profile_id,
|
||||
world_name,
|
||||
opening_cg_id,
|
||||
storyboard_prompt,
|
||||
video_prompt: OPENING_CG_VIDEO_PROMPT.to_string(),
|
||||
player_role_image_src,
|
||||
opening_scene_image_src,
|
||||
})
|
||||
}
|
||||
|
||||
fn missing_opening_cg_field_error(message: &str) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-opening-cg",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_opening_cg_storyboard_prompt(
|
||||
world_tone: &str,
|
||||
player_role_brief: &str,
|
||||
world_summary: &str,
|
||||
core_conflicts: &[String],
|
||||
) -> String {
|
||||
format!(
|
||||
"以3*4网格格式创建故事板,16:9。像素风角色扮演游戏开场动画CG。\n\n故事流程:先展示角色,展示故事背景,然后表现核心冲突,最后衔接开局场景\n故事基调:{}\n\n玩家扮演:将玩家扮演角色作为角色参考图并引用世界草稿中的角色简介:{}\n故事背景:{}\n核心冲突:{}\n开局场景:将首个场景的第一幕背景图作为参考图",
|
||||
clamp_opening_cg_prompt_text(world_tone, 160),
|
||||
clamp_opening_cg_prompt_text(player_role_brief, 320),
|
||||
clamp_opening_cg_prompt_text(world_summary, 420),
|
||||
clamp_opening_cg_prompt_text(core_conflicts.join(";").as_str(), 360),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_opening_cg_player_role_brief(role: &Map<String, Value>) -> String {
|
||||
[
|
||||
read_string_field(role, "name")
|
||||
.map(|value| format!("姓名:{value}"))
|
||||
.unwrap_or_default(),
|
||||
read_string_field(role, "role")
|
||||
.map(|value| format!("身份:{value}"))
|
||||
.unwrap_or_default(),
|
||||
read_string_field(role, "description")
|
||||
.map(|value| format!("简介:{value}"))
|
||||
.unwrap_or_default(),
|
||||
read_string_field(role, "visualDescription")
|
||||
.map(|value| format!("形象:{value}"))
|
||||
.unwrap_or_default(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
}
|
||||
|
||||
fn read_string_array_field(object: &Map<String, Value>, key: &str) -> Vec<String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn clamp_opening_cg_prompt_text(value: &str, max_length: usize) -> String {
|
||||
clamp_text(value, max_length, false)
|
||||
}
|
||||
|
||||
fn require_ark_video_settings(state: &AppState) -> 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 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
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(OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS),
|
||||
model: state.config.ark_character_video_model.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
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": "custom-world-opening-cg",
|
||||
"message": format!("构造上游 HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
|
||||
// Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。
|
||||
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
|
||||
@@ -2143,6 +2846,20 @@ fn parse_json_payload(
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_ark_video_json_payload(
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
) -> Result<ParsedJsonPayload, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text)
|
||||
.map(|payload| ParsedJsonPayload { payload })
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": format!("{fallback_message}:解析响应失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||
if raw_text.trim().is_empty() {
|
||||
return fallback_message.to_string();
|
||||
@@ -2193,6 +2910,13 @@ fn map_dashscope_request_error(message: String) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_ark_video_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
@@ -2200,6 +2924,13 @@ fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppEr
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_ark_video_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "ark",
|
||||
"message": parse_api_error_message(raw_text, fallback_message),
|
||||
}))
|
||||
}
|
||||
|
||||
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::Array(entries) => {
|
||||
@@ -2236,6 +2967,61 @@ fn extract_task_id(payload: &Value) -> Option<String> {
|
||||
find_first_string_by_key(payload, "task_id")
|
||||
}
|
||||
|
||||
fn extract_ark_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"))
|
||||
.or_else(|| find_first_string_by_key(payload, "taskId"))
|
||||
.or_else(|| find_first_string_by_key(payload, "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, "videoUrl"))
|
||||
.or_else(|| find_first_string_by_key(payload, "url"))
|
||||
}
|
||||
|
||||
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 extract_image_urls(payload: &Value) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
collect_strings_by_key(payload, "image", &mut urls);
|
||||
@@ -2263,6 +3049,20 @@ fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_downloaded_video_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or("video/mp4");
|
||||
match mime_type {
|
||||
"video/mp4" | "video/quicktime" | "video/webm" | "video/x-msvideo" => {
|
||||
mime_type.to_string()
|
||||
}
|
||||
_ => "video/mp4".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"image/png" => "png",
|
||||
@@ -2272,6 +3072,15 @@ fn mime_to_extension(mime_type: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
fn video_mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"video/quicktime" => "mov",
|
||||
"video/webm" => "webm",
|
||||
"video/x-msvideo" => "avi",
|
||||
_ => "mp4",
|
||||
}
|
||||
}
|
||||
|
||||
fn conditional_prompt_line(prefix: &str, value: &str) -> String {
|
||||
if value.is_empty() {
|
||||
String::new()
|
||||
@@ -2391,6 +3200,12 @@ fn current_utc_micros() -> i64 {
|
||||
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 custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user