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

2735 lines
94 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, OssPutObjectRequest,
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::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation,
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,
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, 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)]
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>,
}
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>,
}
#[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_dashscope_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_dashscope_settings(&state)?;
let http_client = build_dashscope_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 generated = if let Some(reference_image) = reference_image.as_deref() {
create_reference_image_generation(
&http_client,
&settings,
state.config.dashscope_reference_image_model.as_str(),
normalized.prompt.as_str(),
normalized.size.as_str(),
&[reference_image.to_string()],
Some(normalized.negative_prompt.as_str()),
"创建参考图场景编辑任务失败",
"参考图场景编辑未返回图片地址",
"scene-edit",
)
.await
} else {
create_text_to_image_generation(
&http_client,
&settings,
state.config.dashscope_scene_image_model.as_str(),
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
"创建场景图片生成任务失败",
"查询场景图片任务失败",
"场景图片生成任务失败",
"场景图片生成超时或未返回图片地址",
)
.await
}?;
let scene_model = if reference_image.is_some() {
state.config.dashscope_reference_image_model.clone()
} else {
state.config.dashscope_scene_image_model.clone()
};
let downloaded = download_remote_image(
&http_client,
generated.image_url.as_str(),
"下载生成图片失败",
)
.await?;
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(scene_model),
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_dashscope_settings(state)?;
let http_client = build_dashscope_http_client(&settings)?;
let generated = create_text_to_image_generation(
&http_client,
&settings,
state.config.dashscope_scene_image_model.as_str(),
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
"创建场景图片生成任务失败",
"查询场景图片任务失败",
"场景图片生成任务失败",
"场景图片生成超时或未返回图片地址",
)
.await?;
let downloaded = download_remote_image(
&http_client,
generated.image_url.as_str(),
"下载生成图片失败",
)
.await?;
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 = state.config.dashscope_scene_image_model.clone();
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))
}
async fn persist_custom_world_asset(
state: &AppState,
owner_user_id: &str,
upload: PreparedAssetUpload,
mut response: GeneratedAssetResponse,
) -> Result<GeneratedAssetResponse, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let put_result = oss_client
.put_object(
&http_client,
OssPutObjectRequest {
prefix: upload.prefix,
path_segments: upload.path_segments,
file_name: upload.file_name,
content_type: Some(upload.content_type.clone()),
access: OssObjectAccess::Private,
metadata: build_asset_metadata(
upload.asset_kind,
owner_user_id,
upload.profile_id.as_deref(),
upload.entity_kind,
upload.entity_id.as_str(),
upload.slot,
),
body: upload.body,
},
)
.await
.map_err(map_custom_world_asset_oss_error)?;
// custom world 图片链正式改为 OSS 真值确认,不再把 put_object 返回值直接当成唯一对象真相。
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_custom_world_asset_oss_error)?;
let now_micros = current_utc_micros();
let asset_object = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(upload.content_type)),
head.content_length,
head.etag,
upload.asset_kind.to_string(),
upload.source_job_id,
Some(owner_user_id.to_string()),
upload.profile_id.clone(),
Some(upload.entity_id.clone()),
now_micros,
)
.map_err(map_asset_object_prepare_error)?,
)
.await
.map_err(map_custom_world_asset_spacetime_error)?;
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(now_micros),
asset_object.asset_object_id,
upload.entity_kind.to_string(),
upload.entity_id,
upload.slot.to_string(),
upload.asset_kind.to_string(),
Some(owner_user_id.to_string()),
upload.profile_id,
now_micros,
)
.map_err(map_asset_binding_prepare_error)?,
)
.await
.map_err(map_custom_world_asset_spacetime_error)?;
response.image_src = put_result.legacy_public_path;
Ok(response)
}
fn build_asset_metadata(
asset_kind: &str,
owner_user_id: &str,
profile_id: Option<&str>,
entity_kind: &str,
entity_id: &str,
slot: &str,
) -> BTreeMap<String, String> {
let mut metadata = BTreeMap::from([
("asset_kind".to_string(), asset_kind.to_string()),
("owner_user_id".to_string(), owner_user_id.to_string()),
("entity_kind".to_string(), entity_kind.to_string()),
("entity_id".to_string(), entity_id.to_string()),
("slot".to_string(), slot.to_string()),
]);
if let Some(profile_id) = profile_id {
metadata.insert("profile_id".to_string(), profile_id.to_string());
}
metadata
}
fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-entity-binding",
"message": error.to_string(),
}))
}
fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
map_oss_error(error, "aliyun-oss")
}
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)),
]);
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,
)),
]);
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 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 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_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_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 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_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 mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "jpg",
}
}
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 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_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_dashscope_missing() {
let state = build_state_without_dashscope_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 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())
);
}
#[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);
}
#[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");
}
}