use std::{collections::BTreeMap, fmt, io::Cursor, str::FromStr, sync::Arc, time::Instant}; use crate::editor_project::*; use axum::{ Json, extract::{Extension, State, rejection::JsonRejection}, http::{HeaderMap, StatusCode}, }; use module_assets::{ AssetObjectAccessPolicy, build_asset_object_upsert_input, generate_asset_object_id, }; use platform_image::{ DownloadedImage, generated_asset_sheets::{ GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetConnectedIconPlan, GeneratedAssetSheetError, prepare_generated_icon_spritesheet_all_by_connected_components, prepare_generated_icon_spritesheet_grid, }, }; use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest}; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; use shared_contracts::assets::EditorCanvasGenerationCompletionPayload; use shared_contracts::editor_generation::editor_generation_stable_asset_object_id; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::editor_project::{ EditorSpritesheetSliceBatchPersistRecordInput, EditorSpritesheetSlicePersistItemRecordInput, }; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetGroupSourceLookupRecordInput, EditorProjectGetRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, ExternalGenerationJobRecord, }; use std::time::Duration; use tokio::time::sleep; use crate::{ api_response::json_success_body, asset_billing::{ execute_billable_asset_operation_with_cost, with_editor_generation_durable_billing_boundary, }, auth::AuthenticatedAccessToken, editor_generation_queue::{ EDITOR_ICON_SPEC_GENERATION_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EditorGenerationQueuedResponse, editor_generation_queue_state, editor_generation_source_entity_id, enqueue_editor_generation_job_for_caller, }, editor_screen_background_decision::{ EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, resolve_editor_screen_background_color, }, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, normalize_generated_image_asset_mime, }, http_error::AppError, openai_image_generation::{build_openai_image_http_client, require_openai_image_settings}, platform_errors::{is_retryable_llm_error, map_llm_error, map_oss_error}, prompt::icon_spec::{PROMPT_MAX_LEN, build_extra_param_prompt, get_gen_icon_spec_prompt}, request_context::RequestContext, state::AppState, }; const ICON_SPEC_MODEL: &str = "gpt-image-2"; const ICON_SPEC_ASPECT_RATIO: &str = "16:9"; const ICON_SPEC_IMAGE_SIZE: &str = "2K"; const ICON_SPEC_SIZE: &str = "2048x1152"; const ICON_SPEC_LLM_MAX_ATTEMPTS: u32 = 3; const ICON_SPEC_EXTRA_PARAM_MAX_LEN: usize = 200; pub(crate) const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100; pub(crate) const EDITOR_ICON_DESCRIPTION_MAX_CHARS: usize = 200; pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS: usize = 2_000; pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES: usize = 6 * 1024; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 256; pub(crate) const EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 = EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32; pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); const EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT: usize = 8; const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet"; const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice"; pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components"; pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed"; pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT: &str = "raw-component-limit-exceeded"; pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT: &str = "output-slice-limit-exceeded"; pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT: &str = "merge-candidate-limit-exceeded"; pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT: &str = "crop-pixel-limit-exceeded"; pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT: &str = "slice-processing-timeout"; static EDITOR_ICON_SPRITESHEET_CPU_LIMITER: std::sync::LazyLock< std::sync::Arc, > = std::sync::LazyLock::new(|| { std::sync::Arc::new(tokio::sync::Semaphore::new( EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY, )) }); static EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER: std::sync::LazyLock< std::sync::Arc, > = std::sync::LazyLock::new(|| { std::sync::Arc::new(tokio::sync::Semaphore::new( EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY, )) }); #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ValidatedEditorIconSpritesheetPrompt { descriptions: Vec, prompt: String, } impl ValidatedEditorIconSpritesheetPrompt { pub(crate) fn descriptions(&self) -> &[String] { self.descriptions.as_slice() } pub(crate) fn as_str(&self) -> &str { self.prompt.as_str() } pub(crate) fn into_descriptions(self) -> Vec { self.descriptions } } pub(crate) fn normalize_icon_descriptions( descriptions: Vec, ) -> Result { if descriptions.len() > EDITOR_ICON_DESCRIPTION_LIMIT { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spritesheet", "field": "iconDescriptions", "maxItems": EDITOR_ICON_DESCRIPTION_LIMIT, "message": "图标素材描述数量必须在 1 到 100 个之间。", })), ); } let mut normalized = Vec::with_capacity(descriptions.len()); let mut total_chars = 0usize; let mut total_utf8_bytes = 0usize; for (item_index, description) in descriptions.into_iter().enumerate() { let description = description.trim(); if description.is_empty() { continue; } let actual_length = description.chars().count(); if actual_length > EDITOR_ICON_DESCRIPTION_MAX_CHARS { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spritesheet", "field": "iconDescriptions", "itemIndex": item_index, "maxLength": EDITOR_ICON_DESCRIPTION_MAX_CHARS, "actualLength": actual_length, "message": format!( "每条图标素材描述不能超过 {EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符。" ), })), ); } let separator_length = usize::from(!normalized.is_empty()); let next_total_chars = total_chars .saturating_add(separator_length) .saturating_add(actual_length); if next_total_chars > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spritesheet", "field": "iconDescriptions", "maxTotalLength": EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS, "actualTotalLength": next_total_chars, "message": format!( "图标素材描述合计不能超过 {EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符。" ), })), ); } let next_total_utf8_bytes = total_utf8_bytes .saturating_add(separator_length) .saturating_add(description.len()); if next_total_utf8_bytes > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spritesheet", "field": "iconDescriptions", "maxTotalUtf8Bytes": EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES, "actualTotalUtf8Bytes": next_total_utf8_bytes, "message": format!( "图标素材描述合计不能超过 {EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节。" ), })), ); } total_chars = next_total_chars; total_utf8_bytes = next_total_utf8_bytes; normalized.push(description.to_string()); } if normalized.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spritesheet", "field": "iconDescriptions", "minItems": 1, "message": "图标素材描述数量必须在 1 到 100 个之间。", })), ); } let prompt = normalized.join("\n"); Ok(ValidatedEditorIconSpritesheetPrompt { descriptions: normalized, prompt, }) } pub(crate) async fn resolve_editor_icon_spritesheet_price( state: &AppState, model: Option<&str>, image_size: Option<&str>, ) -> Result { Ok(state .editor_generation_pricing() .await .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-generation-pricing", "message": error.to_string(), })) })? .image_generation_mud_points(Some("icon"), model, image_size)) } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct EditorIconSpritesheetGenerationRequest { pub(crate) reference_id: String, pub(crate) reference_image_srcs: Option>, pub(crate) icon_descriptions: Vec, /// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。 #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) slice_count: Option, /// 图集切分模式;必填且没有默认值,必须在任何副作用之前由调用方显式声明。 /// 这里按原始字符串接收,让业务校验能返回带 `field` 和决策要求的 400, /// 而不是只让 serde 抛一个通用的 JSON 解析错误。 #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) slice_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) grid_x: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) grid_y: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) style: Option, pub(crate) model: Option, pub(crate) screen_color: Option, pub(crate) seg_model: Option, pub(crate) aspect_ratio: Option, pub(crate) image_size: Option, pub(crate) project_id: Option, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, pub(crate) asset_label: Option, pub(crate) canvas_completion: Option, } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub(crate) enum EditorIconSpritesheetSliceMode { ConnectedComponents, Grid, } /// `sliceMode` 的显式决策要求:该字段没有默认值,缺失即拒绝。 pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE: &str = "切分模式没有默认值,必须显式声明:需求明确要求等分网格、固定槽位或指定行列数时传 sliceMode=grid,并用 gridX/gridY 传入该行列数;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components,需要约束素材张数时使用 sliceCount。"; fn editor_icon_spritesheet_slice_mode_error(message: String) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "field": "sliceMode", "message": message, })) } /// 解析显式的切分模式声明:缺失、空白和未知取值都返回可执行的 400。 fn parse_editor_icon_spritesheet_slice_mode( slice_mode: Option<&str>, ) -> Result { let Some(value) = slice_mode.map(str::trim) else { return Err(editor_icon_spritesheet_slice_mode_error(format!( "sliceMode 不能省略:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}" ))); }; match value { "" => Err(editor_icon_spritesheet_slice_mode_error(format!( "sliceMode 不能为空字符串:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}" ))), "connected-components" => Ok(EditorIconSpritesheetSliceMode::ConnectedComponents), "grid" => Ok(EditorIconSpritesheetSliceMode::Grid), other => Err(editor_icon_spritesheet_slice_mode_error(format!( "sliceMode 不支持 {other},只允许 connected-components 或 grid:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}" ))), } } /// 解析并校验图集切分声明:缺失模式、模式与网格参数互相矛盾都在此失败关闭。 fn resolve_editor_icon_spritesheet_slice_request( slice_mode: Option<&str>, grid_x: Option, grid_y: Option, ) -> Result<(EditorIconSpritesheetSliceMode, u32, u32), AppError> { let slice_mode = parse_editor_icon_spritesheet_slice_mode(slice_mode)?; if slice_mode == EditorIconSpritesheetSliceMode::ConnectedComponents && (grid_x.is_some() || grid_y.is_some()) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "field": "gridX/gridY", "message": "sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 sliceMode=grid 同时声明。", })), ); } let (grid_x, grid_y) = resolve_editor_icon_spritesheet_grid_dimensions(slice_mode, grid_x, grid_y)?; Ok((slice_mode, grid_x, grid_y)) } fn resolve_editor_icon_spritesheet_grid_dimensions( mode: EditorIconSpritesheetSliceMode, grid_x: Option, grid_y: Option, ) -> Result<(u32, u32), AppError> { if mode == EditorIconSpritesheetSliceMode::ConnectedComponents { return Ok((0, 0)); } let (Some(grid_x), Some(grid_y)) = (grid_x, grid_y) else { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "field": "gridX/gridY", "message": format!( "sliceMode=grid 必须同时提供 gridX 与 gridY(各 1 到 {}):行列数必须来自需求本身;用网格参数表达素材张数时应改用 sliceMode=connected-components 加 sliceCount。", EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS ), })), ); }; if !(1..=EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS).contains(&grid_x) || !(1..=EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS).contains(&grid_y) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "field": "gridX/gridY", "message": format!( "gridX 与 gridY 必须在 1 到 {} 之间。", EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS ), })), ); } if usize::try_from(grid_x.saturating_mul(grid_y)) .ok() .is_some_and(|slice_count| slice_count > EDITOR_ICON_SPRITESHEET_MAX_SLICES) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "field": "gridX/gridY", "message": format!( "gridX 与 gridY 的切片总数不得超过 {}。", EDITOR_ICON_SPRITESHEET_MAX_SLICES ), })), ); } Ok((grid_x, grid_y)) } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct EditorIconSpritesheetSliceRequest { pub(crate) project_id: String, pub(crate) source_layer_id: String, pub(crate) source_resource_id: String, pub(crate) asset_folder_id: Option, pub(crate) canvas_completion: EditorCanvasGenerationCompletionPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct EditorIconSpritesheetIconResponse { pub(crate) name: String, pub(crate) image_src: String, pub(crate) width: u32, pub(crate) height: u32, pub(crate) resource: Option, pub(crate) asset: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct EditorIconSpritesheetSliceWarningResponse { pub(crate) code: &'static str, pub(crate) reason: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct EditorIconSpritesheetGenerationResponse { pub(crate) spritesheet_image_src: String, pub(crate) spritesheet_width: u32, pub(crate) spritesheet_height: u32, pub(crate) icon_image_srcs: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) slice_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) grid_x: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) grid_y: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) slice_count: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) slice_warning: Option, pub(crate) prompt: String, pub(crate) actual_prompt: Option, pub(crate) model: String, pub(crate) task_id: String, pub(crate) price_mud_points: u32, pub(crate) spritesheet_resource: Option, pub(crate) spritesheet_asset: Option, pub(crate) project: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) warning: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct EditorIconSpritesheetSliceResponse { pub(crate) icon_image_srcs: Vec, pub(crate) project: crate::editor_project::EditorProjectPayload, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub(crate) struct RequestParam { pub(crate) play_setting: String, pub(crate) art_style: String, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct ExtraParam { pub(crate) genre: GameGenre, pub(crate) theme: String, pub(crate) use_case: String, pub(crate) target_user: String, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum GameGenre { Strategy, Shooter, Rpg, Casual, Mmorpg, Simulation, Racing, Moba, Card, Sports, Music, Sandbox, ActionAdventure, Survival, Party, Social, Raising, LocationBased, Other, } impl GameGenre { pub(crate) const ALL: [Self; 19] = [ Self::Strategy, Self::Shooter, Self::Rpg, Self::Casual, Self::Mmorpg, Self::Simulation, Self::Racing, Self::Moba, Self::Card, Self::Sports, Self::Music, Self::Sandbox, Self::ActionAdventure, Self::Survival, Self::Party, Self::Social, Self::Raising, Self::LocationBased, Self::Other, ]; pub(crate) const fn as_slug(self) -> &'static str { match self { Self::Strategy => "策略", Self::Shooter => "射击", Self::Rpg => "RPG", Self::Casual => "休闲", Self::Mmorpg => "MMORPG", Self::Simulation => "模拟", Self::Racing => "竞速", Self::Moba => "MOBA", Self::Card => "卡牌", Self::Sports => "体育", Self::Music => "音乐", Self::Sandbox => "沙盒", Self::ActionAdventure => "动作冒险", Self::Survival => "生存", Self::Party => "派对", Self::Social => "社交", Self::Raising => "养成", Self::LocationBased => "位置游戏", Self::Other => "其他", } } } impl fmt::Display for GameGenre { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(self.as_slug()) } } impl Serialize for GameGenre { fn serialize(&self, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(self.as_slug()) } } impl<'de> Deserialize<'de> for GameGenre { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let slug = String::deserialize(deserializer)?; slug.parse().map_err(|()| { de::Error::custom(format_args!( "无效游戏类型“{slug}”,必须使用 GameGenre::as_slug() 定义的中文值" )) }) } } impl FromStr for GameGenre { type Err = (); fn from_str(value: &str) -> Result { let value = value.trim(); Self::ALL .into_iter() .find(|genre| genre.as_slug() == value) .ok_or(()) } } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct EditorIconSpecGenerationRequest { #[serde(flatten)] request_param: RequestParam, #[serde(default)] reference_id: Option, #[serde(default)] project_id: Option, #[serde(default)] generation_inputs: Option, #[serde(default)] asset_folder_id: Option, #[serde(default)] asset_label: Option, #[serde(default)] source_resource_id: Option, #[serde(default)] canvas_completion: Option, } pub(crate) async fn generate_icon_spec( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { let job = enqueue_icon_spec_generation_for_owner( &state, &request_context, &caller, payload, None, ) .await?; return Ok(crate::api_response::json_success_body( Some(&request_context), EditorGenerationQueuedResponse { queue_state: editor_generation_queue_state(job), }, )); } let caller = caller.with_inline_operation( &request_context, EDITOR_ICON_SPEC_GENERATION_JOB_KIND, &payload, )?; with_editor_generation_durable_billing_boundary(generate_icon_spec_for_owner( &state, &request_context, caller, payload, )) .await } pub(crate) async fn enqueue_icon_spec_generation_for_owner( state: &AppState, request_context: &RequestContext, caller: &EditorGenerationCaller, mut payload: EditorIconSpecGenerationRequest, external_idempotency_key: Option<&str>, ) -> Result { validate_icon_spec_request_fields(&mut payload)?; payload.generation_inputs = sanitize_editor_queued_generation_inputs(payload.generation_inputs.take()); let target = preflight_editor_billable_generation_target( state, caller.owner_user_id.as_str(), payload.project_id.clone(), payload.asset_folder_id.clone(), ) .await?; payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; validate_icon_spec_reference_for_owner(state, caller, &payload).await?; let price_mud_points = resolve_icon_spec_price(state).await?; enqueue_editor_generation_job_for_caller( state, request_context, caller.owner_user_id.as_str(), EDITOR_ICON_SPEC_GENERATION_JOB_KIND, editor_generation_source_entity_id( payload.project_id.as_deref(), "editor-icon-spec-generation", ), "图片画布生成图标规范", u64::from(price_mud_points), &payload, external_idempotency_key, ) .await } pub(crate) async fn generate_icon_spec_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, mut payload: EditorIconSpecGenerationRequest, ) -> Result, AppError> { validate_icon_spec_request_fields(&mut payload)?; validate_icon_spec_reference_for_owner(state, &caller, &payload).await?; let request_param = payload.request_param.clone(); let has_reference_image = payload.reference_id.is_some(); let generation_inputs = payload.generation_inputs.clone(); let image_request = build_icon_spec_image_request(payload, generation_inputs); generate_editor_image_for_owner_with_prompt_builder( state, request_context, caller, image_request, move |generation_inputs| async move { let extra_param = run_icon_spec_extra_param_llm(state, build_extra_param_prompt(&request_param)) .await?; Ok(EditorImagePromptBuildOutput { prompt: get_gen_icon_spec_prompt(&request_param, &extra_param, has_reference_image), generation_inputs: Some(build_icon_spec_generation_inputs( &request_param, extra_param.genre, generation_inputs, )), }) }, ) .await } fn validate_icon_spec_request_fields( payload: &mut EditorIconSpecGenerationRequest, ) -> Result<(), AppError> { payload.request_param.play_setting = require_icon_spec_generation_text( std::mem::take(&mut payload.request_param.play_setting), "playSetting", "玩法设定", )?; payload.request_param.art_style = require_icon_spec_generation_text( std::mem::take(&mut payload.request_param.art_style), "artStyle", "美术风格", )?; Ok(()) } async fn validate_icon_spec_reference_for_owner( state: &AppState, caller: &EditorGenerationCaller, payload: &EditorIconSpecGenerationRequest, ) -> Result<(), AppError> { if let Some(reference_id) = payload.reference_id.as_deref() { validate_editor_reference_id_for_owner(state, caller.owner_user_id.as_str(), reference_id) .await?; } Ok(()) } async fn resolve_icon_spec_price(state: &AppState) -> Result { Ok(state .editor_generation_pricing() .await .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-generation-pricing", "message": error.to_string(), })) })? .image_generation_mud_points( Some("spec"), Some(ICON_SPEC_MODEL), Some(ICON_SPEC_IMAGE_SIZE), )) } fn build_icon_spec_image_request( payload: EditorIconSpecGenerationRequest, generation_inputs: Option, ) -> EditorImageGenerationRequest { EditorImageGenerationRequest { // 中文注释:最终 prompt 由共享执行器的 prompt builder 在预扣后构建, // 这里不再放置会被误校验或误持久化的占位文本。 prompt: String::new(), size: Some(ICON_SPEC_SIZE.to_string()), kind: Some("spec".to_string()), style: None, model: Some(ICON_SPEC_MODEL.to_string()), screen_color: None, seg_model: None, aspect_ratio: Some(ICON_SPEC_ASPECT_RATIO.to_string()), image_size: Some(ICON_SPEC_IMAGE_SIZE.to_string()), reference_image_srcs: payload.reference_id.map(|reference_id| vec![reference_id]), project_id: payload.project_id, asset_kind: Some("icon-spec".to_string()), generation_inputs, asset_folder_id: payload.asset_folder_id, asset_label: payload.asset_label, source_resource_id: payload.source_resource_id, canvas_completion: payload.canvas_completion, } } async fn run_icon_spec_extra_param_llm( state: &AppState, prompt: String, ) -> Result { let configured_llm_client = state.vector_engine_llm_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "editor-icon-spec-llm", "message": "服务端尚未配置可用的 VectorEngine LLM API Key", })) })?; let retry_backoff_ms = configured_llm_client.config().retry_backoff_ms(); let llm_client = configured_llm_client.clone().with_max_retries(0); let request = LlmRunRequest::new(vec![LlmMessage::user(prompt)]) .with_model(EDITOR_AGENT_GPT5_MODEL) .with_openai_chat(); for attempt in 1..=ICON_SPEC_LLM_MAX_ATTEMPTS { match llm_client.run(request.clone()).await { Ok(response) => match parse_extra_param_response( response.text.as_str(), response.finish_reason.as_deref(), ) { Ok(extra_param) => return Ok(extra_param), Err(_) if attempt < ICON_SPEC_LLM_MAX_ATTEMPTS => {} Err(error) => { return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( json!({ "provider": "editor-icon-spec-llm", "operation": "complete-extra-param", "message": error.message(), }), )); } }, Err(error) if is_retryable_llm_error(&error) => { if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS { return Err(map_llm_error(error)); } } Err(error) => return Err(map_llm_error(error)), } sleep_before_icon_spec_llm_retry(retry_backoff_ms, attempt).await; } unreachable!("icon spec extra param retry loop always returns") } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ExtraParamResponseError { Empty, Incomplete, Invalid, } impl ExtraParamResponseError { fn message(self) -> &'static str { match self { Self::Empty => "LLM 未返回可用文本", Self::Incomplete => "LLM 返回内容未完整结束", Self::Invalid => "LLM 返回内容不符合图标规范参数契约", } } } fn parse_extra_param_response( text: &str, finish_reason: Option<&str>, ) -> Result { if finish_reason.is_some_and(|reason| { platform_llm::is_incomplete_finish_reason(platform_llm::LlmApiKind::OpenAiChat, reason) }) { return Err(ExtraParamResponseError::Incomplete); } let text = text.trim(); if text.is_empty() { return Err(ExtraParamResponseError::Empty); } parse_extra_param(text).map_err(|_| ExtraParamResponseError::Invalid) } fn parse_extra_param(text: &str) -> Result { let mut extra_param = serde_json::from_str::(text).map_err(|_| ())?; for value in [ &mut extra_param.theme, &mut extra_param.use_case, &mut extra_param.target_user, ] { let normalized = value.trim(); if normalized.is_empty() || normalized.chars().count() > ICON_SPEC_EXTRA_PARAM_MAX_LEN { return Err(()); } if normalized.len() != value.len() { *value = normalized.to_string(); } } Ok(extra_param) } async fn sleep_before_icon_spec_llm_retry(retry_backoff_ms: u64, attempt: u32) { let delay_ms = retry_backoff_ms.saturating_mul(u64::from(attempt)); if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; } } fn require_icon_spec_generation_text( value: String, field: &'static str, field_label: &'static str, ) -> Result { let value = value.trim().to_string(); if value.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spec", "field": field, "message": format!("{field_label}不能为空"), })), ); } let actual_length = value.chars().count(); if actual_length > PROMPT_MAX_LEN { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spec", "field": field, "maxLength": PROMPT_MAX_LEN, "actualLength": actual_length, "message": format!("{field_label}不能超过 {PROMPT_MAX_LEN} 个字符"), })), ); } Ok(value) } fn build_icon_spec_generation_inputs( request: &RequestParam, genre: GameGenre, current: Option, ) -> Value { let references = sanitize_editor_client_generation_inputs(current) .as_ref() .and_then(|value| value.get("references")) .and_then(Value::as_array) .cloned() .unwrap_or_default(); json!({ "fields": [ { "title": "玩法设定", "value": request.play_setting }, { "title": "美术风格", "value": request.art_style }, { "title": "游戏类型", "value": genre.as_slug() }, ], "references": references, }) } pub(crate) fn game_genre_from_generation_inputs( value: Option<&Value>, ) -> Result, ()> { let raw_genre = value .and_then(|value| value.get("fields")) .and_then(Value::as_array) .and_then(|fields| { fields .iter() // TODO use id field .find(|field| field.get("title").and_then(Value::as_str) == Some("游戏类型")) }) .and_then(|field| field.get("value")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let Some(raw_genre) = raw_genre else { return Ok(None); }; raw_genre.parse().map(Some) } pub(crate) struct PersistEditorSpritesheetSlicesInput { pub(crate) owner_user_id: String, pub(crate) project_id: Option, pub(crate) asset_folder_id: Option, pub(crate) source_resource_id: Option, pub(crate) task_id: String, pub(crate) group_task_id: Option, pub(crate) prompt: String, pub(crate) actual_prompt: Option, pub(crate) model: String, pub(crate) provider: String, pub(crate) generation_inputs: Option, pub(crate) path_kind: &'static str, pub(crate) asset_kind: &'static str, pub(crate) persistence_provider: &'static str, } struct EditorSpritesheetSliceUpload { index: usize, name: String, width: u32, height: u32, image_src: String, object_key: String, asset_object: module_assets::AssetObjectUpsertInput, } struct EditorIconSpritesheetMemoryAdmission { _permit: tokio::sync::OwnedSemaphorePermit, } #[derive(Clone)] pub(crate) struct PreparedEditorIconSpritesheetPlan { plan: GeneratedAssetSheetConnectedIconPlan, processing_deadline: Instant, // 中文注释:每个仍可能运行的 blocking 编码任务都持有同一 admission,超时取消 // async 等待时也不会提前释放整张 RGBA 的内存名额。 _memory_admission: Arc, } pub(crate) async fn persist_editor_spritesheet_slices( state: &AppState, prepared: PreparedEditorIconSpritesheetPlan, input: PersistEditorSpritesheetSlicesInput, ) -> Result, AppError> { use futures_util::{StreamExt as _, TryStreamExt as _}; if prepared.plan.is_empty() { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集中未识别到可拆分的独立素材。", })), ); } let expected_asset_count = u32::try_from(prepared.plan.len()).map_err(|_| { AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集拆分产物数量超过支持范围。", })) })?; let input = Arc::new(input); let http_client = build_editor_spritesheet_http_client( EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT, EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT, ) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "aliyun-oss", "message": format!("创建图集切片上传客户端失败:{error}"), })) })?; // TODO(legacy-spritesheet-deadline): 历史实现的 processing_deadline 只覆盖准入、下载、 // 切片规划和编码,尚未覆盖后续 PUT、HEAD 与批量持久化;应结合可对账的副作用状态机 // 统一剩余预算,避免仅中断本地等待而让远端提交处于不可查询状态。 // TODO(legacy-spritesheet-cleanup): 历史并发上传链路在部分 PUT/HEAD 成功后失败时没有 // 记录成功对象并执行可重试补偿;需先定义 staged/committed/cleanup-pending 生命周期, // 再处理批量持久化失败、补偿失败和任务重放,不能只做一次 best-effort delete。 // TODO(legacy-spritesheet-pipeline): 该并发结构继承自 master;同一个 stream slot 串联编码、 // PUT 与 HEAD,慢上传会阻塞尚未开始的编码并继续消耗 processing_deadline。应拆成带有界 // channel 的独立编码池和上传池,并分别定义 CPU/内存准入、处理期限与网络超时。 let mut uploads = futures_util::stream::iter(0..prepared.plan.len()) .map(|index| { let prepared = prepared.clone(); let http_client = http_client.clone(); let input = Arc::clone(&input); async move { let slice = encode_editor_icon_spritesheet_slice(prepared, index).await?; upload_editor_spritesheet_slice(state, &http_client, index, slice, &input).await } }) .buffer_unordered(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY) .try_collect::>() .await?; uploads.sort_by_key(|upload| upload.index); // 中文注释:上传收齐后只保留轻量 HEAD 元数据;数据库慢调用不应继续占用 // 整张 RGBA 与图集专用内存 admission。 drop(prepared); let generation_inputs_json = serialize_editor_asset_metadata(input.generation_inputs.clone())?; let folder_id = normalize_generated_asset_folder_id( input.asset_folder_id.clone(), input.owner_user_id.as_str(), ); let now_micros = current_utc_micros(); let items = uploads .iter() .map(|upload| { let resource_id = input.project_id.as_ref().map(|_| { stable_editor_spritesheet_slice_record_id( EDITOR_RESOURCE_ID_PREFIX, input.owner_user_id.as_str(), input.task_id.as_str(), upload.index, ) }); let project_resource = input.project_id.as_ref().map(|project_id| { EditorProjectResourceCreateRecordInput { resource_id: resource_id.clone().expect("resource id must exist"), project_id: project_id.clone(), owner_user_id: input.owner_user_id.clone(), asset_object_id: Some(upload.asset_object.asset_object_id.clone()), image_src: upload.image_src.clone(), object_key: Some(upload.object_key.clone()), width: upload.width, height: upload.height, source_type: "generated".to_string(), prompt: Some(input.prompt.clone()), actual_prompt: input.actual_prompt.clone(), model: Some(input.model.clone()), provider: Some(input.provider.clone()), task_id: Some(input.task_id.clone()), source_resource_id: normalize_optional_string(input.source_resource_id.clone()), asset_kind: Some("icon".to_string()), generation_inputs_json: generation_inputs_json.clone(), updated_at_micros: now_micros, image_sequence_frames_json: None, image_sequence_duration_ms: None, } }); let asset = folder_id .as_ref() .map(|folder_id| EditorAssetCreateRecordInput { asset_id: stable_editor_spritesheet_slice_record_id( EDITOR_ASSET_ID_PREFIX, input.owner_user_id.as_str(), input.task_id.as_str(), upload.index, ), owner_user_id: input.owner_user_id.clone(), folder_id: folder_id.clone(), label: upload.name.clone(), asset_object_id: Some(upload.asset_object.asset_object_id.clone()), image_src: upload.image_src.clone(), object_key: Some(upload.object_key.clone()), width: upload.width, height: upload.height, source_type: "generated".to_string(), prompt: Some(input.prompt.clone()), actual_prompt: input.actual_prompt.clone(), model: Some(input.model.clone()), provider: Some(input.provider.clone()), task_id: Some(input.task_id.clone()), asset_kind: Some("icon".to_string()), generation_inputs_json: generation_inputs_json.clone(), source_resource_id: resource_id.clone(), generation_cost_mud_points: 0, now_micros, thumbnail_src: None, group_task_id: normalize_optional_string(input.group_task_id.clone()), group_task_expected_asset_count: input .group_task_id .as_ref() .map(|_| expected_asset_count), image_sequence_frames_json: None, image_sequence_duration_ms: None, }); EditorSpritesheetSlicePersistItemRecordInput { asset_object: upload.asset_object.clone(), project_resource, asset, } }) .collect(); let persisted = state .spacetime_client() .persist_editor_spritesheet_slice_batch(EditorSpritesheetSliceBatchPersistRecordInput { owner_user_id: input.owner_user_id.clone(), task_id: input.task_id.clone(), group_task_id: input.group_task_id.clone(), expected_asset_count, items, completed_at_micros: now_micros, }) .await .map_err(map_editor_project_error)?; if persisted.items.len() != uploads.len() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": "图集切片批量持久化返回数量不一致。", })), ); } Ok(uploads .into_iter() .zip(persisted.items) .map(|(upload, item)| EditorIconSpritesheetIconResponse { name: upload.name, image_src: upload.image_src, width: upload.width, height: upload.height, resource: item .project_resource .map(editor_project_resource_payload_from_record), asset: item.asset.map(editor_asset_payload_from_record), }) .collect()) } pub(crate) struct PreparedEditorSpritesheetSlices { pub(crate) responses: Vec, pub(crate) items: Vec, } pub(crate) async fn prepare_editor_spritesheet_slices_for_generation( state: &AppState, caller: &EditorGenerationCaller, prepared: PreparedEditorIconSpritesheetPlan, input: PersistEditorSpritesheetSlicesInput, ) -> Result { use futures_util::{StreamExt as _, TryStreamExt as _}; if prepared.plan.is_empty() { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集中未识别到可拆分的独立素材。", })), ); } let input = Arc::new(input); let http_client = build_editor_spritesheet_http_client( EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT, EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT, ) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "aliyun-oss", "message": format!("创建图集切片上传客户端失败:{error}"), })) })?; let mut uploads = futures_util::stream::iter(0..prepared.plan.len()) .map(|index| { let prepared = prepared.clone(); let http_client = http_client.clone(); let input = Arc::clone(&input); async move { let slice = encode_editor_icon_spritesheet_slice(prepared, index).await?; upload_editor_spritesheet_slice(state, &http_client, index, slice, &input).await } }) .buffer_unordered(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY) .try_collect::>() .await?; uploads.sort_by_key(|upload| upload.index); drop(prepared); let operation = caller.operation.as_ref().ok_or_else(|| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-generation-operation", "message": "准备图集切片时缺少稳定 operation 上下文。", })) })?; let prepared_at_micros = current_utc_micros(); let mut responses = Vec::with_capacity(uploads.len()); let mut items = Vec::with_capacity(uploads.len()); for mut upload in uploads { let slot = format!("slice-{:04}", upload.index); upload.asset_object.asset_object_id = editor_generation_stable_asset_object_id( caller.owner_user_id.as_str(), operation.operation_kind.as_str(), operation.operation_id.as_str(), slot.as_str(), ); let prepared_item = prepare_editor_generated_asset( caller, slot.as_str(), Some(upload.asset_object), PersistEditorGeneratedAssetInput { project_id: input.project_id.clone(), folder_id: input.asset_folder_id.clone(), label: upload.name.clone(), image_src: upload.image_src.clone(), object_key: Some(upload.object_key), asset_object_id: None, width: upload.width, height: upload.height, prompt: input.prompt.clone(), actual_prompt: input.actual_prompt.clone(), model: input.model.clone(), provider: input.provider.clone(), task_id: input.task_id.clone(), group_task_id: input.group_task_id.clone(), group_task_expected_asset_count: None, source_resource_id: input.source_resource_id.clone(), asset_kind: Some("icon".to_string()), generation_inputs: input.generation_inputs.clone(), thumbnail_src: None, generation_cost_mud_points: 0, image_sequence_frames: None, image_sequence_duration_ms: None, }, prepared_at_micros, )?; responses.push(EditorIconSpritesheetIconResponse { name: upload.name, image_src: upload.image_src, width: upload.width, height: upload.height, resource: prepared_item.record.resource, asset: prepared_item.record.asset, }); items.push(prepared_item.item); } Ok(PreparedEditorSpritesheetSlices { responses, items }) } pub(crate) fn stable_editor_spritesheet_slice_record_id( prefix: &str, owner_user_id: &str, task_id: &str, index: usize, ) -> String { let digest = Sha256::digest( format!( "editor-spritesheet-slice\n{}\n{}\n{index}", owner_user_id.trim(), task_id.trim() ) .as_bytes(), ); format!("{prefix}{}", hex::encode(&digest[..16])) } pub(crate) fn build_editor_spritesheet_http_client( connect_timeout: Duration, request_timeout: Duration, ) -> Result { reqwest::Client::builder() .connect_timeout(connect_timeout) .timeout(request_timeout) .build() } async fn encode_editor_icon_spritesheet_slice( prepared: PreparedEditorIconSpritesheetPlan, index: usize, ) -> Result { if Instant::now() >= prepared.processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); } let permit = match tokio::time::timeout_at( tokio::time::Instant::from_std(prepared.processing_deadline), Arc::clone(&*EDITOR_ICON_SPRITESHEET_CPU_LIMITER).acquire_owned(), ) .await { Ok(Ok(permit)) => permit, Ok(Err(error)) => { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT, "message": format!("图集切片 CPU 并发门限不可用:{error}"), })), ); } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; let processing_deadline = prepared.processing_deadline; let worker = tokio::task::spawn_blocking(move || { // 中文注释:blocking 任务同时持有 CPU permit 和内存 admission;async 超时 // 只放弃等待,不能让尚未退出的编码绕过任一门限。 let _permit = permit; let _memory_admission = &prepared._memory_admission; if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); } prepared .plan .encode(index) .map_err(map_editor_icon_spritesheet_platform_error) }); match tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker).await { Ok(Ok(result)) => result, Ok(Err(error)) => Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": format!("图集切片编码工作线程异常:{error}"), }))), Err(_) => Err(editor_icon_spritesheet_processing_timeout_error()), } } async fn upload_editor_spritesheet_slice( state: &AppState, http_client: &reqwest::Client, index: usize, slice: GeneratedAssetSheetConnectedIcon, input: &PersistEditorSpritesheetSlicesInput, ) -> 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 prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { prefix: LegacyAssetPrefix::CharacterDrafts, path_segments: vec![ "editor".to_string(), sanitize_editor_storage_segment(input.path_kind, "generated-images"), sanitize_editor_storage_segment(input.task_id.as_str(), "task"), ], file_stem: sanitize_editor_storage_segment( format!("asset-{:03}", index + 1).as_str(), "image", ), image: GeneratedImageAssetDataUrl { format: normalize_generated_image_asset_mime("image/png"), bytes: slice.bytes, }, access: OssObjectAccess::Private, metadata: GeneratedImageAssetAdapterMetadata { asset_kind: Some(input.asset_kind.to_string()), owner_user_id: Some(input.owner_user_id.clone()), entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()), entity_id: Some(input.task_id.clone()), slot: Some("asset".to_string()), provider: Some(input.persistence_provider.to_string()), task_id: Some(input.task_id.clone()), }, extra_metadata: BTreeMap::from([( "source".to_string(), "image-canvas-editor".to_string(), )]), }) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "generated-image-assets", "message": format!("准备图集切片 OSS 上传请求失败:{error:?}"), })) })?; let persisted_mime_type = prepared.format.mime_type.clone(); let put_result = oss_client .put_object(http_client, prepared.request) .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; let head = oss_client .head_object( http_client, OssHeadObjectRequest { object_key: put_result.object_key, }, ) .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; let now_micros = current_utc_micros(); let asset_object = build_asset_object_upsert_input( generate_asset_object_id(now_micros), head.bucket, head.object_key.clone(), AssetObjectAccessPolicy::Private, head.content_type.or(Some(persisted_mime_type)), head.content_length, Some( input .actual_prompt .as_deref() .unwrap_or(input.prompt.as_str()) .to_string(), ), input.asset_kind.to_string(), Some(input.task_id.clone()), Some(input.owner_user_id.clone()), None, Some(input.task_id.clone()), now_micros, ) .map_err(map_editor_asset_field_error)?; Ok(EditorSpritesheetSliceUpload { index, name: slice.name, width: slice.width, height: slice.height, image_src: editor_media_src_from_object_key(head.object_key.as_str()), object_key: head.object_key, asset_object, }) } pub async fn generate_editor_icon_spritesheet( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(payload) = parse_editor_generation_json_payload(payload)?; let idempotency_key = optional_editor_idempotency_key(&headers)?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { let queue_job = enqueue_editor_icon_spritesheet_generation_for_owner( &state, &request_context, &caller, payload, idempotency_key, ) .await?; return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { queue_state: editor_generation_queue_state(queue_job), }, )); } let caller = caller.with_inline_operation( &request_context, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, &payload, )?; with_editor_generation_durable_billing_boundary(generate_editor_icon_spritesheet_for_owner( &state, &request_context, caller, payload, )) .await } pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( state: &AppState, request_context: &RequestContext, caller: &EditorGenerationCaller, mut payload: EditorIconSpritesheetGenerationRequest, external_idempotency_key: Option<&str>, ) -> Result { // 切分模式没有默认值:必须在引用解析、定价和入队之前显式声明。 resolve_editor_icon_spritesheet_slice_request( payload.slice_mode.as_deref(), payload.grid_x, payload.grid_y, )?; payload.generation_inputs = sanitize_editor_queued_generation_inputs(payload.generation_inputs.take()); payload.icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?.into_descriptions(); ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-icon-spritesheet", "referenceImageSrcs", "图标素材参考图", )?; let generation_options = normalize_editor_generation_options( payload.model.as_deref(), payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); let extra_reference_limit = EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); ensure_editor_reference_image_source_limit( payload.reference_image_srcs.as_deref(), extra_reference_limit, "editor-icon-spritesheet", "referenceImageSrcs", "图标素材参考图", )?; let target = preflight_editor_billable_generation_target( state, caller.owner_user_id.as_str(), payload.project_id.clone(), payload .asset_folder_id .clone() .or_else(|| Some("project".to_string())), ) .await?; payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; preflight_editor_icon_spec_reference_metadata( state, caller.owner_user_id.as_str(), payload.reference_id.as_str(), ) .await .map_err(|error| { error.with_details(json!({ "provider": "editor-icon-spritesheet", "field": "referenceId", "message": "图标素材规范必须是当前用户已登记的 icon-spec 资源或素材。", })) })?; let price_mud_points = u64::from( resolve_editor_icon_spritesheet_price( state, Some(generation_options.model), Some(generation_options.image_size), ) .await?, ); let source_entity_id = editor_generation_source_entity_id( payload.project_id.as_deref(), "editor-icon-spritesheet-generation", ); enqueue_editor_generation_job_for_caller( state, request_context, caller.owner_user_id.as_str(), EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, source_entity_id, "图片画布生成图标素材", price_mud_points, &payload, external_idempotency_key, ) .await } pub(crate) async fn generate_editor_icon_spritesheet_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, mut payload: EditorIconSpritesheetGenerationRequest, ) -> Result, AppError> { // 切分模式没有默认值:必须在引用解析、定价和任何 provider / OSS 副作用之前显式声明。 let (requested_slice_mode, grid_x, grid_y) = resolve_editor_icon_spritesheet_slice_request( payload.slice_mode.as_deref(), payload.grid_x, payload.grid_y, )?; payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-icon-spritesheet", "referenceImageSrcs", "图标素材参考图", )?; let generation_options = normalize_editor_generation_options( payload.model.as_deref(), payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); let extra_reference_limit = EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); ensure_editor_reference_image_source_limit( payload.reference_image_srcs.as_deref(), extra_reference_limit, "editor-icon-spritesheet", "referenceImageSrcs", "图标素材参考图", )?; let target = preflight_editor_billable_generation_target( state, caller.owner_user_id.as_str(), payload.project_id.clone(), payload .asset_folder_id .clone() .or_else(|| Some("project".to_string())), ) .await?; payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; payload.generation_inputs = rebuild_editor_generation_input_references( state, caller.owner_user_id.as_str(), payload.generation_inputs.take(), build_editor_generation_reference_sources( Some(("图标规范", payload.reference_id.as_str())), payload.reference_image_srcs.as_deref(), "参考图", ), ) .await?; let spritesheet_prompt = normalize_icon_descriptions(payload.icon_descriptions)?; let user_prompt = spritesheet_prompt.as_str().to_string(); let resolved_icon_spec = resolve_editor_icon_spec_reference( state, caller.owner_user_id.as_str(), payload.reference_id.as_str(), ) .await .map_err(|error| { error.with_details(json!({ "provider": "editor-icon-spritesheet", "field": "referenceId", "message": "图标素材规范必须是当前用户已登记的 icon-spec 资源或素材。", })) })?; let icon_spec_genre = resolved_icon_spec.genre; let (image_style, mut generation_warning) = normalize_editor_image_generation_style(payload.style.as_deref(), true); // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。 let requested_screen_color = payload.screen_color.clone(); let reference_count = 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len); let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?; let mut reference_images = Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len)); reference_images.push(resolved_icon_spec.image); for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref()) { reference_images.push( parse_editor_reference_image(state, caller.owner_user_id.as_str(), source) .await .map_err(|error| { error.with_details(json!({ "provider": "editor-icon-spritesheet", "field": "referenceImageSrcs", "message": "图标素材参考图必须是 objectKey、资源或素材引用。", })) })?, ); } let expected_price_mud_points = resolve_editor_icon_spritesheet_price( state, Some(generation_options.model), Some(generation_options.image_size), ) .await?; let size = generation_options.provider_size.as_str(); let settings = require_openai_image_settings(state)?.with_external_api_audit_context( request_context, caller.audit_subject_user_id.clone(), caller .audit_project_id .clone() .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; // TODO(legacy-icon-spritesheet-billing-boundary): 该计费边界继承自 master 的历史实现; // Provider 成功后 operation 即提交,后续解码、OSS、资源与画布持久化失败时缺少可对账中间态。 // 调整前需先定义 provider_succeeded/persistence_pending 等状态、稳定幂等键和补偿语义, // 不能只把后续副作用机械包入 billable future。 let (generated, prompt, screen_background_decision) = execute_billable_asset_operation_with_cost( state, caller.owner_user_id.as_str(), "editor_icon_spritesheet", request_context.request_id(), u64::from(expected_price_mud_points), async { // 背景色决策:预扣泥点之后才发起(决策失败→future 返回 Err→失败退款)。 let screen_background_decision = resolve_editor_screen_background_color( state.llm_client(), state.vector_engine_llm_client(), EditorScreenBackgroundDecisionInput { kind: EditorScreenBackgroundDecisionKind::IconSpritesheet, screen_color: requested_screen_color.clone(), prompt: spritesheet_prompt.as_str().to_string(), icon_descriptions: spritesheet_prompt.descriptions().to_vec(), reference_count, source_image_data_url: None, }, Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext { state, context: crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller .audit_project_id .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), external_call_deadline: request_context.external_call_deadline(), }, }), ) .await?; let screen_color = screen_background_decision.color; let slice_mode = requested_slice_mode; let prompt = match slice_mode { EditorIconSpritesheetSliceMode::Grid => { crate::prompt::icon_spec::build_grid_spritesheet_prompt( &spritesheet_prompt, screen_color, icon_spec_genre, grid_x, grid_y, ) } EditorIconSpritesheetSliceMode::ConnectedComponents => crate::prompt::icon_spec::build_spritesheet_prompt( &spritesheet_prompt, screen_color, icon_spec_genre, ), }; let prompt = apply_editor_pixel_art_style_prompt( prompt, image_style, EditorPixelArtPromptScope::IconSpritesheet, ); let generated = request_editor_generated_images( &http_client, &settings, EditorImageProviderRequest { model: generation_options.model, prompt: prompt.as_str(), negative_prompt: None, size, aspect_ratio: generation_options.aspect_ratio, image_size: generation_options.provider_image_size, reference_images: reference_images.as_slice(), failure_context: "图片画布生成图标素材 spritesheet", }, ) .await?; Ok::<_, AppError>((generated, prompt, screen_background_decision)) }, ) .await?; let screen_color = screen_background_decision.color; let image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "VectorEngine 未返回图标 spritesheet", })) })?; let (source_width, source_height) = decode_editor_provider_source_dimensions( &image, "editor-icon-spritesheet", "图标 spritesheet 原图不是有效图片", )?; let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( payload.generation_inputs.clone(), Some(&screen_background_decision), ); let spritesheet_label = resolve_editor_generated_asset_label(payload.asset_label.clone(), "图标素材图集"); let asset_folder_id = payload .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); let source_persisted = prepare_editor_generated_image( state, &caller, generated.task_id.as_str(), &image, user_prompt.as_str(), generated.actual_prompt.as_deref(), EDITOR_ICON_SPRITESHEET_ASSET_KIND, "icon-spritesheets", "spritesheet-provider-source", EDITOR_PROVIDER_SOURCE_SLOT, "vector-engine", ) .await?; let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str()); let source_object_key = source_persisted.object_key.clone(); let source_asset_object_id = source_persisted.asset_object_id().to_string(); let source_record = prepare_editor_generated_asset( &caller, EDITOR_PROVIDER_SOURCE_SLOT, Some(source_persisted.asset_object), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), folder_id: asset_folder_id.clone(), label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"), image_src: source_image_src.clone(), object_key: Some(source_object_key.clone()), asset_object_id: Some(source_asset_object_id.clone()), width: source_width, height: source_height, prompt: user_prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), task_id: generated.task_id.clone(), source_resource_id: None, asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), generation_cost_mud_points: u64::from(expected_price_mud_points), provider: "VectorEngine".to_string(), group_task_id: None, group_task_expected_asset_count: None, thumbnail_src: None, image_sequence_frames: None, image_sequence_duration_ms: None, }, current_utc_micros(), )?; caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller .audit_project_id .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), external_call_deadline: request_context.external_call_deadline(), }; let removal = remove_editor_generated_screen_background_with_bgfilter( state, source_object_key.as_str(), screen_color, seg_model, EDITOR_BGFILTER_CROSS_CHECK_ENABLED, &matting_audit, ) .await; let removal = match removal { Ok(removal) => removal, Err(error) => { let failure_message = error.body_text(); tracing::warn!( provider = "editor-icon-spritesheet", operation = "background_postprocess", task_id = %generated.task_id, error = %failure_message, "图标图集原图已保存,但透明背景处理失败,使用原图完成画布" ); let (source_record, completed_project, fallback_warning) = persist_editor_source_only_fallback_atomically( state, &caller, payload.project_id.as_deref(), payload.canvas_completion.as_ref(), source_record, generated.task_id.as_str(), source_image_src.as_str(), source_object_key.as_str(), source_asset_object_id.as_str(), EditorSourceOnlyFallbackResultContext { width: source_width, height: source_height, prompt: prompt.as_str(), actual_prompt: generated.actual_prompt.as_deref(), model: generation_options.model, price_mud_points: expected_price_mud_points, }, generation_warning, None, ) .await?; return Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { spritesheet_image_src: source_image_src, spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_mode: Some(requested_slice_mode), grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) .then_some(grid_x), grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) .then_some(grid_y), slice_count: Some(0), slice_warning: None, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), task_id: generated.task_id, price_mud_points: expected_price_mud_points, spritesheet_resource: source_record.resource, spritesheet_asset: source_record.asset, project: completed_project, warning: fallback_warning, }, )); } }; let removal_provider = removal.provider; let removal_model = removal.model.clone(); let (image, postprocess_dimension_error, pixel_art_error) = if image_style == EditorImageGenerationStyle::PixelArt { apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( state, source_object_key.as_str(), source_width, source_height, removal.image, request_context.external_call_deadline(), ) .await } else { let (image, error) = apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, source_object_key.as_str(), source_width, source_height, removal.image, None, ) .await; (image, error, None) }; if let Some(error) = postprocess_dimension_error { tracing::warn!( task_id = %generated.task_id, provider = removal_provider, source_width, source_height, error = ?error, "图标透明图蒙版尺寸恢复失败,改用已保存的 provider 原图完成画布" ); let (source_record, completed_project, fallback_warning) = persist_editor_source_only_fallback_atomically( state, &caller, payload.project_id.as_deref(), payload.canvas_completion.as_ref(), source_record, generated.task_id.as_str(), source_image_src.as_str(), source_object_key.as_str(), source_asset_object_id.as_str(), EditorSourceOnlyFallbackResultContext { width: source_width, height: source_height, prompt: prompt.as_str(), actual_prompt: generated.actual_prompt.as_deref(), model: generation_options.model, price_mud_points: expected_price_mud_points, }, generation_warning, None, ) .await?; return Ok(json_success_body( Some(request_context), EditorIconSpritesheetGenerationResponse { spritesheet_image_src: source_image_src, spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_mode: Some(requested_slice_mode), grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) .then_some(grid_x), grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) .then_some(grid_y), slice_count: Some(0), slice_warning: None, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), task_id: generated.task_id, price_mud_points: expected_price_mud_points, spritesheet_resource: source_record.resource, spritesheet_asset: source_record.asset, project: completed_project, warning: fallback_warning, }, )); } if let Some(error) = pixel_art_error { tracing::warn!( task_id = %generated.task_id, provider = removal_provider, error = %error, "图标图集像素规整失败,保留透明后处理图" ); generation_warning = merge_editor_generation_warnings( generation_warning, Some(editor_pixel_art_fallback_warning()), ); } let matting_generation_inputs = apply_editor_matting_metadata_to_generation_inputs( build_editor_derived_asset_generation_inputs("图标图集抠图", &source_record.record), removal_provider, removal_model.as_str(), ); let (spritesheet_width, spritesheet_height) = decode_editor_provider_source_dimensions( &image, "editor-icon-spritesheet", "图标 spritesheet 透明后处理图不是有效图片", )?; let owner_user_id = caller.owner_user_id.clone(); let spritesheet_prepared_image = prepare_editor_generated_image( state, &caller, generated.task_id.as_str(), &image, user_prompt.as_str(), None, EDITOR_ICON_SPRITESHEET_ASSET_KIND, "icon-spritesheets", "spritesheet", "spritesheet", removal_provider, ) .await?; let spritesheet_image_src = editor_media_src_from_object_key(spritesheet_prepared_image.object_key.as_str()); let prepared_at_micros = current_utc_micros(); let spritesheet_record = prepare_editor_generated_asset( &caller, "spritesheet", Some(spritesheet_prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), folder_id: asset_folder_id.clone(), label: spritesheet_label, image_src: spritesheet_image_src.clone(), object_key: Some(spritesheet_prepared_image.object_key.clone()), asset_object_id: Some(spritesheet_prepared_image.asset_object_id().to_string()), width: spritesheet_width, height: spritesheet_height, prompt: user_prompt.clone(), actual_prompt: None, model: generation_options.model.to_string(), provider: removal_provider.to_string(), task_id: generated.task_id.clone(), group_task_id: None, group_task_expected_asset_count: None, source_resource_id: source_record .record .resource .as_ref() .map(|resource| resource.resource_id.clone()), asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: matting_generation_inputs, thumbnail_src: None, generation_cost_mud_points: 0, image_sequence_frames: None, image_sequence_duration_ms: None, }, prepared_at_micros, )?; let slice_source = DownloadedImage { bytes: image.bytes, mime_type: image.mime_type, extension: image.extension, }; let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all( slice_source, request_context.external_call_deadline(), requested_slice_mode, payload.slice_count, grid_x, grid_y, ) .await { Ok(icon_slices) => { match prepare_editor_spritesheet_slices_for_generation( state, &caller, icon_slices, PersistEditorSpritesheetSlicesInput { owner_user_id: owner_user_id.clone(), project_id: payload.project_id.clone(), asset_folder_id: asset_folder_id.clone(), source_resource_id: spritesheet_record .record .resource .as_ref() .map(|resource| resource.resource_id.clone()), task_id: generated.task_id.clone(), group_task_id: None, prompt: user_prompt.clone(), actual_prompt: None, model: generation_options.model.to_string(), provider: "Genarrative".to_string(), generation_inputs: build_editor_derived_asset_generation_inputs( "图集拆分", &spritesheet_record.record, ), path_kind: "icon-spritesheet-assets", asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, persistence_provider: "vector-engine", }, ) .await { Ok(prepared_slices) => (prepared_slices.responses, prepared_slices.items, None), Err(error) => { let reason = error.body_text(); tracing::warn!( provider = "editor-icon-spritesheet", operation = "persist_automatic_slices", task_id = %generated.task_id, reason = %reason, "图标图集已持久化,但自动拆分素材持久化失败" ); ( Vec::new(), Vec::new(), Some(editor_icon_spritesheet_warning_after_persist_error(&error)), ) } } } Err(error) => { let reason = error.body_text(); tracing::warn!( provider = "editor-icon-spritesheet", operation = "detect_automatic_slices", task_id = %generated.task_id, reason = %reason, "图标图集已持久化,但自动拆分未完成" ); let warning = editor_icon_spritesheet_slice_warning_from_error(&error); (Vec::new(), Vec::new(), Some(warning)) } }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.record.resource.as_ref(), source_record.record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { (Vec::new(), None) }; let canvas_layout_plan = EditorCanvasLayoutPlan::generation( owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), canvas_items, primary_layer_id, ); let job_result_payload_json = editor_generation_job_result_payload_json( &caller, json!({ "ok": true, "spritesheetImageSrc": spritesheet_image_src.clone(), "spritesheetWidth": spritesheet_width, "spritesheetHeight": spritesheet_height, "iconImageSrcs": &icon_image_srcs, "sliceMode": requested_slice_mode, "gridX": (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) .then_some(grid_x), "gridY": (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) .then_some(grid_y), "sliceCount": payload.slice_count, "sliceWarning": &slice_warning, "warning": &generation_warning, "prompt": user_prompt.clone(), "actualPrompt": generated.actual_prompt.clone(), "model": generation_options.model, "taskId": generated.task_id.clone(), "priceMudPoints": expected_price_mud_points, "spritesheetResource": &spritesheet_record.record.resource, "spritesheetAsset": &spritesheet_record.record.asset, "resourceId": spritesheet_record .record .resource .as_ref() .map(|resource| resource.resource_id.clone()), "assetObjectId": spritesheet_prepared_image.asset_object_id(), "objectKey": spritesheet_prepared_image.object_key.clone(), }), ); let completed_at_micros = current_utc_micros(); let mut items = vec![source_record.item, spritesheet_record.item]; items.extend(slice_items); let persisted_result = persist_editor_generation_result_atomically( state, &caller, items, canvas_layout_plan, job_result_payload_json, completed_at_micros, ) .await?; let completed_project = persisted_result .project .map(editor_project_payload_from_record); let mut persisted_items = persisted_result.items; let spritesheet_item_index = persisted_items .iter() .position(|item| item.slot == "spritesheet") .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-generation-operation", "message": "图标图集原子提交缺少 spritesheet 结果。", })) })?; let spritesheet_item = persisted_items.remove(spritesheet_item_index); let spritesheet_record = EditorGeneratedAssetRecord { resource: spritesheet_item .project_resource .map(editor_project_resource_payload_from_record), asset: spritesheet_item.asset.map(editor_asset_payload_from_record), }; for (index, icon) in icon_image_srcs.iter_mut().enumerate() { let slot = format!("slice-{index:04}"); let item_index = persisted_items .iter() .position(|item| item.slot == slot) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-generation-operation", "message": format!("图标图集原子提交缺少 {slot} 结果。"), })) })?; let item = persisted_items.remove(item_index); icon.resource = item .project_resource .map(editor_project_resource_payload_from_record); icon.asset = item.asset.map(editor_asset_payload_from_record); } let slice_count = icon_image_srcs.len(); Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { spritesheet_image_src, spritesheet_width, spritesheet_height, icon_image_srcs, slice_mode: Some(requested_slice_mode), grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) .then_some(grid_x), grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) .then_some(grid_y), slice_count: Some(slice_count), slice_warning, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), task_id: generated.task_id, price_mud_points: expected_price_mud_points, spritesheet_resource: spritesheet_record.resource, spritesheet_asset: spritesheet_record.asset, project: completed_project, warning: generation_warning, }, )) } pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind<'a>( layers: &'a Value, source_layer_id: &str, source_resource_id: &str, resource_asset_kind: Option<&'a str>, ) -> Result, AppError> { let source_layer = layers .as_array() .and_then(|items| { items .iter() .find(|item| item.get("layerId").and_then(Value::as_str) == Some(source_layer_id)) }) .ok_or_else(|| { AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "未找到要拆分的图集图层。", "sourceLayerId": source_layer_id, })) })?; let layer_resource_id = source_layer .get("resourceId") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集图层缺少有效的资源引用。", "sourceLayerId": source_layer_id, })) })?; if layer_resource_id != source_resource_id { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集图层与请求资源不一致。", "sourceLayerId": source_layer_id, "sourceResourceId": source_resource_id, })), ); } match source_layer.get("assetKindOverride") { // 图层 override 只控制画布展示;手工拆分授权必须以服务端资源类型为准。 Some(Value::String(_)) => Ok(resource_asset_kind), Some(Value::Null) | None => Ok(resource_asset_kind), Some(_) => Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集图层的素材类型覆盖无效。", "sourceLayerId": source_layer_id, })), ), } } pub async fn split_editor_icon_spritesheet( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let owner_user_id = authenticated.claims().user_id().to_string(); let project_id = payload.project_id.trim().to_string(); let source_layer_id = payload.source_layer_id.trim().to_string(); let source_resource_id = payload.source_resource_id.trim().to_string(); if project_id.is_empty() || source_layer_id.is_empty() || source_resource_id.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "projectId、sourceLayerId 和 sourceResourceId 不能为空。", })), ); } let project = state .spacetime_client() .get_editor_project(EditorProjectGetRecordInput { project_id: project_id.clone(), owner_user_id: owner_user_id.clone(), }) .await .map_err(map_editor_project_error)?; let source_resource = project .resources .iter() .find(|resource| resource.resource_id == source_resource_id) .cloned() .ok_or_else(|| { AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "未找到要拆分的图集资源。", })) })?; if project.owner_user_id != owner_user_id || source_resource.owner_user_id != owner_user_id || source_resource.project_id != project_id { return Err( AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集资源不属于当前账号或当前项目。", })), ); } let effective_asset_kind = resolve_editor_icon_spritesheet_source_layer_asset_kind( &project.layers, source_layer_id.as_str(), source_resource_id.as_str(), source_resource.asset_kind.as_deref(), )?; if effective_asset_kind != Some("icon-spritesheet") { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "只有图集素材可以使用拆分功能。", })), ); } let source_object_key = normalize_editor_record_object_key( source_resource.object_key.as_deref(), source_resource.image_src.as_str(), ) .ok_or_else(|| { AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集资源缺少可读取的 objectKey。", })) })?; let task_id = build_prefixed_uuid_id("editor-atlas-split-"); let group_task_id = resolve_editor_manual_atlas_split_group_task_id( &state, owner_user_id.as_str(), &source_resource, ) .await? .unwrap_or_else(|| task_id.clone()); let processing_deadline = resolve_editor_icon_spritesheet_processing_deadline( Instant::now(), request_context.external_call_deadline(), ); let memory_admission = acquire_editor_icon_spritesheet_memory_admission(processing_deadline).await?; let download_client = build_editor_spritesheet_http_client( EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT, EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT, ) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "aliyun-oss", "message": format!("创建图集来源下载客户端失败:{error}"), })) })?; // 中文注释:source_resource 已由 owner-scoped 项目读取完成鉴权,直接使用其稳定 // objectKey,避免再按注册 ID 做两轮全账号项目与素材库扫描;memory admission // 在下载前取得,使排队请求不能各自先堆积一份最大 32 MiB 的压缩源图。 let reference = match tokio::time::timeout_at( tokio::time::Instant::from_std(processing_deadline), read_editor_reference_image_object_with_client( &state, source_object_key.as_str(), &download_client, ), ) .await { Ok(result) => result?, Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; let source = DownloadedImage { bytes: reference.bytes, mime_type: reference.mime_type, extension: "png".to_string(), }; let slices = slice_editor_icon_spritesheet_all_with_memory_admission( source, processing_deadline, memory_admission, EditorIconSpritesheetSliceMode::ConnectedComponents, None, 0, 0, ) .await?; let prompt = source_resource .prompt .clone() .unwrap_or_else(|| "拆分图集素材".to_string()); let actual_prompt = source_resource.actual_prompt.clone(); let model = source_resource .model .clone() .unwrap_or_else(|| "connected-components".to_string()); let provider = source_resource .provider .clone() .unwrap_or_else(|| "Genarrative".to_string()); let generation_inputs = source_resource.generation_inputs.clone(); let icon_image_srcs = persist_editor_spritesheet_slices( &state, slices, PersistEditorSpritesheetSlicesInput { owner_user_id: owner_user_id.clone(), project_id: Some(project_id.clone()), asset_folder_id: payload .asset_folder_id .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())), source_resource_id: Some(source_resource_id), task_id, group_task_id: Some(group_task_id), prompt, actual_prompt, model, provider, generation_inputs, path_kind: "icon-spritesheet-slices", asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, persistence_provider: "genarrative", }, ) .await?; let canvas_items = build_icon_spritesheet_slice_canvas_layer_items( &payload.canvas_completion, icon_image_srcs.as_slice(), ); let project = complete_editor_canvas_generation_with_items( &state, owner_user_id.as_str(), Some(project_id.as_str()), Some(&payload.canvas_completion), canvas_items, icon_image_srcs .first() .and_then(|icon| icon.resource.as_ref()) .map(|resource| generated_canvas_layer_id(resource.resource_id.as_str())), ) .await? .ok_or_else(|| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集拆分结果未能写入画布。", })) })?; Ok(json_success_body( Some(&request_context), EditorIconSpritesheetSliceResponse { icon_image_srcs, project, }, )) } pub(crate) async fn slice_editor_icon_spritesheet_all( source: DownloadedImage, request_deadline: Option, slice_mode: EditorIconSpritesheetSliceMode, slice_count: Option, grid_x: u32, grid_y: u32, ) -> Result { let processing_deadline = resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline); let memory_admission = acquire_editor_icon_spritesheet_memory_admission(processing_deadline).await?; slice_editor_icon_spritesheet_all_with_memory_admission( source, processing_deadline, memory_admission, slice_mode, slice_count, grid_x, grid_y, ) .await } async fn acquire_editor_icon_spritesheet_memory_admission( processing_deadline: Instant, ) -> Result, AppError> { if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); } let memory_permit = match tokio::time::timeout_at( tokio::time::Instant::from_std(processing_deadline), Arc::clone(&*EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER).acquire_owned(), ) .await { Ok(Ok(permit)) => permit, Ok(Err(error)) => { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT, "message": format!("图集切片内存门限不可用:{error}"), })), ); } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; Ok(Arc::new(EditorIconSpritesheetMemoryAdmission { _permit: memory_permit, })) } async fn slice_editor_icon_spritesheet_all_with_memory_admission( source: DownloadedImage, processing_deadline: Instant, memory_admission: Arc, slice_mode: EditorIconSpritesheetSliceMode, slice_count: Option, grid_x: u32, grid_y: u32, ) -> Result { if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); } let permit = match tokio::time::timeout_at( tokio::time::Instant::from_std(processing_deadline), Arc::clone(&*EDITOR_ICON_SPRITESHEET_CPU_LIMITER).acquire_owned(), ) .await { Ok(Ok(permit)) => permit, Ok(Err(error)) => { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT, "message": format!("图集切片 CPU 并发门限不可用:{error}"), })), ); } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; let worker_memory_admission = Arc::clone(&memory_admission); let worker = tokio::task::spawn_blocking(move || { // 中文注释:CPU permit 和内存 admission 必须由 blocking 闭包持有;即使 // async 等待超时,尚未退出的解码或连通域处理仍不能绕过并发门限。 let _permit = permit; let _memory_admission = worker_memory_admission; if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); } validate_editor_icon_spritesheet_source(&source)?; match slice_mode { EditorIconSpritesheetSliceMode::Grid => { prepare_generated_icon_spritesheet_grid(&source, grid_x, grid_y) } EditorIconSpritesheetSliceMode::ConnectedComponents => { prepare_generated_icon_spritesheet_all_by_connected_components( &source, slice_count .unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES) .min(EDITOR_ICON_SPRITESHEET_MAX_SLICES), EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, ) } } .map_err(map_editor_icon_spritesheet_platform_error) }); let plan = match tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker) .await { Ok(Ok(result)) => result?, Ok(Err(error)) => { return Err( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": format!("图集切片工作线程异常:{error}"), })), ); } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; if let Some(expected) = slice_count { if expected == 0 || expected > EDITOR_ICON_SPRITESHEET_MAX_SLICES || plan.len() != expected { return Err(AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": format!("请求切片数量为 {expected},实际识别到 {} 个。请调整 sliceCount 或素材排布。", plan.len()), }))); } } if plan.is_empty() { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, "message": "图集中未识别到可拆分的独立素材。", })), ); } Ok(PreparedEditorIconSpritesheetPlan { plan, processing_deadline, _memory_admission: memory_admission, }) } pub(crate) fn resolve_editor_icon_spritesheet_processing_deadline( started_at: Instant, request_deadline: Option, ) -> Instant { let local_deadline = started_at .checked_add(EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION) .unwrap_or(started_at); request_deadline .map(|request_deadline| request_deadline.min(local_deadline)) .unwrap_or(local_deadline) } pub(crate) fn editor_icon_spritesheet_processing_timeout_error() -> AppError { AppError::from_status(StatusCode::GATEWAY_TIMEOUT).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT, "message": "图集切片处理超时。", })) } pub(crate) fn map_editor_icon_spritesheet_platform_error( error: GeneratedAssetSheetError, ) -> AppError { let code = match &error { GeneratedAssetSheetError::RawConnectedComponentLimitExceeded { .. } => { Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT) } GeneratedAssetSheetError::OutputSliceLimitExceeded { .. } => { Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT) } GeneratedAssetSheetError::MergeCandidateLimitExceeded { .. } => { Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT) } GeneratedAssetSheetError::TotalCropPixelLimitExceeded { .. } => { Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT) } _ => None, }; let mut details = json!({ "provider": "editor-icon-spritesheet-slicing", "message": error.to_string(), }); if let Some(code) = code { details["code"] = json!(code); } AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(details) } pub(crate) fn editor_icon_spritesheet_slice_warning_from_error( error: &AppError, ) -> EditorIconSpritesheetSliceWarningResponse { let code = match error .details() .and_then(|details| details.get("code")) .and_then(Value::as_str) { Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT) => { EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT } Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT) => { EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT } Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT) => { EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT } Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT) => { EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT } Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT) => { EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT } _ => EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, }; EditorIconSpritesheetSliceWarningResponse { code, reason: error.body_text(), } } pub(crate) fn editor_icon_spritesheet_warning_after_persist_error( error: &AppError, ) -> EditorIconSpritesheetSliceWarningResponse { let is_processing_failure = error .details() .and_then(|details| details.get("code")) .and_then(Value::as_str) .is_some_and(|code| { matches!( code, EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT | EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT | EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT | EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT | EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT ) }); if is_processing_failure { editor_icon_spritesheet_slice_warning_from_error(error) } else { EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, reason: error.body_text(), } } } fn validate_editor_icon_spritesheet_source(source: &DownloadedImage) -> Result<(), AppError> { let reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice())) .with_guessed_format() .map_err(|error| { AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": format!("无法识别图集图片格式:{error}"), })) })?; let (width, height) = reader.into_dimensions().map_err(|error| { AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": format!("无法读取图集图片尺寸:{error}"), })) })?; validate_editor_icon_spritesheet_dimensions(width, height) } async fn resolve_editor_manual_atlas_split_group_task_id( state: &AppState, owner_user_id: &str, source_resource: &EditorProjectResourceRecord, ) -> Result, AppError> { let lookup = editor_manual_atlas_split_group_source_lookup_input( owner_user_id, source_resource.resource_id.as_str(), source_resource.source_resource_id.as_deref(), source_resource.asset_object_id.as_deref(), source_resource.object_key.as_deref(), ); let asset = state .spacetime_client() .find_editor_asset_group_source(lookup) .await .map_err(map_editor_project_error)?; Ok(asset.and_then(|asset| normalize_optional_string(asset.task_id))) } pub(crate) fn editor_manual_atlas_split_group_source_lookup_input( owner_user_id: &str, resource_id: &str, source_resource_id: Option<&str>, asset_object_id: Option<&str>, object_key: Option<&str>, ) -> EditorAssetGroupSourceLookupRecordInput { let mut source_resource_ids = vec![resource_id.to_string()]; if let Some(source_resource_id) = normalize_optional_string(source_resource_id.map(str::to_string)) { source_resource_ids.push(source_resource_id); } EditorAssetGroupSourceLookupRecordInput { owner_user_id: owner_user_id.to_string(), source_resource_ids, asset_object_id: asset_object_id.map(str::to_string), object_key: object_key.map(str::to_string), } } pub(crate) fn validate_editor_icon_spritesheet_dimensions( width: u32, height: u32, ) -> Result<(), AppError> { let pixel_count = u64::from(width).saturating_mul(u64::from(height)); if width == 0 || height == 0 || width > EDITOR_ICON_SPRITESHEET_MAX_DIMENSION || height > EDITOR_ICON_SPRITESHEET_MAX_DIMENSION || pixel_count > EDITOR_ICON_SPRITESHEET_MAX_PIXELS { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集尺寸超过手动拆分限制。", "width": width, "height": height, "maxDimension": EDITOR_ICON_SPRITESHEET_MAX_DIMENSION, "maxPixels": EDITOR_ICON_SPRITESHEET_MAX_PIXELS, })), ); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn icon_spritesheet_logged_in_handler_forwards_stable_idempotency_key() { let source = include_str!("editor_project_icon.rs"); let start = source .find("pub async fn generate_editor_icon_spritesheet(") .expect("logged-in icon spritesheet handler should exist"); let branch = &source[start..]; let end = branch .find("pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner(") .expect("icon spritesheet enqueue helper should follow handler"); let handler = &branch[..end]; assert!(handler.contains("headers: HeaderMap")); assert!(handler.contains("optional_editor_idempotency_key(&headers)?")); assert!(handler.contains("idempotency_key,")); } #[test] fn game_genre_serde_uses_chinese_slugs() { for genre in GameGenre::ALL { assert_eq!( serde_json::to_value(genre).expect("game genre should serialize"), json!(genre.as_slug()) ); assert_eq!( serde_json::from_value::(json!(genre.as_slug())) .expect("Chinese game genre should deserialize"), genre ); } assert!(serde_json::from_value::(json!("strategy")).is_err()); assert!(serde_json::from_value::(json!("action_adventure")).is_err()); } #[test] fn request_param_uses_play_setting_camel_case_contract() { let request: RequestParam = serde_json::from_value(json!({ "playSetting": "回合制占点", "artStyle": "低多边形" })) .expect("playSetting request should parse"); assert_eq!(request.play_setting, "回合制占点"); assert!( serde_json::from_value::(json!({ "gamePlay": "旧字段", "artStyle": "低多边形" })) .is_err() ); } #[test] fn generation_inputs_keep_only_expected_fields_and_references() { let request = RequestParam { play_setting: "回合制占点".to_string(), art_style: "低多边形".to_string(), }; let inputs = build_icon_spec_generation_inputs( &request, GameGenre::Strategy, Some(json!({ "fields": [{ "title": "客户端字段", "value": "不得保留" }], "references": [{ "title": "参考图", "label": "参考图", "refType": "asset", "refId": "asset-1" }], "theme": "不得保留", "useCase": "不得保留", "targetUser": "不得保留" })), ); assert_eq!( inputs["fields"], json!([ { "title": "玩法设定", "value": "回合制占点" }, { "title": "美术风格", "value": "低多边形" }, { "title": "游戏类型", "value": "策略" } ]) ); assert_eq!(inputs["references"].as_array().map(Vec::len), Some(1)); assert!(inputs.get("theme").is_none()); assert!(inputs.get("useCase").is_none()); assert!(inputs.get("targetUser").is_none()); } #[test] fn extra_param_requires_exact_non_empty_bounded_fields() { let valid = parse_extra_param( r#"{"genre":"策略","theme":" 现代军事 ","useCase":"PC、mobile","targetUser":"策略游戏玩家"}"#, ) .expect("valid extra params should parse"); assert_eq!(valid.theme, "现代军事"); assert!( parse_extra_param( r#"{"genre":"策略","theme":"","useCase":"PC","targetUser":"策略游戏玩家"}"# ) .is_err() ); assert!( parse_extra_param( r#"{"genre":"策略","theme":"现代军事","useCase":"PC","targetUser":"策略游戏玩家","extra":"unexpected"}"# ) .is_err() ); let oversized = json!({ "genre": "策略", "theme": "题".repeat(ICON_SPEC_EXTRA_PARAM_MAX_LEN + 1), "useCase": "PC", "targetUser": "策略游戏玩家", }); assert!(parse_extra_param(oversized.to_string().as_str()).is_err()); } #[test] fn extra_param_rejects_explicitly_incomplete_llm_responses() { let complete_json = r#"{"genre":"策略","theme":"现代军事","useCase":"PC","targetUser":"策略游戏玩家"}"#; for finish_reason in ["length", "content_filter"] { assert_eq!( parse_extra_param_response(complete_json, Some(finish_reason)), Err(ExtraParamResponseError::Incomplete) ); } assert!(parse_extra_param_response(complete_json, Some("stop")).is_ok()); } #[test] fn icon_spec_generation_text_enforces_prompt_max_length() { assert!( require_icon_spec_generation_text( "玩".repeat(PROMPT_MAX_LEN), "playSetting", "玩法设定" ) .is_ok() ); assert!( require_icon_spec_generation_text( "玩".repeat(PROMPT_MAX_LEN + 1), "playSetting", "玩法设定" ) .is_err() ); } #[test] fn icon_descriptions_reject_raw_array_over_openapi_max_items() { let mut descriptions = vec![String::new(); EDITOR_ICON_DESCRIPTION_LIMIT]; descriptions.push("唯一非空项".to_string()); let error = normalize_icon_descriptions(descriptions) .expect_err("raw array above maxItems must fail before blank entries are discarded"); assert_eq!( error .details() .and_then(|details| details.get("maxItems")) .and_then(Value::as_u64), Some(EDITOR_ICON_DESCRIPTION_LIMIT as u64) ); } #[tokio::test] async fn grid_slicing_returns_one_slice_per_declared_cell_with_detached_details() { use image::{ImageBuffer, ImageFormat, Rgba}; let mut image: image::RgbaImage = ImageBuffer::from_pixel(128, 128, Rgba([0, 255, 0, 255])); for index in 0..4u32 { let row = index / 2; let col = index % 2; let color = [80 + (index as u8 * 30), 120, 220, 255]; let x0 = col * 64 + 10; let y0 = row * 64 + 10; for y in y0..y0 + 28 { for x in x0..x0 + 28 { image.put_pixel(x, y, Rgba(color)); } } image.put_pixel(x0 + 38, y0 + 4, Rgba(color)); } let mut bytes = Vec::new(); image::DynamicImage::ImageRgba8(image) .write_to(&mut std::io::Cursor::new(&mut bytes), ImageFormat::Png) .expect("fixture png should encode"); let source = DownloadedImage { bytes, mime_type: "image/png".to_string(), extension: "png".to_string(), }; let prepared = slice_editor_icon_spritesheet_all( source, None, EditorIconSpritesheetSliceMode::Grid, None, 2, 2, ) .await .expect("declared grid sheet should slice"); assert_eq!(prepared.plan.len(), 4); } #[test] fn slice_mode_must_be_declared_and_accepts_explicit_modes() { let missing = resolve_editor_icon_spritesheet_slice_request(None, None, None) .expect_err("omitted sliceMode must fail closed"); assert_eq!(missing.status_code(), StatusCode::BAD_REQUEST); assert_eq!( missing.details().and_then(|details| details.get("field")), Some(&json!("sliceMode")) ); assert!( missing .details() .and_then(|details| details.get("message")) .and_then(Value::as_str) .is_some_and(|message| message.contains("没有默认值") && message.contains("grid") && message.contains("connected-components")), "{:?}", missing.details() ); let empty = resolve_editor_icon_spritesheet_slice_request(Some(" "), None, None) .expect_err("blank sliceMode must fail closed"); assert_eq!( empty.details().and_then(|details| details.get("field")), Some(&json!("sliceMode")) ); assert!( empty .details() .and_then(|details| details.get("message")) .and_then(Value::as_str) .is_some_and(|message| message.contains("不能为空字符串") && message.contains("connected-components")), "{:?}", empty.details() ); let unknown = resolve_editor_icon_spritesheet_slice_request(Some("grid-2x2"), Some(2), Some(2)) .expect_err("unknown sliceMode must fail closed with its own message"); assert_eq!( unknown.details().and_then(|details| details.get("field")), Some(&json!("sliceMode")) ); assert!( unknown .details() .and_then(|details| details.get("message")) .and_then(Value::as_str) .is_some_and( |message| message.contains("grid-2x2") && message.contains("没有默认值") ), "{:?}", unknown.details() ); assert_eq!( resolve_editor_icon_spritesheet_slice_request( Some("connected-components"), None, None, ) .expect("explicit connected-components mode should validate"), (EditorIconSpritesheetSliceMode::ConnectedComponents, 0, 0) ); assert_eq!( resolve_editor_icon_spritesheet_slice_request(Some("grid"), Some(3), Some(2),) .expect("grid dimensions should validate"), (EditorIconSpritesheetSliceMode::Grid, 3, 2) ); let contradictory = resolve_editor_icon_spritesheet_slice_request( Some("connected-components"), Some(2), Some(2), ) .expect_err("grid dimensions must not accompany connected-components"); assert_eq!(contradictory.status_code(), StatusCode::BAD_REQUEST); assert_eq!( contradictory .details() .and_then(|details| details.get("field")), Some(&json!("gridX/gridY")) ); let grid_without_dimensions = resolve_editor_icon_spritesheet_slice_request(Some("grid"), None, None) .expect_err("grid without dimensions must fail closed"); assert!( grid_without_dimensions .details() .and_then(|details| details.get("message")) .and_then(Value::as_str) .is_some_and(|message| message.contains("sliceCount")), "{:?}", grid_without_dimensions.details() ); let connected: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ "referenceId": "spec", "iconDescriptions": ["素材"], "sliceMode": "connected-components" })) .expect("explicit connected-components mode should deserialize"); assert_eq!( connected.slice_mode.as_deref(), Some("connected-components") ); let omitted: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ "referenceId": "spec", "iconDescriptions": ["素材"] })) .expect("omitted sliceMode stays deserializable so the route can return its own 400"); assert_eq!(omitted.slice_mode, None); assert!( resolve_editor_icon_spritesheet_slice_request( omitted.slice_mode.as_deref(), omitted.grid_x, omitted.grid_y, ) .is_err() ); let grid: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ "referenceId": "spec", "iconDescriptions": ["素材"], "sliceMode": "grid", "gridX": 3, "gridY": 2 })) .expect("grid mode should deserialize"); assert_eq!(grid.slice_mode.as_deref(), Some("grid")); assert_eq!(grid.grid_x, Some(3)); assert_eq!(grid.grid_y, Some(2)); } #[test] fn grid_dimensions_reject_more_than_maximum_output_slices() { let error = resolve_editor_icon_spritesheet_grid_dimensions( EditorIconSpritesheetSliceMode::Grid, Some(17), Some(16), ) .expect_err("257 grid cells must exceed the output slice limit"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert_eq!( error.details().and_then(|details| details.get("field")), Some(&json!("gridX/gridY")) ); assert!( error .details() .and_then(|details| details.get("message")) .and_then(Value::as_str) .is_some_and(|message| message.contains("256")) ); } #[test] fn spritesheet_genre_requires_exact_game_type_title() { assert_eq!( game_genre_from_generation_inputs(Some(&json!({ "fields": [ { "title": "玩法设定", "value": "rpg" }, { "title": "游戏类型", "value": "动作冒险" } ] }))), Ok(Some(GameGenre::ActionAdventure)) ); assert_eq!( game_genre_from_generation_inputs(Some(&json!({ "fields": [ { "title": "玩法设定", "value": "rpg" }, { "title": "游戏类型", "value": "action_adventure" } ] }))), Err(()) ); assert_eq!( game_genre_from_generation_inputs(Some(&json!({ "fields": [{ "title": "玩法设定", "value": "rpg" }] }))), Ok(None) ); } #[test] fn icon_spec_dispatch_builds_id_only_reference_into_shared_image_contract() { let image_request = build_icon_spec_image_request( EditorIconSpecGenerationRequest { request_param: RequestParam { play_setting: "回合制占点".to_string(), art_style: "低多边形".to_string(), }, reference_id: Some("resource-reference-1".to_string()), project_id: Some("project-1".to_string()), generation_inputs: None, asset_folder_id: Some("project".to_string()), asset_label: Some("图标规范 1".to_string()), source_resource_id: Some("resource-source-1".to_string()), canvas_completion: None, }, Some(json!({ "fields": [], "references": [] })), ); assert!(image_request.prompt.is_empty()); assert_eq!( image_request.generation_inputs, Some(json!({ "fields": [], "references": [] })) ); assert_eq!(image_request.kind.as_deref(), Some("spec")); assert_eq!(image_request.asset_kind.as_deref(), Some("icon-spec")); assert_eq!(image_request.model.as_deref(), Some(ICON_SPEC_MODEL)); assert_eq!(image_request.size.as_deref(), Some(ICON_SPEC_SIZE)); assert_eq!( image_request.aspect_ratio.as_deref(), Some(ICON_SPEC_ASPECT_RATIO) ); assert_eq!( image_request.image_size.as_deref(), Some(ICON_SPEC_IMAGE_SIZE) ); assert_eq!( image_request.reference_image_srcs, Some(vec!["resource-reference-1".to_string()]) ); assert_eq!(image_request.project_id.as_deref(), Some("project-1")); assert_eq!(image_request.asset_folder_id.as_deref(), Some("project")); assert_eq!(image_request.asset_label.as_deref(), Some("图标规范 1")); assert_eq!( image_request.source_resource_id.as_deref(), Some("resource-source-1") ); } }