use std::{ borrow::Cow, collections::BTreeMap, io::Cursor, time::{Duration, Instant}, }; use axum::{ Json, extract::{Extension, Path, Query, State}, http::StatusCode, }; 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::{ EDITOR_ASSET_LABEL_MAX_CHARS, EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest, EditorCanvasGenerationPlaceholderPayload, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput, EditorAssetGroupCohortCompleteRecordInput, EditorAssetGroupSourceLookupRecordInput, EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput, EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, ExternalGenerationJobPhaseUpdateError, ExternalGenerationJobPhaseUpdateRecordInput, 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 = 2 * 1024 * 1024; pub(crate) const EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES: usize = 4 * 1024 * 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_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_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved"; const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback"; const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1; 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_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source"; const EDITOR_PROVIDER_SOURCE_SLOT: &str = "provider_source"; pub(crate) const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet"; const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg"; const EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS: u64 = 600; pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_ENABLED: bool = true; pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_DISABLED: bool = false; const EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW: Duration = Duration::from_secs(2); const EDITOR_BGFILTER_FLAT_LOCAL_RESERVE: Duration = Duration::from_secs(7); const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material"; const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image"; #[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, pub(crate) expected_revision: u64, } #[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) 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) 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) asset_label: 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, pub(crate) phase_reporter: 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, phase_reporter: None, } } async fn report_processing_phase(&self, state: &AppState) -> Result<(), AppError> { if let Some(reporter) = self.phase_reporter.as_ref() { reporter.report_processing(state).await?; } Ok(()) } } #[derive(Clone, Debug)] pub(crate) struct EditorGenerationPhaseReporter { job_id: String, worker_id: String, lease_token: String, } impl EditorGenerationPhaseReporter { pub(crate) fn new(job_id: String, worker_id: String, lease_token: String) -> Self { Self { job_id, worker_id, lease_token, } } pub(crate) async fn report_processing(&self, state: &AppState) -> Result<(), AppError> { let input = ExternalGenerationJobPhaseUpdateRecordInput { job_id: self.job_id.clone(), worker_id: self.worker_id.clone(), lease_token: self.lease_token.clone(), phase: "processing".to_string(), }; let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1; for attempt in 1..=max_attempts { match state .spacetime_client() .update_external_generation_job_phase(input.clone()) .await { Ok(_) => return Ok(()), Err(error) => { let will_retry = should_retry_editor_generation_phase_update(&error, attempt, max_attempts); tracing::warn!( provider = "external-generation-phase", job_id = %self.job_id, attempt, max_attempts, will_retry, lease_fencing_rejected = error.is_lease_fencing_rejected(), error = %error, "external_generation_processing_phase_report_failed" ); if will_retry { continue; } return Err(map_editor_generation_phase_update_error(error)); } } } unreachable!("phase report retry loop should return on success or final error") } } fn should_retry_editor_generation_phase_update( error: &ExternalGenerationJobPhaseUpdateError, attempt: usize, max_attempts: usize, ) -> bool { error.is_retryable_transport() && attempt < max_attempts } fn map_editor_generation_phase_update_error( error: ExternalGenerationJobPhaseUpdateError, ) -> AppError { let status = if error.is_lease_fencing_rejected() { StatusCode::CONFLICT } else { StatusCode::BAD_GATEWAY }; AppError::from_status(status).with_details(json!({ "provider": "external-generation-phase", "message": format!("更新外部生成任务处理阶段失败:{error}"), })) } #[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, revision: u64, 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) group_task_id: Option, pub(crate) group_task_expected_asset_count: Option, 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 EditorGenerationWarningResponse { code: &'static str, reason: String, } fn editor_postprocess_fallback_warning(reason: &'static str) -> EditorGenerationWarningResponse { EditorGenerationWarningResponse { code: EDITOR_GENERATION_POSTPROCESS_WARNING_CODE, reason: reason.to_string(), } } fn editor_dimension_restore_warning(error: &AppError) -> EditorGenerationWarningResponse { let reason = error .details() .and_then(|details| details.get("message")) .and_then(Value::as_str) .unwrap_or("provider 回图未能归一到目标交付尺寸,已保留实际原图尺寸。") .to_string(); EditorGenerationWarningResponse { code: EDITOR_GENERATION_DIMENSION_WARNING_CODE, reason, } } #[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, #[serde(skip_serializing_if = "Option::is_none")] warning: 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, #[serde(skip_serializing_if = "Option::is_none")] warning: 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, revision: u64, layout_storage_version: u32, #[serde(skip_serializing_if = "Option::is_none")] background_color: Option, 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 expected_revision = payload.expected_revision; let viewport = payload.viewport.into_record(); let layers_json = serialize_editor_layers(payload.layers)?; let owner_user_id = authenticated.claims().user_id().to_string(); let updated_at_micros = current_utc_micros(); let ack = state .spacetime_client() .save_editor_project_layout_v2_ack(EditorProjectLayoutSaveV2RecordInput { project_id, owner_user_id, viewport, layers_json, expected_revision, updated_at_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, revision: ack.revision, 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, group_task_id: None, group_task_expected_asset_count: None, }) .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() { ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-image-generation", "referenceImageSrcs", "生成参考图", )?; 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> { ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-image-generation", "referenceImageSrcs", "生成参考图", )?; 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 provider_request_size = resolve_editor_image_provider_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 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 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 image_size = if is_ui_design_generation { Cow::Owned(generation_options.size.clone()) } else { image_size }; let provider_request_size = if is_ui_design_generation { Cow::Owned(generation_options.provider_size.clone()) } else { provider_request_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.editor_agent_llm_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()), external_call_deadline: request_context.external_call_deadline(), }, }), ) .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, provider_request_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, provider_request_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 asset_label = resolve_editor_generated_asset_label(payload.asset_label.clone(), role_setting.as_str()); 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 (provider_width, provider_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": format!("素材生成结果不是有效图片:{error}"), })) })?; // 中文注释:nanobanana2 的 2K 是 provider 清晰度档位,16:9 实际可能返回 // 2752x1536;画布业务规格统一使用 512 / 1024 / 2048 长边像素矩阵,持久化前归一, // 保证不同模型的完成图与生成前占位标注一致。 let (restored_image, dimension_restore_error) = if is_character_generation { // 角色任务的 provider 原图是独立可复用中间产物,先按原始分辨率保存; // 透明主结果会在后处理完成后再归一到画布业务规格。 (image, None) } else { restore_editor_generated_image_output_dimensions_or_original( image, generation_options.model, image_size.as_ref(), ) }; let mut dimension_warning = dimension_restore_error .as_ref() .map(editor_dimension_restore_warning); if let Some(error) = dimension_restore_error { tracing::warn!( task_id = %generated.task_id, provider_width, provider_height, target_size = %image_size, error = ?error, "画板生成图片尺寸恢复失败,保留并上传 provider 原图" ); } image = restored_image; let (delivery_width, delivery_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((provider_width, provider_height)); let storage_profile = editor_generated_image_storage_profile(normalized_kind); let mut output_prompt = submitted_prompt.clone(); let mut output_actual_prompt = generated.actual_prompt.clone(); let mut output_model = generation_options.model.to_string(); let mut output_provider = "VectorEngine".to_string(); let mut output_generation_inputs = generation_inputs.clone(); let source_record = if is_character_generation { let source_persisted = persist_editor_provider_source_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, "character-images", "character-image", ) .await?; let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str()); let source_object_key = source_persisted.object_key.clone(); let source_asset_object_id = source_persisted.asset_object_id.clone(); let source_record = persist_editor_provider_source_resource( state, source_persisted, PersistEditorProviderSourceResourceInput { 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: editor_generated_asset_variant_label(asset_label.as_str(), "原图"), width: delivery_width, height: delivery_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(), asset_kind: payload .asset_kind .clone() .or_else(|| Some("character".to_string())), generation_inputs: generation_inputs.clone(), generation_cost_mud_points: u64::from(configured_price_mud_points), }, ) .await?; caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller .audit_project_id .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), external_call_deadline: request_context.external_call_deadline(), }; let removal = remove_editor_generated_screen_background_with_bgfilter( state, source_object_key.as_str(), screen_color.expect("character generation should have screen color"), seg_model.expect("character generation should have BgFilter seg model"), EDITOR_BGFILTER_CROSS_CHECK_ENABLED, &matting_audit, ) .await; let removal = match removal { Ok(removal) => removal, Err(error) => { let failure_message = error.body_text(); tracing::warn!( provider = "editor-character-image", operation = "background_postprocess", task_id = %generated.task_id, error = %failure_message, "角色原图已保存,但透明背景处理失败,使用原图完成画布" ); let completed_project = complete_editor_canvas_generation( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), source_record.resource.as_ref(), ) .await?; return Ok(json_success_body( Some(&request_context), EditorImageGenerationResponse { image_src: source_image_src, object_key: Some(source_object_key), asset_object_id: Some(source_asset_object_id), width: delivery_width, height: delivery_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: source_record.resource, asset: source_record.asset, project: completed_project, warning: Some(editor_postprocess_fallback_warning( "生成任务成功,后处理失败。", )), }, )); } }; let removal_provider = removal.provider; let (restored_removal_image, postprocess_dimension_error) = apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, source_object_key.as_str(), delivery_width, delivery_height, removal.image, ) .await; if let Some(error) = postprocess_dimension_error { tracing::warn!( task_id = %generated.task_id, provider = removal_provider, provider_width, provider_height, delivery_width, delivery_height, error = ?error, "角色透明图尺寸恢复失败,保留去背景服务原始输出" ); } image = restored_removal_image; output_prompt = "去除纯色背景".to_string(); output_actual_prompt = None; output_model = removal.model; output_provider = removal_provider.to_string(); output_generation_inputs = build_editor_derived_asset_generation_inputs("角色抠图", &output_model, &source_record); Some(source_record) } else { None }; if is_character_generation { let (restored_image, dimension_restore_error) = restore_editor_generated_image_output_dimensions_or_original( image, generation_options.model, image_size.as_ref(), ); if let Some(error) = dimension_restore_error { dimension_warning = Some(editor_dimension_restore_warning(&error)); tracing::warn!( task_id = %generated.task_id, provider_width, provider_height, target_size = %image_size, error = ?error, "画板角色透明主图尺寸恢复失败,保留后处理原图" ); } image = restored_image; } let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); let persisted = persist_editor_generated_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), &image, output_prompt.as_str(), output_actual_prompt.as_deref(), storage_profile.asset_kind, storage_profile.path_kind, "image", storage_profile.slot, output_provider.as_str(), ) .await?; let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); let provider_source_resource_id = source_record .as_ref() .and_then(|record| record.resource.as_ref()) .map(|resource| resource.resource_id.clone()); 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: asset_label, image_src: image_src.clone(), object_key: Some(persisted.object_key.clone()), asset_object_id: Some(persisted.asset_object_id.clone()), width, height, prompt: output_prompt, actual_prompt: output_actual_prompt, model: output_model, provider: output_provider, task_id: generated.task_id.clone(), group_task_id: None, group_task_expected_asset_count: None, source_resource_id: provider_source_resource_id.or(payload.source_resource_id), asset_kind: payload.asset_kind, generation_inputs: output_generation_inputs, thumbnail_src: None, generation_cost_mud_points: if is_character_generation { 0 } else { 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, warning: dimension_warning, }, )) } 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 build_editor_derived_asset_generation_inputs( stage: &str, model: &str, source: &EditorGeneratedAssetRecord, ) -> Option { let reference = source .resource .as_ref() .map(|resource| { json!({ "title": "输入素材", "label": source .asset .as_ref() .map(|asset| asset.label.as_str()) .unwrap_or("上一步产物"), "refType": "project-resource", "refId": resource.resource_id, }) }) .or_else(|| { source.asset.as_ref().map(|asset| { json!({ "title": "输入素材", "label": asset.label, "refType": "asset", "refId": asset.asset_id, }) }) }); Some(json!({ "fields": [ { "title": "处理阶段", "value": stage }, { "title": "处理模型", "value": model }, ], "references": reference.into_iter().collect::>(), })) } 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, image_size: Option<&str>, ) -> Result { let price_size = if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { image_size .map(|value| normalize_editor_generation_image_size(model, Some(value))) .unwrap_or_else(|| editor_image_price_size_from_pixels(size)) } else { editor_image_price_size_from_pixels(size) }; 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(price_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 editor_image_edit_uses_nanobanana_generate_content(model: &str) -> bool { model == EDITOR_IMAGE_MODEL_NANOBANANA2 } fn normalize_editor_image_edit_generation_options( model: Option<&str>, aspect_ratio: Option<&str>, image_size: Option<&str>, legacy_size: Option<&str>, ) -> EditorGenerationOptions { let model_options = normalize_editor_generation_options(model, None, None); let inferred_aspect_ratio = aspect_ratio .filter(|value| !value.trim().is_empty()) .or_else(|| infer_editor_image_edit_aspect_ratio(legacy_size)); let inferred_image_size = image_size .filter(|value| !value.trim().is_empty()) .or_else(|| infer_editor_image_edit_size_tier(model_options.model, legacy_size)); normalize_editor_generation_options(model, inferred_aspect_ratio, inferred_image_size) } fn infer_editor_image_edit_aspect_ratio(size: Option<&str>) -> Option<&'static str> { let (width, height) = parse_editor_image_edit_pixel_size(size?)?; let source_ratio = f64::from(width) / f64::from(height); [ ("1:1", 1.0), ("4:3", 4.0 / 3.0), ("3:2", 3.0 / 2.0), ("2:3", 2.0 / 3.0), ("9:16", 9.0 / 16.0), ("16:9", 16.0 / 9.0), ] .into_iter() .min_by(|(_, left), (_, right)| { (left - source_ratio) .abs() .total_cmp(&(right - source_ratio).abs()) }) .map(|(label, _)| label) } fn infer_editor_image_edit_size_tier(model: &str, size: Option<&str>) -> Option<&'static str> { let (width, height) = parse_editor_image_edit_pixel_size(size?)?; let long_edge = width.max(height); if model == EDITOR_IMAGE_MODEL_NANOBANANA2 && long_edge <= 768 { return Some("0.5K"); } Some(if long_edge > 1536 { "2K" } else { "1K" }) } fn parse_editor_image_edit_pixel_size(size: &str) -> Option<(u32, u32)> { let normalized = size.trim().replace('*', "x"); let (width, height) = normalized.split_once('x')?; let width = width.parse::().ok()?; let height = height.parse::().ok()?; (width > 0 && height > 0).then_some((width, height)) } 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 resolve_editor_image_provider_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.provider_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_delivery_size(aspect_ratio, image_size); let provider_size = editor_generation_provider_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_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_delivery_size(aspect_ratio: &str, image_size: &str) -> String { let long_edge = match image_size { "0.5K" => 512_u32, "2K" => 2048_u32, _ => 1024_u32, }; let (ratio_width, ratio_height) = match aspect_ratio { "2:3" => (2_u32, 3_u32), "3:2" => (3_u32, 2_u32), "4:3" => (4_u32, 3_u32), "9:16" => (9_u32, 16_u32), "16:9" => (16_u32, 9_u32), _ => (1_u32, 1_u32), }; let (width, height) = if ratio_width >= ratio_height { ( long_edge, (long_edge * ratio_height + ratio_width / 2) / ratio_width, ) } else { ( (long_edge * ratio_width + ratio_height / 2) / ratio_height, long_edge, ) }; format!("{width}x{height}") } fn editor_generation_provider_size_for_model( model: &str, aspect_ratio: &str, image_size: &str, ) -> String { if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { return editor_generation_delivery_size(aspect_ratio, image_size); } match (image_size, aspect_ratio) { ("2K", "1:1") => "2048x2048", ("2K", "2:3") => "1376x2048", ("2K", "9:16") => "1152x2048", ("2K", "4:3") => "2048x1536", ("2K", "3:2") => "2048x1376", ("2K", "16:9") => "2048x1152", ("1K", "2:3") => "688x1024", // 中文注释:9:16 的 1024 长边会低于 provider 最小总像素,按原比例放大并做 16 对齐后直接请求。 ("1K", "9:16") => "608x1088", ("1K", "4:3") => "1024x768", ("1K", "3:2") => "1024x688", ("1K", "16:9") => "1088x608", _ => "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 asset_kind != "icon" { 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_png( image: image::DynamicImage, failure_status: StatusCode, provider: &str, 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": provider, "message": format!("{failure_message}:{error}"), })) })?; Ok(bytes.into_inner()) } fn encode_editor_image_edit_png( image: image::DynamicImage, failure_status: StatusCode, failure_message: &str, ) -> Result, AppError> { encode_editor_image_png(image, failure_status, "editor-image-edit", failure_message) } 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 ensure_editor_output_can_restore_without_upscaling( source_width: u32, source_height: u32, target_width: u32, target_height: u32, operation: &str, ) -> Result<(), AppError> { if target_width == 0 || target_height == 0 { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "code": "invalid-delivery-size", "message": "目标交付尺寸必须大于 0", "operation": operation, "targetWidth": target_width, "targetHeight": target_height, })), ); } if source_width < target_width || source_height < target_height { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "code": "provider-image-under-delivery", "message": "provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位", "operation": operation, "sourceWidth": source_width, "sourceHeight": source_height, "targetWidth": target_width, "targetHeight": target_height, })), ); } let source_ratio = f64::from(source_width) / f64::from(source_height); let target_ratio = f64::from(target_width) / f64::from(target_height); let crop_fraction = if source_ratio >= target_ratio { 1.0 - target_ratio / source_ratio } else { 1.0 - source_ratio / target_ratio }; if crop_fraction > 0.05 { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "code": "provider-image-aspect-ratio-mismatch", "message": "provider 回图比例偏差过大,禁止大幅裁切为目标交付尺寸", "operation": operation, "sourceWidth": source_width, "sourceHeight": source_height, "targetWidth": target_width, "targetHeight": target_height, "cropFraction": crop_fraction, })), ); } Ok(()) } fn restore_editor_image_edit_output_dimensions( output: &DownloadedOpenAiImage, dimensions: &EditorImageEditDimensions, ) -> Result, AppError> { 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(None); } ensure_editor_output_can_restore_without_upscaling( decoded.width(), decoded.height(), dimensions.target_width, dimensions.target_height, "editor-image-edit", )?; 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(Some(DownloadedOpenAiImage { bytes: encode_editor_image_edit_png( restored, StatusCode::BAD_GATEWAY, "恢复图片改造结果目标尺寸失败", )?, mime_type: "image/png".to_string(), extension: "png".to_string(), })) } fn restore_editor_image_edit_output_dimensions_or_original( output: DownloadedOpenAiImage, dimensions: &EditorImageEditDimensions, ) -> (DownloadedOpenAiImage, Option) { match restore_editor_image_edit_output_dimensions(&output, dimensions) { Ok(Some(restored)) => (restored, None), Ok(None) => (output, None), Err(error) => (output, Some(error)), } } fn restore_editor_generated_image_output_dimensions_or_original( output: DownloadedOpenAiImage, model: &str, target_size: &str, ) -> (DownloadedOpenAiImage, Option) { match restore_editor_generated_image_output_dimensions(&output, model, target_size) { Ok(Some(restored)) => (restored, None), Ok(None) => (output, None), Err(error) => (output, Some(error)), } } fn apply_editor_postprocessed_alpha_to_provider_source( provider_source: &DownloadedOpenAiImage, postprocessed: &DownloadedOpenAiImage, ) -> Result, AppError> { let provider_source = image::load_from_memory(provider_source.bytes.as_slice()).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-image-postprocess", "message": format!("provider 原图不是有效图片:{error}"), })) })?; let postprocessed = image::load_from_memory(postprocessed.bytes.as_slice()).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-image-postprocess", "message": format!("透明后处理图不是有效图片:{error}"), })) })?; if postprocessed.width() == provider_source.width() && postprocessed.height() == provider_source.height() { return Ok(None); } // 中文注释:图片模型已经按用户所选 K 档直接生成 provider 原图。 // 去背景服务若缩小图片,只重采样其 alpha 蒙版并应用回原始分辨率 RGB,禁止把低分辨率成品整图放大。 let postprocessed = postprocessed.to_rgba8(); let alpha = image::GrayImage::from_fn(postprocessed.width(), postprocessed.height(), |x, y| { image::Luma([postprocessed.get_pixel(x, y).0[3]]) }); let alpha = image::imageops::resize( &alpha, provider_source.width(), provider_source.height(), image::imageops::FilterType::Lanczos3, ); let mut restored = provider_source.to_rgba8(); for (x, y, pixel) in restored.enumerate_pixels_mut() { pixel.0[3] = alpha.get_pixel(x, y).0[0]; } Ok(Some(DownloadedOpenAiImage { bytes: encode_editor_image_png( image::DynamicImage::ImageRgba8(restored), StatusCode::BAD_GATEWAY, "editor-image-postprocess", "恢复透明蒙版尺寸失败", )?, mime_type: "image/png".to_string(), extension: "png".to_string(), })) } async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state: &AppState, provider_source_object_key: &str, provider_width: u32, provider_height: u32, postprocessed: DownloadedOpenAiImage, ) -> (DownloadedOpenAiImage, Option) { let postprocessed_dimensions = image::ImageReader::new(Cursor::new(postprocessed.bytes.as_slice())) .with_guessed_format() .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-image-postprocess", "message": format!("无法识别透明后处理图格式:{error}"), })) }) .and_then(|reader| { reader.into_dimensions().map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-image-postprocess", "message": format!("无法读取透明后处理图尺寸:{error}"), })) }) }); match postprocessed_dimensions { Ok((postprocessed_width, postprocessed_height)) if postprocessed_width == provider_width && postprocessed_height == provider_height => { return (postprocessed, None); } Ok(_) => {} Err(error) => return (postprocessed, Some(error)), } // 中文注释:provider 原图已在 BgFilter 调用前交给 OSS 并释放;只有输出尺寸漂移时才短时回读原图回贴 alpha。 let provider_source = match download_editor_persisted_image_object(state, provider_source_object_key).await { Ok(provider_source) => provider_source, Err(error) => return (postprocessed, Some(error)), }; match apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed) { Ok(Some(restored)) => (restored, None), Ok(None) => (postprocessed, None), Err(error) => (postprocessed, Some(error)), } } fn restore_editor_generated_image_output_dimensions( output: &DownloadedOpenAiImage, model: &str, target_size: &str, ) -> Result, AppError> { let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "尺寸无效", })) })?; let target_width = target_width.parse::().map_err(|_| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "宽度无效", })) })?; let target_height = target_height.parse::().map_err(|_| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "高度无效", })) })?; 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() == target_width && decoded.height() == target_height { return Ok(None); } ensure_editor_output_can_restore_without_upscaling( decoded.width(), decoded.height(), target_width, target_height, model, )?; let restored = decoded.resize_to_fill( target_width, target_height, image::imageops::FilterType::Lanczos3, ); Ok(Some(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_reference_image_source_is_stable( payload.source_image_src.as_str(), "editor-image-edit", "sourceImageSrc", "待修改图片", )?; ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-image-edit", "referenceImageSrcs", "修改参考图", )?; ensure_editor_image_edit_source_allowed(&state, caller.owner_user_id.as_str(), &payload) .await?; let generation_options = normalize_editor_image_edit_generation_options( payload.model.as_deref(), payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), payload.size.as_deref(), ); 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(), Some(generation_options.image_size), ) .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_reference_image_source_is_stable( payload.source_image_src.as_str(), "editor-image-edit", "sourceImageSrc", "待修改图片", )?; ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-image-edit", "referenceImageSrcs", "修改参考图", )?; 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_image_edit_generation_options( payload.model.as_deref(), payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), payload.size.as_deref(), ); let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some(); let requested_image_size = normalize_editor_image_generation_size(payload.size.as_deref()); let delivery_size = if has_dimension_options { Cow::Owned(generation_options.size.clone()) } else { requested_image_size.clone() }; let provider_requested_image_size = if has_dimension_options { Cow::Owned(generation_options.provider_size.clone()) } else { requested_image_size.clone() }; 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 = if editor_image_edit_uses_nanobanana_generate_content(generation_options.model) { None } else { Some(prepare_editor_image_edit_references( &mut reference_images, provider_requested_image_size.as_ref(), )?) }; let provider_size = edit_dimensions .map(|dimensions| { format!( "{}x{}", dimensions.provider_width, dimensions.provider_height ) }) .unwrap_or_else(|| generation_options.provider_image_size.to_string()); let expected_price_mud_points = resolve_editor_image_edit_price( state, generation_options.model, requested_image_size.as_ref(), Some(generation_options.image_size), ) .await?; let generated = if editor_image_edit_uses_nanobanana_generate_content(generation_options.model) { 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_nanobanana_generate_content( &http_client, &settings, generation_options.model, prompt.as_str(), Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"), generation_options.aspect_ratio, provider_size.as_str(), reference_images.as_slice(), "图片画布修改图片", ), ) .await? } else { 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 控件、低清晰度、变形主体"), provider_size.as_str(), 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())) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": format!("图片改造结果不是有效图片:{error}"), })) })?; let asset_label = resolve_editor_generated_asset_label(payload.asset_label.clone(), "图片修改结果"); let (image, dimension_restore_error) = if let Some(edit_dimensions) = edit_dimensions.as_ref().filter(|_| !has_dimension_options) { restore_editor_image_edit_output_dimensions_or_original(provider_image, edit_dimensions) } else { restore_editor_generated_image_output_dimensions_or_original( provider_image, generation_options.model, delivery_size.as_ref(), ) }; let dimension_warning = dimension_restore_error .as_ref() .map(editor_dimension_restore_warning); if let Some(error) = dimension_restore_error { tracing::warn!( task_id = %generated.task_id, provider_width, provider_height, target_width = edit_dimensions.map(|dimensions| dimensions.target_width), target_height = edit_dimensions.map(|dimensions| dimensions.target_height), target_size = %delivery_size, error = ?error, "画板快速编辑尺寸恢复失败,保留并上传 provider 原图" ); } 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: asset_label.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: prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), provider: "VectorEngine".to_string(), task_id: generated.task_id.clone(), group_task_id: None, group_task_expected_asset_count: None, 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(), Some(asset_label.as_str()), 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, warning: dimension_warning, }, )) } 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); ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), "editor-background-removal", "sourceImageSrc", "待去除背景图片", )?; 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(); caller.report_processing_phase(state).await?; let source_object_key = resolve_editor_reference_object_key_for_owner( state, caller.owner_user_id.as_str(), payload.source_image_src.as_str(), ) .await?; validate_editor_background_removal_source(state, source_object_key.as_str()).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller .audit_project_id .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), external_call_deadline: request_context.external_call_deadline(), }; let removed = request_editor_background_removal_image_with_bgfilter_worker( state, source_object_key.as_str(), &matting_audit, ) .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", "bgfilter-complex", ) .await?; let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); let asset_label = resolve_editor_generated_asset_label(payload.asset_label.clone(), "去背景结果"); 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: asset_label.clone(), 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: "BgFilter complex".to_string(), provider: "BgFilter".to_string(), task_id: task_id.clone(), group_task_id: None, group_task_expected_asset_count: None, 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(), Some(asset_label.as_str()), 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: "BgFilter", resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, }, )) } struct EditorBackgroundRemovalImage { image: DownloadedOpenAiImage, width: u32, height: u32, } async fn validate_editor_background_removal_source( state: &AppState, source_object_key: &str, ) -> Result<(), AppError> { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; let http_client = reqwest::Client::new(); let head = oss_client .head_object( &http_client, OssHeadObjectRequest { object_key: source_object_key.to_string(), }, ) .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; if head.content_length > EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES { return Err(editor_reference_image_too_large()); } let content_type = normalize_editor_background_removal_source_mime_type(head.content_type.as_deref()) .ok_or_else(|| { editor_background_removal_source_unsupported( "图片参考图的 Content-Type 不是支持的图片类型。", ) })?; let signed_url = sign_editor_private_object_read_url( state, source_object_key, EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS, )?; let probe_end = EDITOR_BACKGROUND_REMOVAL_SOURCE_PROBE_BYTES.saturating_sub(1); let response = http_client .get(signed_url) .header(reqwest::header::RANGE, format!("bytes=0-{probe_end}")) .send() .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取图片参考图文件头失败:{error}"), })) })?; if response.status() != StatusCode::PARTIAL_CONTENT { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": "OSS 未按 Range 返回图片文件头。", "status": response.status().as_u16(), })), ); } if response .content_length() .is_none_or(|size| size > EDITOR_BACKGROUND_REMOVAL_SOURCE_PROBE_BYTES) { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": "OSS 返回的图片文件头大小异常。", })), ); } let probe = response.bytes().await.map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取图片参考图文件头失败:{error}"), })) })?; validate_editor_background_removal_source_content(content_type, probe.as_ref()) } fn validate_editor_background_removal_source_content( content_type: &str, bytes: &[u8], ) -> Result<(), AppError> { let detected_type = infer_editor_reference_image_mime_type(bytes).ok_or_else(|| { editor_background_removal_source_unsupported("图片参考图文件魔数不是支持的图片类型。") })?; if detected_type != content_type { return Err(editor_background_removal_source_unsupported( "图片参考图的 Content-Type 与文件魔数不一致。", )); } Ok(()) } fn normalize_editor_background_removal_source_mime_type( content_type: Option<&str>, ) -> Option<&'static str> { let mime_type = content_type?.split(';').next()?.trim(); if mime_type.eq_ignore_ascii_case("image/png") { Some("image/png") } else if mime_type.eq_ignore_ascii_case("image/jpeg") { Some("image/jpeg") } else if mime_type.eq_ignore_ascii_case("image/webp") { Some("image/webp") } else { None } } fn editor_background_removal_source_unsupported(message: &str) -> AppError { AppError::from_status(StatusCode::UNSUPPORTED_MEDIA_TYPE).with_details(json!({ "provider": "editor-reference-image", "field": "sourceImageSrc", "message": message, })) } fn editor_bgfilter_flat_deadline_reserve(aliyun_timeout_ms: u64) -> Duration { Duration::from_millis(aliyun_timeout_ms) .saturating_add(EDITOR_BGFILTER_FLAT_LOCAL_RESERVE) .saturating_add(EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW) } async fn request_editor_background_removal_image_with_bgfilter_worker( state: &AppState, source_object_key: &str, audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { // complex 没有 flat fallback,排队上限只预留 2s 传输窗;worker 重启窗口内的 // 连接失败由 client 内部按预算有界重试,避免 max_attempts=1 的队列任务终态失败。 let removed = crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry( state, source_object_key, crate::bgfilter_worker::BgfilterBackgroundMode::Complex, None, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_BGFILTER_CROSS_CHECK_DISABLED, EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW, audit, ) .await .map_err(crate::bgfilter_worker::BgfilterClientError::into_app_error)?; Ok(EditorBackgroundRemovalImage { width: removed.width, height: removed.height, image: DownloadedOpenAiImage { bytes: removed.bytes, extension: removed.extension, mime_type: removed.mime_type, }, }) } pub(crate) struct EditorScreenBackgroundRemovalOutput { pub(crate) image: DownloadedOpenAiImage, pub(crate) provider: &'static str, pub(crate) model: String, } pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter( state: &AppState, source_object_key: &str, screen_color: EditorScreenBackgroundColor, seg_model: &str, cross_check: bool, audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { // flat 的排队上限从父剩余预算里预扣「阿里云 + 本地 + 传输」的 fallback 时间, // 保证排队吃不完 fallback 的执行窗口;上限为 0 时 client 直接返回 // deadline_exceeded,本函数随即进入 fallback 链。worker 重启窗口内的连接 // 失败由 client 先做小额重试(≤2 次),仍失败才降级,避免不必要的质量损失。 match crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry( state, source_object_key, crate::bgfilter_worker::BgfilterBackgroundMode::Flat, Some(screen_color.hex), seg_model, cross_check, editor_bgfilter_flat_deadline_reserve(state.config.aliyun_matting_request_timeout_ms), audit, ) .await { Ok(image) => { return Ok(EditorScreenBackgroundRemovalOutput { image: DownloadedOpenAiImage { bytes: image.bytes, extension: image.extension, mime_type: image.mime_type, }, provider: "BgFilter", model: seg_model.to_string(), }); } Err(error) if !error.allows_flat_fallback() => { return Err(error.into_app_error()); } Err(error) if audit .external_call_deadline .is_some_and(|deadline| Instant::now() >= deadline) => { return Err(error.into_app_error()); } Err(error) => { tracing::warn!( provider = "bgfilter-worker", worker_code = error.code(), screen_color = screen_color.hex, seg_model, cross_check, "editor_bgfilter_worker_fallback_to_aliyun_matting" ); } } fallback_editor_screen_background_removal(state, source_object_key, screen_color, audit).await } /// BgFilter 内部 worker 对 flat 调用返回可降级错误时的统一兜底链: /// 先阿里云通用抠图,失败再退本地键色扣除。 async fn fallback_editor_screen_background_removal( state: &AppState, source_object_key: &str, screen_color: EditorScreenBackgroundColor, audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { let aliyun_result = match sign_editor_private_object_read_url( state, source_object_key, EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS, ) { Ok(source_url) => { crate::aliyun_matting::segment_image_url_with_aliyun_matting( state, source_url.as_str(), "editor-screen-background", ) .await } Err(error) => Err(error), }; match aliyun_result { Ok(image) => Ok(EditorScreenBackgroundRemovalOutput { image, provider: "Aliyun Matting", model: "segment-common-image".to_string(), }), 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", crate::external_api_audit::matting_failure_audit_failure_stage( &error, "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_is_transport(&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; } let source_image = download_editor_persisted_image_object(state, source_object_key).await?; let output = remove_editor_generated_green_screen_background(&source_image, screen_color); drop(source_image); output.map(|image| EditorScreenBackgroundRemovalOutput { image, provider: "Genarrative Local", model: "screen-color-keying".to_string(), }) } } } fn sign_editor_private_object_read_url( state: &AppState, object_key: &str, expire_seconds: u64, ) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { object_key: object_key.to_string(), expire_seconds: Some(expire_seconds), }) .map(|signed| signed.signed_url) .map_err(|error| map_oss_error(error, "aliyun-oss")) } 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, ], })), ), } } struct PersistEditorSpritesheetSlicesInput { owner_user_id: String, project_id: Option, asset_folder_id: Option, source_resource_id: Option, task_id: String, group_task_id: Option, 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 expected_asset_count = u32::try_from(slices.len()).map_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(), group_task_id: input.group_task_id.clone(), group_task_expected_asset_count: input .group_task_id .as_ref() .map(|_| expected_asset_count), 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, }); } if let Some(group_task_id) = input.group_task_id.as_deref() { let asset_ids = results .iter() .filter_map(|result| result.asset.as_ref().map(|asset| asset.asset_id.clone())) .collect::>(); state .spacetime_client() .complete_editor_asset_group_cohort(EditorAssetGroupCohortCompleteRecordInput { owner_user_id: input.owner_user_id, task_id: input.task_id, group_task_id: group_task_id.to_string(), expected_asset_count, asset_ids, completed_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error)?; } 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() { ensure_editor_reference_image_source_is_stable( payload.reference_image_src.as_str(), "editor-icon-spritesheet", "referenceImageSrc", "图标素材规范", )?; ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-icon-spritesheet", "referenceImageSrcs", "图标素材参考图", )?; let generation_options = normalize_editor_generation_options( payload.model.as_deref(), payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); let 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> { ensure_editor_reference_image_source_is_stable( payload.reference_image_src.as_str(), "editor-icon-spritesheet", "referenceImageSrc", "图标素材规范", )?; ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-icon-spritesheet", "referenceImageSrcs", "图标素材参考图", )?; 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": "图标素材规范必须是 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": "图标素材参考图必须是 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.provider_size.as_str(); let settings = require_openai_image_settings(state)?.with_external_api_audit_context( request_context, caller.audit_subject_user_id.clone(), caller .audit_project_id .clone() .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; 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.editor_agent_llm_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()), external_call_deadline: request_context.external_call_deadline(), }, }), ) .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 spritesheet_label = resolve_editor_generated_asset_label(payload.asset_label.clone(), "图标素材图集"); let asset_folder_id = payload .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); let source_persisted = persist_editor_provider_source_image( state, caller.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", ) .await?; let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str()); let source_object_key = source_persisted.object_key.clone(); let source_record = persist_editor_provider_source_resource( state, source_persisted, PersistEditorProviderSourceResourceInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"), 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, asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), generation_cost_mud_points: u64::from(expected_price_mud_points), }, ) .await?; caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller .audit_project_id .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), external_call_deadline: request_context.external_call_deadline(), }; let removal = remove_editor_generated_screen_background_with_bgfilter( state, source_object_key.as_str(), screen_color, seg_model, EDITOR_BGFILTER_CROSS_CHECK_DISABLED, &matting_audit, ) .await; let removal = match removal { Ok(removal) => removal, Err(error) => { let failure_message = error.body_text(); tracing::warn!( provider = "editor-icon-spritesheet", operation = "background_postprocess", task_id = %generated.task_id, error = %failure_message, "图标图集原图已保存,但透明背景处理失败,使用原图完成画布" ); let completed_project = complete_editor_canvas_generation( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), source_record.resource.as_ref(), ) .await?; return Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { spritesheet_image_src: source_image_src, spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_warning: None, 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: source_record.resource, spritesheet_asset: source_record.asset, project: completed_project, warning: Some(editor_postprocess_fallback_warning( "生成任务成功,后处理失败。", )), }, )); } }; let removal_provider = removal.provider; let (image, postprocess_dimension_error) = apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, source_object_key.as_str(), source_width, source_height, removal.image, ) .await; if let Some(error) = postprocess_dimension_error { tracing::warn!( task_id = %generated.task_id, provider = removal_provider, source_width, source_height, error = ?error, "图标透明图蒙版尺寸恢复失败,保留去背景服务原始输出" ); } let matting_generation_inputs = build_editor_derived_asset_generation_inputs( "图标图集抠图", &removal.model, &source_record, ); 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, "去除纯色背景", None, EDITOR_ICON_SPRITESHEET_ASSET_KIND, "icon-spritesheets", "spritesheet", "spritesheet", removal.provider, ) .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: spritesheet_label, 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: "去除纯色背景".to_string(), actual_prompt: None, model: removal.model.clone(), provider: removal.provider.to_string(), task_id: generated.task_id.clone(), group_task_id: None, group_task_expected_asset_count: None, source_resource_id: source_record .resource .as_ref() .map(|resource| resource.resource_id.clone()), asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: matting_generation_inputs, thumbnail_src: None, generation_cost_mud_points: 0, }, ) .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(), group_task_id: None, prompt: "自动拆分图集".to_string(), actual_prompt: None, model: "connected-components".to_string(), provider: "Genarrative".to_string(), generation_inputs: build_editor_derived_asset_generation_inputs( "图集拆分", "connected-components", &spritesheet_record, ), 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, warning: None, }, )) } 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) .cloned() .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 task_id = build_prefixed_uuid_id("editor-atlas-split-"); let group_task_id = resolve_editor_manual_atlas_split_group_task_id( &state, owner_user_id.as_str(), &source_resource, ) .await? .unwrap_or_else(|| task_id.clone()); let 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 prompt = source_resource .prompt .clone() .unwrap_or_else(|| "拆分图集素材".to_string()); let actual_prompt = source_resource.actual_prompt.clone(); let model = source_resource .model .clone() .unwrap_or_else(|| "connected-components".to_string()); let provider = source_resource .provider .clone() .unwrap_or_else(|| "Genarrative".to_string()); let generation_inputs = source_resource.generation_inputs.clone(); let icon_image_srcs = persist_editor_spritesheet_slices( &state, slices, PersistEditorSpritesheetSlicesInput { owner_user_id: owner_user_id.clone(), project_id: Some(project_id.clone()), asset_folder_id: payload .asset_folder_id .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())), source_resource_id: Some(source_resource_id), task_id, group_task_id: Some(group_task_id), prompt, actual_prompt, model, provider, generation_inputs, path_kind: "icon-spritesheet-slices", asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, persistence_provider: "genarrative", }, ) .await?; let canvas_items = build_icon_spritesheet_slice_canvas_layer_items( &payload.canvas_completion, icon_image_srcs.as_slice(), ); let project = complete_editor_canvas_generation_with_items( &state, owner_user_id.as_str(), Some(project_id.as_str()), Some(&payload.canvas_completion), canvas_items, icon_image_srcs .first() .and_then(|icon| icon.resource.as_ref()) .map(|resource| generated_canvas_layer_id(resource.resource_id.as_str())), ) .await? .ok_or_else(|| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集拆分结果未能写入画布。", })) })?; Ok(json_success_body( Some(&request_context), EditorIconSpritesheetSliceResponse { icon_image_srcs, project, }, )) } 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) } async fn resolve_editor_manual_atlas_split_group_task_id( state: &AppState, owner_user_id: &str, source_resource: &EditorProjectResourceRecord, ) -> Result, AppError> { let lookup = editor_manual_atlas_split_group_source_lookup_input( owner_user_id, source_resource.resource_id.as_str(), source_resource.source_resource_id.as_deref(), source_resource.asset_object_id.as_deref(), source_resource.object_key.as_deref(), ); let asset = state .spacetime_client() .find_editor_asset_group_source(lookup) .await .map_err(map_editor_project_error)?; Ok(asset.and_then(|asset| normalize_optional_string(asset.task_id))) } fn editor_manual_atlas_split_group_source_lookup_input( owner_user_id: &str, resource_id: &str, source_resource_id: Option<&str>, asset_object_id: Option<&str>, object_key: Option<&str>, ) -> EditorAssetGroupSourceLookupRecordInput { let mut source_resource_ids = vec![resource_id.to_string()]; if let Some(source_resource_id) = normalize_optional_string(source_resource_id.map(str::to_string)) { source_resource_ids.push(source_resource_id); } EditorAssetGroupSourceLookupRecordInput { owner_user_id: owner_user_id.to_string(), source_resource_ids, asset_object_id: asset_object_id.map(str::to_string), object_key: object_key.map(str::to_string), } } 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() { ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), "editor-ui-design-asset-extraction", "sourceImageSrc", "UI设计图", )?; ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-ui-design-asset-extraction", "referenceImageSrcs", "UI素材参考图", )?; 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> { ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), "editor-ui-design-asset-extraction", "sourceImageSrc", "UI设计图", )?; ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-ui-design-asset-extraction", "referenceImageSrcs", "UI素材参考图", )?; // 背景色决策挪到预扣泥点之后(见下方 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设计图必须是 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素材参考图必须是 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.editor_agent_llm_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()), external_call_deadline: request_context.external_call_deadline(), }, }), ) .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.provider_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 = resolve_editor_generated_asset_label(payload.spritesheet_label.clone(), "UI设计图素材图集"); let asset_folder_id = payload .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); let source_persisted = persist_editor_provider_source_image( state, caller.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", ) .await?; let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str()); let source_object_key = source_persisted.object_key.clone(); let source_record = persist_editor_provider_source_resource( state, source_persisted, PersistEditorProviderSourceResourceInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: asset_folder_id.clone(), label: editor_generated_asset_variant_label(source_label.as_str(), "原图"), 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, asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), generation_cost_mud_points: u64::from(expected_price_mud_points), }, ) .await?; caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller .audit_project_id .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), external_call_deadline: request_context.external_call_deadline(), }; let removal = remove_editor_generated_screen_background_with_bgfilter( state, source_object_key.as_str(), screen_color, seg_model, EDITOR_BGFILTER_CROSS_CHECK_DISABLED, &matting_audit, ) .await; let removal = match removal { Ok(removal) => removal, Err(error) => { let failure_message = error.body_text(); tracing::warn!( provider = "editor-ui-design-asset-extraction", operation = "background_postprocess", task_id = %generated.task_id, error = %failure_message, "UI 素材图集原图已保存,但透明背景处理失败,使用原图完成画布" ); let completed_project = complete_editor_canvas_generation( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), source_record.resource.as_ref(), ) .await?; return Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { spritesheet_image_src: source_image_src, spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_warning: None, 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: source_record.resource, spritesheet_asset: source_record.asset, project: completed_project, warning: Some(editor_postprocess_fallback_warning( "生成任务成功,后处理失败。", )), }, )); } }; let removal_provider = removal.provider; let (image, postprocess_dimension_error) = apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, source_object_key.as_str(), source_width, source_height, removal.image, ) .await; if let Some(error) = postprocess_dimension_error { tracing::warn!( task_id = %generated.task_id, provider = removal_provider, source_width, source_height, error = ?error, "UI 素材透明图蒙版尺寸恢复失败,保留去背景服务原始输出" ); } let matting_generation_inputs = build_editor_derived_asset_generation_inputs("UI图集抠图", &removal.model, &source_record); 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, "去除纯色背景", None, EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND, "ui-design-spritesheets", "spritesheet", "spritesheet", removal.provider, ) .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: source_label, 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: "去除纯色背景".to_string(), actual_prompt: None, model: removal.model.clone(), provider: removal.provider.to_string(), task_id: generated.task_id.clone(), group_task_id: None, group_task_expected_asset_count: None, source_resource_id: source_record .resource .as_ref() .map(|resource| resource.resource_id.clone()), asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: matting_generation_inputs, thumbnail_src: None, generation_cost_mud_points: 0, }, ) .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(), group_task_id: None, prompt: "自动拆分图集".to_string(), actual_prompt: None, model: "connected-components".to_string(), provider: "Genarrative".to_string(), generation_inputs: build_editor_derived_asset_generation_inputs( "图集拆分", "connected-components", &spritesheet_record, ), 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, warning: None, }, )) } 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), revision: record.revision, layout_storage_version: record.layout_storage_version, background_color: record.background_color, 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, asset_kind: 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(), asset_kind: resource.asset_kind.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(), ); if object.get("assetKind").and_then(Value::as_str) == Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND) { set_optional_json_string_field(object, "assetKind", media.asset_kind.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), group_task_id: normalize_optional_string(input.group_task_id), group_task_expected_asset_count: input.group_task_expected_asset_count, }) .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, group_task_id: None, group_task_expected_asset_count: None, 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 expected_revision = project.canvas.revision; 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 = save_editor_project_layout_with_revision_and_get( state, project_id, owner_user_id, viewport, completion_result.layers, expected_revision, ) .await?; 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 expected_revision = project.canvas.revision; 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 = save_editor_project_layout_with_revision_and_get( state, project_id, owner_user_id, viewport, completion_result.layers, expected_revision, ) .await?; 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>, replacement_title: Option<&str>, 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 expected_revision = project.canvas.revision; 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, replacement_title, recenter_replacement, ) else { return Ok(None); }; let saved = save_editor_project_layout_with_revision_and_get( state, project_id, owner_user_id, viewport, layers, expected_revision, ) .await?; Ok(Some(editor_project_payload_from_record(saved))) } pub(crate) async fn save_editor_project_layout_with_revision_and_get( state: &AppState, project_id: &str, owner_user_id: &str, viewport: EditorCanvasViewportRecord, layers: Value, expected_revision: u64, ) -> Result { state .spacetime_client() .save_editor_project_layout_v2(EditorProjectLayoutSaveV2RecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), viewport, layers_json: serialize_editor_layers(layers)?, expected_revision, updated_at_micros: current_utc_micros(), }) .await .map_err(map_editor_project_error) } fn replace_editor_canvas_layer_with_resource( layers: Value, target_layer_id: &str, resource: &EditorProjectResourcePayload, replacement_title: Option<&str>, 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()), ); if let Some(title) = replacement_title .map(str::trim) .filter(|title| !title.is_empty()) { object.insert("title".to_string(), Value::String(title.to_string())); } 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_primary_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, primary_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, ); (vec![primary_item], Some(primary_layer_id)) } fn build_multi_asset_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, primary_resource: Option<&EditorProjectResourcePayload>, source_resource: Option<&EditorProjectResourcePayload>, ) -> (Vec, Option) { let (mut items, primary_layer_id) = build_primary_canvas_layer_items(completion, primary_resource); let Some(primary_item) = items.first() else { return (items, primary_layer_id); }; let primary_right = primary_item .get("x") .and_then(Value::as_f64) .unwrap_or(completion.placeholder.x) + primary_item .get("width") .and_then(Value::as_f64) .unwrap_or(completion.placeholder.width.max(1.0)); let primary_y = primary_item .get("y") .and_then(Value::as_f64) .unwrap_or(completion.placeholder.y); 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, &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, 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()) } fn resolve_editor_generated_asset_label(value: Option, fallback: &str) -> String { normalize_optional_string(value) .unwrap_or_else(|| fallback.to_string()) .chars() .take(EDITOR_ASSET_LABEL_MAX_CHARS) .collect() } fn editor_generated_asset_variant_label(label: &str, variant: &str) -> String { let suffix = format!("({variant})"); let base_char_limit = EDITOR_ASSET_LABEL_MAX_CHARS.saturating_sub(suffix.chars().count()); let base = label .trim() .chars() .take(base_char_limit) .collect::(); format!("{base}{suffix}") } 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 是模型无关的画布交付像素;provider_size 只用于满足 GPT 图片接口的合法像素矩阵。 size: String, provider_size: String, provider_image_size: &'static str, } pub(crate) struct PersistedEditorGeneratedImage { pub(crate) object_key: String, pub(crate) asset_object_id: String, } struct PersistEditorProviderSourceResourceInput { 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, asset_kind: Option, generation_inputs: Option, generation_cost_mud_points: u64, } 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 { persist_editor_generated_image_data( state, owner_user_id, task_id, GeneratedImageAssetDataUrl { format: normalize_generated_image_asset_mime(image.mime_type.as_str()), bytes: image.bytes.clone(), }, prompt, actual_prompt, asset_kind, path_kind, file_stem, slot, provider, ) .await } async fn persist_editor_generated_image_owned( 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 image_data = GeneratedImageAssetDataUrl { format: normalize_generated_image_asset_mime(image.mime_type.as_str()), bytes: image.bytes, }; persist_editor_generated_image_data( state, owner_user_id, task_id, image_data, prompt, actual_prompt, asset_kind, path_kind, file_stem, slot, provider, ) .await } async fn persist_editor_generated_image_data( state: &AppState, owner_user_id: &str, task_id: &str, image: GeneratedImageAssetDataUrl, 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, 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_provider_source_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, ) -> Result { persist_editor_generated_image_owned( state, owner_user_id, task_id, image, prompt, actual_prompt, asset_kind, path_kind, &format!("{file_stem}-provider-source"), EDITOR_PROVIDER_SOURCE_SLOT, "vector-engine", ) .await } async fn persist_editor_provider_source_resource( state: &AppState, persisted: PersistedEditorGeneratedImage, input: PersistEditorProviderSourceResourceInput, ) -> 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, group_task_id: None, group_task_expected_asset_count: None, source_resource_id: input.source_resource_id, asset_kind: input.asset_kind, generation_inputs: input.generation_inputs, thumbnail_src: None, generation_cost_mud_points: input.generation_cost_mud_points, }, ) .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_SOURCE_PROBE_BYTES: u64 = 16; fn is_editor_inline_media_source(source: &str) -> bool { source .trim_start() .as_bytes() .get(..5) .is_some_and(|prefix| { prefix.eq_ignore_ascii_case(b"data:") || prefix.eq_ignore_ascii_case(b"blob:") }) } fn ensure_editor_reference_image_source_is_stable( source: &str, provider: &str, field: &str, label: &str, ) -> Result<(), AppError> { if !is_editor_inline_media_source(source) { return Ok(()); } Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "field": field, "message": format!("{label}必须先上传 OSS,并使用 objectKey、项目资源 ID 或素材 ID 引用。"), })), ) } fn ensure_editor_reference_image_sources_are_stable( sources: Option<&[String]>, provider: &str, field: &str, label: &str, ) -> Result<(), AppError> { for source in sources.unwrap_or(&[]) { ensure_editor_reference_image_source_is_stable(source, provider, field, label)?; } Ok(()) } pub(crate) async fn parse_editor_reference_image( state: &AppState, owner_user_id: &str, source: &str, ) -> Result { let object_key = resolve_editor_reference_object_key_for_owner(state, owner_user_id, source).await?; read_editor_reference_image_object(state, object_key.as_str()).await } pub(crate) async fn resolve_editor_reference_object_key_for_owner( state: &AppState, owner_user_id: &str, source: &str, ) -> Result { let object_key = resolve_editor_reference_object_key(state, owner_user_id, source).await?; ensure_editor_reference_object_key_owned(state, owner_user_id, object_key.as_str()).await?; Ok(object_key) } 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() } pub(crate) 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": "图片参考图必须是已登记的 generated objectKey、项目资源或素材引用。", })), ); } Ok(object_key) } async fn resolve_editor_reference_object_key( state: &AppState, owner_user_id: &str, source: &str, ) -> Result { ensure_editor_reference_image_source_is_stable( source, "editor-reference-image", "sourceImageSrc", "图片参考图", )?; 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, })) } 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 mut 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 mut bytes = Vec::new(); while let Some(chunk) = response.chunk().await.map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": format!("读取图片参考图内容失败:{error}"), })) })? { if bytes.len().saturating_add(chunk.len()) as u64 > EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES { return Err(editor_reference_image_too_large()); } bytes.extend_from_slice(chunk.as_ref()); } if bytes.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-reference-image", "field": "sourceImageSrc", "message": "图片参考图为空。", })), ); } let mime_type = content_type_mime .or_else(|| infer_editor_reference_image_mime_type(bytes.as_slice()).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, mime_type, file_name: format!("editor-reference.{extension}"), }) } async fn download_editor_persisted_image_object( state: &AppState, object_key: &str, ) -> Result { let image = read_editor_reference_image_object(state, object_key).await?; Ok(DownloadedOpenAiImage { extension: editor_reference_image_extension(image.mime_type.as_str()).to_string(), mime_type: image.mime_type, bytes: image.bytes, }) } 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) if message.contains("版本冲突") => { AppError::from_status(StatusCode::CONFLICT).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}; use std::{ io::{Read as _, Write as _}, net::{TcpListener, TcpStream}, sync::mpsc, thread, }; fn encode_test_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 PNG should encode"); bytes.into_inner() } fn read_mock_http_request(stream: &mut TcpStream) -> Vec { stream .set_read_timeout(Some(Duration::from_secs(1))) .expect("mock request read timeout should be set"); let mut request = Vec::new(); let mut chunk = [0_u8; 1024]; let mut expected_total = None; loop { match stream.read(&mut chunk) { Ok(0) => break, Ok(bytes_read) => { request.extend_from_slice(&chunk[..bytes_read]); if expected_total.is_none() && let Some(header_end) = request .windows(4) .position(|window| window == b"\r\n\r\n") .map(|index| index + 4) { let headers = String::from_utf8_lossy(&request[..header_end]); let content_length = headers .lines() .find_map(|line| { let (name, value) = line.split_once(':')?; name.eq_ignore_ascii_case("content-length") .then(|| value.trim().parse::().ok()) .flatten() }) .unwrap_or(0); expected_total = Some(header_end + content_length); } if expected_total.is_some_and(|total| request.len() >= total) { break; } } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock || error.kind() == std::io::ErrorKind::TimedOut => { break; } Err(error) => panic!("mock server failed to read request: {error}"), } } request } fn parse_mock_http_json_body(request: &[u8]) -> Value { let body_start = request .windows(4) .position(|window| window == b"\r\n\r\n") .map(|index| index + 4) .expect("mock request should contain an HTTP header terminator"); serde_json::from_slice(&request[body_start..]) .expect("mock request body should be valid JSON") } fn spawn_bgfilter_worker_png_mock( response_png: Vec, ) -> (String, mpsc::Receiver>, thread::JoinHandle<()>) { spawn_bgfilter_worker_png_mock_with_delay(response_png, Duration::ZERO) } fn spawn_bgfilter_worker_png_mock_with_delay( response_png: Vec, response_delay: Duration, ) -> (String, mpsc::Receiver>, thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener should bind"); let address = listener .local_addr() .expect("mock listener should expose address"); let (request_sender, request_receiver) = mpsc::channel(); let server = thread::spawn(move || { let (mut stream, _) = listener .accept() .expect("BgFilter worker request should connect"); let request = read_mock_http_request(&mut stream); request_sender .send(request) .expect("captured request should be delivered"); thread::sleep(response_delay); let headers = format!( "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", response_png.len() ); stream .write_all(headers.as_bytes()) .expect("mock response headers should be written"); stream .write_all(response_png.as_slice()) .expect("mock response PNG should be written"); stream.flush().expect("mock response should flush"); }); (format!("http://{address}"), request_receiver, server) } 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 editor_project_revision_conflicts_map_to_http_conflict() { let error = map_editor_project_error(SpacetimeClientError::Procedure( "图片画布版本冲突:期望 revision 4,当前为 5".to_string(), )); assert_eq!(error.status_code(), StatusCode::CONFLICT); } #[test] fn editor_project_layout_save_request_requires_expected_revision() { let missing_revision = serde_json::from_value::(json!({ "viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 }, "layers": [], })) .expect_err("现役画布保存缺少 expectedRevision 时必须在进入写路径前失败"); assert!(missing_revision.to_string().contains("expectedRevision")); let request = serde_json::from_value::(json!({ "viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 }, "layers": [], "expectedRevision": 7, })) .expect("携带 expectedRevision 的画布保存请求应通过反序列化"); assert_eq!(request.expected_revision, 7); } 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_layout_serialization_accepts_payload_up_to_two_mib() { let payload = json!("x".repeat(EDITOR_LAYOUT_MAX_BYTES - 2)); let serialized = serialize_editor_layers(payload).expect("two MiB layout should save"); assert_eq!(serialized.len(), EDITOR_LAYOUT_MAX_BYTES); } #[test] fn editor_layout_serialization_rejects_payload_over_two_mib() { let payload = json!("x".repeat(EDITOR_LAYOUT_MAX_BYTES - 1)); let error = serialize_editor_layers(payload).expect_err("oversized layout should fail"); assert!(error.body_text().contains("图层布局过大")); } #[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", None) .await .unwrap(), 3 ); assert_eq!( resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "2048x1152", None) .await .unwrap(), 5 ); assert_eq!( resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "2048x1152", Some("1K"),) .await .unwrap(), 5 ); assert_eq!( resolve_editor_image_edit_price( &state, EDITOR_IMAGE_MODEL_NANOBANANA2, "2048x2048", Some("2K"), ) .await .unwrap(), 24 ); assert_eq!( resolve_editor_image_edit_price( &state, EDITOR_IMAGE_MODEL_NANOBANANA2, "512x512", Some("0.5K"), ) .await .unwrap(), 8 ); assert!(editor_image_edit_uses_nanobanana_generate_content( EDITOR_IMAGE_MODEL_NANOBANANA2 )); assert!(!editor_image_edit_uses_nanobanana_generate_content( GPT_IMAGE_2_MODEL )); } #[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 provider_output = DownloadedOpenAiImage { bytes: encode_png(1552, 1040), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let restored = restore_editor_image_edit_output_dimensions(&provider_output, &dimensions) .expect("provider output should restore source dimensions") .expect("aligned provider output should require a transformation"); 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 static_image_postprocess_restores_only_alpha_at_provider_dimensions() { let provider_source = image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 10, 20, 255])); let mut provider_source_bytes = Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(provider_source) .write_to(&mut provider_source_bytes, image::ImageFormat::Png) .expect("test image should encode"); let provider_source = DownloadedOpenAiImage { bytes: provider_source_bytes.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let postprocessed = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 240, 77])); let mut postprocessed_bytes = Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(postprocessed) .write_to(&mut postprocessed_bytes, image::ImageFormat::Png) .expect("test image should encode"); let postprocessed = DownloadedOpenAiImage { bytes: postprocessed_bytes.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let restored = apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed) .expect("postprocessed alpha should restore provider dimensions") .expect("smaller alpha mask should be applied to provider source"); let restored_image = image::load_from_memory(restored.bytes.as_slice()) .unwrap() .to_rgba8(); assert_eq!((restored_image.width(), restored_image.height()), (4, 4)); assert_eq!(restored_image.get_pixel(1, 1).0, [200, 10, 20, 77]); assert_eq!(restored.mime_type, "image/png"); assert_eq!(restored.extension, "png"); } #[test] fn publication_material_generation_restores_provider_output_to_workflow_dimensions() { let image = image::DynamicImage::new_rgba8(944, 704); let mut bytes = Cursor::new(Vec::new()); image .write_to(&mut bytes, image::ImageFormat::Png) .expect("test image should encode"); let provider_output = DownloadedOpenAiImage { bytes: bytes.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let restored = restore_editor_generated_image_output_dimensions( &provider_output, GPT_IMAGE_2_MODEL, "720x540", ) .expect("provider output should restore delivery dimensions") .expect("mismatched provider output should require a transformation"); let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap(); assert_eq!( (restored_image.width(), restored_image.height()), (720, 540) ); assert_eq!(restored.mime_type, "image/png"); assert_eq!(restored.extension, "png"); } #[test] fn nanobanana_generation_restores_shared_business_pixel_size() { let image = image::DynamicImage::new_rgba8(2752, 1536); let mut bytes = Cursor::new(Vec::new()); image .write_to(&mut bytes, image::ImageFormat::Png) .expect("test image should encode"); let provider_output = DownloadedOpenAiImage { bytes: bytes.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let (restored, restore_error) = restore_editor_generated_image_output_dimensions_or_original( provider_output, EDITOR_IMAGE_MODEL_NANOBANANA2, "2048x1152", ); assert!(restore_error.is_none()); let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap(); assert_eq!( (restored_image.width(), restored_image.height()), (2048, 1152) ); } #[test] fn provider_1k_under_delivery_is_not_upscaled_to_2k() { let image = image::DynamicImage::new_rgba8(1024, 576); let mut bytes = Cursor::new(Vec::new()); image .write_to(&mut bytes, image::ImageFormat::Png) .expect("test image should encode"); let provider_output = DownloadedOpenAiImage { bytes: bytes.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let expected_bytes = provider_output.bytes.clone(); let (delivery_output, restore_error) = restore_editor_generated_image_output_dimensions_or_original( provider_output, EDITOR_IMAGE_MODEL_NANOBANANA2, "2048x1152", ); let restore_error = restore_error.expect("under delivery should be recorded"); assert_eq!(delivery_output.bytes, expected_bytes); assert_eq!( restore_error .details() .and_then(|details| details.get("code")), Some(&json!("provider-image-under-delivery")) ); let warning = editor_dimension_restore_warning(&restore_error); assert_eq!(warning.code, EDITOR_GENERATION_DIMENSION_WARNING_CODE); assert!(warning.reason.contains("禁止放大伪造清晰度档位")); let delivery_image = image::load_from_memory(delivery_output.bytes.as_slice()).unwrap(); assert_eq!( (delivery_image.width(), delivery_image.height()), (1024, 576) ); } #[test] fn generated_dimension_restore_allows_only_slight_aspect_ratio_crop() { ensure_editor_output_can_restore_without_upscaling( 2064, 1152, 2048, 1152, "test-generation", ) .expect("provider rounding drift should allow slight crop"); let error = ensure_editor_output_can_restore_without_upscaling( 2048, 2048, 2048, 1152, "test-generation", ) .expect_err("large aspect ratio crop should be rejected"); assert_eq!( error.details().and_then(|details| details.get("code")), Some(&json!("provider-image-aspect-ratio-mismatch")) ); } #[test] fn failed_dimension_restore_keeps_provider_output_for_single_upload() { let image = image::DynamicImage::new_rgba8(896, 896); let mut bytes = Cursor::new(Vec::new()); image .write_to(&mut bytes, image::ImageFormat::Png) .expect("test image should encode"); let provider_output = DownloadedOpenAiImage { bytes: bytes.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let expected_bytes = provider_output.bytes.clone(); let (delivery_output, restore_error) = restore_editor_generated_image_output_dimensions_or_original( provider_output, GPT_IMAGE_2_MODEL, "invalid-size", ); assert!(restore_error.is_some()); assert_eq!(delivery_output.bytes, expected_bytes); assert_eq!(delivery_output.mime_type, "image/png"); assert_eq!(delivery_output.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, "512x512"); 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, "512x512"); 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, "2048x1152"); 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, "683x1024"); assert_eq!(gpt.provider_size, "688x1024"); 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, "1024x768"); 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, "1024x768"); assert_eq!(gpt_cover.provider_size, "1024x768"); 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 = normalize_editor_generation_options(Some("gpt-image-2"), Some("9:16"), Some("2K")); assert_eq!(gpt_portrait_2k.model, GPT_IMAGE_2_MODEL); assert_eq!(gpt_portrait_2k.size, "1152x2048"); assert_eq!(gpt_portrait_2k.aspect_ratio, "9:16"); assert_eq!(gpt_portrait_2k.image_size, "2K"); for (image_size, aspect_ratio, expected_size) in [ ("1K", "1:1", "1024x1024"), ("1K", "4:3", "1024x768"), ("1K", "3:2", "1024x683"), ("1K", "2:3", "683x1024"), ("1K", "9:16", "576x1024"), ("1K", "16:9", "1024x576"), ("2K", "1:1", "2048x2048"), ("2K", "4:3", "2048x1536"), ("2K", "3:2", "2048x1365"), ("2K", "2:3", "1365x2048"), ("2K", "9:16", "1152x2048"), ("2K", "16:9", "2048x1152"), ] { let options = normalize_editor_generation_options( Some(GPT_IMAGE_2_MODEL), Some(aspect_ratio), Some(image_size), ); assert_eq!(options.size, expected_size); } for (image_size, aspect_ratio, expected_delivery_size) in [ ("1K", "16:9", "1024x576"), ("1K", "2:3", "683x1024"), ("2K", "3:2", "2048x1365"), ("2K", "9:16", "1152x2048"), ] { let gpt = normalize_editor_generation_options( Some(GPT_IMAGE_2_MODEL), Some(aspect_ratio), Some(image_size), ); let nanobanana = normalize_editor_generation_options( Some(EDITOR_IMAGE_MODEL_NANOBANANA2), Some(aspect_ratio), Some(image_size), ); assert_eq!(gpt.size, expected_delivery_size); assert_eq!(nanobanana.size, expected_delivery_size); assert_eq!(gpt.size, nanobanana.size); } let gpt_landscape_1k = normalize_editor_generation_options(Some(GPT_IMAGE_2_MODEL), Some("16:9"), Some("1K")); assert_eq!(gpt_landscape_1k.provider_size, "1088x608"); assert_eq!(gpt_landscape_1k.size, "1024x576"); for (image_size, aspect_ratio, expected_size) in [ ("0.5K", "1:1", "512x512"), ("0.5K", "4:3", "512x384"), ("0.5K", "3:2", "512x341"), ("0.5K", "2:3", "341x512"), ("0.5K", "9:16", "288x512"), ("0.5K", "16:9", "512x288"), ("1K", "1:1", "1024x1024"), ("1K", "4:3", "1024x768"), ("1K", "3:2", "1024x683"), ("1K", "2:3", "683x1024"), ("1K", "9:16", "576x1024"), ("1K", "16:9", "1024x576"), ("2K", "1:1", "2048x2048"), ("2K", "4:3", "2048x1536"), ("2K", "3:2", "2048x1365"), ("2K", "2:3", "1365x2048"), ("2K", "9:16", "1152x2048"), ("2K", "16:9", "2048x1152"), ] { let options = normalize_editor_generation_options( Some(EDITOR_IMAGE_MODEL_NANOBANANA2), Some(aspect_ratio), Some(image_size), ); assert_eq!(options.size, expected_size); } 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, "1024x1024"); assert_eq!(fallback.aspect_ratio, "1:1"); assert_eq!(fallback.image_size, "1K"); assert_eq!(fallback.provider_size, "1024x1024"); 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), "1024x768" ); assert_eq!( resolve_editor_image_provider_request_size( Some("publication-material"), Some("720x540"), true, &options, ), "720x540" ); } #[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("BgFilter complex".to_string()), provider: Some("BgFilter".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, None, 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_eq!(layer["title"], json!("源图")); assert!(layer.get("sourceAssetId").is_none()); assert!(layer.get("thumbnailSrc").is_none()); let recentered = replace_editor_canvas_layer_with_resource( layers, "layer-source", &resource, Some("勇者快速编辑"), 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["title"], json!("勇者快速编辑")); 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_character_generation_places_processed_result_and_provider_source() { 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", "character", 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]["assetKind"], json!("character")); 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_postprocess_fallback_only_anchors_provider_source() { let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-character-fallback".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 source = editor_project_resource_for_canvas_test( "resource-character-source", "character", 400, 600, ); let source_layer_id = generated_canvas_layer_id(source.resource_id.as_str()); let source_item = build_generated_canvas_layer_item( &completion, &completion.placeholder, &source, source_layer_id.as_str(), 0, ); let layers = json!([{ "itemType": "generation-dialog", "layerId": "generation-dialog:dialog-character-fallback", "resourceId": "generation-dialog:dialog-character-fallback", "dialog": { "id": "dialog-character-fallback", "status": "generating", "placeholder": completion.placeholder.clone() } }]); let completed = apply_editor_canvas_generation_multi_items( layers, &completion, vec![source_item], Some(source_layer_id.clone()), ) .expect("source-only fallback should complete the generation dialog"); let completed_items = completed .layers .as_array() .expect("layers should stay array"); let generated_resources = completed_items .iter() .filter(|item| { item.get("resourceId") .and_then(Value::as_str) .is_some_and(|resource_id| resource_id.starts_with("resource-character-")) }) .collect::>(); assert_eq!(generated_resources.len(), 1); assert_eq!( generated_resources[0]["resourceId"], json!("resource-character-source") ); 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!(source_layer_id)); } #[test] fn editor_canvas_spritesheet_items_include_processed_sheet_provider_source_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-sheet-source", "icon-spritesheet", 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-sheet-source")); assert_eq!(items[1]["title"], json!("UI素材图集(原图)")); 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_processed_sheet_and_provider_source_without_slices() { 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-sheet-source", "icon-spritesheet", 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-sheet-source")); assert_eq!(items[1]["title"], json!("图标素材图集(原图)")); 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_inline_media_is_not_an_object_reference() { for inline_source in ["data:image/png;base64,AAAA", "blob:browser-only"] { let error = normalize_editor_reference_object_key(inline_source) .expect_err("inline image should require upload before generation"); assert!(error.body_text().contains("objectKey")); } let source = include_str!("editor_project.rs"); assert_function_contains_in_order( source, "async fn resolve_editor_reference_object_key(", "async fn find_editor_reference_object_key_by_registered_id", &[ "ensure_editor_reference_image_source_is_stable", "normalize_editor_reference_object_key", ], ); } #[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); } #[test] fn editor_background_removal_source_requires_supported_content_type_and_matching_magic() { assert_eq!( normalize_editor_background_removal_source_mime_type(Some("IMAGE/PNG; charset=utf-8")), Some("image/png") ); assert_eq!( normalize_editor_background_removal_source_mime_type(Some("image/gif")), None ); assert_eq!( normalize_editor_background_removal_source_mime_type(Some("application/pdf")), None ); assert_eq!( normalize_editor_background_removal_source_mime_type(None), None ); assert!( validate_editor_background_removal_source_content( "image/png", b"\x89PNG\r\n\x1a\nrest" ) .is_ok() ); assert_eq!( validate_editor_background_removal_source_content( "image/png", &[0xff, 0xd8, 0xff, 0xe0] ) .expect_err("mismatched image content should be rejected") .status_code(), StatusCode::UNSUPPORTED_MEDIA_TYPE ); assert_eq!( validate_editor_background_removal_source_content("image/png", b"not image") .expect_err("non-image content should be rejected") .status_code(), StatusCode::UNSUPPORTED_MEDIA_TYPE ); } #[test] fn editor_character_postprocess_fallback_response_keeps_source_and_warning() { let response = EditorImageGenerationResponse { image_src: "/api/assets/source-character.png".to_string(), object_key: Some("generated/editor/character-source.png".to_string()), asset_object_id: Some("asset-object-character-source".to_string()), width: 1024, height: 1536, source_type: "generated", prompt: "角色形象".to_string(), actual_prompt: Some("角色形象 actual".to_string()), model: GPT_IMAGE_2_MODEL.to_string(), provider: "VectorEngine", task_id: "task-character-source".to_string(), resource: None, asset: None, project: None, warning: Some(editor_postprocess_fallback_warning( "生成任务成功,后处理失败。", )), }; let payload = serde_json::to_value(response).expect("response should serialize"); assert_eq!( payload["imageSrc"], json!("/api/assets/source-character.png") ); assert_eq!( payload["objectKey"], json!("generated/editor/character-source.png") ); assert_eq!( payload["warning"]["code"], json!(EDITOR_GENERATION_POSTPROCESS_WARNING_CODE) ); assert_eq!( payload["warning"]["reason"], json!("生成任务成功,后处理失败。") ); } #[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, warning: 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_eq!( payload["sliceWarning"]["reason"], json!("图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。") ); } #[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": "/generated-images/editor/ui-design.png", "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": "/generated-images/editor/source.png", "size": "2048x1152", "model": "gemini-3.1-flash-image-preview", "aspectRatio": "16:9", "imageSize": "2K", "referenceImageSrcs": ["/generated-images/editor/style.png"], "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("gemini-3.1-flash-image-preview") ); assert_eq!(request.aspect_ratio.as_deref(), Some("16:9")); assert_eq!(request.image_size.as_deref(), Some("2K")); let legacy_options = normalize_editor_image_edit_generation_options( request.model.as_deref(), None, None, Some("2048x1365"), ); assert_eq!(legacy_options.aspect_ratio, "3:2"); assert_eq!(legacy_options.image_size, "2K"); assert_eq!( request.reference_image_srcs, Some(vec!["/generated-images/editor/style.png".to_string()]) ); assert_eq!(request.target_layer_id.as_deref(), Some("layer-source")); } #[test] fn editor_image_edit_rejects_individual_icons_but_allows_spritesheets_and_specs() { let error = ensure_editor_image_edit_asset_kind_allowed(Some("icon")) .expect_err("individual 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!("icon")), ); assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spritesheet")).is_ok()); 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_inline_media_sources_are_rejected_before_queueing() { let source = include_str!("editor_project.rs"); for (start, end) in [ ( "pub async fn generate_editor_image(", "pub(crate) async fn generate_editor_image_for_owner", ), ( "pub async fn edit_editor_image(", "pub(crate) async fn edit_editor_image_for_owner", ), ( "pub async fn remove_editor_image_background(", "pub(crate) async fn remove_editor_image_background_for_owner", ), ( "pub async fn generate_editor_icon_spritesheet(", "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", ), ( "pub async fn extract_editor_ui_design_assets(", "pub(crate) async fn extract_editor_ui_design_assets_for_owner", ), ] { assert_function_contains_in_order( source, start, end, &[ "ensure_editor_reference_image_source", "enqueue_editor_generation_job", ], ); } for inline_source in [ "data:image/png;base64,AAAA", " DATA:image/png;base64,AAAA", "blob:browser-only", ] { let error = ensure_editor_reference_image_source_is_stable( inline_source, "editor-test", "sourceImageSrc", "测试图片", ) .expect_err("inline media must require upload before generation"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!(error.body_text().contains("先上传 OSS")); } assert!( ensure_editor_reference_image_source_is_stable( "/generated-images/editor/reference.png", "editor-test", "sourceImageSrc", "测试图片", ) .is_ok() ); } #[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_stable_extra_references() { let request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ "referenceImageSrc": "/generated-images/editor/spec.png", "referenceImageSrcs": ["/generated-images/editor/style.png"], "iconDescriptions": ["返回按钮"], "screenColor": "#7FB3FF", "segModel": "anime-seg", "assetLabel": " 冒险界面图标 " })) .expect("icon spritesheet request should deserialize references"); assert_eq!( request.reference_image_srcs, Some(vec!["/generated-images/editor/style.png".to_string()]) ); assert_eq!(request.screen_color.as_deref(), Some("#7FB3FF")); assert_eq!(request.seg_model.as_deref(), Some("anime-seg")); assert_eq!(request.asset_label.as_deref(), Some(" 冒险界面图标 ")); } #[test] fn editor_generated_asset_label_trims_custom_name_and_falls_back_for_blank_input() { assert_eq!( resolve_editor_generated_asset_label( Some(" 冒险界面图标 ".to_string()), "图标素材图集" ), "冒险界面图标" ); assert_eq!( resolve_editor_generated_asset_label(Some(" ".to_string()), "图标素材图集"), "图标素材图集" ); assert_eq!( resolve_editor_generated_asset_label(Some("图".repeat(81)), "图标素材图集") .chars() .count(), EDITOR_ASSET_LABEL_MAX_CHARS ); assert_eq!( editor_generated_asset_variant_label("冒险界面图标", "原图"), "冒险界面图标(原图)" ); let long_variant = editor_generated_asset_variant_label("图".repeat(80).as_str(), "原图"); assert_eq!(long_variant.chars().count(), EDITOR_ASSET_LABEL_MAX_CHARS); assert!(long_variant.ends_with("(原图)")); } #[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_manual_atlas_split_lookup_keeps_original_provenance_after_project_copy() { let mut copied_resource = editor_project_resource_for_canvas_test( "copied-resource-atlas-1", "icon-spritesheet", 1024, 1024, ); copied_resource.source_resource_id = Some("resource-atlas-1".to_string()); copied_resource.asset_object_id = Some("asset-object-atlas-1".to_string()); copied_resource.object_key = Some("generated/icon-atlas-1.png".to_string()); let lookup = editor_manual_atlas_split_group_source_lookup_input( "user-1", copied_resource.resource_id.as_str(), copied_resource.source_resource_id.as_deref(), copied_resource.asset_object_id.as_deref(), copied_resource.object_key.as_deref(), ); assert_eq!(lookup.owner_user_id, "user-1"); assert_eq!( lookup.source_resource_ids, vec!["copied-resource-atlas-1", "resource-atlas-1"] ); assert_eq!( lookup.asset_object_id.as_deref(), Some("asset-object-atlas-1") ); assert_eq!( lookup.object_key.as_deref(), Some("generated/icon-atlas-1.png") ); } #[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_worker_budget_covers_two_attempts_and_parent_reserves() { let config = AppConfig::default(); // N=16、est=5s → attempt=160s、callBudget=321s,全部由公式运行时派生。 assert_eq!(config.bgfilter_provider_attempt_timeout_ms(), 160_000); assert_eq!(config.bgfilter_call_budget_ms(), 321_000); assert_eq!( editor_bgfilter_flat_deadline_reserve(30_000), Duration::from_secs(39) ); // complex:父剩余 400s − 2s 传输窗 − 321s callBudget → 排队上限约 77s。 let complex_deadline = Instant::now() + Duration::from_secs(400); let complex_wait = crate::bgfilter_worker::max_queue_wait_ms( config.bgfilter_call_budget_ms(), Some(complex_deadline), EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW, ); assert!((76_500..=77_000).contains(&complex_wait)); // flat:再扣 39s fallback 预留 → 约 40s,排队吃不掉 fallback 的执行窗口。 let flat_deadline = Instant::now() + Duration::from_secs(400); let flat_wait = crate::bgfilter_worker::max_queue_wait_ms( config.bgfilter_call_budget_ms(), Some(flat_deadline), editor_bgfilter_flat_deadline_reserve(30_000), ); assert!((39_500..=40_000).contains(&flat_wait)); // 父剩余放不下一次完整调用(排队 + callBudget + 预留)时必须为 0:不发请求直接降级。 let tight_deadline = Instant::now() + Duration::from_secs(100); assert_eq!( crate::bgfilter_worker::max_queue_wait_ms( config.bgfilter_call_budget_ms(), Some(tight_deadline), editor_bgfilter_flat_deadline_reserve(30_000), ), 0 ); } #[tokio::test] async fn editor_flat_bgfilter_expired_parent_deadline_does_not_start_fallback() { let state = AppState::new(AppConfig::default()).expect("state should build"); let audit = crate::external_api_audit::ExternalApiAuditContext { user_id: Some("user-deadline".to_string()), profile_id: None, request_id: Some("request-deadline".to_string()), external_call_deadline: Instant::now().checked_sub(Duration::from_millis(1)), }; let error = match remove_editor_generated_screen_background_with_bgfilter( &state, "generated-character-drafts/editor/source.png", parse_editor_screen_background_color(Some("#CFEFFF")) .expect("screen color should parse"), EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_BGFILTER_CROSS_CHECK_ENABLED, &audit, ) .await { Ok(_) => panic!("expired parent deadline should stop before flat fallback"), Err(error) => error, }; assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); assert_eq!( error .details() .and_then(|details| details.get("workerCode")), Some(&json!("deadline_exceeded")) ); } #[tokio::test] async fn editor_flat_bgfilter_background_removal_uses_internal_worker_once() { let response_png = encode_test_png(3, 2); let (base_url, request_receiver, server) = spawn_bgfilter_worker_png_mock_with_delay( response_png.clone(), Duration::from_millis(100), ); let state = AppState::new(AppConfig { bgfilter_worker_base_url: base_url, bgfilter_internal_token: Some("flat-internal-token".to_string()), ..AppConfig::default() }) .expect("state should build"); let audit = crate::external_api_audit::ExternalApiAuditContext { user_id: Some("user-flat".to_string()), profile_id: Some("project-flat".to_string()), request_id: Some("request-flat".to_string()), external_call_deadline: None, }; let removed = remove_editor_generated_screen_background_with_bgfilter( &state, "generated-character-drafts/editor/source.png", parse_editor_screen_background_color(Some("#CFEFFF")) .expect("screen color should parse"), EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_BGFILTER_CROSS_CHECK_ENABLED, &audit, ) .await .expect("internal worker PNG response should decode"); let request = request_receiver .recv_timeout(Duration::from_secs(1)) .expect("mock server should capture request"); server.join().expect("mock server should stop cleanly"); let request_text = String::from_utf8_lossy(request.as_slice()); let lowercase_request = request_text.to_ascii_lowercase(); let payload = parse_mock_http_json_body(request.as_slice()); assert!( request_text.starts_with("POST /internal/bgfilter/v1/remove-background HTTP/1.1\r\n") ); assert!(lowercase_request.contains("\r\nauthorization: bearer flat-internal-token\r\n")); let internal_request_id = payload["requestId"] .as_str() .expect("internal request id should be a string"); assert!(uuid::Uuid::parse_str(internal_request_id).is_ok()); assert_ne!(internal_request_id, "request-flat"); assert_eq!( payload["sourceObjectKey"], json!("generated-character-drafts/editor/source.png") ); assert_eq!(payload["backgroundMode"], json!("flat")); assert_eq!(payload["screenColor"], json!("#CFEFFF")); assert_eq!(payload["segModel"], json!("birefnet")); assert_eq!(payload["crossCheck"], json!(true)); // 无绝对 deadline 时排队额度取 callBudget 等长的有界默认值;两者都由 N/est 公式派生。 assert_eq!(payload["maxQueueWaitMs"], json!(321_000)); assert_eq!(payload["callBudgetMs"], json!(321_000)); assert_eq!(payload["auditContext"]["userId"], json!("user-flat")); assert_eq!(payload["auditContext"]["requestId"], json!("request-flat")); assert_eq!(removed.image.bytes, response_png); assert_eq!(removed.image.mime_type, "image/png"); assert_eq!(removed.image.extension, "png"); assert_eq!(removed.provider, "BgFilter"); } #[tokio::test] async fn editor_complex_bgfilter_background_removal_uses_internal_worker_once() { let response_png = encode_test_png(3, 2); let (base_url, request_receiver, server) = spawn_bgfilter_worker_png_mock(response_png.clone()); let state = AppState::new(AppConfig { bgfilter_worker_base_url: base_url, bgfilter_internal_token: Some("complex-internal-token".to_string()), ..AppConfig::default() }) .expect("state should build"); let audit = crate::external_api_audit::ExternalApiAuditContext { user_id: Some("user-complex".to_string()), profile_id: None, request_id: Some("request-complex".to_string()), external_call_deadline: None, }; let removed = request_editor_background_removal_image_with_bgfilter_worker( &state, "generated-character-drafts/editor/manual-source.png", &audit, ) .await .expect("internal worker PNG response should decode"); let request = request_receiver .recv_timeout(Duration::from_secs(1)) .expect("mock server should capture request"); server.join().expect("mock server should stop cleanly"); let request_text = String::from_utf8_lossy(request.as_slice()); let lowercase_request = request_text.to_ascii_lowercase(); let payload = parse_mock_http_json_body(request.as_slice()); assert!( request_text.starts_with("POST /internal/bgfilter/v1/remove-background HTTP/1.1\r\n") ); assert!(lowercase_request.contains("\r\nauthorization: bearer complex-internal-token\r\n")); let internal_request_id = payload["requestId"] .as_str() .expect("internal request id should be a string"); assert!(uuid::Uuid::parse_str(internal_request_id).is_ok()); assert_ne!(internal_request_id, "request-complex"); assert_eq!( payload["sourceObjectKey"], json!("generated-character-drafts/editor/manual-source.png") ); assert_eq!(payload["backgroundMode"], json!("complex")); assert!(payload["screenColor"].is_null()); assert_eq!(payload["segModel"], json!("birefnet")); assert_eq!(payload["crossCheck"], json!(false)); assert_eq!(payload["maxQueueWaitMs"], json!(321_000)); assert_eq!(payload["callBudgetMs"], json!(321_000)); assert_eq!( payload["auditContext"]["requestId"], json!("request-complex") ); assert_eq!((removed.width, removed.height), (3, 2)); assert_eq!(removed.image.mime_type, "image/png"); assert_eq!(removed.image.extension, "png"); assert_eq!(removed.image.bytes, response_png); } #[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_provider_source_image", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", "persist_editor_provider_source_resource", "build_multi_asset_canvas_layer_items", "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_provider_source_image", "persist_editor_provider_source_resource", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", ], ); assert_function_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &[ "persist_editor_provider_source_image", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", "persist_editor_provider_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_provider_source_image", "persist_editor_provider_source_resource", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", ], ); assert_function_contains_in_order( source, "pub(crate) async fn remove_editor_image_background_for_owner", "struct EditorBackgroundRemovalImage", &[ "caller.report_processing_phase(state).await?", "resolve_editor_reference_object_key_for_owner", "validate_editor_background_removal_source", "request_editor_background_removal_image_with_bgfilter_worker", ], ); 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_provider_source_image", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", "persist_editor_provider_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_provider_source_image", "persist_editor_provider_source_resource", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", ], ); assert_function_contains_in_order( source, "pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(", "async fn fallback_editor_screen_background_removal", &[ "crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry", "crate::bgfilter_worker::BgfilterBackgroundMode::Flat", "editor_bgfilter_flat_deadline_reserve", "state.config.aliyun_matting_request_timeout_ms", "error.allows_flat_fallback()", "fallback_editor_screen_background_removal", ], ); assert_function_not_contains( source, "pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(", "async fn fallback_editor_screen_background_removal", &[ "for attempt in", concat!("EDITOR_BGFILTER_", "RETRY_COUNT"), concat!("editor_bgfilter_", "circuit"), "reqwest::multipart", concat!("editor_bgfilter_", "http_client"), ], ); assert_function_contains_in_order( source, "async fn fallback_editor_screen_background_removal", "fn sign_editor_private_object_read_url", &[ "segment_image_url_with_aliyun_matting", "download_editor_persisted_image_object", "remove_editor_generated_green_screen_background", "drop(source_image)", ], ); assert_function_contains( source, "async fn request_editor_background_removal_image_with_bgfilter_worker", "pub(crate) struct EditorScreenBackgroundRemovalOutput", &[ "crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry", "crate::bgfilter_worker::BgfilterBackgroundMode::Complex", "EDITOR_BGFILTER_DEFAULT_SEG_MODEL", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", "EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW", ".map_err(crate::bgfilter_worker::BgfilterClientError::into_app_error)?", ], ); assert_function_not_contains( source, "async fn request_editor_background_removal_image_with_bgfilter_worker", "pub(crate) struct EditorScreenBackgroundRemovalOutput", &[ "for attempt in", "fallback_editor_screen_background_removal", "reqwest::multipart", concat!("editor_bgfilter_", "http_client"), concat!("editor_bgfilter_", "base_url"), concat!("editor_bgfilter_", "token"), ], ); assert!(!source.contains(concat!("const EDITOR_BGFILTER_", "RETRY_COUNT"))); assert!(!source.contains(concat!("static EDITOR_BGFILTER_", "CIRCUIT"))); 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_flat_bgfilter_parent_calls_internal_worker_once_before_fallback() { let source = include_str!("editor_project.rs"); assert_function_contains( source, "pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(", "async fn fallback_editor_screen_background_removal", &[ "crate::bgfilter_worker::request_bgfilter_worker", "fallback_editor_screen_background_removal", ], ); assert_function_not_contains( source, "pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(", "async fn fallback_editor_screen_background_removal", &[ "for attempt in", "will_retry", concat!("EDITOR_BGFILTER_", "RETRY_COUNT"), ], ); } #[test] fn editor_processing_phase_retries_transport_once_but_not_fencing() { let source = include_str!("editor_project.rs"); assert!(source.contains("const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1;")); assert_function_contains_in_order( source, "pub(crate) async fn report_processing(&self, state: &AppState)", "fn map_editor_generation_phase_update_error", &[ "let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1", "for attempt in 1..=max_attempts", ".update_external_generation_job_phase(input.clone())", "should_retry_editor_generation_phase_update", "if will_retry", "continue", "map_editor_generation_phase_update_error(error)", ], ); assert_function_contains( source, "fn map_editor_generation_phase_update_error", "#[derive(Debug, Serialize)]", &["error.is_lease_fencing_rejected()", "StatusCode::CONFLICT"], ); } #[test] fn editor_processing_phase_retry_policy_stops_after_one_transport_retry() { let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1; let transport_errors = [ ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Build( "initial websocket connect failed".to_string(), )), ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::ConnectDropped), ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Timeout( spacetime_client::SpacetimeClientStage::ProcedureResult, )), ]; for error in &transport_errors { assert!(should_retry_editor_generation_phase_update( error, 1, max_attempts )); assert!(!should_retry_editor_generation_phase_update( error, max_attempts, max_attempts )); } let lease = ExternalGenerationJobPhaseUpdateError::LeaseFencingRejected( "lease token 不匹配".to_string(), ); assert!(!should_retry_editor_generation_phase_update( &lease, 1, max_attempts )); let rejected = ExternalGenerationJobPhaseUpdateError::Rejected("phase 非法".to_string()); assert!(!should_retry_editor_generation_phase_update( &rejected, 1, max_attempts )); for error in [ ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Procedure( "procedure rejected".to_string(), )), ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Runtime( "runtime invariant failed".to_string(), )), ] { assert!(!should_retry_editor_generation_phase_update( &error, 1, max_attempts )); } } #[test] fn editor_static_postprocess_failure_completes_canvas_with_source_only() { let source = include_str!("editor_project.rs"); for (start, end) in [ ( "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", ), ( "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", ), ( "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", ), ] { assert_function_contains_in_order( source, start, end, &[ "persist_editor_provider_source_resource", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "Err(error)", "complete_editor_canvas_generation", "source_record.resource.as_ref()", "warning: Some(editor_postprocess_fallback_warning", ], ); let start_index = source .find(start) .unwrap_or_else(|| panic!("missing function start marker: {start}")); let function_tail = &source[start_index..]; let end_index = function_tail .find(end) .unwrap_or_else(|| panic!("missing function end marker: {end}")); let body = &function_tail[..end_index]; let fallback_start = body .find("let removal = match removal {") .unwrap_or_else(|| panic!("{start} should match only the postprocess result")); let success_marker = if start.contains("generate_editor_image_for_owner") { "image = restored_removal_image;" } else { "let removal_provider = removal.provider;" }; let fallback_end = body[fallback_start..] .find(success_marker) .map(|offset| fallback_start + offset) .unwrap_or_else(|| { panic!("{start} should resume its processed-image success path") }); let fallback = &body[fallback_start..fallback_end]; assert!(fallback.contains("return Ok(json_success_body")); for forbidden in [ "persist_editor_generated_image(", "persist_editor_generated_asset(", "slice_generated_icon_spritesheet", "persist_editor_spritesheet_slices", ] { assert!( !fallback.contains(forbidden), "{start} fallback must not absorb later failure scope: {forbidden}" ); } if start.contains("spritesheet") || start.contains("ui_design") { let success_tail = &body[fallback_end..]; assert!(success_tail.contains("persist_editor_generated_image(")); assert!(success_tail.contains("persist_editor_generated_asset(")); assert!(success_tail.contains("persist_editor_spritesheet_slices")); } } } #[test] fn editor_character_postprocess_fallback_is_inline_and_does_not_swallow_other_failures() { let source = include_str!("editor_project.rs"); let start = source .find("pub(crate) async fn generate_editor_image_for_owner") .expect("character generation function should exist"); let function_tail = &source[start..]; let end = function_tail .find("fn normalize_editor_image_generation_size") .expect("character generation function end marker should exist"); let body = &function_tail[..end]; let phase = body .find("caller.report_processing_phase(state).await?") .expect("phase report should remain fallible"); let removal = body .find("let removal = remove_editor_generated_screen_background_with_bgfilter") .expect("transparent background postprocess should exist"); let fallback_start = body .find("let removal = match removal {") .expect("only the postprocess result should be matched"); let fallback_end = body[fallback_start..] .find("image = restored_removal_image;") .map(|offset| fallback_start + offset) .expect("processed-image success path should follow the fallback match"); let fallback = &body[fallback_start..fallback_end]; assert!( phase < removal, "phase errors must fail before fallback is considered" ); for snippet in [ "Err(error)", "complete_editor_canvas_generation", "source_record.resource.as_ref()", "return Ok(json_success_body", "warning: Some(editor_postprocess_fallback_warning", ] { assert!( fallback.contains(snippet), "character postprocess fallback should contain {snippet}" ); } for forbidden in [ "persist_editor_generated_image(", "persist_editor_generated_asset(", "slice_generated_icon_spritesheet", "build_multi_asset_canvas_layer_items", ] { assert!( !fallback.contains(forbidden), "character postprocess fallback must not absorb later failure scope: {forbidden}" ); } let success_tail = &body[fallback_end..]; assert!(success_tail.contains("persist_editor_generated_image(")); assert!(success_tail.contains("persist_editor_generated_asset(")); assert!(success_tail.contains(".await?;")); } #[test] fn editor_manual_background_removal_calls_internal_worker_once_without_fallback() { let source = include_str!("editor_project.rs"); assert_function_contains( source, "async fn request_editor_background_removal_image_with_bgfilter_worker", "pub(crate) struct EditorScreenBackgroundRemovalOutput", &[ "crate::bgfilter_worker::request_bgfilter_worker", "BgfilterBackgroundMode::Complex", ], ); assert_function_not_contains( source, "async fn request_editor_background_removal_image_with_bgfilter_worker", "pub(crate) struct EditorScreenBackgroundRemovalOutput", &[ "for attempt in", "will_retry", "fallback_editor_screen_background_removal", ], ); } #[test] fn editor_matting_releases_source_buffers_at_oss_boundaries() { let source = include_str!("editor_project.rs"); for (start, end) in [ ( "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", ), ( "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", ), ( "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", ), ] { assert_function_contains_in_order( source, start, end, &[ "persist_editor_provider_source_image", "remove_editor_generated_screen_background_with_bgfilter", "apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original", "persist_editor_generated_image", ], ); } assert_function_contains( source, "async fn persist_editor_provider_source_image", "async fn persist_editor_provider_source_resource", &["persist_editor_generated_image_owned"], ); assert_function_contains_in_order( source, "async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original", "fn restore_editor_generated_image_output_dimensions", &[ "into_dimensions", "return (postprocessed, None)", "download_editor_persisted_image_object", "apply_editor_postprocessed_alpha_to_provider_source", ], ); for (start, end) in [ ( "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", ), ( "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", ), ] { assert_function_contains( source, start, end, &["bytes: image.bytes.clone()", "slice_source"], ); assert_function_not_contains( source, start, end, &[ "download_editor_persisted_image_object", "drop(slice_source)", ], ); } assert_function_not_contains( source, "pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(", "async fn fallback_editor_screen_background_removal", &["fallback_image"], ); assert_function_contains( source, "async fn persist_editor_generated_image_owned", "async fn persist_editor_generated_image_data", &["bytes: image.bytes"], ); assert_function_not_contains( source, "async fn persist_editor_generated_image_owned", "async fn persist_editor_generated_image_data", &["image.bytes.clone()"], ); assert_function_contains( source, "async fn read_editor_reference_image_object", "async fn download_editor_persisted_image_object", &["response.chunk().await", "bytes.extend_from_slice"], ); assert_function_not_contains( source, "async fn read_editor_reference_image_object", "async fn download_editor_persisted_image_object", &["response.bytes().await", "bytes.to_vec()"], ); } #[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 remove_editor_image_background", &[ "let provider_image", "restore_editor_image_edit_output_dimensions_or_original", "persist_editor_generated_image", ], ); assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &[ "let mut image", "restore_editor_generated_image_output_dimensions_or_original", "persist_editor_generated_image", ], ); assert_function_occurrence_count( source, "pub(crate) async fn edit_editor_image_for_owner", "pub async fn remove_editor_image_background", "persist_editor_generated_image(", 1, ); assert_function_occurrence_count( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", "persist_editor_generated_image(", 1, ); 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_provider_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}" ); } } fn assert_function_occurrence_count( source: &str, start: &str, end: &str, snippet: &str, expected_count: usize, ) { 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]; assert_eq!( body.matches(snippet).count(), expected_count, "function body starting with {start} should contain {snippet} exactly {expected_count} times" ); } }