use std::{ collections::BTreeMap, time::{Duration, Instant}, }; use axum::{ Json, extract::{Extension, State, rejection::JsonRejection}, http::StatusCode, response::Response, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use image::{DynamicImage, GenericImageView, imageops::FilterType}; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; use platform_llm::{LlmMessage, LlmTextRequest}; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest, }; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use spacetime_client::SpacetimeClientError; use tokio::time::sleep; use webp::Encoder as WebpEncoder; use crate::generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput, normalize_generated_image_asset_mime, }; use crate::{ api_response::json_success_body, asset_billing::{execute_billable_asset_operation, execute_billable_asset_operation_with_cost}, auth::AuthenticatedAccessToken, custom_world_result_prompts::{ build_result_entity_system_prompt, build_result_entity_user_prompt, build_result_scene_npc_system_prompt, build_result_scene_npc_user_prompt, }, http_error::AppError, llm_model_routing::CREATION_TEMPLATE_LLM_MODEL, openai_image_generation::{ DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, build_openai_image_http_client, create_openai_image_generation, require_openai_image_settings, }, platform_errors::map_oss_error, prompt::scene_background::{ DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, SceneImagePromptLandmark, SceneImagePromptParams, SceneImagePromptProfile, build_custom_world_scene_image_prompt, }, request_context::RequestContext, state::AppState, }; #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CustomWorldEntityRequest { profile: Value, kind: String, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CustomWorldSceneNpcRequest { profile: Value, landmark_id: String, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CustomWorldSceneImageRequest { #[serde(default)] profile_id: Option, #[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, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CustomWorldOpeningCgGenerateRequest { profile: Value, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct GeneratedAssetResponse { image_src: String, asset_id: String, source_type: String, #[serde(skip_serializing_if = "Option::is_none")] model: Option, #[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, Serialize)] #[serde(rename_all = "camelCase")] struct GeneratedOpeningCgResponse { opening_cg: CustomWorldOpeningCgProfileResponse, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct CustomWorldOpeningCgProfileResponse { id: String, status: &'static str, storyboard_image_src: String, storyboard_asset_id: String, video_src: String, video_asset_id: String, poster_image_src: Option, poster_asset_id: Option, storyboard_prompt: String, video_prompt: String, image_model: &'static str, video_model: String, aspect_ratio: &'static str, image_size: &'static str, video_resolution: &'static str, duration_seconds: u32, point_cost: u64, estimated_wait_minutes: u32, generated_at: String, updated_at: String, error_message: Option, } #[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, } const RPG_SCENE_IMAGE_MODEL: &str = GPT_IMAGE_2_MODEL; const OPENING_CG_POINTS_COST: u64 = 80; const OPENING_CG_ESTIMATED_WAIT_MINUTES: u32 = 10; const OPENING_CG_IMAGE_SIZE_LABEL: &str = "2k"; const OPENING_CG_STORYBOARD_IMAGE_SIZE: &str = "2048x1152"; const OPENING_CG_VIDEO_PROMPT: &str = "利用参考图作为故事板,生成一段连贯的动画,没有旁白"; const OPENING_CG_VIDEO_RESOLUTION: &str = "480p"; const OPENING_CG_VIDEO_RATIO: &str = "16:9"; const OPENING_CG_VIDEO_DURATION_SECONDS: u32 = 15; const OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS: u64 = 600_000; const OPENING_CG_ASPECT_RATIO: &str = "16:9"; const OPENING_CG_STORYBOARD_ASSET_KIND: &str = "custom_world_opening_cg_storyboard"; const OPENING_CG_VIDEO_ASSET_KIND: &str = "custom_world_opening_cg_video"; const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile"; const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard"; const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video"; const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000; struct CoverPromptContext { opening_act_title: String, opening_act_summary: String, role_summary: String, story_role_summary: String, landmark_summary: String, } struct NormalizedSceneImageRequest { profile_id: Option, world_name: String, entity_id: String, size: String, prompt: String, negative_prompt: String, reference_image_src: Option, } struct NormalizedOpeningCgRequest { profile_id: Option, world_name: String, opening_cg_id: String, storyboard_prompt: String, video_prompt: String, player_role_image_src: String, opening_scene_image_src: String, } struct ArkVideoSettings { base_url: String, api_key: String, request_timeout_ms: u64, model: String, } struct GeneratedOpeningCgStoryboard { image_src: String, asset_id: String, } struct GeneratedOpeningCgVideo { video_src: String, asset_id: String, } struct DownloadedRemoteVideo { mime_type: String, extension: String, bytes: Vec, } #[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))?; require_openai_image_settings(&state) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let asset_id = format!("custom-scene-{}", current_utc_millis()); let asset = execute_billable_asset_operation( &state, &owner_user_id, "scene_image", asset_id.as_str(), async { let settings = require_openai_image_settings(&state)?; let http_client = build_openai_image_http_client(&settings)?; let reference_image = if let Some(reference_image_src) = normalized.reference_image_src.as_deref() { Some( resolve_reference_image_as_data_url( &state, &http_client, reference_image_src, "referenceImageSrc", ) .await?, ) } else { None }; let reference_images = reference_image .as_ref() .map(|value| vec![value.clone()]) .unwrap_or_default(); let generated = create_openai_image_generation( &http_client, &settings, normalized.prompt.as_str(), Some(normalized.negative_prompt.as_str()), normalized.size.as_str(), 1, &reference_images, "场景图片生成失败", ) .await?; let downloaded = generated .images .into_iter() .next() .map(downloaded_openai_to_custom_world_image) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "场景图片生成成功但未返回图片。", })) })?; let upload = PreparedAssetUpload { prefix: LegacyAssetPrefix::CustomWorldScenes, path_segments: vec![ sanitize_storage_segment( normalized .profile_id .as_deref() .unwrap_or(normalized.world_name.as_str()), "world", ), sanitize_storage_segment(normalized.entity_id.as_str(), "scene"), asset_id.clone(), ], file_name: format!("scene.{}", downloaded.extension), content_type: downloaded.mime_type, body: downloaded.bytes, asset_kind: "scene_image", entity_kind: "custom_world_landmark", entity_id: normalized.entity_id.clone(), profile_id: normalized.profile_id.clone(), slot: "scene_image", source_job_id: Some(generated.task_id.clone()), }; persist_custom_world_asset( &state, &owner_user_id, upload, GeneratedAssetResponse { image_src: String::new(), asset_id: asset_id.clone(), source_type: "generated".to_string(), model: Some(RPG_SCENE_IMAGE_MODEL.to_string()), size: Some(normalized.size), task_id: Some(generated.task_id), prompt: Some(normalized.prompt), actual_prompt: generated.actual_prompt, }, ) .await }, ) .await .map_err(|error| custom_world_ai_error_response(&request_context, error))?; Ok(json_success_body(Some(&request_context), asset)) } pub(crate) async fn generate_custom_world_scene_image_for_profile( state: &AppState, owner_user_id: &str, profile: &Value, profile_id: Option<&str>, world_name: &str, scene_id: &str, scene_name: &str, scene_description: &str, prompt_text: &str, ) -> Result { let payload = CustomWorldSceneImageRequest { profile_id: profile_id.map(ToOwned::to_owned), world_name: Some(world_name.to_string()), landmark_id: Some(scene_id.to_string()), landmark_name: Some(scene_name.to_string()), // 自动草稿生成必须和草稿页手动生成走同一条 prompt 编译链: // 只把幕级描述作为 userPrompt 输入,仍交给 normalize_scene_image_request 组装世界名、地点名、风格与负面词。 prompt: None, size: Some("1280*720".to_string()), negative_prompt: None, reference_image_src: None, user_prompt: Some(prompt_text.to_string()), profile: Some(scene_image_profile_input_from_value( profile, profile_id, world_name, )), landmark: Some(SceneImageLandmarkInput { id: Some(scene_id.to_string()), name: Some(scene_name.to_string()), description: Some(scene_description.to_string()), }), }; let normalized = normalize_scene_image_request(payload)?; let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, &settings, normalized.prompt.as_str(), Some(normalized.negative_prompt.as_str()), normalized.size.as_str(), 1, &[], "场景图片生成失败", ) .await?; let downloaded = generated .images .into_iter() .next() .map(downloaded_openai_to_custom_world_image) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "场景图片生成成功但未返回图片。", })) })?; let asset_id = format!("custom-scene-{}", current_utc_millis()); let upload = PreparedAssetUpload { prefix: LegacyAssetPrefix::CustomWorldScenes, path_segments: vec![ sanitize_storage_segment( normalized .profile_id .as_deref() .unwrap_or(normalized.world_name.as_str()), "world", ), sanitize_storage_segment(normalized.entity_id.as_str(), "scene"), asset_id.clone(), ], file_name: format!("scene.{}", downloaded.extension), content_type: downloaded.mime_type, body: downloaded.bytes, asset_kind: "scene_image", entity_kind: "custom_world_landmark", entity_id: normalized.entity_id.clone(), profile_id: normalized.profile_id.clone(), slot: "scene_image", source_job_id: Some(generated.task_id.clone()), }; let model = RPG_SCENE_IMAGE_MODEL.to_string(); let prompt = normalized.prompt.clone(); let asset = persist_custom_world_asset( state, owner_user_id, upload, GeneratedAssetResponse { image_src: String::new(), asset_id: asset_id.clone(), source_type: "generated".to_string(), model: Some(model.clone()), size: Some(normalized.size), task_id: Some(generated.task_id), prompt: Some(prompt.clone()), actual_prompt: generated.actual_prompt, }, ) .await?; Ok(GeneratedCustomWorldSceneImage { image_src: asset.image_src, asset_id, prompt, model, }) } fn scene_image_profile_input_from_value( profile: &Value, profile_id: Option<&str>, world_name: &str, ) -> SceneImageProfileInput { SceneImageProfileInput { id: profile_id.map(ToOwned::to_owned), name: Some(world_name.to_string()), subtitle: json_text_from_value(profile, "subtitle"), summary: json_text_from_value(profile, "summary"), tone: json_text_from_value(profile, "tone"), player_goal: json_text_from_value(profile, "playerGoal"), setting_text: json_text_from_value(profile, "settingText"), } } fn json_text_from_value(value: &Value, key: &str) -> Option { 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()); require_dashscope_settings(&state) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let asset_id = format!("custom-cover-{}", current_utc_millis()); let asset = execute_billable_asset_operation( &state, &owner_user_id, "custom_world_cover", asset_id.as_str(), async { let settings = require_dashscope_settings(&state)?; let http_client = build_dashscope_http_client(&settings)?; let reference_sources = collect_cover_reference_image_sources( &payload.profile, &payload.character_role_ids, payload.reference_image_src.as_deref().unwrap_or_default(), ); let prompt = build_custom_world_cover_image_prompt( &payload.profile, &payload.character_role_ids, payload.user_prompt.as_deref().unwrap_or_default(), !reference_sources.is_empty(), ); let mut reference_images = Vec::with_capacity(reference_sources.len()); for source in &reference_sources { reference_images.push( resolve_reference_image_as_data_url( &state, &http_client, source.as_str(), "referenceImageSrc", ) .await?, ); } let generated = if reference_images.is_empty() { create_text_to_image_generation( &http_client, &settings, state.config.dashscope_cover_image_model.clone().as_str(), prompt.as_str(), None, size.as_str(), "创建作品封面生成任务失败", "查询作品封面任务失败", "作品封面生成任务失败", "作品封面生成超时或未返回图片地址", ) .await } else { create_reference_image_generation( &http_client, &settings, state.config.dashscope_reference_image_model.as_str(), prompt.as_str(), size.as_str(), &reference_images, None, "创建参考图封面任务失败", "封面生成未返回图片地址", "cover-edit", ) .await }?; let downloaded = download_remote_image( &http_client, generated.image_url.as_str(), "下载作品封面失败", ) .await?; let upload = PreparedAssetUpload { prefix: LegacyAssetPrefix::CustomWorldCovers, path_segments: vec![ sanitize_storage_segment(entity_id.as_str(), "world"), asset_id.clone(), ], file_name: format!("cover.{}", downloaded.extension), content_type: downloaded.mime_type, body: downloaded.bytes, asset_kind: "custom_world_cover", entity_kind: "custom_world_profile", entity_id, profile_id, slot: "cover", source_job_id: Some(generated.task_id.clone()), }; persist_custom_world_asset( &state, &owner_user_id, upload, GeneratedAssetResponse { image_src: String::new(), asset_id: asset_id.clone(), source_type: "generated".to_string(), model: Some(if reference_images.is_empty() { state.config.dashscope_cover_image_model.clone() } else { state.config.dashscope_reference_image_model.clone() }), size: Some(size), task_id: Some(generated.task_id), prompt: Some(prompt), actual_prompt: generated.actual_prompt, }, ) .await }, ) .await .map_err(|error| custom_world_ai_error_response(&request_context, error))?; Ok(json_success_body(Some(&request_context), asset)) } pub async fn upload_custom_world_cover_image( State(state): State, 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)) } pub async fn generate_custom_world_opening_cg( 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-opening-cg", "message": error.body_text(), })), ) })?; let owner_user_id = authenticated.claims().user_id().to_string(); let normalized = normalize_opening_cg_request(&payload.profile) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; require_openai_image_settings(&state) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; require_ark_video_settings(&state) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let opening_cg_id = normalized.opening_cg_id.clone(); let generated = execute_billable_asset_operation_with_cost( &state, &owner_user_id, "custom_world_opening_cg", opening_cg_id.as_str(), OPENING_CG_POINTS_COST, async { let image_settings = require_openai_image_settings(&state)?; let image_http_client = build_openai_image_http_client(&image_settings)?; let video_settings = require_ark_video_settings(&state)?; let video_http_client = build_upstream_http_client(video_settings.request_timeout_ms)?; let player_role_reference = resolve_reference_image_as_data_url( &state, &image_http_client, normalized.player_role_image_src.as_str(), "playerRoleImageSrc", ) .await?; let opening_scene_reference = resolve_reference_image_as_data_url( &state, &image_http_client, normalized.opening_scene_image_src.as_str(), "openingSceneImageSrc", ) .await?; let storyboard = generate_opening_cg_storyboard( &state, &owner_user_id, &image_http_client, &image_settings, &normalized, &[player_role_reference, opening_scene_reference], ) .await?; let storyboard_reference = resolve_reference_image_as_data_url( &state, &image_http_client, storyboard.image_src.as_str(), "storyboardImageSrc", ) .await?; let video = generate_opening_cg_video( &state, &owner_user_id, &video_http_client, &video_settings, &normalized, storyboard_reference.as_str(), ) .await?; let generated_at = current_utc_iso_text(); Ok(CustomWorldOpeningCgProfileResponse { id: opening_cg_id.clone(), status: "ready", storyboard_image_src: storyboard.image_src, storyboard_asset_id: storyboard.asset_id, video_src: video.video_src, video_asset_id: video.asset_id, poster_image_src: None, poster_asset_id: None, storyboard_prompt: normalized.storyboard_prompt.clone(), video_prompt: normalized.video_prompt.clone(), image_model: GPT_IMAGE_2_MODEL, video_model: video_settings.model, aspect_ratio: OPENING_CG_ASPECT_RATIO, image_size: OPENING_CG_IMAGE_SIZE_LABEL, video_resolution: OPENING_CG_VIDEO_RESOLUTION, duration_seconds: OPENING_CG_VIDEO_DURATION_SECONDS, point_cost: OPENING_CG_POINTS_COST, estimated_wait_minutes: OPENING_CG_ESTIMATED_WAIT_MINUTES, generated_at: generated_at.clone(), updated_at: generated_at, error_message: None, }) }, ) .await .map_err(|error| custom_world_ai_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), GeneratedOpeningCgResponse { opening_cg: generated, }, )) } mod assets; use assets::persist_custom_world_asset; mod opening_cg; use opening_cg::{ generate_opening_cg_storyboard, generate_opening_cg_video, map_custom_world_asset_oss_error, }; async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: &str) -> Value { let fallback = build_entity_fallback(profile, kind); let Some(llm_client) = state.llm_client() else { return fallback; }; let request = LlmTextRequest::new(vec![ LlmMessage::system(build_result_entity_system_prompt()), LlmMessage::user(build_result_entity_user_prompt(profile, kind, &fallback)), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) .with_responses_api() .with_web_search(true); llm_client .request_text(request) .await .ok() .and_then(|response| serde_json::from_str::(response.content.trim()).ok()) .unwrap_or(fallback) } async fn generate_scene_npc_with_fallback( state: &AppState, profile: &Value, landmark_id: &str, ) -> Value { let fallback = build_scene_npc_fallback(profile, landmark_id); let Some(llm_client) = state.llm_client() else { return fallback; }; let request = LlmTextRequest::new(vec![ LlmMessage::system(build_result_scene_npc_system_prompt()), LlmMessage::user(build_result_scene_npc_user_prompt( profile, landmark_id, &fallback, )), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) .with_responses_api() .with_web_search(true); llm_client .request_text(request) .await .ok() .and_then(|response| serde_json::from_str::(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 normalize_opening_cg_request(profile: &Value) -> Result { let object = profile.as_object().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-opening-cg", "message": "profile 必须是 JSON object", })) })?; let world_name = read_string_field(object, "name").unwrap_or_else(|| "未命名世界".to_string()); let profile_id = read_string_field(object, "id"); let world_tone = read_string_field(object, "tone") .ok_or_else(|| missing_opening_cg_field_error("世界基调缺失,无法生成开局 CG。"))?; let world_summary = read_string_field(object, "summary") .ok_or_else(|| missing_opening_cg_field_error("世界概述缺失,无法生成开局 CG。"))?; let core_conflicts = read_string_array_field(object, "coreConflicts"); if core_conflicts.is_empty() { return Err(missing_opening_cg_field_error( "核心冲突缺失,无法生成开局 CG。", )); } let player_role = object .get("playableNpcs") .and_then(Value::as_array) .and_then(|roles| roles.first()) .and_then(Value::as_object) .ok_or_else(|| missing_opening_cg_field_error("缺少玩家扮演角色。"))?; let player_role_image_src = read_string_field(player_role, "imageSrc") .ok_or_else(|| missing_opening_cg_field_error("玩家扮演角色缺少角色参考图。"))?; let player_role_brief = build_opening_cg_player_role_brief(player_role); let opening_scene_image_src = profile .pointer("/sceneChapterBlueprints/0/acts/0/backgroundImageSrc") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .ok_or_else(|| { missing_opening_cg_field_error("首个场景第一幕背景图缺失,无法生成开局 CG。") })?; let opening_cg_id = format!("opening-cg-{}", current_utc_millis()); let storyboard_prompt = build_opening_cg_storyboard_prompt( world_tone.as_str(), player_role_brief.as_str(), world_summary.as_str(), core_conflicts.as_slice(), ); Ok(NormalizedOpeningCgRequest { profile_id, world_name, opening_cg_id, storyboard_prompt, video_prompt: OPENING_CG_VIDEO_PROMPT.to_string(), player_role_image_src, opening_scene_image_src, }) } fn missing_opening_cg_field_error(message: &str) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-opening-cg", "message": message, })) } fn build_opening_cg_storyboard_prompt( world_tone: &str, player_role_brief: &str, world_summary: &str, core_conflicts: &[String], ) -> String { format!( "以3*4网格格式创建故事板,16:9。像素风角色扮演游戏开场动画CG。\n\n故事流程:先展示角色,展示故事背景,然后表现核心冲突,最后衔接开局场景\n故事基调:{}\n\n玩家扮演:将玩家扮演角色作为角色参考图并引用世界草稿中的角色简介:{}\n故事背景:{}\n核心冲突:{}\n开局场景:将首个场景的第一幕背景图作为参考图", clamp_opening_cg_prompt_text(world_tone, 160), clamp_opening_cg_prompt_text(player_role_brief, 320), clamp_opening_cg_prompt_text(world_summary, 420), clamp_opening_cg_prompt_text(core_conflicts.join(";").as_str(), 360), ) } fn build_opening_cg_player_role_brief(role: &Map) -> String { [ read_string_field(role, "name") .map(|value| format!("姓名:{value}")) .unwrap_or_default(), read_string_field(role, "role") .map(|value| format!("身份:{value}")) .unwrap_or_default(), read_string_field(role, "description") .map(|value| format!("简介:{value}")) .unwrap_or_default(), read_string_field(role, "visualDescription") .map(|value| format!("形象:{value}")) .unwrap_or_default(), ] .into_iter() .filter(|value| !value.trim().is_empty()) .collect::>() .join(";") } fn read_string_array_field(object: &Map, key: &str) -> Vec { object .get(key) .and_then(Value::as_array) .map(|entries| { entries .iter() .filter_map(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect() }) .unwrap_or_default() } fn clamp_opening_cg_prompt_text(value: &str, max_length: usize) -> String { clamp_text(value, max_length, false) } fn require_ark_video_settings(state: &AppState) -> Result { let base_url = state .config .ark_character_video_base_url .trim() .trim_end_matches('/'); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "ark", "reason": "ARK_CHARACTER_VIDEO_BASE_URL 未配置", })), ); } let api_key = state .config .ark_character_video_api_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "ark", "reason": "ARK_CHARACTER_VIDEO_API_KEY 未配置", })) })?; Ok(ArkVideoSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), request_timeout_ms: state .config .ark_character_video_request_timeout_ms .max(OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS), model: state.config.ark_character_video_model.clone(), }) } fn build_upstream_http_client(timeout_ms: u64) -> Result { reqwest::Client::builder() .timeout(Duration::from_millis(timeout_ms)) .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "custom-world-opening-cg", "message": format!("构造上游 HTTP 客户端失败:{error}"), })) }) } fn require_dashscope_settings(state: &AppState) -> Result { // 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 downloaded_openai_to_custom_world_image(image: DownloadedOpenAiImage) -> DownloadedRemoteImage { DownloadedRemoteImage { extension: image.extension, mime_type: image.mime_type, bytes: image.bytes, } } fn optimize_uploaded_cover_image( parsed_data_url: &ParsedImageDataUrl, crop_rect: &CustomWorldCoverCropRect, ) -> Result { 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_ark_video_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": "ark", "message": format!("{fallback_message}:解析响应失败:{error}"), })) }) } fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { if raw_text.trim().is_empty() { return fallback_message.to_string(); } if let Ok(parsed) = serde_json::from_str::(raw_text) { if let Some(message) = parsed .pointer("/error/message") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { return message.to_string(); } if let Some(message) = parsed .get("message") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { return message.to_string(); } if let Some(code) = parsed .pointer("/error/code") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { return format!("{fallback_message}({code})"); } if let Some(code) = parsed .get("code") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { return format!("{fallback_message}({code})"); } } raw_text.trim().to_string() } fn map_dashscope_request_error(message: String) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": message, })) } fn map_ark_video_request_error(message: String) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "ark", "message": message, })) } fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", "message": parse_api_error_message(raw_text, fallback_message), })) } fn parse_ark_video_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "ark", "message": parse_api_error_message(raw_text, fallback_message), })) } fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { 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_ark_task_id(payload: &Value) -> Option { payload .get("id") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| find_first_string_by_key(payload, "task_id")) .or_else(|| find_first_string_by_key(payload, "taskId")) .or_else(|| find_first_string_by_key(payload, "id")) } fn extract_video_url(payload: &Value) -> Option { find_first_string_by_key(payload, "video_url") .or_else(|| find_first_string_by_key(payload, "videoUrl")) .or_else(|| find_first_string_by_key(payload, "url")) } fn extract_generation_task_status(payload: &Value) -> String { payload .get("status") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| find_first_string_by_key(payload, "task_status")) .or_else(|| find_first_string_by_key(payload, "status")) .unwrap_or_default() } fn normalize_generation_task_status(value: &str) -> String { value.trim().to_ascii_lowercase().replace(' ', "_") } fn is_completed_generation_task_status(status: &str) -> bool { matches!( status, "completed" | "complete" | "done" | "finished" | "success" | "succeeded" | "succeed" ) } fn is_failed_generation_task_status(status: &str) -> bool { matches!( status, "failed" | "canceled" | "cancelled" | "error" | "aborted" | "rejected" | "expired" | "unknown" ) } fn extract_image_urls(payload: &Value) -> Vec { let mut urls = Vec::new(); collect_strings_by_key(payload, "image", &mut urls); collect_strings_by_key(payload, "url", &mut urls); let mut deduped = Vec::new(); for url in urls { if !deduped.contains(&url) { deduped.push(url); } } deduped } fn normalize_downloaded_image_mime_type(content_type: &str) -> String { let mime_type = content_type .split(';') .next() .map(str::trim) .unwrap_or("image/jpeg"); match mime_type { "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { mime_type.to_string() } _ => "image/jpeg".to_string(), } } fn normalize_downloaded_video_mime_type(content_type: &str) -> String { let mime_type = content_type .split(';') .next() .map(str::trim) .unwrap_or("video/mp4"); match mime_type { "video/mp4" | "video/quicktime" | "video/webm" | "video/x-msvideo" => mime_type.to_string(), _ => "video/mp4".to_string(), } } fn mime_to_extension(mime_type: &str) -> &str { match mime_type { "image/png" => "png", "image/webp" => "webp", "image/gif" => "gif", _ => "jpg", } } fn video_mime_to_extension(mime_type: &str) -> &str { match mime_type { "video/quicktime" => "mov", "video/webm" => "webm", "video/x-msvideo" => "avi", _ => "mp4", } } fn conditional_prompt_line(prefix: &str, value: &str) -> String { if value.is_empty() { String::new() } else { format!("{prefix}:{value}。") } } fn sanitize_storage_segment(value: &str, fallback: &str) -> String { let normalized = value .trim() .chars() .map(|character| match character { 'a'..='z' | '0'..='9' | '-' | '_' => character, 'A'..='Z' => character.to_ascii_lowercase(), _ => '-', }) .collect::(); 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 current_utc_iso_text() -> String { time::OffsetDateTime::now_utc() .format(&time::format_description::well_known::Rfc3339) .unwrap_or_else(|_| format!("{}.000000Z", current_utc_millis())) } fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } struct ParsedImageDataUrl { mime_type: String, bytes: Vec, } struct ParsedJsonPayload { payload: Value, } #[cfg(test)] mod tests { use super::*; use crate::config::AppConfig; use axum::response::Response; use platform_auth::{AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus}; use serde_json::Value; use time::OffsetDateTime; fn build_authenticated(state: &AppState) -> AuthenticatedAccessToken { let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: "user_custom_world_ai".to_string(), session_id: "sess_custom_world_ai".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 1, phone_verified: false, binding_status: BindingStatus::Active, display_name: Some("测试旅人".to_string()), }, state.auth_jwt_config(), OffsetDateTime::now_utc(), ) .expect("claims should build"); AuthenticatedAccessToken::new(claims) } fn build_request_context(operation: &str) -> RequestContext { RequestContext::new( "req-custom-world-ai-test".to_string(), operation.to_string(), std::time::Duration::ZERO, true, ) } async fn read_error_response(response: Response) -> Value { use http_body_util::BodyExt as _; let body = response .into_body() .collect() .await .expect("body should collect") .to_bytes(); serde_json::from_slice(&body).expect("body should be valid json") } fn build_state_without_vector_engine_key() -> AppState { let mut config = AppConfig::default(); config.vector_engine_base_url = "https://api.vectorengine.test".to_string(); config.vector_engine_api_key = None; AppState::new(config).expect("state should build") } fn build_state_without_dashscope_key() -> AppState { let mut config = AppConfig::default(); config.dashscope_api_key = None; AppState::new(config).expect("state should build") } #[tokio::test] async fn scene_image_returns_service_unavailable_when_vector_engine_missing() { let state = build_state_without_vector_engine_key(); let request_context = build_request_context("POST /api/runtime/custom-world/scene-image"); let authenticated = build_authenticated(&state); let response = generate_custom_world_scene_image( State(state), Extension(request_context), Extension(authenticated), Ok(Json(CustomWorldSceneImageRequest { profile_id: Some("profile_001".to_string()), world_name: Some("世界".to_string()), landmark_id: Some("landmark_001".to_string()), landmark_name: Some("遗迹".to_string()), prompt: Some("测试场景".to_string()), size: Some("1280*720".to_string()), negative_prompt: None, reference_image_src: None, user_prompt: None, profile: None, landmark: None, })), ) .await .expect_err("missing vector engine should fail"); let payload = read_error_response(response).await; assert_eq!( payload["error"]["code"], Value::String("SERVICE_UNAVAILABLE".to_string()) ); assert_eq!( payload["error"]["details"]["provider"], Value::String("vector-engine".to_string()) ); } #[test] fn automatic_scene_image_payload_reuses_manual_prompt_compiler() { let profile = json!({ "id": "profile_001", "name": "雾海群岛", "subtitle": "失落航线", "summary": "玩家在雾海中追查沉没王冠。", "tone": "潮湿、神秘、低魔奇幻", "playerGoal": "找到王冠并阻止海妖复苏", "settingText": "群岛被永恒雾潮包围。" }); let payload = CustomWorldSceneImageRequest { profile_id: Some("profile_001".to_string()), world_name: Some("雾海群岛".to_string()), landmark_id: Some("reef_temple".to_string()), landmark_name: Some("礁石神殿".to_string()), prompt: None, size: Some("1280*720".to_string()), negative_prompt: None, reference_image_src: None, user_prompt: Some("破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。".to_string()), profile: Some(scene_image_profile_input_from_value( &profile, Some("profile_001"), "雾海群岛", )), landmark: Some(SceneImageLandmarkInput { id: Some("reef_temple".to_string()), name: Some("礁石神殿".to_string()), description: Some("古老礁石上的半沉神殿。".to_string()), }), }; let normalized = normalize_scene_image_request(payload).expect("payload should normalize"); let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams { profile: SceneImagePromptProfile { name: "雾海群岛", subtitle: "失落航线", tone: "潮湿、神秘、低魔奇幻", player_goal: "找到王冠并阻止海妖复苏", summary: "玩家在雾海中追查沉没王冠。", setting_text: "群岛被永恒雾潮包围。", }, landmark: SceneImagePromptLandmark { name: "礁石神殿", description: "古老礁石上的半沉神殿。", }, user_prompt: "破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。", has_reference_image: false, fallback_landmark_name: Some("礁石神殿"), fallback_world_name: "雾海群岛", }); assert_eq!(normalized.prompt, manual_prompt); assert!(normalized.prompt.contains("破碎神殿矗立在蓝绿色雾潮中")); assert_ne!( normalized.prompt, "破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。" ); } #[test] fn automatic_default_scene_image_context_matches_manual_default_context() { let profile = json!({ "id": "profile_001", "name": "雾海群岛", "subtitle": "失落航线", "summary": "玩家在雾海中追查沉没王冠。", "tone": "潮湿、神秘、低魔奇幻", "playerGoal": "找到王冠并阻止海妖复苏", "settingText": "群岛被永恒雾潮包围。" }); let user_prompt = "破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。"; let profile_input = scene_image_profile_input_from_value(&profile, Some("profile_001"), "雾海群岛"); let landmark = SceneImageLandmarkInput { id: Some("reef_temple".to_string()), name: Some("礁石神殿".to_string()), description: Some("古老礁石上的半沉神殿。".to_string()), }; let manual_prompt = build_custom_world_scene_image_prompt(SceneImagePromptParams { profile: SceneImagePromptProfile { name: profile_input.name.as_deref().unwrap_or_default(), subtitle: profile_input.subtitle.as_deref().unwrap_or_default(), tone: profile_input.tone.as_deref().unwrap_or_default(), player_goal: profile_input.player_goal.as_deref().unwrap_or_default(), summary: profile_input.summary.as_deref().unwrap_or_default(), setting_text: profile_input.setting_text.as_deref().unwrap_or_default(), }, landmark: SceneImagePromptLandmark { name: landmark.name.as_deref().unwrap_or_default(), description: landmark.description.as_deref().unwrap_or_default(), }, user_prompt, has_reference_image: false, fallback_landmark_name: Some("礁石神殿"), fallback_world_name: "雾海群岛", }); let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest { profile_id: Some("profile_001".to_string()), world_name: Some("雾海群岛".to_string()), landmark_id: Some("reef_temple".to_string()), landmark_name: Some("礁石神殿".to_string()), prompt: None, size: Some("1280*720".to_string()), negative_prompt: None, reference_image_src: None, user_prompt: Some(user_prompt.to_string()), profile: Some(profile_input), landmark: Some(landmark), }) .expect("payload should normalize"); assert_eq!(normalized.prompt, manual_prompt); } #[test] fn scene_image_response_model_is_gpt_image_2() { assert_eq!(RPG_SCENE_IMAGE_MODEL, "gpt-image-2"); } #[tokio::test] async fn cover_image_returns_service_unavailable_when_dashscope_missing() { let state = build_state_without_dashscope_key(); let request_context = build_request_context("POST /api/runtime/custom-world/cover-image"); let authenticated = build_authenticated(&state); let response = generate_custom_world_cover_image( State(state), Extension(request_context), Extension(authenticated), Ok(Json(CustomWorldCoverImageRequest { profile: CoverProfileInput { id: Some("profile_001".to_string()), name: Some("测试世界".to_string()), ..CoverProfileInput::default() }, user_prompt: Some("测试封面".to_string()), reference_image_src: None, character_role_ids: Vec::new(), size: Some("1600*900".to_string()), })), ) .await .expect_err("missing dashscope should fail"); let payload = read_error_response(response).await; assert_eq!( payload["error"]["code"], Value::String("SERVICE_UNAVAILABLE".to_string()) ); assert_eq!( payload["error"]["details"]["provider"], Value::String("dashscope".to_string()) ); } #[tokio::test] async fn cover_upload_rejects_invalid_data_url_before_touching_oss() { let state = AppState::new(AppConfig::default()).expect("state should build"); let request_context = build_request_context("POST /api/runtime/custom-world/cover-upload"); let authenticated = build_authenticated(&state); let response = upload_custom_world_cover_image( State(state), Extension(request_context), Extension(authenticated), Ok(Json(CustomWorldCoverUploadRequest { profile_id: Some("profile_001".to_string()), world_name: Some("测试世界".to_string()), image_data_url: "not-a-data-url".to_string(), crop_rect: CustomWorldCoverCropRect { x: 0.0, y: 0.0, width: 160.0, height: 90.0, }, })), ) .await .expect_err("invalid data url should fail"); let payload = read_error_response(response).await; assert_eq!( payload["error"]["code"], Value::String("BAD_REQUEST".to_string()) ); assert_eq!( payload["error"]["details"]["provider"], Value::String("custom-world-ai".to_string()) ); } #[test] fn parse_image_data_url_accepts_image_payload() { let parsed = parse_image_data_url("data:image/png;base64,aGVsbG8=").expect("data url should parse"); assert_eq!(parsed.mime_type, "image/png"); assert_eq!(parsed.bytes, b"hello".to_vec()); } #[test] fn push_cover_reference_source_keeps_full_data_url() { let mut sources = Vec::new(); let source = format!("data:image/png;base64,{}", "a".repeat(1024)); push_cover_reference_source(&mut sources, source.as_str()); assert_eq!(sources, vec![source]); } #[test] fn normalize_cover_crop_rect_rejects_non_sixteen_nine_ratio() { let error = normalize_cover_crop_rect( 1920, 1080, &CustomWorldCoverCropRect { x: 0.0, y: 0.0, width: 400.0, height: 400.0, }, ) .expect_err("invalid ratio should fail"); assert_eq!(error.code(), "BAD_REQUEST"); } #[test] fn optimize_uploaded_cover_image_rejects_oversized_source_before_decoding() { let error = optimize_uploaded_cover_image( &ParsedImageDataUrl { mime_type: "image/png".to_string(), bytes: vec![0; COVER_UPLOAD_MAX_BYTES + 1], }, &CustomWorldCoverCropRect { x: 0.0, y: 0.0, width: 160.0, height: 90.0, }, ) .expect_err("oversized upload should fail"); assert_eq!(error.code(), "BAD_REQUEST"); } }