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, #[serde(default)] world_name: Option, #[serde(default)] landmark_id: Option, #[serde(default)] landmark_name: Option, #[serde(default)] prompt: Option, #[serde(default)] size: Option, #[serde(default)] negative_prompt: Option, #[serde(default)] reference_image_src: Option, #[serde(default)] user_prompt: Option, #[serde(default)] profile: Option, #[serde(default)] landmark: Option, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CustomWorldCoverImageRequest { profile: CoverProfileInput, #[serde(default)] user_prompt: Option, #[serde(default)] reference_image_src: Option, #[serde(default)] character_role_ids: Vec, #[serde(default)] size: Option, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CustomWorldCoverUploadRequest { #[serde(default)] profile_id: Option, #[serde(default)] world_name: Option, 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, #[serde(skip_serializing_if = "Option::is_none")] size: Option, #[serde(skip_serializing_if = "Option::is_none")] task_id: Option, #[serde(skip_serializing_if = "Option::is_none")] prompt: Option, #[serde(skip_serializing_if = "Option::is_none")] actual_prompt: Option, } #[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, file_name: String, content_type: String, body: Vec, asset_kind: &'static str, entity_kind: &'static str, entity_id: String, profile_id: Option, slot: &'static str, source_job_id: Option, } #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct SceneImageProfileInput { #[serde(default)] id: Option, #[serde(default)] name: Option, #[serde(default)] subtitle: Option, #[serde(default)] summary: Option, #[serde(default)] tone: Option, #[serde(default)] player_goal: Option, #[serde(default)] setting_text: Option, } #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct SceneImageLandmarkInput { #[serde(default)] id: Option, #[serde(default)] name: Option, #[serde(default)] description: Option, } #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct CoverRoleInput { #[serde(default)] id: Option, #[serde(default)] name: Option, #[serde(default)] title: Option, #[serde(default)] role: Option, #[serde(default)] description: Option, #[serde(default)] image_src: Option, } #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct CoverCampInput { #[serde(default)] name: Option, #[serde(default)] description: Option, #[serde(default)] image_src: Option, } #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct CoverLandmarkInput { #[serde(default)] #[allow(dead_code)] id: Option, #[serde(default)] name: Option, #[serde(default)] description: Option, #[serde(default)] image_src: Option, } #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct CoverActInput { #[serde(default)] #[allow(dead_code)] id: Option, #[serde(default)] title: Option, #[serde(default)] summary: Option, #[serde(default)] background_image_src: Option, } #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct CoverSceneChapterInput { #[serde(default)] #[allow(dead_code)] id: Option, #[serde(default)] #[allow(dead_code)] scene_id: Option, #[serde(default)] #[allow(dead_code)] title: Option, #[serde(default)] #[allow(dead_code)] summary: Option, #[serde(default)] acts: Vec, } #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct CoverProfileInput { #[serde(default)] id: Option, #[serde(default)] name: Option, #[serde(default)] subtitle: Option, #[serde(default)] summary: Option, #[serde(default)] tone: Option, #[serde(default)] player_goal: Option, #[serde(default)] setting_text: Option, #[serde(default)] camp: Option, #[serde(default)] landmarks: Vec, #[serde(default)] playable_npcs: Vec, #[serde(default)] story_npcs: Vec, #[serde(default)] scene_chapter_blueprints: Vec, } #[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, } struct DownloadedRemoteImage { mime_type: String, extension: String, bytes: Vec, } 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, world_name: String, entity_id: String, size: String, prompt: String, negative_prompt: String, reference_image_src: Option, } #[derive(Debug)] struct NormalizedCropRect { left: u32, top: u32, width: u32, height: u32, } #[derive(Debug)] struct OptimizedCoverUpload { mime_type: String, extension: String, bytes: Vec, } 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, Extension(request_context): Extension, Extension(_authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Extension(request_context): Extension, Extension(_authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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 { 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 { 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, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, 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 { 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 { 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::(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::(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 { 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 { // 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::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 { 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 { 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 { let mut content = reference_images .iter() .map(|image| json!({ "image": image })) .collect::>(); 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 { 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 { 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 { 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, 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 { 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, 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::>() .join(" / ") }) .filter(|segment| !segment.is_empty()) .collect::>() .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::>() .join(" / ") }) .filter(|segment| !segment.is_empty()) .collect::>() .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::>() .join(" / ") }) .filter(|segment| !segment.is_empty()) .collect::>() .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::>() .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::>().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::() .trim() .to_string(); if append_ellipsis { format!("{kept}…") } else { kept } } fn parse_json_payload( raw_text: &str, fallback_message: &str, ) -> Result { serde_json::from_str::(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::(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) { 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 { 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 { find_first_string_by_key(payload, "task_id") } fn extract_image_urls(payload: &Value) -> Vec { 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::(); 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 { 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> { 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, key: &str) -> Option { 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 { 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, } 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"); } }