Files
Genarrative/server-rs/crates/api-server/src/custom_world_ai.rs

3114 lines
107 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
collections::BTreeMap,
time::{Duration, Instant},
};
use axum::{
Json,
extract::{Extension, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::{DynamicImage, GenericImageView, imageops::FilterType};
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_llm::{LlmMessage, LlmTextRequest};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use spacetime_client::SpacetimeClientError;
use tokio::time::sleep;
use webp::Encoder as WebpEncoder;
use crate::generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput,
normalize_generated_image_asset_mime,
};
use crate::{
api_response::json_success_body,
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,
build_result_scene_npc_system_prompt, build_result_scene_npc_user_prompt,
},
http_error::AppError,
llm_model_routing::CREATION_TEMPLATE_LLM_MODEL,
openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, build_openai_image_http_client,
create_openai_image_generation, require_openai_image_settings,
},
platform_errors::map_oss_error,
prompt::scene_background::{
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, SceneImagePromptLandmark,
SceneImagePromptParams, SceneImagePromptProfile, build_custom_world_scene_image_prompt,
},
request_context::RequestContext,
state::AppState,
};
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldEntityRequest {
profile: Value,
kind: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldSceneNpcRequest {
profile: Value,
landmark_id: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldSceneImageRequest {
#[serde(default)]
profile_id: Option<String>,
#[serde(default)]
world_name: Option<String>,
#[serde(default)]
landmark_id: Option<String>,
#[serde(default)]
landmark_name: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
size: Option<String>,
#[serde(default)]
negative_prompt: Option<String>,
#[serde(default)]
reference_image_src: Option<String>,
#[serde(default)]
user_prompt: Option<String>,
#[serde(default)]
profile: Option<SceneImageProfileInput>,
#[serde(default)]
landmark: Option<SceneImageLandmarkInput>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldCoverImageRequest {
profile: CoverProfileInput,
#[serde(default)]
user_prompt: Option<String>,
#[serde(default)]
reference_image_src: Option<String>,
#[serde(default)]
character_role_ids: Vec<String>,
#[serde(default)]
size: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldCoverUploadRequest {
#[serde(default)]
profile_id: Option<String>,
#[serde(default)]
world_name: Option<String>,
image_data_url: String,
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 {
image_src: String,
asset_id: String,
source_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
size: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
task_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
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,
pub asset_id: String,
pub prompt: String,
pub model: String,
}
struct PreparedAssetUpload {
prefix: LegacyAssetPrefix,
path_segments: Vec<String>,
file_name: String,
content_type: String,
body: Vec<u8>,
asset_kind: &'static str,
entity_kind: &'static str,
entity_id: String,
profile_id: Option<String>,
slot: &'static str,
source_job_id: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SceneImageProfileInput {
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
subtitle: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
tone: Option<String>,
#[serde(default)]
player_goal: Option<String>,
#[serde(default)]
setting_text: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SceneImageLandmarkInput {
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverRoleInput {
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
role: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
image_src: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverCampInput {
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
image_src: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverLandmarkInput {
#[serde(default)]
#[allow(dead_code)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
image_src: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverActInput {
#[serde(default)]
#[allow(dead_code)]
id: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
background_image_src: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverSceneChapterInput {
#[serde(default)]
#[allow(dead_code)]
id: Option<String>,
#[serde(default)]
#[allow(dead_code)]
scene_id: Option<String>,
#[serde(default)]
#[allow(dead_code)]
title: Option<String>,
#[serde(default)]
#[allow(dead_code)]
summary: Option<String>,
#[serde(default)]
acts: Vec<CoverActInput>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CoverProfileInput {
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
subtitle: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
tone: Option<String>,
#[serde(default)]
player_goal: Option<String>,
#[serde(default)]
setting_text: Option<String>,
#[serde(default)]
camp: Option<CoverCampInput>,
#[serde(default)]
landmarks: Vec<CoverLandmarkInput>,
#[serde(default)]
playable_npcs: Vec<CoverRoleInput>,
#[serde(default)]
story_npcs: Vec<CoverRoleInput>,
#[serde(default)]
scene_chapter_blueprints: Vec<CoverSceneChapterInput>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CustomWorldCoverCropRect {
x: f64,
y: f64,
width: f64,
height: f64,
}
struct DashScopeSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
}
struct DashScopeGeneratedImage {
image_url: String,
task_id: String,
actual_prompt: Option<String>,
}
struct DownloadedRemoteImage {
mime_type: String,
extension: String,
bytes: Vec<u8>,
}
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,
opening_act_summary: String,
role_summary: String,
story_role_summary: String,
landmark_summary: String,
}
struct NormalizedSceneImageRequest {
profile_id: Option<String>,
world_name: String,
entity_id: String,
size: String,
prompt: String,
negative_prompt: String,
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,
top: u32,
width: u32,
height: u32,
}
#[derive(Debug)]
struct OptimizedCoverUpload {
mime_type: String,
extension: String,
bytes: Vec<u8>,
}
const COVER_OUTPUT_WIDTH: u32 = 1600;
const COVER_OUTPUT_HEIGHT: u32 = 900;
const COVER_UPLOAD_MAX_BYTES: usize = 10 * 1024 * 1024;
const COVER_OUTPUT_MAX_BYTES: usize = (1.5 * 1024.0 * 1024.0) as usize;
const COVER_MIN_RATIO: f64 = 1.7;
const COVER_MAX_RATIO: f64 = 1.8;
pub async fn generate_custom_world_entity(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldEntityRequest>, 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-ai",
"message": error.body_text(),
})),
)
})?;
let kind = payload.kind.trim();
if !matches!(kind, "playable" | "story" | "landmark") {
return Err(custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "kind 必须是 playable、story 或 landmark",
})),
));
}
let entity = generate_entity_with_fallback(&state, &payload.profile, kind).await;
Ok(json_success_body(
Some(&request_context),
json!({
"kind": kind,
"entity": entity,
}),
))
}
pub async fn generate_custom_world_scene_npc(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldSceneNpcRequest>, 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-ai",
"message": error.body_text(),
})),
)
})?;
let landmark_id = payload.landmark_id.trim();
if landmark_id.is_empty() {
return Err(custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "landmarkId is required",
})),
));
}
let npc = generate_scene_npc_with_fallback(&state, &payload.profile, landmark_id).await;
Ok(json_success_body(
Some(&request_context),
json!({ "npc": npc }),
))
}
pub async fn generate_custom_world_scene_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldSceneImageRequest>, 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-ai",
"message": error.body_text(),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let normalized = normalize_scene_image_request(payload)
.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))?;
let asset_id = format!("custom-scene-{}", current_utc_millis());
let asset = execute_billable_asset_operation(
&state,
&owner_user_id,
"scene_image",
asset_id.as_str(),
async {
let settings = require_openai_image_settings(&state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image =
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
Some(
resolve_reference_image_as_data_url(
&state,
&http_client,
reference_image_src,
"referenceImageSrc",
)
.await?,
)
} else {
None
};
let reference_images = reference_image
.as_ref()
.map(|value| vec![value.clone()])
.unwrap_or_default();
let generated = create_openai_image_generation(
&http_client,
&settings,
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
1,
&reference_images,
"场景图片生成失败",
)
.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": "vector-engine",
"message": "场景图片生成成功但未返回图片。",
}))
})?;
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",
),
sanitize_storage_segment(normalized.entity_id.as_str(), "scene"),
asset_id.clone(),
],
file_name: format!("scene.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: "scene_image",
entity_kind: "custom_world_landmark",
entity_id: normalized.entity_id.clone(),
profile_id: normalized.profile_id.clone(),
slot: "scene_image",
source_job_id: Some(generated.task_id.clone()),
};
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(RPG_SCENE_IMAGE_MODEL.to_string()),
size: Some(normalized.size),
task_id: Some(generated.task_id),
prompt: Some(normalized.prompt),
actual_prompt: generated.actual_prompt,
},
)
.await
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
pub(crate) async fn generate_custom_world_scene_image_for_profile(
state: &AppState,
owner_user_id: &str,
profile: &Value,
profile_id: Option<&str>,
world_name: &str,
scene_id: &str,
scene_name: &str,
scene_description: &str,
prompt_text: &str,
) -> Result<GeneratedCustomWorldSceneImage, AppError> {
let payload = CustomWorldSceneImageRequest {
profile_id: profile_id.map(ToOwned::to_owned),
world_name: Some(world_name.to_string()),
landmark_id: Some(scene_id.to_string()),
landmark_name: Some(scene_name.to_string()),
// 自动草稿生成必须和草稿页手动生成走同一条 prompt 编译链:
// 只把幕级描述作为 userPrompt 输入,仍交给 normalize_scene_image_request 组装世界名、地点名、风格与负面词。
prompt: None,
size: Some("1280*720".to_string()),
negative_prompt: None,
reference_image_src: None,
user_prompt: Some(prompt_text.to_string()),
profile: Some(scene_image_profile_input_from_value(
profile, profile_id, world_name,
)),
landmark: Some(SceneImageLandmarkInput {
id: Some(scene_id.to_string()),
name: Some(scene_name.to_string()),
description: Some(scene_description.to_string()),
}),
};
let normalized = normalize_scene_image_request(payload)?;
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,
&settings,
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
1,
&[],
"场景图片生成失败",
)
.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": "vector-engine",
"message": "场景图片生成成功但未返回图片。",
}))
})?;
let asset_id = format!("custom-scene-{}", 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",
),
sanitize_storage_segment(normalized.entity_id.as_str(), "scene"),
asset_id.clone(),
],
file_name: format!("scene.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: "scene_image",
entity_kind: "custom_world_landmark",
entity_id: normalized.entity_id.clone(),
profile_id: normalized.profile_id.clone(),
slot: "scene_image",
source_job_id: Some(generated.task_id.clone()),
};
let model = RPG_SCENE_IMAGE_MODEL.to_string();
let prompt = normalized.prompt.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(model.clone()),
size: Some(normalized.size),
task_id: Some(generated.task_id),
prompt: Some(prompt.clone()),
actual_prompt: generated.actual_prompt,
},
)
.await?;
Ok(GeneratedCustomWorldSceneImage {
image_src: asset.image_src,
asset_id,
prompt,
model,
})
}
fn scene_image_profile_input_from_value(
profile: &Value,
profile_id: Option<&str>,
world_name: &str,
) -> SceneImageProfileInput {
SceneImageProfileInput {
id: profile_id.map(ToOwned::to_owned),
name: Some(world_name.to_string()),
subtitle: json_text_from_value(profile, "subtitle"),
summary: json_text_from_value(profile, "summary"),
tone: json_text_from_value(profile, "tone"),
player_goal: json_text_from_value(profile, "playerGoal"),
setting_text: json_text_from_value(profile, "settingText"),
}
}
fn json_text_from_value(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub async fn generate_custom_world_cover_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldCoverImageRequest>, 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-ai",
"message": error.body_text(),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let profile_id = trim_to_option(payload.profile.id.as_deref());
let world_name =
trim_to_option(payload.profile.name.as_deref()).unwrap_or_else(|| "world".to_string());
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
let size = trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1600*900".to_string());
require_dashscope_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let asset_id = format!("custom-cover-{}", current_utc_millis());
let asset = execute_billable_asset_operation(
&state,
&owner_user_id,
"custom_world_cover",
asset_id.as_str(),
async {
let settings = require_dashscope_settings(&state)?;
let http_client = build_dashscope_http_client(&settings)?;
let reference_sources = collect_cover_reference_image_sources(
&payload.profile,
&payload.character_role_ids,
payload.reference_image_src.as_deref().unwrap_or_default(),
);
let prompt = build_custom_world_cover_image_prompt(
&payload.profile,
&payload.character_role_ids,
payload.user_prompt.as_deref().unwrap_or_default(),
!reference_sources.is_empty(),
);
let mut reference_images = Vec::with_capacity(reference_sources.len());
for source in &reference_sources {
reference_images.push(
resolve_reference_image_as_data_url(
&state,
&http_client,
source.as_str(),
"referenceImageSrc",
)
.await?,
);
}
let generated = if reference_images.is_empty() {
create_text_to_image_generation(
&http_client,
&settings,
state.config.dashscope_cover_image_model.clone().as_str(),
prompt.as_str(),
None,
size.as_str(),
"创建作品封面生成任务失败",
"查询作品封面任务失败",
"作品封面生成任务失败",
"作品封面生成超时或未返回图片地址",
)
.await
} else {
create_reference_image_generation(
&http_client,
&settings,
state.config.dashscope_reference_image_model.as_str(),
prompt.as_str(),
size.as_str(),
&reference_images,
None,
"创建参考图封面任务失败",
"封面生成未返回图片地址",
"cover-edit",
)
.await
}?;
let downloaded = download_remote_image(
&http_client,
generated.image_url.as_str(),
"下载作品封面失败",
)
.await?;
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec![
sanitize_storage_segment(entity_id.as_str(), "world"),
asset_id.clone(),
],
file_name: format!("cover.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: "custom_world_cover",
entity_kind: "custom_world_profile",
entity_id,
profile_id,
slot: "cover",
source_job_id: Some(generated.task_id.clone()),
};
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(if reference_images.is_empty() {
state.config.dashscope_cover_image_model.clone()
} else {
state.config.dashscope_reference_image_model.clone()
}),
size: Some(size),
task_id: Some(generated.task_id),
prompt: Some(prompt),
actual_prompt: generated.actual_prompt,
},
)
.await
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(Some(&request_context), asset))
}
pub async fn upload_custom_world_cover_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldCoverUploadRequest>, 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-ai",
"message": error.body_text(),
})),
)
})?;
let parsed = parse_image_data_url(payload.image_data_url.trim()).ok_or_else(|| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "imageDataUrl 必须是有效的图片 Data URL",
})),
)
})?;
let optimized = optimize_uploaded_cover_image(&parsed, &payload.crop_rect)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let owner_user_id = authenticated.claims().user_id().to_string();
let profile_id = trim_to_option(payload.profile_id.as_deref());
let world_name =
trim_to_option(payload.world_name.as_deref()).unwrap_or_else(|| "world".to_string());
let entity_id = profile_id.clone().unwrap_or_else(|| world_name.clone());
let asset_id = format!("custom-cover-upload-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldCovers,
path_segments: vec![
sanitize_storage_segment(entity_id.as_str(), "world"),
asset_id.clone(),
],
file_name: format!("cover.{}", optimized.extension),
content_type: optimized.mime_type,
body: optimized.bytes,
asset_kind: "custom_world_cover",
entity_kind: "custom_world_profile",
entity_id,
profile_id,
slot: "cover",
source_job_id: Some(asset_id.clone()),
};
let asset = persist_custom_world_asset(
&state,
&owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id,
source_type: "uploaded".to_string(),
model: None,
size: None,
task_id: None,
prompt: None,
actual_prompt: None,
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
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,
},
))
}
mod assets;
use assets::persist_custom_world_asset;
mod opening_cg;
use opening_cg::{
generate_opening_cg_storyboard, generate_opening_cg_video, map_custom_world_asset_oss_error,
};
async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value {
let fallback = build_entity_fallback(profile, kind);
let Some(llm_client) = state.llm_client() else {
return fallback;
};
let request = LlmTextRequest::new(vec![
LlmMessage::system(build_result_entity_system_prompt()),
LlmMessage::user(build_result_entity_user_prompt(profile, kind, &fallback)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
.with_web_search(true);
llm_client
.request_text(request)
.await
.ok()
.and_then(|response| serde_json::from_str::<Value>(response.content.trim()).ok())
.unwrap_or(fallback)
}
async fn generate_scene_npc_with_fallback(
state: &AppState,
profile: &Value,
landmark_id: &str,
) -> Value {
let fallback = build_scene_npc_fallback(profile, landmark_id);
let Some(llm_client) = state.llm_client() else {
return fallback;
};
let request = LlmTextRequest::new(vec![
LlmMessage::system(build_result_scene_npc_system_prompt()),
LlmMessage::user(build_result_scene_npc_user_prompt(
profile,
landmark_id,
&fallback,
)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
.with_web_search(true);
llm_client
.request_text(request)
.await
.ok()
.and_then(|response| serde_json::from_str::<Value>(response.content.trim()).ok())
.unwrap_or(fallback)
}
fn build_entity_fallback(profile: &Value, kind: &str) -> Value {
let object = profile.as_object().cloned().unwrap_or_default();
let world_name = read_string_field(&object, "name").unwrap_or_else(|| "自定义世界".to_string());
match kind {
"playable" => build_role_fallback("playable", "新同行者", &world_name, 18),
"story" => build_role_fallback("story", "新场景角色", &world_name, 6),
"landmark" => build_landmark_fallback(&world_name),
_ => json!({}),
}
}
fn build_scene_npc_fallback(profile: &Value, landmark_id: &str) -> Value {
let object = profile.as_object().cloned().unwrap_or_default();
let world_name = read_string_field(&object, "name").unwrap_or_else(|| "自定义世界".to_string());
let landmark_name = object
.get("landmarks")
.and_then(Value::as_array)
.and_then(|entries| {
entries.iter().find_map(|entry| {
let object = entry.as_object()?;
(read_string_field(object, "id").as_deref() == Some(landmark_id))
.then(|| read_string_field(object, "name"))
.flatten()
})
})
.unwrap_or_else(|| "当前场景".to_string());
let mut npc = build_role_fallback("story", &format!("{landmark_name}来客"), &world_name, 6);
if let Some(object) = npc.as_object_mut() {
object.insert(
"description".to_string(),
Value::String(format!("长期活动于{landmark_name},熟悉这里的局势与暗线。")),
);
}
npc
}
fn build_role_fallback(prefix: &str, name: &str, world_name: &str, affinity: i64) -> Value {
let suffix = current_utc_millis();
json!({
"id": format!("{prefix}-{}", suffix),
"name": name,
"title": "关键角色",
"role": "关键角色",
"description": format!("围绕《{world_name}》当前主线冲突生成的新增角色。"),
"backstory": format!("他与《{world_name}》正在展开的局势存在直接牵连。"),
"personality": "谨慎、敏锐,先观察再表态。",
"motivation": "希望借玩家的介入改变当前失衡局面。",
"combatStyle": "偏向试探与控场。",
"initialAffinity": affinity,
"relationshipHooks": ["与玩家保持试探", "掌握局势暗线"],
"relations": [],
"tags": ["自定义", "生成"],
"backstoryReveal": {
"publicSummary": "一个掌握部分旧线索的关键角色。",
"chapters": [
{ "id": "surface", "title": "表层来意", "affinityRequired": 6, "teaser": "他知道这里正在发生什么。", "content": "他一直在观察这片区域的变化。", "contextSnippet": "" },
{ "id": "scar", "title": "旧事裂痕", "affinityRequired": 12, "teaser": "他与旧案有直接关联。", "content": "过往的一次事件把他绑定在这条线里。", "contextSnippet": "" },
{ "id": "hidden", "title": "隐藏执念", "affinityRequired": 18, "teaser": "他真正想推动的局面还没说出口。", "content": "他一直在寻找能撬动局面的机会。", "contextSnippet": "" },
{ "id": "final", "title": "最终底牌", "affinityRequired": 24, "teaser": "他手里还压着一张底牌。", "content": "一旦局势逼近临界点,他会出手。", "contextSnippet": "" }
]
},
"skills": [
{ "id": format!("skill-{}-1", suffix), "name": "试探起手", "summary": "先判断局势与对手意图。", "style": "试探压制" },
{ "id": format!("skill-{}-2", suffix), "name": "借势压场", "summary": "利用环境为自己制造主动权。", "style": "环境协同" },
{ "id": format!("skill-{}-3", suffix), "name": "暗线反制", "summary": "在关键节点打乱对方节奏。", "style": "后手翻盘" }
],
"initialItems": [
{ "id": format!("item-{}-1", suffix), "name": "随身兵装", "category": "武器", "quantity": 1, "rarity": "rare", "description": "常备的近身装备。", "tags": ["自定义"] },
{ "id": format!("item-{}-2", suffix), "name": "私人物件", "category": "道具", "quantity": 1, "rarity": "uncommon", "description": "可在关键时刻调用的人情或凭证。", "tags": ["自定义"] },
{ "id": format!("item-{}-3", suffix), "name": "线索残页", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "记录部分隐藏线索。", "tags": ["线索"] }
]
})
}
fn build_landmark_fallback(world_name: &str) -> Value {
let suffix = current_utc_millis();
json!({
"id": format!("landmark-{}", suffix),
"name": "新场景",
"description": format!("围绕《{world_name}》当前主线冲突扩展出的关键场景。"),
"visualDescription": "低照度、层次复杂、带有明显环境叙事痕迹。",
"sceneNpcIds": [],
"connections": [],
"narrativeResidues": [],
})
}
fn normalize_scene_image_request(
payload: CustomWorldSceneImageRequest,
) -> Result<NormalizedSceneImageRequest, AppError> {
let profile = payload.profile.unwrap_or_default();
let landmark = payload.landmark.unwrap_or_default();
let reference_image_src = trim_to_option(payload.reference_image_src.as_deref());
let profile_id = trim_to_option(payload.profile_id.as_deref())
.or_else(|| trim_to_option(profile.id.as_deref()));
let world_name = trim_to_option(payload.world_name.as_deref())
.or_else(|| trim_to_option(profile.name.as_deref()))
.unwrap_or_else(|| "world".to_string());
let landmark_id = trim_to_option(payload.landmark_id.as_deref())
.or_else(|| trim_to_option(landmark.id.as_deref()));
let landmark_name = trim_to_option(payload.landmark_name.as_deref())
.or_else(|| trim_to_option(landmark.name.as_deref()));
if landmark_id.is_none() && landmark_name.is_none() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "landmarkName 或 landmarkId 至少要提供一个",
})),
);
}
let prompt = trim_to_option(payload.prompt.as_deref()).unwrap_or_else(|| {
build_custom_world_scene_image_prompt(SceneImagePromptParams {
profile: SceneImagePromptProfile {
name: profile.name.as_deref().unwrap_or_default(),
subtitle: profile.subtitle.as_deref().unwrap_or_default(),
tone: profile.tone.as_deref().unwrap_or_default(),
player_goal: profile.player_goal.as_deref().unwrap_or_default(),
summary: profile.summary.as_deref().unwrap_or_default(),
setting_text: profile.setting_text.as_deref().unwrap_or_default(),
},
landmark: SceneImagePromptLandmark {
name: landmark.name.as_deref().unwrap_or_default(),
description: landmark.description.as_deref().unwrap_or_default(),
},
user_prompt: payload.user_prompt.as_deref().unwrap_or_default(),
has_reference_image: reference_image_src.is_some(),
fallback_landmark_name: landmark_name.as_deref(),
fallback_world_name: world_name.as_str(),
})
});
if prompt.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "prompt 不能为空",
})),
);
}
Ok(NormalizedSceneImageRequest {
profile_id,
world_name,
entity_id: landmark_id
.or(landmark_name)
.unwrap_or_else(|| "scene".to_string()),
size: trim_to_option(payload.size.as_deref()).unwrap_or_else(|| "1280*720".to_string()),
prompt,
negative_prompt: trim_to_option(payload.negative_prompt.as_deref())
.unwrap_or_else(|| DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT.to_string()),
reference_image_src: reference_image_src.clone(),
})
}
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('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.dashscope_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": "dashscope",
"reason": "DASHSCOPE_API_KEY 未配置",
}))
})?;
Ok(DashScopeSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
})
}
fn build_dashscope_http_client(settings: &DashScopeSettings) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "dashscope",
"message": format!("构造 DashScope HTTP 客户端失败:{error}"),
}))
})
}
async fn resolve_reference_image_as_data_url(
state: &AppState,
http_client: &reqwest::Client,
source: &str,
field: &str,
) -> Result<String, AppError> {
let trimmed = source.trim();
if trimmed.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"field": field,
"message": "参考图不能为空。",
})),
);
}
if let Some(parsed) = parse_image_data_url(trimmed) {
return Ok(format!(
"data:{};base64,{}",
parsed.mime_type,
BASE64_STANDARD.encode(parsed.bytes)
));
}
if !trimmed.starts_with('/') {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"field": field,
"message": "参考图必须是 Data URL 或 /generated-* 旧路径。",
})),
);
}
let object_key = trimmed.trim_start_matches('/');
if LegacyAssetPrefix::from_object_key(object_key).is_none() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"field": field,
"message": "参考图当前只支持 /generated-* 旧路径。",
})),
);
}
// Rust 端不再回读仓库 public 目录,只兼容 Data URL 和现有 generated-* 旧路径。
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: object_key.to_string(),
expire_seconds: Some(60),
})
.map_err(map_custom_world_asset_oss_error)?;
let response = http_client
.get(signed.signed_url)
.send()
.await
.map_err(|error| map_dashscope_request_error(format!("读取参考图失败:{error}")))?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/png")
.to_string();
let body = response
.bytes()
.await
.map_err(|error| map_dashscope_request_error(format!("读取参考图内容失败:{error}")))?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取参考图失败,状态码:{status}"),
"objectKey": object_key,
})),
);
}
if body.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": "读取参考图失败:对象内容为空",
"objectKey": object_key,
})),
);
}
Ok(format!(
"data:{};base64,{}",
content_type,
BASE64_STANDARD.encode(body)
))
}
async fn create_text_to_image_generation(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
model: &str,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
create_error_message: &str,
poll_error_message: &str,
failed_error_message: &str,
timeout_error_message: &str,
) -> Result<DashScopeGeneratedImage, AppError> {
let mut parameters = Map::from_iter([
("n".to_string(), json!(1)),
("size".to_string(), Value::String(size.to_string())),
("prompt_extend".to_string(), Value::Bool(true)),
("watermark".to_string(), Value::Bool(false)),
]);
if let Some(negative_prompt) = negative_prompt
&& !negative_prompt.trim().is_empty()
{
parameters.insert(
"negative_prompt".to_string(),
Value::String(negative_prompt.trim().to_string()),
);
}
// 文生图链路保持和 Node 旧实现一致:先异步创建任务,再轮询 task 状态。
let response = http_client
.post(format!(
"{}/services/aigc/text2image/image-synthesis",
settings.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header("X-DashScope-Async", "enable")
.json(&json!({
"model": model,
"input": {
"prompt": prompt,
},
"parameters": parameters,
}))
.send()
.await
.map_err(|error| map_dashscope_request_error(format!("{create_error_message}{error}")))?;
let response_status = response.status();
let response_text = response
.text()
.await
.map_err(|error| map_dashscope_request_error(format!("{create_error_message}{error}")))?;
if !response_status.is_success() {
return Err(map_dashscope_upstream_error(
response_text.as_str(),
create_error_message,
));
}
let response_json = parse_json_payload(response_text.as_str(), create_error_message)?;
let task_id = extract_task_id(&response_json.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "场景图片生成任务未返回 task_id",
}))
})?;
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_dashscope_request_error(format!("{poll_error_message}{error}"))
})?;
let poll_status_code = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_dashscope_request_error(format!("{poll_error_message}{error}"))
})?;
if !poll_status_code.is_success() {
return Err(map_dashscope_upstream_error(
poll_text.as_str(),
poll_error_message,
));
}
let poll_json = parse_json_payload(poll_text.as_str(), poll_error_message)?;
let task_status = find_first_string_by_key(&poll_json.payload, "task_status")
.unwrap_or_default()
.trim()
.to_string();
if task_status == "SUCCEEDED" {
let image_url = extract_image_urls(&poll_json.payload)
.into_iter()
.next()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": timeout_error_message,
}))
})?;
return Ok(DashScopeGeneratedImage {
image_url,
task_id,
actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"),
});
}
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
return Err(map_dashscope_upstream_error(
poll_text.as_str(),
failed_error_message,
));
}
sleep(Duration::from_secs(2)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": timeout_error_message,
})),
)
}
async fn create_reference_image_generation(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
model: &str,
prompt: &str,
size: &str,
reference_images: &[String],
negative_prompt: Option<&str>,
create_error_message: &str,
empty_image_error_message: &str,
task_prefix: &str,
) -> Result<DashScopeGeneratedImage, AppError> {
let mut content = reference_images
.iter()
.map(|image| json!({ "image": image }))
.collect::<Vec<_>>();
content.push(json!({ "text": prompt }));
let mut parameters = Map::from_iter([
("n".to_string(), json!(1)),
("size".to_string(), Value::String(size.to_string())),
("prompt_extend".to_string(), Value::Bool(true)),
("watermark".to_string(), Value::Bool(false)),
]);
if let Some(negative_prompt) = negative_prompt
&& !negative_prompt.trim().is_empty()
{
parameters.insert(
"negative_prompt".to_string(),
Value::String(negative_prompt.trim().to_string()),
);
}
let response = http_client
.post(format!(
"{}/services/aigc/multimodal-generation/generation",
settings.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": model,
"input": {
"messages": [
{
"role": "user",
"content": content,
}
]
},
"parameters": parameters,
}))
.send()
.await
.map_err(|error| map_dashscope_request_error(format!("{create_error_message}{error}")))?;
let response_status = response.status();
let response_text = response
.text()
.await
.map_err(|error| map_dashscope_request_error(format!("{create_error_message}{error}")))?;
if !response_status.is_success() {
return Err(map_dashscope_upstream_error(
response_text.as_str(),
create_error_message,
));
}
let response_json = parse_json_payload(response_text.as_str(), create_error_message)?;
let image_url = extract_image_urls(&response_json.payload)
.into_iter()
.next()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": empty_image_error_message,
}))
})?;
Ok(DashScopeGeneratedImage {
image_url,
task_id: format!("{task_prefix}-{}", current_utc_millis()),
actual_prompt: find_first_string_by_key(&response_json.payload, "actual_prompt"),
})
}
async fn download_remote_image(
http_client: &reqwest::Client,
image_url: &str,
fallback_message: &str,
) -> Result<DownloadedRemoteImage, AppError> {
let response = http_client
.get(image_url)
.send()
.await
.map_err(|error| map_dashscope_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("image/jpeg")
.to_string();
let bytes = response
.bytes()
.await
.map_err(|error| map_dashscope_request_error(format!("{fallback_message}{error}")))?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": fallback_message,
"status": status.as_u16(),
})),
);
}
let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str());
Ok(DownloadedRemoteImage {
extension: mime_to_extension(normalized_mime_type.as_str()).to_string(),
mime_type: normalized_mime_type,
bytes: bytes.to_vec(),
})
}
fn downloaded_openai_to_custom_world_image(image: DownloadedOpenAiImage) -> DownloadedRemoteImage {
DownloadedRemoteImage {
extension: image.extension,
mime_type: image.mime_type,
bytes: image.bytes,
}
}
fn optimize_uploaded_cover_image(
parsed_data_url: &ParsedImageDataUrl,
crop_rect: &CustomWorldCoverCropRect,
) -> Result<OptimizedCoverUpload, AppError> {
if parsed_data_url.bytes.len() > COVER_UPLOAD_MAX_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "上传封面原图不能超过 10 MB。",
})),
);
}
let image = image::load_from_memory(parsed_data_url.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": format!("无法解析上传封面:{error}"),
}))
})?;
let (source_width, source_height) = image.dimensions();
if source_width == 0 || source_height == 0 {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "无法解析上传封面的尺寸。",
})),
);
}
let normalized_crop = normalize_cover_crop_rect(source_width, source_height, crop_rect)?;
let resized = image
.crop_imm(
normalized_crop.left,
normalized_crop.top,
normalized_crop.width,
normalized_crop.height,
)
.resize_exact(
COVER_OUTPUT_WIDTH,
COVER_OUTPUT_HEIGHT,
FilterType::CatmullRom,
);
// 上传封面固定产出 1600x900 WebP并按质量档位递减直到满足体积约束。
let mut encoded = encode_dynamic_image_to_webp(&resized, 90.0)?;
for quality in [84.0, 76.0, 68.0, 60.0] {
if encoded.len() <= COVER_OUTPUT_MAX_BYTES {
break;
}
encoded = encode_dynamic_image_to_webp(&resized, quality)?;
}
if encoded.len() > COVER_OUTPUT_MAX_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "上传封面压缩后仍超过体积限制,请缩小裁剪范围或更换图片。",
})),
);
}
Ok(OptimizedCoverUpload {
mime_type: "image/webp".to_string(),
extension: "webp".to_string(),
bytes: encoded,
})
}
fn normalize_cover_crop_rect(
source_width: u32,
source_height: u32,
crop_rect: &CustomWorldCoverCropRect,
) -> Result<NormalizedCropRect, AppError> {
let left = crop_rect
.x
.floor()
.clamp(0.0, source_width.saturating_sub(1) as f64) as u32;
let top = crop_rect
.y
.floor()
.clamp(0.0, source_height.saturating_sub(1) as f64) as u32;
let mut width = crop_rect.width.floor().clamp(1.0, source_width as f64) as u32;
let mut height = crop_rect.height.floor().clamp(1.0, source_height as f64) as u32;
width = width.min(source_width.saturating_sub(left));
height = height.min(source_height.saturating_sub(top));
if width == 0 || height == 0 {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "上传封面裁剪区域不能为空。",
})),
);
}
let ratio = width as f64 / height as f64;
if !(COVER_MIN_RATIO..=COVER_MAX_RATIO).contains(&ratio) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": "上传封面裁剪区域必须保持 16:9。",
})),
);
}
Ok(NormalizedCropRect {
left,
top,
width,
height,
})
}
fn encode_dynamic_image_to_webp(image: &DynamicImage, quality: f32) -> Result<Vec<u8>, AppError> {
let prepared = if image.color().has_alpha() {
DynamicImage::ImageRgba8(image.to_rgba8())
} else {
DynamicImage::ImageRgb8(image.to_rgb8())
};
let encoder = WebpEncoder::from_image(&prepared).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-ai",
"message": format!("构造 WebP 编码器失败:{error}"),
}))
})?;
Ok(encoder.encode(quality).to_vec())
}
fn collect_cover_reference_image_sources(
profile: &CoverProfileInput,
requested_role_ids: &[String],
explicit_reference_image_src: &str,
) -> Vec<String> {
let selected_roles = resolve_selected_roles(profile, requested_role_ids);
let mut sources = Vec::new();
push_cover_reference_source(&mut sources, explicit_reference_image_src);
if let Some(opening_act) = resolve_opening_act(profile) {
push_cover_reference_source(
&mut sources,
trim_to_option(opening_act.background_image_src.as_deref())
.unwrap_or_default()
.as_str(),
);
}
for role in selected_roles {
push_cover_reference_source(
&mut sources,
trim_to_option(role.image_src.as_deref())
.unwrap_or_default()
.as_str(),
);
}
if let Some(camp) = profile.camp.as_ref() {
push_cover_reference_source(
&mut sources,
trim_to_option(camp.image_src.as_deref())
.unwrap_or_default()
.as_str(),
);
}
if let Some(landmark) = profile.landmarks.first() {
push_cover_reference_source(
&mut sources,
trim_to_option(landmark.image_src.as_deref())
.unwrap_or_default()
.as_str(),
);
}
sources.truncate(6);
sources
}
fn push_cover_reference_source(target: &mut Vec<String>, source: &str) {
let Some(normalized) = trim_to_option(Some(source)) else {
return;
};
if !(normalized.starts_with('/') || normalized.starts_with("data:")) {
return;
}
// 参考图源需要保留原始 Data URL / generated 路径,不能做截断,否则会破坏下游解码。
if target.contains(&normalized) {
return;
}
target.push(normalized);
}
fn resolve_selected_roles<'a>(
profile: &'a CoverProfileInput,
requested_role_ids: &[String],
) -> Vec<&'a CoverRoleInput> {
let mut selected = Vec::new();
let mut seen = Vec::new();
for role_id in requested_role_ids {
let Some(role_id) = trim_to_option(Some(role_id.as_str())) else {
continue;
};
if seen.contains(&role_id) {
continue;
}
if let Some(role) = profile
.playable_npcs
.iter()
.find(|role| trim_to_option(role.id.as_deref()).as_deref() == Some(role_id.as_str()))
{
selected.push(role);
seen.push(role_id);
}
if selected.len() >= 3 {
break;
}
}
if !selected.is_empty() {
return selected;
}
profile.playable_npcs.iter().take(3).collect()
}
fn resolve_opening_act(profile: &CoverProfileInput) -> Option<&CoverActInput> {
profile.scene_chapter_blueprints.first()?.acts.first()
}
fn build_cover_prompt_context(
profile: &CoverProfileInput,
requested_role_ids: &[String],
) -> CoverPromptContext {
let opening_act = resolve_opening_act(profile);
let selected_roles = resolve_selected_roles(profile, requested_role_ids);
let role_summary = selected_roles
.iter()
.map(|role| {
[
clamp_cover_text(
trim_to_option(role.name.as_deref())
.unwrap_or_default()
.as_str(),
18,
),
clamp_cover_text(
trim_to_option(role.title.as_deref())
.or_else(|| trim_to_option(role.role.as_deref()))
.unwrap_or_default()
.as_str(),
24,
),
clamp_cover_text(
trim_to_option(role.description.as_deref())
.unwrap_or_default()
.as_str(),
72,
),
]
.into_iter()
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join(" / ")
})
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join("");
let story_role_summary = profile
.story_npcs
.iter()
.take(4)
.map(|role| {
[
clamp_cover_text(
trim_to_option(role.name.as_deref())
.unwrap_or_default()
.as_str(),
18,
),
clamp_cover_text(
trim_to_option(role.title.as_deref())
.or_else(|| trim_to_option(role.role.as_deref()))
.unwrap_or_default()
.as_str(),
24,
),
]
.into_iter()
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join(" / ")
})
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join("");
let landmark_summary = profile
.landmarks
.iter()
.take(3)
.map(|landmark| {
[
clamp_cover_text(
trim_to_option(landmark.name.as_deref())
.unwrap_or_default()
.as_str(),
18,
),
clamp_cover_text(
trim_to_option(landmark.description.as_deref())
.unwrap_or_default()
.as_str(),
72,
),
]
.into_iter()
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join(" / ")
})
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join("");
CoverPromptContext {
opening_act_title: clamp_cover_text(
trim_to_option(opening_act.and_then(|act| act.title.as_deref()))
.unwrap_or_default()
.as_str(),
24,
),
opening_act_summary: clamp_cover_text(
trim_to_option(opening_act.and_then(|act| act.summary.as_deref()))
.unwrap_or_default()
.as_str(),
96,
),
role_summary,
story_role_summary,
landmark_summary,
}
}
fn build_custom_world_cover_image_prompt(
profile: &CoverProfileInput,
requested_role_ids: &[String],
user_prompt: &str,
has_reference_image: bool,
) -> String {
let opening_scene = profile
.camp
.as_ref()
.map(|camp| {
(
trim_to_option(camp.name.as_deref()).unwrap_or_default(),
trim_to_option(camp.description.as_deref()).unwrap_or_default(),
)
})
.or_else(|| {
profile.landmarks.first().map(|landmark| {
(
trim_to_option(landmark.name.as_deref()).unwrap_or_default(),
trim_to_option(landmark.description.as_deref()).unwrap_or_default(),
)
})
});
let prompt_context = build_cover_prompt_context(profile, requested_role_ids);
vec![
"为 16:9 横版 RPG 作品生成一张高完成度封面图,用于创作列表与作品详情头图。".to_string(),
"画面重点是“开局场景 + 2 到 3 个主要角色”的主视觉,不是纯背景图,也不是 UI 截图。"
.to_string(),
"构图需要有明显前中后景层次,前景角色清晰、主体集中、适合移动端缩略显示。".to_string(),
"不要出现任何标题文字、UI、按钮、水印、logo、边框或排版装饰。".to_string(),
if has_reference_image {
"已提供一张参考图,请尽量沿用其构图、镜头或色彩气质。".to_string()
} else {
String::new()
},
conditional_prompt_line(
"作品名",
clamp_cover_text(
trim_to_option(profile.name.as_deref())
.unwrap_or_default()
.as_str(),
48,
)
.as_str(),
),
conditional_prompt_line(
"副标题",
clamp_cover_text(
trim_to_option(profile.subtitle.as_deref())
.unwrap_or_default()
.as_str(),
48,
)
.as_str(),
),
conditional_prompt_line(
"玩家设定",
clamp_cover_text(
trim_to_option(profile.setting_text.as_deref())
.unwrap_or_default()
.as_str(),
96,
)
.as_str(),
),
conditional_prompt_line(
"世界概述",
clamp_cover_text(
trim_to_option(profile.summary.as_deref())
.unwrap_or_default()
.as_str(),
96,
)
.as_str(),
),
conditional_prompt_line(
"整体基调",
clamp_cover_text(
trim_to_option(profile.tone.as_deref())
.unwrap_or_default()
.as_str(),
72,
)
.as_str(),
),
conditional_prompt_line(
"主线目标",
clamp_cover_text(
trim_to_option(profile.player_goal.as_deref())
.unwrap_or_default()
.as_str(),
72,
)
.as_str(),
),
conditional_prompt_line("开局第一幕标题", prompt_context.opening_act_title.as_str()),
conditional_prompt_line(
"开局第一幕摘要",
prompt_context.opening_act_summary.as_str(),
),
opening_scene
.as_ref()
.map(|(name, _)| conditional_prompt_line("开局场景", name.as_str()))
.unwrap_or_default(),
opening_scene
.as_ref()
.map(|(_, description)| conditional_prompt_line("场景描述", description.as_str()))
.unwrap_or_default(),
conditional_prompt_line("关键场景素材", prompt_context.landmark_summary.as_str()),
conditional_prompt_line("需要出现的角色主形象", prompt_context.role_summary.as_str()),
conditional_prompt_line(
"可辅助参考的场景角色",
prompt_context.story_role_summary.as_str(),
),
conditional_prompt_line("额外要求", user_prompt),
"整体观感要像一张正式作品封面,主体明确,氛围饱满,人物与场景统一。".to_string(),
]
.into_iter()
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn clamp_cover_text(value: &str, max_length: usize) -> String {
clamp_text(value, max_length, false)
}
fn clamp_text(value: &str, max_length: usize, append_ellipsis: bool) -> String {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
let normalized = normalized.trim().to_string();
if normalized.is_empty() {
return String::new();
}
if normalized.chars().count() <= max_length {
return normalized;
}
let kept = normalized
.chars()
.take(if append_ellipsis {
max_length.saturating_sub(1)
} else {
max_length
})
.collect::<String>()
.trim()
.to_string();
if append_ellipsis {
format!("{kept}")
} else {
kept
}
}
fn parse_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": "dashscope",
"message": format!("{fallback_message}:解析响应失败:{error}"),
}))
})
}
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();
}
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 message.to_string();
}
if let Some(message) = parsed
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return message.to_string();
}
if let Some(code) = parsed
.pointer("/error/code")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
if let Some(code) = parsed
.get("code")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{fallback_message}{code}");
}
}
raw_text.trim().to_string()
}
fn map_dashscope_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": message,
}))
}
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",
"message": parse_api_error_message(raw_text, fallback_message),
}))
}
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) => {
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 {
if 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_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);
collect_strings_by_key(payload, "url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/jpeg");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/jpeg".to_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",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "jpg",
}
}
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()
} else {
format!("{prefix}{value}")
}
}
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 parse_image_data_url(value: &str) -> Option<ParsedImageDataUrl> {
let prefix = "data:";
let separator = ";base64,";
let body = value.strip_prefix(prefix)?;
let (mime_type, data) = body.split_once(separator)?;
if !mime_type.starts_with("image/") {
return None;
}
let bytes = decode_base64(data)?;
Some(ParsedImageDataUrl {
mime_type: mime_type.to_string(),
bytes,
})
}
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 read_string_field(object: &Map<String, Value>, key: &str) -> Option<String> {
object
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn trim_to_option(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn current_utc_millis() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch");
i64::try_from(duration.as_millis()).expect("current unix millis should fit in i64")
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time 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 custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
struct ParsedImageDataUrl {
mime_type: String,
bytes: Vec<u8>,
}
struct ParsedJsonPayload {
payload: Value,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use axum::response::Response;
use platform_auth::{AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus};
use serde_json::Value;
use time::OffsetDateTime;
fn build_authenticated(state: &AppState) -> AuthenticatedAccessToken {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_custom_world_ai".to_string(),
session_id: "sess_custom_world_ai".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some("测试旅人".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
AuthenticatedAccessToken::new(claims)
}
fn build_request_context(operation: &str) -> RequestContext {
RequestContext::new(
"req-custom-world-ai-test".to_string(),
operation.to_string(),
std::time::Duration::ZERO,
true,
)
}
async fn read_error_response(response: Response) -> Value {
use http_body_util::BodyExt as _;
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
serde_json::from_slice(&body).expect("body should be valid json")
}
fn build_state_without_vector_engine_key() -> AppState {
let mut config = AppConfig::default();
config.vector_engine_base_url = "https://api.vectorengine.test".to_string();
config.vector_engine_api_key = None;
AppState::new(config).expect("state should build")
}
fn build_state_without_dashscope_key() -> AppState {
let mut config = AppConfig::default();
config.dashscope_api_key = None;
AppState::new(config).expect("state should build")
}
#[tokio::test]
async fn scene_image_returns_service_unavailable_when_vector_engine_missing() {
let state = build_state_without_vector_engine_key();
let request_context = build_request_context("POST /api/runtime/custom-world/scene-image");
let authenticated = build_authenticated(&state);
let response = generate_custom_world_scene_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("世界".to_string()),
landmark_id: Some("landmark_001".to_string()),
landmark_name: Some("遗迹".to_string()),
prompt: Some("测试场景".to_string()),
size: Some("1280*720".to_string()),
negative_prompt: None,
reference_image_src: None,
user_prompt: None,
profile: None,
landmark: None,
})),
)
.await
.expect_err("missing vector engine should fail");
let payload = read_error_response(response).await;
assert_eq!(
payload["error"]["code"],
Value::String("SERVICE_UNAVAILABLE".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("vector-engine".to_string())
);
}
#[test]
fn automatic_scene_image_payload_reuses_manual_prompt_compiler() {
let profile = json!({
"id": "profile_001",
"name": "雾海群岛",
"subtitle": "失落航线",
"summary": "玩家在雾海中追查沉没王冠。",
"tone": "潮湿、神秘、低魔奇幻",
"playerGoal": "找到王冠并阻止海妖复苏",
"settingText": "群岛被永恒雾潮包围。"
});
let payload = CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("雾海群岛".to_string()),
landmark_id: Some("reef_temple".to_string()),
landmark_name: Some("礁石神殿".to_string()),
prompt: None,
size: Some("1280*720".to_string()),
negative_prompt: None,
reference_image_src: None,
user_prompt: Some("破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。".to_string()),
profile: Some(scene_image_profile_input_from_value(
&profile,
Some("profile_001"),
"雾海群岛",
)),
landmark: Some(SceneImageLandmarkInput {
id: Some("reef_temple".to_string()),
name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()),
}),
};
let normalized = normalize_scene_image_request(payload).expect("payload should normalize");
let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams {
profile: SceneImagePromptProfile {
name: "雾海群岛",
subtitle: "失落航线",
tone: "潮湿、神秘、低魔奇幻",
player_goal: "找到王冠并阻止海妖复苏",
summary: "玩家在雾海中追查沉没王冠。",
setting_text: "群岛被永恒雾潮包围。",
},
landmark: SceneImagePromptLandmark {
name: "礁石神殿",
description: "古老礁石上的半沉神殿。",
},
user_prompt: "破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。",
has_reference_image: false,
fallback_landmark_name: Some("礁石神殿"),
fallback_world_name: "雾海群岛",
});
assert_eq!(normalized.prompt, manual_prompt);
assert!(normalized.prompt.contains("破碎神殿矗立在蓝绿色雾潮中"));
assert_ne!(
normalized.prompt,
"破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。"
);
}
#[test]
fn automatic_default_scene_image_context_matches_manual_default_context() {
let profile = json!({
"id": "profile_001",
"name": "雾海群岛",
"subtitle": "失落航线",
"summary": "玩家在雾海中追查沉没王冠。",
"tone": "潮湿、神秘、低魔奇幻",
"playerGoal": "找到王冠并阻止海妖复苏",
"settingText": "群岛被永恒雾潮包围。"
});
let user_prompt = "破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。";
let profile_input =
scene_image_profile_input_from_value(&profile, Some("profile_001"), "雾海群岛");
let landmark = SceneImageLandmarkInput {
id: Some("reef_temple".to_string()),
name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()),
};
let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams {
profile: SceneImagePromptProfile {
name: profile_input.name.as_deref().unwrap_or_default(),
subtitle: profile_input.subtitle.as_deref().unwrap_or_default(),
tone: profile_input.tone.as_deref().unwrap_or_default(),
player_goal: profile_input.player_goal.as_deref().unwrap_or_default(),
summary: profile_input.summary.as_deref().unwrap_or_default(),
setting_text: profile_input.setting_text.as_deref().unwrap_or_default(),
},
landmark: SceneImagePromptLandmark {
name: landmark.name.as_deref().unwrap_or_default(),
description: landmark.description.as_deref().unwrap_or_default(),
},
user_prompt,
has_reference_image: false,
fallback_landmark_name: Some("礁石神殿"),
fallback_world_name: "雾海群岛",
});
let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("雾海群岛".to_string()),
landmark_id: Some("reef_temple".to_string()),
landmark_name: Some("礁石神殿".to_string()),
prompt: None,
size: Some("1280*720".to_string()),
negative_prompt: None,
reference_image_src: None,
user_prompt: Some(user_prompt.to_string()),
profile: Some(profile_input),
landmark: Some(landmark),
})
.expect("payload should normalize");
assert_eq!(normalized.prompt, manual_prompt);
}
#[test]
fn scene_image_response_model_is_gpt_image_2() {
assert_eq!(RPG_SCENE_IMAGE_MODEL, "gpt-image-2");
}
#[tokio::test]
async fn cover_image_returns_service_unavailable_when_dashscope_missing() {
let state = build_state_without_dashscope_key();
let request_context = build_request_context("POST /api/runtime/custom-world/cover-image");
let authenticated = build_authenticated(&state);
let response = generate_custom_world_cover_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(CustomWorldCoverImageRequest {
profile: CoverProfileInput {
id: Some("profile_001".to_string()),
name: Some("测试世界".to_string()),
..CoverProfileInput::default()
},
user_prompt: Some("测试封面".to_string()),
reference_image_src: None,
character_role_ids: Vec::new(),
size: Some("1600*900".to_string()),
})),
)
.await
.expect_err("missing dashscope should fail");
let payload = read_error_response(response).await;
assert_eq!(
payload["error"]["code"],
Value::String("SERVICE_UNAVAILABLE".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("dashscope".to_string())
);
}
#[tokio::test]
async fn cover_upload_rejects_invalid_data_url_before_touching_oss() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request_context = build_request_context("POST /api/runtime/custom-world/cover-upload");
let authenticated = build_authenticated(&state);
let response = upload_custom_world_cover_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(CustomWorldCoverUploadRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("测试世界".to_string()),
image_data_url: "not-a-data-url".to_string(),
crop_rect: CustomWorldCoverCropRect {
x: 0.0,
y: 0.0,
width: 160.0,
height: 90.0,
},
})),
)
.await
.expect_err("invalid data url should fail");
let payload = read_error_response(response).await;
assert_eq!(
payload["error"]["code"],
Value::String("BAD_REQUEST".to_string())
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("custom-world-ai".to_string())
);
}
#[test]
fn parse_image_data_url_accepts_image_payload() {
let parsed =
parse_image_data_url("data:image/png;base64,aGVsbG8=").expect("data url should parse");
assert_eq!(parsed.mime_type, "image/png");
assert_eq!(parsed.bytes, b"hello".to_vec());
}
#[test]
fn push_cover_reference_source_keeps_full_data_url() {
let mut sources = Vec::new();
let source = format!("data:image/png;base64,{}", "a".repeat(1024));
push_cover_reference_source(&mut sources, source.as_str());
assert_eq!(sources, vec![source]);
}
#[test]
fn normalize_cover_crop_rect_rejects_non_sixteen_nine_ratio() {
let error = normalize_cover_crop_rect(
1920,
1080,
&CustomWorldCoverCropRect {
x: 0.0,
y: 0.0,
width: 400.0,
height: 400.0,
},
)
.expect_err("invalid ratio should fail");
assert_eq!(error.code(), "BAD_REQUEST");
}
#[test]
fn optimize_uploaded_cover_image_rejects_oversized_source_before_decoding() {
let error = optimize_uploaded_cover_image(
&ParsedImageDataUrl {
mime_type: "image/png".to_string(),
bytes: vec![0; COVER_UPLOAD_MAX_BYTES + 1],
},
&CustomWorldCoverCropRect {
x: 0.0,
y: 0.0,
width: 160.0,
height: 90.0,
},
)
.expect_err("oversized upload should fail");
assert_eq!(error.code(), "BAD_REQUEST");
}
}