2753 lines
94 KiB
Rust
2753 lines
94 KiB
Rust
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,
|
||
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,
|
||
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))?;
|
||
let asset_id = format!("custom-scene-{}", current_utc_millis());
|
||
crate::asset_billing::consume_asset_operation_points(
|
||
&state,
|
||
&owner_user_id,
|
||
"scene_image",
|
||
asset_id.as_str(),
|
||
)
|
||
.await
|
||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||
let asset_result = 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;
|
||
|
||
let asset = match asset_result {
|
||
Ok(asset) => asset,
|
||
Err(error) => {
|
||
crate::asset_billing::refund_asset_operation_points(
|
||
&state,
|
||
&owner_user_id,
|
||
"scene_image",
|
||
&asset_id,
|
||
)
|
||
.await;
|
||
return Err(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());
|
||
let asset_id = format!("custom-cover-{}", current_utc_millis());
|
||
crate::asset_billing::consume_asset_operation_points(
|
||
&state,
|
||
&owner_user_id,
|
||
"custom_world_cover",
|
||
asset_id.as_str(),
|
||
)
|
||
.await
|
||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||
let asset_result = 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;
|
||
|
||
let asset = match asset_result {
|
||
Ok(asset) => asset,
|
||
Err(error) => {
|
||
crate::asset_billing::refund_asset_operation_points(
|
||
&state,
|
||
&owner_user_id,
|
||
"custom_world_cover",
|
||
&asset_id,
|
||
)
|
||
.await;
|
||
return Err(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 {
|
||
let status = match error {
|
||
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
|
||
StatusCode::BAD_REQUEST
|
||
}
|
||
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
|
||
platform_oss::OssError::Request(_)
|
||
| platform_oss::OssError::SerializePolicy(_)
|
||
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
|
||
};
|
||
AppError::from_status(status).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": error.to_string(),
|
||
}))
|
||
}
|
||
|
||
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")
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn scene_image_returns_service_unavailable_when_dashscope_missing() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
let request_context = build_request_context("POST /api/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");
|
||
|
||
assert!(normalized.prompt.contains("世界名:雾海群岛"));
|
||
assert!(normalized.prompt.contains("世界副标题:失落航线"));
|
||
assert!(normalized.prompt.contains("场景名称:礁石神殿"));
|
||
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 = AppState::new(AppConfig::default()).expect("state should build");
|
||
let request_context = build_request_context("POST /api/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/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");
|
||
}
|
||
}
|