This commit is contained in:
2026-05-05 14:40:41 +08:00
parent e847fcea6f
commit 07e777fef8
76 changed files with 4246 additions and 444 deletions

View File

@@ -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网格格式创建故事板169。像素风角色扮演游戏开场动画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))
}