use std::{ borrow::Cow, collections::{BTreeMap, HashSet}, io::Cursor, sync::{Arc, LazyLock}, time::{Duration, Instant}, }; use axum::{ Json, extract::{Extension, Path, Query, State, rejection::JsonRejection}, http::StatusCode, }; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input, generate_asset_object_id, }; use platform_image::{ DownloadedImage, generated_asset_sheets::{ GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetConnectedIconPlan, GeneratedAssetSheetError, prepare_generated_icon_spritesheet_all_by_connected_components, }, }; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; use shared_contracts::assets::{ EDITOR_ASSET_LABEL_MAX_CHARS, EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest, EditorCanvasGenerationPlaceholderPayload, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::editor_project::{ EditorSpritesheetSliceBatchPersistRecordInput, EditorSpritesheetSlicePersistItemRecordInput, }; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput, 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, ExternalGenerationJobRecord, 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, enqueue_editor_generation_job_for_caller, }, editor_green_screen::{ EditorScreenBackgroundColor, 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_MAX_DIMENSION: u32 = 4096; const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048; const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 64; const EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY: usize = 2; const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2; const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2; const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 = EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4; const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); 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_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT: &str = "raw-component-limit-exceeded"; const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT: &str = "output-slice-limit-exceeded"; const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT: &str = "merge-candidate-limit-exceeded"; const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT: &str = "crop-pixel-limit-exceeded"; const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT: &str = "slice-processing-timeout"; const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved"; const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback"; const EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE: &str = "unsupported-image-style"; pub(crate) const EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE: &str = "multiple-generation-warnings"; const EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT: f64 = 0.05; const EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY: usize = 2; const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); static EDITOR_PIXEL_ART_CPU_LIMITER: LazyLock> = LazyLock::new(|| { Arc::new(tokio::sync::Semaphore::new( EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY, )) }); static EDITOR_ICON_SPRITESHEET_CPU_LIMITER: LazyLock> = LazyLock::new(|| { Arc::new(tokio::sync::Semaphore::new( EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY, )) }); static EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER: LazyLock> = LazyLock::new(|| { Arc::new(tokio::sync::Semaphore::new( EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY, )) }); 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_BGFILTER_MATTING_PROVIDER: &str = "BgFilter"; const EDITOR_ALIYUN_MATTING_PROVIDER: &str = "Aliyun Matting"; const EDITOR_ALIYUN_MATTING_MODEL: &str = "segment-common-image"; const EDITOR_LOCAL_MATTING_PROVIDER: &str = "Genarrative Local"; const EDITOR_LOCAL_MATTING_MODEL: &str = "screen-color-keying"; 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) style: Option, pub(crate) model: Option, pub(crate) screen_color: Option, pub(crate) seg_model: Option, pub(crate) aspect_ratio: Option, pub(crate) image_size: Option, pub(crate) 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) style: Option, pub(crate) model: Option, pub(crate) screen_color: Option, pub(crate) seg_model: Option, pub(crate) aspect_ratio: Option, pub(crate) image_size: Option, pub(crate) project_id: Option, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, pub(crate) asset_label: Option, pub(crate) canvas_completion: Option, } #[derive(Clone, 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, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum EditorImageGenerationStyle { #[default] None, PixelArt, } 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_pixel_art_fallback_warning() -> EditorGenerationWarningResponse { editor_postprocess_fallback_warning("像素规整未完成,已保留原始生成结果。") } fn editor_postprocess_fallback_warning_with_dimension( reason: &'static str, dimension_warning: Option<&EditorGenerationWarningResponse>, ) -> EditorGenerationWarningResponse { let reason = dimension_warning .map(|warning| format!("{reason} {}", warning.reason)) .unwrap_or_else(|| reason.to_string()); EditorGenerationWarningResponse { code: EDITOR_GENERATION_POSTPROCESS_WARNING_CODE, reason, } } 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, } } fn editor_unsupported_image_style_warning() -> EditorGenerationWarningResponse { EditorGenerationWarningResponse { code: EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE, reason: "不支持的图片风格,已按无风格处理。".to_string(), } } fn merge_editor_generation_warnings( current: Option, next: Option, ) -> Option { match (current, next) { (None, None) => None, (Some(warning), None) | (None, Some(warning)) => Some(warning), (Some(current), Some(next)) if current.reason == next.reason => Some(current), (Some(current), Some(next)) => Some(EditorGenerationWarningResponse { code: if current.code == next.code { current.code } else { EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE }, reason: format!("{} {}", current.reason, next.reason), }), } } fn normalize_editor_image_generation_style( value: Option<&str>, pixel_art_supported: bool, ) -> ( EditorImageGenerationStyle, Option, ) { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return (EditorImageGenerationStyle::None, None); }; match value { "none" => (EditorImageGenerationStyle::None, None), "pixelArt" if pixel_art_supported => (EditorImageGenerationStyle::PixelArt, None), _ => ( EditorImageGenerationStyle::None, Some(editor_unsupported_image_style_warning()), ), } } pub(crate) fn parse_editor_generation_json_payload( payload: Result, JsonRejection>, ) -> Result, AppError> { payload.map_err(|error| { let status = if error.status() == StatusCode::UNPROCESSABLE_ENTITY { StatusCode::BAD_REQUEST } else { error.status() }; AppError::from_status(status).with_details(json!({ "provider": "editor-generation-request", "message": error.body_text(), })) }) } #[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, 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, 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, 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, #[serde(skip_serializing_if = "Option::is_none")] model: Option, #[serde(skip_serializing_if = "Option::is_none")] 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, #[serde(skip_serializing_if = "Option::is_none")] model: Option, #[serde(skip_serializing_if = "Option::is_none")] 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( sanitize_editor_client_generation_inputs(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( sanitize_editor_client_generation_inputs(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, payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(payload) = parse_editor_generation_json_payload(payload)?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { let queue_job = enqueue_editor_image_generation_for_owner( &state, &request_context, &caller, payload, None, ) .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 enqueue_editor_image_generation_for_owner( state: &AppState, request_context: &RequestContext, caller: &EditorGenerationCaller, mut payload: EditorImageGenerationRequest, external_idempotency_key: Option<&str>, ) -> Result { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-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", ); enqueue_editor_generation_job_for_caller( state, request_context, caller.owner_user_id.as_str(), EDITOR_IMAGE_GENERATION_JOB_KIND, source_entity_id, "图片画布生成图片", price_mud_points, &payload, external_idempotency_key, ) .await } pub(crate) async fn generate_editor_image_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, mut payload: EditorImageGenerationRequest, ) -> Result, AppError> { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-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")); let pixel_art_supported = matches!(normalized_kind, None | Some("") | Some("character")); let (image_style, mut generation_warning) = normalize_editor_image_generation_style(payload.style.as_deref(), pixel_art_supported); // 背景色决策挪到预扣泥点之后(见下方 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(editor_image_generation_negative_prompt( is_ui_design_generation, )); 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 长边像素矩阵,持久化前归一, // 保证不同模型的完成图与生成前占位标注一致。 // 角色带背景原图和透明结果是同一个业务产物的两个版本,必须在 // provider 原图持久化前就收口到同一交付尺寸。否则 1K 任务会把模型原生 // 2752x1536 原图和长边 1024 的透明图同时放入画布。像素模式不再豁免, // 逻辑像素规整直接在归一并居中裁切后的交付尺寸图上进行。 let (restored_image, dimension_restore_error) = restore_editor_generated_image_output_dimensions_or_original( image, generation_options.model, image_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_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 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, role_setting.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, fallback_warning) = complete_editor_source_only_fallback( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), &source_record, generation_warning, dimension_warning.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(), task_id: generated.task_id, resource: source_record.resource, asset: source_record.asset, project: completed_project, warning: fallback_warning, }, )); } }; let removal_provider = removal.provider; let removal_model = removal.model.clone(); let (restored_removal_image, postprocess_dimension_error, pixel_art_error) = if image_style == EditorImageGenerationStyle::PixelArt { apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( state, source_object_key.as_str(), delivery_width, delivery_height, removal.image, request_context.external_call_deadline(), ) .await } else { let (image, error) = apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, source_object_key.as_str(), delivery_width, delivery_height, removal.image, None, ) .await; (image, error, None) }; if let Some(error) = postprocess_dimension_error { tracing::warn!( task_id = %generated.task_id, provider = removal_provider, provider_width, provider_height, delivery_width, delivery_height, error = ?error, "角色透明图尺寸恢复失败,改用已保存的同尺寸原图完成画布" ); let (completed_project, fallback_warning) = complete_editor_source_only_fallback( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), &source_record, generation_warning, dimension_warning.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(), task_id: generated.task_id, resource: source_record.resource, asset: source_record.asset, project: completed_project, // 中文注释:原图安全降级同样要保留此前累积的告警(例如未知 style 的 // unsupported-image-style),不能被后处理降级告警整条覆盖。 warning: fallback_warning, }, )); } if let Some(error) = pixel_art_error { tracing::warn!( task_id = %generated.task_id, provider = removal_provider, error = %error, "角色像素规整失败,保留透明后处理图" ); generation_warning = merge_editor_generation_warnings( generation_warning, Some(editor_pixel_art_fallback_warning()), ); } image = restored_removal_image; output_prompt = role_setting.clone(); output_actual_prompt = None; output_provider = removal_provider.to_string(); output_generation_inputs = apply_editor_matting_metadata_to_generation_inputs( build_editor_derived_asset_generation_inputs("角色抠图", &source_record), removal_provider, removal_model.as_str(), ); Some(source_record) } else { None }; // 中文注释:普通图片的逻辑像素规整在交付尺寸归一之后进行,snapper 会把结果 // 还原回输入尺寸,因此这里不再需要二次恢复交付尺寸。 if !is_character_generation && image_style == EditorImageGenerationStyle::PixelArt { let (pixel_art_image, pixel_art_error) = snap_editor_pixel_art_or_original(image, request_context.external_call_deadline()) .await; image = pixel_art_image; if let Some(error) = pixel_art_error { tracing::warn!( task_id = %generated.task_id, error = %error, "画板图片像素规整失败,保留 provider 图" ); generation_warning = merge_editor_generation_warnings( generation_warning, Some(editor_pixel_art_fallback_warning()), ); } } 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(), task_id: generated.task_id, resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, // 中文注释:成功路径同时收口尺寸归一降级与像素规整等生成告警。 warning: merge_editor_generation_warnings(generation_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 apply_editor_matting_metadata_to_generation_inputs( generation_inputs: Option, provider: &str, model: &str, ) -> Option { let mut value = match generation_inputs { Some(value) if value.is_object() => value, Some(other) => return Some(other), None => json!({ "fields": [], "references": [] }), }; // 抠图供应商和模型是内部实际执行结果,不属于用户可见的生成输入快照; // 仿照 screenColorHex 只写顶层扩展,图片信息仍只读取 fields / references。 value["mattingProvider"] = Value::String(provider.to_string()); value["mattingModel"] = Value::String(model.to_string()); Some(value) } fn build_editor_derived_asset_generation_inputs( stage: &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 }, ], "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) { restore_editor_generated_image_output_dimensions_or_original_with_filter( output, model, target_size, image::imageops::FilterType::Lanczos3, ) } fn restore_editor_generated_image_output_dimensions_or_original_with_filter( output: DownloadedOpenAiImage, model: &str, target_size: &str, filter: image::imageops::FilterType, ) -> (DownloadedOpenAiImage, Option) { match restore_editor_generated_image_output_dimensions_with_filter( &output, model, target_size, filter, ) { 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> { apply_editor_postprocessed_alpha_to_provider_source_with_policy( provider_source, postprocessed, false, ) } fn decode_editor_provider_source_dimensions( image: &DownloadedOpenAiImage, provider: &'static str, message: &'static str, ) -> Result<(u32, u32), AppError> { 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": provider, "message": format!("{message}:{error}"), })) }) } fn apply_editor_postprocessed_alpha_using_provider_rgb( provider_source: &DownloadedOpenAiImage, postprocessed: &DownloadedOpenAiImage, ) -> Result { apply_editor_postprocessed_alpha_to_provider_source_with_policy( provider_source, postprocessed, true, )? .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-image-postprocess", "message": "像素规整输入未能组合 provider RGB 与透明后处理 Alpha", })) }) } fn apply_editor_postprocessed_alpha_to_provider_source_with_policy( provider_source: &DownloadedOpenAiImage, postprocessed: &DownloadedOpenAiImage, always_use_provider_rgb: bool, ) -> 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}"), })) })?; let dimensions_match = postprocessed.width() == provider_source.width() && postprocessed.height() == provider_source.height(); if dimensions_match && !always_use_provider_rgb { return Ok(None); } let source_ratio = f64::from(provider_source.width()) / f64::from(provider_source.height()); let postprocessed_ratio = f64::from(postprocessed.width()) / f64::from(postprocessed.height()); let ratio_drift = if source_ratio >= postprocessed_ratio { 1.0 - postprocessed_ratio / source_ratio } else { 1.0 - source_ratio / postprocessed_ratio }; if ratio_drift > EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-image-postprocess", "code": "postprocessed-alpha-aspect-ratio-mismatch", "message": "透明后处理图比例与原图偏差过大,禁止拉伸 Alpha 蒙版", "sourceWidth": provider_source.width(), "sourceHeight": provider_source.height(), "postprocessedWidth": postprocessed.width(), "postprocessedHeight": postprocessed.height(), "ratioDrift": ratio_drift, })), ); } // 中文注释:图片模型已经按用户所选 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 = if dimensions_match { alpha } else { 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(), })) } // 中文注释:已经持有 provider 原图时的纯内存尺寸守卫,不发起任何 OSS 请求。 fn apply_editor_postprocessed_alpha_with_provider_source_or_original( provider_source: &DownloadedOpenAiImage, postprocessed: DownloadedOpenAiImage, ) -> (DownloadedOpenAiImage, Option) { 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 editor_postprocessed_alpha_matches_delivery_dimensions( postprocessed: &DownloadedOpenAiImage, provider_width: u32, provider_height: u32, ) -> Result { // 中文注释:不能只读取图片头中的尺寸。合法 header + 截断像素数据若穿过同尺寸 // 快路径,后续会把不可解码的透明图持久化;这里必须完成整图解码。 let decoded = 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}"), })) })?; let (postprocessed_width, postprocessed_height) = (decoded.width(), decoded.height()); Ok(postprocessed_width == provider_width && postprocessed_height == provider_height) } 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, download_deadline: Option, ) -> (DownloadedOpenAiImage, Option) { match editor_postprocessed_alpha_matches_delivery_dimensions( &postprocessed, provider_width, provider_height, ) { Ok(true) => return (postprocessed, None), Ok(false) => {} Err(error) => return (postprocessed, Some(error)), } // 中文注释:provider 原图已在 BgFilter 调用前交给 OSS 并释放;只有输出尺寸漂移时才短时回读原图回贴 alpha。 // download_deadline 为像素规整降级路径提供绝对上界,避免在预算已耗尽后仍发起无界 GET; // 非像素路径沿用既有语义传 None。 let download = download_editor_persisted_image_object(state, provider_source_object_key); let downloaded = match download_deadline { Some(deadline) => { match tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), download).await { Ok(downloaded) => downloaded, Err(_) => Err( AppError::from_status(StatusCode::GATEWAY_TIMEOUT).with_details(json!({ "provider": "editor-image-postprocess", "message": "回读 provider 原图超时,已保留透明后处理图。", })), ), } } None => download.await, }; let provider_source = match downloaded { Ok(provider_source) => provider_source, Err(error) => return (postprocessed, Some(error)), }; apply_editor_postprocessed_alpha_with_provider_source_or_original( &provider_source, postprocessed, ) } fn take_arc_downloaded_image(image: Arc) -> DownloadedOpenAiImage { Arc::try_unwrap(image).unwrap_or_else(|image| image.as_ref().clone()) } async fn acquire_editor_pixel_art_cpu_permit( processing_deadline: Instant, ) -> Result { if Instant::now() >= processing_deadline { return Err("像素规整处理预算已耗尽,已保留原始生成结果。".to_string()); } match tokio::time::timeout_at( tokio::time::Instant::from_std(processing_deadline), Arc::clone(&*EDITOR_PIXEL_ART_CPU_LIMITER).acquire_owned(), ) .await { Ok(Ok(permit)) => Ok(permit), Ok(Err(error)) => Err(format!("像素规整 CPU 并发门限不可用:{error}")), Err(_) => Err("像素规整等待 CPU 时处理预算已耗尽,已保留原始生成结果。".to_string()), } } async fn snap_editor_pixel_art_or_original( rgba_source: DownloadedOpenAiImage, request_deadline: Option, ) -> (DownloadedOpenAiImage, Option) { let processing_deadline = resolve_editor_pixel_art_processing_deadline(Instant::now(), request_deadline); if Instant::now() >= processing_deadline { return ( rgba_source, Some("像素规整处理预算已耗尽,已保留原始生成结果。".to_string()), ); } let permit = match acquire_editor_pixel_art_cpu_permit(processing_deadline).await { Ok(permit) => permit, Err(error) => return (rgba_source, Some(error)), }; let rgba_source = Arc::new(rgba_source); let worker_rgba_source = Arc::clone(&rgba_source); let worker = tokio::task::spawn_blocking(move || { // 中文注释:permit 必须由 blocking 闭包持有,而不是只保护 spawn; // 即使上层 future 因 worker deadline 被取消,仍会限制尚未结束的 CPU 任务数量。 let _permit = permit; platform_image::snap_pixel_art_with_deadline( worker_rgba_source.as_ref(), worker_rgba_source.as_ref(), Some(processing_deadline), ) }); let result = tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker).await; match result { Ok(Ok(Ok(image))) => (image, None), Ok(Ok(Err(error))) => ( take_arc_downloaded_image(rgba_source), Some(error.to_string()), ), Ok(Err(error)) => ( take_arc_downloaded_image(rgba_source), Some(format!("像素规整工作线程异常:{error}")), ), Err(_) => ( take_arc_downloaded_image(rgba_source), Some("像素规整处理超时,已保留原始生成结果。".to_string()), ), } } fn resolve_editor_pixel_art_processing_deadline( started_at: Instant, request_deadline: Option, ) -> Instant { let local_deadline = started_at .checked_add(EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION) .unwrap_or(started_at); request_deadline .map(|request_deadline| request_deadline.min(local_deadline)) .unwrap_or(local_deadline) } // 中文注释:像素规整的每一条 best-effort 降级都不能直接把 BgFilter 原始输出当作 // 最终结果——它的尺寸可能相对交付尺寸漂移。这里复用非像素路径的同一套守卫:先做 // 纯内存尺寸比对,一致就原样返回且不产生 OSS GET;只有真的漂移才回读原图重贴 // alpha,修不好则返回尺寸错误,由调用方走原图安全降级。 async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( state: &AppState, provider_source_object_key: &str, delivery_width: u32, delivery_height: u32, postprocessed: DownloadedOpenAiImage, request_deadline: Option, reason: String, ) -> (DownloadedOpenAiImage, Option, Option) { // 中文注释:只有尚未成功取得 provider 原图的降级分支才会走到这里。像素预算此时 // 多半已经耗尽,不能拿它去绑这次回读(否则必然失败、守卫形同虚设)。优先用外层 // 请求 deadline,但它只在队列 worker 路径上有值——inline HTTP 请求的 // RequestContext 默认没有 external_call_deadline,此时必须自行重新计时派生一个 // 上界,绝不允许出现无界 GET。 let repair_deadline = request_deadline.unwrap_or_else(|| { let started_at = Instant::now(); started_at .checked_add(EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION) .unwrap_or(started_at) }); let (image, dimension_error) = apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, provider_source_object_key, delivery_width, delivery_height, postprocessed, Some(repair_deadline), ) .await; (image, dimension_error, Some(reason)) } // 中文注释:provider 原图已在内存里的降级分支走这里,零额外 OSS GET, // 保证「最多因复用失败增加一次 GET」的不变式不被降级路径打破。 fn degrade_editor_pixel_art_with_provider_source( provider_source: &DownloadedOpenAiImage, delivery_width: u32, delivery_height: u32, postprocessed: DownloadedOpenAiImage, reason: String, ) -> (DownloadedOpenAiImage, Option, Option) { match editor_postprocessed_alpha_matches_delivery_dimensions( &postprocessed, delivery_width, delivery_height, ) { Ok(true) => (postprocessed, None, Some(reason)), Ok(false) => { let (image, dimension_error) = apply_editor_postprocessed_alpha_with_provider_source_or_original( provider_source, postprocessed, ); (image, dimension_error, Some(reason)) } Err(error) => (postprocessed, Some(error), Some(reason)), } } async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( state: &AppState, provider_source_object_key: &str, delivery_width: u32, delivery_height: u32, postprocessed: DownloadedOpenAiImage, request_deadline: Option, ) -> (DownloadedOpenAiImage, Option, Option) { let processing_deadline = resolve_editor_pixel_art_processing_deadline(Instant::now(), request_deadline); // 中文注释:像素模式必须用 provider 平底原图找网格。原图已经在调用 // BgFilter 前持久化并释放,因此这里只回读既有对象一次;这次回读同时承担 // 尺寸漂移时的 alpha 回贴,禁止为两个步骤分别发起 OSS GET。 if Instant::now() >= processing_deadline { return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( state, provider_source_object_key, delivery_width, delivery_height, postprocessed, request_deadline, "像素规整处理预算已耗尽,已保留透明后处理图。".to_string(), ) .await; } let provider_source = match tokio::time::timeout_at( tokio::time::Instant::from_std(processing_deadline), download_editor_persisted_image_object(state, provider_source_object_key), ) .await { Ok(Ok(provider_source)) => provider_source, Ok(Err(error)) => { let reason = error.body_text(); return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( state, provider_source_object_key, delivery_width, delivery_height, postprocessed, request_deadline, reason, ) .await; } Err(_) => { return degrade_editor_pixel_art_to_postprocessed_with_dimension_guard( state, provider_source_object_key, delivery_width, delivery_height, postprocessed, request_deadline, "像素规整读取 provider 原图超时,已保留透明后处理图。".to_string(), ) .await; } }; let permit = match acquire_editor_pixel_art_cpu_permit(processing_deadline).await { Ok(permit) => permit, Err(error) => { return degrade_editor_pixel_art_with_provider_source( &provider_source, delivery_width, delivery_height, postprocessed, error, ); } }; let provider_source = Arc::new(provider_source); let postprocessed = Arc::new(postprocessed); let worker_provider_source = Arc::clone(&provider_source); let worker_postprocessed = Arc::clone(&postprocessed); let worker = tokio::task::spawn_blocking(move || { // 中文注释:Alpha 合成和网格规整都属于像素 CPU 工作,必须在同一个 permit // 与同一个绝对 deadline 内完成,避免在 Tokio worker 上无界并发解码/编码。 let _permit = permit; if Instant::now() >= processing_deadline { return Err(( None, None, "像素规整处理预算已耗尽,已保留透明后处理图。".to_string(), )); } let rgba_source = match apply_editor_postprocessed_alpha_using_provider_rgb( worker_provider_source.as_ref(), worker_postprocessed.as_ref(), ) { Ok(rgba_source) => rgba_source, Err(error) => { return Err(( None, Some(error), "像素规整输入准备失败,已保留透明后处理图。".to_string(), )); } }; match platform_image::snap_pixel_art_with_deadline( worker_provider_source.as_ref(), &rgba_source, Some(processing_deadline), ) { Ok(image) => Ok(image), Err(error) => Err((Some(rgba_source), None, error.to_string())), } }); let result = tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker).await; match result { Ok(Ok(Ok(image))) => (image, None, None), // 中文注释:fallback 由 provider 原图逐像素合成,尺寸天然等于交付尺寸, // 不需要再走守卫;准备阶段已产出尺寸错误时同样直接交回调用方降级。 Ok(Ok(Err((Some(rgba_source), preparation_error, error)))) => { (rgba_source, preparation_error, Some(error)) } Ok(Ok(Err((None, Some(preparation_error), error)))) => ( take_arc_downloaded_image(postprocessed), Some(preparation_error), Some(error), ), Ok(Ok(Err((None, None, error)))) => degrade_editor_pixel_art_with_provider_source( provider_source.as_ref(), delivery_width, delivery_height, take_arc_downloaded_image(postprocessed), error, ), Ok(Err(error)) => degrade_editor_pixel_art_with_provider_source( provider_source.as_ref(), delivery_width, delivery_height, take_arc_downloaded_image(postprocessed), format!("像素规整工作线程异常:{error}"), ), Err(_) => degrade_editor_pixel_art_with_provider_source( provider_source.as_ref(), delivery_width, delivery_height, take_arc_downloaded_image(postprocessed), "像素规整处理超时,已保留透明后处理图。".to_string(), ), } } fn restore_editor_generated_image_output_dimensions_with_filter( output: &DownloadedOpenAiImage, model: &str, target_size: &str, filter: image::imageops::FilterType, ) -> 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, filter); 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() { let queue_job = enqueue_editor_image_edit_for_owner(&state, &request_context, &caller, payload, None) .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 enqueue_editor_image_edit_for_owner( state: &AppState, request_context: &RequestContext, caller: &EditorGenerationCaller, mut payload: EditorImageEditRequest, external_idempotency_key: Option<&str>, ) -> Result { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); 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"); enqueue_editor_generation_job_for_caller( state, request_context, caller.owner_user_id.as_str(), EDITOR_IMAGE_EDIT_JOB_KIND, source_entity_id, "图片画布修改图片", price_mud_points, &payload, external_idempotency_key, ) .await } pub(crate) async fn edit_editor_image_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, mut payload: EditorImageEditRequest, ) -> Result, AppError> { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); 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(), 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(mut payload): Json, ) -> Result, AppError> { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); 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, mut payload: EditorBackgroundRemovalRequest, ) -> Result, AppError> { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); let started_at = Instant::now(); caller.report_processing_phase(state).await?; let source = resolve_editor_background_removal_source( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.source_resource_id.as_deref(), 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 generation_inputs = apply_editor_matting_metadata_to_generation_inputs( payload.generation_inputs, EDITOR_BGFILTER_MATTING_PROVIDER, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, ); 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: source.model.unwrap_or_default(), 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, 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), 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, } impl EditorScreenBackgroundRemovalOutput { fn bgfilter(image: DownloadedOpenAiImage, seg_model: &str) -> Self { Self { image, provider: EDITOR_BGFILTER_MATTING_PROVIDER, model: seg_model.to_string(), } } fn aliyun(image: DownloadedOpenAiImage) -> Self { Self { image, provider: EDITOR_ALIYUN_MATTING_PROVIDER, model: EDITOR_ALIYUN_MATTING_MODEL.to_string(), } } fn local(image: DownloadedOpenAiImage) -> Self { Self { image, provider: EDITOR_LOCAL_MATTING_PROVIDER, model: EDITOR_LOCAL_MATTING_MODEL.to_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::bgfilter( DownloadedOpenAiImage { bytes: image.bytes, extension: image.extension, mime_type: image.mime_type, }, seg_model, )); } 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::aliyun(image)), Err(error) => { tracing::warn!( provider = "aliyun-matting", screen_color = screen_color.hex, error = %error, error_details = ?error.details(), "editor_aliyun_matting_fallback_to_local_screen_background_removal" ); if crate::external_api_audit::matting_failure_external_call_attempted(&error) { crate::external_api_audit::record_matting_external_api_failure( state, audit, "aliyun-matting", state.config.aliyun_matting_endpoint.clone(), "editor-screen-background-removal", 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(EditorScreenBackgroundRemovalOutput::local) } } } 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!({ "field": "segModel", "message": "抠图参数无效。", })), ), } } 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, } struct EditorSpritesheetSliceUpload { index: usize, name: String, width: u32, height: u32, image_src: String, object_key: String, asset_object: module_assets::AssetObjectUpsertInput, } struct EditorIconSpritesheetMemoryAdmission { _permit: tokio::sync::OwnedSemaphorePermit, } #[derive(Clone)] struct PreparedEditorIconSpritesheetPlan { plan: GeneratedAssetSheetConnectedIconPlan, processing_deadline: Instant, // 中文注释:每个仍可能运行的 blocking 编码任务都持有同一 admission,超时取消 // async 等待时也不会提前释放整张 RGBA 的内存名额。 _memory_admission: Arc, } async fn persist_editor_spritesheet_slices( state: &AppState, prepared: PreparedEditorIconSpritesheetPlan, input: PersistEditorSpritesheetSlicesInput, ) -> Result, AppError> { use futures_util::{StreamExt as _, TryStreamExt as _}; if prepared.plan.is_empty() { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集中未识别到可拆分的独立素材。", })), ); } let expected_asset_count = u32::try_from(prepared.plan.len()).map_err(|_| { AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集拆分产物数量超过支持范围。", })) })?; let input = Arc::new(input); let http_client = build_editor_spritesheet_http_client( EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT, EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT, ) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "aliyun-oss", "message": format!("创建图集切片上传客户端失败:{error}"), })) })?; let mut uploads = futures_util::stream::iter(0..prepared.plan.len()) .map(|index| { let prepared = prepared.clone(); let http_client = http_client.clone(); let input = Arc::clone(&input); async move { let slice = encode_editor_icon_spritesheet_slice(prepared, index).await?; upload_editor_spritesheet_slice(state, &http_client, index, slice, &input).await } }) .buffer_unordered(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY) .try_collect::>() .await?; uploads.sort_by_key(|upload| upload.index); // 中文注释:上传收齐后只保留轻量 HEAD 元数据;数据库慢调用不应继续占用 // 整张 RGBA 与图集专用内存 admission。 drop(prepared); let generation_inputs_json = serialize_editor_asset_metadata(input.generation_inputs.clone())?; let folder_id = normalize_generated_asset_folder_id( input.asset_folder_id.clone(), input.owner_user_id.as_str(), ); let now_micros = current_utc_micros(); let items = uploads .iter() .map(|upload| { let resource_id = input.project_id.as_ref().map(|_| { stable_editor_spritesheet_slice_record_id( EDITOR_RESOURCE_ID_PREFIX, input.owner_user_id.as_str(), input.task_id.as_str(), upload.index, ) }); let project_resource = input.project_id.as_ref().map(|project_id| { EditorProjectResourceCreateRecordInput { resource_id: resource_id.clone().expect("resource id must exist"), project_id: project_id.clone(), owner_user_id: input.owner_user_id.clone(), asset_object_id: Some(upload.asset_object.asset_object_id.clone()), image_src: upload.image_src.clone(), object_key: Some(upload.object_key.clone()), width: upload.width, height: upload.height, source_type: "generated".to_string(), prompt: Some(input.prompt.clone()), actual_prompt: input.actual_prompt.clone(), model: Some(input.model.clone()), provider: Some(input.provider.clone()), task_id: Some(input.task_id.clone()), source_resource_id: normalize_optional_string(input.source_resource_id.clone()), asset_kind: Some("icon".to_string()), generation_inputs_json: generation_inputs_json.clone(), updated_at_micros: now_micros, } }); let asset = folder_id .as_ref() .map(|folder_id| EditorAssetCreateRecordInput { asset_id: stable_editor_spritesheet_slice_record_id( EDITOR_ASSET_ID_PREFIX, input.owner_user_id.as_str(), input.task_id.as_str(), upload.index, ), owner_user_id: input.owner_user_id.clone(), folder_id: folder_id.clone(), label: upload.name.clone(), asset_object_id: Some(upload.asset_object.asset_object_id.clone()), image_src: upload.image_src.clone(), object_key: Some(upload.object_key.clone()), width: upload.width, height: upload.height, source_type: "generated".to_string(), prompt: Some(input.prompt.clone()), actual_prompt: input.actual_prompt.clone(), model: Some(input.model.clone()), provider: Some(input.provider.clone()), task_id: Some(input.task_id.clone()), asset_kind: Some("icon".to_string()), generation_inputs_json: generation_inputs_json.clone(), source_resource_id: resource_id.clone(), generation_cost_mud_points: 0, now_micros, thumbnail_src: None, group_task_id: normalize_optional_string(input.group_task_id.clone()), group_task_expected_asset_count: input .group_task_id .as_ref() .map(|_| expected_asset_count), }); EditorSpritesheetSlicePersistItemRecordInput { asset_object: upload.asset_object.clone(), project_resource, asset, } }) .collect(); let persisted = state .spacetime_client() .persist_editor_spritesheet_slice_batch(EditorSpritesheetSliceBatchPersistRecordInput { owner_user_id: input.owner_user_id.clone(), task_id: input.task_id.clone(), group_task_id: input.group_task_id.clone(), expected_asset_count, items, completed_at_micros: now_micros, }) .await .map_err(map_editor_project_error)?; if persisted.items.len() != uploads.len() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": "图集切片批量持久化返回数量不一致。", })), ); } Ok(uploads .into_iter() .zip(persisted.items) .map(|(upload, item)| EditorIconSpritesheetIconResponse { name: upload.name, image_src: upload.image_src, width: upload.width, height: upload.height, resource: item .project_resource .map(editor_project_resource_payload_from_record), asset: item.asset.map(editor_asset_payload_from_record), }) .collect()) } fn stable_editor_spritesheet_slice_record_id( prefix: &str, owner_user_id: &str, task_id: &str, index: usize, ) -> String { let digest = Sha256::digest( format!( "editor-spritesheet-slice\n{}\n{}\n{index}", owner_user_id.trim(), task_id.trim() ) .as_bytes(), ); format!("{prefix}{}", hex::encode(&digest[..16])) } fn build_editor_spritesheet_http_client( connect_timeout: Duration, request_timeout: Duration, ) -> Result { reqwest::Client::builder() .connect_timeout(connect_timeout) .timeout(request_timeout) .build() } async fn encode_editor_icon_spritesheet_slice( prepared: PreparedEditorIconSpritesheetPlan, index: usize, ) -> Result { if Instant::now() >= prepared.processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); } let permit = match tokio::time::timeout_at( tokio::time::Instant::from_std(prepared.processing_deadline), Arc::clone(&*EDITOR_ICON_SPRITESHEET_CPU_LIMITER).acquire_owned(), ) .await { Ok(Ok(permit)) => permit, Ok(Err(error)) => { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT, "message": format!("图集切片 CPU 并发门限不可用:{error}"), })), ); } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; let processing_deadline = prepared.processing_deadline; let worker = tokio::task::spawn_blocking(move || { // 中文注释:blocking 任务同时持有 CPU permit 和内存 admission;async 超时 // 只放弃等待,不能让尚未退出的编码绕过任一门限。 let _permit = permit; let _memory_admission = &prepared._memory_admission; if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); } prepared .plan .encode(index) .map_err(map_editor_icon_spritesheet_platform_error) }); match tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker).await { Ok(Ok(result)) => result, Ok(Err(error)) => Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": format!("图集切片编码工作线程异常:{error}"), }))), Err(_) => Err(editor_icon_spritesheet_processing_timeout_error()), } } async fn upload_editor_spritesheet_slice( state: &AppState, http_client: &reqwest::Client, index: usize, slice: GeneratedAssetSheetConnectedIcon, input: &PersistEditorSpritesheetSlicesInput, ) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { prefix: LegacyAssetPrefix::CharacterDrafts, path_segments: vec![ "editor".to_string(), sanitize_editor_storage_segment(input.path_kind, "generated-images"), sanitize_editor_storage_segment(input.task_id.as_str(), "task"), ], file_stem: sanitize_editor_storage_segment( format!("asset-{:03}", index + 1).as_str(), "image", ), image: GeneratedImageAssetDataUrl { format: normalize_generated_image_asset_mime("image/png"), bytes: slice.bytes, }, access: OssObjectAccess::Private, metadata: GeneratedImageAssetAdapterMetadata { asset_kind: Some(input.asset_kind.to_string()), owner_user_id: Some(input.owner_user_id.clone()), entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()), entity_id: Some(input.task_id.clone()), slot: Some("asset".to_string()), provider: Some(input.persistence_provider.to_string()), task_id: Some(input.task_id.clone()), }, extra_metadata: BTreeMap::from([( "source".to_string(), "image-canvas-editor".to_string(), )]), }) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "generated-image-assets", "message": format!("准备图集切片 OSS 上传请求失败:{error:?}"), })) })?; let persisted_mime_type = prepared.format.mime_type.clone(); let put_result = oss_client .put_object(http_client, prepared.request) .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; let head = oss_client .head_object( http_client, OssHeadObjectRequest { object_key: put_result.object_key, }, ) .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; let now_micros = current_utc_micros(); let asset_object = build_asset_object_upsert_input( generate_asset_object_id(now_micros), head.bucket, head.object_key.clone(), AssetObjectAccessPolicy::Private, head.content_type.or(Some(persisted_mime_type)), head.content_length, Some( input .actual_prompt .as_deref() .unwrap_or(input.prompt.as_str()) .to_string(), ), input.asset_kind.to_string(), Some(input.task_id.clone()), Some(input.owner_user_id.clone()), None, Some(input.task_id.clone()), now_micros, ) .map_err(map_editor_asset_field_error)?; Ok(EditorSpritesheetSliceUpload { index, name: slice.name, width: slice.width, height: slice.height, image_src: editor_media_src_from_object_key(head.object_key.as_str()), object_key: head.object_key, asset_object, }) } pub async fn generate_editor_icon_spritesheet( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(payload) = parse_editor_generation_json_payload(payload)?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { let queue_job = enqueue_editor_icon_spritesheet_generation_for_owner( &state, &request_context, &caller, payload, None, ) .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 enqueue_editor_icon_spritesheet_generation_for_owner( state: &AppState, request_context: &RequestContext, caller: &EditorGenerationCaller, mut payload: EditorIconSpritesheetGenerationRequest, external_idempotency_key: Option<&str>, ) -> Result { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); 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", ); enqueue_editor_generation_job_for_caller( state, request_context, caller.owner_user_id.as_str(), EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, source_entity_id, "图片画布生成图标素材", price_mud_points, &payload, external_idempotency_key, ) .await } pub(crate) async fn generate_editor_icon_spritesheet_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, mut payload: EditorIconSpritesheetGenerationRequest, ) -> Result, AppError> { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); 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)?; let user_prompt = icon_descriptions.join("\n"); let icon_spec_genre = resolve_editor_icon_spec_game_genre( state, caller.owner_user_id.as_str(), payload.reference_image_src.as_str(), ) .await?; let (image_style, mut generation_warning) = normalize_editor_image_generation_style(payload.style.as_deref(), true); // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。 let requested_screen_color = payload.screen_color.clone(); let reference_count = 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len); let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?; let mut reference_images = Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len)); reference_images.push( 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: user_prompt.clone(), 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 = crate::prompt::icon_spec::build_spritesheet_prompt( user_prompt.as_str(), screen_color, icon_spec_genre, ); 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) = decode_editor_provider_source_dimensions( &image, "editor-icon-spritesheet", "图标 spritesheet 原图不是有效图片", )?; let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( payload.generation_inputs.clone(), Some(&screen_background_decision), ); let spritesheet_label = resolve_editor_generated_asset_label(payload.asset_label.clone(), "图标素材图集"); let asset_folder_id = payload .asset_folder_id .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); let source_persisted = persist_editor_provider_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), image, user_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: user_prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), task_id: generated.task_id.clone(), source_resource_id: None, asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), generation_cost_mud_points: u64::from(expected_price_mud_points), }, ) .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_ENABLED, &matting_audit, ) .await; let removal = match removal { Ok(removal) => removal, Err(error) => { let failure_message = error.body_text(); tracing::warn!( provider = "editor-icon-spritesheet", operation = "background_postprocess", task_id = %generated.task_id, error = %failure_message, "图标图集原图已保存,但透明背景处理失败,使用原图完成画布" ); let (completed_project, fallback_warning) = complete_editor_source_only_fallback( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), &source_record, generation_warning, None, ) .await?; return Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { spritesheet_image_src: source_image_src, spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_warning: None, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), task_id: generated.task_id, price_mud_points: expected_price_mud_points, spritesheet_resource: source_record.resource, spritesheet_asset: source_record.asset, project: completed_project, warning: fallback_warning, }, )); } }; let removal_provider = removal.provider; let removal_model = removal.model.clone(); let (image, postprocess_dimension_error, pixel_art_error) = if image_style == EditorImageGenerationStyle::PixelArt { apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( state, source_object_key.as_str(), source_width, source_height, removal.image, request_context.external_call_deadline(), ) .await } else { let (image, error) = apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, source_object_key.as_str(), source_width, source_height, removal.image, None, ) .await; (image, error, None) }; if let Some(error) = postprocess_dimension_error { tracing::warn!( task_id = %generated.task_id, provider = removal_provider, source_width, source_height, error = ?error, "图标透明图蒙版尺寸恢复失败,改用已保存的 provider 原图完成画布" ); let (completed_project, fallback_warning) = complete_editor_source_only_fallback( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), &source_record, generation_warning, None, ) .await?; return Ok(json_success_body( Some(request_context), EditorIconSpritesheetGenerationResponse { spritesheet_image_src: source_image_src, spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_warning: None, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), task_id: generated.task_id, price_mud_points: expected_price_mud_points, spritesheet_resource: source_record.resource, spritesheet_asset: source_record.asset, project: completed_project, warning: fallback_warning, }, )); } if let Some(error) = pixel_art_error { tracing::warn!( task_id = %generated.task_id, provider = removal_provider, error = %error, "图标图集像素规整失败,保留透明后处理图" ); generation_warning = merge_editor_generation_warnings( generation_warning, Some(editor_pixel_art_fallback_warning()), ); } let matting_generation_inputs = apply_editor_matting_metadata_to_generation_inputs( build_editor_derived_asset_generation_inputs("图标图集抠图", &source_record), removal_provider, removal_model.as_str(), ); let (spritesheet_width, spritesheet_height) = decode_editor_provider_source_dimensions( &image, "editor-icon-spritesheet", "图标 spritesheet 透明后处理图不是有效图片", )?; let owner_user_id = caller.owner_user_id; let spritesheet_persisted = persist_editor_generated_image( state, owner_user_id.as_str(), generated.task_id.as_str(), &image, user_prompt.as_str(), None, EDITOR_ICON_SPRITESHEET_ASSET_KIND, "icon-spritesheets", "spritesheet", "spritesheet", removal_provider, ) .await?; let spritesheet_image_src = editor_media_src_from_object_key(spritesheet_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: user_prompt.clone(), actual_prompt: None, model: generation_options.model.to_string(), provider: removal_provider.to_string(), task_id: generated.task_id.clone(), group_task_id: None, group_task_expected_asset_count: None, source_resource_id: source_record .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, mime_type: image.mime_type, extension: image.extension, }; let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all( slice_source, request_context.external_call_deadline(), ) .await { 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: user_prompt.clone(), actual_prompt: None, model: generation_options.model.to_string(), provider: "Genarrative".to_string(), generation_inputs: build_editor_derived_asset_generation_inputs( "图集拆分", &spritesheet_record, ), 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(editor_icon_spritesheet_warning_after_persist_error(&error)), ) } } } Err(error) => { let reason = error.body_text(); tracing::warn!( provider = "editor-icon-spritesheet", operation = "detect_automatic_slices", task_id = %generated.task_id, reason = %reason, "图标图集已持久化,但自动拆分未完成" ); let warning = editor_icon_spritesheet_slice_warning_from_error(&error); (Vec::new(), Some(warning)) } }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.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(), task_id: generated.task_id, price_mud_points: expected_price_mud_points, spritesheet_resource: spritesheet_record.resource, spritesheet_asset: spritesheet_record.asset, project: completed_project, warning: generation_warning, }, )) } pub 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 project.owner_user_id != owner_user_id || source_resource.owner_user_id != owner_user_id || source_resource.project_id != project_id { return Err( AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集资源不属于当前账号或当前项目。", })), ); } 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 source_object_key = normalize_editor_record_object_key( source_resource.object_key.as_deref(), source_resource.image_src.as_str(), ) .ok_or_else(|| { AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集资源缺少可读取的 objectKey。", })) })?; let task_id = build_prefixed_uuid_id("editor-atlas-split-"); let group_task_id = resolve_editor_manual_atlas_split_group_task_id( &state, owner_user_id.as_str(), &source_resource, ) .await? .unwrap_or_else(|| task_id.clone()); let processing_deadline = resolve_editor_icon_spritesheet_processing_deadline( Instant::now(), request_context.external_call_deadline(), ); let memory_admission = acquire_editor_icon_spritesheet_memory_admission(processing_deadline).await?; let download_client = build_editor_spritesheet_http_client( EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT, EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT, ) .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "aliyun-oss", "message": format!("创建图集来源下载客户端失败:{error}"), })) })?; // 中文注释:source_resource 已由 owner-scoped 项目读取完成鉴权,直接使用其稳定 // objectKey,避免再按注册 ID 做两轮全账号项目与素材库扫描;memory admission // 在下载前取得,使排队请求不能各自先堆积一份最大 32 MiB 的压缩源图。 let reference = match tokio::time::timeout_at( tokio::time::Instant::from_std(processing_deadline), read_editor_reference_image_object_with_client( &state, source_object_key.as_str(), &download_client, ), ) .await { Ok(result) => result?, Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; let source = DownloadedImage { bytes: reference.bytes, mime_type: reference.mime_type, extension: "png".to_string(), }; let slices = slice_editor_icon_spritesheet_all_with_memory_admission( source, processing_deadline, memory_admission, ) .await?; let prompt = source_resource .prompt .clone() .unwrap_or_else(|| "拆分图集素材".to_string()); let actual_prompt = source_resource.actual_prompt.clone(); let model = source_resource .model .clone() .unwrap_or_else(|| "connected-components".to_string()); let provider = source_resource .provider .clone() .unwrap_or_else(|| "Genarrative".to_string()); let generation_inputs = source_resource.generation_inputs.clone(); let icon_image_srcs = persist_editor_spritesheet_slices( &state, slices, PersistEditorSpritesheetSlicesInput { owner_user_id: owner_user_id.clone(), project_id: Some(project_id.clone()), asset_folder_id: payload .asset_folder_id .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())), source_resource_id: Some(source_resource_id), task_id, group_task_id: Some(group_task_id), prompt, actual_prompt, model, provider, generation_inputs, path_kind: "icon-spritesheet-slices", asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, persistence_provider: "genarrative", }, ) .await?; let canvas_items = build_icon_spritesheet_slice_canvas_layer_items( &payload.canvas_completion, icon_image_srcs.as_slice(), ); let project = complete_editor_canvas_generation_with_items( &state, owner_user_id.as_str(), Some(project_id.as_str()), Some(&payload.canvas_completion), canvas_items, icon_image_srcs .first() .and_then(|icon| icon.resource.as_ref()) .map(|resource| generated_canvas_layer_id(resource.resource_id.as_str())), ) .await? .ok_or_else(|| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集拆分结果未能写入画布。", })) })?; Ok(json_success_body( Some(&request_context), EditorIconSpritesheetSliceResponse { icon_image_srcs, project, }, )) } async fn slice_editor_icon_spritesheet_all( source: DownloadedImage, request_deadline: Option, ) -> Result { let processing_deadline = resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline); let memory_admission = acquire_editor_icon_spritesheet_memory_admission(processing_deadline).await?; slice_editor_icon_spritesheet_all_with_memory_admission( source, processing_deadline, memory_admission, ) .await } async fn acquire_editor_icon_spritesheet_memory_admission( processing_deadline: Instant, ) -> Result, AppError> { if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); } let memory_permit = match tokio::time::timeout_at( tokio::time::Instant::from_std(processing_deadline), Arc::clone(&*EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER).acquire_owned(), ) .await { Ok(Ok(permit)) => permit, Ok(Err(error)) => { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT, "message": format!("图集切片内存门限不可用:{error}"), })), ); } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; Ok(Arc::new(EditorIconSpritesheetMemoryAdmission { _permit: memory_permit, })) } async fn slice_editor_icon_spritesheet_all_with_memory_admission( source: DownloadedImage, processing_deadline: Instant, memory_admission: Arc, ) -> Result { if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); } let permit = match tokio::time::timeout_at( tokio::time::Instant::from_std(processing_deadline), Arc::clone(&*EDITOR_ICON_SPRITESHEET_CPU_LIMITER).acquire_owned(), ) .await { Ok(Ok(permit)) => permit, Ok(Err(error)) => { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT, "message": format!("图集切片 CPU 并发门限不可用:{error}"), })), ); } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; let worker_memory_admission = Arc::clone(&memory_admission); let worker = tokio::task::spawn_blocking(move || { // 中文注释:CPU permit 和内存 admission 必须由 blocking 闭包持有;即使 // async 等待超时,尚未退出的解码或连通域处理仍不能绕过并发门限。 let _permit = permit; let _memory_admission = worker_memory_admission; if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); } validate_editor_icon_spritesheet_source(&source)?; prepare_generated_icon_spritesheet_all_by_connected_components( &source, EDITOR_ICON_SPRITESHEET_MAX_SLICES, EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, ) .map_err(map_editor_icon_spritesheet_platform_error) }); let plan = match tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker) .await { Ok(Ok(result)) => result?, Ok(Err(error)) => { return Err( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": format!("图集切片工作线程异常:{error}"), })), ); } Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()), }; if plan.is_empty() { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, "message": "图集中未识别到可拆分的独立素材。", })), ); } Ok(PreparedEditorIconSpritesheetPlan { plan, processing_deadline, _memory_admission: memory_admission, }) } fn resolve_editor_icon_spritesheet_processing_deadline( started_at: Instant, request_deadline: Option, ) -> Instant { let local_deadline = started_at .checked_add(EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION) .unwrap_or(started_at); request_deadline .map(|request_deadline| request_deadline.min(local_deadline)) .unwrap_or(local_deadline) } fn editor_icon_spritesheet_processing_timeout_error() -> AppError { AppError::from_status(StatusCode::GATEWAY_TIMEOUT).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT, "message": "图集切片处理超时。", })) } fn map_editor_icon_spritesheet_platform_error(error: GeneratedAssetSheetError) -> AppError { let code = match &error { GeneratedAssetSheetError::RawConnectedComponentLimitExceeded { .. } => { Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT) } GeneratedAssetSheetError::OutputSliceLimitExceeded { .. } => { Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT) } GeneratedAssetSheetError::MergeCandidateLimitExceeded { .. } => { Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT) } GeneratedAssetSheetError::TotalCropPixelLimitExceeded { .. } => { Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT) } _ => None, }; let mut details = json!({ "provider": "editor-icon-spritesheet-slicing", "message": error.to_string(), }); if let Some(code) = code { details["code"] = json!(code); } AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(details) } fn editor_icon_spritesheet_slice_warning_from_error( error: &AppError, ) -> EditorIconSpritesheetSliceWarningResponse { let code = match error .details() .and_then(|details| details.get("code")) .and_then(Value::as_str) { Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT) => { EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT } Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT) => { EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT } Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT) => { EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT } Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT) => { EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT } Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT) => { EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT } _ => EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, }; EditorIconSpritesheetSliceWarningResponse { code, reason: error.body_text(), } } fn editor_icon_spritesheet_warning_after_persist_error( error: &AppError, ) -> EditorIconSpritesheetSliceWarningResponse { let is_processing_failure = error .details() .and_then(|details| details.get("code")) .and_then(Value::as_str) .is_some_and(|code| { matches!( code, EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT | EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT | EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT | EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT | EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT ) }); if is_processing_failure { editor_icon_spritesheet_slice_warning_from_error(error) } else { EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, reason: error.body_text(), } } } fn validate_editor_icon_spritesheet_source(source: &DownloadedImage) -> Result<(), AppError> { let reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice())) .with_guessed_format() .map_err(|error| { AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": format!("无法识别图集图片格式:{error}"), })) })?; let (width, height) = reader.into_dimensions().map_err(|error| { AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": format!("无法读取图集图片尺寸:{error}"), })) })?; validate_editor_icon_spritesheet_dimensions(width, height) } async fn resolve_editor_manual_atlas_split_group_task_id( state: &AppState, owner_user_id: &str, source_resource: &EditorProjectResourceRecord, ) -> Result, AppError> { let lookup = editor_manual_atlas_split_group_source_lookup_input( owner_user_id, source_resource.resource_id.as_str(), source_resource.source_resource_id.as_deref(), source_resource.asset_object_id.as_deref(), source_resource.object_key.as_deref(), ); let asset = state .spacetime_client() .find_editor_asset_group_source(lookup) .await .map_err(map_editor_project_error)?; Ok(asset.and_then(|asset| normalize_optional_string(asset.task_id))) } 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_dimensions(width: u32, height: u32) -> Result<(), AppError> { let pixel_count = u64::from(width).saturating_mul(u64::from(height)); if width == 0 || height == 0 || width > EDITOR_ICON_SPRITESHEET_MAX_DIMENSION || height > EDITOR_ICON_SPRITESHEET_MAX_DIMENSION || pixel_count > EDITOR_ICON_SPRITESHEET_MAX_PIXELS { return Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ "provider": "editor-icon-spritesheet-slicing", "message": "图集尺寸超过手动拆分限制。", "width": width, "height": height, "maxDimension": EDITOR_ICON_SPRITESHEET_MAX_DIMENSION, "maxPixels": EDITOR_ICON_SPRITESHEET_MAX_PIXELS, })), ); } Ok(()) } pub async fn extract_editor_ui_design_assets( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { let queue_job = enqueue_editor_ui_design_asset_extraction_for_owner( &state, &request_context, &caller, payload, None, ) .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 enqueue_editor_ui_design_asset_extraction_for_owner( state: &AppState, request_context: &RequestContext, caller: &EditorGenerationCaller, mut payload: EditorUiDesignAssetExtractionRequest, external_idempotency_key: Option<&str>, ) -> Result { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); 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", ); enqueue_editor_generation_job_for_caller( state, request_context, caller.owner_user_id.as_str(), EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, source_entity_id, "图片画布提取UI设计图素材", price_mud_points, &payload, external_idempotency_key, ) .await } pub(crate) async fn extract_editor_ui_design_assets_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, mut payload: EditorUiDesignAssetExtractionRequest, ) -> Result, AppError> { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); 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) = decode_editor_provider_source_dimensions( &image, "editor-ui-design-asset-extraction", "UI 素材 spritesheet 原图不是有效图片", )?; 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, fallback_warning) = complete_editor_source_only_fallback( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), &source_record, None, None, ) .await?; return Ok(json_success_body( Some(&request_context), EditorIconSpritesheetGenerationResponse { spritesheet_image_src: source_image_src, spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_warning: None, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), task_id: generated.task_id, price_mud_points: expected_price_mud_points, spritesheet_resource: source_record.resource, spritesheet_asset: source_record.asset, project: completed_project, warning: fallback_warning, }, )); } }; let removal_provider = removal.provider; let removal_model = removal.model.clone(); let (image, postprocess_dimension_error) = apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, source_object_key.as_str(), source_width, source_height, removal.image, None, ) .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 素材透明图蒙版尺寸恢复失败,改用已保存的 provider 原图完成画布" ); let (completed_project, fallback_warning) = complete_editor_source_only_fallback( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), &source_record, None, None, ) .await?; return Ok(json_success_body( Some(request_context), EditorIconSpritesheetGenerationResponse { spritesheet_image_src: source_image_src, spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), slice_warning: None, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), task_id: generated.task_id, price_mud_points: expected_price_mud_points, spritesheet_resource: source_record.resource, spritesheet_asset: source_record.asset, project: completed_project, warning: fallback_warning, }, )); } let matting_generation_inputs = apply_editor_matting_metadata_to_generation_inputs( build_editor_derived_asset_generation_inputs("UI图集抠图", &source_record), removal_provider, removal_model.as_str(), ); let (spritesheet_width, spritesheet_height) = decode_editor_provider_source_dimensions( &image, "editor-ui-design-asset-extraction", "UI 素材 spritesheet 透明后处理图不是有效图片", )?; 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: generation_options.model.to_string(), provider: removal_provider.to_string(), task_id: generated.task_id.clone(), group_task_id: None, group_task_expected_asset_count: None, source_resource_id: source_record .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, mime_type: image.mime_type, extension: image.extension, }; let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all( slice_source, request_context.external_call_deadline(), ) .await { 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: generation_options.model.to_string(), provider: "Genarrative".to_string(), generation_inputs: build_editor_derived_asset_generation_inputs( "图集拆分", &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(editor_icon_spritesheet_warning_after_persist_error(&error)), ) } } } Err(error) => { let reason = error.body_text(); tracing::warn!( provider = "editor-ui-design-asset-extraction", operation = "detect_slices", task_id = %generated.task_id, reason = %reason, "UI 设计图透明图集已持久化,但未识别到可拆分素材" ); let warning = editor_icon_spritesheet_slice_warning_from_error(&error); (Vec::new(), Some(warning)) } }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.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(), 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: sanitize_editor_user_model(record.model), provider: None, 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_user_generation_inputs), 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: sanitize_editor_user_model(record.model), provider: None, 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_user_generation_inputs), 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 is_editor_internal_processing_model(model: &str) -> bool { matches!( model.trim().to_ascii_lowercase().as_str(), "anime-seg" | "bgfilter complex" | "birefnet" | "connected-components" | "screen-color-keying" | "segment-common-image" ) } fn sanitize_editor_user_model(model: Option) -> Option { normalize_optional_string(model).filter(|model| !is_editor_internal_processing_model(model)) } fn sanitize_editor_reserved_generation_inputs(mut value: Value) -> Value { if let Some(object) = value.as_object_mut() { for field in ["screenColorHex", "mattingProvider", "mattingModel"] { object.remove(field); } } value } pub(crate) fn sanitize_editor_client_generation_inputs(value: Option) -> Option { value.map(sanitize_editor_reserved_generation_inputs) } fn sanitize_editor_user_generation_inputs(value: Value) -> Value { sanitize_editor_reserved_generation_inputs(sanitize_editor_payload_inline_media(value)) } 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: sanitize_editor_user_model(record.model), provider: None, task_id: record.task_id, asset_kind: record.asset_kind, generation_inputs: record .generation_inputs .map(sanitize_editor_user_generation_inputs), 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()); } } if is_editor_canvas_resource_layer(object) { object.remove("provider"); if object .get("model") .and_then(Value::as_str) .is_some_and(is_editor_internal_processing_model) { object.remove("model"); } if let Some(generation_inputs) = object.get_mut("generationInputs") { let sanitized = sanitize_editor_reserved_generation_inputs(generation_inputs.take()); *generation_inputs = sanitized; } } 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 is_editor_canvas_resource_layer(object: &serde_json::Map) -> bool { if object.get("itemType").and_then(Value::as_str) == Some("generation-dialog") { return false; } object .get("layerId") .and_then(Value::as_str) .is_some_and(|layer_id| !layer_id.trim().is_empty()) && ["resourceId", "sourceAssetId"] .iter() .any(|field| object.get(*field).and_then(Value::as_str).is_some()) } 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 model = normalize_optional_string(Some(input.model)); 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: 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, 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, mut input: PersistEditorGeneratedAssetRequest, ) -> Result< ( Option, Option, ), AppError, > { input.generation_inputs = sanitize_editor_client_generation_inputs(input.generation_inputs.take()); 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))) } async fn complete_editor_source_only_fallback( state: &AppState, owner_user_id: &str, project_id: Option<&str>, completion: Option<&EditorCanvasGenerationCompletionRequest>, source_record: &EditorGeneratedAssetRecord, current_warning: Option, dimension_warning: Option<&EditorGenerationWarningResponse>, ) -> Result< ( Option, Option, ), AppError, > { let project = complete_editor_canvas_generation( state, owner_user_id, project_id, completion, source_record.resource.as_ref(), ) .await?; let warning = merge_editor_generation_warnings( current_warning, Some(editor_postprocess_fallback_warning_with_dimension( "生成任务成功,后处理失败。", dimension_warning, )), ); Ok((project, warning)) } 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_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 editor_image_generation_negative_prompt(is_ui_design_generation: bool) -> &'static str { if is_ui_design_generation { "水印、无界面的纯场景插画、海报、地图、低清晰度、变形主体、不可读布局" } else { "文字、水印、边框、按钮、UI 控件、低清晰度、变形主体" } } 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, // asset_object.prompt 是跨资源检索用的用户意图,不承载 provider // actual/system prompt;后者仍只保存在 resource/asset 审计字段。 Some(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 } async fn resolve_editor_icon_spec_game_genre( state: &AppState, owner_user_id: &str, reference_source: &str, ) -> Result, AppError> { let reference_source = reference_source.trim(); if reference_source.is_empty() { return Ok(None); } let normalized_object_key = normalize_editor_reference_object_key(reference_source).ok(); let projects = state .spacetime_client() .list_editor_projects(owner_user_id.to_string()) .await .map_err(map_editor_project_error)?; let resource_genre = projects .iter() .flat_map(|project| project.resources.iter()) .filter(|resource| resource.asset_kind.as_deref() == Some("icon-spec")) .filter(|resource| { resource.resource_id.trim() == reference_source || normalized_object_key.as_deref().is_some_and(|object_key| { editor_record_object_key_matches( resource.object_key.as_deref(), resource.image_src.as_str(), object_key, ) }) }) .find_map(|resource| { crate::editor_project_icon_specs::game_genre_from_generation_inputs( resource.generation_inputs.as_ref(), ) }); if resource_genre.is_some() { return Ok(resource_genre); } 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() .filter(|asset| asset.asset_kind.as_deref() == Some("icon-spec")) .filter(|asset| { asset.asset_id.trim() == reference_source || normalized_object_key.as_deref().is_some_and(|object_key| { editor_record_object_key_matches( asset.object_key.as_deref(), asset.image_src.as_str(), object_key, ) }) }) .find_map(|asset| { crate::editor_project_icon_specs::game_genre_from_generation_inputs( asset.generation_inputs.as_ref(), ) })) } 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) } struct ResolvedEditorBackgroundRemovalSource { object_key: String, model: Option, } async fn resolve_editor_background_removal_source( state: &AppState, owner_user_id: &str, project_id: Option<&str>, source_resource_id: Option<&str>, source_reference: &str, ) -> Result { ensure_editor_reference_image_source_is_stable( source_reference, "editor-reference-image", "sourceImageSrc", "图片参考图", )?; let owner_user_id = owner_user_id.trim(); if owner_user_id.is_empty() { return Err(editor_reference_image_forbidden(source_reference.trim())); } let projects = state .spacetime_client() .list_editor_projects(owner_user_id.to_string()) .await .map_err(map_editor_project_error)?; let library = state .spacetime_client() .get_editor_asset_library(owner_user_id.to_string(), current_utc_micros()) .await .map_err(map_editor_project_error)?; let source_object_key = match normalize_editor_reference_object_key(source_reference) { Ok(object_key) => object_key, Err(error) => find_editor_reference_object_key_by_registered_id_from_records( projects.as_slice(), library.assets.as_slice(), source_reference.trim(), ) .ok_or(error)?, }; if !editor_reference_object_key_is_registered_for_owner( projects.as_slice(), library.assets.as_slice(), source_object_key.as_str(), ) { ensure_editor_reference_asset_object_owned( state, owner_user_id, source_object_key.as_str(), ) .await?; } let model = resolve_editor_background_removal_source_model_from_records( projects.as_slice(), library.assets.as_slice(), project_id, source_resource_id, source_reference, source_object_key.as_str(), )?; Ok(ResolvedEditorBackgroundRemovalSource { object_key: source_object_key, model, }) } fn resolve_editor_background_removal_source_model_from_records( projects: &[EditorProjectRecord], assets: &[EditorAssetRecord], project_id: Option<&str>, source_resource_id: Option<&str>, source_reference: &str, source_object_key: &str, ) -> Result, AppError> { let project_id = project_id.map(str::trim).filter(|value| !value.is_empty()); let source_resource_id = source_resource_id .map(str::trim) .filter(|value| !value.is_empty()); let source_reference = source_reference.trim(); if let Some(source_resource_id) = source_resource_id { let resource = if let Some(project_id) = project_id { projects .iter() .find(|project| project.project_id.trim() == project_id) .and_then(|project| { project .resources .iter() .find(|resource| resource.resource_id.trim() == source_resource_id) }) } else { find_editor_background_removal_resource(projects, None, source_resource_id) } .ok_or_else(|| editor_reference_image_forbidden(source_object_key))?; if !editor_record_object_key_matches( resource.object_key.as_deref(), resource.image_src.as_str(), source_object_key, ) { return Err(editor_reference_image_forbidden(source_object_key)); } return Ok(resolve_editor_background_removal_resource_model( projects, resource, project_id, )); } let resource = project_id .and_then(|project_id| { projects .iter() .find(|project| project.project_id.trim() == project_id) }) .and_then(|project| { project.resources.iter().find(|resource| { resource.resource_id.trim() == source_reference || editor_record_object_key_matches( resource.object_key.as_deref(), resource.image_src.as_str(), source_object_key, ) }) }) .or_else(|| { projects .iter() .flat_map(|project| project.resources.iter()) .find(|resource| { resource.resource_id.trim() == source_reference || editor_record_object_key_matches( resource.object_key.as_deref(), resource.image_src.as_str(), source_object_key, ) }) }); if let Some(resource) = resource { return Ok(resolve_editor_background_removal_resource_model( projects, resource, project_id, )); } let asset = assets.iter().find(|asset| { asset.asset_id.trim() == source_reference || editor_record_object_key_matches( asset.object_key.as_deref(), asset.image_src.as_str(), source_object_key, ) }); Ok(asset.and_then(|asset| { sanitize_editor_user_model(asset.model.clone()).or_else(|| { asset.source_resource_id.as_deref().and_then(|resource_id| { find_editor_background_removal_resource(projects, project_id, resource_id).and_then( |resource| { resolve_editor_background_removal_resource_model( projects, resource, project_id, ) }, ) }) }) })) } fn find_editor_background_removal_resource<'a>( projects: &'a [EditorProjectRecord], project_id: Option<&str>, resource_id: &str, ) -> Option<&'a EditorProjectResourceRecord> { let resource_id = resource_id.trim(); project_id .and_then(|project_id| { projects .iter() .find(|project| project.project_id.trim() == project_id) }) .and_then(|project| { project .resources .iter() .find(|resource| resource.resource_id.trim() == resource_id) }) .or_else(|| { projects .iter() .flat_map(|project| project.resources.iter()) .find(|resource| resource.resource_id.trim() == resource_id) }) } fn resolve_editor_background_removal_resource_model( projects: &[EditorProjectRecord], starting_resource: &EditorProjectResourceRecord, preferred_project_id: Option<&str>, ) -> Option { let mut current = Some(starting_resource); let mut visited_resource_ids = HashSet::new(); while let Some(resource) = current { if !visited_resource_ids.insert(resource.resource_id.trim().to_string()) { return None; } if let Some(model) = sanitize_editor_user_model(resource.model.clone()) { return Some(model); } current = resource .source_resource_id .as_deref() .and_then(|resource_id| { find_editor_background_removal_resource(projects, preferred_project_id, resource_id) }); } None } 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) = find_editor_reference_resource_object_key_by_registered_id(projects.as_slice(), 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(find_editor_reference_asset_object_key_by_registered_id( library.assets.as_slice(), source, )) } fn find_editor_reference_object_key_by_registered_id_from_records( projects: &[EditorProjectRecord], assets: &[EditorAssetRecord], source: &str, ) -> Option { find_editor_reference_resource_object_key_by_registered_id(projects, source) .or_else(|| find_editor_reference_asset_object_key_by_registered_id(assets, source)) } fn find_editor_reference_resource_object_key_by_registered_id( projects: &[EditorProjectRecord], source: &str, ) -> Option { projects .iter() .flat_map(|project| project.resources.iter()) .find_map(|resource| editor_reference_resource_object_key(resource, source)) } fn find_editor_reference_asset_object_key_by_registered_id( assets: &[EditorAssetRecord], source: &str, ) -> Option { 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 editor_reference_object_key_is_registered_in_projects(projects.as_slice(), 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 editor_reference_object_key_is_registered_in_assets(library.assets.as_slice(), object_key) { return Ok(()); } ensure_editor_reference_asset_object_owned(state, owner_user_id, object_key).await } fn editor_reference_object_key_is_registered_for_owner( projects: &[EditorProjectRecord], assets: &[EditorAssetRecord], object_key: &str, ) -> bool { editor_reference_object_key_is_registered_in_projects(projects, object_key) || editor_reference_object_key_is_registered_in_assets(assets, object_key) } fn editor_reference_object_key_is_registered_in_projects( projects: &[EditorProjectRecord], object_key: &str, ) -> bool { 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, ) }) }) } fn editor_reference_object_key_is_registered_in_assets( assets: &[EditorAssetRecord], object_key: &str, ) -> bool { assets.iter().any(|asset| { editor_record_object_key_matches( asset.object_key.as_deref(), asset.image_src.as_str(), object_key, ) }) } async fn ensure_editor_reference_asset_object_owned( state: &AppState, owner_user_id: &str, object_key: &str, ) -> Result<(), AppError> { 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 { let http_client = reqwest::Client::new(); read_editor_reference_image_object_with_client(state, source, &http_client).await } async fn read_editor_reference_image_object_with_client( state: &AppState, source: &str, http_client: &reqwest::Client, ) -> 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 = http_client .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")); } #[test] fn matting_metadata_records_internal_result_without_touching_visible_fields() { let updated = apply_editor_matting_metadata_to_generation_inputs( Some(json!({ "fields": [{ "title": "处理阶段", "value": "角色抠图" }], "references": [], "screenColorHex": "#CFEFFF", })), EDITOR_BGFILTER_MATTING_PROVIDER, EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG, ) .expect("matting metadata should be recorded"); assert_eq!( updated["fields"], json!([{ "title": "处理阶段", "value": "角色抠图" }]) ); assert_eq!(updated["references"], json!([])); assert_eq!(updated["screenColorHex"], json!("#CFEFFF")); assert_eq!(updated["mattingProvider"], json!("BgFilter")); assert_eq!(updated["mattingModel"], json!("anime-seg")); assert!( updated["fields"] .as_array() .expect("fields should remain an array") .iter() .all(|field| field["title"] != "处理模型") ); let created = apply_editor_matting_metadata_to_generation_inputs( None, EDITOR_LOCAL_MATTING_PROVIDER, EDITOR_LOCAL_MATTING_MODEL, ) .expect("missing inputs should receive an internal metadata envelope"); assert_eq!( created, json!({ "fields": [], "references": [], "mattingProvider": "Genarrative Local", "mattingModel": "screen-color-keying", }) ); } fn test_screen_background_removal_image() -> DownloadedOpenAiImage { DownloadedOpenAiImage { bytes: vec![1, 2, 3], extension: "png".to_string(), mime_type: "image/png".to_string(), } } #[test] fn screen_background_removal_outputs_record_each_actual_matting_backend() { let bgfilter = EditorScreenBackgroundRemovalOutput::bgfilter( test_screen_background_removal_image(), EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG, ); assert_eq!(bgfilter.provider, "BgFilter"); assert_eq!(bgfilter.model, "anime-seg"); let aliyun = EditorScreenBackgroundRemovalOutput::aliyun(test_screen_background_removal_image()); assert_eq!(aliyun.provider, "Aliyun Matting"); assert_eq!(aliyun.model, "segment-common-image"); let local = EditorScreenBackgroundRemovalOutput::local(test_screen_background_removal_image()); assert_eq!(local.provider, "Genarrative Local"); assert_eq!(local.model, "screen-color-keying"); } 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()), } } fn test_editor_project_resource_record( resource_id: &str, project_id: &str, object_key: &str, model: Option<&str>, source_resource_id: Option<&str>, ) -> EditorProjectResourceRecord { EditorProjectResourceRecord { resource_id: resource_id.to_string(), project_id: project_id.to_string(), owner_user_id: "user-1".to_string(), asset_object_id: None, image_src: format!("/{object_key}"), object_key: Some(object_key.to_string()), width: 512, height: 512, source_type: "generated".to_string(), prompt: None, actual_prompt: None, model: model.map(ToOwned::to_owned), provider: Some("internal-provider".to_string()), task_id: None, source_resource_id: source_resource_id.map(ToOwned::to_owned), asset_kind: None, generation_inputs: None, public_showcase_enabled: false, created_at: "2026-07-31T00:00:00.000Z".to_string(), updated_at: "2026-07-31T00:00:00.000Z".to_string(), } } fn test_editor_project_record( project_id: &str, resources: Vec, ) -> EditorProjectRecord { let viewport = EditorCanvasViewportRecord { x: 0.0, y: 0.0, scale: 1.0, }; EditorProjectRecord { project_id: project_id.to_string(), owner_user_id: "user-1".to_string(), title: "测试项目".to_string(), canvas: EditorCanvasRecord { canvas_id: format!("canvas-{project_id}"), project_id: project_id.to_string(), title: "测试画布".to_string(), viewport: viewport.clone(), layers: json!([]), revision: 1, layout_storage_version: 2, background_color: None, created_at: "2026-07-31T00:00:00.000Z".to_string(), updated_at: "2026-07-31T00:00:00.000Z".to_string(), }, viewport, layers: json!([]), resources, created_at: "2026-07-31T00:00:00.000Z".to_string(), updated_at: "2026-07-31T00:00:00.000Z".to_string(), } } fn test_editor_asset_record( asset_id: &str, object_key: &str, model: Option<&str>, source_resource_id: Option<&str>, ) -> EditorAssetRecord { EditorAssetRecord { asset_id: asset_id.to_string(), folder_id: "project".to_string(), label: "测试素材".to_string(), asset_object_id: None, image_src: format!("/{object_key}"), object_key: Some(object_key.to_string()), width: 512, height: 512, source_type: "generated".to_string(), prompt: None, actual_prompt: None, model: model.map(ToOwned::to_owned), provider: Some("internal-provider".to_string()), task_id: None, asset_kind: None, generation_inputs: None, source_resource_id: source_resource_id.map(ToOwned::to_owned), public_showcase_enabled: Some(false), thumbnail_src: None, generation_cost_mud_points: 0, showcase_id: None, showcase_review_status: None, showcase_display_enabled: None, showcase_like_count: None, created_at: "2026-07-31T00:00:00.000Z".to_string(), updated_at: "2026-07-31T00:00:00.000Z".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_removes_internal_generation_metadata() { let state = AppState::new(AppConfig::default()).expect("state should build"); let record = EditorShowcaseAssetRecord { model: Some("BgFilter complex".to_string()), provider: Some("BgFilter".to_string()), generation_inputs: Some(json!({ "fields": [{"title": "处理阶段", "value": "角色抠图"}], "references": [{"refType": "asset", "refId": "asset-source"}], "characterAnimation": {"action": "idle"}, "screenColorHex": "#CFEFFF", "mattingProvider": "BgFilter", "mattingModel": "birefnet" })), ..test_editor_showcase_asset_record("user-author-1") }; let owner_payload = serde_json::to_value( editor_project_resource_payload_from_showcase_record(record.clone()), ) .expect("owner payload should serialize"); assert!(owner_payload.get("provider").is_none()); assert!(owner_payload.get("model").is_none()); let owner_generation_inputs = owner_payload["generationInputs"] .as_object() .expect("owner generation inputs should stay object"); assert!(!owner_generation_inputs.contains_key("screenColorHex")); assert!(!owner_generation_inputs.contains_key("mattingProvider")); assert!(!owner_generation_inputs.contains_key("mattingModel")); assert_eq!( owner_generation_inputs["characterAnimation"], json!({"action": "idle"}) ); let public_payload = serde_json::to_value( editor_project_resource_public_payload_from_record(&state, record), ) .expect("public payload should serialize"); assert!(public_payload.get("provider").is_none()); assert!(public_payload.get("model").is_none()); let generation_inputs = public_payload["generationInputs"] .as_object() .expect("generation inputs should stay object"); assert!(!generation_inputs.contains_key("screenColorHex")); assert!(!generation_inputs.contains_key("mattingProvider")); assert!(!generation_inputs.contains_key("mattingModel")); assert_eq!( generation_inputs["fields"], json!([{"title": "处理阶段", "value": "角色抠图"}]) ); assert_eq!( generation_inputs["references"], json!([{"refType": "asset", "refId": "asset-source"}]) ); assert_eq!( generation_inputs["characterAnimation"], json!({"action": "idle"}) ); } #[test] fn editor_owner_payload_keeps_normal_model_and_sanitizes_asset_metadata() { let mut asset = test_editor_asset_record( "asset-1", "generated-character-drafts/editor/asset-1.png", Some("gpt-image-2"), None, ); asset.generation_inputs = Some(json!({ "fields": [], "references": [], "characterAnimation": {"action": "idle"}, "screenColorHex": "#CFEFFF", "mattingProvider": "BgFilter", "mattingModel": "birefnet" })); let value = serde_json::to_value(editor_asset_payload_from_record(asset)) .expect("asset serializes"); assert_eq!(value["model"], json!("gpt-image-2")); assert!(value.get("provider").is_none()); assert!(value["generationInputs"].get("screenColorHex").is_none()); assert!(value["generationInputs"].get("mattingProvider").is_none()); assert!(value["generationInputs"].get("mattingModel").is_none()); assert_eq!( value["generationInputs"]["characterAnimation"], json!({"action": "idle"}) ); } #[test] fn editor_client_generation_inputs_cannot_forge_internal_audit_fields() { let sanitized = sanitize_editor_client_generation_inputs(Some(json!({ "fields": [{"title": "角色设定", "value": "红发骑士"}], "references": [], "characterAnimation": {"action": "idle"}, "screenColorHex": "#000000", "mattingProvider": "forged-provider", "mattingModel": "forged-model" }))) .expect("generation inputs should stay present"); assert!(sanitized.get("screenColorHex").is_none()); assert!(sanitized.get("mattingProvider").is_none()); assert!(sanitized.get("mattingModel").is_none()); assert_eq!(sanitized["characterAnimation"], json!({"action": "idle"})); assert_eq!( sanitized["fields"], json!([{"title": "角色设定", "value": "红发骑士"}]) ); } #[test] fn background_removal_source_model_recovers_normal_ancestor() { let source_key = "generated-character-drafts/editor/result.png"; let projects = vec![test_editor_project_record( "project-1", vec![ test_editor_project_resource_record( "resource-original", "project-1", "generated-character-drafts/editor/original.png", Some("gpt-image-2"), None, ), test_editor_project_resource_record( "resource-internal", "project-1", "generated-character-drafts/editor/internal.png", Some("birefnet"), Some("resource-original"), ), test_editor_project_resource_record( "resource-result", "project-1", source_key, Some("BgFilter complex"), Some("resource-internal"), ), ], )]; let model = resolve_editor_background_removal_source_model_from_records( projects.as_slice(), &[], Some("project-1"), Some("resource-result"), "resource-result", source_key, ) .expect("matching resource should resolve"); assert_eq!(model.as_deref(), Some("gpt-image-2")); } #[test] fn background_removal_source_model_is_none_without_normal_ancestor() { let source_key = "generated-character-drafts/editor/result.png"; let projects = vec![test_editor_project_record( "project-1", vec![test_editor_project_resource_record( "resource-result", "project-1", source_key, Some("BgFilter complex"), None, )], )]; let model = resolve_editor_background_removal_source_model_from_records( projects.as_slice(), &[], Some("project-1"), Some("resource-result"), source_key, source_key, ) .expect("matching resource should resolve"); assert_eq!(model, None); } #[test] fn background_removal_source_model_resolves_owned_asset_model() { let source_key = "generated-character-drafts/editor/asset.png"; let assets = vec![test_editor_asset_record( "asset-1", source_key, Some("nanobanana2"), None, )]; let model = resolve_editor_background_removal_source_model_from_records( &[], assets.as_slice(), None, None, "asset-1", source_key, ) .expect("owned asset should resolve"); assert_eq!(model.as_deref(), Some("nanobanana2")); } #[test] fn background_removal_source_model_rejects_mismatched_project_resource() { let projects = vec![ test_editor_project_record( "project-1", vec![test_editor_project_resource_record( "resource-1", "project-1", "generated-character-drafts/editor/project-1.png", Some("gpt-image-2"), None, )], ), test_editor_project_record( "project-2", vec![test_editor_project_resource_record( "resource-2", "project-2", "generated-character-drafts/editor/project-2.png", Some("gpt-image-2"), None, )], ), ]; let error = resolve_editor_background_removal_source_model_from_records( projects.as_slice(), &[], Some("project-1"), Some("resource-2"), "resource-2", "generated-character-drafts/editor/project-2.png", ) .expect_err("project and resource must belong to the same source record"); assert_eq!(error.status_code(), StatusCode::FORBIDDEN); } #[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_payload_sanitizer_removes_internal_layer_metadata_without_touching_dialog() { let sanitized = sanitize_editor_payload_media( json!([ { "layerId": "layer-1", "resourceId": "resource-1", "model": "BgFilter complex", "provider": "BgFilter", "generationInputs": { "fields": [], "references": [], "characterAnimation": {"action": "idle"}, "screenColorHex": "#CFEFFF", "mattingProvider": "BgFilter", "mattingModel": "birefnet" } }, { "layerId": "generation-dialog:dialog-1", "resourceId": "generation-dialog:dialog-1", "itemType": "generation-dialog", "dialog": { "model": "gpt-image-2", "provider": "dialog-provider" } } ]), &[], ); assert!(sanitized[0].get("model").is_none()); assert!(sanitized[0].get("provider").is_none()); assert!( sanitized[0]["generationInputs"] .get("screenColorHex") .is_none() ); assert!( sanitized[0]["generationInputs"] .get("mattingProvider") .is_none() ); assert!( sanitized[0]["generationInputs"] .get("mattingModel") .is_none() ); assert_eq!( sanitized[0]["generationInputs"]["characterAnimation"], json!({"action": "idle"}) ); assert_eq!(sanitized[1]["dialog"]["model"], json!("gpt-image-2")); assert_eq!(sanitized[1]["dialog"]["provider"], json!("dialog-provider")); } #[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 static_image_postprocess_rejects_wrong_aspect_ratio_alpha_mask() { let provider_source = image::DynamicImage::new_rgba8(1024, 576); let mut provider_source_bytes = Cursor::new(Vec::new()); provider_source .write_to(&mut provider_source_bytes, image::ImageFormat::Png) .expect("provider source 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::DynamicImage::new_rgba8(512, 512); let mut postprocessed_bytes = Cursor::new(Vec::new()); postprocessed .write_to(&mut postprocessed_bytes, image::ImageFormat::Png) .expect("postprocessed image should encode"); let postprocessed = DownloadedOpenAiImage { bytes: postprocessed_bytes.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let error = apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed) .expect_err("wrong-aspect alpha masks must use source-only fallback"); assert_eq!( error.details().and_then(|details| details.get("code")), Some(&json!("postprocessed-alpha-aspect-ratio-mismatch")) ); } #[test] fn static_image_postprocess_rejects_truncated_png_even_when_header_has_dimensions() { let mut bytes = encode_test_png(4, 4); bytes.truncate(33); let postprocessed = DownloadedOpenAiImage { bytes, mime_type: "image/png".to_string(), extension: "png".to_string(), }; let error = editor_postprocessed_alpha_matches_delivery_dimensions(&postprocessed, 4, 4) .expect_err("a dimension-only PNG header must not pass the full decode guard"); assert!(error.body_text().contains("透明后处理图不是有效图片")); } #[test] fn provider_source_dimensions_require_a_fully_decodable_image() { let invalid = DownloadedOpenAiImage { bytes: b"not-an-image".to_vec(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let error = decode_editor_provider_source_dimensions( &invalid, "editor-icon-spritesheet", "图标 spritesheet 原图不是有效图片", ) .expect_err("invalid provider bytes must fail before source persistence"); assert_eq!(error.status_code(), StatusCode::BAD_GATEWAY); assert!(error.body_text().contains("原图不是有效图片")); } #[tokio::test] async fn static_image_postprocess_reports_provider_source_read_failure() { let state = AppState::new(AppConfig::default()).expect("state should build"); let postprocessed = DownloadedOpenAiImage { bytes: encode_test_png(2, 2), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let original_bytes = postprocessed.bytes.clone(); let (untrusted_postprocessed, error) = apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( &state, "generated-images/editor/missing-provider-source.png", 4, 4, postprocessed, Some(Instant::now() + Duration::from_millis(50)), ) .await; assert_eq!(untrusted_postprocessed.bytes, original_bytes); assert!( error.is_some(), "provider source read failure must be surfaced" ); } #[test] fn pixel_art_sampling_uses_provider_rgb_even_when_bgfilter_dimensions_match() { let provider_source = image::RgbaImage::from_pixel(2, 2, 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("provider 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 mut postprocessed = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 240, 128])); postprocessed.put_pixel(0, 0, image::Rgba([9, 8, 230, 0])); postprocessed.put_pixel(1, 1, image::Rgba([7, 6, 220, 255])); let mut postprocessed_bytes = Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(postprocessed) .write_to(&mut postprocessed_bytes, image::ImageFormat::Png) .expect("BgFilter test image should encode"); let postprocessed = DownloadedOpenAiImage { bytes: postprocessed_bytes.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; assert!( apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed) .expect("legacy same-size alpha path should remain valid") .is_none() ); let combined = apply_editor_postprocessed_alpha_using_provider_rgb(&provider_source, &postprocessed) .expect("pixel-art RGBA source should combine"); let combined = image::load_from_memory(combined.bytes.as_slice()) .expect("combined PNG should decode") .to_rgba8(); assert_eq!(combined.get_pixel(0, 0).0, [200, 10, 20, 0]); assert_eq!(combined.get_pixel(0, 1).0, [200, 10, 20, 128]); assert_eq!(combined.get_pixel(1, 1).0, [200, 10, 20, 255]); } #[tokio::test] async fn pixel_art_expired_budget_returns_original_without_entering_cpu_work() { let original = DownloadedOpenAiImage { bytes: vec![1, 2, 3, 4], mime_type: "image/png".to_string(), extension: "png".to_string(), }; let expired = Instant::now() .checked_sub(Duration::from_millis(1)) .expect("expired test deadline should be representable"); let (fallback, error) = snap_editor_pixel_art_or_original(original.clone(), Some(expired)).await; assert_eq!(fallback.bytes, original.bytes); assert!( error .expect("expired budget should warn") .contains("预算已耗尽") ); } #[test] fn pixel_art_processing_deadline_uses_earlier_local_or_request_budget() { assert_eq!(EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY, 2); assert_eq!( EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION, Duration::from_secs(30) ); let started_at = Instant::now(); let local_deadline = resolve_editor_pixel_art_processing_deadline(started_at, None); assert_eq!( local_deadline.duration_since(started_at), EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION ); let request_deadline = started_at + Duration::from_secs(5); assert_eq!( resolve_editor_pixel_art_processing_deadline(started_at, Some(request_deadline)), request_deadline ); } #[test] fn editor_image_generation_returns_never_drop_accumulated_warnings() { let source = include_str!("editor_project.rs"); // 中文注释:generation_warning 承载未知 style 的 unsupported-image-style 等 // 累积告警,普通图片与角色生成的每一个返回点都必须经 merge 合并;任何 // 直接的 `warning: Some(` 都会把此前累积的告警整条覆盖掉。 for (start, end) in [ ( "pub(crate) async fn generate_editor_image_for_owner", "fn editor_image_generation_billing_asset_kind", ), ( "async fn generate_editor_icon_spritesheet_for_owner", "fn slice_editor_icon_spritesheet_all", ), ] { assert_function_not_contains(source, start, end, &["warning: Some("]); } } #[test] fn pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions() { let source = include_str!("editor_project.rs"); // 中文注释:像素规整的每一条 best-effort 降级(预算耗尽、回读原图失败或超时、 // permit 获取失败、worker 内 deadline、join 异常、worker 超时)都不能把 BgFilter // 原始输出连同 None 尺寸错误直接交回调用方——那等于谎报「尺寸无误」。 // 本断言只覆盖「如实上报尺寸错误」这一层:拿到错误后如何处置仍由各调用方 // 既有语义决定,角色、图标与 UI 据此统一走 provider 原图安全降级,禁止继续 // 持久化或拆分未经验证的透明后处理图。 assert_function_not_contains( source, "async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", "fn restore_editor_generated_image_output_dimensions", &[ "return (postprocessed, None,", "(postprocessed, None, Some(reason))", "take_arc_downloaded_image(postprocessed),\n None,", "fallback.unwrap_or_else(", ], ); // 中文注释:像素函数里只有「尚未取得 provider 原图」的三条分支(进函数即预算耗尽、 // 第一次回读失败、第一次回读超时)允许走会回读的守卫;取得原图之后的四条分支 // (permit 获取失败、worker 内 deadline、join 异常、worker 超时)必须走纯内存守卫, // 否则单次请求会对同一 object key 发出两次 GET,突破「最多增加一次 GET」的不变式。 let pixel_fn_start = source .find("async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original(") .expect("pixel art entry should exist"); let pixel_fn_body = &source[pixel_fn_start..]; let pixel_fn_body = &pixel_fn_body[..pixel_fn_body .find("fn restore_editor_generated_image_output_dimensions_with_filter") .expect("pixel art entry should be followed by the dimension restore helper")]; assert_eq!( pixel_fn_body .matches("degrade_editor_pixel_art_to_postprocessed_with_dimension_guard(") .count(), 3, "只有未取得 provider 原图的三条分支可以走回读守卫" ); assert_eq!( pixel_fn_body .matches("degrade_editor_pixel_art_with_provider_source(") .count(), 4, "取得 provider 原图后的四条降级分支必须走纯内存守卫" ); // 纯内存守卫本身不得触碰 OSS。 assert_function_not_contains( source, "fn degrade_editor_pixel_art_with_provider_source", "async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", &["download_editor_persisted_image_object"], ); // 会回读原图的守卫必须带绝对 deadline,且在外层 request_deadline 缺失时 // (inline HTTP 请求的 RequestContext 默认就没有)必须自行派生兜底上界, // 不允许退化成无界 GET。 assert_function_contains_in_order( source, "async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard", "fn degrade_editor_pixel_art_with_provider_source", &[ "request_deadline: Option", "request_deadline.unwrap_or_else(", "EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION", "Some(repair_deadline),", ], ); // 守卫必须复用非像素路径的同一套尺寸比对与 alpha 回贴,保证尺寸一致时 // 不产生额外 OSS GET,漂移修不好时返回尺寸错误。 assert_function_contains_in_order( source, "async fn degrade_editor_pixel_art_to_postprocessed_with_dimension_guard", "async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", &[ "apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original", "delivery_width", "delivery_height", "(image, dimension_error, Some(reason))", ], ); } #[test] fn pixel_art_provider_input_prep_shares_deadline_and_cpu_permit() { let source = include_str!("editor_project.rs"); assert_function_contains_in_order( source, "async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", "fn restore_editor_generated_image_output_dimensions", &[ "resolve_editor_pixel_art_processing_deadline(Instant::now(), request_deadline)", "tokio::time::timeout_at(", "download_editor_persisted_image_object", "acquire_editor_pixel_art_cpu_permit(processing_deadline)", "tokio::task::spawn_blocking", "let _permit = permit", "if Instant::now() >= processing_deadline", "apply_editor_postprocessed_alpha_using_provider_rgb", "platform_image::snap_pixel_art_with_deadline", ], ); } #[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_with_filter( &provider_output, GPT_IMAGE_2_MODEL, "720x540", image::imageops::FilterType::Lanczos3, ) .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 character_generation_covers_full_model_ratio_and_size_matrix() { for (image_size, aspect_ratio, expected_delivery_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 mut models = vec![EDITOR_IMAGE_MODEL_NANOBANANA2]; if image_size != "0.5K" { models.push(GPT_IMAGE_2_MODEL); } for model in models { let (expected_width, expected_height) = parse_editor_image_edit_pixel_size(expected_delivery_size) .expect("delivery matrix should contain valid pixel dimensions"); let options = normalize_editor_generation_options( Some(model), Some(aspect_ratio), Some(image_size), ); assert_eq!( options.size, expected_delivery_size, "{model} {image_size} {aspect_ratio} should use the shared delivery matrix" ); assert_eq!( resolve_editor_image_request_size(Some("character"), None, true, &options,), expected_delivery_size, "character source and cutout should share {model} {image_size} {aspect_ratio} delivery dimensions" ); let provider_width = (expected_width * 9).div_ceil(8); let provider_height = (expected_height * 9).div_ceil(8); let provider_image = image::DynamicImage::new_luma8(provider_width, provider_height); let mut provider_bytes = Cursor::new(Vec::new()); provider_image .write_to(&mut provider_bytes, image::ImageFormat::Png) .expect("synthetic provider image should encode"); let provider_output = DownloadedOpenAiImage { bytes: provider_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, model, expected_delivery_size, ); assert!( restore_error.is_none(), "{model} {image_size} {aspect_ratio} should restore a larger provider image" ); let restored = image::load_from_memory(restored.bytes.as_slice()) .expect("restored character source should remain a valid image"); assert_eq!( (restored.width(), restored.height()), (expected_width, expected_height), "{model} {image_size} {aspect_ratio} should restore real pixels before background removal" ); } } } #[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_ui_design_generation_keeps_ui_controls_out_of_negative_prompt() { let ui_design_negative_prompt = editor_image_generation_negative_prompt(true); for required_ui_element in ["文字", "边框", "按钮", "UI 控件"] { assert!(!ui_design_negative_prompt.contains(required_ui_element)); } for excluded_non_ui_output in ["无界面的纯场景插画", "海报", "地图", "不可读布局"] { assert!(ui_design_negative_prompt.contains(excluded_non_ui_output)); } assert_eq!( editor_image_generation_negative_prompt(false), "文字、水印、边框、按钮、UI 控件、低清晰度、变形主体" ); } #[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_generated_asset_persistence_keeps_user_prompt_separate_from_system_prompt() { 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", &[ "image,\n role_setting.as_str(),\n generated.actual_prompt.as_deref(),", "output_prompt = role_setting.clone();", ], ); assert_function_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &[ "let user_prompt = icon_descriptions.join(\"\\n\");", "image,\n user_prompt.as_str(),\n generated.actual_prompt.as_deref(),", "prompt: user_prompt.clone(),", "&image,\n user_prompt.as_str(),\n None,", ], ); assert_function_contains( source, "async fn persist_editor_generated_image_data", "async fn persist_editor_provider_source_image", &["Some(prompt.to_string())"], ); assert_function_not_contains( source, "async fn persist_editor_generated_image_data", "async fn persist_editor_provider_source_image", &["actual_prompt.unwrap_or(prompt)"], ); } #[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 icon_spritesheet_limits_reject_large_images() { assert!(validate_editor_icon_spritesheet_dimensions(2048, 2048).is_ok()); assert!(validate_editor_icon_spritesheet_dimensions(4096, 1024).is_ok()); assert!(validate_editor_icon_spritesheet_dimensions(2049, 2048).is_err()); assert!(validate_editor_icon_spritesheet_dimensions(4097, 1).is_err()); } #[test] fn icon_spritesheet_platform_limits_map_to_stable_warning_codes() { for (error, expected_code) in [ ( GeneratedAssetSheetError::RawConnectedComponentLimitExceeded { component_count: 4097, max_component_count: 4096, }, EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT, ), ( GeneratedAssetSheetError::OutputSliceLimitExceeded { slice_count: 65, max_slice_count: 64, }, EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT, ), ( GeneratedAssetSheetError::MergeCandidateLimitExceeded { candidate_count: 513, max_candidate_count: 512, }, EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT, ), ( GeneratedAssetSheetError::TotalCropPixelLimitExceeded { total_crop_pixels: EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS + 1, max_total_crop_pixels: EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, }, EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT, ), ] { let error = map_editor_icon_spritesheet_platform_error(error); assert_eq!(error.status_code(), StatusCode::UNPROCESSABLE_ENTITY); assert_eq!( error.details().and_then(|details| details.get("code")), Some(&json!(expected_code)) ); assert_eq!( editor_icon_spritesheet_slice_warning_from_error(&error).code, expected_code ); } } #[test] fn icon_spritesheet_processing_deadline_uses_earlier_budget() { assert_eq!(EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY, 2); assert_eq!(EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY, 2); assert_eq!(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY, 2); assert_eq!( EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT, Duration::from_secs(10) ); assert_eq!( EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT, Duration::from_secs(60) ); assert_eq!( EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4 ); assert_eq!( EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION, Duration::from_secs(30) ); let started_at = Instant::now(); assert_eq!( resolve_editor_icon_spritesheet_processing_deadline(started_at, None) .duration_since(started_at), EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION ); let request_deadline = started_at + Duration::from_secs(5); assert_eq!( resolve_editor_icon_spritesheet_processing_deadline(started_at, Some(request_deadline)), request_deadline ); } #[tokio::test] async fn icon_spritesheet_expired_deadline_skips_blocking_image_work() { let source = DownloadedImage { bytes: b"not-an-image".to_vec(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let expired = Instant::now() .checked_sub(Duration::from_millis(1)) .expect("expired deadline should be representable"); let error = slice_editor_icon_spritesheet_all(source, Some(expired)) .await .err() .expect("expired CPU budget must fail before decoding"); assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); assert_eq!( error.details().and_then(|details| details.get("code")), Some(&json!(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT)) ); } #[test] fn all_spritesheet_paths_share_bounded_blocking_slicer() { let source = include_str!("editor_project.rs"); assert_function_contains_in_order( source, "async fn slice_editor_icon_spritesheet_all", "fn resolve_editor_icon_spritesheet_processing_deadline", &[ "EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER", "acquire_owned", "EDITOR_ICON_SPRITESHEET_CPU_LIMITER", "tokio::task::spawn_blocking", "let _permit = permit", "validate_editor_icon_spritesheet_source", "prepare_generated_icon_spritesheet_all_by_connected_components", "EDITOR_ICON_SPRITESHEET_MAX_SLICES", "EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS", "tokio::time::timeout_at", ], ); for (start, end) in [ ( "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn split_editor_icon_spritesheet", ), ( "pub async fn split_editor_icon_spritesheet", "async fn slice_editor_icon_spritesheet_all", ), ( "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, &["slice_editor_icon_spritesheet_all"]); } assert_function_contains_in_order( source, "pub async fn split_editor_icon_spritesheet", "async fn slice_editor_icon_spritesheet_all", &[ "slice_editor_icon_spritesheet_all", ".await?", "persist_editor_spritesheet_slices", "complete_editor_canvas_generation_with_items", ], ); assert_function_contains_in_order( source, "async fn persist_editor_spritesheet_slices", "async fn encode_editor_icon_spritesheet_slice", &[ "build_editor_spritesheet_http_client", "EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT", "EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT", "encode_editor_icon_spritesheet_slice", "upload_editor_spritesheet_slice", ".buffer_unordered(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY)", ".try_collect::>()", "uploads.sort_by_key", "drop(prepared)", "persist_editor_spritesheet_slice_batch", ], ); assert_function_not_contains( source, "async fn persist_editor_spritesheet_slices", "async fn encode_editor_icon_spritesheet_slice", &[ "persist_editor_generated_image(", "persist_editor_generated_asset(", ], ); assert_function_contains_in_order( source, "fn build_editor_spritesheet_http_client", "pub async fn generate_editor_icon_spritesheet", &["reqwest::Client::builder()", ".connect_timeout", ".timeout"], ); assert_function_contains_in_order( source, "pub async fn split_editor_icon_spritesheet", "async fn slice_editor_icon_spritesheet_all", &[ "get_editor_project", "normalize_editor_record_object_key", "read_editor_reference_image_object", "slice_editor_icon_spritesheet_all", ], ); assert_function_not_contains( source, "pub async fn split_editor_icon_spritesheet", "async fn slice_editor_icon_spritesheet_all", &["parse_editor_reference_image", "list_editor_projects"], ); assert_function_contains_in_order( source, "pub async fn split_editor_icon_spritesheet", "async fn slice_editor_icon_spritesheet_all", &[ "acquire_editor_icon_spritesheet_memory_admission", "read_editor_reference_image_object_with_client", "slice_editor_icon_spritesheet_all_with_memory_admission", "persist_editor_spritesheet_slices", ], ); } #[test] fn spritesheet_slice_record_ids_are_stable_and_partitioned() { let first = stable_editor_spritesheet_slice_record_id( EDITOR_ASSET_ID_PREFIX, "user-1", "task-1", 0, ); assert_eq!( first, stable_editor_spritesheet_slice_record_id( EDITOR_ASSET_ID_PREFIX, "user-1", "task-1", 0, ) ); assert_ne!( first, stable_editor_spritesheet_slice_record_id( EDITOR_ASSET_ID_PREFIX, "user-1", "task-1", 1, ) ); assert_ne!( first, stable_editor_spritesheet_slice_record_id( EDITOR_ASSET_ID_PREFIX, "user-2", "task-1", 0, ) ); assert!(first.starts_with(EDITOR_ASSET_ID_PREFIX)); } #[test] fn automatic_spritesheet_persist_keeps_processing_timeout_warning_code() { let timeout = editor_icon_spritesheet_processing_timeout_error(); assert_eq!( editor_icon_spritesheet_warning_after_persist_error(&timeout).code, EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT ); let persistence = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": "upload failed", })); assert_eq!( editor_icon_spritesheet_warning_after_persist_error(&persistence).code, EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE ); } #[tokio::test] async fn spritesheet_upload_timeout_releases_memory_admission() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("stall server should bind"); let address = listener.local_addr().expect("stall address"); let server = tokio::spawn(async move { let (_socket, _) = listener.accept().await.expect("client should connect"); std::future::pending::<()>().await; }); let limiter = Arc::new(tokio::sync::Semaphore::new(1)); let permit = Arc::clone(&limiter) .acquire_owned() .await .expect("memory admission"); let client = build_editor_spritesheet_http_client( Duration::from_millis(50), Duration::from_millis(50), ) .expect("bounded client"); let error = async move { let _permit = permit; client.get(format!("http://{address}/stall")).send().await } .await .expect_err("stalled upload must time out"); assert!(error.is_timeout()); assert!(limiter.try_acquire_owned().is_ok()); server.abort(); } #[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_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_dimension_warning() { let dimension_warning = EditorGenerationWarningResponse { code: EDITOR_GENERATION_DIMENSION_WARNING_CODE, reason: "provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位".to_string(), }; 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(), task_id: "task-character-source".to_string(), resource: None, asset: None, project: None, warning: Some(editor_postprocess_fallback_warning_with_dimension( "生成任务成功,后处理失败。", Some(&dimension_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!( "生成任务成功,后处理失败。 provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位" ) ); } #[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: "图集中未识别到可拆分的独立素材。".to_string(), }), prompt: "图标 prompt".to_string(), actual_prompt: Some("图标 prompt".to_string()), model: "gemini-3.1-flash-image-preview".to_string(), 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!("图集中未识别到可拆分的独立素材。") ); } #[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")); assert_eq!(request.style, None); } #[test] fn editor_image_generation_style_is_tolerant_for_unknown_strings() { for value in [None, Some(""), Some(" "), Some("none")] { let (style, warning) = normalize_editor_image_generation_style(value, true); assert_eq!(style, EditorImageGenerationStyle::None); assert!(warning.is_none()); } let (style, warning) = normalize_editor_image_generation_style(Some("pixelArt"), true); assert_eq!(style, EditorImageGenerationStyle::PixelArt); assert!(warning.is_none()); for (value, pixel_art_supported) in [("futureStyle", true), ("pixelArt", false)] { let (style, warning) = normalize_editor_image_generation_style(Some(value), pixel_art_supported); assert_eq!(style, EditorImageGenerationStyle::None); let warning = warning.expect("unsupported style should warn"); assert_eq!( warning.code, EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE ); assert!(warning.reason.contains("已按无风格处理")); } } #[test] fn editor_image_generation_style_queue_json_round_trips_and_rejects_wrong_types() { let request: EditorImageGenerationRequest = serde_json::from_value(json!({ "prompt": "生成像素角色", "kind": "character", "style": "pixelArt" })) .expect("pixel art request should deserialize"); let queued_json = serde_json::to_string(&request).expect("request should serialize"); let restored: EditorImageGenerationRequest = serde_json::from_str(queued_json.as_str()).expect("queued request should deserialize"); assert_eq!(restored.style.as_deref(), Some("pixelArt")); let legacy: EditorImageGenerationRequest = serde_json::from_value(json!({ "prompt": "旧请求" })) .expect("legacy request should remain compatible"); assert_eq!(legacy.style, None); assert!( serde_json::to_value(&legacy) .expect("legacy request should serialize") .get("style") .is_none() ); assert!( serde_json::from_value::(json!({ "prompt": "错误请求", "style": {} })) .is_err() ); } #[test] fn editor_generation_warnings_merge_without_changing_the_response_shape() { let merged = merge_editor_generation_warnings( Some(editor_unsupported_image_style_warning()), Some(editor_postprocess_fallback_warning( "生成任务成功,后处理失败。", )), ) .expect("two warnings should merge"); assert_eq!(merged.code, EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE); assert!(merged.reason.contains("已按无风格处理")); assert!(merged.reason.contains("后处理失败")); } #[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(" 冒险界面图标 ")); assert_eq!(request.style, None); let pixel_request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ "referenceImageSrc": "/generated-images/editor/spec.png", "iconDescriptions": ["返回按钮"], "style": "pixelArt" })) .expect("pixel icon request should deserialize"); let queued_json = serde_json::to_string(&pixel_request).expect("pixel icon request should serialize"); let restored: EditorIconSpritesheetGenerationRequest = serde_json::from_str(queued_json.as_str()) .expect("queued pixel icon request should deserialize"); assert_eq!(restored.style.as_deref(), Some("pixelArt")); } #[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_without_internal_details() { 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 ); let error = parse_editor_bgfilter_seg_model(Some("u2net")).expect_err("unknown model should fail"); let body = error.body_text(); assert!(body.contains("抠图参数无效")); assert!(!body.to_ascii_lowercase().contains("bgfilter")); assert!(!body.contains(EDITOR_BGFILTER_DEFAULT_SEG_MODEL)); assert!(!body.contains(EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG)); } #[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"); assert_eq!(removed.model, "birefnet"); } #[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_manual_background_removal_reuses_single_account_snapshot_pair() { let source = include_str!("editor_project.rs"); assert_function_not_contains( source, "pub(crate) async fn remove_editor_image_background_for_owner", "struct EditorBackgroundRemovalImage", &["resolve_editor_reference_object_key_for_owner"], ); assert_function_occurrence_count( source, "async fn resolve_editor_background_removal_source(", "fn resolve_editor_background_removal_source_model_from_records(", ".list_editor_projects(", 1, ); assert_function_occurrence_count( source, "async fn resolve_editor_background_removal_source(", "fn resolve_editor_background_removal_source_model_from_records(", ".get_editor_asset_library(", 1, ); } #[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", "let output_model = generation_options.model.to_string();", "apply_editor_matting_metadata_to_generation_inputs", "build_editor_derived_asset_generation_inputs(\"角色抠图\"", "generation_inputs: output_generation_inputs", ], ); assert_function_not_contains( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &["{ \"title\": \"处理模型\""], ); 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_ENABLED", "persist_editor_provider_source_resource", "slice_editor_icon_spritesheet_all", "persist_editor_spritesheet_slices", "model: generation_options.model.to_string(),", "apply_editor_matting_metadata_to_generation_inputs", "build_editor_derived_asset_generation_inputs(\"图标图集抠图\"", "generation_inputs: matting_generation_inputs", ], ); assert_function_not_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn extract_editor_ui_design_assets", &["{ \"title\": \"处理模型\""], ); 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_ENABLED", ], ); 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_background_removal_source", "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_editor_icon_spritesheet_all", "model: generation_options.model.to_string(),", "apply_editor_matting_metadata_to_generation_inputs", "build_editor_derived_asset_generation_inputs(\"UI图集抠图\"", "generation_inputs: matting_generation_inputs", ], ); assert_function_not_contains( source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", &["{ \"title\": \"处理模型\""], ); 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", "EditorScreenBackgroundRemovalOutput::bgfilter", "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", "EditorScreenBackgroundRemovalOutput::aliyun", "download_editor_persisted_image_object", "remove_editor_generated_green_screen_background", "drop(source_image)", "output.map(EditorScreenBackgroundRemovalOutput::local)", ], ); 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 split_editor_icon_spritesheet", &[ 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"); assert_function_contains( source, "async fn complete_editor_source_only_fallback", "pub(crate) async fn complete_editor_canvas_generation_with_items", &[ "complete_editor_canvas_generation", "source_record.resource.as_ref()", "merge_editor_generation_warnings", "editor_postprocess_fallback_warning_with_dimension", ], ); assert_function_not_contains( source, "async fn complete_editor_source_only_fallback", "pub(crate) async fn complete_editor_canvas_generation_with_items", &[ "persist_editor_generated_image", "persist_editor_generated_asset", "persist_editor_spritesheet_slices", "slice_editor_icon_spritesheet_all", "complete_editor_canvas_generation_with_items", ], ); 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_occurrence_count( source, start, end, "complete_editor_source_only_fallback(", 2, ); 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_source_only_fallback", "return Ok(json_success_body", ], ); 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")); assert!(fallback.contains("complete_editor_source_only_fallback")); 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") { assert!(fallback.contains("icon_image_srcs: Vec::new()")); assert!(fallback.contains("slice_warning: None")); 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")); } } for (start, end) in [ ( "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn split_editor_icon_spritesheet", ), ( "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", ), ] { let start_index = source.find(start).expect("handler should exist"); let tail = &source[start_index..]; let end_index = tail.find(end).expect("handler end should exist"); let body = &tail[..end_index]; let fallback_start = body .find("if let Some(error) = postprocess_dimension_error") .expect("dimension fallback should exist"); let fallback_end = body[fallback_start..] .find("let matting_generation_inputs") .map(|offset| fallback_start + offset) .expect("normal transparent persistence path should follow fallback"); let fallback = &body[fallback_start..fallback_end]; for snippet in [ "complete_editor_source_only_fallback", "return Ok(json_success_body", "icon_image_srcs: Vec::new()", "slice_warning: None", ] { assert!( fallback.contains(snippet), "{start} fallback should contain {snippet}" ); } for forbidden in [ "persist_editor_generated_image(", "persist_editor_generated_asset(", "persist_editor_spritesheet_slices(", "slice_editor_icon_spritesheet_all(", "complete_editor_canvas_generation_with_items(", ] { assert!( !fallback.contains(forbidden), "{start} fallback must not perform {forbidden}" ); } } } #[test] fn editor_character_postprocess_fallback_uses_shared_helper_without_swallowing_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 dimension_restore = body .find("restore_editor_generated_image_output_dimensions_or_original") .expect("character provider image should be restored to delivery dimensions"); let source_persist = body .find("persist_editor_provider_source_image") .expect("character provider source should be persisted"); 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" ); assert!( dimension_restore < source_persist && source_persist < removal, "character delivery dimensions must be restored before source persistence and background removal" ); for snippet in [ "Err(error)", "complete_editor_source_only_fallback", "return Ok(json_success_body", "warning: 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?;")); let alpha_restore = body .find("apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original") .expect("character postprocess should restore alpha to the source dimensions"); let dimension_fallback = body[alpha_restore..] .find("if let Some(error) = postprocess_dimension_error") .map(|offset| alpha_restore + offset) .expect("character postprocess dimension errors should be handled"); let processed_image = body[dimension_fallback..] .find("image = restored_removal_image;") .map(|offset| dimension_fallback + offset) .expect("processed image should only continue after dimension validation"); let dimension_fallback_body = &body[dimension_fallback..processed_image]; for snippet in [ "complete_editor_source_only_fallback", "return Ok(json_success_body", "generation_warning", "warning: fallback_warning", ] { assert!( dimension_fallback_body.contains(snippet), "character dimension fallback should contain {snippet}" ); } } #[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"], ); // 中文注释:尺寸一致时必须在回读 provider 原图之前就返回,只有漂移才发起 GET。 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", &[ "editor_postprocessed_alpha_matches_delivery_dimensions", "Ok(true) => return (postprocessed, None)", "download_editor_persisted_image_object", "apply_editor_postprocessed_alpha_with_provider_source_or_original", ], ); assert_function_contains_in_order( source, "fn editor_postprocessed_alpha_matches_delivery_dimensions", "async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original", &["image::load_from_memory"], ); 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", "slice_source"]); assert_function_not_contains( source, start, end, &[ "bytes: image.bytes.clone()", "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_occurrence_count( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn split_editor_icon_spritesheet", "persist_editor_generated_image(", 1, ); assert_function_occurrence_count( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn split_editor_icon_spritesheet", "persist_editor_spritesheet_slices(", 1, ); assert_function_not_contains( source, "async fn snap_editor_pixel_art_or_original", "fn restore_editor_generated_image_output_dimensions", &[ "persist_editor_generated_image", "persist_editor_generated_asset", "persist_editor_spritesheet_slices", "complete_editor_canvas_generation", ], ); assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &[ "normalize_editor_image_generation_style", "snap_editor_pixel_art_or_original", "persist_editor_generated_image", ], ); assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn split_editor_icon_spritesheet", &[ "normalize_editor_image_generation_style", "remove_editor_generated_screen_background_with_bgfilter", "apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", "persist_editor_generated_image", "persist_editor_spritesheet_slices", ], ); 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_editor_icon_spritesheet_all", "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_warning_after_persist_error", "slice_editor_icon_spritesheet_all", ], ); } #[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" ); } }