use std::{ borrow::Cow, collections::BTreeMap, io::Cursor, sync::{Mutex, OnceLock}, time::{Duration, Instant}, }; use axum::{ Json, extract::{Extension, Path, Query, State}, http::StatusCode, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input, generate_asset_object_id, }; use platform_image::{ DownloadedImage, generated_asset_sheets::{ GeneratedAssetSheetConnectedIcon, slice_generated_icon_spritesheet_all_by_connected_components, slice_generated_icon_spritesheet_by_connected_components, }, }; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::assets::{ EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest, EditorCanvasGenerationPlaceholderPayload, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput, EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, SpacetimeClientError, }; use crate::{ api_response::json_success_body, asset_billing::execute_billable_asset_operation_with_cost, auth::AuthenticatedAccessToken, editor_generation_queue::{ EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EditorGenerationQueuedResponse, editor_generation_queue_state, editor_generation_source_entity_id, enqueue_editor_generation_job, }, editor_green_screen::{ EditorScreenBackgroundColor, editor_green_screen_asset_prompt_clause, editor_green_screen_character_prompt_clause, editor_ui_design_asset_extraction_prompt, remove_editor_generated_green_screen_background, }, editor_screen_background_decision::{ EditorScreenBackgroundDecision, EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, resolve_editor_screen_background_color, }, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, decode_generated_image_asset_data_url, normalize_generated_image_asset_mime, }, http_error::AppError, openai_image_generation::{ DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiReferenceImage, build_openai_image_http_client, create_openai_image_edit_with_references_and_model, create_openai_image_generation_with_model, create_openai_nanobanana_generate_content, require_openai_image_settings, }, platform_errors::map_oss_error, request_context::RequestContext, state::AppState, work_author::{ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID}, }; const EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE: usize = 36; pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "proj-"; pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-"; pub(crate) const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-"; pub(crate) const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-"; const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024; pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布"; const EDITOR_ASSET_METADATA_MAX_BYTES: usize = 64 * 1024; const EDITOR_ASSET_DEFAULT_FOLDER_ID: &str = "project"; const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024"; const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2"; const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana"; const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100; const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION: u32 = 4096; const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS: u64 = 2048 * 2048; const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES: usize = 64; const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5; const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image"; const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image"; const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image"; const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image"; const EDITOR_IMAGE_EDIT_ASSET_KIND: &str = "editor_image_edit"; const EDITOR_IMAGE_EDIT_SOURCE_ASSET_KIND: &str = "editor_image_edit_source"; const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet"; const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice"; const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components"; const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed"; const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet"; const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset"; const EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source"; const EDITOR_GREEN_SCREEN_SOURCE_SLOT: &str = "green_screen_source"; const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet"; const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg"; const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material"; const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image"; static EDITOR_BGFILTER_CIRCUIT: OnceLock> = OnceLock::new(); #[derive(Clone, Copy, Debug, Default)] struct EditorBgfilterCircuitState { consecutive_failures: u32, open_until: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectCreateRequest { title: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorCanvasViewportPayload { x: f64, y: f64, scale: f64, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectLayoutSaveRequest { pub(crate) viewport: EditorCanvasViewportPayload, pub(crate) layers: Value, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectRenameRequest { title: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectResourceCreateRequest { image_src: String, object_key: Option, asset_object_id: Option, width: u32, height: u32, source_type: String, prompt: Option, actual_prompt: Option, model: Option, provider: Option, task_id: Option, source_resource_id: Option, asset_kind: Option, generation_inputs: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectResourceShowcaseUpdateRequest { public_showcase_enabled: bool, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetFolderCreateRequest { label: String, sort_order: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetFolderUpdateRequest { label: Option, collapsed: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetCreateRequest { folder_id: String, label: String, image_src: String, object_key: Option, asset_object_id: Option, width: u32, height: u32, source_type: String, prompt: Option, actual_prompt: Option, model: Option, provider: Option, task_id: Option, asset_kind: Option, generation_inputs: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetUpdateRequest { label: Option, folder_id: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorImageGenerationRequest { pub(crate) prompt: String, pub(crate) size: Option, pub(crate) kind: 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) reference_image_srcs: Option>, pub(crate) project_id: Option, pub(crate) asset_kind: Option, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, pub(crate) asset_label: Option, pub(crate) source_resource_id: Option, pub(crate) canvas_completion: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorImageEditRequest { pub(crate) prompt: String, pub(crate) source_image_src: String, pub(crate) size: Option, pub(crate) model: Option, pub(crate) reference_image_srcs: Option>, pub(crate) project_id: Option, pub(crate) asset_kind: Option, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, pub(crate) asset_label: Option, pub(crate) source_resource_id: Option, pub(crate) target_layer_id: Option, pub(crate) canvas_completion: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorBackgroundRemovalRequest { pub(crate) source_image_src: String, pub(crate) project_id: Option, pub(crate) target_layer_id: Option, pub(crate) asset_kind: Option, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, pub(crate) asset_label: Option, pub(crate) source_resource_id: Option, pub(crate) task_id: Option, pub(crate) canvas_completion: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetGenerationRequest { pub(crate) reference_image_src: String, pub(crate) reference_image_srcs: Option>, pub(crate) icon_descriptions: Vec, 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) canvas_completion: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetSliceRequest { pub(crate) project_id: String, pub(crate) source_resource_id: String, pub(crate) asset_folder_id: Option, pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorUiDesignAssetExtractionRequest { pub(crate) source_image_src: String, pub(crate) model: Option, pub(crate) screen_color: Option, pub(crate) seg_model: Option, pub(crate) reference_image_srcs: Option>, pub(crate) aspect_ratio: String, pub(crate) image_size: String, pub(crate) project_id: Option, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, pub(crate) spritesheet_label: Option, pub(crate) canvas_completion: Option, } pub(crate) struct EditorGenerationCaller { pub(crate) owner_user_id: String, pub(crate) audit_subject_user_id: Option, pub(crate) audit_project_id: Option, } impl EditorGenerationCaller { fn from_authenticated(authenticated: &AuthenticatedAccessToken) -> Self { let owner_user_id = authenticated.claims().user_id().to_string(); Self { audit_subject_user_id: Some(owner_user_id.clone()), owner_user_id, audit_project_id: None, } } } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectResponse { project: EditorProjectPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectLayoutSaveResponse { project_id: String, canvas_id: String, updated_at: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectRecentResponse { project: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectListResponse { projects: Vec, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectDeleteResponse { deleted_project_id: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectResourceResponse { resource: EditorProjectResourcePayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectResourceListResponse { resources: Vec, next_cursor: Option, #[serde(skip_serializing_if = "Option::is_none")] campaign: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectResourceListQuery { cursor: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetLibraryResponse { library: EditorAssetLibraryPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetFolderResponse { folder: EditorAssetFolderPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetFolderDeleteResponse { library: EditorAssetLibraryPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetResponse { asset: EditorAssetPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorShowcaseAssetResponse { showcase_asset: EditorProjectResourcePayload, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EditorShowcaseAssetLikeRequest { liked: bool, } #[derive(Debug)] pub(crate) struct EditorGeneratedAssetRecord { pub(crate) resource: Option, pub(crate) asset: Option, } struct EditorCanvasGenerationCompletionResult { layers: Value, changed: bool, } #[derive(Debug)] pub(crate) struct PersistEditorGeneratedAssetInput { pub(crate) project_id: Option, pub(crate) owner_user_id: String, pub(crate) folder_id: Option, pub(crate) label: String, pub(crate) image_src: String, pub(crate) object_key: Option, pub(crate) asset_object_id: Option, pub(crate) width: u32, pub(crate) height: u32, pub(crate) prompt: String, pub(crate) actual_prompt: Option, pub(crate) model: String, pub(crate) provider: String, pub(crate) task_id: String, pub(crate) source_resource_id: Option, pub(crate) asset_kind: Option, pub(crate) generation_inputs: Option, pub(crate) thumbnail_src: Option, pub(crate) generation_cost_mud_points: u64, } pub(crate) struct PersistEditorGeneratedAssetRequest { pub(crate) project_id: Option, pub(crate) owner_user_id: String, pub(crate) folder_id: Option, pub(crate) label: String, pub(crate) image_src: String, pub(crate) object_key: Option, pub(crate) asset_object_id: Option, pub(crate) width: u32, pub(crate) height: u32, pub(crate) prompt: String, pub(crate) actual_prompt: Option, pub(crate) model: String, pub(crate) provider: String, pub(crate) task_id: String, pub(crate) source_resource_id: Option, pub(crate) asset_kind: Option, pub(crate) generation_inputs: Option, pub(crate) thumbnail_src: Option, pub(crate) generation_cost_mud_points: u64, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct EditorGeneratedImageStorageProfile { asset_kind: &'static str, path_kind: &'static str, slot: &'static str, } pub(crate) struct EditorCanvasGeneratedLayerInput { pub(crate) layer_id: String, pub(crate) resource_id: String, pub(crate) title: String, pub(crate) src: String, pub(crate) media_type: Option, pub(crate) thumbnail_src: Option, pub(crate) original_width: u32, pub(crate) original_height: u32, pub(crate) display_width: f64, pub(crate) display_height: f64, pub(crate) source_type: String, pub(crate) prompt: Option, pub(crate) actual_prompt: Option, pub(crate) model: Option, pub(crate) provider: Option, pub(crate) task_id: Option, pub(crate) object_key: Option, pub(crate) asset_object_id: Option, pub(crate) duration_seconds: Option, pub(crate) source_resource_id: Option, pub(crate) asset_kind: Option, pub(crate) generation_inputs: Option, pub(crate) image_sequence_frames: Option, pub(crate) preview_video_path: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorImageGenerationResponse { image_src: String, object_key: Option, asset_object_id: Option, width: u32, height: u32, source_type: &'static str, prompt: String, actual_prompt: Option, model: String, provider: &'static str, task_id: String, resource: Option, asset: Option, project: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorBackgroundRemovalResponse { image_src: String, object_key: String, asset_object_id: String, width: u32, height: u32, source_type: &'static str, task_id: String, elapsed_ms: u64, provider: &'static str, resource: Option, asset: Option, project: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetIconResponse { name: String, image_src: String, width: u32, height: u32, resource: Option, asset: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetSliceWarningResponse { code: &'static str, reason: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetGenerationResponse { spritesheet_image_src: String, spritesheet_width: u32, spritesheet_height: u32, icon_image_srcs: Vec, #[serde(skip_serializing_if = "Option::is_none")] slice_warning: Option, prompt: String, actual_prompt: Option, model: String, provider: &'static str, task_id: String, price_mud_points: u32, spritesheet_resource: Option, spritesheet_asset: Option, project: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetSliceResponse { icon_image_srcs: Vec, project: EditorProjectPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectPayload { project_id: String, title: String, canvas: EditorCanvasPayload, viewport: EditorCanvasViewportPayload, layers: Value, resources: Vec, updated_at: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorCanvasPayload { canvas_id: String, project_id: String, title: String, viewport: EditorCanvasViewportPayload, layers: Value, created_at: String, updated_at: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectResourcePayload { pub(crate) resource_id: String, #[serde(skip_serializing_if = "Option::is_none")] showcase_id: Option, #[serde(skip_serializing_if = "Option::is_none")] asset_id: Option, #[serde(skip_serializing_if = "Option::is_none")] label: Option, project_id: String, owner_user_id: String, #[serde(skip_serializing_if = "Option::is_none")] author_display_name: Option, #[serde(skip_serializing_if = "Option::is_none")] author_public_user_code: Option, image_src: String, object_key: Option, asset_object_id: Option, width: u32, height: u32, source_type: String, prompt: Option, actual_prompt: Option, model: Option, provider: Option, task_id: Option, source_resource_id: Option, asset_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] showcase_category: Option, generation_inputs: Option, public_showcase_enabled: bool, #[serde(skip_serializing_if = "Option::is_none")] review_status: Option, #[serde(skip_serializing_if = "Option::is_none")] display_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] like_count: Option, generation_cost_mud_points: u64, #[serde(skip_serializing_if = "Option::is_none")] refund_mud_points: Option, created_at: String, updated_at: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorShowcaseCampaignPayload { enabled: bool, title: String, image_src: String, image_object_key: Option, image_width: Option, image_height: Option, prompt: String, author: String, cost_text: String, updated_at: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetLibraryPayload { folders: Vec, assets: Vec, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetFolderPayload { folder_id: String, label: String, sort_order: u32, collapsed: bool, system_default: bool, created_at: String, updated_at: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorAssetPayload { asset_id: String, folder_id: String, label: String, image_src: String, object_key: Option, asset_object_id: Option, width: u32, height: u32, source_type: String, prompt: Option, actual_prompt: Option, model: Option, provider: Option, task_id: Option, asset_kind: Option, generation_inputs: Option, source_resource_id: Option, public_showcase_enabled: Option, thumbnail_src: Option, generation_cost_mud_points: u64, showcase_id: Option, showcase_review_status: Option, showcase_display_enabled: Option, showcase_like_count: Option, created_at: String, updated_at: String, } pub async fn load_recent_editor_project( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { let owner_user_id = authenticated.claims().user_id().to_string(); let project = state .spacetime_client() .get_recent_editor_project(owner_user_id) .await .map_err(map_editor_project_error)?; let project = match project { Some(project) => Some(editor_project_payload_from_record( repair_editor_project_record_inline_media(&state, project).await, )), None => None, }; Ok(json_success_body( Some(&request_context), EditorProjectRecentResponse { project }, )) } /// 公开读取画布生成模型定价;用于前端按钮泥点展示,后端仍会在提交时重新校验。 pub async fn get_editor_generation_pricing( State(state): State, Extension(request_context): Extension, ) -> Result, AppError> { let pricing = 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(), })) })?; Ok(json_success_body(Some(&request_context), pricing)) } pub async fn list_editor_projects( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { let owner_user_id = authenticated.claims().user_id().to_string(); let project_records = state .spacetime_client() .list_editor_projects(owner_user_id) .await .map_err(map_editor_project_error)?; let mut projects = Vec::with_capacity(project_records.len()); for project in project_records { projects.push(editor_project_payload_from_record( repair_editor_project_record_inline_media(&state, project).await, )); } Ok(json_success_body( Some(&request_context), EditorProjectListResponse { projects }, )) } pub async fn list_public_editor_project_resources( State(state): State, Extension(request_context): Extension, Query(query): Query, ) -> Result, AppError> { let mut resource_records = state .spacetime_client() .list_public_editor_showcase_assets(EditorShowcaseAssetPublicListRecordInput { limit: (EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE + 1) as u32, cursor: normalize_optional_string(query.cursor), }) .await .map_err(map_editor_project_error)?; let next_cursor = if resource_records.len() > EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE { resource_records .get(EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE - 1) .and_then(editor_project_resource_showcase_cursor) } else { None }; resource_records.truncate(EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE); let resources = resource_records .into_iter() .map(|record| editor_project_resource_public_payload_from_record(&state, record)) .collect(); let campaign = state .spacetime_client() .get_editor_showcase_campaign_config(EditorShowcaseCampaignConfigGetRecordInput { include_disabled: false, }) .await .map_err(map_editor_project_error)? .map(editor_showcase_campaign_payload_from_record); Ok(json_success_body( Some(&request_context), EditorProjectResourceListResponse { resources, next_cursor, campaign, }, )) } fn editor_project_resource_showcase_cursor(record: &EditorShowcaseAssetRecord) -> Option { let created_at = record .approved_at .as_deref() .unwrap_or(record.updated_at.as_str()); let created_at_micros = parse_editor_timestamp_micros(created_at)?; Some(format!("{created_at_micros}:{}", record.showcase_id)) } fn parse_editor_timestamp_micros(value: &str) -> Option { let (seconds, micros) = value.trim().strip_suffix('Z')?.split_once('.')?; if micros.len() != 6 { return None; } seconds .parse::() .ok()? .checked_mul(1_000_000)? .checked_add(micros.parse::().ok()?) } pub async fn create_editor_project( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let now_micros = current_utc_micros(); let title = payload .title .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string()); let project = state .spacetime_client() .create_editor_project(EditorProjectCreateRecordInput { project_id: build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), title, now_micros, }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorProjectResponse { project: editor_project_payload_from_record(project), }, )) } pub async fn get_editor_project( State(state): State, Path(project_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { let project = state .spacetime_client() .get_editor_project(EditorProjectGetRecordInput { project_id, owner_user_id: authenticated.claims().user_id().to_string(), }) .await .map_err(map_editor_project_error)?; let project = repair_editor_project_record_inline_media(&state, project).await; Ok(json_success_body( Some(&request_context), EditorProjectResponse { project: editor_project_payload_from_record(project), }, )) } pub async fn save_editor_project_layout( State(state): State, Path(project_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let layers_json = serialize_editor_layers(payload.layers)?; let ack = state .spacetime_client() .save_editor_project_layout_ack(EditorProjectLayoutSaveRecordInput { project_id, owner_user_id: authenticated.claims().user_id().to_string(), viewport: payload.viewport.into_record(), layers_json, updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorProjectLayoutSaveResponse { project_id: ack.project_id, canvas_id: ack.canvas_id, updated_at: ack.updated_at, }, )) } pub async fn rename_editor_project( State(state): State, Path(project_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let project = state .spacetime_client() .rename_editor_project(EditorProjectRenameRecordInput { project_id, owner_user_id: authenticated.claims().user_id().to_string(), title: payload.title, updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorProjectResponse { project: editor_project_payload_from_record(project), }, )) } pub async fn delete_editor_project( State(state): State, Path(project_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { let deleted_project_id = state .spacetime_client() .delete_editor_project(EditorProjectDeleteRecordInput { project_id, owner_user_id: authenticated.claims().user_id().to_string(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorProjectDeleteResponse { deleted_project_id }, )) } pub async fn create_editor_project_resource( State(state): State, Path(project_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let generation_inputs_json = serialize_editor_asset_metadata(payload.generation_inputs.clone())?; let object_key = normalize_editor_object_key(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; let resource = state .spacetime_client() .create_editor_project_resource(EditorProjectResourceCreateRecordInput { resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX), project_id, owner_user_id: authenticated.claims().user_id().to_string(), asset_object_id: normalize_optional_string(payload.asset_object_id), image_src, object_key, width: payload.width, height: payload.height, source_type: payload.source_type, prompt: normalize_optional_string(payload.prompt), actual_prompt: normalize_optional_string(payload.actual_prompt), model: normalize_optional_string(payload.model), provider: normalize_optional_string(payload.provider), task_id: normalize_optional_string(payload.task_id), source_resource_id: normalize_optional_string(payload.source_resource_id), asset_kind: normalize_optional_string(payload.asset_kind), generation_inputs_json, updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorProjectResourceResponse { resource: editor_project_resource_payload_from_record(resource), }, )) } pub async fn update_editor_project_resource_showcase( State(state): State, Path(resource_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let resource = state .spacetime_client() .update_editor_project_resource_showcase(EditorProjectResourceShowcaseUpdateRecordInput { resource_id, owner_user_id: current_owner_user_id(&authenticated), public_showcase_enabled: payload.public_showcase_enabled, updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorProjectResourceResponse { resource: editor_project_resource_payload_from_record(resource), }, )) } pub async fn get_editor_asset_library( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { let owner_user_id = current_owner_user_id(&authenticated); let library = state .spacetime_client() .get_editor_asset_library(owner_user_id.clone(), current_utc_micros()) .await .map_err(map_editor_project_error)?; let library = repair_editor_asset_library_inline_media(&state, owner_user_id.as_str(), library).await; Ok(json_success_body( Some(&request_context), EditorAssetLibraryResponse { library: editor_asset_library_payload_from_record(library), }, )) } pub async fn create_editor_asset_folder( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let folder = state .spacetime_client() .create_editor_asset_folder(EditorAssetFolderCreateRecordInput { folder_id: build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX), owner_user_id: current_owner_user_id(&authenticated), label: payload.label, sort_order: payload.sort_order.unwrap_or(100), now_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorAssetFolderResponse { folder: editor_asset_folder_payload_from_record(folder), }, )) } pub async fn update_editor_asset_folder( State(state): State, Path(folder_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let folder = state .spacetime_client() .update_editor_asset_folder(EditorAssetFolderUpdateRecordInput { folder_id, owner_user_id: current_owner_user_id(&authenticated), label: normalize_optional_string(payload.label), collapsed: payload.collapsed, updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorAssetFolderResponse { folder: editor_asset_folder_payload_from_record(folder), }, )) } pub async fn delete_editor_asset_folder( State(state): State, Path(folder_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { let library = state .spacetime_client() .delete_editor_asset_folder(EditorAssetFolderDeleteRecordInput { folder_id, owner_user_id: current_owner_user_id(&authenticated), updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorAssetFolderDeleteResponse { library: editor_asset_library_payload_from_record(library), }, )) } pub async fn create_editor_asset( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let generation_inputs_json = serialize_editor_asset_metadata(payload.generation_inputs.clone())?; let object_key = normalize_editor_object_key(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; let asset = state .spacetime_client() .create_editor_asset(EditorAssetCreateRecordInput { asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX), owner_user_id: current_owner_user_id(&authenticated), folder_id: payload.folder_id, label: payload.label, asset_object_id: normalize_optional_string(payload.asset_object_id), image_src, object_key, width: payload.width, height: payload.height, source_type: payload.source_type, prompt: normalize_optional_string(payload.prompt), actual_prompt: normalize_optional_string(payload.actual_prompt), model: normalize_optional_string(payload.model), provider: normalize_optional_string(payload.provider), task_id: normalize_optional_string(payload.task_id), asset_kind: normalize_optional_string(payload.asset_kind), generation_inputs_json, source_resource_id: None, now_micros: current_utc_micros(), thumbnail_src: None, generation_cost_mud_points: 0, }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorAssetResponse { asset: editor_asset_payload_from_record(asset), }, )) } pub async fn update_editor_asset( State(state): State, Path(asset_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let asset = state .spacetime_client() .update_editor_asset(EditorAssetUpdateRecordInput { asset_id, owner_user_id: current_owner_user_id(&authenticated), label: normalize_optional_string(payload.label), folder_id: normalize_optional_string(payload.folder_id), updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorAssetResponse { asset: editor_asset_payload_from_record(asset), }, )) } pub async fn delete_editor_asset( State(state): State, Path(asset_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { let asset = state .spacetime_client() .delete_editor_asset(EditorAssetDeleteRecordInput { asset_id, owner_user_id: current_owner_user_id(&authenticated), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorAssetResponse { asset: editor_asset_payload_from_record(asset), }, )) } pub async fn submit_editor_asset_showcase( State(state): State, Path(asset_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { let record = state .spacetime_client() .submit_editor_showcase_asset(EditorShowcaseAssetSubmitRecordInput { asset_id, owner_user_id: current_owner_user_id(&authenticated), now_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorShowcaseAssetResponse { showcase_asset: editor_project_resource_payload_from_showcase_record(record), }, )) } pub async fn toggle_editor_showcase_asset_like( State(state): State, Path(showcase_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let record = state .spacetime_client() .toggle_editor_showcase_asset_like(EditorShowcaseAssetLikeToggleRecordInput { showcase_id, user_id: current_owner_user_id(&authenticated), liked: payload.liked, updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorShowcaseAssetResponse { showcase_asset: editor_project_resource_payload_from_showcase_record(record), }, )) } pub async fn generate_editor_image( 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 normalized_kind = payload.kind.as_deref().map(str::trim); let is_ui_design_generation = matches!(normalized_kind, Some("ui-design")); let is_publication_material_generation = matches!(normalized_kind, Some("publication-material")); let generation_options = normalize_editor_generation_options( if is_ui_design_generation || is_publication_material_generation { Some(GPT_IMAGE_2_MODEL) } else { payload.model.as_deref() }, payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); let price_mud_points = u64::from( 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( normalized_kind, Some(generation_options.model), Some(generation_options.image_size), ), ); let source_entity_id = editor_generation_source_entity_id( payload.project_id.as_deref(), "editor-image-generation", ); let queue_job = enqueue_editor_generation_job( &state, &request_context, caller.owner_user_id.as_str(), EDITOR_IMAGE_GENERATION_JOB_KIND, source_entity_id, "图片画布生成图片", price_mud_points, &payload, ) .await?; return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { queue_state: editor_generation_queue_state(queue_job), }, )); } generate_editor_image_for_owner(&state, &request_context, caller, payload).await } pub(crate) async fn generate_editor_image_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, payload: EditorImageGenerationRequest, ) -> Result, AppError> { let role_setting = payload.prompt.trim().to_string(); if role_setting.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-generation", "message": "生成提示词不能为空", })), ); } let normalized_kind = payload.kind.as_deref().map(str::trim); let is_character_generation = matches!(normalized_kind, Some("character")); // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要、但随后会被 payload 消费掉的输入。 let requested_screen_color = payload.screen_color.clone(); let reference_count = payload.reference_image_srcs.as_ref().map_or(0, Vec::len); let seg_model = if is_character_generation { Some(parse_editor_bgfilter_seg_model( payload.seg_model.as_deref(), )?) } else { None }; let is_ui_design_generation = matches!(normalized_kind, Some("ui-design")); let is_publication_material_generation = matches!(normalized_kind, Some("publication-material")); let generation_options = normalize_editor_generation_options( if is_ui_design_generation || is_publication_material_generation { Some(GPT_IMAGE_2_MODEL) } else { payload.model.as_deref() }, payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some(); let image_size = resolve_editor_image_request_size( normalized_kind, payload.size.as_deref(), has_dimension_options, &generation_options, ); let failure_context = match normalized_kind { Some("character") => "图片画布生成角色形象", Some("spec") => "图片画布生成规范", Some("quick-edit") => "图片画布快速编辑图片", Some("ui-design") => "图片画布生成UI设计图", Some("publication-material") => "图片画布生成宣发素材", _ => "图片画布生成图片", }; let reference_limit = if matches!(normalized_kind, Some("quick-edit")) { 9 } else { 5 }; let reference_sources = payload .reference_image_srcs .unwrap_or_default() .into_iter() .map(|source| source.trim().to_string()) .filter(|source| !source.is_empty()) .take(reference_limit) .collect::>(); // 决策移入闭包后 payload.reference_image_srcs 已被 reference_sources 消费, // 这里从过滤后的 reference_sources 预先固化 ui-design 的「是否带参考图」布尔。 let ui_has_references = !reference_sources.is_empty(); 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)?; let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"); let reference_images = if reference_sources.is_empty() { Vec::new() } else { let mut parsed_reference_images = Vec::with_capacity(reference_sources.len()); for source in &reference_sources { parsed_reference_images.push( parse_editor_reference_image(state, caller.owner_user_id.as_str(), source.as_str()) .await?, ); } parsed_reference_images }; let image_size = if is_ui_design_generation { Cow::Owned(generation_options.size.clone()) } else { image_size }; let configured_price_mud_points = 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( normalized_kind, Some(generation_options.model), Some(generation_options.image_size), ); let generate_operation = async { // 背景色决策:预扣泥点之后才发起,仅角色生图需要(决策失败→future 返回 Err→失败退款, // 用户不损失泥点,平台也只在用户确有余额时才付这次 gpt-5-mini 决策)。 let screen_background_decision = if is_character_generation { Some( resolve_editor_screen_background_color( state.llm_client(), state.creative_agent_gpt5_client(), EditorScreenBackgroundDecisionInput { kind: EditorScreenBackgroundDecisionKind::Character, screen_color: requested_screen_color.clone(), prompt: role_setting.clone(), icon_descriptions: Vec::new(), 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()), }, }), ) .await?, ) } else { None }; let screen_color = screen_background_decision .as_ref() .map(|decision| decision.color); let submitted_prompt = if is_character_generation { build_editor_character_image_prompt( role_setting.as_str(), screen_color.expect("character generation should have screen color"), ) } else if is_ui_design_generation { build_editor_ui_design_prompt(role_setting.as_str(), ui_has_references) } else { role_setting.clone() }; let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { create_openai_nanobanana_generate_content( &http_client, &settings, generation_options.model, submitted_prompt.as_str(), negative_prompt, generation_options.aspect_ratio, generation_options.provider_image_size, reference_images.as_slice(), failure_context, ) .await? } else if reference_images.is_empty() { create_openai_image_generation_with_model( &http_client, &settings, generation_options.model, submitted_prompt.as_str(), negative_prompt, image_size.as_ref(), 1, &[], failure_context, ) .await? } else { create_openai_image_edit_with_references_and_model( &http_client, &settings, generation_options.model, submitted_prompt.as_str(), negative_prompt, image_size.as_ref(), 1, reference_images.as_slice(), failure_context, ) .await? }; Ok::<_, AppError>((generated, submitted_prompt, screen_background_decision)) }; let (generated, submitted_prompt, screen_background_decision) = execute_billable_asset_operation_with_cost( state, caller.owner_user_id.as_str(), editor_image_generation_billing_asset_kind(normalized_kind), request_context.request_id(), u64::from(configured_price_mud_points), generate_operation, ) .await?; let screen_color = screen_background_decision .as_ref() .map(|decision| decision.color); let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( payload.generation_inputs.clone(), screen_background_decision.as_ref(), ); let mut image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "VectorEngine 未返回图片", })) })?; let source_record = if is_character_generation { let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); let source_persisted = persist_editor_green_screen_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), &image, submitted_prompt.as_str(), generated.actual_prompt.as_deref(), "character-images", "character-image", ) .await?; let source_record = persist_editor_green_screen_source_resource( state, source_persisted, PersistEditorGreenScreenSourceResourceInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: payload .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())), label: format!("{}(原图)", role_setting), width: source_width, height: source_height, prompt: role_setting.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), task_id: generated.task_id.clone(), source_resource_id: payload.source_resource_id.clone(), generation_inputs: generation_inputs.clone(), }, ) .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()), }; image = remove_editor_generated_screen_background_with_bgfilter( state, &image, screen_color.expect("character generation should have screen color"), seg_model.expect("character generation should have BgFilter seg model"), &matting_audit, ) .await?; Some(source_record) } else { None }; let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); let storage_profile = editor_generated_image_storage_profile(normalized_kind); let persisted = persist_editor_generated_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), &image, submitted_prompt.as_str(), generated.actual_prompt.as_deref(), storage_profile.asset_kind, storage_profile.path_kind, "image", storage_profile.slot, "vector-engine", ); let persisted = persisted.await?; let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); let generated_asset = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: payload.asset_folder_id, label: normalize_optional_string(payload.asset_label) .unwrap_or_else(|| role_setting.clone()), image_src: image_src.clone(), object_key: Some(persisted.object_key.clone()), asset_object_id: Some(persisted.asset_object_id.clone()), width, height, prompt: role_setting.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), provider: "VectorEngine".to_string(), task_id: generated.task_id.clone(), source_resource_id: payload.source_resource_id, asset_kind: payload.asset_kind, generation_inputs, thumbnail_src: None, generation_cost_mud_points: u64::from(configured_price_mud_points), }, ) .await?; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_multi_asset_canvas_layer_items( completion, generated_asset.resource.as_ref(), source_record .as_ref() .and_then(|record| record.resource.as_ref()), ) } else { (Vec::new(), None) }; let completed_project = complete_editor_canvas_generation_with_items( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), canvas_items, primary_layer_id, ) .await?; Ok(json_success_body( Some(&request_context), EditorImageGenerationResponse { image_src, object_key: Some(persisted.object_key), asset_object_id: Some(persisted.asset_object_id), width, height, source_type: "generated", prompt: role_setting, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), provider: "VectorEngine", task_id: generated.task_id, resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, }, )) } fn editor_image_generation_billing_asset_kind(normalized_kind: Option<&str>) -> &'static str { match normalized_kind { Some("character") => EDITOR_CHARACTER_IMAGE_ASSET_KIND, Some("spec") => EDITOR_SPEC_IMAGE_ASSET_KIND, Some("quick-edit") => EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND, Some("ui-design") => EDITOR_UI_DESIGN_IMAGE_ASSET_KIND, Some("publication-material") => EDITOR_PUBLICATION_MATERIAL_ASSET_KIND, _ => EDITOR_GENERATED_IMAGE_ASSET_KIND, } } pub(crate) fn apply_editor_screen_background_decision_to_generation_inputs( generation_inputs: Option, decision: Option<&EditorScreenBackgroundDecision>, ) -> Option { let Some(decision) = decision else { return generation_inputs; }; let mut value = match generation_inputs { Some(value) if value.is_object() => value, Some(other) => return Some(other), None => json!({ "fields": [], "references": [] }), }; // 抠图背景色是内部自动决策 / 兜底细节,产品契约要求不进用户可见的 generationInputs.fields; // 只写顶层内部可追踪元数据 screenColorHex(不属于用户输入快照,前端展示不读取)。 value["screenColorHex"] = Value::String(decision.color.hex.to_string()); Some(value) } fn editor_generated_image_storage_profile( normalized_kind: Option<&str>, ) -> EditorGeneratedImageStorageProfile { match normalized_kind { Some("character") => EditorGeneratedImageStorageProfile { asset_kind: EDITOR_CHARACTER_IMAGE_ASSET_KIND, path_kind: "character-images", slot: EDITOR_CHARACTER_IMAGE_SLOT, }, Some("spec") => EditorGeneratedImageStorageProfile { asset_kind: EDITOR_SPEC_IMAGE_ASSET_KIND, path_kind: "spec-images", slot: "spec", }, Some("quick-edit") => EditorGeneratedImageStorageProfile { asset_kind: EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND, path_kind: "quick-edit-images", slot: "quick-edit", }, Some("ui-design") => EditorGeneratedImageStorageProfile { asset_kind: EDITOR_UI_DESIGN_IMAGE_ASSET_KIND, path_kind: "ui-design-images", slot: "ui-design", }, Some("publication-material") => EditorGeneratedImageStorageProfile { asset_kind: EDITOR_PUBLICATION_MATERIAL_ASSET_KIND, path_kind: "publication-materials", slot: "publication-material", }, _ => EditorGeneratedImageStorageProfile { asset_kind: EDITOR_GENERATED_IMAGE_ASSET_KIND, path_kind: "generated-images", slot: "image", }, } } async fn resolve_editor_image_edit_price( state: &AppState, model: &str, size: &str, ) -> Result { let expected_price_mud_points = 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("quick-edit"), Some(model), Some(editor_image_price_size_from_pixels(size)), ); Ok(expected_price_mud_points) } fn editor_image_price_size_from_pixels(size: &str) -> &'static str { let Some((width, height)) = size.split_once('x') else { return "1K"; }; let Ok(width) = width.parse::() else { return "1K"; }; let Ok(height) = height.parse::() else { return "1K"; }; if width.max(height) > 1536 { "2K" } else { "1K" } } fn normalize_editor_image_generation_size(size: Option<&str>) -> Cow<'static, str> { match size.map(str::trim).filter(|value| !value.is_empty()) { Some("1024x1024") | Some("1024*1024") | Some("1:1") => Cow::Borrowed("1024x1024"), Some("1536x1024") | Some("1536*1024") | Some("16:9") => Cow::Borrowed("1536x1024"), Some("2048x1152") | Some("2048*1152") | Some("1920x1080") | Some("1920*1080") | Some("2k-16:9") => Cow::Borrowed("2048x1152"), Some("1024x1536") | Some("1024*1536") | Some("9:16") => Cow::Borrowed("1024x1536"), Some(value) if is_editor_custom_image_size(value) => Cow::Owned(value.to_string()), _ => Cow::Borrowed(EDITOR_IMAGE_GENERATION_SIZE), } } fn resolve_editor_image_request_size( normalized_kind: Option<&str>, payload_size: Option<&str>, has_dimension_options: bool, generation_options: &EditorGenerationOptions, ) -> Cow<'static, str> { let legacy_size = normalize_editor_image_generation_size(payload_size); let has_explicit_payload_size = payload_size .map(str::trim) .is_some_and(|value| !value.is_empty()); if matches!(normalized_kind, Some("publication-material")) && has_explicit_payload_size { return legacy_size; } if has_dimension_options { return Cow::Owned(generation_options.size.clone()); } legacy_size } fn is_editor_custom_image_size(value: &str) -> bool { let Some((width, height)) = value.split_once('x') else { return false; }; let Ok(width) = width.parse::() else { return false; }; let Ok(height) = height.parse::() else { return false; }; (64..=4096).contains(&width) && (64..=4096).contains(&height) } fn normalize_editor_generation_options( model: Option<&str>, aspect_ratio: Option<&str>, image_size: Option<&str>, ) -> EditorGenerationOptions { let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) { Some(GPT_IMAGE_2_MODEL) => GPT_IMAGE_2_MODEL, Some( EDITOR_IMAGE_MODEL_NANOBANANA2 | EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS | EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS, ) => EDITOR_IMAGE_MODEL_NANOBANANA2, // 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2; // 角色 / 图标素材入口由前端显式传入 nanobanana2 默认值。 None => GPT_IMAGE_2_MODEL, _ => EDITOR_IMAGE_MODEL_NANOBANANA2, }; let aspect_ratio = normalize_editor_generation_aspect_ratio(aspect_ratio); let image_size = normalize_editor_generation_image_size(normalized_model, image_size); let size = editor_generation_size_for_model(normalized_model, aspect_ratio, image_size); let provider_image_size = editor_generation_provider_image_size_for_model(normalized_model, image_size); EditorGenerationOptions { model: normalized_model, aspect_ratio, image_size, size, provider_image_size, } } fn normalize_editor_generation_aspect_ratio(aspect_ratio: Option<&str>) -> &'static str { match aspect_ratio .map(str::trim) .filter(|value| !value.is_empty()) { Some("2:3") => "2:3", Some("3:2") => "3:2", Some("4:3") => "4:3", Some("9:16") => "9:16", Some("16:9") => "16:9", _ => "1:1", } } fn normalize_editor_generation_image_size(model: &str, image_size: Option<&str>) -> &'static str { match ( model, image_size.map(str::trim).filter(|value| !value.is_empty()), ) { (EDITOR_IMAGE_MODEL_NANOBANANA2, Some("0.5K")) => "0.5K", (_, Some("2K")) => "2K", _ => "1K", } } fn editor_generation_size_for_model(model: &str, aspect_ratio: &str, image_size: &str) -> String { if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { return match image_size { // 中文注释:VectorEngine 的 nanobanana2 文档要求 0.5K 传入 512。 "0.5K" => "512", "2K" => "2048", _ => "1024", } .to_string(); } match (image_size, aspect_ratio) { ("2K", "1:1") => "2048x2048", // 中文注释:gpt-image-2 文档未列出 2K 竖版,竖版选择回落到文档明确支持的 1K 竖版。 ("2K", "2:3") | ("2K", "9:16") => "1024x1536", ("2K", "4:3") => "2048x1536", ("2K", "16:9") | ("2K", "3:2") => "2048x1152", ("1K", "2:3") | ("1K", "9:16") => "1024x1536", ("1K", "4:3") => "1536x1024", ("1K", "3:2") | ("1K", "16:9") => "1536x1024", _ => "1024x1024", } .to_string() } fn editor_generation_provider_image_size_for_model( model: &str, image_size: &'static str, ) -> &'static str { if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { return match image_size { "0.5K" => "512", "2K" => "2K", _ => "1K", }; } image_size } #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct EditorImageEditDimensions { target_width: u32, target_height: u32, provider_width: u32, provider_height: u32, } fn align_editor_image_edit_dimension(value: u32) -> u32 { value.saturating_add(15) / 16 * 16 } fn ensure_editor_image_edit_asset_kind_allowed(asset_kind: Option<&str>) -> Result<(), AppError> { let Some(asset_kind) = asset_kind.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(()); }; if !matches!(asset_kind, "icon" | "icon-spritesheet") { return Ok(()); } Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "message": "图标与图标图集不支持快速编辑", "assetKind": asset_kind, })), ) } fn resolve_editor_image_edit_target_layer_asset_kind<'a>( layers: &'a Value, resources: &'a [EditorProjectResourcePayload], target_layer_id: &str, ) -> Result, AppError> { let target_layer = layers .as_array() .and_then(|items| { items .iter() .find(|item| item.get("layerId").and_then(Value::as_str) == Some(target_layer_id)) }) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "message": "快速编辑目标图层不存在", "targetLayerId": target_layer_id, })) })?; if let Some(asset_kind) = target_layer.get("assetKind").and_then(Value::as_str) { return Ok(Some(asset_kind)); } let resource_id = target_layer.get("resourceId").and_then(Value::as_str); Ok(resource_id.and_then(|resource_id| { resources .iter() .find(|resource| resource.resource_id == resource_id) .and_then(|resource| resource.asset_kind.as_deref()) })) } async fn ensure_editor_image_edit_source_allowed( state: &AppState, owner_user_id: &str, payload: &EditorImageEditRequest, ) -> Result<(), AppError> { let Some(project_id) = payload .project_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) else { return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref()); }; let Some(target_layer_id) = payload .target_layer_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) else { return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref()); }; let project = state .spacetime_client() .get_editor_project(EditorProjectGetRecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), }) .await .map_err(map_editor_project_error)?; let project = editor_project_payload_from_record(project); let asset_kind = resolve_editor_image_edit_target_layer_asset_kind( &project.layers, &project.resources, target_layer_id, )? .or(payload.asset_kind.as_deref()); ensure_editor_image_edit_asset_kind_allowed(asset_kind) } fn encode_editor_image_edit_png( image: image::DynamicImage, failure_status: StatusCode, failure_message: &str, ) -> Result, AppError> { let mut bytes = Cursor::new(Vec::new()); image .write_to(&mut bytes, image::ImageFormat::Png) .map_err(|error| { AppError::from_status(failure_status).with_details(json!({ "provider": "editor-image-edit", "message": format!("{failure_message}:{error}"), })) })?; Ok(bytes.into_inner()) } fn prepare_editor_image_edit_references( reference_images: &mut [OpenAiReferenceImage], target_size: &str, ) -> Result { let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "message": "图片改造目标尺寸无效", })) })?; let target_width = target_width.parse::().map_err(|_| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "message": "图片改造目标宽度无效", })) })?; let target_height = target_height.parse::().map_err(|_| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "message": "图片改造目标高度无效", })) })?; let provider_width = align_editor_image_edit_dimension(target_width); let provider_height = align_editor_image_edit_dimension(target_height); if reference_images.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "message": "图片改造缺少原图", })), ); } for (index, reference) in reference_images.iter_mut().enumerate() { let decoded = image::load_from_memory(reference.bytes.as_slice()).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "message": format!("图片改造参考图不是有效图片:{error}"), "referenceIndex": index, })) })?; let width = decoded.width(); let height = decoded.height(); let reference_provider_width = align_editor_image_edit_dimension(width); let reference_provider_height = align_editor_image_edit_dimension(height); if reference_provider_width == width && reference_provider_height == height { continue; } // 中文注释:只在 provider 边界向右、向下复制边缘像素补齐到 16 倍数, // 不缩放、不裁切用户原图;生成结果持久化前会恢复到业务目标精确尺寸。 let source = decoded.to_rgba8(); let mut aligned = image::RgbaImage::new(reference_provider_width, reference_provider_height); for y in 0..reference_provider_height { let source_y = y.min(height.saturating_sub(1)); for x in 0..reference_provider_width { let source_x = x.min(width.saturating_sub(1)); aligned.put_pixel(x, y, *source.get_pixel(source_x, source_y)); } } reference.bytes = encode_editor_image_edit_png( image::DynamicImage::ImageRgba8(aligned), StatusCode::BAD_REQUEST, "图片改造参考图 16 对齐失败", )?; reference.mime_type = "image/png".to_string(); reference.file_name = format!("editor-image-edit-reference-{}.png", index + 1); } Ok(EditorImageEditDimensions { target_width, target_height, provider_width, provider_height, }) } fn restore_editor_image_edit_output_dimensions( output: DownloadedOpenAiImage, dimensions: &EditorImageEditDimensions, ) -> Result { let decoded = image::load_from_memory(output.bytes.as_slice()).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": format!("图片改造结果不是有效图片:{error}"), })) })?; if decoded.width() == dimensions.target_width && decoded.height() == dimensions.target_height { return Ok(output); } let restored = if decoded.width() == dimensions.provider_width && decoded.height() == dimensions.provider_height { decoded.crop_imm(0, 0, dimensions.target_width, dimensions.target_height) } else { decoded.resize_to_fill( dimensions.target_width, dimensions.target_height, image::imageops::FilterType::Lanczos3, ) }; Ok(DownloadedOpenAiImage { bytes: encode_editor_image_edit_png( restored, StatusCode::BAD_GATEWAY, "恢复图片改造结果目标尺寸失败", )?, mime_type: "image/png".to_string(), extension: "png".to_string(), }) } pub async fn edit_editor_image( 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() { ensure_editor_image_edit_source_allowed(&state, caller.owner_user_id.as_str(), &payload) .await?; let generation_options = normalize_editor_generation_options(payload.model.as_deref(), None, None); let image_size = normalize_editor_image_generation_size(payload.size.as_deref()); let price_mud_points = u64::from( resolve_editor_image_edit_price(&state, generation_options.model, image_size.as_ref()) .await?, ); let source_entity_id = editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-image-edit"); let queue_job = enqueue_editor_generation_job( &state, &request_context, caller.owner_user_id.as_str(), EDITOR_IMAGE_EDIT_JOB_KIND, source_entity_id, "图片画布修改图片", price_mud_points, &payload, ) .await?; return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { queue_state: editor_generation_queue_state(queue_job), }, )); } edit_editor_image_for_owner(&state, &request_context, caller, payload).await } pub(crate) async fn edit_editor_image_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, payload: EditorImageEditRequest, ) -> Result, AppError> { ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?; let prompt = payload.prompt.trim().to_string(); if prompt.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "message": "修改提示词不能为空", })), ); } let generation_options = normalize_editor_generation_options(payload.model.as_deref(), None, None); let requested_image_size = normalize_editor_image_generation_size(payload.size.as_deref()); let mut reference_images = Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len)); reference_images.push( parse_editor_reference_image( state, caller.owner_user_id.as_str(), payload.source_image_src.as_str(), ) .await?, ); for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref(), 8) { reference_images.push( parse_editor_reference_image(state, caller.owner_user_id.as_str(), source).await?, ); } 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)?; let edit_dimensions = prepare_editor_image_edit_references(&mut reference_images, requested_image_size.as_ref())?; let image_size = format!( "{}x{}", edit_dimensions.provider_width, edit_dimensions.provider_height ); let expected_price_mud_points = resolve_editor_image_edit_price(state, generation_options.model, image_size.as_ref()) .await?; let generated = execute_billable_asset_operation_with_cost( state, caller.owner_user_id.as_str(), "editor_image_edit", request_context.request_id(), u64::from(expected_price_mud_points), create_openai_image_edit_with_references_and_model( &http_client, &settings, generation_options.model, prompt.as_str(), Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"), image_size.as_ref(), 1, reference_images.as_slice(), "图片画布修改图片", ), ) .await?; let provider_image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "VectorEngine 未返回图片", })) })?; let (provider_width, provider_height) = image::load_from_memory(provider_image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or(( edit_dimensions.provider_width, edit_dimensions.provider_height, )); let source_persisted = persist_editor_generated_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), &provider_image, prompt.as_str(), generated.actual_prompt.as_deref(), EDITOR_IMAGE_EDIT_SOURCE_ASSET_KIND, "edit-images", "image-provider-source", "provider_source", "vector-engine", ) .await?; let source_label = normalize_optional_string(payload.asset_label.clone()) .map(|label| format!("{label}(原始输出)")) .unwrap_or_else(|| "图片修改原始输出".to_string()); let _source_record = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: payload .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())), label: source_label, image_src: editor_media_src_from_object_key(source_persisted.object_key.as_str()), object_key: Some(source_persisted.object_key), asset_object_id: Some(source_persisted.asset_object_id), width: provider_width, height: provider_height, prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), provider: "VectorEngine".to_string(), task_id: generated.task_id.clone(), source_resource_id: payload.source_resource_id.clone(), asset_kind: Some(EDITOR_IMAGE_EDIT_SOURCE_ASSET_KIND.to_string()), generation_inputs: payload.generation_inputs.clone(), thumbnail_src: None, generation_cost_mud_points: 0, }, ) .await?; let image = restore_editor_image_edit_output_dimensions(provider_image, &edit_dimensions)?; let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); let owner_user_id = caller.owner_user_id.clone(); let persisted = persist_editor_generated_image( state, owner_user_id.as_str(), generated.task_id.as_str(), &image, prompt.as_str(), generated.actual_prompt.as_deref(), EDITOR_IMAGE_EDIT_ASSET_KIND, "edit-images", "image", "edit", "vector-engine", ) .await?; let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); let generated_asset = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), folder_id: payload.asset_folder_id, label: normalize_optional_string(payload.asset_label) .unwrap_or_else(|| "图片修改结果".to_string()), image_src: image_src.clone(), object_key: Some(persisted.object_key.clone()), asset_object_id: Some(persisted.asset_object_id.clone()), width, height, prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), provider: "VectorEngine".to_string(), task_id: generated.task_id.clone(), source_resource_id: payload.source_resource_id, asset_kind: payload.asset_kind, generation_inputs: payload.generation_inputs, thumbnail_src: None, generation_cost_mud_points: u64::from(expected_price_mud_points), }, ) .await?; let completed_project = if payload.canvas_completion.is_some() { complete_editor_canvas_generation( state, owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), generated_asset.resource.as_ref(), ) .await? } else { complete_editor_canvas_background_removal( state, owner_user_id.as_str(), payload.project_id.as_deref(), payload.target_layer_id.as_deref(), generated_asset.resource.as_ref(), true, ) .await? }; Ok(json_success_body( Some(&request_context), EditorImageGenerationResponse { image_src, object_key: Some(persisted.object_key), asset_object_id: Some(persisted.asset_object_id), width, height, source_type: "generated", prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), provider: "VectorEngine", task_id: generated.task_id, resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, }, )) } pub async fn remove_editor_image_background( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); let source_entity_id = editor_generation_source_entity_id( payload.project_id.as_deref(), "editor-background-removal", ); let queue_job = enqueue_editor_generation_job( &state, &request_context, caller.owner_user_id.as_str(), EDITOR_BACKGROUND_REMOVAL_JOB_KIND, source_entity_id, "图片画布去除背景", 0, &payload, ) .await?; Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { queue_state: editor_generation_queue_state(queue_job), }, )) } pub(crate) async fn remove_editor_image_background_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, payload: EditorBackgroundRemovalRequest, ) -> Result, AppError> { let started_at = Instant::now(); let source_image = parse_editor_reference_image( state, caller.owner_user_id.as_str(), payload.source_image_src.as_str(), ) .await?; let removed = request_editor_background_removal_image(state, source_image).await?; let task_id = normalize_optional_string(payload.task_id.clone()) .unwrap_or_else(|| format!("background-removal-{}", current_utc_micros())); let persisted = persist_editor_generated_image( state, caller.owner_user_id.as_str(), task_id.as_str(), &removed.image, "remove background", None, "background-removal", "background-removal", "removed-background", "result", "birefnet", ) .await?; let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); let generated_asset = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: payload.asset_folder_id, label: normalize_optional_string(payload.asset_label) .unwrap_or_else(|| "去背景结果".to_string()), image_src: image_src.clone(), object_key: Some(persisted.object_key.clone()), asset_object_id: Some(persisted.asset_object_id.clone()), width: removed.width, height: removed.height, prompt: "remove background".to_string(), actual_prompt: None, model: "BiRefNet".to_string(), provider: "BiRefNet".to_string(), task_id: task_id.clone(), source_resource_id: payload.source_resource_id, asset_kind: payload.asset_kind, generation_inputs: payload.generation_inputs, thumbnail_src: None, generation_cost_mud_points: 0, }, ) .await?; let completed_project = if payload.canvas_completion.is_some() { complete_editor_canvas_generation( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), generated_asset.resource.as_ref(), ) .await? } else { complete_editor_canvas_background_removal( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.target_layer_id.as_deref(), generated_asset.resource.as_ref(), false, ) .await? }; Ok(json_success_body( Some(&request_context), EditorBackgroundRemovalResponse { image_src, object_key: persisted.object_key, asset_object_id: persisted.asset_object_id, width: removed.width, height: removed.height, source_type: "generated", task_id, elapsed_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), provider: "BiRefNet", resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, }, )) } struct EditorBackgroundRemovalImage { image: DownloadedOpenAiImage, width: u32, height: u32, } async fn remove_editor_generated_screen_background_with_bgfilter( state: &AppState, image: &DownloadedOpenAiImage, screen_color: EditorScreenBackgroundColor, seg_model: &str, audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { if let Some(remaining) = editor_bgfilter_circuit_open_remaining(state) { // 熔断打开只跳过 BgFilter 调用本身,仍要走阿里云兜底;绝不能在 cooldown 内直接退化到 // 低质量本地扣色,否则整段冷却期从「双供应商降级」退化成单一本地键色扣除。 tracing::warn!( provider = "bgfilter", screen_color = screen_color.hex, seg_model, cooldown_ms = remaining.as_millis() as u64, "editor_bgfilter_circuit_open_fallback_to_aliyun_matting" ); return fallback_editor_screen_background_removal(state, image, screen_color, audit).await; } match request_editor_generated_screen_background_with_bgfilter( state, image, screen_color, seg_model, ) .await { Ok(image) => { record_editor_bgfilter_success(); Ok(image) } Err(error) => { record_editor_bgfilter_failure(state); tracing::warn!( provider = "bgfilter", screen_color = screen_color.hex, seg_model, error = %error, error_details = ?error.details(), "editor_bgfilter_fallback_to_aliyun_matting" ); crate::external_api_audit::record_matting_external_api_failure( state, audit, "bgfilter", state.config.editor_bgfilter_base_url.clone(), "editor-screen-background-removal", "bgfilter_segment", crate::external_api_audit::matting_failure_audit_status_code(&error), crate::external_api_audit::matting_failure_audit_timeout(&error), crate::external_api_audit::matting_failure_audit_latency_ms(&error), error.message().to_string(), crate::external_api_audit::matting_failure_audit_raw_excerpt(&error), ) .await; fallback_editor_screen_background_removal(state, image, screen_color, audit).await } } } /// BgFilter 不可用(熔断打开或调用失败)时的统一兜底链:先阿里云通用抠图,失败再退本地键色扣除。 /// 熔断打开路径与 BgFilter 调用失败路径共用此链,确保 cooldown 内仍是「阿里云 → 本地」而非直接本地。 async fn fallback_editor_screen_background_removal( state: &AppState, image: &DownloadedOpenAiImage, screen_color: EditorScreenBackgroundColor, audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { match crate::aliyun_matting::segment_image_with_aliyun_matting( state, image, "editor-screen-background", ) .await { Ok(image) => Ok(image), Err(error) => { tracing::warn!( provider = "aliyun-matting", screen_color = screen_color.hex, error = %error, error_details = ?error.details(), "editor_aliyun_matting_fallback_to_local_screen_background_removal" ); if crate::external_api_audit::matting_failure_external_call_attempted(&error) { crate::external_api_audit::record_matting_external_api_failure( state, audit, "aliyun-matting", state.config.aliyun_matting_endpoint.clone(), "editor-screen-background-removal", "aliyun_segment", crate::external_api_audit::matting_failure_audit_status_code(&error), crate::external_api_audit::matting_failure_audit_timeout(&error), crate::external_api_audit::matting_failure_audit_latency_ms(&error), error.message().to_string(), crate::external_api_audit::matting_failure_audit_raw_excerpt(&error), ) .await; } remove_editor_generated_green_screen_background(image, screen_color) } } } fn editor_bgfilter_circuit_open_remaining(state: &AppState) -> Option { let threshold = state.config.editor_bgfilter_circuit_failure_threshold; if threshold == 0 { return None; } let mut circuit = editor_bgfilter_circuit_state().lock().ok()?; let open_until = circuit.open_until?; let now = Instant::now(); if open_until > now { return Some(open_until.duration_since(now)); } circuit.open_until = None; circuit.consecutive_failures = 0; None } fn record_editor_bgfilter_success() { if let Ok(mut circuit) = editor_bgfilter_circuit_state().lock() { circuit.consecutive_failures = 0; circuit.open_until = None; } } fn record_editor_bgfilter_failure(state: &AppState) { let threshold = state.config.editor_bgfilter_circuit_failure_threshold; if threshold == 0 { return; } let cooldown = state.config.editor_bgfilter_circuit_cooldown; if let Ok(mut circuit) = editor_bgfilter_circuit_state().lock() { circuit.consecutive_failures = circuit.consecutive_failures.saturating_add(1); if circuit.consecutive_failures >= threshold { circuit.open_until = Some(Instant::now() + cooldown); } } } fn editor_bgfilter_circuit_state() -> &'static Mutex { EDITOR_BGFILTER_CIRCUIT.get_or_init(|| Mutex::new(EditorBgfilterCircuitState::default())) } #[cfg(test)] fn reset_editor_bgfilter_circuit_for_tests() { if let Ok(mut circuit) = editor_bgfilter_circuit_state().lock() { *circuit = EditorBgfilterCircuitState::default(); } } async fn request_editor_generated_screen_background_with_bgfilter( state: &AppState, image: &DownloadedOpenAiImage, screen_color: EditorScreenBackgroundColor, seg_model: &str, ) -> Result { let url = editor_bgfilter_endpoint(state)?; let call_id = format!("bgfilter-call-{}", current_utc_micros()); let request_started_at = Instant::now(); let timeout_ms = state.config.editor_bgfilter_request_timeout_ms.max(1); let input_bytes = image.bytes.len(); let source_mime_type = image.mime_type.clone(); let source_file_name = format!( "editor-generated-source.{}", image.extension.trim_start_matches('.') ); tracing::info!( %call_id, upstream_url = %url, file_name = %source_file_name, mime_type = %source_mime_type, screen_color = screen_color.hex, seg_model, input_bytes, timeout_ms, "editor_bgfilter_request_start" ); let http_client = reqwest::Client::builder() .timeout(std::time::Duration::from_millis(timeout_ms)) .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "bgfilter", "message": format!("创建 BgFilter HTTP 客户端失败:{error}"), })) })?; let file_part = reqwest::multipart::Part::bytes(image.bytes.clone()) .file_name(source_file_name) .mime_str(source_mime_type.as_str()) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "bgfilter", "message": format!("图片 MIME 类型无效:{error}"), })) })?; let form = reqwest::multipart::Form::new() .part("file", file_part) .text("screen_color", screen_color.hex.to_string()) .text("seg_model", seg_model.to_string()); let mut request = http_client.post(url.as_str()).multipart(form); if let Some(token) = state .config .editor_bgfilter_token .as_deref() .map(str::trim) .filter(|token| !token.is_empty()) { request = request.header("X-Genarrative-Image-Token", token); } let response = request.send().await.map_err(|error| { let latency_ms = request_started_at.elapsed().as_millis() as u64; tracing::warn!( %call_id, elapsed_ms = latency_ms, timeout = error.is_timeout(), error = %error, "editor_bgfilter_request_failed" ); map_editor_bgfilter_error(error, latency_ms) })?; let status = response.status(); if !status.is_success() { let message = response .text() .await .unwrap_or_else(|error| format!("读取上游错误失败:{error}")); tracing::warn!( %call_id, upstream_status = status.as_u16(), elapsed_ms = request_started_at.elapsed().as_millis() as u64, upstream_message = %message.chars().take(160).collect::(), "editor_bgfilter_upstream_rejected" ); return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "bgfilter", "message": "BgFilter 服务返回非成功状态", "upstreamStatus": status.as_u16(), "latencyMs": request_started_at.elapsed().as_millis() as u64, "upstreamMessage": message.chars().take(500).collect::(), })), ); } let mime_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .and_then(normalize_editor_reference_image_mime_type) .unwrap_or("image/png") .to_string(); let upstream_elapsed_ms = response .headers() .get("x-bgfilter-elapsed-ms") .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse::().ok()); let upstream_seg_model = response .headers() .get("x-bgfilter-seg-model") .and_then(|value| value.to_str().ok()) .map(ToOwned::to_owned); let upstream_screen_color = response .headers() .get("x-bgfilter-screen-color") .and_then(|value| value.to_str().ok()) .map(ToOwned::to_owned); let bytes = read_editor_image_removal_response_bytes(response, "bgfilter", "BgFilter", request_started_at) .await?; if bytes.is_empty() { // HTTP 已成功但 body 为空属于上游内容缺陷(5xx 归类正确),仍补 latencyMs 与 Aliyun 兜底口径对齐。 return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "bgfilter", "message": "BgFilter 服务未返回图片", "latencyMs": request_started_at.elapsed().as_millis() as u64, })), ); } let decoded = decode_editor_removed_background_image(bytes.as_slice(), "bgfilter")?; let image = DownloadedOpenAiImage { bytes, extension: editor_reference_image_extension(mime_type.as_str()).to_string(), mime_type, }; tracing::info!( %call_id, status = status.as_u16(), width = decoded.width(), height = decoded.height(), screen_color = screen_color.hex, seg_model, upstream_screen_color = upstream_screen_color.as_deref().unwrap_or(""), upstream_seg_model = upstream_seg_model.as_deref().unwrap_or(""), input_bytes, output_bytes = image.bytes.len(), elapsed_ms = request_started_at.elapsed().as_millis() as u64, upstream_elapsed_ms, "editor_bgfilter_request_done" ); Ok(image) } async fn request_editor_background_removal_image( state: &AppState, source_image: OpenAiReferenceImage, ) -> Result { let url = editor_background_removal_endpoint(state)?; let call_id = format!("background-removal-call-{}", current_utc_micros()); let request_started_at = Instant::now(); let timeout_ms = state .config .editor_background_removal_request_timeout_ms .max(1); let input_bytes = source_image.bytes.len(); let source_file_name = source_image.file_name.clone(); let source_mime_type = source_image.mime_type.clone(); tracing::info!( %call_id, upstream_url = %url, file_name = %source_file_name, mime_type = %source_mime_type, input_bytes, timeout_ms, "editor_background_removal_request_start" ); let http_client = reqwest::Client::builder() .timeout(std::time::Duration::from_millis(timeout_ms)) .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "birefnet", "message": format!("创建抠图 HTTP 客户端失败:{error}"), })) })?; let file_part = reqwest::multipart::Part::bytes(source_image.bytes) .file_name(source_image.file_name) .mime_str(source_image.mime_type.as_str()) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "birefnet", "message": format!("图片 MIME 类型无效:{error}"), })) })?; let mut request = http_client .post(url.as_str()) .multipart(reqwest::multipart::Form::new().part("file", file_part)); if let Some(token) = state .config .editor_background_removal_token .as_deref() .map(str::trim) .filter(|token| !token.is_empty()) { request = request.header("X-Genarrative-Image-Token", token); } let response = request.send().await.map_err(|error| { tracing::warn!( %call_id, elapsed_ms = request_started_at.elapsed().as_millis() as u64, timeout = error.is_timeout(), error = %error, "editor_background_removal_request_failed" ); map_editor_background_removal_error(error) })?; let status = response.status(); if !status.is_success() { let message = response .text() .await .unwrap_or_else(|error| format!("读取上游错误失败:{error}")); tracing::warn!( %call_id, upstream_status = status.as_u16(), elapsed_ms = request_started_at.elapsed().as_millis() as u64, upstream_message = %message.chars().take(160).collect::(), "editor_background_removal_upstream_rejected" ); return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "birefnet", "message": "抠图服务返回非成功状态", "upstreamStatus": status.as_u16(), "upstreamMessage": message.chars().take(500).collect::(), })), ); } let mime_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .and_then(normalize_editor_reference_image_mime_type) .unwrap_or("image/png") .to_string(); let upstream_elapsed_ms = response .headers() .get("x-birefnet-elapsed-ms") .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse::().ok()); let bytes = read_editor_background_removal_bytes(response, request_started_at).await?; if bytes.is_empty() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "birefnet", "message": "抠图服务未返回图片", })), ); } let decoded = decode_editor_background_removal_image(bytes.as_slice())?; let image = DownloadedOpenAiImage { bytes, extension: editor_reference_image_extension(mime_type.as_str()).to_string(), mime_type, }; tracing::info!( %call_id, status = status.as_u16(), width = decoded.width(), height = decoded.height(), input_bytes, output_bytes = image.bytes.len(), elapsed_ms = request_started_at.elapsed().as_millis() as u64, upstream_elapsed_ms, "editor_background_removal_request_done" ); Ok(EditorBackgroundRemovalImage { image, width: decoded.width(), height: decoded.height(), }) } fn editor_background_removal_endpoint(state: &AppState) -> Result { let base_url = state.config.editor_background_removal_base_url.trim(); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "birefnet", "message": "抠图服务地址未配置", })), ); } Ok(format!( "{}/remove-background", base_url.trim_end_matches('/') )) } fn editor_bgfilter_endpoint(state: &AppState) -> Result { let base_url = state.config.editor_bgfilter_base_url.trim(); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "bgfilter", "message": "BgFilter 服务地址未配置", })), ); } Ok(format!( "{}/remove-background", base_url.trim_end_matches('/') )) } fn parse_editor_bgfilter_seg_model(value: Option<&str>) -> Result<&'static str, AppError> { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(EDITOR_BGFILTER_DEFAULT_SEG_MODEL); }; match value.to_ascii_lowercase().as_str() { EDITOR_BGFILTER_DEFAULT_SEG_MODEL => Ok(EDITOR_BGFILTER_DEFAULT_SEG_MODEL), EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG => Ok(EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG), _ => Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "bgfilter", "message": "segModel 必须是 birefnet 或 anime-seg。", "allowed": [ EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG, ], })), ), } } fn map_editor_background_removal_error(error: reqwest::Error) -> AppError { let status = if error.is_timeout() { StatusCode::GATEWAY_TIMEOUT } else { StatusCode::BAD_GATEWAY }; AppError::from_status(status).with_details(json!({ "provider": "birefnet", "message": format!("请求抠图服务失败:{error}"), "timeout": error.is_timeout(), })) } fn map_editor_bgfilter_error(error: reqwest::Error, latency_ms: u64) -> AppError { let status = if error.is_timeout() { StatusCode::GATEWAY_TIMEOUT } else { StatusCode::BAD_GATEWAY }; AppError::from_status(status).with_details(json!({ "provider": "bgfilter", "message": format!("请求 BgFilter 服务失败:{error}"), "timeout": error.is_timeout(), "transport": true, "latencyMs": latency_ms, })) } async fn read_editor_background_removal_bytes( response: reqwest::Response, request_started_at: Instant, ) -> Result, AppError> { read_editor_image_removal_response_bytes(response, "birefnet", "抠图服务", request_started_at) .await } async fn read_editor_image_removal_response_bytes( mut response: reqwest::Response, provider: &str, service_label: &str, request_started_at: Instant, ) -> Result, AppError> { if response.content_length().is_some_and(|content_length| { content_length > EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES as u64 }) { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": provider, "message": format!("{service_label}返回图片过大"), "maxBytes": EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES, })), ); } let mut bytes = Vec::new(); // response.chunk() 失败属于「HTTP 状态已成功、读 body 时链路断裂」的传输层故障, // 必须打上 transport/timeout/latencyMs/rawExcerpt,否则外部 API 失败审计会把它错记成 502/5xx。 while let Some(chunk) = response.chunk().await.map_err(|error| { editor_image_removal_body_read_error( provider, service_label, error.is_timeout(), request_started_at.elapsed().as_millis() as u64, &error.to_string(), ) })? { if bytes.len().saturating_add(chunk.len()) > EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": provider, "message": format!("{service_label}返回图片过大"), "maxBytes": EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES, })), ); } bytes.extend_from_slice(chunk.as_ref()); } Ok(bytes) } /// 读取抠图上游响应体失败(response.chunk 中断)时构造的传输层失败错误。 /// 与 `map_editor_bgfilter_error` 对齐:timeout 走 504、其余 502,均带 transport 标记以及 /// latencyMs / rawExcerpt,供外部 API 失败审计正确归类为 transport 而非 5xx。 fn editor_image_removal_body_read_error( provider: &str, service_label: &str, is_timeout: bool, latency_ms: u64, error_text: &str, ) -> AppError { let status = if is_timeout { StatusCode::GATEWAY_TIMEOUT } else { StatusCode::BAD_GATEWAY }; AppError::from_status(status).with_details(json!({ "provider": provider, "message": format!("读取{service_label}结果失败:{error_text}"), "timeout": is_timeout, "transport": true, "latencyMs": latency_ms, "rawExcerpt": error_text, })) } fn decode_editor_background_removal_image(bytes: &[u8]) -> Result { decode_editor_removed_background_image(bytes, "birefnet") } fn decode_editor_removed_background_image( bytes: &[u8], provider: &str, ) -> Result { let mut reader = image::ImageReader::new(Cursor::new(bytes)) .with_guessed_format() .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": provider, "message": format!("识别抠图结果格式失败:{error}"), })) })?; let mut limits = image::Limits::default(); limits.max_image_width = Some(EDITOR_BACKGROUND_REMOVAL_MAX_IMAGE_DIMENSION); limits.max_image_height = Some(EDITOR_BACKGROUND_REMOVAL_MAX_IMAGE_DIMENSION); limits.max_alloc = Some(EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES as u64 * 4); reader.limits(limits); reader.decode().map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": provider, "message": format!("抠图结果不是有效图片:{error}"), "maxDimension": EDITOR_BACKGROUND_REMOVAL_MAX_IMAGE_DIMENSION, })) }) } struct PersistEditorSpritesheetSlicesInput { owner_user_id: String, project_id: Option, asset_folder_id: Option, source_resource_id: Option, task_id: String, prompt: String, actual_prompt: Option, model: String, provider: String, generation_inputs: Option, path_kind: &'static str, asset_kind: &'static str, persistence_provider: &'static str, } async fn persist_editor_spritesheet_slices( state: &AppState, slices: Vec, input: PersistEditorSpritesheetSlicesInput, ) -> Result, AppError> { if slices.is_empty() { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集中未识别到可拆分的独立素材。", })), ); } let mut results = Vec::with_capacity(slices.len()); for (index, slice) in slices.into_iter().enumerate() { let image = DownloadedOpenAiImage { bytes: slice.bytes, mime_type: "image/png".to_string(), extension: "png".to_string(), }; let file_stem = format!("asset-{:03}", index + 1); let persisted = persist_editor_generated_image( state, input.owner_user_id.as_str(), input.task_id.as_str(), &image, input.prompt.as_str(), input.actual_prompt.as_deref(), input.asset_kind, input.path_kind, file_stem.as_str(), "asset", input.persistence_provider, ) .await?; let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); let record = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { project_id: input.project_id.clone(), owner_user_id: input.owner_user_id.clone(), folder_id: input.asset_folder_id.clone(), label: slice.name.clone(), image_src: image_src.clone(), object_key: Some(persisted.object_key), asset_object_id: Some(persisted.asset_object_id), width: slice.width, height: slice.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(), 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, }, ) .await?; results.push(EditorIconSpritesheetIconResponse { name: slice.name, image_src, width: slice.width, height: slice.height, resource: record.resource, asset: record.asset, }); } Ok(results) } pub async fn generate_editor_icon_spritesheet( 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 generation_options = normalize_editor_generation_options( payload.model.as_deref(), payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); 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", ); let queue_job = enqueue_editor_generation_job( &state, &request_context, caller.owner_user_id.as_str(), EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, source_entity_id, "图片画布生成图标素材", price_mud_points, &payload, ) .await?; return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { queue_state: editor_generation_queue_state(queue_job), }, )); } generate_editor_icon_spritesheet_for_owner(&state, &request_context, caller, payload).await } pub(crate) async fn generate_editor_icon_spritesheet_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, payload: EditorIconSpritesheetGenerationRequest, ) -> Result, AppError> { let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; // 背景色决策挪到预扣泥点之后(见下方 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( parse_editor_reference_image( state, caller.owner_user_id.as_str(), payload.reference_image_src.as_str(), ) .await .map_err(|error| { error.with_details(json!({ "provider": "editor-icon-spritesheet", "field": "referenceImageSrc", "message": "图标素材规范必须是图片 Data URL、objectKey、资源或素材引用。", })) })?, ); for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref(), 8) { 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": "图标素材参考图必须是图片 Data URL、objectKey、资源或素材引用。", })) })?, ); } let generation_options = normalize_editor_generation_options( payload.model.as_deref(), payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); 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.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)?; 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.creative_agent_gpt5_client(), EditorScreenBackgroundDecisionInput { kind: EditorScreenBackgroundDecisionKind::IconSpritesheet, screen_color: requested_screen_color.clone(), prompt: icon_descriptions.join("\n"), icon_descriptions: icon_descriptions.clone(), 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()), }, }), ) .await?; let screen_color = screen_background_decision.color; let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions, screen_color); let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { create_openai_nanobanana_generate_content( &http_client, &settings, generation_options.model, prompt.as_str(), None, generation_options.aspect_ratio, generation_options.provider_image_size, reference_images.as_slice(), "图片画布生成图标素材 spritesheet", ) .await? } else { create_openai_image_edit_with_references_and_model( &http_client, &settings, generation_options.model, prompt.as_str(), None, size, 1, reference_images.as_slice(), "图片画布生成图标素材 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) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( payload.generation_inputs.clone(), Some(&screen_background_decision), ); let asset_folder_id = payload .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); let source_persisted = persist_editor_green_screen_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), &image, prompt.as_str(), generated.actual_prompt.as_deref(), "icon-spritesheets", "spritesheet", ) .await?; let source_record = persist_editor_green_screen_source_resource( state, source_persisted, PersistEditorGreenScreenSourceResourceInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: "图标素材图集(原图)".to_string(), width: source_width, height: source_height, prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), task_id: generated.task_id.clone(), source_resource_id: None, generation_inputs: generation_inputs.clone(), }, ) .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()), }; let image = remove_editor_generated_screen_background_with_bgfilter( state, &image, screen_color, seg_model, &matting_audit, ) .await?; let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); let owner_user_id = caller.owner_user_id; let spritesheet_persisted = persist_editor_generated_image( state, owner_user_id.as_str(), generated.task_id.as_str(), &image, prompt.as_str(), generated.actual_prompt.as_deref(), EDITOR_ICON_SPRITESHEET_ASSET_KIND, "icon-spritesheets", "spritesheet", "spritesheet", "vector-engine", ) .await?; let spritesheet_image_src = editor_media_src_from_object_key(spritesheet_persisted.object_key.as_str()); let spritesheet_record = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: "图标素材图集".to_string(), image_src: spritesheet_image_src.clone(), object_key: Some(spritesheet_persisted.object_key.clone()), asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), width: spritesheet_width, height: spritesheet_height, prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), provider: "VectorEngine".to_string(), task_id: generated.task_id.clone(), source_resource_id: None, asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), thumbnail_src: None, generation_cost_mud_points: u64::from(expected_price_mud_points), }, ) .await?; let slice_source = DownloadedImage { bytes: image.bytes.clone(), mime_type: image.mime_type.clone(), extension: image.extension.clone(), }; let (icon_image_srcs, slice_warning) = match slice_generated_icon_spritesheet_by_connected_components( &slice_source, &icon_descriptions, ) { Ok(icon_slices) => { match persist_editor_spritesheet_slices( state, 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 .resource .as_ref() .map(|resource| resource.resource_id.clone()), task_id: generated.task_id.clone(), prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), provider: "VectorEngine".to_string(), generation_inputs: generation_inputs.clone(), path_kind: "icon-spritesheet-assets", asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, persistence_provider: "vector-engine", }, ) .await { Ok(icon_image_srcs) => (icon_image_srcs, 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(), Some(EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, reason, }), ) } } } Err(error) => { let reason = error.to_string(); tracing::warn!( provider = "editor-icon-spritesheet", operation = "detect_automatic_slices", task_id = %generated.task_id, reason = %reason, "图标图集已持久化,但自动拆分未识别到全部素材" ); ( Vec::new(), Some(EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, reason, }), ) } }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.resource.as_ref(), source_record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { (Vec::new(), None) }; let completed_project = complete_editor_canvas_generation_with_items( state, owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), canvas_items, primary_layer_id, ) .await?; Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { spritesheet_image_src, spritesheet_width, spritesheet_height, icon_image_srcs, slice_warning, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), provider: "VectorEngine", 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, }, )) } 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_resource_id = payload.source_resource_id.trim().to_string(); if project_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 和 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) .ok_or_else(|| { AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "未找到要拆分的图集资源。", })) })?; if source_resource.asset_kind.as_deref() != Some("icon-spritesheet") { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "只有图集素材可以使用拆分功能。", })), ); } let reference = parse_editor_reference_image(&state, owner_user_id.as_str(), source_resource_id.as_str()) .await?; let source = DownloadedImage { bytes: reference.bytes, mime_type: reference.mime_type, extension: "png".to_string(), }; validate_editor_icon_spritesheet_manual_source(&source)?; let slices = slice_generated_icon_spritesheet_all_by_connected_components(&source).map_err(|error| { AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": error.to_string(), })) })?; validate_editor_icon_spritesheet_manual_slice_count(slices.len())?; let task_id = build_prefixed_uuid_id("editor-atlas-split-"); 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, 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, }, )) } fn validate_editor_icon_spritesheet_manual_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_manual_dimensions(width, height) } fn validate_editor_icon_spritesheet_manual_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_MANUAL_MAX_DIMENSION || height > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION || pixel_count > EDITOR_ICON_SPRITESHEET_MANUAL_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_MANUAL_MAX_DIMENSION, "maxPixels": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS, })), ); } Ok(()) } fn validate_editor_icon_spritesheet_manual_slice_count(slice_count: usize) -> Result<(), AppError> { if slice_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集识别出的素材数量超过手动拆分限制。", "sliceCount": slice_count, "maxSliceCount": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES, })), ); } Ok(()) } pub async fn extract_editor_ui_design_assets( 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 generation_options = normalize_editor_ui_design_asset_extraction_options( payload.model.as_deref(), payload.aspect_ratio.as_str(), payload.image_size.as_str(), )?; let price_mud_points = u64::from( resolve_editor_ui_design_asset_extraction_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-ui-design-asset-extraction", ); let queue_job = enqueue_editor_generation_job( &state, &request_context, caller.owner_user_id.as_str(), EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, source_entity_id, "图片画布提取UI设计图素材", price_mud_points, &payload, ) .await?; return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { queue_state: editor_generation_queue_state(queue_job), }, )); } extract_editor_ui_design_assets_for_owner(&state, &request_context, caller, payload).await } pub(crate) async fn extract_editor_ui_design_assets_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, payload: EditorUiDesignAssetExtractionRequest, ) -> Result, AppError> { // 背景色决策挪到预扣泥点之后(见下方 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 decision_prompt = format!( "从 UI 设计图提取被红色框选的素材,输出 spritesheet。目标规格 {} {}。", payload.aspect_ratio, payload.image_size ); let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?; let reference_image = parse_editor_reference_image( state, caller.owner_user_id.as_str(), payload.source_image_src.as_str(), ) .await .map_err(|error| { error.with_details(json!({ "provider": "editor-ui-design-asset-extraction", "field": "sourceImageSrc", "message": "UI设计图必须是图片 Data URL、objectKey、资源或素材引用。", })) })?; let mut reference_images = vec![reference_image]; for source in normalize_editor_reference_image_sources( payload.reference_image_srcs.as_deref(), EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT, ) { 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-ui-design-asset-extraction", "field": "referenceImageSrcs", "message": "UI素材参考图必须是图片 Data URL、objectKey、资源或素材引用。", })) })?, ); } let generation_options = normalize_editor_ui_design_asset_extraction_options( payload.model.as_deref(), payload.aspect_ratio.as_str(), payload.image_size.as_str(), )?; let expected_price_mud_points = resolve_editor_ui_design_asset_extraction_price( state, Some(generation_options.model), Some(generation_options.image_size), ) .await?; 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)?; let (generated, prompt, screen_background_decision) = execute_billable_asset_operation_with_cost( state, caller.owner_user_id.as_str(), "editor_ui_design_asset_extraction", 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.creative_agent_gpt5_client(), EditorScreenBackgroundDecisionInput { kind: EditorScreenBackgroundDecisionKind::UiDesignAssetExtraction, screen_color: requested_screen_color.clone(), prompt: decision_prompt.clone(), icon_descriptions: Vec::new(), 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()), }, }), ) .await?; let screen_color = screen_background_decision.color; let prompt = editor_ui_design_asset_extraction_prompt(screen_color); let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { create_openai_nanobanana_generate_content( &http_client, &settings, generation_options.model, prompt.as_str(), None, generation_options.aspect_ratio, generation_options.provider_image_size, reference_images.as_slice(), "图片画布提取UI设计图素材 spritesheet", ) .await? } else { create_openai_image_edit_with_references_and_model( &http_client, &settings, generation_options.model, prompt.as_str(), None, generation_options.size.as_str(), 1, reference_images.as_slice(), "图片画布提取UI设计图素材 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 未返回 UI 设计图素材 spritesheet", })) })?; let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( payload.generation_inputs.clone(), Some(&screen_background_decision), ); let source_label = normalize_optional_string(payload.spritesheet_label.clone()) .unwrap_or_else(|| "UI设计图素材图集".to_string()); let asset_folder_id = payload .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); let source_persisted = persist_editor_green_screen_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), &image, prompt.as_str(), generated.actual_prompt.as_deref(), "ui-design-spritesheets", "spritesheet", ) .await?; let source_record = persist_editor_green_screen_source_resource( state, source_persisted, PersistEditorGreenScreenSourceResourceInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: format!("{}(原图)", source_label), width: source_width, height: source_height, prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), task_id: generated.task_id.clone(), source_resource_id: None, generation_inputs: generation_inputs.clone(), }, ) .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()), }; let image = remove_editor_generated_screen_background_with_bgfilter( state, &image, screen_color, seg_model, &matting_audit, ) .await?; let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); let owner_user_id = caller.owner_user_id; let spritesheet_persisted = persist_editor_generated_image( state, owner_user_id.as_str(), generated.task_id.as_str(), &image, prompt.as_str(), generated.actual_prompt.as_deref(), EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND, "ui-design-spritesheets", "spritesheet", "spritesheet", "vector-engine", ) .await?; let spritesheet_image_src = editor_media_src_from_object_key(spritesheet_persisted.object_key.as_str()); let spritesheet_record = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: normalize_optional_string(payload.spritesheet_label) .unwrap_or_else(|| "UI设计图素材图集".to_string()), image_src: spritesheet_image_src.clone(), object_key: Some(spritesheet_persisted.object_key.clone()), asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), width: spritesheet_width, height: spritesheet_height, prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), provider: "VectorEngine".to_string(), task_id: generated.task_id.clone(), source_resource_id: None, asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), thumbnail_src: None, generation_cost_mud_points: u64::from(expected_price_mud_points), }, ) .await?; let slice_source = DownloadedImage { bytes: image.bytes.clone(), mime_type: image.mime_type.clone(), extension: image.extension.clone(), }; let (icon_image_srcs, slice_warning) = match slice_generated_icon_spritesheet_all_by_connected_components(&slice_source) { Ok(icon_slices) => { match persist_editor_spritesheet_slices( state, 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 .resource .as_ref() .map(|resource| resource.resource_id.clone()), task_id: generated.task_id.clone(), prompt: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), provider: "VectorEngine".to_string(), generation_inputs: generation_inputs.clone(), path_kind: "ui-design-assets", asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND, persistence_provider: "vector-engine", }, ) .await { Ok(icon_image_srcs) => (icon_image_srcs, None), Err(error) => { let reason = error.body_text(); tracing::warn!( provider = "editor-ui-design-asset-extraction", operation = "persist_slices", task_id = %generated.task_id, reason = %reason, "UI 设计图透明图集已持久化,但独立素材持久化失败" ); ( Vec::new(), Some(EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, reason, }), ) } } } Err(error) => { let reason = error.to_string(); tracing::warn!( provider = "editor-ui-design-asset-extraction", operation = "detect_slices", task_id = %generated.task_id, reason = %reason, "UI 设计图透明图集已持久化,但未识别到可拆分素材" ); ( Vec::new(), Some(EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, reason, }), ) } }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.resource.as_ref(), source_record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { (Vec::new(), None) }; let completed_project = complete_editor_canvas_generation_with_items( state, owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), canvas_items, primary_layer_id, ) .await?; Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { spritesheet_image_src, spritesheet_width, spritesheet_height, icon_image_srcs, slice_warning, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), provider: "VectorEngine", 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, }, )) } async fn repair_editor_project_record_inline_media( state: &AppState, mut record: EditorProjectRecord, ) -> EditorProjectRecord { let owner_user_id = record.owner_user_id.clone(); let mut repaired_resources = Vec::with_capacity(record.resources.len()); for resource in record.resources { repaired_resources.push( repair_editor_project_resource_inline_media(state, owner_user_id.as_str(), resource) .await, ); } record.resources = repaired_resources; record } async fn repair_editor_asset_library_inline_media( state: &AppState, owner_user_id: &str, mut record: EditorAssetLibraryRecord, ) -> EditorAssetLibraryRecord { let mut repaired_assets = Vec::with_capacity(record.assets.len()); for asset in record.assets { repaired_assets.push(repair_editor_asset_inline_media(state, owner_user_id, asset).await); } record.assets = repaired_assets; record } async fn repair_editor_project_resource_inline_media( state: &AppState, owner_user_id: &str, resource: EditorProjectResourceRecord, ) -> EditorProjectResourceRecord { if !should_repair_editor_inline_image( resource.image_src.as_str(), resource.object_key.as_deref(), ) { return resource; } let repair = persist_editor_legacy_inline_image( state, owner_user_id, resource.image_src.as_str(), resource .task_id .as_deref() .unwrap_or(resource.resource_id.as_str()), resource .prompt .as_deref() .or(resource.actual_prompt.as_deref()) .unwrap_or("历史画布内联图片修复"), resource.actual_prompt.as_deref(), resource.asset_kind.as_deref(), "legacy-resources", resource.resource_id.as_str(), ) .await; let persisted = match repair { Ok(persisted) => persisted, Err(error) => { tracing::warn!( resource_id = %resource.resource_id, error = %error, "repair legacy inline editor project resource media failed" ); return resource; } }; let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); let repaired = state .spacetime_client() .repair_editor_project_resource_media(EditorProjectResourceMediaRepairRecordInput { resource_id: resource.resource_id.clone(), owner_user_id: owner_user_id.to_string(), asset_object_id: persisted.asset_object_id.clone(), image_src: image_src.clone(), object_key: persisted.object_key.clone(), updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error); match repaired { Ok(record) => record, Err(error) => { tracing::warn!( resource_id = %resource.resource_id, error = %error, "persist legacy inline editor project resource repair failed" ); EditorProjectResourceRecord { image_src, object_key: Some(persisted.object_key), asset_object_id: Some(persisted.asset_object_id), ..resource } } } } async fn repair_editor_asset_inline_media( state: &AppState, owner_user_id: &str, asset: EditorAssetRecord, ) -> EditorAssetRecord { if !should_repair_editor_inline_image(asset.image_src.as_str(), asset.object_key.as_deref()) { return asset; } let repair = persist_editor_legacy_inline_image( state, owner_user_id, asset.image_src.as_str(), asset.task_id.as_deref().unwrap_or(asset.asset_id.as_str()), asset.prompt.as_deref().unwrap_or(asset.label.as_str()), asset.actual_prompt.as_deref(), asset.asset_kind.as_deref(), "legacy-assets", asset.asset_id.as_str(), ) .await; let persisted = match repair { Ok(persisted) => persisted, Err(error) => { tracing::warn!( asset_id = %asset.asset_id, error = %error, "repair legacy inline editor asset media failed" ); return asset; } }; let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); let repaired = state .spacetime_client() .repair_editor_asset_media(EditorAssetMediaRepairRecordInput { asset_id: asset.asset_id.clone(), owner_user_id: owner_user_id.to_string(), asset_object_id: persisted.asset_object_id.clone(), image_src: image_src.clone(), object_key: persisted.object_key.clone(), updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error); match repaired { Ok(record) => record, Err(error) => { tracing::warn!( asset_id = %asset.asset_id, error = %error, "persist legacy inline editor asset repair failed" ); EditorAssetRecord { image_src, object_key: Some(persisted.object_key), asset_object_id: Some(persisted.asset_object_id), ..asset } } } } async fn persist_editor_legacy_inline_image( state: &AppState, owner_user_id: &str, image_src: &str, task_id: &str, prompt: &str, actual_prompt: Option<&str>, asset_kind: Option<&str>, path_kind: &str, file_stem: &str, ) -> Result { let decoded = decode_generated_image_asset_data_url(image_src).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", "message": format!("历史画布内联图片无法解析:{error:?}"), })) })?; let image = DownloadedOpenAiImage { bytes: decoded.bytes, mime_type: decoded.format.mime_type, extension: decoded.format.extension, }; persist_editor_generated_image( state, owner_user_id, task_id, &image, prompt, actual_prompt, asset_kind.unwrap_or(EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND), path_kind, file_stem, "legacy-repair", "vector-engine", ) .await } fn should_repair_editor_inline_image(image_src: &str, object_key: Option<&str>) -> bool { object_key .map(str::trim) .filter(|value| !value.is_empty()) .is_none() && is_editor_inline_image_src(image_src) } fn is_editor_inline_image_src(value: &str) -> bool { value .trim_start() .to_ascii_lowercase() .starts_with("data:image/") } pub(crate) fn editor_project_payload_from_record( record: EditorProjectRecord, ) -> EditorProjectPayload { let resources: Vec = record .resources .into_iter() .map(editor_project_resource_payload_from_record) .collect(); let canvas = editor_canvas_payload_from_record(record.canvas, resources.as_slice()); let layers = sanitize_editor_payload_media(record.layers, resources.as_slice()); EditorProjectPayload { project_id: record.project_id, title: record.title, canvas, viewport: EditorCanvasViewportPayload { x: record.viewport.x, y: record.viewport.y, scale: record.viewport.scale, }, layers, resources, updated_at: record.updated_at, } } fn editor_canvas_payload_from_record( record: EditorCanvasRecord, resources: &[EditorProjectResourcePayload], ) -> EditorCanvasPayload { EditorCanvasPayload { canvas_id: record.canvas_id, project_id: record.project_id, title: record.title, viewport: EditorCanvasViewportPayload { x: record.viewport.x, y: record.viewport.y, scale: record.viewport.scale, }, layers: sanitize_editor_payload_media(record.layers, resources), created_at: record.created_at, updated_at: record.updated_at, } } pub(crate) fn editor_project_resource_payload_from_record( record: EditorProjectResourceRecord, ) -> EditorProjectResourcePayload { let image_src = normalize_editor_record_media_src(record.image_src, record.object_key.as_deref()); EditorProjectResourcePayload { resource_id: record.resource_id, showcase_id: None, asset_id: None, label: None, project_id: record.project_id, owner_user_id: record.owner_user_id, author_display_name: None, author_public_user_code: None, image_src, object_key: record.object_key, asset_object_id: record.asset_object_id, width: record.width, height: record.height, source_type: record.source_type, prompt: record.prompt, actual_prompt: record.actual_prompt, model: record.model, provider: record.provider, task_id: record.task_id, source_resource_id: record.source_resource_id, asset_kind: record.asset_kind, showcase_category: None, generation_inputs: record .generation_inputs .map(sanitize_editor_payload_inline_media), public_showcase_enabled: record.public_showcase_enabled, review_status: None, display_enabled: None, like_count: None, generation_cost_mud_points: 0, refund_mud_points: None, created_at: record.created_at, updated_at: record.updated_at, } } fn editor_project_resource_payload_from_showcase_record( record: EditorShowcaseAssetRecord, ) -> EditorProjectResourcePayload { let image_src = normalize_editor_record_media_src(record.image_src, record.object_key.as_deref()); EditorProjectResourcePayload { resource_id: record.showcase_id.clone(), showcase_id: Some(record.showcase_id), asset_id: Some(record.asset_id), label: Some(record.label), project_id: "editor-showcase".to_string(), owner_user_id: record.owner_user_id, author_display_name: None, author_public_user_code: None, image_src, object_key: record.object_key, asset_object_id: record.asset_object_id, width: record.width, height: record.height, source_type: record.source_type, prompt: record.prompt, actual_prompt: record.actual_prompt, model: record.model, provider: record.provider, task_id: record.task_id, source_resource_id: record.source_resource_id, asset_kind: record.asset_kind, showcase_category: record.showcase_category, generation_inputs: record .generation_inputs .map(sanitize_editor_payload_inline_media), public_showcase_enabled: record.display_enabled, review_status: Some(record.review_status), display_enabled: Some(record.display_enabled), like_count: Some(record.like_count), generation_cost_mud_points: record.generation_cost_mud_points, refund_mud_points: Some(record.refund_mud_points), created_at: record.submitted_at, updated_at: record.updated_at, } } fn editor_project_resource_public_payload_from_record( state: &AppState, record: EditorShowcaseAssetRecord, ) -> EditorProjectResourcePayload { let mut payload = editor_project_resource_payload_from_showcase_record(record); let (display_name, public_user_code) = resolve_editor_project_resource_public_author(state, payload.owner_user_id.as_str()); payload.author_display_name = display_name; payload.author_public_user_code = public_user_code; payload } fn resolve_editor_project_resource_public_author( state: &AppState, owner_user_id: &str, ) -> (Option, Option) { let Some(owner_user_id) = normalize_optional_string(Some(owner_user_id.to_string())) else { return (None, Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string())); }; if owner_user_id == ORPHAN_WORK_OWNER_USER_ID { return (None, Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string())); } match state.auth_user_service().get_user_by_id(&owner_user_id) { Ok(Some(user)) => ( normalize_optional_string(Some(user.display_name)), normalize_optional_string(Some(user.public_user_code)), ), Ok(None) | Err(_) => (None, Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string())), } } pub(crate) fn editor_asset_library_payload_from_record( record: EditorAssetLibraryRecord, ) -> EditorAssetLibraryPayload { EditorAssetLibraryPayload { folders: record .folders .into_iter() .map(editor_asset_folder_payload_from_record) .collect(), assets: record .assets .into_iter() .map(editor_asset_payload_from_record) .collect(), } } pub(crate) fn editor_asset_folder_payload_from_record( record: EditorAssetFolderRecord, ) -> EditorAssetFolderPayload { EditorAssetFolderPayload { folder_id: record.folder_id, label: record.label, sort_order: record.sort_order, collapsed: record.collapsed, system_default: record.system_default, created_at: record.created_at, updated_at: record.updated_at, } } pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload { let image_src = normalize_editor_record_media_src(record.image_src, record.object_key.as_deref()); EditorAssetPayload { asset_id: record.asset_id, folder_id: record.folder_id, label: record.label, image_src, object_key: record.object_key, asset_object_id: record.asset_object_id, width: record.width, height: record.height, source_type: record.source_type, prompt: record.prompt, actual_prompt: record.actual_prompt, model: record.model, provider: record.provider, task_id: record.task_id, asset_kind: record.asset_kind, generation_inputs: record .generation_inputs .map(sanitize_editor_payload_inline_media), source_resource_id: record.source_resource_id, public_showcase_enabled: record.public_showcase_enabled, thumbnail_src: normalize_optional_string(record.thumbnail_src), generation_cost_mud_points: record.generation_cost_mud_points, showcase_id: record.showcase_id, showcase_review_status: record.showcase_review_status, showcase_display_enabled: record.showcase_display_enabled, showcase_like_count: record.showcase_like_count, created_at: record.created_at, updated_at: record.updated_at, } } fn editor_showcase_campaign_payload_from_record( record: EditorShowcaseCampaignConfigRecord, ) -> EditorShowcaseCampaignPayload { EditorShowcaseCampaignPayload { enabled: record.enabled, title: record.title, image_src: record.image_src, image_object_key: record.image_object_key, image_width: record.image_width, image_height: record.image_height, prompt: record.prompt, author: record.author, cost_text: record.cost_text, updated_at: record.updated_at, } } #[derive(Clone, Debug)] struct EditorPayloadMediaReference { image_src: String, object_key: Option, asset_object_id: Option, } fn sanitize_editor_payload_media( mut value: Value, resources: &[EditorProjectResourcePayload], ) -> Value { let resource_media: BTreeMap = resources .iter() .filter(|resource| !resource.image_src.trim().is_empty()) .map(|resource| { ( resource.resource_id.clone(), EditorPayloadMediaReference { image_src: resource.image_src.clone(), object_key: resource.object_key.clone(), asset_object_id: resource.asset_object_id.clone(), }, ) }) .collect(); sanitize_editor_payload_media_value(&mut value, None, &resource_media); value } fn sanitize_editor_payload_inline_media(mut value: Value) -> Value { sanitize_editor_payload_media_value(&mut value, None, &BTreeMap::new()); value } fn sanitize_editor_payload_media_value( value: &mut Value, inherited_media: Option<&EditorPayloadMediaReference>, resource_media: &BTreeMap, ) { match value { Value::Object(object) => { let current_media = object .get("resourceId") .and_then(Value::as_str) .and_then(|resource_id| resource_media.get(resource_id)) .cloned() .or_else(|| inherited_media.cloned()); if let Some(media) = current_media.as_ref() { for field in ["src", "imageSrc", "thumbnailSrc", "previewVideoPath"] { rewrite_or_remove_inline_media_field(object, field, media); } fill_missing_media_identity_field(object, "objectKey", media.object_key.as_ref()); fill_missing_media_identity_field( object, "assetObjectId", media.asset_object_id.as_ref(), ); } for child in object.values_mut() { sanitize_editor_payload_media_value(child, current_media.as_ref(), resource_media); } object.retain(|_, child| !child.is_null()); } Value::Array(items) => { for item in items.iter_mut() { sanitize_editor_payload_media_value(item, inherited_media, resource_media); } items.retain(|item| !item.is_null()); } Value::String(item) if is_forbidden_editor_persisted_media_src(item) => { *value = Value::Null; } _ => {} } } fn rewrite_or_remove_inline_media_field( object: &mut serde_json::Map, field: &str, media: &EditorPayloadMediaReference, ) { let Some(Value::String(current)) = object.get(field) else { return; }; if !is_forbidden_editor_persisted_media_src(current) { return; } if media.image_src.trim().is_empty() { object.remove(field); } else { object.insert(field.to_string(), Value::String(media.image_src.clone())); } } fn fill_missing_media_identity_field( object: &mut serde_json::Map, field: &str, value: Option<&String>, ) { let Some(value) = value .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) else { return; }; let should_fill = object .get(field) .and_then(Value::as_str) .map(str::trim) .filter(|current| !current.is_empty()) .is_none(); if should_fill { object.insert(field.to_string(), Value::String(value.to_string())); } } pub(crate) async fn persist_editor_generated_asset( state: &AppState, input: PersistEditorGeneratedAssetInput, ) -> Result { let generation_inputs_json = serialize_editor_asset_metadata(input.generation_inputs.clone())?; let object_key = normalize_editor_object_key(input.object_key); let asset_object_id = normalize_optional_string(input.asset_object_id); let image_src = normalize_editor_persisted_media_src(input.image_src, object_key.as_deref())?; let now_micros = current_utc_micros(); let resource = if let Some(project_id) = normalize_optional_string(input.project_id.clone()) { let record = state .spacetime_client() .create_editor_project_resource(EditorProjectResourceCreateRecordInput { resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX), project_id, owner_user_id: input.owner_user_id.clone(), asset_object_id: asset_object_id.clone(), image_src: image_src.clone(), object_key: object_key.clone(), width: input.width, height: input.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: normalize_optional_string(input.asset_kind.clone()), generation_inputs_json: generation_inputs_json.clone(), updated_at_micros: now_micros, }) .await .map_err(map_editor_project_error)?; Some(editor_project_resource_payload_from_record(record)) } else { None }; let folder_id = normalize_generated_asset_folder_id(input.folder_id, input.owner_user_id.as_str()); let created_resource_id = resource .as_ref() .map(|resource| resource.resource_id.clone()) .or_else(|| normalize_optional_string(input.source_resource_id.clone())); let asset = if let Some(folder_id) = folder_id { let record = state .spacetime_client() .create_editor_asset(EditorAssetCreateRecordInput { asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX), owner_user_id: input.owner_user_id.clone(), folder_id, label: input.label, asset_object_id, image_src, object_key, width: input.width, height: input.height, source_type: "generated".to_string(), prompt: Some(input.prompt), actual_prompt: input.actual_prompt, model: Some(input.model), provider: Some(input.provider), task_id: Some(input.task_id), asset_kind: normalize_optional_string(input.asset_kind), generation_inputs_json, source_resource_id: created_resource_id, generation_cost_mud_points: input.generation_cost_mud_points, now_micros, thumbnail_src: normalize_optional_string(input.thumbnail_src), }) .await .map_err(map_editor_project_error)?; Some(editor_asset_payload_from_record(record)) } else { None }; Ok(EditorGeneratedAssetRecord { resource, asset }) } pub(crate) async fn persist_editor_generated_media_asset( state: &AppState, input: PersistEditorGeneratedAssetRequest, ) -> Result< ( Option, Option, ), AppError, > { let persisted = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { project_id: input.project_id, owner_user_id: input.owner_user_id, folder_id: input.folder_id, label: input.label, image_src: input.image_src, object_key: input.object_key, asset_object_id: input.asset_object_id, width: input.width, height: input.height, prompt: input.prompt, actual_prompt: input.actual_prompt, model: input.model, provider: input.provider, task_id: input.task_id, source_resource_id: input.source_resource_id, asset_kind: input.asset_kind, generation_inputs: input.generation_inputs, thumbnail_src: input.thumbnail_src, generation_cost_mud_points: input.generation_cost_mud_points, }, ) .await?; Ok((persisted.resource, persisted.asset)) } pub(crate) async fn complete_editor_canvas_generation( state: &AppState, owner_user_id: &str, project_id: Option<&str>, completion: Option<&EditorCanvasGenerationCompletionRequest>, resource: Option<&EditorProjectResourcePayload>, ) -> Result, AppError> { let (Some(project_id), Some(completion), Some(resource)) = (project_id, completion, resource) else { return Ok(None); }; let project_id = project_id.trim(); if project_id.is_empty() { return Ok(None); } let project = state .spacetime_client() .get_editor_project(EditorProjectGetRecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), }) .await .map_err(map_editor_project_error)?; let project = repair_editor_project_record_inline_media(state, project).await; let viewport = project.viewport.clone(); let project_payload = editor_project_payload_from_record(project); let Some(placeholder) = resolve_canvas_completion_placeholder(project_payload.layers.clone(), completion)? else { return Ok(None); }; let layer_id = generated_canvas_layer_id(resource.resource_id.as_str()); let item = build_generated_canvas_layer_item(completion, &placeholder, resource, layer_id.as_str(), 0); let completion_result = apply_editor_canvas_generation_items( project_payload.layers, completion, placeholder, vec![item], Some(layer_id), )?; if !completion_result.changed { return Ok(None); } let saved = state .spacetime_client() .save_editor_project_layout(EditorProjectLayoutSaveRecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), viewport, layers_json: serialize_editor_layers(completion_result.layers)?, updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(Some(editor_project_payload_from_record(saved))) } pub(crate) async fn complete_editor_canvas_generation_with_items( state: &AppState, owner_user_id: &str, project_id: Option<&str>, completion: Option<&EditorCanvasGenerationCompletionRequest>, items: Vec, primary_layer_id: Option, ) -> Result, AppError> { let (Some(project_id), Some(completion)) = (project_id, completion) else { return Ok(None); }; let project_id = project_id.trim(); if project_id.is_empty() || items.is_empty() { return Ok(None); } let project = state .spacetime_client() .get_editor_project(EditorProjectGetRecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), }) .await .map_err(map_editor_project_error)?; let project = repair_editor_project_record_inline_media(state, project).await; let viewport = project.viewport.clone(); let project_payload = editor_project_payload_from_record(project); let Some(placeholder) = resolve_canvas_completion_placeholder(project_payload.layers.clone(), completion)? else { return Ok(None); }; let items = translate_editor_canvas_generation_items(items, &completion.placeholder, &placeholder); let completion_result = apply_editor_canvas_generation_items( project_payload.layers, completion, placeholder, items, primary_layer_id, )?; if !completion_result.changed { return Ok(None); } let saved = state .spacetime_client() .save_editor_project_layout(EditorProjectLayoutSaveRecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), viewport, layers_json: serialize_editor_layers(completion_result.layers)?, updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(Some(editor_project_payload_from_record(saved))) } pub(crate) async fn complete_editor_canvas_background_removal( state: &AppState, owner_user_id: &str, project_id: Option<&str>, target_layer_id: Option<&str>, resource: Option<&EditorProjectResourcePayload>, recenter_replacement: bool, ) -> Result, AppError> { let (Some(project_id), Some(target_layer_id), Some(resource)) = (project_id, target_layer_id, resource) else { return Ok(None); }; let project_id = project_id.trim(); let target_layer_id = target_layer_id.trim(); if project_id.is_empty() || target_layer_id.is_empty() { return Ok(None); } let project = state .spacetime_client() .get_editor_project(EditorProjectGetRecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), }) .await .map_err(map_editor_project_error)?; let project = repair_editor_project_record_inline_media(state, project).await; let viewport = project.viewport.clone(); let project_payload = editor_project_payload_from_record(project); let Some(layers) = replace_editor_canvas_layer_with_resource( project_payload.layers, target_layer_id, resource, recenter_replacement, ) else { return Ok(None); }; let saved = state .spacetime_client() .save_editor_project_layout(EditorProjectLayoutSaveRecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), viewport, layers_json: serialize_editor_layers(layers)?, updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; Ok(Some(editor_project_payload_from_record(saved))) } fn replace_editor_canvas_layer_with_resource( layers: Value, target_layer_id: &str, resource: &EditorProjectResourcePayload, recenter_replacement: bool, ) -> Option { let mut items = match layers { Value::Array(items) => items, _ => return None, }; let mut changed = false; for item in &mut items { let Some(object) = item.as_object_mut() else { continue; }; let is_target = object .get("layerId") .and_then(Value::as_str) .is_some_and(|layer_id| layer_id == target_layer_id); if !is_target { continue; } let replacement_width = f64::from(resource.width.max(1)); let replacement_height = f64::from(resource.height.max(1)); let centered_position = recenter_replacement .then(|| { object .get("x") .and_then(Value::as_f64) .zip(object.get("y").and_then(Value::as_f64)) .zip(object.get("width").and_then(Value::as_f64)) .zip(object.get("height").and_then(Value::as_f64)) .map(|(((x, y), width), height)| { ( x + width / 2.0 - replacement_width / 2.0, y + height / 2.0 - replacement_height / 2.0, ) }) }) .flatten(); object.insert( "resourceId".to_string(), Value::String(resource.resource_id.clone()), ); object.insert("src".to_string(), Value::String(resource.image_src.clone())); if let Some((x, y)) = centered_position { object.insert("x".to_string(), json!(x)); object.insert("y".to_string(), json!(y)); } object.insert("width".to_string(), json!(resource.width.max(1))); object.insert("height".to_string(), json!(resource.height.max(1))); object.insert("originalWidth".to_string(), json!(resource.width.max(1))); object.insert("originalHeight".to_string(), json!(resource.height.max(1))); object.insert( "sourceType".to_string(), Value::String(resource.source_type.clone()), ); object.insert("mediaType".to_string(), Value::String("image".to_string())); set_optional_json_string_field(object, "prompt", resource.prompt.as_ref()); set_optional_json_string_field(object, "actualPrompt", resource.actual_prompt.as_ref()); set_optional_json_string_field(object, "model", resource.model.as_ref()); set_optional_json_string_field(object, "provider", resource.provider.as_ref()); set_optional_json_string_field(object, "taskId", resource.task_id.as_ref()); set_optional_json_string_field(object, "objectKey", resource.object_key.as_ref()); set_optional_json_string_field(object, "assetObjectId", resource.asset_object_id.as_ref()); set_optional_json_string_field( object, "sourceResourceId", resource.source_resource_id.as_ref(), ); set_optional_json_string_field(object, "assetKind", resource.asset_kind.as_ref()); set_optional_json_value_field( object, "generationInputs", resource.generation_inputs.clone(), ); object.remove("sourceAssetId"); object.remove("thumbnailSrc"); object.remove("imageSequenceFrames"); object.remove("previewVideoPath"); object.remove("durationSeconds"); changed = true; break; } changed.then_some(Value::Array(items)) } fn set_optional_json_string_field( object: &mut serde_json::Map, field: &str, value: Option<&String>, ) { match value .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { Some(value) => { object.insert(field.to_string(), Value::String(value.to_string())); } None => { object.remove(field); } } } fn set_optional_json_value_field( object: &mut serde_json::Map, field: &str, value: Option, ) { match value { Some(value) if !value.is_null() => { object.insert(field.to_string(), value); } _ => { object.remove(field); } } } fn translate_editor_canvas_generation_items( mut items: Vec, submitted_placeholder: &EditorCanvasGenerationPlaceholderPayload, resolved_placeholder: &EditorCanvasGenerationPlaceholderPayload, ) -> Vec { let dx = resolved_placeholder.x - submitted_placeholder.x; let dy = resolved_placeholder.y - submitted_placeholder.y; if !dx.is_finite() || !dy.is_finite() || (dx == 0.0 && dy == 0.0) { return items; } for item in &mut items { let Some(object) = item.as_object_mut() else { continue; }; if let Some(x) = object.get("x").and_then(Value::as_f64) && (x + dx).is_finite() { object.insert("x".to_string(), json!(x + dx)); } if let Some(y) = object.get("y").and_then(Value::as_f64) && (y + dy).is_finite() { object.insert("y".to_string(), json!(y + dy)); } } items } fn resolve_canvas_completion_placeholder( layers: Value, completion: &EditorCanvasGenerationCompletionRequest, ) -> Result, AppError> { let dialog_id = normalized_canvas_completion_dialog_id(completion); let items = match layers { Value::Array(items) => items, _ => Vec::new(), }; if let Some(dialog_id) = dialog_id.as_deref() { return items .iter() .find(|item| generation_dialog_item_matches(item, dialog_id)) .map(|item| { generation_dialog_item_placeholder(item, dialog_id) .unwrap_or_else(|| completion.placeholder.clone()) }) .map(Some) .map_or(Ok(None), Ok); } Ok(Some(completion.placeholder.clone())) } fn apply_editor_canvas_generation_items( layers: Value, completion: &EditorCanvasGenerationCompletionRequest, _placeholder: EditorCanvasGenerationPlaceholderPayload, generated_items: Vec, primary_layer_id: Option, ) -> Result { let mut items = match layers { Value::Array(items) => items, _ => Vec::new(), }; let dialog_id = normalized_canvas_completion_dialog_id(completion); if let Some(dialog_id) = dialog_id.as_deref() && !items .iter() .any(|item| generation_dialog_item_matches(item, dialog_id)) { return Ok(EditorCanvasGenerationCompletionResult { layers: Value::Array(items), changed: false, }); } let mut changed = false; let mut next_z_index = next_canvas_layer_z_index(&items); let mut completed_layer_id = primary_layer_id; for mut item in generated_items { let existing_layer_id = item.get("resourceId") .and_then(Value::as_str) .and_then(|resource_id| { items.iter().find_map(|existing| { let existing_resource_id = existing.get("resourceId").and_then(Value::as_str)?; if existing_resource_id == resource_id { existing .get("layerId") .and_then(Value::as_str) .map(str::to_string) } else { None } }) }); if let Some(existing_layer_id) = existing_layer_id { completed_layer_id = Some(existing_layer_id); continue; } let needs_z_index = match item.get("zIndex").and_then(Value::as_i64) { Some(value) => value <= 0, None => true, }; if needs_z_index && let Some(object) = item.as_object_mut() { object.insert("zIndex".to_string(), json!(next_z_index)); next_z_index += 1; } if completed_layer_id.is_none() { completed_layer_id = item .get("layerId") .and_then(Value::as_str) .map(str::to_string); } items.push(item); changed = true; } if let Some(dialog_id) = dialog_id.as_deref() { for item in &mut items { if generation_dialog_item_matches(item, dialog_id) && let Some(dialog) = item.get_mut("dialog").and_then(Value::as_object_mut) { dialog.insert("status".to_string(), json!("idle")); dialog.insert("composerOpen".to_string(), json!(false)); if let Some(completed_layer_id) = completed_layer_id.as_deref() { dialog.insert("generatedLayerId".to_string(), json!(completed_layer_id)); } dialog.remove("errorMessage"); changed = true; } } } Ok(EditorCanvasGenerationCompletionResult { layers: Value::Array(items), changed, }) } fn normalized_canvas_completion_dialog_id( completion: &EditorCanvasGenerationCompletionRequest, ) -> Option { completion .dialog_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn build_generated_canvas_layer_item( completion: &EditorCanvasGenerationCompletionRequest, placeholder: &EditorCanvasGenerationPlaceholderPayload, resource: &EditorProjectResourcePayload, generated_layer_id: &str, z_index: i64, ) -> Value { let original_width = positive_f64(resource.width as f64, placeholder.original_width); let original_height = positive_f64(resource.height as f64, placeholder.original_height); let width = positive_f64(original_width, placeholder.width); let height = positive_f64(original_height, placeholder.height); let placeholder_width = positive_f64(placeholder.width, width); let placeholder_height = positive_f64(placeholder.height, height); let x = placeholder.x + placeholder_width / 2.0 - width / 2.0; let y = placeholder.y + placeholder_height / 2.0 - height / 2.0; let asset_kind = resource.asset_kind.as_deref(); json!({ "layerId": generated_layer_id, "resourceId": resource.resource_id, "title": completion.title.trim(), "src": resource.image_src, "x": x, "y": y, "width": width, "height": height, "originalWidth": original_width, "originalHeight": original_height, "zIndex": z_index, "sourceType": "generated", "mediaType": generated_canvas_media_type(asset_kind), "prompt": resource.prompt, "actualPrompt": resource.actual_prompt, "model": resource.model, "provider": resource.provider, "taskId": resource.task_id, "objectKey": resource.object_key, "assetObjectId": resource.asset_object_id, "sourceResourceId": resource.source_resource_id, "assetKind": resource.asset_kind, "generationInputs": resource.generation_inputs, }) } pub(crate) fn build_editor_canvas_generated_layer_item( completion: &EditorCanvasGenerationCompletionRequest, input: EditorCanvasGeneratedLayerInput, ) -> Value { let placeholder = &completion.placeholder; let original_width = positive_f64(input.original_width as f64, placeholder.original_width); let original_height = positive_f64(input.original_height as f64, placeholder.original_height); let width = positive_f64(input.display_width, placeholder.width); let height = positive_f64(input.display_height, placeholder.height); let placeholder_width = positive_f64(placeholder.width, width); let placeholder_height = positive_f64(placeholder.height, height); let x = placeholder.x + placeholder_width / 2.0 - width / 2.0; let y = placeholder.y + placeholder_height / 2.0 - height / 2.0; json!({ "layerId": input.layer_id, "resourceId": input.resource_id, "title": input.title, "src": input.src, "mediaType": input.media_type, "thumbnailSrc": input.thumbnail_src, "x": x, "y": y, "width": width, "height": height, "originalWidth": original_width, "originalHeight": original_height, "sourceType": input.source_type, "prompt": input.prompt, "actualPrompt": input.actual_prompt, "model": input.model, "provider": input.provider, "taskId": input.task_id, "objectKey": input.object_key, "assetObjectId": input.asset_object_id, "durationSeconds": input.duration_seconds, "sourceResourceId": input.source_resource_id, "assetKind": input.asset_kind, "generationInputs": input.generation_inputs, "imageSequenceFrames": input.image_sequence_frames, "previewVideoPath": input.preview_video_path, }) } fn positive_f64(value: f64, fallback: f64) -> f64 { if value.is_finite() && value > 0.0 { value.round().max(1.0) } else if fallback.is_finite() && fallback > 0.0 { fallback.round().max(1.0) } else { 1.0 } } fn generated_canvas_layer_id(resource_id: &str) -> String { format!("layer-editor-resource-{resource_id}") } fn generated_canvas_media_type(asset_kind: Option<&str>) -> &'static str { match asset_kind { Some("video") => "video", Some("sound-effect") | Some("background-music") => "audio", _ => "image", } } fn build_multi_asset_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, primary_resource: Option<&EditorProjectResourcePayload>, source_resource: Option<&EditorProjectResourcePayload>, ) -> (Vec, Option) { let Some(primary_resource) = primary_resource else { return (Vec::new(), None); }; let placeholder = completion.placeholder.clone(); let primary_layer_id = generated_canvas_layer_id(primary_resource.resource_id.as_str()); let primary_item = build_generated_canvas_layer_item( completion, &placeholder, primary_resource, primary_layer_id.as_str(), 0, ); let primary_right = primary_item .get("x") .and_then(Value::as_f64) .unwrap_or(placeholder.x) + primary_item .get("width") .and_then(Value::as_f64) .unwrap_or(placeholder.width.max(1.0)); let primary_y = primary_item .get("y") .and_then(Value::as_f64) .unwrap_or(placeholder.y); let mut items = vec![primary_item]; if let Some(source_resource) = source_resource { let source_layer_id = generated_canvas_layer_id(source_resource.resource_id.as_str()); let mut source_item = build_generated_canvas_layer_item( completion, &placeholder, source_resource, source_layer_id.as_str(), 0, ); if let Some(object) = source_item.as_object_mut() { object.insert("x".to_string(), json!(primary_right + 32.0)); object.insert("y".to_string(), json!(primary_y)); object.insert( "title".to_string(), json!(format!("{}(原图)", completion.title.trim())), ); } items.push(source_item); } (items, Some(primary_layer_id)) } fn build_icon_spritesheet_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, spritesheet_resource: Option<&EditorProjectResourcePayload>, source_resource: Option<&EditorProjectResourcePayload>, icons: &[EditorIconSpritesheetIconResponse], ) -> (Vec, Option) { let placeholder = completion.placeholder.clone(); let (mut items, primary_layer_id) = build_multi_asset_canvas_layer_items(completion, spritesheet_resource, source_resource); if items.is_empty() { return (items, primary_layer_id); } let start_x = items .iter() .filter_map(|item| Some(item.get("x")?.as_f64()? + item.get("width")?.as_f64()?)) .fold(placeholder.x, f64::max) + 32.0; items.extend(build_icon_slice_canvas_layer_items_from_origin( completion, icons, start_x, placeholder.y, )); (items, primary_layer_id) } fn build_icon_spritesheet_slice_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, icons: &[EditorIconSpritesheetIconResponse], ) -> Vec { let placeholder = &completion.placeholder; build_icon_slice_canvas_layer_items_from_origin( completion, icons, placeholder.x + placeholder.width.max(1.0) + 32.0, placeholder.y, ) } fn build_icon_slice_canvas_layer_items_from_origin( completion: &EditorCanvasGenerationCompletionRequest, icons: &[EditorIconSpritesheetIconResponse], start_x: f64, start_y: f64, ) -> Vec { let placeholder = &completion.placeholder; let mut items = Vec::new(); let mut cursor_x = start_x; let mut cursor_y = start_y; let mut row_height = 0.0_f64; let max_row_width = 560.0_f64; let spacing = 24.0_f64; for icon in icons { let Some(resource) = icon.resource.as_ref() else { continue; }; let width = positive_f64(resource.width as f64, 128.0); let height = positive_f64(resource.height as f64, 128.0); if cursor_x > start_x && cursor_x + width - start_x > max_row_width { cursor_x = start_x; cursor_y += row_height + spacing; row_height = 0.0; } let layer_id = generated_canvas_layer_id(resource.resource_id.as_str()); let mut item = build_generated_canvas_layer_item( completion, &placeholder, resource, layer_id.as_str(), 0, ); if let Some(object) = item.as_object_mut() { object.insert("title".to_string(), json!(icon.name)); object.insert("x".to_string(), json!(cursor_x)); object.insert("y".to_string(), json!(cursor_y)); object.insert("width".to_string(), json!(width)); object.insert("height".to_string(), json!(height)); object.insert("originalWidth".to_string(), json!(width)); object.insert("originalHeight".to_string(), json!(height)); } cursor_x += width + spacing; row_height = row_height.max(height); items.push(item); } items } fn next_canvas_layer_z_index(items: &[Value]) -> i64 { items .iter() .filter_map(|item| item.get("zIndex").and_then(Value::as_i64)) .max() .unwrap_or(0) + 1 } fn generation_dialog_item_matches(item: &Value, dialog_id: &str) -> bool { item.get("itemType").and_then(Value::as_str) == Some("generation-dialog") && item .get("dialog") .and_then(|dialog| dialog.get("id")) .and_then(Value::as_str) .is_some_and(|candidate| candidate == dialog_id) } fn generation_dialog_item_placeholder( item: &Value, dialog_id: &str, ) -> Option { if !generation_dialog_item_matches(item, dialog_id) { return None; } serde_json::from_value( item.get("dialog")? .get("placeholder") .cloned() .unwrap_or(Value::Null), ) .ok() } fn normalize_generated_asset_folder_id( folder_id: Option, owner_user_id: &str, ) -> Option { let normalized = normalize_optional_string(folder_id); match normalized.as_deref() { None => None, Some("project") => Some(default_editor_asset_folder_id(owner_user_id)), Some(value) if value.starts_with("folder-") => { Some(default_editor_asset_folder_id(owner_user_id)) } Some(_) => normalized, } } fn default_editor_asset_folder_id(owner_user_id: &str) -> String { format!("{owner_user_id}:asset-folder:{EDITOR_ASSET_DEFAULT_FOLDER_ID}") } fn normalize_editor_object_key(value: Option) -> Option { normalize_optional_string(value) .map(|item| item.trim_start_matches('/').to_string()) .filter(|item| !item.is_empty()) } fn editor_media_src_from_object_key(object_key: &str) -> String { format!("/{}", object_key.trim().trim_start_matches('/')) } fn normalize_editor_record_media_src(image_src: String, object_key: Option<&str>) -> String { if let Some(media_src) = object_key .map(str::trim) .filter(|value| !value.is_empty()) .map(editor_media_src_from_object_key) { return media_src; } if is_forbidden_editor_persisted_media_src(image_src.as_str()) { return String::new(); } image_src } pub(crate) fn normalize_editor_persisted_media_src( image_src: String, object_key: Option<&str>, ) -> Result { if let Some(object_key) = object_key.map(str::trim).filter(|value| !value.is_empty()) { return Ok(editor_media_src_from_object_key(object_key)); } if is_forbidden_editor_persisted_media_src(image_src.as_str()) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", "message": "图片画布媒体必须先上传 OSS 并以 objectKey 引用,不能写入 Data URL。", })), ); } Ok(image_src) } fn is_forbidden_editor_persisted_media_src(value: &str) -> bool { let normalized = value.trim_start().to_ascii_lowercase(); let value = normalized.as_str(); value.starts_with("blob:") || value.starts_with("data:image/") || value.starts_with("data:video/") || value.starts_with("data:audio/") } fn value_contains_forbidden_editor_persisted_media(value: &Value) -> bool { match value { Value::String(item) => is_forbidden_editor_persisted_media_src(item), Value::Array(items) => items .iter() .any(value_contains_forbidden_editor_persisted_media), Value::Object(object) => object .values() .any(value_contains_forbidden_editor_persisted_media), _ => false, } } impl EditorCanvasViewportPayload { pub(crate) fn into_record(self) -> EditorCanvasViewportRecord { EditorCanvasViewportRecord { x: self.x, y: self.y, scale: self.scale, } } } fn current_owner_user_id(authenticated: &AuthenticatedAccessToken) -> String { authenticated.claims().user_id().to_string() } pub(crate) fn serialize_editor_layers(layers: Value) -> Result { if value_contains_forbidden_editor_persisted_media(&layers) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", "message": "图层布局不能包含内联媒体 Data URL。", })), ); } let payload = serde_json::to_string(&layers).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", "message": format!("图层布局无法序列化:{error}"), })) })?; if payload.len() > EDITOR_LAYOUT_MAX_BYTES { return Err( AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({ "provider": "editor-project", "message": "图层布局过大", })), ); } Ok(payload) } pub(crate) fn serialize_editor_asset_metadata( metadata: Option, ) -> Result, AppError> { let Some(metadata) = metadata else { return Ok(None); }; if metadata.is_null() { return Ok(None); } if value_contains_forbidden_editor_persisted_media(&metadata) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", "message": "资产元数据不能包含内联媒体 Data URL。", })), ); } let payload = serde_json::to_string(&metadata).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", "message": format!("资产元数据无法序列化:{error}"), })) })?; if payload.len() > EDITOR_ASSET_METADATA_MAX_BYTES { return Err( AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({ "provider": "editor-project", "message": "资产元数据过大", })), ); } Ok(Some(payload)) } pub(crate) fn normalize_optional_string(value: Option) -> Option { value .map(|item| item.trim().to_string()) .filter(|item| !item.is_empty()) } pub(crate) fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String { let normalized = value .trim() .chars() .map(|character| match character { 'a'..='z' | '0'..='9' | '-' | '_' => character, 'A'..='Z' => character.to_ascii_lowercase(), _ => '-', }) .collect::() .split('-') .filter(|part| !part.is_empty()) .collect::>() .join("-"); if normalized.is_empty() { fallback.to_string() } else { normalized } } fn normalize_icon_descriptions(descriptions: Vec) -> Result, AppError> { let normalized = descriptions .into_iter() .map(|description| description.trim().to_string()) .filter(|description| !description.is_empty()) .take(EDITOR_ICON_DESCRIPTION_LIMIT.saturating_add(1)) .collect::>(); if normalized.is_empty() || normalized.len() > EDITOR_ICON_DESCRIPTION_LIMIT { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spritesheet", "field": "iconDescriptions", "message": "图标素材描述数量必须在 1 到 100 个之间。", })), ); } Ok(normalized) } async fn resolve_editor_icon_spritesheet_price( state: &AppState, model: Option<&str>, image_size: Option<&str>, ) -> Result { let expected_price_mud_points = 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); Ok(expected_price_mud_points) } fn editor_ui_design_asset_extraction_bad_request(message: impl Into) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-ui-design-asset-extraction", "message": message.into(), })) } fn normalize_editor_ui_design_asset_extraction_options( model: Option<&str>, aspect_ratio: &str, image_size: &str, ) -> Result { if aspect_ratio.trim() != "1:1" { return Err(editor_ui_design_asset_extraction_bad_request( "aspectRatio 必须固定为 1:1。", )); } let normalized_image_size = match image_size.trim().to_ascii_uppercase().as_str() { "1K" => "1K", "2K" => "2K", _ => { return Err(editor_ui_design_asset_extraction_bad_request( "imageSize 仅支持 1K 或 2K。", )); } }; let model = model .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(EDITOR_IMAGE_MODEL_NANOBANANA2); Ok(normalize_editor_generation_options( Some(model), Some("1:1"), Some(normalized_image_size), )) } async fn resolve_editor_ui_design_asset_extraction_price( state: &AppState, model: Option<&str>, image_size: Option<&str>, ) -> Result { let expected_price_mud_points = 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); Ok(expected_price_mud_points) } fn build_editor_icon_spritesheet_prompt( icon_descriptions: &[String], screen_color: EditorScreenBackgroundColor, ) -> String { format!( "参考图1的图标素材规范,{};禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}", editor_green_screen_asset_prompt_clause(screen_color), icon_descriptions.join("、") ) } fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool) -> String { let mut prompt = vec![ "生成玩法UI原型图".to_string(), format!("【用户输入】{}", user_input.trim()), ]; if has_icon_spec_reference { prompt.push("参考图1为图标素材规范,请在UI图标、按钮符号、描边、材质、圆角、阴影和状态层级上严格遵循参考图1的素材规范。".to_string()); } prompt.join("\n") } fn build_editor_character_image_prompt( role_setting: &str, screen_color: EditorScreenBackgroundColor, ) -> String { vec![ format!("按照角色描述生成游戏角色立绘。严格基于图1的角色美术视觉规范的美术风格、角色头身比、角色朝向等特征。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。{};禁止生成美术视觉规范,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。", editor_green_screen_character_prompt_clause(screen_color)), format!("角色设定:{}", role_setting.trim()), ] .join("\n") } #[derive(Debug, Clone, PartialEq, Eq)] struct EditorGenerationOptions { model: &'static str, aspect_ratio: &'static str, image_size: &'static str, size: String, provider_image_size: &'static str, } pub(crate) struct PersistedEditorGeneratedImage { pub(crate) object_key: String, pub(crate) asset_object_id: String, } struct PersistEditorGreenScreenSourceResourceInput { project_id: Option, owner_user_id: String, folder_id: Option, label: String, width: u32, height: u32, prompt: String, actual_prompt: Option, model: String, task_id: String, source_resource_id: Option, generation_inputs: Option, } pub(crate) async fn persist_editor_generated_image( state: &AppState, owner_user_id: &str, task_id: &str, image: &DownloadedOpenAiImage, prompt: &str, actual_prompt: Option<&str>, asset_kind: &str, path_kind: &str, file_stem: &str, slot: &str, provider: &str, ) -> 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(path_kind, "generated-images"), sanitize_editor_storage_segment(task_id, "task"), ], file_stem: sanitize_editor_storage_segment(file_stem, "image"), image: GeneratedImageAssetDataUrl { format: normalize_generated_image_asset_mime(image.mime_type.as_str()), bytes: image.bytes.clone(), }, access: OssObjectAccess::Private, metadata: GeneratedImageAssetAdapterMetadata { asset_kind: Some(asset_kind.to_string()), owner_user_id: Some(owner_user_id.to_string()), entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()), entity_id: Some(task_id.to_string()), slot: Some(slot.to_string()), provider: Some(provider.to_string()), task_id: Some(task_id.to_string()), }, 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 http_client = reqwest::Client::new(); 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.clone(), }, ) .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; let now_micros = current_utc_micros(); let asset_object = state .spacetime_client() .confirm_asset_object( build_asset_object_upsert_input( generate_asset_object_id(now_micros), head.bucket, head.object_key.clone(), AssetObjectAccessPolicy::Private, head.content_type.or(Some(persisted_mime_type)), head.content_length, Some(actual_prompt.unwrap_or(prompt).to_string()), asset_kind.to_string(), Some(task_id.to_string()), Some(owner_user_id.to_string()), None, Some(task_id.to_string()), now_micros, ) .map_err(map_editor_asset_field_error)?, ) .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) })?; Ok(PersistedEditorGeneratedImage { object_key: head.object_key, asset_object_id: asset_object.asset_object_id, }) } async fn persist_editor_green_screen_source_image( state: &AppState, owner_user_id: &str, task_id: &str, image: &DownloadedOpenAiImage, prompt: &str, actual_prompt: Option<&str>, path_kind: &str, file_stem: &str, ) -> Result { persist_editor_generated_image( state, owner_user_id, task_id, image, prompt, actual_prompt, EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, path_kind, &format!("{file_stem}-green-screen-source"), EDITOR_GREEN_SCREEN_SOURCE_SLOT, "vector-engine", ) .await } async fn persist_editor_green_screen_source_resource( state: &AppState, persisted: PersistedEditorGeneratedImage, input: PersistEditorGreenScreenSourceResourceInput, ) -> Result { persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { project_id: input.project_id, owner_user_id: input.owner_user_id, folder_id: input.folder_id, label: input.label, image_src: editor_media_src_from_object_key(persisted.object_key.as_str()), object_key: Some(persisted.object_key), asset_object_id: Some(persisted.asset_object_id), width: input.width, height: input.height, prompt: input.prompt, actual_prompt: input.actual_prompt, model: input.model, provider: "VectorEngine".to_string(), task_id: input.task_id, source_resource_id: input.source_resource_id, asset_kind: Some(EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string()), generation_inputs: input.generation_inputs, thumbnail_src: None, generation_cost_mud_points: 0, }, ) .await } const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS: u64 = 300; const EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES: u64 = 32 * 1024 * 1024; const EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024; const EDITOR_BACKGROUND_REMOVAL_MAX_IMAGE_DIMENSION: u32 = 8192; pub(crate) async fn parse_editor_reference_image( state: &AppState, owner_user_id: &str, source: &str, ) -> Result { let trimmed = source.trim(); if trimmed.starts_with("data:") { return parse_editor_reference_image_data_url(trimmed); } let object_key = resolve_editor_reference_object_key(state, owner_user_id, trimmed).await?; ensure_editor_reference_object_key_owned(state, owner_user_id, object_key.as_str()).await?; read_editor_reference_image_object(state, object_key.as_str()).await } fn normalize_editor_reference_image_sources(sources: Option<&[String]>, limit: usize) -> Vec<&str> { sources .unwrap_or(&[]) .iter() .map(String::as_str) .map(str::trim) .filter(|source| !source.is_empty()) .take(limit) .collect() } fn normalize_editor_reference_object_key(source: &str) -> Result { let object_key = source.trim().trim_start_matches('/').to_string(); if object_key.is_empty() || LegacyAssetPrefix::from_object_key(object_key.as_str()).is_none() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-reference-image", "field": "sourceImageSrc", "message": "图片参考图必须是图片 Data URL、已登记的 generated objectKey、项目资源或素材引用。", })), ); } Ok(object_key) } async fn resolve_editor_reference_object_key( state: &AppState, owner_user_id: &str, source: &str, ) -> Result { match normalize_editor_reference_object_key(source) { Ok(object_key) => Ok(object_key), Err(error) => { find_editor_reference_object_key_by_registered_id(state, owner_user_id, source.trim()) .await? .ok_or(error) } } } async fn find_editor_reference_object_key_by_registered_id( state: &AppState, owner_user_id: &str, source: &str, ) -> Result, AppError> { if source.is_empty() { return Ok(None); } let projects = state .spacetime_client() .list_editor_projects(owner_user_id.to_string()) .await .map_err(map_editor_project_error)?; if let Some(object_key) = projects .iter() .flat_map(|project| project.resources.iter()) .find_map(|resource| editor_reference_resource_object_key(resource, source)) { return Ok(Some(object_key)); } let library = state .spacetime_client() .get_editor_asset_library(owner_user_id.to_string(), current_utc_micros()) .await .map_err(map_editor_project_error)?; Ok(library .assets .iter() .find_map(|asset| editor_reference_asset_object_key(asset, source))) } fn editor_reference_resource_object_key( resource: &EditorProjectResourceRecord, source: &str, ) -> Option { (resource.resource_id.trim() == source) .then(|| { normalize_editor_record_object_key( resource.object_key.as_deref(), resource.image_src.as_str(), ) }) .flatten() } fn editor_reference_asset_object_key(asset: &EditorAssetRecord, source: &str) -> Option { (asset.asset_id.trim() == source) .then(|| { normalize_editor_record_object_key( asset.object_key.as_deref(), asset.image_src.as_str(), ) }) .flatten() } fn normalize_editor_record_object_key(object_key: Option<&str>, image_src: &str) -> Option { object_key .map(str::trim) .map(|value| value.trim_start_matches('/')) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| normalize_editor_reference_object_key(image_src).ok()) } async fn ensure_editor_reference_object_key_owned( state: &AppState, owner_user_id: &str, object_key: &str, ) -> Result<(), AppError> { let owner_user_id = owner_user_id.trim(); if owner_user_id.is_empty() { return Err(editor_reference_image_forbidden(object_key)); } let projects = state .spacetime_client() .list_editor_projects(owner_user_id.to_string()) .await .map_err(map_editor_project_error)?; if projects.iter().any(|project| { project.resources.iter().any(|resource| { editor_record_object_key_matches( resource.object_key.as_deref(), resource.image_src.as_str(), object_key, ) }) }) { return Ok(()); } let library = state .spacetime_client() .get_editor_asset_library(owner_user_id.to_string(), current_utc_micros()) .await .map_err(map_editor_project_error)?; if library.assets.iter().any(|asset| { editor_record_object_key_matches( asset.object_key.as_deref(), asset.image_src.as_str(), object_key, ) }) { return Ok(()); } let Some(oss_client) = state.oss_client() else { return Err(editor_reference_image_forbidden(object_key)); }; if let Some(asset_object) = state .spacetime_client() .get_asset_object_by_location(module_assets::AssetObjectLocationInput { bucket: oss_client.config_bucket().to_string(), object_key: object_key.to_string(), }) .await .map_err(map_editor_project_error)? { validate_editor_reference_asset_object(&asset_object, owner_user_id, state, object_key)?; return Ok(()); } Err(editor_reference_image_forbidden(object_key)) } fn validate_editor_reference_asset_object( asset_object: &module_assets::AssetObjectRecord, owner_user_id: &str, state: &AppState, object_key: &str, ) -> Result<(), AppError> { let Some(oss_client) = state.oss_client() else { return Err(editor_reference_image_forbidden(object_key)); }; let owned = asset_object .owner_user_id .as_deref() .map(str::trim) .is_some_and(|value| value == owner_user_id); let bucket_matches = asset_object.bucket.trim() == oss_client.config_bucket().trim(); let key_matches = asset_object.object_key.trim().trim_start_matches('/') == object_key; if owned && bucket_matches && key_matches { Ok(()) } else { Err(editor_reference_image_forbidden(object_key)) } } fn editor_record_object_key_matches( record_object_key: Option<&str>, image_src: &str, object_key: &str, ) -> bool { normalize_editor_record_object_key(record_object_key, image_src) .is_some_and(|value| value == object_key) } fn editor_reference_image_forbidden(object_key: &str) -> AppError { AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ "provider": "editor-reference-image", "field": "sourceImageSrc", "message": "图片参考图不属于当前账号。", "objectKey": object_key, })) } fn parse_editor_reference_image_data_url(source: &str) -> Result { let Some((header, data)) = source.trim().split_once(',') else { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "field": "sourceImageSrc", "message": "修改图片参考图必须是图片 Data URL。", })), ); }; let Some(mime_type) = header .strip_prefix("data:") .and_then(|value| value.strip_suffix(";base64")) .filter(|value| value.starts_with("image/")) else { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "field": "sourceImageSrc", "message": "修改图片参考图必须是 base64 图片 Data URL。", })), ); }; let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "field": "sourceImageSrc", "message": format!("修改图片参考图解码失败:{error}"), })) })?; if bytes.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "field": "sourceImageSrc", "message": "修改图片参考图为空。", })), ); } let extension = match mime_type { "image/jpeg" => "jpg", "image/png" => "png", "image/webp" => "webp", _ => "png", }; Ok(OpenAiReferenceImage { bytes, mime_type: mime_type.to_string(), file_name: format!("editor-reference.{extension}"), }) } async fn read_editor_reference_image_object( state: &AppState, source: &str, ) -> Result { if source.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-reference-image", "field": "sourceImageSrc", "message": "图片参考图不能为空。", })), ); } let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { object_key: source.to_string(), expire_seconds: Some(EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS), }) .map_err(|error| map_oss_error(error, "aliyun-oss"))?; let response = reqwest::Client::new() .get(signed.signed_url.as_str()) .send() .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取图片参考图失败:{error}"), })) })?; let status = response.status(); if !status.is_success() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取图片参考图返回非成功状态:{}", status.as_u16()), "objectKey": signed.object_key, })), ); } if response .content_length() .is_some_and(|size| size > EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES) { return Err(editor_reference_image_too_large()); } let content_type_mime = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .and_then(normalize_editor_reference_image_mime_type) .map(ToOwned::to_owned); let bytes = response.bytes().await.map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取图片参考图内容失败:{error}"), })) })?; if bytes.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-reference-image", "field": "sourceImageSrc", "message": "图片参考图为空。", })), ); } if bytes.len() as u64 > EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES { return Err(editor_reference_image_too_large()); } let mime_type = content_type_mime .or_else(|| infer_editor_reference_image_mime_type(bytes.as_ref()).map(ToOwned::to_owned)) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-reference-image", "field": "sourceImageSrc", "message": "图片参考图 objectKey 指向的资源不是图片。", "objectKey": signed.object_key, })) })? .to_string(); let extension = editor_reference_image_extension(mime_type.as_str()); Ok(OpenAiReferenceImage { bytes: bytes.to_vec(), mime_type, file_name: format!("editor-reference.{extension}"), }) } fn normalize_editor_reference_image_mime_type(content_type: &str) -> Option<&str> { let mime_type = content_type.split(';').next()?.trim(); mime_type.starts_with("image/").then_some(mime_type) } fn infer_editor_reference_image_mime_type(bytes: &[u8]) -> Option<&'static str> { if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { return Some("image/png"); } if bytes.starts_with(&[0xff, 0xd8, 0xff]) { return Some("image/jpeg"); } if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" { return Some("image/webp"); } None } fn editor_reference_image_extension(mime_type: &str) -> &'static str { match mime_type { "image/jpeg" => "jpg", "image/png" => "png", "image/webp" => "webp", _ => "png", } } fn editor_reference_image_too_large() -> AppError { AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({ "provider": "editor-reference-image", "message": "图片参考图不能超过 32MB。", })) } pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError { match error { SpacetimeClientError::Procedure(message) if message.contains("无权") => { AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ "provider": "editor-project", "message": message, })) } SpacetimeClientError::Procedure(message) if message.contains("不存在") => { AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ "provider": "editor-project", "message": message, })) } SpacetimeClientError::Procedure(message) => AppError::from_status(StatusCode::BAD_REQUEST) .with_details(json!({ "provider": "editor-project", "message": message, })), SpacetimeClientError::Runtime(message) => AppError::from_status(StatusCode::BAD_REQUEST) .with_details(json!({ "provider": "editor-project", "message": message, })), other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": other.to_string(), })), } } pub(crate) fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", "message": error.to_string(), })) } pub(crate) fn current_utc_micros() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock should be after unix epoch"); i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } #[cfg(test)] mod tests { use super::*; use crate::{config::AppConfig, editor_green_screen::parse_editor_screen_background_color}; fn editor_project_resource_for_canvas_test( resource_id: &str, asset_kind: &str, width: u32, height: u32, ) -> EditorProjectResourcePayload { EditorProjectResourcePayload { resource_id: resource_id.to_string(), showcase_id: None, asset_id: None, label: None, project_id: "editor-project-1".to_string(), owner_user_id: "user-1".to_string(), author_display_name: None, author_public_user_code: None, image_src: format!("/generated-character-drafts/editor/{resource_id}.png"), object_key: Some(format!( "generated-character-drafts/editor/{resource_id}.png" )), asset_object_id: Some(format!("asset-object-{resource_id}")), width, height, source_type: "generated".to_string(), prompt: Some("银发游侠".to_string()), actual_prompt: Some("实际提示词".to_string()), model: Some("gpt-image-2".to_string()), provider: Some("VectorEngine".to_string()), task_id: Some("task-character".to_string()), source_resource_id: None, asset_kind: Some(asset_kind.to_string()), showcase_category: None, generation_inputs: None, public_showcase_enabled: true, review_status: None, display_enabled: None, like_count: None, generation_cost_mud_points: 0, refund_mud_points: None, created_at: "2026-07-13T00:00:00.000Z".to_string(), updated_at: "2026-07-13T00:00:00.000Z".to_string(), } } #[test] fn editor_project_procedure_errors_preserve_business_reason_as_bad_request() { let error = map_editor_project_error(SpacetimeClientError::Procedure( "只有生成素材可以提交精选审核".to_string(), )); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert_eq!( error.details().and_then(|details| details.get("message")), Some(&json!("只有生成素材可以提交精选审核")), ); } #[test] fn bgfilter_body_read_failure_audits_as_transport_not_5xx() { // HTTP 状态已成功、但读 body 时链路断裂:外部 API 失败审计必须归类为 transport, // 并保留 latencyMs / rawExcerpt,而不是被错记成 502/5xx。 let error = editor_image_removal_body_read_error( "bgfilter", "BgFilter", false, 137, "error reading a body from connection: connection reset", ); assert_eq!(error.status_code(), StatusCode::BAD_GATEWAY); assert_eq!( crate::external_api_audit::matting_failure_audit_status_code(&error), None, "body-read 传输故障不应带 HTTP statusCode,否则会落成 5xx" ); assert!(!crate::external_api_audit::matting_failure_audit_timeout(&error)); assert_eq!( crate::external_api_audit::matting_failure_audit_latency_ms(&error), Some(137) ); assert_eq!( crate::external_api_audit::matting_failure_audit_raw_excerpt(&error).as_deref(), Some("error reading a body from connection: connection reset") ); } #[test] fn bgfilter_body_read_timeout_maps_to_gateway_timeout_and_transport() { let error = editor_image_removal_body_read_error("bgfilter", "BgFilter", true, 42, "operation timed out"); assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); assert_eq!( crate::external_api_audit::matting_failure_audit_status_code(&error), None ); assert!(crate::external_api_audit::matting_failure_audit_timeout(&error)); assert_eq!( crate::external_api_audit::matting_failure_audit_latency_ms(&error), Some(42) ); } fn manual_screen_background_decision(hex: &str) -> EditorScreenBackgroundDecision { EditorScreenBackgroundDecision { color: parse_editor_screen_background_color(Some(hex)) .expect("hex should be a known background color candidate"), mode: crate::editor_screen_background_decision::EditorScreenBackgroundDecisionMode::Manual, attempts: 0, fallback: false, } } #[test] fn screen_background_decision_records_hex_without_touching_visible_fields() { let decision = manual_screen_background_decision("#B0C2E0"); // 用户可见的 fields 不被注入抠图背景色,只写内部 screenColorHex。 let updated = apply_editor_screen_background_decision_to_generation_inputs( Some(json!({ "fields": [{ "title": "角色设定", "value": "红发骑士" }], "references": [], })), Some(&decision), ) .expect("generation inputs should be preserved"); assert_eq!(updated["screenColorHex"], json!("#B0C2E0")); assert_eq!( updated["fields"], json!([{ "title": "角色设定", "value": "红发骑士" }]), ); // 无 generation_inputs 时创建骨架并写入内部元数据,fields 保持为空。 let created = apply_editor_screen_background_decision_to_generation_inputs(None, Some(&decision)) .expect("missing generation inputs should still record the decision"); assert_eq!( created, json!({ "fields": [], "references": [], "screenColorHex": "#B0C2E0", }), ); } #[test] fn screen_background_decision_leaves_existing_visible_fields_untouched() { let decision = manual_screen_background_decision("#CFEFFF"); // 即使旧数据的 fields 里残留了抠图背景色,后端也不修改用户可见字段。 let updated = apply_editor_screen_background_decision_to_generation_inputs( Some(json!({ "fields": [{ "title": "抠图背景色", "value": "旧值" }], "references": [], })), Some(&decision), ) .expect("generation inputs should be preserved"); assert_eq!( updated["fields"], json!([{ "title": "抠图背景色", "value": "旧值" }]), ); assert_eq!(updated["screenColorHex"], json!("#CFEFFF")); } fn apply_editor_canvas_generation_completion( layers: Value, completion: &EditorCanvasGenerationCompletionRequest, resource: &EditorProjectResourcePayload, ) -> Result { let placeholder = resolve_canvas_completion_placeholder(layers.clone(), completion)? .unwrap_or_else(|| completion.placeholder.clone()); let layer_id = generated_canvas_layer_id(resource.resource_id.as_str()); let item = build_generated_canvas_layer_item( completion, &placeholder, resource, layer_id.as_str(), 0, ); apply_editor_canvas_generation_items( layers, completion, placeholder, vec![item], Some(layer_id), ) } fn apply_editor_canvas_generation_multi_items( layers: Value, completion: &EditorCanvasGenerationCompletionRequest, items: Vec, primary_layer_id: Option, ) -> Result { let placeholder = resolve_canvas_completion_placeholder(layers.clone(), completion)? .unwrap_or_else(|| completion.placeholder.clone()); let items = translate_editor_canvas_generation_items(items, &completion.placeholder, &placeholder); apply_editor_canvas_generation_items( layers, completion, placeholder, items, primary_layer_id, ) } #[test] fn editor_project_id_prefix_uses_proj_for_new_ids() { assert_eq!(EDITOR_PROJECT_ID_PREFIX, "proj-"); } #[test] fn editor_record_media_src_prefers_object_key_over_legacy_inline_media() { assert_eq!( normalize_editor_record_media_src( "data:image/png;base64,too-large".to_string(), Some("/generated-character-drafts/editor/character.png") ), "/generated-character-drafts/editor/character.png" ); } #[test] fn editor_record_media_src_drops_legacy_inline_media_without_object_key() { assert_eq!( normalize_editor_record_media_src("data:image/png;base64,too-large".to_string(), None), "" ); assert_eq!( normalize_editor_record_media_src("blob:http://localhost/preview".to_string(), None), "" ); } fn test_editor_showcase_asset_record(owner_user_id: &str) -> EditorShowcaseAssetRecord { EditorShowcaseAssetRecord { showcase_id: "showcase-1".to_string(), asset_id: "asset-1".to_string(), owner_user_id: owner_user_id.to_string(), label: "精选素材".to_string(), asset_object_id: None, image_src: "/generated-character-drafts/editor/spec.png".to_string(), object_key: None, width: 512, height: 512, source_type: "generated".to_string(), prompt: None, actual_prompt: None, model: None, provider: None, task_id: None, source_resource_id: None, asset_kind: None, generation_inputs: None, thumbnail_src: None, generation_cost_mud_points: 10, refund_mud_points: 5, review_status: "approved".to_string(), display_enabled: true, like_count: 0, asset_deleted_while_pending: false, reviewed_by_admin_user_id: Some("admin-1".to_string()), review_note: None, refund_ledger_id: Some("editor-showcase-refund:showcase-1".to_string()), refund_completed_at: Some("2026-06-23T00:00:00.000Z".to_string()), submitted_at: "2026-06-23T00:00:00.000Z".to_string(), reviewed_at: Some("2026-06-23T00:00:00.000Z".to_string()), approved_at: Some("2026-06-23T00:00:00.000Z".to_string()), rejected_at: None, updated_at: "2026-06-23T00:00:00.000Z".to_string(), showcase_category: Some("characters".to_string()), } } #[test] fn editor_project_resource_payload_includes_owner_user_id() { let payload = editor_project_resource_payload_from_record(EditorProjectResourceRecord { resource_id: "resource-1".to_string(), project_id: "project-1".to_string(), owner_user_id: "user-author-1".to_string(), asset_object_id: None, image_src: "/generated-character-drafts/editor/spec.png".to_string(), object_key: None, width: 512, height: 512, source_type: "generated".to_string(), prompt: None, actual_prompt: None, model: None, provider: None, task_id: None, source_resource_id: None, asset_kind: None, generation_inputs: None, public_showcase_enabled: true, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), }); let value = serde_json::to_value(payload).expect("payload should serialize"); assert_eq!(value["ownerUserId"], json!("user-author-1")); } #[test] fn editor_project_resource_showcase_cursor_uses_microsecond_timestamp() { let record = EditorShowcaseAssetRecord { showcase_id: "resource-1".to_string(), approved_at: Some("1713686401.234567Z".to_string()), updated_at: "1713686401.234567Z".to_string(), ..test_editor_showcase_asset_record("user-author-1") }; assert_eq!( editor_project_resource_showcase_cursor(&record).as_deref(), Some("1713686401234567:resource-1") ); assert_eq!(parse_editor_timestamp_micros("bad-time"), None); } #[test] fn editor_project_resource_public_payload_uses_display_author_fields() { let state = AppState::new(AppConfig::default()).expect("state should build"); state .auth_user_service() .ensure_orphan_work_owner_user("user-author-1", "author-1", "作者昵称", "SY-00000042") .expect("author should be inserted"); let payload = editor_project_resource_public_payload_from_record( &state, test_editor_showcase_asset_record("user-author-1"), ); let value = serde_json::to_value(payload).expect("payload should serialize"); assert_eq!(value["ownerUserId"], json!("user-author-1")); assert_eq!(value["authorDisplayName"], json!("作者昵称")); assert_eq!(value["authorPublicUserCode"], json!("SY-00000042")); } #[test] fn editor_project_resource_public_payload_falls_back_to_public_user_code() { let state = AppState::new(AppConfig::default()).expect("state should build"); let payload = editor_project_resource_public_payload_from_record( &state, test_editor_showcase_asset_record("missing-user"), ); let value = serde_json::to_value(payload).expect("payload should serialize"); assert_eq!(value["ownerUserId"], json!("missing-user")); assert!(value.get("authorDisplayName").is_none()); assert_eq!(value["authorPublicUserCode"], json!("SY-00000000")); } #[test] fn editor_payload_media_sanitizer_replaces_inline_layer_src_from_resource() { let resources = vec![EditorProjectResourcePayload { resource_id: "resource-1".to_string(), showcase_id: None, asset_id: None, label: None, project_id: "project-1".to_string(), owner_user_id: "user-1".to_string(), author_display_name: None, author_public_user_code: None, image_src: "/generated-character-drafts/editor/spec.png".to_string(), object_key: Some("generated-character-drafts/editor/spec.png".to_string()), asset_object_id: Some("asset-object-1".to_string()), width: 512, height: 512, source_type: "generated".to_string(), prompt: None, actual_prompt: None, model: None, provider: None, task_id: None, source_resource_id: None, asset_kind: Some("spec".to_string()), showcase_category: None, generation_inputs: None, public_showcase_enabled: true, review_status: None, display_enabled: None, like_count: None, generation_cost_mud_points: 0, refund_mud_points: None, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), }]; let sanitized = sanitize_editor_payload_media( json!([ { "layerId": "layer-1", "resourceId": "resource-1", "src": "data:image/png;base64,layer", "thumbnailSrc": "data:image/png;base64,thumbnail", "imageSequenceFrames": [ { "frameIndex": 0, "imageSrc": "data:image/png;base64,frame" } ], "generationInputs": { "references": [ { "resourceId": "resource-1", "src": "data:image/png;base64,reference" } ] } } ]), resources.as_slice(), ); let payload = serde_json::to_string(&sanitized).expect("payload serializes"); assert!(!payload.contains("data:image")); assert_eq!( sanitized[0]["src"], json!("/generated-character-drafts/editor/spec.png") ); assert_eq!( sanitized[0]["thumbnailSrc"], json!("/generated-character-drafts/editor/spec.png") ); assert_eq!( sanitized[0]["imageSequenceFrames"][0]["imageSrc"], json!("/generated-character-drafts/editor/spec.png") ); assert_eq!( sanitized[0]["objectKey"], json!("generated-character-drafts/editor/spec.png") ); assert_eq!( sanitized[0]["generationInputs"]["references"][0]["src"], json!("/generated-character-drafts/editor/spec.png") ); } #[test] fn editor_canvas_generation_completion_saves_after_sanitizing_legacy_inline_layers() { let existing_resources = vec![EditorProjectResourcePayload { resource_id: "resource-spec".to_string(), showcase_id: None, asset_id: None, label: None, project_id: "project-1".to_string(), owner_user_id: "user-1".to_string(), author_display_name: None, author_public_user_code: None, image_src: "/generated-character-drafts/editor/spec.png".to_string(), object_key: Some("generated-character-drafts/editor/spec.png".to_string()), asset_object_id: Some("asset-object-spec".to_string()), width: 512, height: 512, source_type: "generated".to_string(), prompt: None, actual_prompt: None, model: None, provider: None, task_id: None, source_resource_id: None, asset_kind: Some("spec".to_string()), showcase_category: None, generation_inputs: None, public_showcase_enabled: true, review_status: None, display_enabled: None, like_count: None, generation_cost_mud_points: 0, refund_mud_points: None, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), }]; let dirty_layers = json!([ { "layerId": "layer-spec", "resourceId": "resource-spec", "title": "规范图", "src": "data:image/png;base64,old-spec", "thumbnailSrc": "data:image/png;base64,old-thumbnail" }, { "layerId": "generation-dialog:dialog-character", "resourceId": "generation-dialog:dialog-character", "itemType": "generation-dialog", "dialog": { "id": "dialog-character", "mode": "character", "status": "generating", "placeholder": { "x": 200, "y": 160, "width": 420, "height": 560, "originalWidth": 1024, "originalHeight": 1536 } } } ]); let layers = sanitize_editor_payload_media(dirty_layers, existing_resources.as_slice()); assert!( !serde_json::to_string(&layers) .unwrap() .contains("data:image") ); let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-character".to_string()), title: "角色形象 1".to_string(), placeholder: EditorCanvasGenerationPlaceholderPayload { x: 200.0, y: 160.0, width: 420.0, height: 560.0, original_width: 1024.0, original_height: 1536.0, }, }; let generated_resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), showcase_id: None, asset_id: None, label: None, project_id: "editor-project-1".to_string(), owner_user_id: "user-1".to_string(), author_display_name: None, author_public_user_code: None, image_src: "/generated-character-drafts/editor/character.png".to_string(), object_key: Some("generated-character-drafts/editor/character.png".to_string()), asset_object_id: Some("asset-object-character".to_string()), width: 1024, height: 1536, source_type: "generated".to_string(), prompt: Some("银发游侠".to_string()), actual_prompt: Some("实际提示词".to_string()), model: Some("gpt-image-2".to_string()), provider: Some("VectorEngine".to_string()), task_id: Some("task-character".to_string()), source_resource_id: None, asset_kind: Some("character".to_string()), showcase_category: None, generation_inputs: None, public_showcase_enabled: true, review_status: None, display_enabled: None, like_count: None, generation_cost_mud_points: 0, refund_mud_points: None, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), }; let completed = apply_editor_canvas_generation_completion(layers, &completion, &generated_resource) .expect("completion should apply to sanitized legacy layout"); assert!(completed.changed); serialize_editor_layers(completed.layers).expect("sanitized completion should save"); } #[test] fn editor_persisted_media_src_rejects_inline_media_without_object_key() { let rejected = normalize_editor_persisted_media_src( "data:image/png;base64,too-large".to_string(), None, ) .expect_err("inline image media should be rejected"); assert!(rejected.body_text().contains("objectKey")); assert!( normalize_editor_persisted_media_src("data:video/mp4;base64,AAAA".to_string(), None) .is_err() ); assert!( normalize_editor_persisted_media_src("data:audio/mp3;base64,AAAA".to_string(), None) .is_err() ); assert!( normalize_editor_persisted_media_src("blob:http://localhost/preview".to_string(), None) .is_err() ); assert_eq!( normalize_editor_persisted_media_src( "data:image/png;base64,legacy".to_string(), Some("generated-character-drafts/editor/spec.png") ) .expect("objectKey-backed legacy rows should normalize"), "/generated-character-drafts/editor/spec.png" ); } #[test] fn editor_layout_serialization_rejects_nested_inline_media() { let error = serialize_editor_layers(json!([ { "layerId": "layer-animation", "thumbnailSrc": "/generated-character-drafts/editor/frame-1.png", "imageSequenceFrames": [ { "frameIndex": 1, "imageSrc": "data:image/png;base64,frame" } ] } ])) .expect_err("layout should reject nested inline image data"); assert!(error.body_text().contains("图层布局")); assert!( serialize_editor_layers(json!([ { "layerId": "layer-animation", "thumbnailSrc": "/generated-character-drafts/editor/frame-1.png", "imageSequenceFrames": [ { "frameIndex": 1, "imageSrc": "/generated-character-drafts/editor/frame-1.png" } ], "previewVideoPath": "/generated-character-drafts/editor/preview.mp4" } ])) .is_ok() ); } #[test] fn editor_asset_metadata_serialization_rejects_nested_inline_media() { let error = serialize_editor_asset_metadata(Some(json!({ "references": [ { "title": "参考图", "src": "data:image/png;base64,reference" } ] }))) .expect_err("metadata should reject nested inline image data"); assert!(error.body_text().contains("资产元数据")); assert!( serialize_editor_asset_metadata(Some(json!({ "references": [ { "title": "参考图", "src": "/generated-character-drafts/editor/reference.png" } ] }))) .is_ok() ); } #[test] fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() { assert_eq!(normalize_editor_image_generation_size(None), "1024x1024"); assert_eq!( normalize_editor_image_generation_size(Some("1536x1024")), "1536x1024" ); assert_eq!( normalize_editor_image_generation_size(Some("1024x1536")), "1024x1536" ); assert_eq!( normalize_editor_image_generation_size(Some("2048x1152")), "2048x1152" ); assert_eq!( normalize_editor_image_generation_size(Some("640x640")), "640x640" ); assert_eq!( normalize_editor_image_generation_size(Some("bad-size")), "1024x1024" ); } #[tokio::test] async fn editor_image_edit_price_uses_selected_model_and_size() { let state = AppState::new(AppConfig::default()).expect("state should build"); assert_eq!( resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "1024x1024") .await .unwrap(), 3 ); assert_eq!( resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "2048x1152") .await .unwrap(), 5 ); assert_eq!( resolve_editor_image_edit_price(&state, EDITOR_IMAGE_MODEL_NANOBANANA2, "2048x2048") .await .unwrap(), 24 ); } #[test] fn editor_image_edit_aligns_provider_images_and_restores_source_dimensions() { fn encode_png(width: u32, height: u32) -> Vec { let image = image::DynamicImage::new_rgba8(width, height); let mut bytes = Cursor::new(Vec::new()); image .write_to(&mut bytes, image::ImageFormat::Png) .expect("test image should encode"); bytes.into_inner() } let mut references = vec![ OpenAiReferenceImage { bytes: encode_png(1537, 1025), mime_type: "image/png".to_string(), file_name: "source.png".to_string(), }, OpenAiReferenceImage { bytes: encode_png(501, 333), mime_type: "image/png".to_string(), file_name: "style.png".to_string(), }, ]; let dimensions = prepare_editor_image_edit_references(&mut references, "1537x1025") .expect("references should align for provider"); assert_eq!(dimensions.target_width, 1537); assert_eq!(dimensions.target_height, 1025); assert_eq!(dimensions.provider_width, 1552); assert_eq!(dimensions.provider_height, 1040); let source = image::load_from_memory(references[0].bytes.as_slice()).unwrap(); assert_eq!((source.width(), source.height()), (1552, 1040)); let extra = image::load_from_memory(references[1].bytes.as_slice()).unwrap(); assert_eq!((extra.width(), extra.height()), (512, 336)); let restored = restore_editor_image_edit_output_dimensions( DownloadedOpenAiImage { bytes: encode_png(1552, 1040), mime_type: "image/png".to_string(), extension: "png".to_string(), }, &dimensions, ) .expect("provider output should restore source dimensions"); let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap(); assert_eq!( (restored_image.width(), restored_image.height()), (1537, 1025) ); assert_eq!(restored.mime_type, "image/png"); assert_eq!(restored.extension, "png"); } #[test] fn editor_generation_dimensions_follow_model_options() { let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K")); assert_eq!(default_generation.model, GPT_IMAGE_2_MODEL); assert_eq!(default_generation.size, "1024x1024"); let nanobanana = normalize_editor_generation_options( Some(EDITOR_IMAGE_MODEL_NANOBANANA2), Some("1:1"), Some("0.5K"), ); assert_eq!(nanobanana.model, EDITOR_IMAGE_MODEL_NANOBANANA2); assert_eq!(nanobanana.size, "512"); assert_eq!(nanobanana.aspect_ratio, "1:1"); assert_eq!(nanobanana.image_size, "0.5K"); assert_eq!(nanobanana.provider_image_size, "512"); let nanobanana_alias = normalize_editor_generation_options(Some("nanobanana2"), Some("1:1"), Some("0.5K")); assert_eq!(nanobanana_alias.model, EDITOR_IMAGE_MODEL_NANOBANANA2); assert_eq!(nanobanana_alias.size, "512"); assert_eq!(nanobanana_alias.provider_image_size, "512"); let legacy_nanobanana_alias = normalize_editor_generation_options(Some("nano-banana"), Some("16:9"), Some("2K")); assert_eq!( legacy_nanobanana_alias.model, EDITOR_IMAGE_MODEL_NANOBANANA2 ); assert_eq!(legacy_nanobanana_alias.size, "2048"); assert_eq!(legacy_nanobanana_alias.aspect_ratio, "16:9"); let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K")); assert_eq!(gpt.model, GPT_IMAGE_2_MODEL); assert_eq!(gpt.size, "1024x1536"); assert_eq!(gpt.aspect_ratio, "2:3"); assert_eq!(gpt.image_size, "1K"); assert_eq!(gpt.provider_image_size, "1K"); let nanobanana_cover = normalize_editor_generation_options(Some("nanobanana2"), Some("4:3"), Some("1K")); assert_eq!(nanobanana_cover.model, EDITOR_IMAGE_MODEL_NANOBANANA2); assert_eq!(nanobanana_cover.size, "1024"); assert_eq!(nanobanana_cover.aspect_ratio, "4:3"); let gpt_cover = normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K")); assert_eq!(gpt_cover.model, GPT_IMAGE_2_MODEL); assert_eq!(gpt_cover.size, "1536x1024"); assert_eq!(gpt_cover.aspect_ratio, "4:3"); let gpt_landscape_2k = normalize_editor_generation_options(Some("gpt-image-2"), Some("16:9"), Some("2K")); assert_eq!(gpt_landscape_2k.model, GPT_IMAGE_2_MODEL); assert_eq!(gpt_landscape_2k.size, "2048x1152"); assert_eq!(gpt_landscape_2k.aspect_ratio, "16:9"); assert_eq!(gpt_landscape_2k.image_size, "2K"); let gpt_portrait_2k_fallback = normalize_editor_generation_options(Some("gpt-image-2"), Some("9:16"), Some("2K")); assert_eq!(gpt_portrait_2k_fallback.model, GPT_IMAGE_2_MODEL); assert_eq!(gpt_portrait_2k_fallback.size, "1024x1536"); assert_eq!(gpt_portrait_2k_fallback.aspect_ratio, "9:16"); assert_eq!(gpt_portrait_2k_fallback.image_size, "2K"); let fallback = normalize_editor_generation_options( Some("unknown-model"), Some("bad-ratio"), Some("bad-size"), ); assert_eq!(fallback.model, EDITOR_IMAGE_MODEL_NANOBANANA2); assert_eq!(fallback.size, "1024"); assert_eq!(fallback.aspect_ratio, "1:1"); assert_eq!(fallback.image_size, "1K"); assert_eq!(fallback.provider_image_size, "1K"); } #[test] fn publication_material_request_size_keeps_workflow_pixels() { let options = normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K")); assert_eq!( resolve_editor_image_request_size( Some("publication-material"), Some("720x540"), true, &options, ), "720x540" ); assert_eq!( resolve_editor_image_request_size(Some("generate"), Some("720x540"), true, &options), "1536x1024" ); } #[test] fn publication_material_generation_is_locked_to_gpt_image_2() { let source = include_str!("editor_project.rs"); assert_function_contains( source, "pub(crate) async fn generate_editor_image_for_owner", "let configured_price_mud_points", &[ "is_publication_material_generation", "Some(GPT_IMAGE_2_MODEL)", ], ); } #[test] fn editor_ui_design_prompt_uses_fixed_user_input_block_and_optional_icon_spec() { let prompt = build_editor_ui_design_prompt("二消玩法,主界面和结算弹窗", true); assert!(prompt.contains("生成玩法UI原型图")); assert!(prompt.contains("【用户输入】二消玩法,主界面和结算弹窗")); assert!(prompt.contains("参考图1为图标素材规范")); let no_reference_prompt = build_editor_ui_design_prompt("只要战斗HUD", false); assert!(!no_reference_prompt.contains("参考图1为图标素材规范")); } #[test] fn editor_character_image_prompt_appends_user_role_setting() { let prompt = build_editor_character_image_prompt( "菜市场卖菜大妈", parse_editor_screen_background_color(Some("#FFD6C2")) .expect("screen color should parse"), ); assert!(prompt.contains("严格基于图1的角色美术视觉规范")); assert!(prompt.contains("生成游戏角色立绘")); assert!(prompt.contains("背景固定为单一纯色背景 暖浅桃色 #FFD6C2 / RGB(255,214,194)")); assert!(prompt.contains("纯色背景必须平整无纹理、无渐变、无阴影")); assert!( prompt.contains("角色主体及其服饰、道具的颜色必须与背景色明显区分,不得带与背景色相同或相近的描边、投影或反光") ); assert!(prompt.contains("禁止镜头透视")); assert!(prompt.contains("角色设定:菜市场卖菜大妈")); } #[test] fn editor_canvas_generation_completion_inserts_result_layer_and_keeps_composer_closed() { let layers = json!([ { "layerId": "layer-existing", "resourceId": "resource-existing", "zIndex": 4 }, { "itemType": "generation-dialog", "layerId": "generation-dialog:dialog-character", "resourceId": "generation-dialog:dialog-character", "dialog": { "id": "dialog-character", "mode": "character", "prompt": "银发游侠", "status": "generating", "composerOpen": false, "errorMessage": "旧错误", "placeholder": { "x": 200, "y": 160, "width": 420, "height": 560, "originalWidth": 1024, "originalHeight": 1536 } } } ]); let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-character".to_string()), title: "角色形象 1".to_string(), placeholder: EditorCanvasGenerationPlaceholderPayload { x: 200.0, y: 160.0, width: 420.0, height: 560.0, original_width: 1024.0, original_height: 1536.0, }, }; let resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), showcase_id: None, asset_id: None, label: None, project_id: "editor-project-1".to_string(), owner_user_id: "user-1".to_string(), author_display_name: None, author_public_user_code: None, image_src: "data:image/png;base64,character".to_string(), object_key: Some("generated-character-drafts/editor/character.png".to_string()), asset_object_id: Some("asset-object-1".to_string()), width: 1024, height: 1536, source_type: "generated".to_string(), prompt: Some("银发游侠".to_string()), actual_prompt: Some("实际提示词".to_string()), model: Some("gpt-image-2".to_string()), provider: Some("VectorEngine".to_string()), task_id: Some("task-character".to_string()), source_resource_id: Some("resource-spec".to_string()), asset_kind: Some("character".to_string()), showcase_category: None, generation_inputs: Some(json!({ "fields": [{"title": "角色设定", "value": "银发游侠"}], "references": [] })), public_showcase_enabled: true, review_status: None, display_enabled: None, like_count: None, generation_cost_mud_points: 0, refund_mud_points: None, created_at: "2026-06-21T12:16:22.000Z".to_string(), updated_at: "2026-06-21T12:16:22.000Z".to_string(), }; let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource) .expect("completion should apply"); assert!(completed.changed); let items = completed .layers .as_array() .expect("layers should stay array"); let result_layer = items .iter() .find(|item| item["resourceId"] == "editor-resource-character") .expect("result layer should be inserted"); assert_eq!( result_layer["layerId"], json!("layer-editor-resource-editor-resource-character") ); assert_eq!(result_layer["title"], json!("角色形象 1")); assert_eq!(result_layer["x"], json!(-102.0)); assert_eq!(result_layer["y"], json!(-328.0)); assert_eq!(result_layer["originalWidth"], json!(1024.0)); assert_eq!(result_layer["originalHeight"], json!(1536.0)); assert_eq!(result_layer["zIndex"], json!(5)); assert_eq!(result_layer["mediaType"], json!("image")); assert_eq!( result_layer["src"], json!("data:image/png;base64,character") ); let dialog = items .iter() .find(|item| item["itemType"] == "generation-dialog") .and_then(|item| item.get("dialog")) .expect("dialog should remain"); assert_eq!(dialog["status"], json!("idle")); assert_eq!(dialog["composerOpen"], json!(false)); assert_eq!( dialog["generatedLayerId"], json!("layer-editor-resource-editor-resource-character") ); assert!(dialog.get("errorMessage").is_none()); } #[test] fn editor_canvas_background_removal_replaces_target_layer_resource() { let layers = json!([ { "layerId": "layer-source", "resourceId": "resource-source", "title": "源图", "x": 120, "y": 140, "width": 320, "height": 240, "originalWidth": 1024, "originalHeight": 768, "zIndex": 6, "locked": true, "sourceAssetId": "asset-old", "thumbnailSrc": "/old-thumb.png" }, { "layerId": "layer-other", "resourceId": "resource-other" } ]); let resource = EditorProjectResourcePayload { resource_id: "editor-resource-cutout".to_string(), showcase_id: None, asset_id: None, label: None, project_id: "editor-project-1".to_string(), owner_user_id: "user-1".to_string(), author_display_name: None, author_public_user_code: None, image_src: "/generated-character-drafts/editor/background-removal/result.png" .to_string(), object_key: Some( "generated-character-drafts/editor/background-removal/result.png".to_string(), ), asset_object_id: Some("asset-object-cutout".to_string()), width: 512, height: 768, source_type: "generated".to_string(), prompt: Some("remove background".to_string()), actual_prompt: None, model: Some("BiRefNet".to_string()), provider: Some("BiRefNet".to_string()), task_id: Some("extgen-cutout".to_string()), source_resource_id: Some("resource-source".to_string()), asset_kind: Some("character".to_string()), showcase_category: None, generation_inputs: Some(json!({ "fields": [{"title": "prompt", "value": "角色"}], "references": [] })), public_showcase_enabled: true, review_status: None, display_enabled: None, like_count: None, generation_cost_mud_points: 0, refund_mud_points: None, created_at: "2026-06-29T00:00:00.000Z".to_string(), updated_at: "2026-06-29T00:00:00.000Z".to_string(), }; let replaced = replace_editor_canvas_layer_with_resource( layers.clone(), "layer-source", &resource, false, ) .expect("target layer should be replaced"); let items = replaced.as_array().expect("layers should stay array"); let layer = items .iter() .find(|item| item["layerId"] == "layer-source") .expect("source layer should remain"); assert_eq!(items.len(), 2); assert_eq!(layer["resourceId"], json!("editor-resource-cutout")); assert_eq!( layer["src"], json!("/generated-character-drafts/editor/background-removal/result.png") ); assert_eq!(layer["width"], json!(512)); assert_eq!(layer["height"], json!(768)); assert_eq!(layer["x"], json!(120)); assert_eq!(layer["y"], json!(140)); assert_eq!(layer["zIndex"], json!(6)); assert_eq!(layer["locked"], json!(true)); assert_eq!(layer["taskId"], json!("extgen-cutout")); assert_eq!(layer["sourceResourceId"], json!("resource-source")); assert_eq!(layer["assetKind"], json!("character")); assert!(layer.get("sourceAssetId").is_none()); assert!(layer.get("thumbnailSrc").is_none()); let recentered = replace_editor_canvas_layer_with_resource(layers, "layer-source", &resource, true) .expect("quick edit replacement should recenter the layer"); let recentered_layer = recentered .as_array() .and_then(|items| items.iter().find(|item| item["layerId"] == "layer-source")) .expect("quick edit source layer should remain"); assert_eq!(recentered_layer["x"], json!(24.0)); assert_eq!(recentered_layer["y"], json!(-124.0)); assert_eq!(recentered_layer["x"].as_f64().unwrap() + 512.0 / 2.0, 280.0); assert_eq!(recentered_layer["y"].as_f64().unwrap() + 768.0 / 2.0, 260.0); } #[test] fn editor_canvas_generation_completion_is_idempotent_for_existing_resource_layer() { let layers = json!([ { "layerId": "layer-existing-result", "resourceId": "editor-resource-character", "zIndex": 7 }, { "itemType": "generation-dialog", "layerId": "generation-dialog:dialog-character", "resourceId": "generation-dialog:dialog-character", "dialog": { "id": "dialog-character", "mode": "character", "prompt": "银发游侠", "status": "generating", "composerOpen": false } } ]); let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-character".to_string()), title: "角色形象 1".to_string(), placeholder: EditorCanvasGenerationPlaceholderPayload { x: 0.0, y: 0.0, width: 1.0, height: 1.0, original_width: 1.0, original_height: 1.0, }, }; let resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), showcase_id: None, asset_id: None, label: None, project_id: "editor-project-1".to_string(), owner_user_id: "user-1".to_string(), author_display_name: None, author_public_user_code: None, image_src: "data:image/png;base64,character".to_string(), object_key: None, asset_object_id: None, width: 1024, height: 1024, source_type: "generated".to_string(), prompt: Some("银发游侠".to_string()), actual_prompt: None, model: Some("gpt-image-2".to_string()), provider: Some("VectorEngine".to_string()), task_id: Some("task-character".to_string()), source_resource_id: None, asset_kind: Some("character".to_string()), showcase_category: None, generation_inputs: None, public_showcase_enabled: true, review_status: None, display_enabled: None, like_count: None, generation_cost_mud_points: 0, refund_mud_points: None, created_at: "2026-06-21T12:16:22.000Z".to_string(), updated_at: "2026-06-21T12:16:22.000Z".to_string(), }; let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource) .expect("completion should apply"); assert!(completed.changed); let items = completed .layers .as_array() .expect("layers should stay array"); assert_eq!( items .iter() .filter(|item| item["resourceId"] == "editor-resource-character") .count(), 1 ); let dialog = items .iter() .find(|item| item["itemType"] == "generation-dialog") .and_then(|item| item.get("dialog")) .expect("dialog should remain"); assert_eq!(dialog["generatedLayerId"], json!("layer-existing-result")); } #[test] fn editor_canvas_generation_completion_uses_latest_layout_placeholder() { let layers = json!([ { "itemType": "generation-dialog", "layerId": "generation-dialog:dialog-character", "resourceId": "generation-dialog:dialog-character", "dialog": { "id": "dialog-character", "mode": "character", "prompt": "银发游侠", "status": "generating", "composerOpen": false, "placeholder": { "x": 500, "y": 400, "width": 420, "height": 560, "originalWidth": 1024, "originalHeight": 1536 } } } ]); let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-character".to_string()), title: "角色形象 1".to_string(), placeholder: EditorCanvasGenerationPlaceholderPayload { x: 0.0, y: 0.0, width: 1.0, height: 1.0, original_width: 1.0, original_height: 1.0, }, }; let resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), showcase_id: None, asset_id: None, label: None, project_id: "editor-project-1".to_string(), owner_user_id: "user-1".to_string(), author_display_name: None, author_public_user_code: None, image_src: "data:image/png;base64,character".to_string(), object_key: None, asset_object_id: None, width: 1024, height: 1536, source_type: "generated".to_string(), prompt: Some("银发游侠".to_string()), actual_prompt: None, model: Some("gpt-image-2".to_string()), provider: Some("VectorEngine".to_string()), task_id: Some("task-character".to_string()), source_resource_id: None, asset_kind: Some("character".to_string()), showcase_category: None, generation_inputs: None, public_showcase_enabled: true, review_status: None, display_enabled: None, like_count: None, generation_cost_mud_points: 0, refund_mud_points: None, created_at: "2026-06-21T12:16:22.000Z".to_string(), updated_at: "2026-06-21T12:16:22.000Z".to_string(), }; let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource) .expect("completion should apply"); let result_layer = completed .layers .as_array() .expect("layers should stay array") .iter() .find(|item| item["resourceId"] == "editor-resource-character") .expect("result layer should be inserted"); assert_eq!(result_layer["x"], json!(198.0)); assert_eq!(result_layer["y"], json!(-88.0)); } #[test] fn editor_canvas_generation_multi_item_completion_uses_latest_layout_placeholder() { let layers = json!([ { "itemType": "generation-dialog", "layerId": "generation-dialog:dialog-icons", "resourceId": "generation-dialog:dialog-icons", "dialog": { "id": "dialog-icons", "mode": "icon", "prompt": "返回按钮", "status": "generating", "composerOpen": false, "placeholder": { "x": 300, "y": 240, "width": 200, "height": 200, "originalWidth": 200, "originalHeight": 200 } } } ]); let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-icons".to_string()), title: "图标素材图集".to_string(), placeholder: EditorCanvasGenerationPlaceholderPayload { x: 100.0, y: 80.0, width: 200.0, height: 200.0, original_width: 200.0, original_height: 200.0, }, }; let item = json!({ "layerId": "layer-icon-sheet", "resourceId": "resource-icon-sheet", "title": "图标素材图集", "x": 150.0, "y": 130.0, "width": 100.0, "height": 100.0 }); let completed = apply_editor_canvas_generation_multi_items( layers, &completion, vec![item], Some("layer-icon-sheet".to_string()), ) .expect("multi-item completion should apply"); let result_layer = completed .layers .as_array() .expect("layers should stay array") .iter() .find(|item| item["resourceId"] == "resource-icon-sheet") .expect("result layer should be inserted"); assert_eq!(result_layer["x"], json!(350.0)); assert_eq!(result_layer["y"], json!(290.0)); } #[test] fn editor_canvas_multi_asset_items_keep_source_and_anchor_processed_result() { let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-character".to_string()), title: "角色形象 1".to_string(), placeholder: EditorCanvasGenerationPlaceholderPayload { x: 100.0, y: 80.0, width: 200.0, height: 300.0, original_width: 400.0, original_height: 600.0, }, }; let processed = editor_project_resource_for_canvas_test( "resource-character-cutout", "character", 400, 600, ); let source = editor_project_resource_for_canvas_test( "resource-character-source", EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, 400, 600, ); let (items, primary_layer_id) = build_multi_asset_canvas_layer_items(&completion, Some(&processed), Some(&source)); assert_eq!(items.len(), 2); assert_eq!( primary_layer_id.as_deref(), Some("layer-editor-resource-resource-character-cutout") ); assert_eq!(items[0]["resourceId"], json!("resource-character-cutout")); assert_eq!(items[0]["x"], json!(0.0)); assert_eq!(items[0]["y"], json!(-70.0)); assert_eq!(items[1]["resourceId"], json!("resource-character-source")); assert_eq!(items[1]["title"], json!("角色形象 1(原图)")); assert_eq!(items[1]["x"], json!(432.0)); assert_eq!(items[1]["y"], json!(-70.0)); let layers = json!([{ "itemType": "generation-dialog", "layerId": "generation-dialog:dialog-character", "resourceId": "generation-dialog:dialog-character", "dialog": { "id": "dialog-character", "status": "generating", "placeholder": completion.placeholder.clone() } }]); let completed = apply_editor_canvas_generation_multi_items( layers, &completion, items, primary_layer_id, ) .expect("multi-asset completion should apply"); let completed_items = completed .layers .as_array() .expect("layers should stay array"); assert_eq!( completed_items .iter() .filter(|item| item .get("resourceId") .and_then(Value::as_str) .is_some_and(|id| id.starts_with("resource-character-"))) .count(), 2 ); let dialog = completed_items .iter() .find(|item| item["itemType"] == "generation-dialog") .and_then(|item| item.get("dialog")) .expect("generation dialog should remain"); assert_eq!( dialog["generatedLayerId"], json!("layer-editor-resource-resource-character-cutout") ); } #[test] fn editor_canvas_spritesheet_items_include_source_sheet_and_slices() { let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-ui-assets".to_string()), title: "UI素材图集".to_string(), placeholder: EditorCanvasGenerationPlaceholderPayload { x: 100.0, y: 80.0, width: 256.0, height: 256.0, original_width: 512.0, original_height: 512.0, }, }; let processed = editor_project_resource_for_canvas_test( "resource-ui-sheet", "icon-spritesheet", 512, 512, ); let source = editor_project_resource_for_canvas_test( "resource-ui-source", EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, 512, 512, ); let slice = editor_project_resource_for_canvas_test("resource-ui-slice", "icon", 128, 128); let icons = vec![EditorIconSpritesheetIconResponse { name: "开始按钮".to_string(), image_src: slice.image_src.clone(), width: 128, height: 128, resource: Some(slice), asset: None, }]; let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items( &completion, Some(&processed), Some(&source), icons.as_slice(), ); assert_eq!(items.len(), 3); assert_eq!( primary_layer_id.as_deref(), Some("layer-editor-resource-resource-ui-sheet") ); assert_eq!(items[0]["resourceId"], json!("resource-ui-sheet")); assert_eq!(items[1]["resourceId"], json!("resource-ui-source")); assert_eq!(items[2]["resourceId"], json!("resource-ui-slice")); let source_right = items[1]["x"].as_f64().unwrap() + items[1]["width"].as_f64().unwrap(); assert!(items[2]["x"].as_f64().unwrap() > source_right); } #[test] fn editor_canvas_spritesheet_items_keep_sheet_and_source_when_slices_are_empty() { let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-icon-sheet".to_string()), title: "图标素材图集".to_string(), placeholder: EditorCanvasGenerationPlaceholderPayload { x: 100.0, y: 80.0, width: 256.0, height: 256.0, original_width: 512.0, original_height: 512.0, }, }; let processed = editor_project_resource_for_canvas_test( "resource-icon-sheet", "icon-spritesheet", 512, 512, ); let source = editor_project_resource_for_canvas_test( "resource-icon-source", EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, 512, 512, ); let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items( &completion, Some(&processed), Some(&source), &[], ); assert_eq!(items.len(), 2); assert_eq!(items[0]["resourceId"], json!("resource-icon-sheet")); assert_eq!(items[1]["resourceId"], json!("resource-icon-source")); assert_eq!( primary_layer_id.as_deref(), Some("layer-editor-resource-resource-icon-sheet") ); } #[test] fn manual_icon_spritesheet_limits_reject_large_images_and_excessive_slices() { assert!(validate_editor_icon_spritesheet_manual_dimensions(2048, 2048).is_ok()); assert!(validate_editor_icon_spritesheet_manual_dimensions(4096, 1024).is_ok()); assert!(validate_editor_icon_spritesheet_manual_dimensions(2049, 2048).is_err()); assert!(validate_editor_icon_spritesheet_manual_dimensions(4097, 1).is_err()); assert!(validate_editor_icon_spritesheet_manual_slice_count(64).is_ok()); assert!(validate_editor_icon_spritesheet_manual_slice_count(65).is_err()); } #[test] fn editor_canvas_generation_completion_skips_when_dialog_was_removed() { let layers = json!([ { "layerId": "layer-existing", "resourceId": "resource-existing", "zIndex": 4 } ]); let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-character".to_string()), title: "角色形象 1".to_string(), placeholder: EditorCanvasGenerationPlaceholderPayload { x: 200.0, y: 160.0, width: 420.0, height: 560.0, original_width: 1024.0, original_height: 1536.0, }, }; let resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), showcase_id: None, asset_id: None, label: None, project_id: "editor-project-1".to_string(), owner_user_id: "user-1".to_string(), author_display_name: None, author_public_user_code: None, image_src: "data:image/png;base64,character".to_string(), object_key: None, asset_object_id: None, width: 1024, height: 1536, source_type: "generated".to_string(), prompt: Some("银发游侠".to_string()), actual_prompt: None, model: Some("gpt-image-2".to_string()), provider: Some("VectorEngine".to_string()), task_id: Some("task-character".to_string()), source_resource_id: None, asset_kind: Some("character".to_string()), showcase_category: None, generation_inputs: None, public_showcase_enabled: true, review_status: None, display_enabled: None, like_count: None, generation_cost_mud_points: 0, refund_mud_points: None, created_at: "2026-06-21T12:16:22.000Z".to_string(), updated_at: "2026-06-21T12:16:22.000Z".to_string(), }; let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource) .expect("completion should be ignored"); assert!(!completed.changed); assert_eq!( completed.layers, json!([ { "layerId": "layer-existing", "resourceId": "resource-existing", "zIndex": 4 } ]) ); } #[test] fn editor_icon_spritesheet_prompt_uses_ordered_descriptions_and_size_tiers() { let descriptions = vec![ "返回按钮".to_string(), "设置按钮".to_string(), "下一关按钮".to_string(), ]; let prompt = build_editor_icon_spritesheet_prompt( &descriptions, parse_editor_screen_background_color(Some("#7FB3FF")) .expect("screen color should parse"), ); assert!(prompt.contains("参考图1的图标素材规范")); assert!(prompt.contains("背景必须是单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255)")); assert!(prompt.contains("平整无纹理、无渐变、无阴影")); assert!( prompt.contains("素材主体及其配色必须与背景色明显区分,不要出现与背景色相同或相近的描边、底板、投影或反光") ); assert!(prompt.contains("返回按钮、设置按钮、下一关按钮")); } #[test] fn editor_icon_spritesheet_prompt_accepts_new_background_colors() { let prompt = build_editor_icon_spritesheet_prompt( &["发光能量按钮".to_string()], parse_editor_screen_background_color(Some("#A8F7F0")) .expect("new screen color should parse"), ); assert!(prompt.contains("背景必须是单一纯色背景 高对比浅青 #A8F7F0 / RGB(168,247,240)")); } #[test] fn editor_ui_design_asset_extraction_prompt_is_fixed() { assert_eq!( editor_ui_design_asset_extraction_prompt( parse_editor_screen_background_color(None) .expect("default screen color should parse") ), "仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 浅雾蓝 #CFEFFF / RGB(207,239,255)。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材主体及其配色必须与背景色明显区分,不要出现与背景色相同或相近的描边、底板、投影或反光。" ); } #[test] fn editor_icon_description_validation_filters_empty_and_rejects_more_than_limit() { let descriptions = normalize_icon_descriptions(vec![ " 返回按钮 ".to_string(), " ".to_string(), "设置按钮".to_string(), ]) .expect("valid descriptions should pass"); assert_eq!(descriptions, vec!["返回按钮", "设置按钮"]); let too_many = (0..101) .map(|index| format!("图标{index}")) .collect::>(); assert!(normalize_icon_descriptions(too_many).is_err()); } #[test] fn editor_reference_image_data_url_stays_compatible() { let parsed = parse_editor_reference_image_data_url("data:image/png;base64,AAAA") .expect("legacy data URL reference should still parse"); assert_eq!(parsed.mime_type, "image/png"); assert_eq!(parsed.file_name, "editor-reference.png"); assert_eq!(parsed.bytes, vec![0, 0, 0]); } #[test] fn editor_reference_resource_id_resolves_registered_object_key() { let resource = EditorProjectResourceRecord { resource_id: "editor-resource-icon-spec".to_string(), project_id: "project-1".to_string(), owner_user_id: "user-1".to_string(), asset_object_id: Some("asset-object-1".to_string()), image_src: "/generated-character-drafts/editor/spec.png".to_string(), object_key: None, width: 512, height: 512, source_type: "generated".to_string(), prompt: None, actual_prompt: None, model: None, provider: None, task_id: None, source_resource_id: None, asset_kind: Some("icon-spec".to_string()), generation_inputs: None, public_showcase_enabled: true, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), }; assert_eq!( editor_reference_resource_object_key(&resource, "editor-resource-icon-spec"), Some("generated-character-drafts/editor/spec.png".to_string()) ); assert_eq!( editor_reference_resource_object_key(&resource, "missing"), None ); } #[test] fn editor_reference_asset_id_resolves_registered_object_key() { let asset = EditorAssetRecord { asset_id: "editor-asset-icon-spec".to_string(), folder_id: "project".to_string(), label: "图标规范".to_string(), asset_object_id: Some("asset-object-1".to_string()), image_src: "/generated-character-drafts/editor/spec.png".to_string(), thumbnail_src: None, object_key: None, width: 512, height: 512, source_type: "generated".to_string(), prompt: None, actual_prompt: None, model: None, provider: None, task_id: None, asset_kind: Some("icon-spec".to_string()), generation_inputs: None, source_resource_id: None, public_showcase_enabled: Some(true), generation_cost_mud_points: 0, showcase_id: None, showcase_review_status: None, showcase_display_enabled: None, showcase_like_count: None, created_at: "2026-06-23T00:00:00.000Z".to_string(), updated_at: "2026-06-23T00:00:00.000Z".to_string(), }; assert_eq!( editor_reference_asset_object_key(&asset, "editor-asset-icon-spec"), Some("generated-character-drafts/editor/spec.png".to_string()) ); assert_eq!(editor_reference_asset_object_key(&asset, "missing"), None); } #[test] fn editor_reference_image_sources_are_trimmed_and_limited() { let sources = vec![ " generated-character-drafts/editor/a.png ".to_string(), "".to_string(), "generated-character-drafts/editor/b.png".to_string(), "generated-character-drafts/editor/c.png".to_string(), ]; assert_eq!( normalize_editor_reference_image_sources(Some(sources.as_slice()), 2), vec![ "generated-character-drafts/editor/a.png", "generated-character-drafts/editor/b.png" ] ); } #[test] fn editor_reference_object_key_requires_generated_prefix() { assert_eq!( normalize_editor_reference_object_key("/generated-character-drafts/editor/ref.png") .unwrap(), "generated-character-drafts/editor/ref.png" ); assert!(normalize_editor_reference_object_key("/private/editor/ref.png").is_err()); } #[test] fn editor_reference_object_key_matches_registered_media() { assert!(editor_record_object_key_matches( Some("/generated-character-drafts/editor/ref.png"), "", "generated-character-drafts/editor/ref.png", )); assert!(!editor_record_object_key_matches( Some("generated-character-drafts/editor/other.png"), "", "generated-character-drafts/editor/ref.png", )); } #[test] fn editor_reference_asset_object_requires_matching_owner_bucket_and_key() { let state = AppState::new(AppConfig { oss_bucket: Some("genarrative-assets".to_string()), oss_endpoint: Some("oss-cn-beijing.aliyuncs.com".to_string()), oss_access_key_id: Some("test-ak".to_string()), oss_access_key_secret: Some("test-sk".to_string()), ..AppConfig::default() }) .expect("state should build"); let asset_object = module_assets::AssetObjectRecord { asset_object_id: "assetobj_reference_1".to_string(), bucket: "genarrative-assets".to_string(), object_key: "generated-character-drafts/editor/ref.png".to_string(), access_policy: module_assets::AssetObjectAccessPolicy::Private, content_type: Some("image/png".to_string()), content_length: 1024, content_hash: None, version: 1, source_job_id: None, owner_user_id: Some("user-1".to_string()), profile_id: None, entity_id: None, asset_kind: "editor_generated_image".to_string(), created_at: "2026-06-27T00:00:00Z".to_string(), updated_at: "2026-06-27T00:00:00Z".to_string(), }; assert!( validate_editor_reference_asset_object( &asset_object, "user-1", &state, "generated-character-drafts/editor/ref.png", ) .is_ok() ); assert!( validate_editor_reference_asset_object( &asset_object, "user-2", &state, "generated-character-drafts/editor/ref.png", ) .is_err() ); } #[test] fn editor_reference_image_mime_type_falls_back_to_magic_bytes() { assert_eq!( infer_editor_reference_image_mime_type(b"\x89PNG\r\n\x1a\nrest"), Some("image/png") ); assert_eq!( infer_editor_reference_image_mime_type(&[0xff, 0xd8, 0xff, 0xe0]), Some("image/jpeg") ); assert_eq!( infer_editor_reference_image_mime_type(b"RIFFxxxxWEBPrest"), Some("image/webp") ); assert_eq!(infer_editor_reference_image_mime_type(b"not image"), None); } #[tokio::test] async fn editor_icon_spritesheet_price_is_resolved_from_generation_config() { let state = AppState::new(AppConfig::default()).expect("state should build"); assert_eq!( resolve_editor_icon_spritesheet_price( &state, Some("gemini-3.1-flash-image-preview"), Some("1K") ) .await .unwrap(), 12 ); assert_eq!( resolve_editor_icon_spritesheet_price(&state, Some("gpt-image-2"), Some("1K")) .await .unwrap(), 3 ); let response = EditorIconSpritesheetGenerationResponse { spritesheet_image_src: "data:image/png;base64,sheet".to_string(), spritesheet_width: 512, spritesheet_height: 512, icon_image_srcs: Vec::new(), slice_warning: Some(EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, reason: "图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。".to_string(), }), prompt: "图标 prompt".to_string(), actual_prompt: Some("图标 prompt".to_string()), model: "gemini-3.1-flash-image-preview".to_string(), provider: "VectorEngine", task_id: "task-icons".to_string(), price_mud_points: crate::editor_generation_config::editor_image_generation_mud_points( Some("icon"), None, Some("1K"), ), spritesheet_resource: None, spritesheet_asset: None, project: None, }; let payload = serde_json::to_value(response).expect("response should serialize"); assert_eq!(payload["priceMudPoints"], json!(12)); assert_eq!(payload["iconImageSrcs"], json!([])); assert_eq!( payload["sliceWarning"]["code"], json!("insufficient-connected-components") ); assert!( payload["sliceWarning"]["reason"] .as_str() .is_some_and(|reason| reason.contains("连通域数量不足")) ); } #[tokio::test] async fn editor_ui_design_asset_extraction_price_uses_selected_model_and_size() { let state = AppState::new(AppConfig::default()).expect("state should build"); let request: EditorUiDesignAssetExtractionRequest = serde_json::from_value(json!({ "sourceImageSrc": "data:image/png;base64,AAAA", "model": "gpt-image-2", "screenColor": "#E6D8FF", "segModel": "anime-seg", "aspectRatio": "1:1", "imageSize": "2K" })) .expect("UI extraction request should deserialize dimensions"); assert_eq!(request.screen_color.as_deref(), Some("#E6D8FF")); assert_eq!(request.seg_model.as_deref(), Some("anime-seg")); let generation_options = normalize_editor_ui_design_asset_extraction_options( request.model.as_deref(), request.aspect_ratio.as_str(), request.image_size.as_str(), ) .expect("UI extraction dimensions should pass"); assert_eq!(generation_options.model, GPT_IMAGE_2_MODEL); assert_eq!(generation_options.size, "2048x2048"); assert_eq!( resolve_editor_ui_design_asset_extraction_price( &state, Some(generation_options.model), Some(generation_options.image_size) ) .await .unwrap(), 5 ); let default_generation = normalize_editor_ui_design_asset_extraction_options(None, "1:1", "1K") .expect("default UI extraction options should pass"); assert_eq!(default_generation.model, EDITOR_IMAGE_MODEL_NANOBANANA2); let blank_model_generation = normalize_editor_ui_design_asset_extraction_options(Some(" "), "1:1", "1K") .expect("blank UI extraction model should use default"); assert_eq!(blank_model_generation.model, EDITOR_IMAGE_MODEL_NANOBANANA2); assert_eq!( resolve_editor_ui_design_asset_extraction_price( &state, Some(default_generation.model), Some(default_generation.image_size), ) .await .unwrap(), 12 ); let ratio_error = match normalize_editor_ui_design_asset_extraction_options(None, "16:9", "2K") { Ok(_) => panic!("UI extraction ratio should stay square"), Err(error) => error, }; assert!(ratio_error.body_text().contains("aspectRatio")); let size_error = match normalize_editor_ui_design_asset_extraction_options(None, "1:1", "0.5K") { Ok(_) => panic!("UI extraction size should be 1K or 2K"), Err(error) => error, }; assert!(size_error.body_text().contains("imageSize")); } #[test] fn editor_image_generation_request_omits_price_mud_points() { let request: EditorImageGenerationRequest = serde_json::from_value(json!({ "prompt": "生成图片", "model": "gpt-image-2", "screenColor": "#CFEFFF", "segModel": "anime-seg", "aspectRatio": "1:1", "imageSize": "1K" })) .expect("image generation request should deserialize without price"); assert_eq!(request.prompt, "生成图片"); assert_eq!(request.screen_color.as_deref(), Some("#CFEFFF")); assert_eq!(request.seg_model.as_deref(), Some("anime-seg")); } #[test] fn editor_image_edit_request_omits_price_mud_points() { let request: EditorImageEditRequest = serde_json::from_value(json!({ "prompt": "修改图片", "sourceImageSrc": "data:image/png;base64,AAAA", "size": "2048x1152", "model": "gpt-image-2", "referenceImageSrcs": ["data:image/png;base64,BBBB"], "targetLayerId": "layer-source" })) .expect("image edit request should deserialize without price"); assert_eq!(request.prompt, "修改图片"); assert_eq!(request.size.as_deref(), Some("2048x1152")); assert_eq!(request.model.as_deref(), Some("gpt-image-2")); assert_eq!( request.reference_image_srcs, Some(vec!["data:image/png;base64,BBBB".to_string()]) ); assert_eq!(request.target_layer_id.as_deref(), Some("layer-source")); } #[test] fn editor_image_edit_rejects_icons_but_allows_icon_specs() { for asset_kind in ["icon", "icon-spritesheet"] { let error = ensure_editor_image_edit_asset_kind_allowed(Some(asset_kind)) .expect_err("icon assets should not support quick edit"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert_eq!( error.details().and_then(|details| details.get("message")), Some(&json!("图标与图标图集不支持快速编辑")), ); assert_eq!( error.details().and_then(|details| details.get("assetKind")), Some(&json!(asset_kind)), ); } assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spec")).is_ok()); assert!(ensure_editor_image_edit_asset_kind_allowed(Some("image")).is_ok()); assert!(ensure_editor_image_edit_asset_kind_allowed(None).is_ok()); let layers = json!([ { "layerId": "layer-icon", "assetKind": "icon" }, { "layerId": "layer-icon-spec", "assetKind": "icon-spec" } ]); let icon_kind = resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon") .expect("icon layer should exist"); assert!(ensure_editor_image_edit_asset_kind_allowed(icon_kind).is_err()); let icon_spec_kind = resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon-spec") .expect("icon spec layer should exist"); assert!(ensure_editor_image_edit_asset_kind_allowed(icon_spec_kind).is_ok()); assert!( resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "missing").is_err() ); } #[test] fn editor_image_edit_guards_before_queue_and_owner_execution() { let source = include_str!("editor_project.rs"); assert_function_contains_in_order( source, "pub async fn edit_editor_image(", "pub(crate) async fn edit_editor_image_for_owner", &[ "ensure_editor_image_edit_source_allowed(", "enqueue_editor_generation_job", ], ); assert_function_contains_in_order( source, "pub(crate) async fn edit_editor_image_for_owner", "pub async fn remove_editor_image_background", &[ "ensure_editor_image_edit_source_allowed(", "parse_editor_reference_image", ], ); } #[test] fn editor_image_edit_can_complete_by_replacing_target_layer() { let source = include_str!("editor_project.rs"); assert_function_contains( source, "pub(crate) async fn edit_editor_image_for_owner", "pub async fn remove_editor_image_background", &[ "payload.canvas_completion.is_some()", "complete_editor_canvas_background_removal", "payload.target_layer_id.as_deref()", ], ); } #[test] fn editor_icon_spritesheet_request_accepts_extra_references() { let request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ "referenceImageSrc": "data:image/png;base64,spec", "referenceImageSrcs": ["data:image/png;base64,style"], "iconDescriptions": ["返回按钮"], "screenColor": "#7FB3FF", "segModel": "anime-seg" })) .expect("icon spritesheet request should deserialize references"); assert_eq!( request.reference_image_srcs, Some(vec!["data:image/png;base64,style".to_string()]) ); assert_eq!(request.screen_color.as_deref(), Some("#7FB3FF")); assert_eq!(request.seg_model.as_deref(), Some("anime-seg")); } #[test] fn editor_icon_spritesheet_slice_request_requires_project_source_and_layout() { let request: EditorIconSpritesheetSliceRequest = serde_json::from_value(json!({ "projectId": "project-1", "sourceResourceId": "resource-atlas-1", "assetFolderId": "project", "canvasCompletion": { "title": "拆分图集", "placeholder": { "x": 120.0, "y": 80.0, "width": 360.0, "height": 360.0, "originalWidth": 1024.0, "originalHeight": 1024.0 } } })) .expect("spritesheet slice request should deserialize"); assert_eq!(request.project_id, "project-1"); assert_eq!(request.source_resource_id, "resource-atlas-1"); assert_eq!(request.asset_folder_id.as_deref(), Some("project")); assert_eq!(request.canvas_completion.title, "拆分图集"); assert_eq!(request.canvas_completion.placeholder.width, 360.0); } #[test] fn editor_bgfilter_seg_model_parser_defaults_and_rejects_unknown() { assert_eq!( parse_editor_bgfilter_seg_model(None).expect("default should parse"), EDITOR_BGFILTER_DEFAULT_SEG_MODEL ); assert_eq!( parse_editor_bgfilter_seg_model(Some("Anime-Seg")).expect("anime seg should parse"), EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG ); assert!(parse_editor_bgfilter_seg_model(Some("u2net")).is_err()); } #[test] fn editor_bgfilter_circuit_opens_after_consecutive_failures_and_resets_on_success() { reset_editor_bgfilter_circuit_for_tests(); let state = AppState::new(AppConfig { editor_bgfilter_circuit_failure_threshold: 2, editor_bgfilter_circuit_cooldown: Duration::from_secs(60), ..AppConfig::default() }) .expect("state should build"); assert!(editor_bgfilter_circuit_open_remaining(&state).is_none()); record_editor_bgfilter_failure(&state); assert!(editor_bgfilter_circuit_open_remaining(&state).is_none()); record_editor_bgfilter_failure(&state); assert!(editor_bgfilter_circuit_open_remaining(&state).is_some()); record_editor_bgfilter_success(); assert!(editor_bgfilter_circuit_open_remaining(&state).is_none()); reset_editor_bgfilter_circuit_for_tests(); } #[test] fn editor_image_and_spritesheet_generation_are_billable() { let source = include_str!("editor_project.rs"); assert_function_contains( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &[ "execute_billable_asset_operation_with_cost", "configured_price_mud_points", ], ); assert_function_contains( source, "pub(crate) async fn edit_editor_image_for_owner", "pub async fn generate_editor_icon_spritesheet", &[ "execute_billable_asset_operation_with_cost", "expected_price_mud_points", ], ); assert_function_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &[ "execute_billable_asset_operation_with_cost", "expected_price_mud_points", ], ); assert_function_contains( source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &[ "execute_billable_asset_operation_with_cost", "expected_price_mud_points", ], ); } #[test] fn editor_canvas_screen_background_generation_uses_bgfilter_postprocess() { let source = include_str!("editor_project.rs"); assert_function_contains( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", "persist_editor_green_screen_source_resource", "if is_character_generation", ], ); assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &[ "persist_editor_green_screen_source_image", "persist_editor_green_screen_source_resource", "remove_editor_generated_screen_background_with_bgfilter", ], ); assert_function_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", "persist_editor_green_screen_source_resource", "slice_generated_icon_spritesheet_by_connected_components", "persist_editor_spritesheet_slices", ], ); assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &[ "persist_editor_green_screen_source_image", "persist_editor_green_screen_source_resource", "remove_editor_generated_screen_background_with_bgfilter", ], ); assert_function_contains( source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", "persist_editor_green_screen_source_resource", "slice_generated_icon_spritesheet_all_by_connected_components", ], ); assert_function_contains_in_order( source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &[ "persist_editor_green_screen_source_image", "persist_editor_green_screen_source_resource", "remove_editor_generated_screen_background_with_bgfilter", ], ); assert_function_contains_in_order( source, "async fn remove_editor_generated_screen_background_with_bgfilter", "async fn fallback_editor_screen_background_removal", &[ // 熔断打开分支必须先委派统一兜底链(阿里云→本地),不能直接退化本地扣色; // BgFilter 调用失败分支同样走该兜底链。 "editor_bgfilter_circuit_open_remaining", "fallback_editor_screen_background_removal", "request_editor_generated_screen_background_with_bgfilter", "fallback_editor_screen_background_removal", ], ); assert_function_contains_in_order( source, "async fn fallback_editor_screen_background_removal", "async fn request_editor_generated_screen_background_with_bgfilter", &[ "segment_image_with_aliyun_matting", "remove_editor_generated_green_screen_background", ], ); assert_function_contains( source, "async fn request_editor_generated_screen_background_with_bgfilter", "async fn request_editor_background_removal_image", &[ "editor_bgfilter_endpoint", "editor_bgfilter_request_timeout_ms.max(1)", "\"screen_color\"", "\"seg_model\"", "seg_model.to_string()", ], ); assert_function_contains( source, "async fn request_editor_background_removal_image", "fn editor_background_removal_endpoint", &[ "editor_background_removal_endpoint", "\"provider\": \"birefnet\"", ], ); assert_function_not_contains( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &["try_remove_editor_downloaded_image_background"], ); assert_function_not_contains( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &["remove_editor_generated_green_screen_background"], ); assert_function_not_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &["try_remove_editor_downloaded_image_background"], ); assert_function_not_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &["remove_editor_generated_green_screen_background"], ); assert_function_not_contains( source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &["try_remove_editor_downloaded_image_background"], ); assert_function_not_contains( source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &["remove_editor_generated_green_screen_background"], ); assert_function_not_contains( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &[ concat!("prepare_editor_", "character_image_for_response"), "Err(_)", ], ); assert_function_not_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &[ concat!("prepare_editor_", "icon_spritesheet_image_for_persist"), "Err(_)", ], ); assert_function_not_contains( source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &["Err(_) => image"], ); } #[test] fn editor_paid_image_postprocess_keeps_provider_outputs_recoverable() { let source = include_str!("editor_project.rs"); assert_function_contains( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &["EDITOR_ASSET_DEFAULT_FOLDER_ID"], ); assert_function_contains_in_order( source, "pub(crate) async fn edit_editor_image_for_owner", "pub async fn generate_editor_icon_spritesheet", &[ "let provider_image", "EDITOR_IMAGE_EDIT_SOURCE_ASSET_KIND", "persist_editor_generated_asset", "restore_editor_image_edit_output_dimensions", ], ); assert_function_contains_in_order( source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &[ "persist_editor_green_screen_source_resource", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND", "persist_editor_generated_asset", "slice_generated_icon_spritesheet_all_by_connected_components", "persist_editor_spritesheet_slices", ], ); assert_function_contains( source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &[ "EDITOR_ASSET_DEFAULT_FOLDER_ID", "EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS", "EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE", ], ); } #[test] fn generated_asset_folder_id_normalizes_project_and_legacy_local_folder() { assert_eq!( normalize_generated_asset_folder_id(Some("project".to_string()), "user-1"), Some("user-1:asset-folder:project".to_string()) ); assert_eq!( normalize_generated_asset_folder_id(Some(" folder-temporary ".to_string()), "user-1"), Some("user-1:asset-folder:project".to_string()) ); assert_eq!( normalize_generated_asset_folder_id(Some("folder-real".to_string()), "user-1"), Some("user-1:asset-folder:project".to_string()) ); assert_eq!( normalize_generated_asset_folder_id( Some("user-1:asset-folder:project".to_string()), "user-1", ), Some("user-1:asset-folder:project".to_string()) ); assert_eq!(normalize_generated_asset_folder_id(None, "user-1"), None); } fn assert_function_contains(source: &str, start: &str, end: &str, snippets: &[&str]) { let start_index = source .find(start) .unwrap_or_else(|| panic!("missing function start marker: {start}")); let tail = &source[start_index..]; let end_index = tail .find(end) .unwrap_or_else(|| panic!("missing function end marker: {end}")); let body = &tail[..end_index]; for snippet in snippets { assert!( body.contains(snippet), "function body starting with {start} should contain {snippet}" ); } } fn assert_function_contains_in_order(source: &str, start: &str, end: &str, snippets: &[&str]) { let start_index = source .find(start) .unwrap_or_else(|| panic!("missing function start marker: {start}")); let tail = &source[start_index..]; let end_index = tail .find(end) .unwrap_or_else(|| panic!("missing function end marker: {end}")); let body = &tail[..end_index]; let mut cursor = 0; for snippet in snippets { let next = body[cursor..].find(snippet).unwrap_or_else(|| { panic!( "function body starting with {start} should contain {snippet} after offset {cursor}" ) }); cursor += next + snippet.len(); } } fn assert_function_not_contains(source: &str, start: &str, end: &str, snippets: &[&str]) { let start_index = source .find(start) .unwrap_or_else(|| panic!("missing function start marker: {start}")); let tail = &source[start_index..]; let end_index = tail .find(end) .unwrap_or_else(|| panic!("missing function end marker: {end}")); let body = &tail[..end_index]; for snippet in snippets { assert!( !body.contains(snippet), "function body starting with {start} should not contain {snippet}" ); } } }