use std::{ borrow::Cow, collections::{BTreeMap, HashSet}, future::Future, io::Cursor, sync::{ Arc, LazyLock, atomic::{AtomicBool, AtomicUsize, Ordering}, }, time::{Duration, Instant}, }; use axum::{ Json, extract::{Extension, Path, Query, State, rejection::JsonRejection}, http::{HeaderValue, StatusCode}, }; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectUpsertInput, build_asset_object_upsert_input, generate_asset_object_id, }; use platform_image::DownloadedImage; 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, EditorSceneGenerateRequest, }; use shared_contracts::editor_generation::editor_generation_request_fingerprint; use shared_contracts::editor_generation::{ editor_generation_stable_asset_id, editor_generation_stable_asset_object_id, editor_generation_stable_resource_id, }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::editor_project::{ EditorGenerationResultPersistItemRecordInput, EditorGenerationResultPersistRecord, EditorGenerationResultPersistRecordInput, EditorGenerationTargetPreflightRecordInput, EditorPixelArtCanvasCompletionRecordInput, EditorPixelArtCanvasPlaceholderRecordInput, EditorPixelArtResultPersistRecordInput, EditorPixelArtResultPreflightRecordInput, }; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput, EditorReferenceRecord, EditorReferenceResolveRecordInput, EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobPhaseUpdateError, ExternalGenerationJobPhaseUpdateRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, SpacetimeClientStage, }; use crate::{ api_response::json_success_body, asset_billing::{ begin_editor_generation_commit_attempt, current_external_generation_billing_price_mud_points, editor_generation_commit_attempt_was_dispatched, execute_billable_asset_operation_with_cost, mark_editor_generation_commit_dispatched, mark_editor_generation_commit_outcome_confirmed, with_editor_generation_durable_billing_boundary, }, 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_project_icon::{ EditorIconSpritesheetGenerationResponse, EditorIconSpritesheetIconResponse, PersistEditorSpritesheetSlicesInput, editor_icon_spritesheet_slice_warning_from_error, editor_icon_spritesheet_warning_after_persist_error, prepare_editor_spritesheet_slices_for_generation, slice_editor_icon_spritesheet_all, }, editor_screen_background_decision::{ EditorScreenBackgroundDecision, EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, resolve_editor_screen_background_color, }, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::{ GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput, GeneratedImageAssetPreparedPut, }, decode_generated_image_asset_data_url, normalize_generated_image_asset_mime, }, http_error::AppError, openai_image_generation::{ DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages, OpenAiImageSettings, 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, prompt::editor_scene::build_editor_scene_prompt, request_context::RequestContext, state::AppState, work_author::{ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID}, }; #[cfg(test)] use crate::editor_project_icon::*; #[cfg(test)] use platform_image::generated_asset_sheets::GeneratedAssetSheetError; const EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE: usize = 36; const MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES: usize = 512 * 1024; const MAX_EDITOR_GENERATION_WARNING_CHARS: usize = 2_048; const EDITOR_GENERATION_SLICE_WARNING_PREFIX: &str = "图集已生成,但自动拆分未完成:"; const EDITOR_GENERATION_WARNING_REDACTED_MESSAGE: &str = "自动拆分未完成(告警详情含内联媒体引用,已省略)"; const ATOMIC_EDITOR_GENERATION_RAW_RESULT_FIELD: &str = "atomic-editor-generation-result"; const MAX_EDITOR_GENERATION_UNKNOWN_RESULT_REPLAYS: usize = 2; 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; pub(crate) const EDITOR_ASSET_DEFAULT_FOLDER_ID: &str = "project"; const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024"; pub(crate) 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_IMAGE_GENERATION_REFERENCE_LIMIT: usize = 5; const EDITOR_QUICK_EDIT_REFERENCE_LIMIT: usize = 9; const EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT: usize = 8; const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5; pub(crate) fn editor_provider_reference_limit(model: &str) -> usize { if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { 14 } else { 5 } } const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_ANIMATION_CONTRACT_KIND: &str = "character-animation"; pub(crate) 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_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; /// 中文注释:inline 完美像素的端点级并发闸。它是仓库里唯一「用户主动触发 + 同步执行 + /// 下载大图 + 吃 CPU」且不经生成队列的路径——其余像素规整入口都由 job + worker 承担准入。 /// 闸此前只有 CPU 许可那一道,而它设在下载之后:请求先把最多 32 MiB 读进内存、先打完 /// 几轮全账号 SpacetimeDB 扫描,才被拦下排队,等于闸在资源已被消耗之后才检查。 /// 客户端只发几百字节 JSON 就能让服务端拉取 32 MiB,放大比约 64000 : 1,且本操作免费 /// (generation_cost_mud_points = 0)、无 per-user 配额。 /// /// 这道闸覆盖从第一次 IO 到 handler 结束的全过程,一个数字同时封住并发 SpacetimeDB /// 扫描数、并发源图缓冲数与并发 OSS PUT 数。取 4 而不是等于 CPU 槽的 2:2 会让下载完全 /// 串在 snap 后面,4 才能让两个请求下载的同时另两个在算,把下载延迟藏进 CPU 时间里。 const EDITOR_PIXEL_ART_SNAP_MAX_CONCURRENCY: usize = 4; /// 中文注释:等待队列保险丝,对齐 BgFilter 的 Q。部署配置里全局准入是 512,小于这个值, /// 所以正常部署下它打不到;它兜的是 max_concurrent_requests 未配置(代码默认 None, /// 即无全局上限)时的连接风暴。它只防雪崩,不做流量整形。 const EDITOR_PIXEL_ART_SNAP_MAX_QUEUE_DEPTH: usize = 2048; const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); /// 中文注释:持久化阶段的独立预算。它与上面的处理预算相加就是服务端最坏合法时长,必须小于 /// 客户端 `snapEditorImageToPixelArt` 的 120 秒超时——否则客户端会在服务端仍在合法工作时 /// 先 abort,对账采样到仍在途的操作(占位还在、素材库还空),用户照提示核对却什么也看不到, /// 重试就造出孤儿对象。30 + 60 = 90,余 30 秒给网络往返。 /// 独立起算而不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,因下载慢而被砍预算、 /// 中途放弃只会留下孤儿对象。 const EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION: Duration = Duration::from_secs(60); /// 中文注释:完美像素的 OSS PUT/HEAD 位于数据库事务外;随后 asset object、project /// resource、editor asset 与可选 canvas completion 由单个 SpacetimeDB procedure 原子提交。 /// HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此第一次 OSS PUT 之后仍属于 /// 未知结果边界。这个 detail 字段只在该边界之后置位,客户端据此先读权威快照对账。 pub(crate) const EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL: &str = "resultPersistenceStarted"; const EDITOR_PIXEL_ART_SNAP_ASSET_KIND: &str = "editor_pixel_art_snap"; const EDITOR_PIXEL_ART_SNAP_MODEL: &str = "Perfect Pixel"; const EDITOR_PIXEL_ART_SNAP_PROVIDER: &str = "Genarrative"; const EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION: &str = "perfect-pixel-v1"; static EDITOR_PIXEL_ART_CPU_LIMITER: LazyLock> = LazyLock::new(|| { Arc::new(tokio::sync::Semaphore::new( EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY, )) }); static EDITOR_PIXEL_ART_SNAP_LIMITER: LazyLock> = LazyLock::new(|| { Arc::new(tokio::sync::Semaphore::new( EDITOR_PIXEL_ART_SNAP_MAX_CONCURRENCY, )) }); static EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH: AtomicUsize = AtomicUsize::new(0); 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"; pub(crate) 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_SCENE_IMAGE_ASSET_KIND: &str = "editor_scene_image"; 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, image_sequence_frames: Option, image_sequence_duration_ms: 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, image_sequence_frames: Option, image_sequence_duration_ms: 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, } pub(crate) struct EditorImagePromptBuildOutput { pub(crate) prompt: String, pub(crate) generation_inputs: Option, } pub(crate) struct EditorImageProviderRequest<'a> { pub(crate) model: &'a str, pub(crate) prompt: &'a str, pub(crate) negative_prompt: Option<&'a str>, pub(crate) size: &'a str, pub(crate) aspect_ratio: &'a str, pub(crate) image_size: &'a str, pub(crate) reference_images: &'a [OpenAiReferenceImage], pub(crate) failure_context: &'a str, } pub(crate) async fn request_editor_generated_images( http_client: &reqwest::Client, settings: &OpenAiImageSettings, request: EditorImageProviderRequest<'_>, ) -> Result { if request.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { create_openai_nanobanana_generate_content( http_client, settings, request.model, request.prompt, request.negative_prompt, request.aspect_ratio, request.image_size, request.reference_images, request.failure_context, ) .await } else if request.reference_images.is_empty() { create_openai_image_generation_with_model( http_client, settings, request.model, request.prompt, request.negative_prompt, request.size, 1, &[], request.failure_context, ) .await } else { create_openai_image_edit_with_references_and_model( http_client, settings, request.model, request.prompt, request.negative_prompt, request.size, 1, request.reference_images, request.failure_context, ) .await } } #[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 EditorPixelArtSnapRequest { pub(crate) source_image_src: String, pub(crate) project_id: String, pub(crate) source_resource_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) 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, } #[derive(Clone, Debug)] pub(crate) struct EditorGenerationOperationContext { pub(crate) operation_kind: String, pub(crate) operation_id: String, pub(crate) operation_fingerprint: String, pub(crate) worker_id: Option, pub(crate) lease_token: Option, pub(crate) queue_result_context: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum EditorGenerationQueueConsumer { Standard, EditorAgent, ExternalApi, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct EditorGenerationQueueResultContext { consumer: EditorGenerationQueueConsumer, source_module: String, source_entity_id: String, } impl EditorGenerationQueueResultContext { pub(crate) fn new( consumer: EditorGenerationQueueConsumer, source_module: impl Into, source_entity_id: impl Into, ) -> Self { Self { consumer, source_module: source_module.into(), source_entity_id: source_entity_id.into(), } } pub(crate) fn from_job(job: &ExternalGenerationJobRecord) -> Self { let consumer = if job .dedupe_key .trim() .starts_with("external-api-generation:") { EditorGenerationQueueConsumer::ExternalApi } else if serde_json::from_str::(job.request_payload_json.as_str()) .ok() .is_some_and(|payload| { payload .pointer("/generationInputs/source") .and_then(Value::as_str) .is_some_and(|source| source.trim() == "editor-agent") }) { EditorGenerationQueueConsumer::EditorAgent } else { EditorGenerationQueueConsumer::Standard }; Self::new( consumer, job.source_module.clone(), job.source_entity_id.clone(), ) } } 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, pub(crate) operation: Option, } impl EditorGenerationCaller { pub(crate) 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, operation: None, } } pub(crate) fn with_inline_operation( mut self, request_context: &RequestContext, operation_kind: &str, payload: &T, ) -> Result { let request_payload_json = serde_json::to_string(payload).map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-generation-operation", "message": format!("序列化编辑器生成幂等请求失败:{error}"), })) })?; self.operation = Some(EditorGenerationOperationContext { operation_kind: operation_kind.to_string(), operation_id: request_context.request_id().trim().to_string(), operation_fingerprint: editor_generation_request_fingerprint( operation_kind, request_payload_json.as_str(), ), worker_id: None, lease_token: None, queue_result_context: None, }); Ok(self) } pub(crate) 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, } #[derive(Debug)] pub(crate) struct PreparedEditorGeneratedAssetRecord { pub(crate) item: PreparedEditorGenerationResultItem, pub(crate) record: EditorGeneratedAssetRecord, } pub(crate) type PreparedEditorGenerationResultItem = EditorGenerationResultPersistItemRecordInput; #[derive(Clone, Debug)] pub(crate) enum EditorCanvasLayoutPlan { None, Generation { owner_user_id: String, project_id: Option, completion: Option, items: Vec, primary_layer_id: Option, }, BackgroundRemoval { owner_user_id: String, project_id: Option, target_layer_id: Option, resource: Option, replacement_title: Option, recenter_replacement: bool, }, } impl EditorCanvasLayoutPlan { pub(crate) fn generation( owner_user_id: &str, project_id: Option<&str>, completion: Option<&EditorCanvasGenerationCompletionRequest>, items: Vec, primary_layer_id: Option, ) -> Self { Self::Generation { owner_user_id: owner_user_id.to_string(), project_id: project_id.map(str::to_string), completion: completion.cloned(), items, primary_layer_id, } } #[allow(clippy::too_many_arguments)] pub(crate) fn background_removal( owner_user_id: &str, project_id: Option<&str>, target_layer_id: Option<&str>, resource: Option<&EditorProjectResourcePayload>, replacement_title: Option<&str>, recenter_replacement: bool, ) -> Self { Self::BackgroundRemoval { owner_user_id: owner_user_id.to_string(), project_id: project_id.map(str::to_string), target_layer_id: target_layer_id.map(str::to_string), resource: resource.cloned(), replacement_title: replacement_title.map(str::to_string), recenter_replacement, } } async fn prepare( &self, state: &AppState, ) -> Result, AppError> { let layout = match self { Self::None => None, Self::Generation { owner_user_id, project_id, completion, items, primary_layer_id, } => { prepare_editor_canvas_generation_layout( state, owner_user_id, project_id.as_deref(), completion.as_ref(), items.clone(), primary_layer_id.clone(), ) .await? } Self::BackgroundRemoval { owner_user_id, project_id, target_layer_id, resource, replacement_title, recenter_replacement, } => { prepare_editor_canvas_background_removal_layout( state, owner_user_id, project_id.as_deref(), target_layer_id.as_deref(), resource.as_ref(), replacement_title.as_deref(), *recenter_replacement, ) .await? } }; Ok(layout) } } pub(crate) async fn persist_editor_generation_result_atomically( state: &AppState, caller: &EditorGenerationCaller, items: Vec, canvas_layout_plan: EditorCanvasLayoutPlan, job_result_payload_json: Option, completed_at_micros: i64, ) -> Result { let operation = caller.operation.as_ref().ok_or_else(|| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-generation-operation", "message": "编辑器生成缺少稳定 operation 上下文。", })) })?; let job_completion = match ( &operation.worker_id, &operation.lease_token, &operation.queue_result_context, ) { (Some(worker_id), Some(lease_token), Some(queue_result_context)) => { let result_payload_json = job_result_payload_json .map(|payload| { compact_atomic_editor_generation_job_result(queue_result_context, payload) }) .transpose()?; Some(ExternalGenerationJobCompleteRecordInput { job_id: operation.operation_id.clone(), worker_id: worker_id.clone(), lease_token: lease_token.clone(), result_payload_json, completed_at_micros, }) } (None, None, None) => None, _ => { return Err( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-generation-operation", "message": "编辑器生成 queue lease guard 不完整。", })), ); } }; let canvas_layout = canvas_layout_plan.prepare(state).await?; let has_canvas_layout = canvas_layout.is_some(); let prepared_commit = EditorGenerationResultPersistRecordInput { owner_user_id: caller.owner_user_id.clone(), operation_kind: operation.operation_kind.clone(), operation_id: operation.operation_id.clone(), operation_fingerprint: operation.operation_fingerprint.clone(), items, canvas_layout, job_completion, completed_at_micros, }; let first_attempt = persist_editor_generation_prepared_commit(state, &prepared_commit).await; match first_attempt { Ok(persisted) => Ok(persisted), Err(error) if has_canvas_layout && is_editor_canvas_revision_conflict(&error) => { let refreshed_layout = canvas_layout_plan.prepare(state).await?; let refreshed_prepared_commit = EditorGenerationResultPersistRecordInput { canvas_layout: refreshed_layout, ..prepared_commit }; persist_editor_generation_prepared_commit(state, &refreshed_prepared_commit).await } Err(error) => Err(error), } } async fn persist_editor_generation_prepared_commit( state: &AppState, prepared_commit: &EditorGenerationResultPersistRecordInput, ) -> Result { call_with_editor_generation_unknown_result_replay(|| { state .spacetime_client() .persist_editor_generation_result_with_dispatch( prepared_commit.clone(), mark_editor_generation_commit_dispatched, ) }) .await .map_err(map_editor_project_error) } async fn call_with_editor_generation_unknown_result_replay( mut call: F, ) -> Result where F: FnMut() -> Fut, Fut: std::future::Future>, { for replay in 0..=MAX_EDITOR_GENERATION_UNKNOWN_RESULT_REPLAYS { begin_editor_generation_commit_attempt(); match call().await { Ok(value) => { mark_editor_generation_commit_outcome_confirmed(); return Ok(value); } Err(error) if replay < MAX_EDITOR_GENERATION_UNKNOWN_RESULT_REPLAYS && is_editor_generation_retryable_transport_error(&error) => { if editor_generation_commit_attempt_was_dispatched() && !is_editor_generation_unknown_result_error(&error, true) { mark_editor_generation_commit_outcome_confirmed(); } continue; } Err(error) => { if editor_generation_commit_attempt_was_dispatched() && !is_editor_generation_unknown_result_error(&error, true) { mark_editor_generation_commit_outcome_confirmed(); } return Err(error); } } } unreachable!("editor generation unknown-result replay loop must return") } fn is_editor_generation_retryable_transport_error(error: &SpacetimeClientError) -> bool { match error { SpacetimeClientError::Build(_) | SpacetimeClientError::ConnectDropped => true, SpacetimeClientError::Timeout(_) => true, SpacetimeClientError::Runtime(message) => { message.contains("503") || message.contains("Service Unavailable") || message.contains("Failed to connect") || message.contains("WebSocket") || message.contains("连接已断开") || message.contains("连接在返回结果前已断开") } SpacetimeClientError::Procedure(_) => false, } } fn is_editor_generation_unknown_result_error( error: &SpacetimeClientError, dispatched: bool, ) -> bool { dispatched && !matches!( error, SpacetimeClientError::Procedure(_) | SpacetimeClientError::Timeout( SpacetimeClientStage::PoolAcquire | SpacetimeClientStage::ConnectBuild | SpacetimeClientStage::ConnectHandshake ) | SpacetimeClientError::Build(_) ) } fn is_editor_canvas_revision_conflict(error: &AppError) -> bool { error.status_code() == StatusCode::CONFLICT && error .details() .and_then(|details| details.get("provider")) .and_then(Value::as_str) == Some("editor-project") && error .details() .and_then(|details| details.get("message")) .and_then(Value::as_str) .is_some_and(|message| message.starts_with("图片画布版本冲突:")) } pub(crate) fn editor_generation_job_result_payload_json( caller: &EditorGenerationCaller, compact_result: Value, ) -> Option { let operation = caller.operation.as_ref()?; operation.worker_id.as_ref()?; Some(json!({ ATOMIC_EDITOR_GENERATION_RAW_RESULT_FIELD: compact_result }).to_string()) } fn compact_atomic_editor_generation_job_result( queue_result_context: &EditorGenerationQueueResultContext, raw_payload_json: String, ) -> Result { let raw_payload = serde_json::from_str::(raw_payload_json.as_str()).map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-generation-operation", "message": format!("解析编辑器生成 queue 结果失败:{error}"), })) })?; let response = raw_payload .get(ATOMIC_EDITOR_GENERATION_RAW_RESULT_FIELD) .or_else(|| raw_payload.get("result")) .or_else(|| raw_payload.get("editor-agent-tool-call-result")) .cloned() .unwrap_or(raw_payload); serialize_atomic_editor_generation_job_result(queue_result_context, &response) } fn serialize_atomic_editor_generation_job_result( queue_result_context: &EditorGenerationQueueResultContext, response: &Value, ) -> Result { let mut payload = json!({ "sourceModule": queue_result_context.source_module.clone(), "sourceEntityId": queue_result_context.source_entity_id.clone(), }); if queue_result_context.consumer == EditorGenerationQueueConsumer::EditorAgent && let Some(object) = payload.as_object_mut() { object.insert( "editor-agent-tool-call-result".to_string(), compact_editor_generation_result(response.clone()), ); } if queue_result_context.consumer == EditorGenerationQueueConsumer::ExternalApi && let Some(object) = payload.as_object_mut() { object.insert( "result".to_string(), compact_external_api_generation_result(response.clone()), ); } if let Some(warning) = extract_editor_generation_warning(response) && let Some(object) = payload.as_object_mut() { object.insert("warning".to_string(), warning); } let payload_json = serde_json::to_string(&payload).map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-generation-operation", "message": format!("序列化编辑器生成 queue 结果失败:{error}"), })) })?; if payload_json.len() > MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES { return Err( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-generation-operation", "message": "编辑器生成 queue 紧凑结果仍超过持久化上限。", "actualBytes": payload_json.len(), "maxBytes": MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES, })), ); } Ok(payload_json) } fn compact_editor_generation_result(mut result: Value) -> Value { let Some(object) = result.as_object_mut() else { return result; }; 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") .and_then(Value::as_object_mut) { for field in ["screenColorHex", "mattingProvider", "mattingModel"] { generation_inputs.remove(field); } } object.remove("project"); object.remove("asset"); object.remove("spritesheetAsset"); for field in ["resource", "spritesheetResource"] { let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) else { continue; }; resource.retain(|key, _| { matches!( key.as_str(), "resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId" ) }); } if let Some(icon_image_srcs) = object .get_mut("iconImageSrcs") .and_then(Value::as_array_mut) { for icon in icon_image_srcs { let Some(icon) = icon.as_object_mut() else { continue; }; if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) { resource.retain(|key, _| { matches!( key.as_str(), "resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId" ) }); } icon.retain(|key, _| { matches!( key.as_str(), "name" | "imageSrc" | "width" | "height" | "resource" ) }); } } result } fn compact_external_api_generation_result(result: Value) -> Value { let mut result = result.get("data").cloned().unwrap_or(result); let Some(object) = result.as_object_mut() else { return Value::Null; }; object.retain(|key, _| { matches!( key.as_str(), "ok" | "imageSrc" | "videoSrc" | "audioSrc" | "previewVideoPath" | "thumbnailSrc" | "objectKey" | "assetObjectId" | "width" | "height" | "sourceType" | "prompt" | "actualPrompt" | "model" | "taskId" | "durationSeconds" | "resolution" | "priceMudPoints" | "audioKind" | "spritesheetImageSrc" | "spritesheetWidth" | "spritesheetHeight" | "iconImageSrcs" | "frames" | "frameCount" | "frameWidth" | "frameHeight" | "fps" | "resource" | "asset" | "spritesheetResource" | "spritesheetAsset" | "warning" | "sliceWarning" ) }); for field in ["resource", "spritesheetResource"] { if let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) { compact_external_generation_resource(resource); } } for field in ["asset", "spritesheetAsset"] { if let Some(asset) = object.get_mut(field).and_then(Value::as_object_mut) { compact_external_generation_asset(asset); } } if let Some(icons) = object .get_mut("iconImageSrcs") .and_then(Value::as_array_mut) { for icon in icons { let Some(icon) = icon.as_object_mut() else { continue; }; icon.retain(|key, _| { matches!( key.as_str(), "name" | "imageSrc" | "objectKey" | "width" | "height" | "resource" | "asset" ) }); if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) { compact_external_generation_resource(resource); } if let Some(asset) = icon.get_mut("asset").and_then(Value::as_object_mut) { compact_external_generation_asset(asset); } remove_unstable_external_generation_media_fields(icon); } } if let Some(frames) = object.get_mut("frames").and_then(Value::as_array_mut) { for frame in frames { let Some(frame) = frame.as_object_mut() else { continue; }; frame.retain(|key, _| { matches!( key.as_str(), "frameIndex" | "imageSrc" | "objectKey" | "assetObjectId" | "width" | "height" ) }); remove_unstable_external_generation_media_fields(frame); } } for field in ["warning", "sliceWarning"] { if let Some(warning) = object.get_mut(field).and_then(Value::as_object_mut) && let Some(reason) = warning.get_mut("reason") && let Some(value) = reason.as_str() { *reason = Value::String(normalize_editor_generation_warning_reason(value)); } } remove_unstable_external_generation_media_fields(object); result } fn compact_external_generation_resource(resource: &mut serde_json::Map) { resource.retain(|key, _| { matches!( key.as_str(), "resourceId" | "projectId" | "objectKey" | "assetObjectId" | "imageSrc" | "width" | "height" | "sourceType" | "assetKind" | "taskId" | "sourceResourceId" ) }); remove_unstable_external_generation_media_fields(resource); } fn compact_external_generation_asset(asset: &mut serde_json::Map) { asset.retain(|key, _| { matches!( key.as_str(), "assetId" | "folderId" | "objectKey" | "assetObjectId" | "imageSrc" | "thumbnailSrc" | "width" | "height" | "sourceType" | "assetKind" | "taskId" ) }); remove_unstable_external_generation_media_fields(asset); } fn remove_unstable_external_generation_media_fields(object: &mut serde_json::Map) { object.retain(|key, value| { if !matches!( key.as_str(), "imageSrc" | "videoSrc" | "audioSrc" | "previewVideoPath" | "thumbnailSrc" | "spritesheetImageSrc" ) { return true; } value .as_str() .is_some_and(is_stable_external_generation_media_reference) }); } fn is_stable_external_generation_media_reference(value: &str) -> bool { let value = value.trim(); !value.is_empty() && value.starts_with('/') && !value.starts_with("//") && !value.contains('?') && !value.contains('#') && !value.to_ascii_lowercase().starts_with("data:") && !value.to_ascii_lowercase().starts_with("blob:") && !value.to_ascii_lowercase().starts_with("http://") && !value.to_ascii_lowercase().starts_with("https://") } fn extract_editor_generation_warning_fields( warning: Option<&Value>, is_slice_warning: bool, ) -> Option<(String, String)> { let warning = warning?; let code = warning.get("code")?.as_str()?.trim(); let reason = warning.get("reason")?.as_str()?.trim(); if code.is_empty() || reason.is_empty() { return None; } let reason = if is_slice_warning { format!("{EDITOR_GENERATION_SLICE_WARNING_PREFIX}{reason}") } else { reason.to_string() }; Some((code.to_string(), reason)) } fn extract_editor_generation_warning(response: &Value) -> Option { let data = response.get("data").unwrap_or(response); let common = extract_editor_generation_warning_fields(data.get("warning"), false); let slice = extract_editor_generation_warning_fields(data.get("sliceWarning"), true); let (code, reason) = match (common, slice) { (None, None) => return None, (Some(warning), None) | (None, Some(warning)) => warning, (Some((common_code, common_reason)), Some((slice_code, slice_reason))) => { let code = if common_code == slice_code { common_code } else { EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE.to_string() }; (code, format!("{common_reason} {slice_reason}")) } }; Some(json!({ "code": code, "reason": normalize_editor_generation_warning_reason(reason.as_str()), })) } fn normalize_editor_generation_warning_reason(reason: &str) -> String { let normalized = reason.to_ascii_lowercase(); if normalized.contains("data:") || normalized.contains("blob:") || normalized.contains("http://") || normalized.contains("https://") || normalized.contains("x-amz-") || normalized.contains("signature=") { return EDITOR_GENERATION_WARNING_REDACTED_MESSAGE.to_string(); } let mut chars = reason.chars(); let mut bounded = chars .by_ref() .take(MAX_EDITOR_GENERATION_WARNING_CHARS) .collect::(); if chars.next().is_some() { bounded.push('…'); } bounded } 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) image_sequence_frames: Option, pub(crate) image_sequence_duration_ms: Option, } 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, pub(crate) image_sequence_frames: Option, pub(crate) image_sequence_duration_ms: Option, } #[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)] pub(crate) 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) 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) source_resource_id: Option, pub(crate) asset_kind: Option, pub(crate) generation_inputs: Option, } #[derive(Clone, 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(), } } pub(crate) 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(), } } pub(crate) 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), }), } } pub(crate) 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 EditorPixelArtSnapResponse { image_src: String, object_key: String, asset_object_id: String, width: u32, height: u32, source_type: &'static str, task_id: String, elapsed_ms: u64, provider: &'static str, resource: EditorProjectResourcePayload, asset: EditorAssetPayload, project: Option, } #[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(Clone, 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, #[serde(skip_serializing_if = "Option::is_none")] image_sequence_frames: Option, #[serde(skip_serializing_if = "Option::is_none")] image_sequence_duration_ms: 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, #[serde(skip_serializing_if = "Option::is_none")] image_sequence_frames: Option, #[serde(skip_serializing_if = "Option::is_none")] image_sequence_duration_ms: 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_generation_inputs( payload.asset_kind.as_deref(), sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.clone()), )?; let image_sequence_frames_json = serialize_editor_image_sequence_frames(payload.image_sequence_frames)?; 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(), image_sequence_frames_json, image_sequence_duration_ms: payload.image_sequence_duration_ms, }) .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_generation_inputs( payload.asset_kind.as_deref(), sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.clone()), )?; let image_sequence_frames_json = serialize_editor_image_sequence_frames(payload.image_sequence_frames)?; 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, image_sequence_frames_json, image_sequence_duration_ms: payload.image_sequence_duration_ms, }) .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), }, )) } fn editor_scene_style_label(style_preset: &str) -> &'static str { match style_preset.trim() { "anime" => "日系动画", "watercolor" => "清透水彩", "flat" => "平面几何", "stop-motion" => "定格模型", "custom" => "自定义", _ => "未知", } } fn build_editor_scene_generation_inputs( payload: &EditorSceneGenerateRequest, generation_options: &EditorGenerationOptions, ) -> Value { let references = payload .generation_inputs .as_ref() .and_then(|value| value.get("references")) .and_then(Value::as_array) .cloned() .unwrap_or_default(); let mut fields = vec![ json!({ "title": "画面内容", "value": payload.scene_content.trim() }), json!({ "title": "视觉风格", "value": editor_scene_style_label(payload.style_preset.as_str()) }), json!({ "title": "图片比例", "value": generation_options.aspect_ratio }), json!({ "title": "清晰度", "value": generation_options.image_size }), json!({ "title": "生成模型", "value": generation_options.model }), ]; if payload.style_preset.trim() == "custom" && let Some(custom_style) = payload .custom_style .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { fields.push(json!({ "title": "自定义画风", "value": custom_style })); } json!({ "fields": fields, "references": references }) } fn normalize_editor_scene_optional_text<'a>(value: Option<&'a str>, default: &'a str) -> &'a str { value .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(default) } fn normalize_editor_scene_generation_options( model: Option<&str>, aspect_ratio: Option<&str>, image_size: Option<&str>, ) -> EditorGenerationOptions { normalize_editor_generation_options( Some(normalize_editor_scene_optional_text( model, EDITOR_IMAGE_MODEL_NANOBANANA2, )), Some(normalize_editor_scene_optional_text(aspect_ratio, "16:9")), Some(normalize_editor_scene_optional_text(image_size, "1K")), ) } fn normalize_editor_scene_asset_label(asset_label: Option) -> String { resolve_editor_generated_asset_label(asset_label, "游戏场景") } pub async fn generate_editor_scene( 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 generation_options = normalize_editor_scene_generation_options( payload.model.as_deref(), payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); let prompt = build_editor_scene_prompt( payload.scene_content.as_str(), payload.style_preset.as_str(), payload.custom_style.as_deref(), generation_options.aspect_ratio, ) .map_err(|message| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-scene-generation", "message": message, })) })?; let generation_inputs = build_editor_scene_generation_inputs(&payload, &generation_options); let caller = EditorGenerationCaller::from_authenticated(&authenticated); let image_payload = EditorImageGenerationRequest { prompt, size: None, kind: Some("scene".to_string()), style: None, model: Some(generation_options.model.to_string()), screen_color: None, seg_model: None, aspect_ratio: Some(generation_options.aspect_ratio.to_string()), image_size: Some(generation_options.image_size.to_string()), reference_image_srcs: (!payload.reference_image_srcs.is_empty()) .then_some(payload.reference_image_srcs), project_id: payload.project_id, asset_kind: Some("scene".to_string()), generation_inputs: Some(generation_inputs), asset_folder_id: payload.asset_folder_id, asset_label: Some(normalize_editor_scene_asset_label(payload.asset_label)), source_resource_id: None, canvas_completion: payload.canvas_completion, }; if !state.config.external_generation_mode.is_inline() { let queue_job = enqueue_editor_image_generation_for_owner( &state, &request_context, &caller, image_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, image_payload).await } 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)?; ensure_generic_editor_image_generation_contract(&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), }, )); } let caller = caller.with_inline_operation( &request_context, EDITOR_IMAGE_GENERATION_JOB_KIND, &payload, )?; with_editor_generation_durable_billing_boundary(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 { validate_editor_image_generation_prompt(payload.prompt.as_str())?; payload.generation_inputs = sanitize_editor_untrusted_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 reference_limit = if matches!(normalized_kind, Some("quick-edit")) { EDITOR_QUICK_EDIT_REFERENCE_LIMIT .min(editor_provider_reference_limit(generation_options.model)) } else { EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT }; ensure_editor_reference_image_source_limit( payload.reference_image_srcs.as_deref(), reference_limit, "editor-image-generation", "referenceImageSrcs", "生成参考图", )?; let target = preflight_editor_billable_generation_target( state, caller.owner_user_id.as_str(), payload.project_id.clone(), resolve_editor_image_generation_target_folder_id( payload.asset_folder_id.clone(), normalized_kind, ), ) .await?; payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; 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 } /// Runs the non-generating validation and authorization required before a caller performs /// preparatory provider work such as icon-spec metadata completion. Final dispatch deliberately /// validates again because queued execution can happen later in another process. pub(crate) async fn validate_editor_image_generation_parameters_for_owner( state: &AppState, request_context: &RequestContext, caller: &EditorGenerationCaller, payload: &EditorImageGenerationRequest, ) -> Result<(), AppError> { ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-image-generation", "referenceImageSrcs", "生成参考图", )?; let normalized_kind = payload.kind.as_deref().map(str::trim); if matches!(normalized_kind, Some("character")) { parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?; } 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 reference_limit = if matches!(normalized_kind, Some("quick-edit")) { EDITOR_QUICK_EDIT_REFERENCE_LIMIT .min(editor_provider_reference_limit(generation_options.model)) } else { EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT }; ensure_editor_reference_image_source_limit( payload.reference_image_srcs.as_deref(), reference_limit, "editor-image-generation", "referenceImageSrcs", "生成参考图", )?; for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref()) { 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()), ); build_openai_image_http_client(&settings)?; 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), ); Ok(()) } pub(crate) async fn generate_editor_image_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, payload: EditorImageGenerationRequest, ) -> Result, AppError> { validate_editor_image_generation_prompt(payload.prompt.as_str())?; let prompt = payload.prompt.clone(); generate_editor_image_for_owner_with_prompt_builder( state, request_context, caller, payload, move |generation_inputs| async move { Ok(EditorImagePromptBuildOutput { prompt, generation_inputs, }) }, ) .await } fn validate_editor_image_generation_prompt(prompt: &str) -> Result<(), AppError> { if prompt.trim().is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-generation", "message": "生成提示词不能为空", })), ); } Ok(()) } pub(crate) async fn generate_editor_image_for_owner_with_prompt_builder( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, mut payload: EditorImageGenerationRequest, prompt_builder: F, ) -> Result, AppError> where F: FnOnce(Option) -> Fut, Fut: Future>, { 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_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); // 中文注释:角色形象带绿幕抠像底色,只像素化角色本身;普通图片没有抠像底色,整幅画面像素化。 // ui-design 等 kind 走不到 PixelArt(pixel_art_supported 已挡掉),落到 Image 分支无副作用。 let pixel_art_prompt_scope = if is_character_generation { EditorPixelArtPromptScope::Character } else { EditorPixelArtPromptScope::Image }; // 背景色决策挪到预扣泥点之后(见下方 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 reference_limit = if matches!(normalized_kind, Some("quick-edit")) { EDITOR_QUICK_EDIT_REFERENCE_LIMIT .min(editor_provider_reference_limit(generation_options.model)) } else { EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT }; ensure_editor_reference_image_source_limit( payload.reference_image_srcs.as_deref(), reference_limit, "editor-image-generation", "referenceImageSrcs", "生成参考图", )?; let target = preflight_editor_billable_generation_target( state, caller.owner_user_id.as_str(), payload.project_id.clone(), resolve_editor_image_generation_target_folder_id( payload.asset_folder_id.clone(), normalized_kind, ), ) .await?; payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; payload.generation_inputs = rebuild_editor_generation_input_references( state, caller.owner_user_id.as_str(), payload.generation_inputs.take(), build_editor_generation_reference_sources( None, payload.reference_image_srcs.as_deref(), "参考图", ), ) .await?; 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_sources = payload .reference_image_srcs .unwrap_or_default() .into_iter() .map(|source| source.trim().to_string()) .filter(|source| !source.is_empty()) .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 effective_price_mud_points = if let Some(price_mud_points) = current_external_generation_billing_price_mud_points() { price_mud_points } else { 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 prompt_generation_inputs = payload.generation_inputs.take(); let generate_operation = async { let prompt_build = prompt_builder(prompt_generation_inputs).await?; let role_setting = prompt_build.prompt.trim().to_string(); if role_setting.is_empty() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-image-generation", "message": "生成提示词构建结果不能为空", })), ); } // 背景色决策:预扣泥点之后才发起,仅角色生图需要(决策失败→future 返回 Err→失败退款, // 用户不损失泥点,平台也只在用户确有余额时才付这次 gpt-5-mini 决策)。 let screen_background_decision = if is_character_generation { Some( resolve_editor_screen_background_color( state.llm_client(), state.vector_engine_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 = apply_editor_pixel_art_style_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() }, image_style, pixel_art_prompt_scope, ); let generated = request_editor_generated_images( &http_client, &settings, EditorImageProviderRequest { model: generation_options.model, prompt: submitted_prompt.as_str(), negative_prompt, size: provider_request_size.as_ref(), aspect_ratio: generation_options.aspect_ratio, image_size: generation_options.provider_image_size, reference_images: reference_images.as_slice(), failure_context, }, ) .await?; Ok::<_, AppError>(( generated, submitted_prompt, screen_background_decision, role_setting, prompt_build.generation_inputs, )) }; let ( generated, submitted_prompt, screen_background_decision, role_setting, prompt_generation_inputs, ) = 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(effective_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( prompt_generation_inputs, 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 = prepare_editor_generated_image( state, &caller, generated.task_id.as_str(), &image, role_setting.as_str(), generated.actual_prompt.as_deref(), storage_profile.asset_kind, "character-images", "character-image-provider-source", EDITOR_PROVIDER_SOURCE_SLOT, "vector-engine", ) .await?; let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str()); let source_object_key = source_persisted.object_key.clone(); let source_asset_object_id = source_persisted.asset_object_id().to_string(); let source_record = prepare_editor_generated_asset( &caller, EDITOR_PROVIDER_SOURCE_SLOT, Some(source_persisted.asset_object), PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), 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(effective_price_mud_points), thumbnail_src: None, group_task_id: None, group_task_expected_asset_count: None, image_sequence_frames: None, image_sequence_duration_ms: None, image_src: source_image_src.clone(), object_key: Some(source_object_key.clone()), asset_object_id: Some(source_asset_object_id.clone()), provider: "VectorEngine".to_string(), }, current_utc_micros(), )?; caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller .audit_project_id .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), external_call_deadline: request_context.external_call_deadline(), }; let removal = remove_editor_generated_screen_background_with_bgfilter( state, source_object_key.as_str(), screen_color.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 (source_record, completed_project, fallback_warning) = persist_editor_source_only_fallback_atomically( state, &caller, payload.project_id.as_deref(), payload.canvas_completion.as_ref(), source_record, generated.task_id.as_str(), source_image_src.as_str(), source_object_key.as_str(), source_asset_object_id.as_str(), EditorSourceOnlyFallbackResultContext { width: delivery_width, height: delivery_height, prompt: role_setting.as_str(), actual_prompt: generated.actual_prompt.as_deref(), model: generation_options.model, price_mud_points: 0, }, 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 (source_record, completed_project, fallback_warning) = persist_editor_source_only_fallback_atomically( state, &caller, payload.project_id.as_deref(), payload.canvas_completion.as_ref(), source_record, generated.task_id.as_str(), source_image_src.as_str(), source_object_key.as_str(), source_asset_object_id.as_str(), EditorSourceOnlyFallbackResultContext { width: delivery_width, height: delivery_height, prompt: role_setting.as_str(), actual_prompt: generated.actual_prompt.as_deref(), model: generation_options.model, price_mud_points: 0, }, 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.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 prepared_image = prepare_editor_generated_image( state, &caller, 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(prepared_image.object_key.as_str()); let provider_source_resource_id = source_record .as_ref() .and_then(|prepared| prepared.record.resource.as_ref()) .map(|resource| resource.resource_id.clone()); let completed_at_micros = current_utc_micros(); let prepared_asset = prepare_editor_generated_asset( &caller, storage_profile.slot, Some(prepared_image.asset_object.clone()), 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(prepared_image.object_key.clone()), asset_object_id: Some(prepared_image.asset_object_id().to_string()), 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(effective_price_mud_points) }, image_sequence_frames: None, image_sequence_duration_ms: None, }, completed_at_micros, )?; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_multi_asset_canvas_layer_items( completion, prepared_asset.record.resource.as_ref(), source_record .as_ref() .and_then(|prepared| prepared.record.resource.as_ref()), ) } else { (Vec::new(), None) }; let canvas_layout_plan = EditorCanvasLayoutPlan::generation( caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), canvas_items, primary_layer_id, ); let final_warning = merge_editor_generation_warnings(generation_warning, dimension_warning); let job_result_payload_json = editor_generation_job_result_payload_json( &caller, json!({ "imageSrc": image_src.clone(), "objectKey": prepared_image.object_key.clone(), "assetObjectId": prepared_image.asset_object_id(), "width": width, "height": height, "sourceType": "generated", "prompt": role_setting.clone(), "actualPrompt": generated.actual_prompt.clone(), "model": generation_options.model, "taskId": generated.task_id.clone(), "resource": &prepared_asset.record.resource, "asset": &prepared_asset.record.asset, "warning": final_warning.clone(), }), ); let mut items = source_record .into_iter() .map(|prepared| prepared.item) .collect::>(); items.push(prepared_asset.item); let persisted_result = persist_editor_generation_result_atomically( state, &caller, items, canvas_layout_plan, job_result_payload_json, completed_at_micros, ) .await?; let persisted_item = persisted_result .items .into_iter() .find(|item| item.slot == storage_profile.slot) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-generation-operation", "message": "图片生成原子提交缺少主结果。", })) })?; let generated_asset = EditorGeneratedAssetRecord { resource: persisted_item .project_resource .map(editor_project_resource_payload_from_record), asset: persisted_item.asset.map(editor_asset_payload_from_record), }; let completed_project = persisted_result .project .map(editor_project_payload_from_record); Ok(json_success_body( Some(&request_context), EditorImageGenerationResponse { image_src, object_key: Some(prepared_image.object_key.clone()), asset_object_id: Some(prepared_image.asset_object_id().to_string()), 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: final_warning, }, )) } fn editor_image_generation_billing_asset_kind(normalized_kind: Option<&str>) -> &'static str { match normalized_kind { Some("scene") => EDITOR_SCENE_IMAGE_ASSET_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) } pub(crate) 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) } pub(crate) 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("scene") => EditorGeneratedImageStorageProfile { asset_kind: EDITOR_SCENE_IMAGE_ASSET_KIND, path_kind: "scene-images", slot: "scene", }, 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) } pub(crate) 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, })) })?; match target_layer.get("assetKindOverride") { Some(Value::String(value)) if !value.trim().is_empty() => { return Ok(Some(value.trim())); } Some(Value::Null) | None => {} Some(_) => { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-edit", "message": "快速编辑目标图层的素材类型覆盖无效", "targetLayerId": target_layer_id, })), ); } } 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, ) } pub(crate) 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) } pub(crate) 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()) } // 中文注释:只在 depth < max_depth 时递增,用 CAS 而不是「先读后加」——两个线程同时读到 // max_depth - 1 各自加一就会越界。抽成自由函数是为了能直接单测边界与并发行为。 fn try_enter_bounded_queue(depth: &AtomicUsize, max_depth: usize) -> bool { depth .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { (current < max_depth).then_some(current + 1) }) .is_ok() } /// 中文注释:递减必须放在 Drop 里。等待中的 future 随时可能被丢弃(客户端断连、超时触发、 /// 上层取消),若把递减写在正常返回路径上,计数就会只增不减,最终队列永久「满」、接口 /// 彻底不可用——这是本改动里唯一一处写错会造成永久性故障的地方。 struct EditorPixelArtSnapQueueGuard; impl EditorPixelArtSnapQueueGuard { fn try_enter() -> Option { try_enter_bounded_queue( &EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH, EDITOR_PIXEL_ART_SNAP_MAX_QUEUE_DEPTH, ) .then_some(Self) } } impl Drop for EditorPixelArtSnapQueueGuard { fn drop(&mut self) { EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.fetch_sub(1, Ordering::AcqRel); } } // 中文注释:端点级并发闸,必须在第一次 IO 之前取得,许可持有到 handler 结束。等待时间与 // 后续处理共用同一份 processing_deadline,所以排队不会让请求重新获得完整预算。 // 中文注释:三条竞争路径的错误各自抽成构造函数。它们要靠全局信号量被打满或队列计数到顶 // 才走得到,而那两个都是进程级 static——在测试里把它们填满会让并行跑的其他用例连带失败, // 正是本文件刚修掉的那类竞态。抽出来之后状态码、文案和 retry-after 可以直接断言, // 「哪条路径用哪个构造函数」则由 snap_editor_image_to_pixel_art 的顺序守卫钉住。 fn editor_pixel_art_snap_queue_full_error() -> AppError { editor_pixel_art_snap_failure( StatusCode::SERVICE_UNAVAILABLE, "完美像素排队已满,请稍后重试。", ) .with_header("retry-after", HeaderValue::from_static("1")) } fn editor_pixel_art_snap_limiter_unavailable_error(error: impl std::fmt::Display) -> AppError { editor_pixel_art_snap_failure( StatusCode::SERVICE_UNAVAILABLE, format!("完美像素并发门限不可用:{error}"), ) } fn editor_pixel_art_snap_wait_timeout_error() -> AppError { editor_pixel_art_snap_failure( StatusCode::GATEWAY_TIMEOUT, "完美像素等待处理槽位时预算已耗尽。", ) } async fn acquire_editor_pixel_art_snap_permit( processing_deadline: Instant, ) -> Result { // 中文注释:这句预检不能省。timeout_at 会先 poll 一次内层 future,许可空闲时 // acquire_owned 立刻就绪,于是预算已耗尽的请求照样拿到许可,白占一个名额再去打 // 几轮全账号扫描,直到下载那步才失败。既有 CPU 许可的同名预检也是为此存在。 if Instant::now() >= processing_deadline { return Err(editor_pixel_art_snap_failure( StatusCode::GATEWAY_TIMEOUT, "完美像素处理预算已耗尽。", )); } let queue_guard = EditorPixelArtSnapQueueGuard::try_enter() .ok_or_else(editor_pixel_art_snap_queue_full_error)?; let acquired = tokio::time::timeout_at( tokio::time::Instant::from_std(processing_deadline), Arc::clone(&*EDITOR_PIXEL_ART_SNAP_LIMITER).acquire_owned(), ) .await; // 中文注释:排队阶段到此结束,先让出队列位再判定结果,避免持有许可期间还占着队列名额。 drop(queue_guard); match acquired { Ok(Ok(permit)) => Ok(permit), Ok(Err(error)) => Err(editor_pixel_art_snap_limiter_unavailable_error(error)), Err(_) => Err(editor_pixel_art_snap_wait_timeout_error()), } } 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()), } } fn editor_pixel_art_snap_failure(status: StatusCode, message: impl Into) -> AppError { AppError::from_status(status).with_details(json!({ "provider": "pixel-art-snapper", "message": message.into(), })) } fn map_editor_pixel_art_snapper_error(error: platform_image::PixelArtSnapError) -> AppError { let status = match &error { platform_image::PixelArtSnapError::InvalidInput(_) | platform_image::PixelArtSnapError::Decode { .. } => StatusCode::BAD_REQUEST, platform_image::PixelArtSnapError::GridNotDetected => StatusCode::UNPROCESSABLE_ENTITY, platform_image::PixelArtSnapError::DeadlineExceeded { .. } => StatusCode::GATEWAY_TIMEOUT, platform_image::PixelArtSnapError::Encode(_) | platform_image::PixelArtSnapError::Processing(_) => StatusCode::INTERNAL_SERVER_ERROR, }; editor_pixel_art_snap_failure(status, error.to_string()) } fn validate_editor_pixel_art_static_asset_kind(asset_kind: Option<&str>) -> Result<(), AppError> { let Some(asset_kind) = asset_kind.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(()); }; let normalized = asset_kind.to_ascii_lowercase(); let is_non_static = normalized.contains("video") || normalized.contains("audio") || normalized.contains("animation") || normalized.contains("image-sequence") || matches!(normalized.as_str(), "sound-effect" | "background-music"); if !is_non_static { return Ok(()); } Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "pixel-art-snapper", "field": "assetKind", "assetKind": asset_kind, "message": "完美像素只支持静态图片素材。", })), ) } fn resolve_editor_pixel_art_snap_asset_kind( requested_asset_kind: Option<&str>, source_asset_kind: Option>, discovered_source_asset_kinds: &[String], storage_asset_kinds: &[String], ) -> Result, AppError> { let requested_asset_kind = requested_asset_kind .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let preferred_source_asset_kind = source_asset_kind .flatten() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let discovered_source_asset_kinds = discovered_source_asset_kinds .iter() .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect::>(); let storage_asset_kinds = storage_asset_kinds .iter() .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .collect::>(); for authoritative_asset_kind in preferred_source_asset_kind .iter() .chain(discovered_source_asset_kinds.iter()) { // 中文注释:同一个 objectKey 可能同时留在项目资源与素材库。请求字段即使 // 省略,也不能掩盖任一语义权威记录里的动画/音视频类型。 validate_editor_pixel_art_static_asset_kind(Some(authoritative_asset_kind.as_str()))?; } for storage_asset_kind in storage_asset_kinds { // 中文注释:asset_object.asset_kind 使用存储 taxonomy(例如 // editor_generation_reference_image),只承担非静态媒体门禁,不能覆盖项目 // 资源/素材库使用的语义 taxonomy,也不能拿来和请求 assetKind 做精确比较。 validate_editor_pixel_art_static_asset_kind(Some(storage_asset_kind))?; } let authoritative_asset_kind = preferred_source_asset_kind.or_else(|| discovered_source_asset_kinds.into_iter().next()); if authoritative_asset_kind.is_some() && requested_asset_kind.is_some() && requested_asset_kind != authoritative_asset_kind { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "pixel-art-snapper", "field": "assetKind", "requestedAssetKind": requested_asset_kind, "sourceAssetKind": authoritative_asset_kind, "message": "assetKind 与来源素材权威类型不一致。", })), ); } let asset_kind = authoritative_asset_kind.or(requested_asset_kind); validate_editor_pixel_art_static_asset_kind(asset_kind.as_deref())?; Ok(asset_kind) } fn validate_editor_pixel_art_static_raster(image: &DownloadedOpenAiImage) -> Result<(), AppError> { validate_editor_pixel_art_static_raster_bytes(image.bytes.as_slice()).map_err(|message| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "pixel-art-snapper", "field": "sourceImageSrc", "message": message, })) }) } fn validate_editor_pixel_art_static_raster_bytes(bytes: &[u8]) -> Result<(), &'static str> { if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { return validate_editor_pixel_art_static_png(bytes); } if bytes.starts_with(&[0xff, 0xd8, 0xff]) { return Ok(()); } if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" { return validate_editor_pixel_art_static_webp(bytes); } if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { return Err("完美像素不支持 GIF;请先转换为静态 PNG、JPEG 或 WebP。"); } Err("完美像素只支持静态 PNG、JPEG 或 WebP 图片。") } fn validate_editor_pixel_art_static_png(bytes: &[u8]) -> Result<(), &'static str> { let mut offset = 8usize; let mut saw_iend = false; while offset < bytes.len() { let header_end = offset .checked_add(8) .filter(|end| *end <= bytes.len()) .ok_or("PNG 文件结构不完整。")?; let length = u32::from_be_bytes( bytes[offset..offset + 4] .try_into() .map_err(|_| "PNG 文件结构不完整。")?, ) as usize; let chunk_type = &bytes[offset + 4..header_end]; let chunk_end = header_end .checked_add(length) .and_then(|end| end.checked_add(4)) .filter(|end| *end <= bytes.len()) .ok_or("PNG 文件结构不完整。")?; if chunk_type == b"acTL" { return Err("完美像素不支持 APNG 动图;请先转换为静态 PNG。"); } offset = chunk_end; if chunk_type == b"IEND" { saw_iend = true; break; } } if !saw_iend { return Err("PNG 文件结构不完整。"); } Ok(()) } fn validate_editor_pixel_art_static_webp(bytes: &[u8]) -> Result<(), &'static str> { let riff_size = u32::from_le_bytes( bytes[4..8] .try_into() .map_err(|_| "WebP 文件结构不完整。")?, ) as usize; let riff_end = riff_size .checked_add(8) .filter(|end| *end <= bytes.len()) .ok_or("WebP 文件结构不完整。")?; let mut offset = 12usize; let mut saw_image_chunk = false; while offset < riff_end { let header_end = offset .checked_add(8) .filter(|end| *end <= riff_end) .ok_or("WebP 文件结构不完整。")?; let chunk_type = &bytes[offset..offset + 4]; let length = u32::from_le_bytes( bytes[offset + 4..header_end] .try_into() .map_err(|_| "WebP 文件结构不完整。")?, ) as usize; let data_end = header_end .checked_add(length) .filter(|end| *end <= riff_end) .ok_or("WebP 文件结构不完整。")?; if chunk_type == b"ANIM" || chunk_type == b"ANMF" { return Err("完美像素不支持动画 WebP;请先转换为静态 WebP。"); } if chunk_type == b"VP8X" && bytes .get(header_end) .is_some_and(|feature_flags| feature_flags & 0x02 != 0) { return Err("完美像素不支持动画 WebP;请先转换为静态 WebP。"); } if chunk_type == b"VP8 " || chunk_type == b"VP8L" { saw_image_chunk = true; } offset = data_end .checked_add(length % 2) .filter(|end| *end <= riff_end) .ok_or("WebP 文件结构不完整。")?; } if !saw_image_chunk { return Err("WebP 文件结构不完整。"); } Ok(()) } async fn snap_editor_pixel_art_strict( rgba_source: Arc, request_deadline: Option, ) -> Result { snap_editor_pixel_art_with_grid_policy(rgba_source, request_deadline, true).await } async fn snap_editor_pixel_art_with_grid_policy( rgba_source: Arc, request_deadline: Option, require_detected_grid: bool, ) -> Result { let processing_deadline = resolve_editor_pixel_art_processing_deadline(Instant::now(), request_deadline); if Instant::now() >= processing_deadline { return Err(editor_pixel_art_snap_failure( StatusCode::GATEWAY_TIMEOUT, "像素规整处理预算已耗尽。", )); } let permit = acquire_editor_pixel_art_cpu_permit(processing_deadline) .await .map_err(|message| { let status = if message.contains("预算已耗尽") { StatusCode::GATEWAY_TIMEOUT } else { StatusCode::SERVICE_UNAVAILABLE }; editor_pixel_art_snap_failure(status, message.replace(",已保留原始生成结果。", "。")) })?; 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; if require_detected_grid { platform_image::snap_pixel_art_strict_with_deadline( worker_rgba_source.as_ref(), worker_rgba_source.as_ref(), Some(processing_deadline), ) } else { 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))) => Ok(image), Ok(Ok(Err(error))) => Err(map_editor_pixel_art_snapper_error(error)), Ok(Err(error)) => Err(editor_pixel_art_snap_failure( StatusCode::INTERNAL_SERVER_ERROR, format!("像素规整工作线程异常:{error}"), )), Err(_) => Err(editor_pixel_art_snap_failure( StatusCode::GATEWAY_TIMEOUT, "像素规整处理超时。", )), } } async fn snap_editor_pixel_art_or_original( rgba_source: DownloadedOpenAiImage, request_deadline: Option, ) -> (DownloadedOpenAiImage, Option) { let rgba_source = Arc::new(rgba_source); match snap_editor_pixel_art_with_grid_policy(Arc::clone(&rgba_source), request_deadline, false) .await { Ok(image) => (image, None), Err(error) => { let reason = error.body_text(); let reason = reason.trim_end_matches(|character| matches!(character, '。' | '!' | '!')); ( take_arc_downloaded_image(rgba_source), Some(format!("{reason},已保留原始生成结果。")), ) } } } 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)), } } pub(crate) 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), }, )); } let caller = caller.with_inline_operation(&request_context, EDITOR_IMAGE_EDIT_JOB_KIND, &payload)?; with_editor_generation_durable_billing_boundary(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_untrusted_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 extra_reference_limit = EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); ensure_editor_reference_image_source_limit( payload.reference_image_srcs.as_deref(), extra_reference_limit, "editor-image-edit", "referenceImageSrcs", "修改参考图", )?; let target = preflight_editor_billable_generation_target( state, caller.owner_user_id.as_str(), payload.project_id.clone(), payload.asset_folder_id.clone(), ) .await?; payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; 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 target = preflight_editor_billable_generation_target( state, caller.owner_user_id.as_str(), payload.project_id.clone(), payload.asset_folder_id.clone(), ) .await?; payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; let extra_reference_limit = EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); ensure_editor_reference_image_source_limit( payload.reference_image_srcs.as_deref(), extra_reference_limit, "editor-image-edit", "referenceImageSrcs", "修改参考图", )?; payload.generation_inputs = rebuild_editor_generation_input_references( state, caller.owner_user_id.as_str(), payload.generation_inputs.take(), build_editor_generation_reference_sources( Some(("原图", payload.source_image_src.as_str())), payload.reference_image_srcs.as_deref(), "参考图", ), ) .await?; 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()) { 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 prepared_image = prepare_editor_generated_image( state, &caller, 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(prepared_image.object_key.as_str()); let completed_at_micros = current_utc_micros(); let prepared_asset = prepare_editor_generated_asset( &caller, "edit", Some(prepared_image.asset_object.clone()), 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(prepared_image.object_key.clone()), asset_object_id: Some(prepared_image.asset_object_id().to_string()), 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), image_sequence_frames: None, image_sequence_duration_ms: None, }, completed_at_micros, )?; let canvas_layout_plan = if let Some(completion) = payload.canvas_completion.as_ref() { let canvas_item = prepared_asset.record.resource.as_ref().map(|resource| { let layer_id = generated_canvas_layer_id(resource.resource_id.as_str()); ( build_editor_canvas_resource_placement_item(completion, resource, layer_id.clone()), layer_id, ) }); EditorCanvasLayoutPlan::generation( owner_user_id.as_str(), payload.project_id.as_deref(), Some(completion), canvas_item .as_ref() .map(|(item, _)| item.clone()) .into_iter() .collect(), canvas_item.map(|(_, layer_id)| layer_id), ) } else { EditorCanvasLayoutPlan::background_removal( owner_user_id.as_str(), payload.project_id.as_deref(), payload.target_layer_id.as_deref(), prepared_asset.record.resource.as_ref(), Some(asset_label.as_str()), true, ) }; let job_result_payload_json = editor_generation_job_result_payload_json( &caller, json!({ "imageSrc": image_src.clone(), "objectKey": prepared_image.object_key.clone(), "assetObjectId": prepared_image.asset_object_id(), "width": width, "height": height, "sourceType": "generated", "prompt": prompt.clone(), "actualPrompt": generated.actual_prompt.clone(), "model": generation_options.model, "taskId": generated.task_id.clone(), "resource": &prepared_asset.record.resource, "asset": &prepared_asset.record.asset, "warning": &dimension_warning, }), ); let persisted_result = persist_editor_generation_result_atomically( state, &caller, vec![prepared_asset.item], canvas_layout_plan, job_result_payload_json, completed_at_micros, ) .await?; let persisted_item = persisted_result .items .into_iter() .find(|item| item.slot == "edit") .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-generation-operation", "message": "图片修改原子提交缺少 edit 结果。", })) })?; let generated_asset = EditorGeneratedAssetRecord { resource: persisted_item .project_resource .map(editor_project_resource_payload_from_record), asset: persisted_item.asset.map(editor_asset_payload_from_record), }; let completed_project = persisted_result .project .map(editor_project_payload_from_record); Ok(json_success_body( Some(&request_context), EditorImageGenerationResponse { image_src, object_key: Some(prepared_image.object_key.clone()), asset_object_id: Some(prepared_image.asset_object_id().to_string()), 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_untrusted_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()); payload.generation_inputs = rebuild_editor_generation_input_references( state, caller.owner_user_id.as_str(), payload.generation_inputs.take(), build_editor_generation_reference_sources( Some(("原图", payload.source_image_src.as_str())), None, "参考图", ), ) .await?; 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 prepared_image = prepare_editor_generated_image( state, &caller, 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(prepared_image.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 completed_at_micros = current_utc_micros(); let prepared_asset = prepare_editor_generated_asset( &caller, "result", Some(prepared_image.asset_object.clone()), 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(prepared_image.object_key.clone()), asset_object_id: Some(prepared_image.asset_object_id().to_string()), 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, image_sequence_frames: None, image_sequence_duration_ms: None, }, completed_at_micros, )?; let canvas_layout_plan = if let Some(completion) = payload.canvas_completion.as_ref() { let canvas_item = prepared_asset.record.resource.as_ref().map(|resource| { let layer_id = generated_canvas_layer_id(resource.resource_id.as_str()); ( build_editor_canvas_resource_placement_item(completion, resource, layer_id.clone()), layer_id, ) }); EditorCanvasLayoutPlan::generation( caller.owner_user_id.as_str(), payload.project_id.as_deref(), Some(completion), canvas_item .as_ref() .map(|(item, _)| item.clone()) .into_iter() .collect(), canvas_item.map(|(_, layer_id)| layer_id), ) } else { EditorCanvasLayoutPlan::background_removal( caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.target_layer_id.as_deref(), prepared_asset.record.resource.as_ref(), Some(asset_label.as_str()), false, ) }; let job_result_payload_json = editor_generation_job_result_payload_json( &caller, json!({ "imageSrc": image_src.clone(), "objectKey": prepared_image.object_key.clone(), "assetObjectId": prepared_image.asset_object_id(), "width": removed.width, "height": removed.height, "sourceType": "generated", "taskId": task_id.clone(), "resource": &prepared_asset.record.resource, "asset": &prepared_asset.record.asset, }), ); let persisted_result = persist_editor_generation_result_atomically( state, &caller, vec![prepared_asset.item], canvas_layout_plan, job_result_payload_json, completed_at_micros, ) .await?; let persisted_item = persisted_result .items .into_iter() .find(|item| item.slot == "result") .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-generation-operation", "message": "去背景原子提交缺少 result 结果。", })) })?; let generated_asset = EditorGeneratedAssetRecord { resource: persisted_item .project_resource .map(editor_project_resource_payload_from_record), asset: persisted_item.asset.map(editor_asset_payload_from_record), }; let completed_project = persisted_result .project .map(editor_project_payload_from_record); Ok(json_success_body( Some(&request_context), EditorBackgroundRemovalResponse { image_src, object_key: prepared_image.object_key.clone(), asset_object_id: prepared_image.asset_object_id().to_string(), 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, } fn validate_editor_pixel_art_snap_canvas_completion( completion: &EditorCanvasGenerationCompletionRequest, ) -> Result<(), AppError> { let Some(dialog_id) = normalized_canvas_completion_dialog_id(completion) else { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "pixel-art-snapper", "field": "canvasCompletion.dialogId", "message": "完美像素必须关联有效的画布生成占位。", })), ); }; if dialog_id.contains('\0') { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "pixel-art-snapper", "field": "canvasCompletion.dialogId", "message": "完美像素画布生成占位 ID 不得包含 NUL。", })), ); } if completion.title.trim().is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "pixel-art-snapper", "field": "canvasCompletion.title", "message": "完美像素画布结果标题不能为空。", })), ); } let placeholder = &completion.placeholder; if !placeholder.x.is_finite() || !placeholder.y.is_finite() || !placeholder.width.is_finite() || !placeholder.height.is_finite() || !placeholder.original_width.is_finite() || !placeholder.original_height.is_finite() || placeholder.width <= 0.0 || placeholder.height <= 0.0 || placeholder.original_width <= 0.0 || placeholder.original_height <= 0.0 { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "pixel-art-snapper", "field": "canvasCompletion.placeholder", "message": "完美像素画布占位的坐标与尺寸必须有效。", })), ); } Ok(()) } fn validate_editor_pixel_art_snap_placeholder_exists( layers: &Value, resources: &[EditorProjectResourcePayload], owner_user_id: &str, project_id: &str, completion: &EditorCanvasGenerationCompletionRequest, ) -> Result<(), AppError> { let dialog_id = normalized_canvas_completion_dialog_id(completion).ok_or_else(|| { editor_pixel_art_snap_failure( StatusCode::BAD_REQUEST, "完美像素必须关联有效的画布生成占位。", ) })?; if layers.as_array().is_some_and(|items| { items .iter() .any(|item| generation_dialog_item_matches(item, dialog_id.as_str())) }) { return Ok(()); } // 中文注释:首个事务若以 DialogMissing 成功、但 HTTP 响应丢失,同 operation 的稳定 // project resource 已存在,而占位按定义仍然不存在。该形状必须允许继续走到原子 procedure // 的 exact compare-and-return;否则幂等重放会被这个处理前门禁反向拦成 409。 let expected_task_id = format!("pixel-art-snap-{dialog_id}"); let expected_resource_id = format!( "{EDITOR_RESOURCE_ID_PREFIX}{}", editor_pixel_art_stable_record_suffix( owner_user_id, project_id, dialog_id.as_str(), "project-resource", ) ); if resources.iter().any(|resource| { resource.resource_id == expected_resource_id && resource.owner_user_id == owner_user_id && resource.project_id == project_id && resource.task_id.as_deref() == Some(expected_task_id.as_str()) }) { return Ok(()); } Err( AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "pixel-art-snapper", "field": "canvasCompletion.dialogId", "dialogId": dialog_id, "message": "完美像素画布占位不存在或尚未保存,请重试。", })), ) } #[derive(Debug)] struct EditorPixelArtSourceResolution { object_key: String, asset_kind: Option, generation_input_reference: Option, existing_result_generation_inputs: Option>, } fn resolve_editor_pixel_art_persisted_generation_inputs( authoritative: Option, existing_result: Option>, ) -> Option { existing_result.unwrap_or(authoritative) } fn push_editor_pixel_art_source_asset_kind( asset_kinds: &mut Vec, asset_kind: Option<&str>, ) { let Some(asset_kind) = asset_kind .map(str::trim) .filter(|asset_kind| !asset_kind.is_empty()) else { return; }; if !asset_kinds.iter().any(|candidate| candidate == asset_kind) { asset_kinds.push(asset_kind.to_string()); } } // 中文注释:source_resource 来自 owner-scoped 的 get_editor_project,已经完成鉴权, // 因此可以直接用它的 objectKey,不必再按注册 ID 做两轮全账号项目与素材库扫描。做法 // 对齐图集拆分:用对资源的显式归属断言替代扫描,而不是省掉校验。 // 前提是 sourceImageSrc 能在不发 RPC 的前提下确认指向同一张图——它要么本身就是 // objectKey,要么就是这个 resourceId。否则落回完整解析路径。 fn resolve_editor_pixel_art_source_without_lookup( owner_user_id: &str, project_id: &str, source: &str, source_resource: &EditorProjectResourcePayload, ) -> Result, AppError> { if source_resource.owner_user_id != owner_user_id || source_resource.project_id != project_id { return Err(editor_pixel_art_snap_failure( StatusCode::FORBIDDEN, "来源项目资源不属于当前账号或当前项目。", )); } let source_resource_object_key = normalize_editor_record_object_key( source_resource.object_key.as_deref(), source_resource.image_src.as_str(), ) .ok_or_else(|| { editor_pixel_art_snap_failure( StatusCode::BAD_REQUEST, "来源项目资源没有可用的稳定图片引用。", ) })?; let source = source.trim(); if source == source_resource.resource_id.trim() { return Ok(Some(source_resource_object_key)); } match normalize_editor_reference_object_key(source) { // 中文注释:sourceImageSrc 已是 objectKey,直接比对即可判定一致性;不一致必须 // 报错而不是落回慢路径,否则「两个字段指向不同图片」会被慢路径静默接受。 Ok(source_object_key) => { if source_object_key != source_resource_object_key { return Err(editor_pixel_art_snap_failure( StatusCode::BAD_REQUEST, "sourceImageSrc 与 sourceResourceId 指向不同图片。", )); } Ok(Some(source_resource_object_key)) } // sourceImageSrc 是别的注册 ID,只能走完整解析。 Err(_) => Ok(None), } } async fn resolve_editor_pixel_art_source_for_owner( state: &AppState, owner_user_id: &str, project_id: &str, source: &str, project: &EditorProjectPayload, source_resource: Option<&EditorProjectResourcePayload>, requested_asset_kind: Option<&str>, expected_result_resource_id: &str, expected_result_task_id: &str, ) -> Result { let existing_result_generation_inputs = project .resources .iter() .find(|resource| { resource.resource_id.trim() == expected_result_resource_id && resource.task_id.as_deref().map(str::trim) == Some(expected_result_task_id) }) .map(|resource| resource.generation_inputs.clone()); let resolved_without_lookup = match source_resource { Some(source_resource) => resolve_editor_pixel_art_source_without_lookup( owner_user_id, project_id, source, source_resource, )?, None => None, }; // 中文注释:没有已鉴权 source_resource 时,注册 ID 解析、归属校验和下面的跨记录 // asset_kind 扫描需要的是同一份全账号快照。此前这三件事各自取数—— // `resolve_editor_reference_object_key_for_owner` 内部两个子函数各扫一轮,本函数再扫 // 第三轮——同一份数据被 `list_editor_projects` + `get_editor_asset_library` 拉了 6 次, // 全部在 30 秒预算和端点准入名额之内顺序执行。参照抠图入口 // `resolve_editor_background_removal_source` 的做法:扫一次,后续解析全部交给 // `_from_records` 纯函数在内存里完成。老包装 `resolve_editor_reference_object_key_for_owner` // 保持不动,它服务的是没有任何上下文的调用方。 let owner_records = if resolved_without_lookup.is_some() { None } else { 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)?; Some((projects, library)) }; let object_key = match resolved_without_lookup.clone() { Some(object_key) => object_key, None => { let (projects, library) = owner_records .as_ref() .expect("owner records are loaded whenever the lookup-free path is unavailable"); let object_key = match normalize_editor_reference_object_key(source) { 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.trim(), ) .ok_or(error)?, }; // 中文注释:命中 owner 已登记记录就不再发 RPC;只有两份记录都查不到时才回落到 // asset object 点查兜底,与抠图入口的短路一致。 if !editor_reference_object_key_is_registered_for_owner( projects.as_slice(), library.assets.as_slice(), object_key.as_str(), ) { ensure_editor_reference_asset_object_owned( state, owner_user_id, object_key.as_str(), ) .await?; } object_key } }; if resolved_without_lookup.is_none() && let Some(source_resource) = source_resource { let source_resource_object_key = normalize_editor_record_object_key( source_resource.object_key.as_deref(), source_resource.image_src.as_str(), ) .ok_or_else(|| { editor_pixel_art_snap_failure( StatusCode::BAD_REQUEST, "来源项目资源没有可用的稳定图片引用。", ) })?; if source_resource_object_key != object_key { return Err(editor_pixel_art_snap_failure( StatusCode::BAD_REQUEST, "sourceImageSrc 与 sourceResourceId 指向不同图片。", )); } } let mut discovered_asset_kinds = Vec::new(); let mut storage_asset_kinds = Vec::new(); for resource in project.resources.iter().filter(|resource| { editor_record_object_key_matches( resource.object_key.as_deref(), resource.image_src.as_str(), object_key.as_str(), ) }) { push_editor_pixel_art_source_asset_kind( &mut discovered_asset_kinds, resource.asset_kind.as_deref(), ); } // 中文注释:跨记录扫描只在没有已鉴权 source_resource 时才需要。它的作用是防止同一 // objectKey 在别处登记为动画/音视频时被静态请求掩盖;有 source_resource 时该语义 // 权威已经确定,当前项目内的同键记录仍在上面按内存扫过。字节级门禁 // (validate_editor_pixel_art_static_raster)在下载后照常执行,不受此影响。 if let Some((projects, library)) = owner_records.as_ref() { for resource in projects .iter() .flat_map(|project| project.resources.iter()) .filter(|resource| { editor_record_object_key_matches( resource.object_key.as_deref(), resource.image_src.as_str(), object_key.as_str(), ) }) { push_editor_pixel_art_source_asset_kind( &mut discovered_asset_kinds, resource.asset_kind.as_deref(), ); } for asset in library.assets.iter().filter(|asset| { editor_record_object_key_matches( asset.object_key.as_deref(), asset.image_src.as_str(), object_key.as_str(), ) }) { push_editor_pixel_art_source_asset_kind( &mut discovered_asset_kinds, asset.asset_kind.as_deref(), ); } } // 中文注释:这一处是按 (bucket, objectKey) 的点查而不是全账号扫描,成本与账号规模 // 无关,两条路径都保留——它承担存储 taxonomy 的非静态门禁。 if let Some(oss_client) = state.oss_client() && 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.clone(), }) .await .map_err(map_editor_project_error)? { validate_editor_reference_asset_object( &asset_object, owner_user_id, state, object_key.as_str(), )?; push_editor_pixel_art_source_asset_kind( &mut storage_asset_kinds, Some(asset_object.asset_kind.as_str()), ); } let asset_kind = resolve_editor_pixel_art_snap_asset_kind( requested_asset_kind, source_resource.map(|resource| resource.asset_kind.as_deref()), discovered_asset_kinds.as_slice(), storage_asset_kinds.as_slice(), )?; let generation_input_reference = if let Some(source_resource) = source_resource { Some(json!({ "title": "原图", "label": source_resource .label .as_deref() .or(source_resource.asset_kind.as_deref()) .unwrap_or("项目资源"), "refType": "project-resource", "refId": source_resource.resource_id, })) } else if let Some((projects, library)) = owner_records.as_ref() { editor_generation_reference_from_records( projects.as_slice(), library.assets.as_slice(), "原图".to_string(), source, ) .or_else(|| { editor_generation_reference_from_records( projects.as_slice(), library.assets.as_slice(), "原图".to_string(), object_key.as_str(), ) }) } else { None }; Ok(EditorPixelArtSourceResolution { object_key, asset_kind, generation_input_reference, existing_result_generation_inputs, }) } fn resolve_editor_pixel_art_asset_folder_id(asset_folder_id: Option) -> Option { normalize_optional_string(asset_folder_id) .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())) } #[derive(Debug, PartialEq, Eq)] struct EditorPixelArtPersistenceIdentity { operation_id: String, operation_fingerprint: String, task_id: String, asset_object_id: String, resource_id: String, asset_id: String, } fn canonicalize_editor_json_value(value: Value) -> Value { match value { Value::Array(items) => Value::Array( items .into_iter() .map(canonicalize_editor_json_value) .collect(), ), Value::Object(object) => { let sorted = object.into_iter().collect::>(); Value::Object( sorted .into_iter() .map(|(key, value)| (key, canonicalize_editor_json_value(value))) .collect(), ) } other => other, } } fn editor_pixel_art_sha256_hex(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } fn editor_pixel_art_stable_record_suffix( owner_user_id: &str, project_id: &str, operation_id: &str, record_kind: &str, ) -> String { let input = format!( "editor-pixel-art-result-v1\0{owner_user_id}\0{project_id}\0{operation_id}\0{record_kind}" ); editor_pixel_art_sha256_hex(input.as_bytes())[..32].to_string() } #[allow(clippy::too_many_arguments)] fn build_editor_pixel_art_persistence_identity( owner_user_id: &str, project_id: &str, dialog_id: &str, source_object_key: &str, source_image_sha256: &str, output_image_sha256: &str, source_resource_id: Option<&str>, asset_kind: Option<&str>, asset_folder_id: &str, asset_label: &str, generation_inputs: Option<&Value>, canvas_completion: &EditorCanvasGenerationCompletionRequest, ) -> Result { let operation_id = dialog_id.to_string(); let fingerprint_payload = canonicalize_editor_json_value(json!({ "algorithmVersion": EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION, "ownerUserId": owner_user_id, "projectId": project_id, "dialogId": dialog_id, "sourceObjectKey": source_object_key, "sourceImageSha256": source_image_sha256, "outputImageSha256": output_image_sha256, "sourceResourceId": source_resource_id, "assetKind": asset_kind, "assetFolderId": asset_folder_id, "assetLabel": asset_label, "generationInputs": generation_inputs, "canvasCompletion": { "dialogId": dialog_id, "title": canvas_completion.title.trim(), "placeholder": { "x": canvas_completion.placeholder.x, "y": canvas_completion.placeholder.y, "width": canvas_completion.placeholder.width, "height": canvas_completion.placeholder.height, "originalWidth": canvas_completion.placeholder.original_width, "originalHeight": canvas_completion.placeholder.original_height, }, }, })); let fingerprint_bytes = serde_json::to_vec(&fingerprint_payload).map_err(|error| { editor_pixel_art_snap_failure( StatusCode::INTERNAL_SERVER_ERROR, format!("完美像素幂等指纹无法序列化:{error}"), ) })?; let operation_fingerprint = editor_pixel_art_sha256_hex(fingerprint_bytes.as_slice()); let stable_record_id = |prefix: &str, record_kind: &str| { format!( "{prefix}{}", editor_pixel_art_stable_record_suffix( owner_user_id, project_id, operation_id.as_str(), record_kind, ) ) }; let asset_object_id = stable_record_id("assetobj_", "asset-object"); let resource_id = stable_record_id(EDITOR_RESOURCE_ID_PREFIX, "project-resource"); let asset_id = stable_record_id(EDITOR_ASSET_ID_PREFIX, "asset"); Ok(EditorPixelArtPersistenceIdentity { operation_id, operation_fingerprint, task_id: format!("pixel-art-snap-{dialog_id}"), asset_object_id, resource_id, asset_id, }) } pub async fn snap_editor_image_to_pixel_art( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(mut payload) = parse_editor_generation_json_payload(payload)?; // 旧版本把客户端 references 纳入完美像素 operation fingerprint。继续用同一份 // canonical 输入计算指纹,确保升级前响应丢失的请求仍能命中原 operation;真正持久化 // 的 generationInputs 会在下方删除客户端 references,并按已鉴权源记录重建。 let fingerprint_generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.clone()) .map(canonicalize_editor_json_value); // 中文注释:`screenColorHex / mattingProvider / mattingModel` 是服务端产出的处理事实 // (背景色决策与 bgfilter 实际执行后写入),不接受客户端声明,否则用户可以给自己的记录 // 伪造抠图模型等审计字段,污染后台按这些字段做的统计与排障。本端点是纯几何规整、不抠图, // 任何 matting 元数据出现在这里本身就是伪造。与其余生成入口共用同一个 sanitizer,位置也 // 保持一致:在任何 IO 之前。 payload.generation_inputs = sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); payload.generation_inputs = payload .generation_inputs .take() .map(canonicalize_editor_json_value); let started_at = Instant::now(); // 中文注释:处理预算必须从进入 handler 起算,而不是等下载完成后才起算。inline HTTP // 请求的 RequestContext 默认没有 external_call_deadline(只有队列 worker 会设), // 所以下载阶段此前完全落在 30s 之外:请求可以先挂在 OSS GET 上,再持着最多 32 MiB // 排队等 CPU 许可,预算形同虚设。这里一次性派生绝对 deadline,下载与规整共用同一份。 let processing_deadline = resolve_editor_pixel_art_processing_deadline( started_at, request_context.external_call_deadline(), ); ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), "pixel-art-snapper", "sourceImageSrc", "待像素规整图片", )?; let project_id = normalize_optional_string(Some(payload.project_id.clone())).ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "pixel-art-snapper", "field": "projectId", "message": "完美像素必须关联有效画布项目。", })) })?; validate_editor_pixel_art_snap_canvas_completion(&payload.canvas_completion)?; let dialog_id = normalized_canvas_completion_dialog_id(&payload.canvas_completion).ok_or_else(|| { editor_pixel_art_snap_failure( StatusCode::BAD_REQUEST, "完美像素必须关联有效的画布生成占位。", ) })?; // 中文注释:在 CPU 处理和 OSS PUT 前完成所有无副作用校验;主动操作的像素规整 // 失败必须直接返回错误,不能先创建与原图相同的派生资源。 serialize_editor_asset_metadata(payload.generation_inputs.clone())?; // 中文注释:端点级并发闸设在第一次 IO 之前——上面几步都是纯内存校验,让畸形请求也去 // 排队既浪费名额又让 400 拖到 30 秒。闸之后的全部 IO(全账号扫描、下载、规整、持久化) // 都在许可覆盖范围内,许可随 handler 返回自动释放。 let _snap_permit = acquire_editor_pixel_art_snap_permit(processing_deadline).await?; let owner_user_id = current_owner_user_id(&authenticated); let expected_result_task_id = format!("pixel-art-snap-{dialog_id}"); let expected_result_resource_id = format!( "{EDITOR_RESOURCE_ID_PREFIX}{}", editor_pixel_art_stable_record_suffix( owner_user_id.as_str(), project_id.as_str(), dialog_id.as_str(), "project-resource", ) ); // 中文注释:归属校验阶段必须自己套绝对 deadline。预算只是从 handler 入口起算, // 起算不等于覆盖——此前这段里的 SpacetimeDB 调用全是裸 await,第一次真正应用预算 // 是下载。SpacetimeDB 慢时请求会一路走到下载才发现预算早已耗尽,返回的还是下载相关 // 文案,同时全程占着端点准入名额,把并发闸变成瓶颈。 // 这里不重复写 `Instant::now() >= deadline` 预检:紧邻上一行的 // `acquire_editor_pixel_art_snap_permit` 已经做过该预检,成功即意味着尚未超预算, // 且本块首个 await 是网络 IO 不会立即就绪,不构成 timeout_at 先 poll 再判超时的陷阱。 let source_resource_id = normalize_optional_string(payload.source_resource_id.clone()); let source = tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), async { 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 project = editor_project_payload_from_record(project); validate_editor_pixel_art_snap_placeholder_exists( &project.layers, project.resources.as_slice(), owner_user_id.as_str(), project_id.as_str(), &payload.canvas_completion, )?; let source_resource = if let Some(source_resource_id) = source_resource_id.as_deref() { Some( project .resources .iter() .find(|resource| resource.resource_id.trim() == source_resource_id) .ok_or_else(|| { editor_pixel_art_snap_failure( StatusCode::NOT_FOUND, "来源项目资源不存在或不属于当前画布项目。", ) })?, ) } else { None }; resolve_editor_pixel_art_source_for_owner( &state, owner_user_id.as_str(), project_id.as_str(), payload.source_image_src.as_str(), &project, source_resource, payload.asset_kind.as_deref(), expected_result_resource_id.as_str(), expected_result_task_id.as_str(), ) .await }) .await .map_err(|_| { editor_pixel_art_snap_failure( StatusCode::GATEWAY_TIMEOUT, "完美像素来源归属校验超出处理预算。", ) })??; let source_object_key = source.object_key; let asset_kind = source.asset_kind; let authoritative_generation_inputs = rebuild_editor_generation_inputs_with_authoritative_references( payload.generation_inputs.take(), source.generation_input_reference.into_iter().collect(), ); // 旧结果已经落库时,重放必须携带原记录的 metadata 才能通过 SpacetimeDB 的精确 // compare-and-return;这只复用已由服务端持久化的 owner-scoped 记录。新操作始终使用 // 上面按已鉴权源重建的 references,不再接受客户端自报 provenance。 payload.generation_inputs = resolve_editor_pixel_art_persisted_generation_inputs( authoritative_generation_inputs, source.existing_result_generation_inputs, ); let source_image = download_editor_persisted_image_object_within_deadline( &state, source_object_key.as_str(), processing_deadline, ) .await?; validate_editor_pixel_art_static_raster(&source_image)?; let source_image_sha256 = editor_pixel_art_sha256_hex(source_image.bytes.as_slice()); // 中文注释:传已派生的绝对 deadline 而不是原始 request_deadline。 // resolve_editor_pixel_art_processing_deadline 取 min,所以内层拿到的仍是这一份, // 下载耗掉的时间会如实从规整预算里扣除,而不是让规整重新获得完整 30s。 let snapped_image = snap_editor_pixel_art_strict(Arc::new(source_image), Some(processing_deadline)).await?; // 中文注释:只读 PNG 头取尺寸,不做整图解码。这里的字节是本进程 snapper 刚 encode // 出来的内存缓冲:没有网络截断风险,编码失败会在上一步直接返回 Err,所以不需要像 // editor_postprocessed_alpha_matches_delivery_dimensions 那样为识别截断像素数据而 // 整图解码——那条路径处理的是 provider 经网络送来的图。在这里整图解码是纯浪费: // 输出上限 8294400 像素,一次解码要多分配约 33 MiB 并全量 inflate,而且发生在 // CPU 许可之外。 let mut reader = image::ImageReader::new(Cursor::new(snapped_image.bytes.as_slice())); reader.set_format(image::ImageFormat::Png); let (width, height) = reader.into_dimensions().map_err(|error| { editor_pixel_art_snap_failure( StatusCode::INTERNAL_SERVER_ERROR, format!("完美像素输出不是有效 PNG:{error}"), ) })?; let output_image_sha256 = editor_pixel_art_sha256_hex(snapped_image.bytes.as_slice()); let asset_folder_id = normalize_generated_asset_folder_id( resolve_editor_pixel_art_asset_folder_id(payload.asset_folder_id.take()), owner_user_id.as_str(), ) .ok_or_else(|| { editor_pixel_art_snap_failure( StatusCode::INTERNAL_SERVER_ERROR, "完美像素结果缺少账号素材目录。", ) })?; let asset_label = resolve_editor_generated_asset_label(payload.asset_label.take(), "完美像素"); let persistence_identity = build_editor_pixel_art_persistence_identity( owner_user_id.as_str(), project_id.as_str(), dialog_id.as_str(), source_object_key.as_str(), source_image_sha256.as_str(), output_image_sha256.as_str(), source_resource_id.as_deref(), asset_kind.as_deref(), asset_folder_id.as_str(), asset_label.as_str(), fingerprint_generation_inputs.as_ref(), &payload.canvas_completion, )?; let response_task_id = persistence_identity.task_id.clone(); let generation_inputs_json = serialize_editor_asset_metadata(payload.generation_inputs.take())?; let canvas_completion = EditorPixelArtCanvasCompletionRecordInput { dialog_id, title: payload.canvas_completion.title.trim().to_string(), placeholder: EditorPixelArtCanvasPlaceholderRecordInput { x: payload.canvas_completion.placeholder.x, y: payload.canvas_completion.placeholder.y, width: payload.canvas_completion.placeholder.width, height: payload.canvas_completion.placeholder.height, original_width: payload.canvas_completion.placeholder.original_width, original_height: payload.canvas_completion.placeholder.original_height, }, }; let fingerprint_file_stem = format!( "perfect-pixel-{}", persistence_identity.operation_fingerprint ); let prepared_upload = prepare_editor_generated_image_object_data( owner_user_id.as_str(), persistence_identity.task_id.as_str(), GeneratedImageAssetDataUrl { format: normalize_generated_image_asset_mime(snapped_image.mime_type.as_str()), bytes: snapped_image.bytes, }, EDITOR_PIXEL_ART_SNAP_ASSET_KIND, "pixel-art-snaps", fingerprint_file_stem.as_str(), "result", "genarrative", )?; let prepared_object_key = prepared_upload.storage_paths.object_key.clone(); let image_src = editor_media_src_from_object_key(prepared_object_key.as_str()); let mut project_resource = EditorProjectResourceCreateRecordInput { resource_id: persistence_identity.resource_id.clone(), project_id: project_id.clone(), owner_user_id: owner_user_id.clone(), asset_object_id: Some(persistence_identity.asset_object_id.clone()), image_src: image_src.clone(), object_key: Some(prepared_object_key), width, height, source_type: "generated".to_string(), prompt: Some("完美像素".to_string()), actual_prompt: None, model: Some(EDITOR_PIXEL_ART_SNAP_MODEL.to_string()), provider: Some(EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string()), task_id: Some(persistence_identity.task_id.clone()), source_resource_id: source_resource_id.clone(), asset_kind: asset_kind.clone(), generation_inputs_json: generation_inputs_json.clone(), image_sequence_frames_json: None, image_sequence_duration_ms: None, updated_at_micros: current_utc_micros(), }; // 中文注释:持久化阶段此前完全无界,只受 OSS 客户端每请求 120 秒约束,而 PUT 与 HEAD // 各自独立计时,加上多次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上, // 远超客户端 120 秒——客户端会在服务端仍在合法工作时先放弃,对账因此采样到一个仍在 // 途的操作:占位还在、素材库还空,用户照提示去查什么也看不到,重试就造出孤儿对象。 // // 这里独立起算 60 秒,不与处理预算取 min。OSS PUT/HEAD 后只调用一次原子 procedure; // procedure future 被本地 timeout 丢弃并不能撤销远端事务,因此同一 dialog 的 operation、 // record IDs 与 fingerprint 都保持稳定,让响应丢失后的同输入重放只能得到同一份结果。 let persistence_started_at = Instant::now(); let persistence_deadline = persistence_started_at .checked_add(EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION) .unwrap_or(persistence_started_at); // 中文注释:preflight 与 PUT/HEAD/原子 persist 共用同一个绝对 deadline。preflight // 是只读 procedure,失败或超时证明第一次 PUT 尚未发出,因此不得附加 unknown 标记。 tokio::time::timeout_at( tokio::time::Instant::from_std(persistence_deadline), state.spacetime_client().preflight_editor_pixel_art_result( EditorPixelArtResultPreflightRecordInput { owner_user_id: owner_user_id.clone(), project_id: project_id.clone(), asset_folder_id: asset_folder_id.clone(), project_resource: project_resource.clone(), canvas_completion: canvas_completion.clone(), }, ), ) .await .map_err(|_| { editor_pixel_art_snap_failure( StatusCode::GATEWAY_TIMEOUT, "完美像素结果持久化预检超出处理预算。", ) })? .map_err(map_editor_project_error)?; // 中文注释:Tokio Timeout 会先 poll 内层 future;preflight 与截止同时 ready 时, // timeout_at 仍可能返回成功。进入任何上传 future 前必须再检查一次绝对截止,避免 // 第二个 timeout_at 首次 poll 内层并发出已经超预算的 PUT。 if Instant::now() >= persistence_deadline { return Err(editor_pixel_art_snap_failure( StatusCode::GATEWAY_TIMEOUT, "完美像素结果持久化预检超出处理预算。", )); } let persistence_task_id = response_task_id.clone(); let result_persistence_started = Arc::new(AtomicBool::new(false)); let upload_persistence_started = Arc::clone(&result_persistence_started); let persisted = tokio::select! { // 中文注释:截止与上传同时 ready 时先选 timer,确保尚未开始的 PUT 不会被首轮 poll。 biased; _ = tokio::time::sleep_until(tokio::time::Instant::from_std(persistence_deadline)) => { let error = editor_pixel_art_snap_failure( StatusCode::GATEWAY_TIMEOUT, "完美像素结果持久化超出处理预算。", ); Err(if result_persistence_started.load(Ordering::Acquire) { error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) } else { error }) } result = async move { let mut uploaded = upload_editor_generated_image_object_prepared( &state, owner_user_id.as_str(), persistence_identity.task_id.as_str(), prepared_upload, "完美像素", EDITOR_PIXEL_ART_SNAP_ASSET_KIND, Some(persistence_identity.asset_object_id.clone()), Some(upload_persistence_started.as_ref()), ) .await?; let completed_at_micros = current_utc_micros(); uploaded.asset_object.content_type = Some("image/png".to_string()); uploaded.asset_object.content_hash = Some(output_image_sha256); uploaded.asset_object.updated_at_micros = completed_at_micros; project_resource.updated_at_micros = completed_at_micros; state .spacetime_client() .persist_editor_pixel_art_result(EditorPixelArtResultPersistRecordInput { owner_user_id: owner_user_id.clone(), project_id: project_id.clone(), operation_id: persistence_identity.operation_id, operation_fingerprint: persistence_identity.operation_fingerprint, asset_object: uploaded.asset_object, project_resource, asset: EditorAssetCreateRecordInput { asset_id: persistence_identity.asset_id, owner_user_id: owner_user_id.clone(), folder_id: asset_folder_id, label: asset_label, asset_object_id: Some(persistence_identity.asset_object_id), image_src, object_key: Some(uploaded.object_key), width, height, source_type: "generated".to_string(), prompt: Some("完美像素".to_string()), actual_prompt: None, model: Some(EDITOR_PIXEL_ART_SNAP_MODEL.to_string()), provider: Some(EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string()), task_id: Some(persistence_identity.task_id), asset_kind, generation_inputs_json, source_resource_id: Some(persistence_identity.resource_id), now_micros: completed_at_micros, thumbnail_src: None, generation_cost_mud_points: 0, group_task_id: None, group_task_expected_asset_count: None, image_sequence_frames_json: None, image_sequence_duration_ms: None, }, canvas_completion, completed_at_micros, }) .await .map_err(|error| { tracing::warn!( provider = EDITOR_PIXEL_ART_SNAP_PROVIDER, task_id = %persistence_task_id, error = %error, "editor_pixel_art_snap_atomic_persistence_failed_after_object_put" ); map_editor_project_error(error) .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) }) } => result, }?; let image_src = editor_media_src_from_object_key(persisted.asset_object.object_key.as_str()); let resource = editor_project_resource_payload_from_record(persisted.project_resource); let asset = editor_asset_payload_from_record(persisted.asset); let completed_project = persisted.project.map(editor_project_payload_from_record); Ok(json_success_body( Some(&request_context), EditorPixelArtSnapResponse { image_src, object_key: persisted.asset_object.object_key, asset_object_id: persisted.asset_object.asset_object_id, width, height, source_type: "generated", task_id: response_task_id, elapsed_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), provider: EDITOR_PIXEL_ART_SNAP_PROVIDER, resource, asset, project: completed_project, }, )) } 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")) } pub(crate) 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": "抠图参数无效。", })), ), } } 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), }, )); } let caller = caller.with_inline_operation( &request_context, EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, &payload, )?; with_editor_generation_durable_billing_boundary(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_untrusted_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 extra_reference_limit = EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); ensure_editor_reference_image_source_limit( payload.reference_image_srcs.as_deref(), extra_reference_limit, "editor-ui-design-asset-extraction", "referenceImageSrcs", "UI素材参考图", )?; let target = preflight_editor_billable_generation_target( state, caller.owner_user_id.as_str(), payload.project_id.clone(), resolve_editor_ui_design_asset_extraction_target_folder_id(payload.asset_folder_id.clone()), ) .await?; payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; 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素材参考图", )?; 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 extra_reference_limit = EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); ensure_editor_reference_image_source_limit( payload.reference_image_srcs.as_deref(), extra_reference_limit, "editor-ui-design-asset-extraction", "referenceImageSrcs", "UI素材参考图", )?; let target = preflight_editor_billable_generation_target( state, caller.owner_user_id.as_str(), payload.project_id.clone(), resolve_editor_ui_design_asset_extraction_target_folder_id(payload.asset_folder_id.clone()), ) .await?; payload.project_id = target.project_id; payload.asset_folder_id = target.asset_folder_id; payload.generation_inputs = rebuild_editor_generation_input_references( state, caller.owner_user_id.as_str(), payload.generation_inputs.take(), build_editor_generation_reference_sources( Some(("UI设计图", payload.source_image_src.as_str())), payload.reference_image_srcs.as_deref(), "参考图", ), ) .await?; // 背景色决策挪到预扣泥点之后(见下方 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()) { 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 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.vector_engine_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_prepared_image = prepare_editor_generated_image( state, &caller, generated.task_id.as_str(), &image, prompt.as_str(), generated.actual_prompt.as_deref(), EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND, "ui-design-spritesheets", "spritesheet-provider-source", EDITOR_PROVIDER_SOURCE_SLOT, "vector-engine", ) .await?; let source_image_src = editor_media_src_from_object_key(source_prepared_image.object_key.as_str()); let source_object_key = source_prepared_image.object_key.clone(); let source_asset_object_id = source_prepared_image.asset_object_id().to_string(); let prepared_at_micros = current_utc_micros(); let source_record = prepare_editor_generated_asset( &caller, EDITOR_PROVIDER_SOURCE_SLOT, Some(source_prepared_image.asset_object.clone()), PersistEditorGeneratedAssetInput { 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(), "原图"), image_src: source_image_src.clone(), object_key: Some(source_object_key.clone()), asset_object_id: Some(source_asset_object_id.clone()), width: source_width, height: source_height, prompt: 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: None, asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: generation_inputs.clone(), thumbnail_src: None, generation_cost_mud_points: u64::from(expected_price_mud_points), image_sequence_frames: None, image_sequence_duration_ms: None, }, prepared_at_micros, )?; caller.report_processing_phase(state).await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller .audit_project_id .clone() .or_else(|| payload.project_id.clone()), request_id: Some(request_context.request_id().to_string()), external_call_deadline: request_context.external_call_deadline(), }; let removal = remove_editor_generated_screen_background_with_bgfilter( state, source_object_key.as_str(), screen_color, seg_model, EDITOR_BGFILTER_CROSS_CHECK_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 (source_record, completed_project, fallback_warning) = persist_editor_source_only_fallback_atomically( state, &caller, payload.project_id.as_deref(), payload.canvas_completion.as_ref(), source_record, generated.task_id.as_str(), source_image_src.as_str(), source_object_key.as_str(), source_asset_object_id.as_str(), EditorSourceOnlyFallbackResultContext { width: source_width, height: source_height, prompt: prompt.as_str(), actual_prompt: generated.actual_prompt.as_deref(), model: generation_options.model, price_mud_points: expected_price_mud_points, }, 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 (source_record, completed_project, fallback_warning) = persist_editor_source_only_fallback_atomically( state, &caller, payload.project_id.as_deref(), payload.canvas_completion.as_ref(), source_record, generated.task_id.as_str(), source_image_src.as_str(), source_object_key.as_str(), source_asset_object_id.as_str(), EditorSourceOnlyFallbackResultContext { width: source_width, height: source_height, prompt: prompt.as_str(), actual_prompt: generated.actual_prompt.as_deref(), model: generation_options.model, price_mud_points: expected_price_mud_points, }, 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.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.clone(); let spritesheet_prepared_image = prepare_editor_generated_image( state, &caller, 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_prepared_image.object_key.as_str()); let spritesheet_record = prepare_editor_generated_asset( &caller, "spritesheet", Some(spritesheet_prepared_image.asset_object.clone()), 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_prepared_image.object_key.clone()), asset_object_id: Some(spritesheet_prepared_image.asset_object_id().to_string()), 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 .record .resource .as_ref() .map(|resource| resource.resource_id.clone()), asset_kind: Some("icon-spritesheet".to_string()), generation_inputs: matting_generation_inputs, thumbnail_src: None, generation_cost_mud_points: 0, image_sequence_frames: None, image_sequence_duration_ms: None, }, prepared_at_micros, )?; let slice_source = DownloadedImage { bytes: image.bytes, mime_type: image.mime_type, extension: image.extension, }; let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all( slice_source, request_context.external_call_deadline(), ) .await { Ok(icon_slices) => { match prepare_editor_spritesheet_slices_for_generation( state, &caller, icon_slices, PersistEditorSpritesheetSlicesInput { owner_user_id: owner_user_id.clone(), project_id: payload.project_id.clone(), asset_folder_id: asset_folder_id.clone(), source_resource_id: spritesheet_record .record .resource .as_ref() .map(|resource| resource.resource_id.clone()), task_id: generated.task_id.clone(), group_task_id: None, prompt: "自动拆分图集".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.record, ), path_kind: "ui-design-assets", asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND, persistence_provider: "vector-engine", }, ) .await { Ok(prepared_slices) => (prepared_slices.responses, prepared_slices.items, None), Err(error) => { let reason = error.body_text(); tracing::warn!( provider = "editor-ui-design-asset-extraction", operation = "persist_slices", task_id = %generated.task_id, reason = %reason, "UI 设计图透明图集已上传,但独立素材准备失败" ); ( Vec::new(), 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(), Vec::new(), Some(warning)) } }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.record.resource.as_ref(), source_record.record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { (Vec::new(), None) }; let canvas_layout_plan = EditorCanvasLayoutPlan::generation( owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), canvas_items, primary_layer_id, ); let job_result_payload_json = editor_generation_job_result_payload_json( &caller, json!({ "ok": true, "spritesheetImageSrc": spritesheet_image_src.clone(), "spritesheetWidth": spritesheet_width, "spritesheetHeight": spritesheet_height, "iconImageSrcs": &icon_image_srcs, "sliceWarning": &slice_warning, "warning": null, "prompt": prompt.clone(), "actualPrompt": generated.actual_prompt.clone(), "model": generation_options.model, "taskId": generated.task_id.clone(), "priceMudPoints": expected_price_mud_points, "spritesheetResource": &spritesheet_record.record.resource, "spritesheetAsset": &spritesheet_record.record.asset, "assetObjectId": spritesheet_prepared_image.asset_object_id(), "objectKey": spritesheet_prepared_image.object_key.clone(), }), ); let completed_at_micros = current_utc_micros(); let mut items = vec![source_record.item, spritesheet_record.item]; items.extend(slice_items); let persisted_result = persist_editor_generation_result_atomically( state, &caller, items, canvas_layout_plan, job_result_payload_json, completed_at_micros, ) .await?; let completed_project = persisted_result .project .map(editor_project_payload_from_record); let mut persisted_items = persisted_result.items; let spritesheet_item_index = persisted_items .iter() .position(|item| item.slot == "spritesheet") .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-generation-operation", "message": "UI 设计图素材原子提交缺少 spritesheet 结果。", })) })?; let spritesheet_item = persisted_items.remove(spritesheet_item_index); let spritesheet_record = EditorGeneratedAssetRecord { resource: spritesheet_item .project_resource .map(editor_project_resource_payload_from_record), asset: spritesheet_item.asset.map(editor_asset_payload_from_record), }; for (index, icon) in icon_image_srcs.iter_mut().enumerate() { let slot = format!("slice-{index:04}"); let item_index = persisted_items .iter() .position(|item| item.slot == slot) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-generation-operation", "message": format!("UI 设计图素材原子提交缺少 {slot} 结果。"), })) })?; let item = persisted_items.remove(item_index); icon.resource = item .project_resource .map(editor_project_resource_payload_from_record); icon.asset = item.asset.map(editor_asset_payload_from_record); } 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()); let formal_image_sequence_frames = record .image_sequence_frames .as_ref() .and_then(normalize_editor_image_sequence_frames_value); let asset_kind = resolve_editor_asset_kind( record.asset_kind, image_src.as_str(), 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, showcase_category: None, generation_inputs: record .generation_inputs .map(sanitize_editor_user_generation_inputs), image_sequence_frames: formal_image_sequence_frames, image_sequence_duration_ms: record.image_sequence_duration_ms, 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()); let formal_image_sequence_frames = record .image_sequence_frames .as_ref() .and_then(normalize_editor_image_sequence_frames_value); let asset_kind = resolve_editor_asset_kind( record.asset_kind, image_src.as_str(), 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, showcase_category: record.showcase_category, generation_inputs: record .generation_inputs .map(sanitize_editor_user_generation_inputs), image_sequence_frames: formal_image_sequence_frames, image_sequence_duration_ms: record.image_sequence_duration_ms, 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 strip_editor_generation_input_references(mut value: Value) -> Value { if let Some(object) = value.as_object_mut() { object.remove("references"); } value } pub(crate) fn sanitize_editor_untrusted_generation_inputs(value: Option) -> Option { sanitize_editor_client_generation_inputs(value).map(strip_editor_generation_input_references) } fn editor_generation_reference_from_records( projects: &[EditorProjectRecord], assets: &[EditorAssetRecord], title: String, source: &str, ) -> Option { let source = source.trim(); if source.is_empty() { return None; } if let Some(resource) = projects .iter() .flat_map(|project| project.resources.iter()) .find(|resource| resource.resource_id.trim() == source) { return Some(json!({ "title": title, "label": resource.asset_kind.as_deref().unwrap_or("项目资源"), "refType": "project-resource", "refId": resource.resource_id, })); } if let Some(asset) = assets.iter().find(|asset| asset.asset_id.trim() == source) { return Some(json!({ "title": title, "label": asset.label, "refType": "asset", "refId": asset.asset_id, })); } let object_key = normalize_editor_reference_object_key(source).ok()?; if let Some(resource) = projects .iter() .flat_map(|project| project.resources.iter()) .find(|resource| { editor_record_object_key_matches( resource.object_key.as_deref(), resource.image_src.as_str(), object_key.as_str(), ) }) { return Some(json!({ "title": title, "label": resource.asset_kind.as_deref().unwrap_or("项目资源"), "refType": "project-resource", "refId": resource.resource_id, })); } assets .iter() .find(|asset| { editor_record_object_key_matches( asset.object_key.as_deref(), asset.image_src.as_str(), object_key.as_str(), ) }) .map(|asset| { json!({ "title": title, "label": asset.label, "refType": "asset", "refId": asset.asset_id, }) }) } pub(crate) async fn rebuild_editor_generation_input_references( state: &AppState, owner_user_id: &str, generation_inputs: Option, reference_sources: Vec<(String, String)>, ) -> Result, AppError> { let sanitized = sanitize_editor_client_generation_inputs(generation_inputs); if sanitized.is_none() && reference_sources.is_empty() { return Ok(None); } let references = if reference_sources.is_empty() { Vec::new() } else { 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)?; reference_sources .into_iter() .filter_map(|(title, source)| { editor_generation_reference_from_records( projects.as_slice(), library.assets.as_slice(), title, source.as_str(), ) }) .collect() }; Ok(rebuild_editor_generation_inputs_with_authoritative_references(sanitized, references)) } fn rebuild_editor_generation_inputs_with_authoritative_references( generation_inputs: Option, references: Vec, ) -> Option { if generation_inputs.is_none() && references.is_empty() { return None; } let mut value = generation_inputs.unwrap_or_else(|| json!({ "fields": [] })); if !value.is_object() { value = json!({ "fields": [] }); } value .as_object_mut() .expect("generation inputs should be an object") .insert("references".to_string(), Value::Array(references)); Some(value) } 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()); let formal_image_sequence_frames = record .image_sequence_frames .as_ref() .and_then(normalize_editor_image_sequence_frames_value); let asset_kind = resolve_editor_asset_kind( record.asset_kind, image_src.as_str(), 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, image_sequence_frames: formal_image_sequence_frames, image_sequence_duration_ms: record.image_sequence_duration_ms, 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, } } pub(crate) fn resolve_editor_asset_kind( asset_kind: Option, image_src: &str, object_key: Option<&str>, ) -> Option { if asset_kind.is_some() { return asset_kind; } let source_is_video = editor_asset_source_has_extension(image_src, object_key, &["mp4", "mov", "webm", "avi"]); let source_is_audio = editor_asset_source_has_extension( image_src, object_key, &["mp3", "wav", "ogg", "m4a", "aac", "flac"], ); if source_is_video { Some("video".to_string()) } else if source_is_audio { Some("audio".to_string()) } else { Some("image".to_string()) } } pub(crate) fn sanitize_editor_generation_inputs(value: Option) -> Option { value .map(sanitize_editor_payload_inline_media) .filter(|value| { value .as_object() .map(|inputs| !inputs.is_empty()) .unwrap_or(true) }) } fn normalize_editor_image_sequence_frame(frame: &Value) -> Option { let Some(frame) = frame.as_object() else { return None; }; if frame.contains_key("frameIndex") { return None; } frame .get("imageSrc") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; let object_key = normalize_editor_object_key( frame .get("objectKey") .and_then(Value::as_str) .map(str::to_string), )?; let asset_object_id = normalize_optional_string( frame .get("assetObjectId") .and_then(Value::as_str) .map(str::to_string), )?; let width = frame.get("width").and_then(Value::as_u64)?; let height = frame.get("height").and_then(Value::as_u64)?; if width == 0 || height == 0 || u32::try_from(width).is_err() || u32::try_from(height).is_err() { return None; } let normalized = serde_json::Map::from_iter([ ( "imageSrc".to_string(), Value::String(editor_media_src_from_object_key(&object_key)), ), ("objectKey".to_string(), Value::String(object_key)), ("assetObjectId".to_string(), Value::String(asset_object_id)), ("width".to_string(), Value::from(width)), ("height".to_string(), Value::from(height)), ]); Some(Value::Object(normalized)) } pub(crate) fn normalize_editor_image_sequence_frames_value(frames: &Value) -> Option { let frames = frames.as_array()?; let normalized = frames .iter() .map(normalize_editor_image_sequence_frame) .collect::>>()?; (normalized.len() >= 2).then_some(Value::Array(normalized)) } fn editor_asset_source_has_extension( image_src: &str, object_key: Option<&str>, extensions: &[&str], ) -> bool { [Some(image_src), object_key] .into_iter() .flatten() .filter_map(|value| value.split(['?', '#']).next()) .filter_map(|value| value.rsplit('.').next()) .any(|extension| { extensions .iter() .any(|candidate| extension.eq_ignore_ascii_case(candidate)) }) } 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, source_type: 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(), source_type: normalize_optional_string(Some(resource.source_type.clone())), }, ) }) .collect(); strip_legacy_canvas_layer_media_type(&mut value); sanitize_editor_payload_media_value(&mut value, None, &resource_media); value } fn strip_legacy_canvas_layer_media_type(value: &mut Value) { let Some(items) = value.as_array_mut() else { return; }; for item in items { let Some(object) = item.as_object_mut() else { continue; }; if matches!( object.get("itemType").and_then(Value::as_str), Some("canvas-settings" | "generation-dialog") ) { continue; } object.remove("mediaType"); } } 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"); } // 中文注释:结构化保存校验完 sourceType 后会把图层列置空(资源行才是权威), // 于是布局读回时整个键都不存在,客户端只能猜一个默认值再原样回写,下一次保存 // 就被判成「sourceType 与项目资源不一致」。这里按图层自己声明的 resourceId // 回填权威值,口径与上面的 objectKey / assetObjectId 一致;缺资源行的 // legacy 本地序列取不到映射,保持它自带的值不动。 let resource_source_type = object .get("resourceId") .and_then(Value::as_str) .and_then(|resource_id| resource_media.get(resource_id)) .and_then(|media| media.source_type.clone()); fill_missing_media_identity_field( object, "sourceType", resource_source_type.as_ref(), ); 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, mut input: PersistEditorGeneratedAssetInput, ) -> Result { let generation_inputs_json = serialize_editor_generation_inputs( input.asset_kind.as_deref(), input.generation_inputs.clone(), )?; let image_sequence_frames_json = serialize_editor_image_sequence_frames(input.image_sequence_frames.take())?; 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, image_sequence_frames_json: image_sequence_frames_json.clone(), image_sequence_duration_ms: input.image_sequence_duration_ms, }) .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, image_sequence_frames_json, image_sequence_duration_ms: input.image_sequence_duration_ms, }) .await .map_err(map_editor_project_error)?; Some(editor_asset_payload_from_record(record)) } else { None }; Ok(EditorGeneratedAssetRecord { resource, asset }) } pub(crate) fn prepare_editor_generated_asset( caller: &EditorGenerationCaller, slot: &str, asset_object: Option, mut input: PersistEditorGeneratedAssetInput, prepared_at_micros: i64, ) -> Result { let operation = caller.operation.as_ref().ok_or_else(|| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-generation-operation", "message": "准备编辑器生成结果时缺少稳定 operation 上下文。", })) })?; let slot = slot.trim(); if slot.is_empty() { return Err( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-generation-operation", "message": "准备编辑器生成结果时缺少稳定 slot。", })), ); } if let Some(candidate) = asset_object.as_ref() { input.asset_object_id = Some(candidate.asset_object_id.clone()); input.object_key = Some(candidate.object_key.clone()); } let generation_inputs_json = serialize_editor_generation_inputs( input.asset_kind.as_deref(), input.generation_inputs.clone(), )?; let image_sequence_frames_json = serialize_editor_image_sequence_frames(input.image_sequence_frames.take())?; 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 resource_input = normalize_optional_string(input.project_id).map(|project_id| { EditorProjectResourceCreateRecordInput { resource_id: editor_generation_stable_resource_id( caller.owner_user_id.as_str(), operation.operation_kind.as_str(), operation.operation_id.as_str(), slot, ), project_id, owner_user_id: caller.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: prepared_at_micros, image_sequence_frames_json: image_sequence_frames_json.clone(), image_sequence_duration_ms: input.image_sequence_duration_ms, } }); let resource = resource_input.clone().map(|record| { editor_project_resource_payload_from_record(EditorProjectResourceRecord { resource_id: record.resource_id, project_id: record.project_id, owner_user_id: record.owner_user_id, asset_object_id: record.asset_object_id, image_src: record.image_src, object_key: record.object_key, width: record.width, height: record.height, source_type: record.source_type, prompt: record.prompt, actual_prompt: record.actual_prompt, model: record.model, provider: record.provider, task_id: record.task_id, source_resource_id: record.source_resource_id, asset_kind: record.asset_kind, generation_inputs: record .generation_inputs_json .and_then(|value| serde_json::from_str(value.as_str()).ok()), public_showcase_enabled: false, created_at: String::new(), updated_at: String::new(), image_sequence_frames: record .image_sequence_frames_json .and_then(|value| serde_json::from_str(value.as_str()).ok()), image_sequence_duration_ms: record.image_sequence_duration_ms, }) }); let folder_id = normalize_generated_asset_folder_id(input.folder_id, caller.owner_user_id.as_str()); let source_resource_id = resource .as_ref() .map(|resource| resource.resource_id.clone()) .or_else(|| normalize_optional_string(input.source_resource_id)); let asset_input = folder_id.map(|folder_id| EditorAssetCreateRecordInput { asset_id: editor_generation_stable_asset_id( caller.owner_user_id.as_str(), operation.operation_kind.as_str(), operation.operation_id.as_str(), slot, ), owner_user_id: caller.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, generation_cost_mud_points: input.generation_cost_mud_points, now_micros: prepared_at_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, image_sequence_frames_json, image_sequence_duration_ms: input.image_sequence_duration_ms, }); let asset = asset_input.clone().map(|record| { editor_asset_payload_from_record(EditorAssetRecord { asset_id: record.asset_id, folder_id: record.folder_id, label: record.label, asset_object_id: record.asset_object_id, image_src: record.image_src, object_key: record.object_key, width: record.width, height: record.height, source_type: record.source_type, prompt: record.prompt, actual_prompt: record.actual_prompt, model: record.model, provider: record.provider, task_id: record.task_id, asset_kind: record.asset_kind, generation_inputs: record .generation_inputs_json .and_then(|value| serde_json::from_str(value.as_str()).ok()), source_resource_id: record.source_resource_id, public_showcase_enabled: None, thumbnail_src: record.thumbnail_src, generation_cost_mud_points: record.generation_cost_mud_points, showcase_id: None, showcase_review_status: None, showcase_display_enabled: None, showcase_like_count: None, created_at: String::new(), updated_at: String::new(), image_sequence_frames: record .image_sequence_frames_json .and_then(|value| serde_json::from_str(value.as_str()).ok()), image_sequence_duration_ms: record.image_sequence_duration_ms, }) }); Ok(PreparedEditorGeneratedAssetRecord { item: PreparedEditorGenerationResultItem { slot: slot.to_string(), asset_object, project_resource: resource_input, asset: asset_input, binding: None, }, record: 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, image_sequence_frames: input.image_sequence_frames, image_sequence_duration_ms: input.image_sequence_duration_ms, }, ) .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))) } #[derive(Clone, Copy)] pub(crate) struct EditorSourceOnlyFallbackResultContext<'a> { pub(crate) width: u32, pub(crate) height: u32, pub(crate) prompt: &'a str, pub(crate) actual_prompt: Option<&'a str>, pub(crate) model: &'a str, pub(crate) price_mud_points: u32, } fn build_editor_source_only_fallback_compact_result( operation_kind: &str, source_record: &EditorGeneratedAssetRecord, task_id: &str, image_src: &str, object_key: &str, asset_object_id: &str, result_context: EditorSourceOnlyFallbackResultContext<'_>, warning: Option<&EditorGenerationWarningResponse>, ) -> Value { if matches!( operation_kind, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND | EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND ) { json!({ "ok": true, "spritesheetImageSrc": image_src, "spritesheetWidth": result_context.width, "spritesheetHeight": result_context.height, "iconImageSrcs": [], "sliceWarning": null, "prompt": result_context.prompt, "actualPrompt": result_context.actual_prompt, "model": result_context.model, "taskId": task_id, "priceMudPoints": result_context.price_mud_points, "spritesheetResource": &source_record.resource, "spritesheetAsset": &source_record.asset, "objectKey": object_key, "assetObjectId": asset_object_id, "warning": warning, }) } else { json!({ "taskId": task_id, "imageSrc": image_src, "objectKey": object_key, "assetObjectId": asset_object_id, "width": result_context.width, "height": result_context.height, "sourceType": "generated", "prompt": result_context.prompt, "actualPrompt": result_context.actual_prompt, "model": result_context.model, "resource": &source_record.resource, "asset": &source_record.asset, "warning": warning, }) } } pub(crate) async fn persist_editor_source_only_fallback_atomically( state: &AppState, caller: &EditorGenerationCaller, project_id: Option<&str>, completion: Option<&EditorCanvasGenerationCompletionRequest>, source_record: PreparedEditorGeneratedAssetRecord, task_id: &str, image_src: &str, object_key: &str, asset_object_id: &str, result_context: EditorSourceOnlyFallbackResultContext<'_>, current_warning: Option, dimension_warning: Option<&EditorGenerationWarningResponse>, ) -> Result< ( EditorGeneratedAssetRecord, Option, Option, ), AppError, > { let completed_at_micros = current_utc_micros(); let canvas_item = completion.and_then(|completion| { source_record.record.resource.as_ref().map(|resource| { let layer_id = generated_canvas_layer_id(resource.resource_id.as_str()); ( build_editor_canvas_resource_placement_item(completion, resource, layer_id.clone()), layer_id, ) }) }); let canvas_layout_plan = EditorCanvasLayoutPlan::generation( caller.owner_user_id.as_str(), project_id, completion, canvas_item .as_ref() .map(|(item, _)| item.clone()) .into_iter() .collect(), canvas_item.map(|(_, layer_id)| layer_id), ); let warning = merge_editor_generation_warnings( current_warning, Some(editor_postprocess_fallback_warning_with_dimension( "生成任务成功,后处理失败。", dimension_warning, )), ); let operation_kind = caller .operation .as_ref() .map(|operation| operation.operation_kind.as_str()) .unwrap_or_default(); let compact_result = build_editor_source_only_fallback_compact_result( operation_kind, &source_record.record, task_id, image_src, object_key, asset_object_id, result_context, warning.as_ref(), ); let job_result_payload_json = editor_generation_job_result_payload_json(caller, compact_result); let persisted = persist_editor_generation_result_atomically( state, caller, vec![source_record.item], canvas_layout_plan, job_result_payload_json, completed_at_micros, ) .await?; let item = persisted .items .into_iter() .find(|item| item.slot == EDITOR_PROVIDER_SOURCE_SLOT) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-generation-operation", "message": "角色原图降级提交缺少 provider-source 结果。", })) })?; let source_record = EditorGeneratedAssetRecord { resource: item .project_resource .map(editor_project_resource_payload_from_record), asset: item.asset.map(editor_asset_payload_from_record), }; let project = persisted.project.map(editor_project_payload_from_record); Ok((source_record, project, warning)) } pub(crate) 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(layout) = prepare_editor_canvas_generation_layout( state, owner_user_id, project_id, completion, items, primary_layer_id, ) .await? else { return Ok(None); }; let saved = state .spacetime_client() .save_editor_project_layout_v2(layout) .await .map_err(map_editor_project_error)?; Ok(Some(editor_project_payload_from_record(saved))) } pub(crate) async fn prepare_editor_canvas_generation_layout( 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); } Ok(Some(EditorProjectLayoutSaveV2RecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), viewport, layers_json: serialize_editor_layers(completion_result.layers)?, expected_revision, updated_at_micros: current_utc_micros(), })) } 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(layout) = prepare_editor_canvas_background_removal_layout( state, owner_user_id, project_id, target_layer_id, resource, replacement_title, recenter_replacement, ) .await? else { return Ok(None); }; let saved = state .spacetime_client() .save_editor_project_layout_v2(layout) .await .map_err(map_editor_project_error)?; Ok(Some(editor_project_payload_from_record(saved))) } #[allow(clippy::too_many_arguments)] async fn prepare_editor_canvas_background_removal_layout( 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); }; Ok(Some(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(), })) } 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()), ); 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; 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", "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, "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, "sourceResourceId": input.source_resource_id, "assetKind": input.asset_kind, "generationInputs": input.generation_inputs, }) } pub(crate) fn build_editor_canvas_resource_placement_item( completion: &EditorCanvasGenerationCompletionRequest, resource: &EditorProjectResourcePayload, layer_id: String, ) -> Value { let placeholder = &completion.placeholder; 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(placeholder.width, original_width); let height = positive_f64(placeholder.height, original_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": layer_id, "resourceId": resource.resource_id, "title": completion.title.trim(), "x": x, "y": y, "width": width, "height": height, "originalWidth": original_width, "originalHeight": original_height, }) } 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 } } pub(crate) fn generated_canvas_layer_id(resource_id: &str) -> String { format!("layer-editor-resource-{resource_id}") } 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) } pub(crate) 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) } pub(crate) 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() } pub(crate) 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 resolve_editor_image_generation_target_folder_id( asset_folder_id: Option, normalized_kind: Option<&str>, ) -> Option { asset_folder_id.or_else(|| { matches!(normalized_kind, Some("character")) .then(|| EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string()) }) } fn resolve_editor_ui_design_asset_extraction_target_folder_id( asset_folder_id: Option, ) -> Option { asset_folder_id.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())) } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct EditorBillableGenerationTarget { pub(crate) project_id: Option, pub(crate) asset_folder_id: Option, } pub(crate) async fn preflight_editor_billable_generation_target( state: &AppState, owner_user_id: &str, project_id: Option, asset_folder_id: Option, ) -> Result { let target = EditorBillableGenerationTarget { project_id: normalize_optional_string(project_id), asset_folder_id: normalize_generated_asset_folder_id(asset_folder_id, owner_user_id), }; state .spacetime_client() .preflight_editor_generation_target(EditorGenerationTargetPreflightRecordInput { owner_user_id: owner_user_id.trim().to_string(), project_id: target.project_id.clone(), asset_folder_id: target.asset_folder_id.clone(), }) .await .map_err(map_editor_project_error)?; Ok(target) } pub(crate) fn ensure_generic_editor_image_generation_contract( payload: &EditorImageGenerationRequest, ) -> Result<(), AppError> { if payload .kind .as_deref() .is_some_and(|kind| kind.trim() == "scene") || payload .asset_kind .as_deref() .is_some_and(|asset_kind| asset_kind.trim() == "scene") { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-generation", "message": "游戏场景必须通过 /api/editor/scenes/generations 提交结构化场景意图", })), ); } Ok(()) } 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()) } pub(crate) 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 metadata.as_object().is_some_and(serde_json::Map::is_empty) { 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 serialize_editor_generation_inputs( asset_kind: Option<&str>, metadata: Option, ) -> Result, AppError> { if asset_kind == Some(EDITOR_CHARACTER_ANIMATION_CONTRACT_KIND) && let Some(inputs) = metadata.as_ref().and_then(Value::as_object) { let legacy_runtime_keys = [ "characterAnimation", "frames", "previewVideoPath", "frameCount", "fps", "durationSeconds", "screenColorHex", ]; let rejected_keys = legacy_runtime_keys .into_iter() .filter(|key| inputs.contains_key(*key)) .collect::>(); if !rejected_keys.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", "message": "generationInputs 不能写入旧版角色动作运行元数据;请使用 imageSequenceFrames 和 imageSequenceDurationMs。", "fields": rejected_keys, })), ); } } serialize_editor_asset_metadata(metadata) } pub(crate) fn with_editor_media_duration_generation_input( metadata: Option, duration_seconds: u32, ) -> Option { if duration_seconds == 0 { return metadata; } let mut object = match metadata { Some(Value::Object(object)) => object, None => serde_json::Map::new(), Some(value) => return Some(value), }; let fields = object .entry("fields".to_string()) .or_insert_with(|| Value::Array(Vec::new())); let Some(fields) = fields.as_array_mut() else { return Some(Value::Object(object)); }; fields.retain(|field| { field .get("title") .and_then(Value::as_str) .map(str::trim) .is_none_or(|title| title != "时长" && title != "duration") }); fields.push(json!({ "title": "时长", "value": format!("{duration_seconds}秒"), })); object .entry("references".to_string()) .or_insert_with(|| Value::Array(Vec::new())); Some(Value::Object(object)) } pub(crate) fn serialize_editor_image_sequence_frames( frames: Option, ) -> Result, AppError> { let Some(frames) = frames else { return Ok(None); }; if !frames.is_array() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", "message": "图片序列帧必须是数组。", })), ); }; if frames .as_array() .is_some_and(|frames| frames.iter().any(|frame| frame.get("frameIndex").is_some())) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", "message": "图片序列帧不能包含旧版 frameIndex;数组位置是唯一播放顺序。", "fields": ["frameIndex"], })), ); } if frames.as_array().is_some_and(|frames| { frames.iter().any(|frame| { let Some(frame) = frame.as_object() else { return false; }; normalize_editor_object_key( frame .get("objectKey") .and_then(Value::as_str) .map(str::to_string), ) .is_none() || normalize_optional_string( frame .get("assetObjectId") .and_then(Value::as_str) .map(str::to_string), ) .is_none() }) }) { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", "message": "图片序列帧每帧必须同时提供 objectKey 和 assetObjectId;临时签名 URL 不能作为正式帧引用。", "fields": ["objectKey", "assetObjectId"], })), ); } let Some(frames) = normalize_editor_image_sequence_frames_value(&frames) else { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", "message": "图片序列帧至少需要两帧,且不能包含无效帧。", })), ); }; serialize_editor_asset_metadata(Some(frames)) } pub(crate) fn normalize_optional_string(value: Option) -> Option { value .map(|item| item.trim().to_string()) .filter(|item| !item.is_empty()) } pub(crate) 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() } pub(crate) 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 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") } /// 中文注释:`style="pixelArt"` 此前只驱动 provider 返回后的确定性像素规整,完全不参与提示词。 /// snapper 是几何对齐器——检测网格步长后按格重采样;provider 交一张柔和渐变图时两轴都测不到 /// 步长,生成路径用的 legacy profile 会退到 `min(w,h)/64` 统一网格,产出的是马赛克而不是像素 /// 画。这里在提示词端补一句,让 provider 本身就输出块状结构,snapper 从「抢救」变成「对齐」。 /// 实测只提「像素风格」效果已可接受,因此不叠加网格密度、色板和抗锯齿等约束。 /// /// 普通图片没有抠像底色,整幅画面都该像素化。 const EDITOR_PIXEL_ART_IMAGE_PROMPT: &str = "画面为像素风格"; /// 中文注释:角色形象生成后要用 bgfilter 按纯色抠像,绿幕底必须保持平整;同一段提示词里已经 /// 写死了「纯色背景必须平整无纹理、无渐变」和「禁止出现建筑、室内布景、风景、地面道具、漂浮 /// 物」,也就是画面里除角色本身没有别的内容。所以只点名角色,不说「画面」。 const EDITOR_PIXEL_ART_CHARACTER_PROMPT: &str = "角色主体为像素风格"; /// 中文注释:图标图集同样要抠绿幕底,但一张图里是多个彼此分离的图标素材,逐个点名避免模型 /// 只把其中一部分做成像素块。 const EDITOR_PIXEL_ART_ICON_SPRITESHEET_PROMPT: &str = "每个图标素材均为像素风格"; /// 中文注释:按链路命名而不是按「整幅画面 / 只有主体」这类抽象作用域分类——三条链路的差异 /// 不只是作用域,措辞本身还各自绑定各自提示词里已有的约束。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum EditorPixelArtPromptScope { Image, Character, IconSpritesheet, } /// 中文注释:追加在末尾而不是前置——风格约束靠后更容易被遵守,也不会和「生成游戏角色立绘」 /// 这类任务声明抢第一位。非 PixelArt 必须原样返回:普通图片路径的提示词就是用户原文。 pub(crate) fn apply_editor_pixel_art_style_prompt( prompt: String, style: EditorImageGenerationStyle, scope: EditorPixelArtPromptScope, ) -> String { if style != EditorImageGenerationStyle::PixelArt { return prompt; } let clause = match scope { EditorPixelArtPromptScope::Image => EDITOR_PIXEL_ART_IMAGE_PROMPT, EditorPixelArtPromptScope::Character => EDITOR_PIXEL_ART_CHARACTER_PROMPT, EditorPixelArtPromptScope::IconSpritesheet => EDITOR_PIXEL_ART_ICON_SPRITESHEET_PROMPT, }; format!("{prompt}\n{clause}") } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct EditorGenerationOptions { pub(crate) model: &'static str, pub(crate) aspect_ratio: &'static str, pub(crate) image_size: &'static str, // 中文注释:size 是模型无关的画布交付像素;provider_size 只用于满足 GPT 图片接口的合法像素矩阵。 pub(crate) size: String, pub(crate) provider_size: String, pub(crate) provider_image_size: &'static str, } pub(crate) struct PersistedEditorGeneratedImage { pub(crate) object_key: String, pub(crate) asset_object_id: String, } pub(crate) struct PreparedEditorGeneratedImage { pub(crate) object_key: String, pub(crate) asset_object: AssetObjectUpsertInput, } impl PreparedEditorGeneratedImage { pub(crate) fn asset_object_id(&self) -> &str { self.asset_object.asset_object_id.as_str() } } struct UploadedEditorGeneratedImageObject { object_key: String, asset_object: AssetObjectUpsertInput, } #[allow(clippy::too_many_arguments)] fn prepare_editor_generated_image_object_data( owner_user_id: &str, task_id: &str, image: GeneratedImageAssetDataUrl, asset_kind: &str, path_kind: &str, file_stem: &str, slot: &str, provider: &str, ) -> Result { 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:?}"), })) }) } pub(crate) struct PersistEditorProviderSourceResourceInput { pub(crate) project_id: Option, pub(crate) owner_user_id: String, pub(crate) folder_id: Option, pub(crate) label: String, pub(crate) width: u32, pub(crate) height: u32, pub(crate) prompt: String, pub(crate) actual_prompt: Option, pub(crate) model: String, pub(crate) task_id: String, pub(crate) source_resource_id: Option, pub(crate) asset_kind: Option, pub(crate) generation_inputs: Option, pub(crate) 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 } #[allow(clippy::too_many_arguments)] pub(crate) async fn prepare_editor_generated_image( state: &AppState, caller: &EditorGenerationCaller, 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 operation = caller.operation.as_ref().ok_or_else(|| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-generation-operation", "message": "准备编辑器生成对象时缺少稳定 operation 上下文。", })) })?; let uploaded = upload_editor_generated_image_object_data( state, caller.owner_user_id.as_str(), task_id, GeneratedImageAssetDataUrl { format: normalize_generated_image_asset_mime(image.mime_type.as_str()), bytes: image.bytes.clone(), }, prompt, asset_kind, path_kind, file_stem, slot, provider, Some(editor_generation_stable_asset_object_id( caller.owner_user_id.as_str(), operation.operation_kind.as_str(), operation.operation_id.as_str(), slot, )), ) .await?; Ok(PreparedEditorGeneratedImage { object_key: uploaded.object_key, asset_object: uploaded.asset_object, }) } 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 uploaded = upload_editor_generated_image_object_data( state, owner_user_id, task_id, image, prompt, asset_kind, path_kind, file_stem, slot, provider, None, ) .await?; let asset_object = state .spacetime_client() .confirm_asset_object(uploaded.asset_object) .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY) .with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) })?; Ok(PersistedEditorGeneratedImage { object_key: uploaded.object_key, asset_object_id: asset_object.asset_object_id, }) } #[allow(clippy::too_many_arguments)] async fn upload_editor_generated_image_object_data( state: &AppState, owner_user_id: &str, task_id: &str, image: GeneratedImageAssetDataUrl, prompt: &str, asset_kind: &str, path_kind: &str, file_stem: &str, slot: &str, provider: &str, asset_object_id: Option, ) -> Result { let prepared = prepare_editor_generated_image_object_data( owner_user_id, task_id, image, asset_kind, path_kind, file_stem, slot, provider, )?; upload_editor_generated_image_object_prepared( state, owner_user_id, task_id, prepared, prompt, asset_kind, asset_object_id, None, ) .await } async fn upload_editor_generated_image_object_prepared( state: &AppState, owner_user_id: &str, task_id: &str, prepared: GeneratedImageAssetPreparedPut, prompt: &str, asset_kind: &str, asset_object_id: Option, result_persistence_started: Option<&AtomicBool>, ) -> 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 persisted_mime_type = prepared.format.mime_type.clone(); // 中文注释:写路径此前也是每次新建 client,PUT 与 HEAD 都没有超时——请求可以在 // 上传阶段无限期挂住,而这一段发生在 CPU 处理之后,任何按处理预算派生的 deadline // 都已经不适用(余额多半为零,传进来只会把算完的结果丢掉)。这里的界只能来自 // 客户端级超时,与读路径共用同一个进程级客户端。 let http_client = state.editor_oss_http_client(); // 中文注释:标记边界就在这一行。上面两处失败(OSS 未配置、prepare_put_object)都发生在 // 第一次 PUT 之前,标了会让客户端对着什么都没落库的失败去核对素材库,是反向谎报;从 // PUT 开始(含 PUT 自身——响应丢失时字节可能已经落盘)到本函数返回,一律标记。 // 调用方拿到的是同一个 AppError,无法自行区分内部走到了哪一步,所以只能在这里标。 if let Some(result_persistence_started) = result_persistence_started { result_persistence_started.store(true, Ordering::Release); } let put_result = oss_client .put_object(http_client, prepared.request) .await .map_err(|error| { map_oss_error(error, "aliyun-oss") .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) })?; 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") .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) })?; let now_micros = current_utc_micros(); let asset_object = build_asset_object_upsert_input( asset_object_id.unwrap_or_else(|| 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(|error| { map_editor_asset_field_error(error) .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) })?; Ok(UploadedEditorGeneratedImageObject { object_key: head.object_key, asset_object, }) } pub(crate) 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 } pub(crate) 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, image_sequence_frames: None, image_sequence_duration_ms: None, }, ) .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 引用。"), })), ) } pub(crate) fn ensure_editor_reference_image_sources_are_stable( sources: Option<&[String]>, provider: &str, field: &str, label: &str, ) -> Result<(), AppError> { for source in sources.unwrap_or(&[]) { ensure_editor_reference_image_source_is_stable(source, provider, field, label)?; } Ok(()) } pub(crate) async fn parse_editor_reference_image( state: &AppState, owner_user_id: &str, source: &str, ) -> Result { let object_key = resolve_editor_reference_object_key_for_owner(state, owner_user_id, source).await?; read_editor_reference_image_object(state, object_key.as_str()).await } pub(crate) struct ResolvedEditorIconSpecReference { pub(crate) image: OpenAiReferenceImage, pub(crate) genre: Option, } pub(crate) struct EditorIconSpecReferenceMetadata { object_key: String, genre: Option, } async fn resolve_editor_reference_record_by_id_for_owner( state: &AppState, owner_user_id: &str, reference_id: &str, ) -> Result { let reference_id = reference_id.trim(); if reference_id.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-reference-image", "field": "referenceId", "message": "编辑器引用 ID 不能为空。", })), ); } let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; state .spacetime_client() .resolve_editor_reference(EditorReferenceResolveRecordInput { owner_user_id: owner_user_id.to_string(), bucket: oss_client.config_bucket().to_string(), reference_id: reference_id.to_string(), }) .await .map_err(map_editor_project_error) } pub(crate) async fn validate_editor_reference_id_for_owner( state: &AppState, owner_user_id: &str, reference_id: &str, ) -> Result<(), AppError> { let resolved = resolve_editor_reference_record_by_id_for_owner(state, owner_user_id, reference_id).await?; match resolved { EditorReferenceRecord::ProjectResource(resource) => resource.object_key, EditorReferenceRecord::Asset(asset) => asset.object_key, } .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": "已验证的编辑器引用缺少 object_key。", })) })?; Ok(()) } pub(crate) async fn resolve_editor_icon_spec_reference( state: &AppState, owner_user_id: &str, reference_id: &str, ) -> Result { let metadata = preflight_editor_icon_spec_reference_metadata(state, owner_user_id, reference_id).await?; let image = read_editor_reference_image_object(state, metadata.object_key.as_str()).await?; Ok(ResolvedEditorIconSpecReference { image, genre: metadata.genre, }) } pub(crate) async fn preflight_editor_icon_spec_reference_metadata( state: &AppState, owner_user_id: &str, reference_id: &str, ) -> Result { let reference_id = reference_id.trim(); if reference_id.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spritesheet", "field": "referenceId", "message": "图标素材规范引用不能为空。", })), ); } let resolved = resolve_editor_reference_record_by_id_for_owner(state, owner_user_id, reference_id).await?; let (object_key, generation_inputs, asset_kind) = match resolved { EditorReferenceRecord::ProjectResource(resource) => ( resource.object_key, resource.generation_inputs, resource.asset_kind, ), EditorReferenceRecord::Asset(asset) => { (asset.object_key, asset.generation_inputs, asset.asset_kind) } }; if asset_kind.as_deref().map(str::trim) != Some("icon-spec") { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spritesheet", "field": "referenceId", "message": "图标素材规范引用必须指向 icon-spec 资源或素材。", })), ); } let object_key = object_key.ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": "已验证的编辑器引用缺少 object_key。", })) })?; let genre = crate::editor_project_icon::game_genre_from_generation_inputs(generation_inputs.as_ref()) .map_err(|()| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": "图标规范引用保存了无效的游戏类型。", })) })?; Ok(EditorIconSpecReferenceMetadata { object_key, genre }) } 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 } pub(crate) fn normalize_editor_reference_image_sources(sources: Option<&[String]>) -> Vec<&str> { sources .unwrap_or(&[]) .iter() .map(String::as_str) .map(str::trim) .filter(|source| !source.is_empty()) .collect() } pub(crate) fn ensure_editor_reference_image_source_limit( sources: Option<&[String]>, limit: usize, provider: &str, field: &str, label: &str, ) -> Result<(), AppError> { let actual_count = sources .unwrap_or(&[]) .iter() .filter(|source| !source.trim().is_empty()) .count(); if actual_count <= limit { return Ok(()); } Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "field": field, "message": format!("{label}最多允许 {limit} 张,当前提交 {actual_count} 张。"), "actualCount": actual_count, "maxCount": limit, })), ) } pub(crate) fn build_editor_generation_reference_sources( primary: Option<(&str, &str)>, extras: Option<&[String]>, extra_title: &str, ) -> Vec<(String, String)> { let mut references = Vec::new(); if let Some((title, source)) = primary { if !source.trim().is_empty() { references.push((title.to_string(), source.trim().to_string())); } } references.extend( extras .unwrap_or(&[]) .iter() .map(String::as_str) .map(str::trim) .filter(|source| !source.is_empty()) .enumerate() .map(|(index, source)| (format!("{extra_title} {}", index + 1), source.to_string())), ); references } 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() } pub(crate) 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 { // 中文注释:默认调用方走进程级共享客户端。它自带 connect / total 超时,GET 不再 // 可能无界挂起,顺带复用连接池,避免每次读参考图都重新做一次 TLS 握手。需要按链路 // 单独调超时的调用方(如图集拆分)改走 `_with_client` 自带客户端。 read_editor_reference_image_object_with_client(state, source, state.editor_oss_http_client()) .await } pub(crate) 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, }) } // 中文注释:客户端级超时兜住所有调用方,但像素规整还要求下载本身计入 30s 处理预算。 // 否则预算只从下载完成后起算:请求可以先花掉客户端的 60s 上限,再持着完整图片去排 // CPU 许可的队,实际占用远超预算。这里用调用方给出的绝对 deadline 把整段 GET 收进 // 同一个预算,两层保护各司其职——客户端超时是兜底,deadline 是本次请求的真实上界。 async fn download_editor_persisted_image_object_within_deadline( state: &AppState, object_key: &str, processing_deadline: Instant, ) -> Result { tokio::time::timeout_at( tokio::time::Instant::from_std(processing_deadline), download_editor_persisted_image_object(state, object_key), ) .await .unwrap_or_else(|_| { Err(editor_pixel_art_snap_failure( StatusCode::GATEWAY_TIMEOUT, "读取待像素规整图片超时。", )) }) } 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("版本冲突") || message.contains("幂等") => { AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "editor-project", "message": message, })) } SpacetimeClientError::Procedure(message) if message.contains("不存在") => { AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ "provider": "editor-project", "message": message, })) } SpacetimeClientError::Procedure(message) => AppError::from_status(StatusCode::BAD_REQUEST) .with_details(json!({ "provider": "editor-project", "message": message, })), SpacetimeClientError::Runtime(message) => AppError::from_status(StatusCode::BAD_REQUEST) .with_details(json!({ "provider": "editor-project", "message": message, })), other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": other.to_string(), })), } } pub(crate) fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "asset-object", "message": error.to_string(), })) } pub(crate) fn current_utc_micros() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock should be after unix epoch"); i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } #[cfg(test)] mod tests { use super::*; use crate::{config::AppConfig, editor_green_screen::parse_editor_screen_background_color}; use std::{ io::{Read as _, Write as _}, net::{TcpListener, TcpStream}, sync::mpsc, thread, }; #[test] fn ordinary_image_prompt_validation_stays_outside_prompt_builder_execution() { assert!(validate_editor_image_generation_prompt("生成森林场景").is_ok()); assert!(validate_editor_image_generation_prompt(" \n\t ").is_err()); } #[test] fn editor_asset_payload_uses_formal_sequence_fields_only() { let stored_formal_frames = json!([ { "imageSrc": "https://example.invalid/frame01.png?Signature=temporary", "objectKey": "generated/formal/frame01.png", "assetObjectId": "asset-object-frame01", "width": 192, "height": 256 }, { "imageSrc": "https://example.invalid/frame02.png?Signature=temporary", "objectKey": "generated/formal/frame02.png", "assetObjectId": "asset-object-frame02", "width": 192, "height": 256 } ]); let formal_frames = json!([ { "imageSrc": "/generated/formal/frame01.png", "objectKey": "generated/formal/frame01.png", "assetObjectId": "asset-object-frame01", "width": 192, "height": 256 }, { "imageSrc": "/generated/formal/frame02.png", "objectKey": "generated/formal/frame02.png", "assetObjectId": "asset-object-frame02", "width": 192, "height": 256 } ]); let record = EditorAssetRecord { asset_id: "editor-asset-action".to_string(), folder_id: "project".to_string(), label: "角色动作".to_string(), asset_object_id: Some("asset-object-frame-1".to_string()), image_src: "/generated/formal/frame01.png".to_string(), object_key: Some("generated/formal/frame01.png".to_string()), width: 192, height: 256, source_type: "generated".to_string(), prompt: Some("挥手".to_string()), actual_prompt: None, model: Some("seedance2.0-fast".to_string()), provider: Some("Ark".to_string()), task_id: Some("task-action".to_string()), asset_kind: Some("character-animation".to_string()), generation_inputs: Some(json!({ "fields": [{ "title": "动作", "value": "挥手" }], "references": [] })), source_resource_id: Some("editor-resource-action".to_string()), public_showcase_enabled: Some(false), thumbnail_src: Some("/generated/formal/frame01.png".to_string()), generation_cost_mud_points: 0, showcase_id: None, showcase_review_status: None, showcase_display_enabled: None, showcase_like_count: None, created_at: "2026-07-28T00:00:00.000Z".to_string(), updated_at: "2026-07-28T00:00:00.000Z".to_string(), image_sequence_frames: Some(stored_formal_frames), image_sequence_duration_ms: Some(4_000), }; let payload = editor_asset_payload_from_record(record.clone()); assert_eq!(payload.asset_kind.as_deref(), Some("character-animation")); assert_eq!(payload.image_sequence_frames, Some(formal_frames.clone())); assert_eq!(payload.image_sequence_duration_ms, Some(4_000)); assert_eq!( payload.generation_inputs, Some(json!({ "fields": [{ "title": "动作", "value": "挥手" }], "references": [] })) ); let mut formal_mp4_record = record.clone(); formal_mp4_record.image_src = "/generated/formal/action.mp4".to_string(); formal_mp4_record.object_key = Some("generated/formal/action.mp4".to_string()); formal_mp4_record.generation_inputs = Some(json!({ "fields": [{ "title": "动作", "value": "挥手" }], "references": [] })); let formal_mp4 = editor_asset_payload_from_record(formal_mp4_record); assert_eq!( formal_mp4.asset_kind.as_deref(), Some("character-animation") ); assert_eq!( formal_mp4.image_sequence_frames, Some(formal_frames.clone()) ); assert_eq!(formal_mp4.image_sequence_duration_ms, Some(4_000)); let mut duration_only_record = record.clone(); duration_only_record.image_sequence_frames = None; let duration_only = editor_asset_payload_from_record(duration_only_record); assert!(duration_only.image_sequence_frames.is_none()); assert_eq!(duration_only.image_sequence_duration_ms, Some(4_000)); let mut frames_only_record = record.clone(); frames_only_record.image_sequence_duration_ms = None; let frames_only = editor_asset_payload_from_record(frames_only_record); assert_eq!(frames_only.image_sequence_frames, Some(formal_frames)); assert!(frames_only.image_sequence_duration_ms.is_none()); let unrelated_generation_inputs = json!({ "frames": [{ "name": "storyboard-frame" }], "fps": 24, "durationSeconds": 12, "purpose": "unrelated image metadata" }); let unrelated = editor_asset_payload_from_record(EditorAssetRecord { asset_kind: Some("image".to_string()), generation_inputs: Some(unrelated_generation_inputs.clone()), image_sequence_frames: None, image_sequence_duration_ms: None, ..record }); assert_eq!(unrelated.asset_kind.as_deref(), Some("image")); assert_eq!( unrelated.generation_inputs, Some(unrelated_generation_inputs) ); assert!(unrelated.image_sequence_frames.is_none()); assert!(unrelated.image_sequence_duration_ms.is_none()); } #[test] fn editor_project_resource_payload_uses_formal_sequence_fields_only() { let formal_frames = json!([ { "imageSrc": "/generated/formal/frame01.png", "objectKey": "generated/formal/frame01.png", "assetObjectId": "asset-object-frame01", "width": 192, "height": 256 }, { "imageSrc": "/generated/formal/frame02.png", "objectKey": "generated/formal/frame02.png", "assetObjectId": "asset-object-frame02", "width": 192, "height": 256 } ]); let record = EditorProjectResourceRecord { resource_id: "editor-resource-action".to_string(), project_id: "proj-action".to_string(), owner_user_id: "user-action".to_string(), asset_object_id: None, image_src: "/generated/formal/frame01.png".to_string(), object_key: None, width: 192, height: 256, source_type: "generated".to_string(), prompt: None, actual_prompt: None, model: None, provider: None, task_id: None, source_resource_id: None, asset_kind: Some("character-animation".to_string()), generation_inputs: Some(json!({ "fields": [], "references": [] })), public_showcase_enabled: false, created_at: "2026-07-28T00:00:00.000Z".to_string(), updated_at: "2026-07-28T00:00:00.000Z".to_string(), image_sequence_frames: None, image_sequence_duration_ms: Some(4_000), }; let duration_only = editor_project_resource_payload_from_record(record.clone()); assert!(duration_only.image_sequence_frames.is_none()); assert_eq!(duration_only.image_sequence_duration_ms, Some(4_000)); let mut formal_mp4_record = record.clone(); formal_mp4_record.image_src = "/generated/formal/action.mp4".to_string(); formal_mp4_record.object_key = Some("generated/formal/action.mp4".to_string()); formal_mp4_record.image_sequence_frames = Some(formal_frames.clone()); formal_mp4_record.generation_inputs = None; let formal_mp4 = editor_project_resource_payload_from_record(formal_mp4_record); assert_eq!( formal_mp4.asset_kind.as_deref(), Some("character-animation") ); assert_eq!( formal_mp4.image_sequence_frames, Some(formal_frames.clone()) ); assert_eq!(formal_mp4.image_sequence_duration_ms, Some(4_000)); let mut frames_only_record = record; frames_only_record.image_sequence_frames = Some(formal_frames.clone()); frames_only_record.image_sequence_duration_ms = None; let frames_only = editor_project_resource_payload_from_record(frames_only_record); assert_eq!(frames_only.image_sequence_frames, Some(formal_frames)); assert!(frames_only.image_sequence_duration_ms.is_none()); } 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, image_sequence_frames: None, image_sequence_duration_ms: 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 pixel_art_authorized_source_resource_skips_account_wide_lookup() { let resource = editor_project_resource_for_canvas_test("res-1", "character", 128, 128); let object_key = "generated-character-drafts/editor/res-1.png"; // sourceImageSrc 就是该 resourceId:无需任何 RPC 即可定位。 assert_eq!( resolve_editor_pixel_art_source_without_lookup( "user-1", "editor-project-1", "res-1", &resource, ) .expect("authorized resource should resolve"), Some(object_key.to_string()) ); // sourceImageSrc 已是同一个 objectKey:同样免 RPC。 assert_eq!( resolve_editor_pixel_art_source_without_lookup( "user-1", "editor-project-1", object_key, &resource, ) .expect("matching object key should resolve"), Some(object_key.to_string()) ); // 中文注释:两个字段指向不同图片必须直接报错,不能落回慢路径——慢路径会用 // sourceImageSrc 解析出的 key 继续,等于让 sourceResourceId 形同虚设。 let mismatched = resolve_editor_pixel_art_source_without_lookup( "user-1", "editor-project-1", "generated-character-drafts/editor/other.png", &resource, ) .expect_err("mismatched object key must fail"); assert_eq!(mismatched.status_code(), StatusCode::BAD_REQUEST); // sourceImageSrc 是别的注册 ID:无法免 RPC 判定,落回完整解析。 assert_eq!( resolve_editor_pixel_art_source_without_lookup( "user-1", "editor-project-1", "editor-asset-42", &resource, ) .expect("unknown registered id should fall back"), None ); } #[test] fn pixel_art_source_resource_ownership_is_asserted_before_skipping_lookup() { let resource = editor_project_resource_for_canvas_test("res-1", "character", 128, 128); // 中文注释:跳过全账号扫描的前提是这条断言,不能省。 for (owner, project) in [ ("other-user", "editor-project-1"), ("user-1", "other-project"), ] { let error = resolve_editor_pixel_art_source_without_lookup(owner, project, "res-1", &resource) .expect_err("foreign resource must be rejected"); assert_eq!(error.status_code(), StatusCode::FORBIDDEN); } } #[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 character_animation_canvas_completion_writes_placement_only() { let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-action".to_string()), title: "角色动作".to_string(), placeholder: EditorCanvasGenerationPlaceholderPayload { x: 120.0, y: 80.0, width: 420.0, height: 420.0, original_width: 512.0, original_height: 512.0, }, }; let mut resource = editor_project_resource_for_canvas_test( "resource-action", EDITOR_CHARACTER_ANIMATION_CONTRACT_KIND, 512, 512, ); resource.source_resource_id = Some("resource-preview".to_string()); resource.image_sequence_frames = Some(json!([ { "imageSrc": "/generated/action/frame01.png", "width": 512, "height": 512 }, { "imageSrc": "/generated/action/frame02.png", "width": 512, "height": 512 } ])); resource.image_sequence_duration_ms = Some(4_000); let item = build_editor_canvas_resource_placement_item( &completion, &resource, "layer-action".to_string(), ); assert_eq!(item["layerId"], json!("layer-action")); assert_eq!(item["resourceId"], json!("resource-action")); for field in [ "mediaType", "assetKind", "generationInputs", "imageSequenceFrames", "imageSequenceDurationMs", "previewVideoPath", "sourceResourceId", "src", ] { assert!( item.get(field).is_none(), "{field} must remain resource-owned" ); } } #[test] fn generated_media_canvas_item_keeps_duration_only_in_generation_inputs() { let generation_inputs = with_editor_media_duration_generation_input( Some(json!({ "fields": [ { "title": "视频描述", "value": "角色挥手" }, { "title": "duration", "value": "4秒" } ], "references": [] })), 5, ); let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-video".to_string()), title: "生成视频".to_string(), placeholder: EditorCanvasGenerationPlaceholderPayload { x: 120.0, y: 80.0, width: 560.0, height: 315.0, original_width: 1280.0, original_height: 720.0, }, }; let item = build_editor_canvas_generated_layer_item( &completion, EditorCanvasGeneratedLayerInput { layer_id: "layer-video".to_string(), resource_id: "resource-video".to_string(), title: "生成视频".to_string(), src: "/generated/video.mp4".to_string(), thumbnail_src: None, original_width: 1280, original_height: 720, display_width: 560.0, display_height: 315.0, source_type: "generated".to_string(), prompt: Some("角色挥手".to_string()), actual_prompt: None, model: Some("seedance2.0-fast".to_string()), provider: Some("VectorEngine".to_string()), task_id: Some("task-video".to_string()), object_key: Some("generated/video.mp4".to_string()), asset_object_id: Some("asset-object-video".to_string()), source_resource_id: None, asset_kind: Some("video".to_string()), generation_inputs, }, ); assert!(item.get("durationSeconds").is_none()); assert_eq!( item["generationInputs"]["fields"].as_array().map(Vec::len), Some(2) ); assert_eq!( item["generationInputs"]["fields"][1]["title"], json!("时长") ); assert_eq!(item["generationInputs"]["fields"][1]["value"], json!("5秒")); } #[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); assert!(is_editor_canvas_revision_conflict(&error)); } #[test] fn editor_canvas_revision_retry_rejects_other_conflict_kinds_and_providers() { for error in [ AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "editor-project", "message": "完美像素幂等冲突:dialog 指向的结果图层不存在", })), AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "editor-project", "message": "其它版本冲突", })), AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "spacetimedb", "message": "图片画布版本冲突:期望 revision 4,当前为 5", })), AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-project", "message": "图片画布版本冲突:期望 revision 4,当前为 5", })), ] { assert!(!is_editor_canvas_revision_conflict(&error)); } } #[test] fn atomic_editor_generation_canvas_retry_replays_one_prepared_commit_once() { let source = include_str!("editor_project.rs"); let body = source .split_once("pub(crate) async fn persist_editor_generation_result_atomically") .and_then(|(_, tail)| tail.split_once("fn is_editor_canvas_revision_conflict")) .map(|(body, _)| body) .expect("atomic editor generation persist helper should exist"); assert_eq!( body.matches("persist_editor_generation_prepared_commit(state, &") .count(), 2, "the helper must make one initial commit and at most one CAS retry", ); for expected in [ "canvas_layout_plan.prepare(state).await?", "let prepared_commit = EditorGenerationResultPersistRecordInput", "persist_editor_generation_prepared_commit(state, &prepared_commit)", "has_canvas_layout", "is_editor_canvas_revision_conflict(&error)", "let refreshed_layout = canvas_layout_plan.prepare(state).await?", "canvas_layout: refreshed_layout", "..prepared_commit", ] { assert!( body.contains(expected), "missing retry contract: {expected}" ); } assert_eq!( body.matches("get_external_generation_job(").count(), 0, "queue routing context must be captured before the summary projection loses it", ); assert!(!body.contains("first_layout_updated_at_micros")); } #[tokio::test] async fn atomic_editor_generation_unknown_result_replays_are_bounded() { let mut attempts = 0usize; let result = call_with_editor_generation_unknown_result_replay(|| { attempts += 1; let attempt = attempts; async move { if attempt == 1 { Err(SpacetimeClientError::ConnectDropped) } else { Ok("applied") } } }) .await .expect("the same prepared commit should be replayed after an unknown result"); assert_eq!(result, "applied"); assert_eq!(attempts, 2); let mut exhausted_attempts = 0usize; let exhausted = call_with_editor_generation_unknown_result_replay::<(), _, _>(|| { exhausted_attempts += 1; async { Err(SpacetimeClientError::ConnectDropped) } }) .await; assert!(matches!( exhausted, Err(SpacetimeClientError::ConnectDropped) )); assert_eq!( exhausted_attempts, MAX_EDITOR_GENERATION_UNKNOWN_RESULT_REPLAYS + 1 ); } #[tokio::test] async fn atomic_editor_generation_domain_errors_are_not_replayed() { let mut attempts = 0usize; let result = call_with_editor_generation_unknown_result_replay::<(), _, _>(|| { attempts += 1; async { Err(SpacetimeClientError::Procedure( "图片画布版本冲突:期望 revision 4,当前为 5".to_string(), )) } }) .await; assert!(matches!(result, Err(SpacetimeClientError::Procedure(_)))); assert_eq!(attempts, 1); } #[test] fn atomic_editor_generation_unknown_outcome_requires_post_dispatch_ambiguity() { assert!(!is_editor_generation_unknown_result_error( &SpacetimeClientError::Build("invalid config".to_string()), false, )); for stage in [ SpacetimeClientStage::PoolAcquire, SpacetimeClientStage::ConnectBuild, SpacetimeClientStage::ConnectHandshake, ] { assert!(!is_editor_generation_unknown_result_error( &SpacetimeClientError::Timeout(stage), true, )); } assert!(!is_editor_generation_unknown_result_error( &SpacetimeClientError::Procedure("rejected".to_string()), true, )); assert!(is_editor_generation_unknown_result_error( &SpacetimeClientError::ConnectDropped, true, )); assert!(is_editor_generation_unknown_result_error( &SpacetimeClientError::Timeout(SpacetimeClientStage::ProcedureResult), true, )); assert!(is_editor_generation_unknown_result_error( &SpacetimeClientError::Runtime("malformed response".to_string()), true, )); } #[test] fn all_atomic_editor_generation_commit_sites_delegate_canvas_retry_to_shared_helper() { let editor_source = include_str!("editor_project.rs"); let icon_source = include_str!("editor_project_icon.rs"); let character_source = include_str!("character_animation_assets.rs"); let audio_source = include_str!("vector_engine_audio_generation/generation.rs"); for (source, start, end) in [ ( editor_source, "pub(crate) async fn generate_editor_image_for_owner", "fn editor_image_generation_billing_asset_kind", ), ( editor_source, "pub(crate) async fn edit_editor_image_for_owner", "pub async fn remove_editor_image_background", ), ( editor_source, "pub(crate) async fn remove_editor_image_background_for_owner", "fn validate_editor_pixel_art_snap_canvas_completion", ), ( icon_source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "fn resolve_editor_icon_spritesheet_source_layer_asset_kind", ), ( editor_source, "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "async fn repair_editor_project_record_inline_media", ), ( editor_source, "async fn persist_editor_source_only_fallback_atomically", "async fn complete_editor_source_only_fallback", ), ( character_source, "pub(crate) async fn generate_editor_character_animation_for_owner", "pub async fn generate_editor_video", ), ( character_source, "pub(crate) async fn generate_editor_video_for_owner", "pub async fn get_character_animation_job", ), ( audio_source, "async fn persist_editor_audio_generation", "fn build_editor_audio_job_result_payload", ), ] { let body = source .split_once(start) .and_then(|(_, tail)| tail.split_once(end)) .map(|(body, _)| body) .unwrap_or_else(|| panic!("missing atomic generation commit site: {start}")); assert!( body.contains("EditorCanvasLayoutPlan::"), "{start} must pass a replayable canvas plan", ); assert!( body.contains("persist_editor_generation_result_atomically("), "{start} must use the shared atomic persist helper", ); assert!( !body.contains("prepare_editor_canvas_generation_layout("), "{start} must not prepare canvas outside the retrying helper", ); assert!( !body.contains("prepare_editor_canvas_background_removal_layout("), "{start} must not prepare background replacement outside the retrying helper", ); } assert!( !audio_source.contains("is_editor_audio_canvas_revision_conflict"), "audio must not keep its former broad conflict classifier", ); } #[test] fn editor_project_idempotency_conflicts_map_to_http_conflict() { for message in [ "完美像素 asset_object 幂等键已被其他内容占用", "完美像素幂等冲突:dialog 指向的结果图层不存在", ] { let error = map_editor_project_error(SpacetimeClientError::Procedure(message.to_string())); assert_eq!(error.status_code(), StatusCode::CONFLICT); } let missing_source = map_editor_project_error(SpacetimeClientError::Procedure( "完美像素来源项目资源不存在".to_string(), )); assert_eq!(missing_source.status_code(), StatusCode::NOT_FOUND); let invalid_fingerprint = map_editor_project_error(SpacetimeClientError::Procedure( "完美像素 operation_fingerprint 必须是 64 位小写十六进制".to_string(), )); assert_eq!(invalid_fingerprint.status_code(), StatusCode::BAD_REQUEST); } #[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_persists_reusable_hex_with_generation_inputs() { let decision = manual_screen_background_decision("#B0C2E0"); 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": "红发骑士" }]), ); assert_eq!( serialize_editor_asset_metadata(Some(updated)) .expect("generation inputs should serialize") .as_deref() .and_then(|payload| serde_json::from_str::(payload).ok()) .and_then(|payload| payload["screenColorHex"].as_str().map(str::to_string)), Some("#B0C2E0".to_string()), ); } #[test] fn screen_background_decision_leaves_existing_visible_fields_untouched() { let decision = manual_screen_background_decision("#CFEFFF"); // 即使旧数据的 fields 里残留了抠图背景色,后端也不修改用户可见字段。 let updated = apply_editor_screen_background_decision_to_generation_inputs( Some(json!({ "fields": [{ "title": "抠图背景色", "value": "旧值" }], "references": [], })), Some(&decision), ) .expect("generation inputs should be preserved"); assert_eq!( updated["fields"], json!([{ "title": "抠图背景色", "value": "旧值" }]), ); assert_eq!(updated["screenColorHex"], json!("#CFEFFF")); } fn apply_editor_canvas_generation_completion( layers: Value, completion: &EditorCanvasGenerationCompletionRequest, resource: &EditorProjectResourcePayload, ) -> Result { let placeholder = resolve_canvas_completion_placeholder(layers.clone(), completion)? .unwrap_or_else(|| completion.placeholder.clone()); let layer_id = generated_canvas_layer_id(resource.resource_id.as_str()); let item = build_generated_canvas_layer_item( completion, &placeholder, resource, layer_id.as_str(), 0, ); apply_editor_canvas_generation_items( layers, completion, placeholder, vec![item], Some(layer_id), ) } fn apply_editor_canvas_generation_multi_items( layers: Value, completion: &EditorCanvasGenerationCompletionRequest, items: Vec, primary_layer_id: Option, ) -> Result { let placeholder = resolve_canvas_completion_placeholder(layers.clone(), completion)? .unwrap_or_else(|| completion.placeholder.clone()); let items = translate_editor_canvas_generation_items(items, &completion.placeholder, &placeholder); apply_editor_canvas_generation_items( layers, completion, placeholder, items, primary_layer_id, ) } #[test] fn editor_project_id_prefix_uses_proj_for_new_ids() { assert_eq!(EDITOR_PROJECT_ID_PREFIX, "proj-"); } #[test] fn editor_record_media_src_prefers_object_key_over_legacy_inline_media() { assert_eq!( normalize_editor_record_media_src( "data:image/png;base64,too-large".to_string(), Some("/generated-character-drafts/editor/character.png") ), "/generated-character-drafts/editor/character.png" ); } #[test] fn editor_record_media_src_drops_legacy_inline_media_without_object_key() { assert_eq!( normalize_editor_record_media_src("data:image/png;base64,too-large".to_string(), None), "" ); assert_eq!( normalize_editor_record_media_src("blob:http://localhost/preview".to_string(), None), "" ); } fn test_editor_showcase_asset_record(owner_user_id: &str) -> EditorShowcaseAssetRecord { EditorShowcaseAssetRecord { showcase_id: "showcase-1".to_string(), asset_id: "asset-1".to_string(), owner_user_id: owner_user_id.to_string(), label: "精选素材".to_string(), asset_object_id: None, image_src: "/generated-character-drafts/editor/spec.png".to_string(), object_key: None, width: 512, height: 512, source_type: "generated".to_string(), prompt: None, actual_prompt: None, model: None, provider: None, task_id: None, source_resource_id: None, asset_kind: None, generation_inputs: None, thumbnail_src: None, generation_cost_mud_points: 10, refund_mud_points: 5, review_status: "approved".to_string(), display_enabled: true, like_count: 0, asset_deleted_while_pending: false, reviewed_by_admin_user_id: Some("admin-1".to_string()), review_note: None, refund_ledger_id: Some("editor-showcase-refund:showcase-1".to_string()), refund_completed_at: Some("2026-06-23T00:00:00.000Z".to_string()), submitted_at: "2026-06-23T00:00:00.000Z".to_string(), reviewed_at: Some("2026-06-23T00:00:00.000Z".to_string()), approved_at: Some("2026-06-23T00:00:00.000Z".to_string()), rejected_at: None, updated_at: "2026-06-23T00:00:00.000Z".to_string(), showcase_category: Some("characters".to_string()), image_sequence_frames: None, image_sequence_duration_ms: None, } } 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(), image_sequence_frames: None, image_sequence_duration_ms: None, } } 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(), image_sequence_frames: None, image_sequence_duration_ms: None, } } #[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, image_sequence_frames: None, image_sequence_duration_ms: 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 untrusted_generation_inputs_drop_client_claimed_references() { let sanitized = sanitize_editor_untrusted_generation_inputs(Some(json!({ "fields": [{"title": "角色设定", "value": "红发骑士"}], "references": [{ "title": "伪造引用", "label": "其他用户素材", "refType": "asset", "refId": "asset-forged" }] }))) .expect("visible fields should stay present"); assert_eq!( sanitized["fields"], json!([{"title": "角色设定", "value": "红发骑士"}]) ); assert!(sanitized.get("references").is_none()); } #[test] fn generation_reference_provenance_comes_from_matching_owner_records() { let resource = test_editor_project_resource_record( "resource-1", "project-1", "generated-character-drafts/editor/shared.png", Some("gpt-image-2"), None, ); let projects = vec![test_editor_project_record("project-1", vec![resource])]; let asset = test_editor_asset_record( "asset-1", "generated-character-drafts/editor/asset.png", Some("gpt-image-2"), None, ); let resource_reference = editor_generation_reference_from_records( projects.as_slice(), std::slice::from_ref(&asset), "参考图 1".to_string(), "generated-character-drafts/editor/shared.png", ) .expect("owned resource object key should produce provenance"); assert_eq!(resource_reference["refType"], "project-resource"); assert_eq!(resource_reference["refId"], "resource-1"); let asset_reference = editor_generation_reference_from_records( projects.as_slice(), std::slice::from_ref(&asset), "参考图 2".to_string(), "asset-1", ) .expect("owned asset id should produce provenance"); assert_eq!(asset_reference["refType"], "asset"); assert_eq!(asset_reference["refId"], "asset-1"); assert!( editor_generation_reference_from_records( projects.as_slice(), std::slice::from_ref(&asset), "参考图 3".to_string(), "asset-forged", ) .is_none() ); } #[test] fn perfect_pixel_replaces_client_references_with_authoritative_source() { let untrusted = sanitize_editor_untrusted_generation_inputs(Some(json!({ "fields": [{"title": "处理", "value": "完美像素"}], "references": [{ "title": "伪造跨账号引用", "label": "其他账号素材", "refType": "asset", "refId": "asset-other-owner" }] }))); let rebuilt = rebuild_editor_generation_inputs_with_authoritative_references( untrusted, vec![json!({ "title": "原图", "label": "当前项目资源", "refType": "project-resource", "refId": "resource-owned" })], ) .expect("perfect pixel metadata should be rebuilt"); assert_eq!( rebuilt["references"], json!([{ "title": "原图", "label": "当前项目资源", "refType": "project-resource", "refId": "resource-owned" }]) ); assert!(!rebuilt.to_string().contains("asset-other-owner")); } #[test] fn perfect_pixel_replay_uses_existing_server_metadata_for_exact_compare() { let authoritative = Some(json!({ "fields": [], "references": [{"refType": "project-resource", "refId": "resource-owned"}] })); let historical = Some(json!({ "fields": [], "references": [{"refType": "asset", "refId": "legacy-client-value"}] })); assert_eq!( resolve_editor_pixel_art_persisted_generation_inputs( authoritative, Some(historical.clone()), ), historical ); assert_eq!( resolve_editor_pixel_art_persisted_generation_inputs(None, Some(None)), None ); } #[test] fn editor_reference_limit_rejects_overflow_instead_of_truncating() { let sources = (1..=6) .map(|index| format!("generated-character-drafts/editor/{index}.png")) .collect::>(); let error = ensure_editor_reference_image_source_limit( Some(sources.as_slice()), 5, "editor-image-generation", "referenceImageSrcs", "生成参考图", ) .expect_err("the sixth reference must be rejected"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert_eq!( error .details() .and_then(|details| details.get("actualCount")), Some(&json!(6)) ); assert_eq!( error.details().and_then(|details| details.get("maxCount")), Some(&json!(5)) ); } #[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_showcase_payload_uses_formal_sequence_metadata_only() { let formal_frames = json!([ { "imageSrc": "/generated/formal/frame-0001.png", "objectKey": "generated/formal/frame-0001.png", "assetObjectId": "asset-object-frame-0001", "width": 192, "height": 256 }, { "imageSrc": "/generated/formal/frame-0002.png", "objectKey": "generated/formal/frame-0002.png", "assetObjectId": "asset-object-frame-0002", "width": 192, "height": 256 } ]); let mut record = test_editor_showcase_asset_record("user-author-1"); record.image_src = "/generated/formal/action.mp4".to_string(); record.object_key = Some("generated/formal/action.mp4".to_string()); record.asset_kind = Some("character-animation".to_string()); record.image_sequence_frames = Some(formal_frames); record.image_sequence_duration_ms = Some(6_000); record.generation_inputs = Some(json!({ "fields": [{ "title": "动作", "value": "挥手" }] })); let payload = editor_project_resource_payload_from_showcase_record(record); assert_eq!(payload.asset_kind.as_deref(), Some("character-animation")); assert_eq!(payload.image_sequence_duration_ms, Some(6_000)); assert_eq!( payload .image_sequence_frames .as_ref() .and_then(Value::as_array) .and_then(|frames| frames.first()) .and_then(|frame| frame.get("imageSrc")), Some(&json!("/generated/formal/frame-0001.png")) ); assert_eq!( payload.generation_inputs, Some(json!({ "fields": [{ "title": "动作", "value": "挥手" }] })) ); } #[test] fn editor_showcase_payload_does_not_backfill_missing_formal_sequence_fields() { let formal_frames = json!([ { "imageSrc": "/generated/formal/frame-0001.png", "objectKey": "generated/formal/frame-0001.png", "assetObjectId": "asset-object-frame-0001", "width": 192, "height": 256 }, { "imageSrc": "/generated/formal/frame-0002.png", "objectKey": "generated/formal/frame-0002.png", "assetObjectId": "asset-object-frame-0002", "width": 192, "height": 256 } ]); let legacy_generation_inputs = json!({ "fields": [{ "title": "动作", "value": "待机" }], "characterAnimation": { "frames": [ { "imageSrc": "/generated/legacy/frame-0001.png", "width": 64, "height": 64 }, { "imageSrc": "/generated/legacy/frame-0002.png", "width": 64, "height": 64 } ], "durationSeconds": 9 } }); let mut duration_only_record = test_editor_showcase_asset_record("user-author-1"); duration_only_record.asset_kind = Some("character-animation".to_string()); duration_only_record.generation_inputs = Some(legacy_generation_inputs.clone()); duration_only_record.image_sequence_duration_ms = Some(5_000); let duration_only = editor_project_resource_payload_from_showcase_record(duration_only_record); assert_eq!(duration_only.image_sequence_duration_ms, Some(5_000)); assert!(duration_only.image_sequence_frames.is_none()); let mut frames_only_record = test_editor_showcase_asset_record("user-author-1"); frames_only_record.asset_kind = Some("character-animation".to_string()); frames_only_record.generation_inputs = Some(legacy_generation_inputs); frames_only_record.image_sequence_frames = Some(formal_frames.clone()); let frames_only = editor_project_resource_payload_from_showcase_record(frames_only_record); assert_eq!(frames_only.image_sequence_frames, Some(formal_frames)); assert!(frames_only.image_sequence_duration_ms.is_none()); } #[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, image_sequence_frames: None, image_sequence_duration_ms: 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", "mediaType": "image-sequence", "src": "data:image/png;base64,layer", "thumbnailSrc": "data:image/png;base64,thumbnail", "imageSequenceFrames": [ { "imageSrc": "data:image/png;base64,frame" } ], "generationInputs": { "references": [ { "resourceId": "resource-1", "mediaType": "image", "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") ); assert!(sanitized[0].get("mediaType").is_none()); assert_eq!( sanitized[0]["generationInputs"]["references"][0]["mediaType"], json!("image") ); } #[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_payload_sanitizer_refills_layer_source_type_from_resource() { // 中文注释:结构化保存校验完 sourceType 后会把图层列置空,布局读回时整个键都不存在, // 客户端只能猜一个默认值再原样回写,下一次保存就被判成「sourceType 与项目资源不一致」。 // 读边界必须按图层自己声明的 resourceId 回填权威值,口径与 objectKey / assetObjectId 一致。 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, image_sequence_frames: None, image_sequence_duration_ms: 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" }, { "layerId": "layer-local", "resourceId": "local-resource-sequence", "sourceType": "generated" } ]), resources.as_slice(), ); assert_eq!(sanitized[0]["sourceType"], json!("generated")); // 缺资源行的 legacy 本地序列取不到映射,保持它自带的值不动。 assert_eq!(sanitized[1]["sourceType"], json!("generated")); } #[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, image_sequence_frames: None, image_sequence_duration_ms: 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, image_sequence_frames: None, image_sequence_duration_ms: 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": [ { "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": [ { "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 action_generation_inputs_reject_legacy_runtime_fields_only_for_actions() { for (field, value) in [ ("characterAnimation", json!({ "frames": [] })), ("frames", json!([])), ("previewVideoPath", json!("/generated/action/preview.mp4")), ("frameCount", json!(32)), ("fps", json!(8)), ("durationSeconds", json!(4)), ("screenColorHex", json!("#00ff00")), ] { let mut metadata = serde_json::Map::new(); metadata.insert("fields".to_string(), json!([])); metadata.insert(field.to_string(), value); let metadata = Value::Object(metadata); let error = serialize_editor_generation_inputs( Some(EDITOR_CHARACTER_ANIMATION_CONTRACT_KIND), Some(metadata.clone()), ) .expect_err("legacy animation runtime metadata must fail explicitly"); assert!(error.body_text().contains("imageSequenceFrames")); assert!(error.body_text().contains("imageSequenceDurationMs")); assert!(serialize_editor_generation_inputs(Some("image"), Some(metadata)).is_ok()); } } #[test] fn editor_sequence_frame_serialization_rejects_frame_index() { let error = serialize_editor_image_sequence_frames(Some(json!([ { "frameIndex": 99, "imageSrc": "/generated/action/frame01.png", "objectKey": "generated/action/frame01.png", "width": 192, "height": 256 }, { "frameIndex": 1, "imageSrc": "/generated/action/frame02.png", "assetObjectId": "asset-object-frame02", "width": 192, "height": 256 } ]))) .expect_err("formal sequence must use array order without frameIndex"); assert!(error.body_text().contains("frameIndex")); } #[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("预算已耗尽,已保留原始生成结果") ); } #[tokio::test] async fn pixel_art_strict_expired_budget_returns_error_without_fallback_wording() { let original = Arc::new(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 error = snap_editor_pixel_art_strict(original, Some(expired)) .await .expect_err("strict pixel snap should surface exhausted budgets"); assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); assert!(error.body_text().contains("预算已耗尽")); assert!(!error.body_text().contains("已保留原始生成结果")); } #[tokio::test] async fn pixel_art_strict_rejects_only_when_both_legacy_axes_are_undetected() { let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( 128, 128, image::Rgba([20, 30, 40, 255]), )); let mut bytes = Cursor::new(Vec::new()); image .write_to(&mut bytes, image::ImageFormat::Png) .expect("test PNG should encode"); let source = DownloadedOpenAiImage { bytes: bytes.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let strict_error = snap_editor_pixel_art_strict(Arc::new(source.clone()), None) .await .expect_err("strict action should reject the legacy uniform-grid fallback"); let (legacy_output, legacy_error) = snap_editor_pixel_art_or_original(source.clone(), None).await; assert_eq!(strict_error.status_code(), StatusCode::UNPROCESSABLE_ENTITY); assert!(strict_error.body_text().contains("未识别到")); assert!(legacy_error.is_none()); let legacy_output = image::load_from_memory(legacy_output.bytes.as_slice()) .expect("legacy output should remain a valid image"); assert_eq!((legacy_output.width(), legacy_output.height()), (128, 128)); } #[test] fn pixel_art_best_effort_warning_contract_stays_stable() { let warning = editor_pixel_art_fallback_warning(); assert_eq!(warning.code, EDITOR_GENERATION_POSTPROCESS_WARNING_CODE); assert_eq!(warning.reason, "像素规整未完成,已保留原始生成结果。"); } #[test] fn pixel_art_snap_queue_guard_is_bounded_and_released_on_drop() { assert_eq!(EDITOR_PIXEL_ART_SNAP_MAX_CONCURRENCY, 4); assert_eq!(EDITOR_PIXEL_ART_SNAP_MAX_QUEUE_DEPTH, 2048); let depth = AtomicUsize::new(0); assert!(try_enter_bounded_queue(&depth, 2)); assert!(try_enter_bounded_queue(&depth, 2)); // 中文注释:满了必须拒绝,且拒绝时不得把计数推过上限。 assert!(!try_enter_bounded_queue(&depth, 2)); assert_eq!(depth.load(Ordering::Acquire), 2); // 上限为 0 时任何进入都必须失败。 let closed = AtomicUsize::new(0); assert!(!try_enter_bounded_queue(&closed, 0)); assert_eq!(closed.load(Ordering::Acquire), 0); } #[test] fn pixel_art_snap_queue_depth_returns_to_zero_after_guards_drop() { // 中文注释:递减写在 Drop 里,被取消的等待者也必须归还名额;否则计数只增不减, // 队列会永久「满」,接口彻底不可用。 let before = EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.load(Ordering::Acquire); { let _first = EditorPixelArtSnapQueueGuard::try_enter().expect("queue should accept"); let _second = EditorPixelArtSnapQueueGuard::try_enter().expect("queue should accept"); assert_eq!( EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.load(Ordering::Acquire), before + 2 ); } assert_eq!( EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.load(Ordering::Acquire), before ); } #[tokio::test] async fn pixel_art_snap_permit_reports_exhausted_budget_without_waiting() { let expired = Instant::now() - Duration::from_secs(1); let error = acquire_editor_pixel_art_snap_permit(expired) .await .expect_err("expired budget should not acquire a permit"); assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); } #[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 pixel_art_snap_contended_paths_carry_their_documented_responses() { use axum::response::IntoResponse; // 中文注释:队列满必须是 503 且带 retry-after——客户端与网关据此决定退避,删掉这个头 // 会让重试立刻打回来,把保险丝变成放大器。此前 `retry-after` 在整个 crate 里只出现在 // 生产代码一处,零断言。 let queue_full = editor_pixel_art_snap_queue_full_error(); assert_eq!(queue_full.status_code(), StatusCode::SERVICE_UNAVAILABLE); assert!(queue_full.body_text().contains("完美像素排队已满")); let queue_full_response = queue_full.into_response(); assert_eq!( queue_full_response.headers().get("retry-after"), Some(&HeaderValue::from_static("1")), "queue-full responses must tell the caller when to retry" ); // 中文注释:等待槽位超时是 504 而不是 503——预算耗尽属于超时语义,错成 503 会让客户端 // 把「这次来不及了」当成「服务暂时不可用」而立刻重试。 let wait_timeout = editor_pixel_art_snap_wait_timeout_error(); assert_eq!(wait_timeout.status_code(), StatusCode::GATEWAY_TIMEOUT); assert!(wait_timeout.body_text().contains("预算已耗尽")); // 中文注释:信号量被关闭是服务端自身不可用,503;同时必须把底层错误带进文案,否则 // 这条实践中极难复现的路径在排障时只剩一句无信息的通用文案。 let unavailable = editor_pixel_art_snap_limiter_unavailable_error("semaphore closed"); assert_eq!(unavailable.status_code(), StatusCode::SERVICE_UNAVAILABLE); assert!(unavailable.body_text().contains("完美像素并发门限不可用")); assert!(unavailable.body_text().contains("semaphore closed")); } #[test] fn pixel_art_snapper_errors_map_to_their_documented_status_codes() { // 中文注释:四个状态码分档此前只有 GridNotDetected 被间接覆盖。分档错位的后果是双向的: // 把用户上传的坏图(Decode)报成 500 会让客户端当作服务端故障去重试,把服务端自身失败 // (Encode / Processing)报成 400 又会让用户以为是自己的输入有问题。 for (error, expected_status) in [ ( platform_image::PixelArtSnapError::InvalidInput("空输入".to_string()), StatusCode::BAD_REQUEST, ), ( platform_image::PixelArtSnapError::Decode { input: "source", message: "corrupt png".to_string(), }, StatusCode::BAD_REQUEST, ), ( platform_image::PixelArtSnapError::GridNotDetected, StatusCode::UNPROCESSABLE_ENTITY, ), ( platform_image::PixelArtSnapError::DeadlineExceeded { stage: "输入解码" }, StatusCode::GATEWAY_TIMEOUT, ), ( platform_image::PixelArtSnapError::Encode("encode failed".to_string()), StatusCode::INTERNAL_SERVER_ERROR, ), ( platform_image::PixelArtSnapError::Processing("resize failed".to_string()), StatusCode::INTERNAL_SERVER_ERROR, ), ] { let rendered = error.to_string(); let mapped = map_editor_pixel_art_snapper_error(error); assert_eq!( mapped.status_code(), expected_status, "unexpected status for {rendered}" ); // 中文注释:底层文案必须原样带上。丢掉它,客户端只会看到一句通用失败, // 「识别不到网格」和「解码失败」在用户侧变得无法区分。 assert!( mapped.body_text().contains(rendered.as_str()), "mapped error should carry the snapper message: {rendered}" ); } } #[test] fn pixel_art_server_worst_case_fits_inside_the_client_timeout() { // 中文注释:这条不变式是跨端的,钉住它才能防止任一侧被单独调大。客户端 // `snapEditorImageToPixelArt` 配的是 120 秒;服务端最坏合法时长是处理预算加持久化 // 预算之和——处理段(准入、归属校验、下载、规整)由 processing_deadline 封顶, // 持久化段独立起算由 persistence_deadline 封顶。两者相加必须真小于客户端超时, // 否则客户端会在服务端仍在合法工作时先 abort:对账采样到仍在途的操作,占位还在、 // 素材库还空,用户照提示核对却什么也看不到,重试就造出孤儿 OSS 对象。 const CLIENT_TIMEOUT: Duration = Duration::from_secs(120); let server_worst_case = EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION + EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION; assert_eq!(server_worst_case, Duration::from_secs(90)); assert!( server_worst_case < CLIENT_TIMEOUT, "server worst case {server_worst_case:?} must stay under the client timeout {CLIENT_TIMEOUT:?}" ); // 中文注释:余量不能只是「小于」。网络往返、代理缓冲和客户端计时精度都要吃掉一部分, // 贴着上限等于没有余量。 assert!( CLIENT_TIMEOUT - server_worst_case >= Duration::from_secs(30), "client timeout should keep at least 30s of headroom over the server worst case" ); } #[test] fn explicit_pixel_art_snap_contract_preserves_source_metadata() { let request: EditorPixelArtSnapRequest = serde_json::from_value(json!({ "sourceImageSrc": "editor-resource-source", "projectId": "proj-source", "sourceResourceId": "editor-resource-source", "assetKind": "character", "generationInputs": { "fields": [{ "title": "角色", "value": "陶罐精灵" }], "references": [], }, "assetFolderId": "project", "assetLabel": "像素角色", "canvasCompletion": { "dialogId": "dialog-pixel-art", "title": "完美像素", "placeholder": { "x": 10.0, "y": 20.0, "width": 128.0, "height": 128.0, "originalWidth": 128.0, "originalHeight": 128.0 } } })) .expect("pixel-art snap request should deserialize"); assert_eq!(request.project_id, "proj-source"); assert_eq!( request.source_resource_id.as_deref(), Some("editor-resource-source") ); assert_eq!(request.asset_kind.as_deref(), Some("character")); assert_eq!( request .generation_inputs .as_ref() .and_then(|value| value["fields"][0]["value"].as_str()), Some("陶罐精灵") ); assert_eq!( request.canvas_completion.dialog_id.as_deref(), Some("dialog-pixel-art") ); } #[test] fn explicit_pixel_art_snap_rejects_non_static_or_forged_asset_kinds() { assert_eq!( resolve_editor_pixel_art_snap_asset_kind( Some("character"), Some(Some("character")), &[], &[], ) .expect("matching source kind should be preserved") .as_deref(), Some("character") ); assert_eq!( resolve_editor_pixel_art_snap_asset_kind(Some("character"), Some(None), &[], &[]) .expect("legacy source without a kind may adopt a validated static kind") .as_deref(), Some("character") ); let mismatch = resolve_editor_pixel_art_snap_asset_kind( Some("image"), Some(Some("character")), &[], &[], ) .expect_err("request kind must not override authoritative source metadata"); assert_eq!(mismatch.status_code(), StatusCode::BAD_REQUEST); for asset_kind in [ "video", "editor_uploaded_video", "audio", "sound-effect", "background-music", "image-sequence", "character-animation", ] { let error = resolve_editor_pixel_art_snap_asset_kind(Some(asset_kind), None, &[], &[]) .expect_err("non-static source kinds must be rejected"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); } let discovered_animation = vec!["character-animation".to_string()]; let omitted_kind_error = resolve_editor_pixel_art_snap_asset_kind(None, None, &discovered_animation, &[]) .expect_err("omitting both request metadata fields must not hide source metadata"); assert_eq!(omitted_kind_error.status_code(), StatusCode::BAD_REQUEST); let local_reference_storage_kind = vec!["editor_generation_reference_image".to_string()]; assert_eq!( resolve_editor_pixel_art_snap_asset_kind( Some("character"), None, &[], &local_reference_storage_kind, ) .expect("storage taxonomy must not conflict with requested semantic kind") .as_deref(), Some("character") ); let animated_storage_kind = vec!["character-animation".to_string()]; let omitted_storage_kind_error = resolve_editor_pixel_art_snap_asset_kind(None, None, &[], &animated_storage_kind) .expect_err("omitted request metadata must not bypass asset_object media guard"); assert_eq!( omitted_storage_kind_error.status_code(), StatusCode::BAD_REQUEST ); let image_kind = resolve_editor_pixel_art_snap_asset_kind(Some("image"), None, &[], &[]) .expect("static image kind should be accepted"); assert_eq!(image_kind.as_deref(), Some("image")); } #[test] fn explicit_pixel_art_snap_blank_asset_folder_falls_back_to_project() { for asset_folder_id in [None, Some(" ".to_string())] { assert_eq!( resolve_editor_pixel_art_asset_folder_id(asset_folder_id).as_deref(), Some(EDITOR_ASSET_DEFAULT_FOLDER_ID) ); } assert_eq!( resolve_editor_pixel_art_asset_folder_id(Some( " user-1:asset-folder:custom ".to_string() )) .as_deref(), Some("user-1:asset-folder:custom") ); } #[test] fn explicit_pixel_art_snap_identity_is_stable_but_input_drift_changes_fingerprint() { for (record_kind, expected) in [ ("asset-object", "8a264e1086ee3d6878d753aec254e0a5"), ("project-resource", "9eee84b77e8828ca8f5042919198ac1c"), ("asset", "c7e7e66538f4613226e68001b8408104"), ] { assert_eq!( editor_pixel_art_stable_record_suffix( "owner-1", "project-1", "dialog-1", record_kind, ), expected, "API 与 SpacetimeDB module 必须共享逐字相同的稳定 ID 算法", ); } let completion: EditorCanvasGenerationCompletionRequest = serde_json::from_value(json!({ "dialogId": "generation-dialog-7", "title": "完美像素", "placeholder": { "x": 10.0, "y": 20.0, "width": 128.0, "height": 128.0, "originalWidth": 128.0, "originalHeight": 128.0 } })) .expect("completion request should deserialize"); let first_inputs: Value = serde_json::from_str(r#"{"z":1,"nested":{"b":2,"a":1}}"#) .expect("first generation inputs should parse"); let reordered_inputs: Value = serde_json::from_str(r#"{"nested":{"a":1,"b":2},"z":1}"#) .expect("reordered generation inputs should parse"); let build = |source_hash: &str, inputs: &Value, completion: &EditorCanvasGenerationCompletionRequest| { build_editor_pixel_art_persistence_identity( "user-1", "project-1", "generation-dialog-7", "generated/editor/source.png", source_hash, "output-sha256", Some("source-resource-1"), Some("character"), "user-1:asset-folder:project", "完美像素", Some(inputs), completion, ) .expect("pixel-art identity should build") }; let first = build("source-sha256", &first_inputs, &completion); let reordered = build("source-sha256", &reordered_inputs, &completion); assert_eq!(first, reordered, "JSON key order must not alter identity"); assert_eq!(first.operation_id, "generation-dialog-7"); assert_eq!(first.task_id, "pixel-art-snap-generation-dialog-7"); assert!(first.asset_object_id.starts_with("assetobj_")); assert!(first.resource_id.starts_with(EDITOR_RESOURCE_ID_PREFIX)); assert!(first.asset_id.starts_with(EDITOR_ASSET_ID_PREFIX)); let mut padded_completion = completion.clone(); padded_completion.dialog_id = Some(" generation-dialog-7 ".to_string()); padded_completion.title = " 完美像素 ".to_string(); let padded = build("source-sha256", &first_inputs, &padded_completion); assert_eq!( first.operation_fingerprint, padded.operation_fingerprint, "completion fields interpreted with trim semantics must fingerprint identically" ); let drifted = build("different-source-sha256", &first_inputs, &completion); assert_eq!(first.operation_id, drifted.operation_id); assert_eq!(first.task_id, drifted.task_id); assert_eq!(first.asset_object_id, drifted.asset_object_id); assert_eq!(first.resource_id, drifted.resource_id); assert_eq!(first.asset_id, drifted.asset_id); assert_ne!(first.operation_fingerprint, drifted.operation_fingerprint); } #[test] fn explicit_pixel_art_snap_accepts_only_static_png_jpeg_or_webp_bytes() { let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( 4, 4, image::Rgba([20, 30, 40, 255]), )); let mut png = Cursor::new(Vec::new()); image .write_to(&mut png, image::ImageFormat::Png) .expect("test PNG should encode"); assert!(validate_editor_pixel_art_static_raster_bytes(png.get_ref()).is_ok()); assert!(validate_editor_pixel_art_static_raster_bytes(&[0xff, 0xd8, 0xff, 0xd9]).is_ok()); let mut webp = Cursor::new(Vec::new()); image .write_to(&mut webp, image::ImageFormat::WebP) .expect("test WebP should encode"); assert!(validate_editor_pixel_art_static_raster_bytes(webp.get_ref()).is_ok()); let mut apng = b"\x89PNG\r\n\x1a\n".to_vec(); apng.extend_from_slice(&8u32.to_be_bytes()); apng.extend_from_slice(b"acTL"); apng.extend_from_slice(&[0; 8]); apng.extend_from_slice(&[0; 4]); assert!( validate_editor_pixel_art_static_raster_bytes(apng.as_slice()) .expect_err("APNG should be rejected") .contains("APNG") ); let mut animated_webp = b"RIFF".to_vec(); animated_webp.extend_from_slice(&12u32.to_le_bytes()); animated_webp.extend_from_slice(b"WEBP"); animated_webp.extend_from_slice(b"ANIM"); animated_webp.extend_from_slice(&0u32.to_le_bytes()); assert!( validate_editor_pixel_art_static_raster_bytes(animated_webp.as_slice()) .expect_err("animated WebP should be rejected") .contains("动画 WebP") ); let mut animated_vp8x_webp = b"RIFF".to_vec(); animated_vp8x_webp.extend_from_slice(&30u32.to_le_bytes()); animated_vp8x_webp.extend_from_slice(b"WEBP"); animated_vp8x_webp.extend_from_slice(b"VP8X"); animated_vp8x_webp.extend_from_slice(&10u32.to_le_bytes()); animated_vp8x_webp.extend_from_slice(&[0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0]); animated_vp8x_webp.extend_from_slice(b"VP8L"); animated_vp8x_webp.extend_from_slice(&0u32.to_le_bytes()); assert!( validate_editor_pixel_art_static_raster_bytes(animated_vp8x_webp.as_slice()) .expect_err("VP8X animation flag should be rejected") .contains("动画 WebP") ); assert!( validate_editor_pixel_art_static_raster_bytes(b"GIF89a") .expect_err("GIF should be rejected") .contains("GIF") ); assert!( validate_editor_pixel_art_static_raster_bytes(b"not-an-image") .expect_err("unknown image bytes should be rejected") .contains("PNG、JPEG 或 WebP") ); } #[test] fn explicit_pixel_art_snap_requires_non_empty_canvas_dialog_id() { for dialog_id in [None, Some(" "), Some("dialog\0id")] { let completion: EditorCanvasGenerationCompletionRequest = serde_json::from_value(json!({ "dialogId": dialog_id, "title": "完美像素", "placeholder": { "x": 10.0, "y": 20.0, "width": 128.0, "height": 128.0, "originalWidth": 128.0, "originalHeight": 128.0 } })) .expect("completion request should deserialize"); let error = validate_editor_pixel_art_snap_canvas_completion(&completion) .expect_err("pixel-art snap must require a stable dialog id"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert_eq!( error.details().and_then(|details| details.get("field")), Some(&json!("canvasCompletion.dialogId")) ); } } #[test] fn explicit_pixel_art_snap_validates_completion_before_persistence() { let mut completion: EditorCanvasGenerationCompletionRequest = serde_json::from_value(json!({ "dialogId": "dialog-pixel-art", "title": "完美像素", "placeholder": { "x": 10.0, "y": 20.0, "width": 128.0, "height": 128.0, "originalWidth": 128.0, "originalHeight": 128.0 } })) .expect("completion request should deserialize"); completion.title = " ".to_string(); let blank_title = validate_editor_pixel_art_snap_canvas_completion(&completion) .expect_err("blank result title must fail before persistence"); assert_eq!( blank_title .details() .and_then(|details| details.get("field")), Some(&json!("canvasCompletion.title")) ); completion.title = "完美像素".to_string(); completion.placeholder.width = 0.0; let invalid_placeholder = validate_editor_pixel_art_snap_canvas_completion(&completion) .expect_err("invalid placeholder must fail before persistence"); assert_eq!( invalid_placeholder .details() .and_then(|details| details.get("field")), Some(&json!("canvasCompletion.placeholder")) ); } #[test] fn explicit_pixel_art_snap_requires_a_persisted_placeholder_before_processing() { let completion: EditorCanvasGenerationCompletionRequest = serde_json::from_value(json!({ "dialogId": "dialog-pixel-art", "title": "完美像素", "placeholder": { "x": 10.0, "y": 20.0, "width": 128.0, "height": 128.0, "originalWidth": 128.0, "originalHeight": 128.0 } })) .expect("completion request should deserialize"); let missing = validate_editor_pixel_art_snap_placeholder_exists( &json!([]), &[], "user-1", "editor-project-1", &completion, ) .expect_err("missing server placeholder should fail before processing"); assert_eq!(missing.status_code(), StatusCode::CONFLICT); let layers = json!([{ "itemType": "generation-dialog", "dialog": { "id": "dialog-pixel-art", "placeholder": completion.placeholder.clone() } }]); assert!( validate_editor_pixel_art_snap_placeholder_exists( &layers, &[], "user-1", "editor-project-1", &completion, ) .is_ok() ); let mut replay_resource = editor_project_resource_for_canvas_test("placeholder", "image", 128, 128); replay_resource.resource_id = format!( "{EDITOR_RESOURCE_ID_PREFIX}{}", editor_pixel_art_stable_record_suffix( "user-1", "editor-project-1", "dialog-pixel-art", "project-resource", ) ); replay_resource.task_id = Some("pixel-art-snap-dialog-pixel-art".to_string()); assert!( validate_editor_pixel_art_snap_placeholder_exists( &json!([]), &[replay_resource], "user-1", "editor-project-1", &completion, ) .is_ok(), "DialogMissing 的同 operation 重放必须进入 procedure 做 exact compare" ); } #[test] fn explicit_pixel_art_snap_is_inline_strict_and_persists_only_after_processing() { let source = concat!( include_str!("editor_project_icon.rs"), include_str!("editor_project.rs") ); assert_function_contains_in_order( source, "pub async fn snap_editor_image_to_pixel_art(", "async fn validate_editor_background_removal_source", &[ // 中文注释:保留审计字段的剥离必须排在最前——它和其余生成入口共用同一个 // sanitizer,漏掉这一步用户就能给自己的记录伪造 mattingModel 等处理事实。 // 本端点是纯几何规整、不抠图,任何 matting 元数据出现在这里都是伪造。 "sanitize_editor_client_generation_inputs", // 中文注释:处理预算在任何 IO 之前派生,下载与规整共用同一份绝对 deadline; // 一旦预算改回下载之后起算,下面的顺序断言会先失败。 "resolve_editor_pixel_art_processing_deadline", "ensure_editor_reference_image_source_is_stable", "validate_editor_pixel_art_snap_canvas_completion", "serialize_editor_asset_metadata", // 中文注释:并发闸必须排在第一次 IO 之前。挪到 .get_editor_project 之后, // 全账号扫描与下载就重新回到闸外,等于闸在资源被消耗之后才检查。 "acquire_editor_pixel_art_snap_permit", // 中文注释:归属校验阶段必须自己套绝对 deadline。预算只是从 handler 入口 // 起算,起算不等于覆盖——这段里的 SpacetimeDB 调用一旦退回裸 await,第一次 // 真正应用预算就又变成下载,请求会一路走到下载才发现预算早已耗尽,而且全程 // 占着端点准入名额。 "tokio::time::timeout_at(", ".get_editor_project", "validate_editor_pixel_art_snap_placeholder_exists", "resolve_editor_pixel_art_source_for_owner", "完美像素来源归属校验超出处理预算。", "download_editor_persisted_image_object_within_deadline", "validate_editor_pixel_art_static_raster", "snap_editor_pixel_art_strict", "Some(processing_deadline)", // 中文注释:prepare 只计算精确 object key;只读 preflight 与后续 // PUT/HEAD/原子 persist 共用第二份 60 秒绝对 deadline。preflight 必须发生 // 在第一次外部写之前,避免已知的目录/布局拒绝留下 OSS 孤儿对象。 "prepare_editor_generated_image_object_data(", "EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION", "tokio::time::timeout_at(", ".preflight_editor_pixel_art_result(", "if Instant::now() >= persistence_deadline", "tokio::select!", "biased;", "upload_editor_generated_image_object_prepared(", ".persist_editor_pixel_art_result(", ], ); assert_function_contains_in_order( source, "pub async fn snap_editor_image_to_pixel_art(", "async fn validate_editor_background_removal_source", &[ // 中文注释:timer 分支不能因为 preflight 已成功就谎报已 PUT;只有执行 // helper 在第一次 PUT 前置位后,持久化超时才附加 unknown 标记。 "AtomicBool::new(false)", "tokio::select!", "result_persistence_started.load(Ordering::Acquire)", "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", "upload_editor_generated_image_object_prepared(", ], ); assert_function_occurrence_count( source, "pub async fn snap_editor_image_to_pixel_art(", "async fn validate_editor_background_removal_source", ".preflight_editor_pixel_art_result(", 1, ); assert_function_occurrence_count( source, "pub async fn snap_editor_image_to_pixel_art(", "async fn validate_editor_background_removal_source", ".persist_editor_pixel_art_result(", 1, ); // 中文注释:全账号扫描各只能出现一次。此前解析链路是三个各自取数的函数串联 // (resolve_editor_reference_object_key + ensure_editor_reference_object_key_owned // 各扫一轮,本解析器再扫第三轮),同一份数据被拉了 6 次全账号 RPC,全部顺序执行 // 在预算和准入名额之内。改为扫一次后交给 `_from_records` 纯函数在内存里解析; // 一旦有人重新引入取数包装,这两条计数会立刻失败。 for (scan, expected_occurrences) in [ (".list_editor_projects(", 1), (".get_editor_asset_library(", 1), ] { assert_function_occurrence_count( source, "async fn resolve_editor_pixel_art_source_for_owner", "fn resolve_editor_pixel_art_asset_folder_id", scan, expected_occurrences, ); } assert_function_not_contains( source, "async fn resolve_editor_pixel_art_source_for_owner", "fn resolve_editor_pixel_art_asset_folder_id", &[ // 中文注释:这个包装内部自带两轮全账号扫描,已经持有 owner 快照的调用方 // 不能再走它,否则同一份数据会被重复拉取三倍。断言调用形式而不是裸函数名, // 否则会命中生产代码里说明「老包装保持不动」的那句注释。 "resolve_editor_reference_object_key_for_owner(state", ], ); // 中文注释:第一次 OSS PUT 之后的 procedure 错误与整段 timeout 都必须带 // resultPersistenceStarted。远端 procedure 是单事务,但本地 future 被 timeout/drop // 不能撤销远端调用,因此这两条仍是未知结果;PUT/HEAD/helper 构造错误由 helper 测试 // 单独钉住。 assert_function_occurrence_count( source, "pub async fn snap_editor_image_to_pixel_art(", "async fn validate_editor_background_removal_source", "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", 2, ); assert_function_contains_in_order( source, "pub async fn snap_editor_image_to_pixel_art(", "async fn validate_editor_background_removal_source", &[ // 中文注释:标记只能出现在第一次 PUT 之后。出现在 persist 之前说明有纯 // 校验失败被误标成「可能已落库」,会让常见的 400 也触发多余的对账读取。 ".preflight_editor_pixel_art_result(", "upload_editor_generated_image_object_prepared(", "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", ], ); assert_function_not_contains( source, "pub async fn snap_editor_image_to_pixel_art(", "async fn validate_editor_background_removal_source", &[ "enqueue_editor_generation_job", "external_generation_job", "queue_state", "snap_editor_pixel_art_or_original", "confirm_asset_object(", "persist_editor_generated_asset(", "complete_editor_canvas_generation(", "create_editor_project_resource(", "create_editor_asset(", // 中文注释:下载必须走带 deadline 的包装。退回裸调用会让 OSS GET 重新 // 落在 30s 预算之外,请求可以持着最多 32 MiB 无限期排队等 CPU 许可。 "download_editor_persisted_image_object(&state", // 中文注释:完美像素必须走 upload-only helper;这些旧包装会在新原子 // procedure 之前单独 confirm asset object,重新制造 object-only 部分状态。 "persist_editor_generated_image(", "persist_editor_generated_image_owned(", // 中文注释:取输出尺寸只读 PNG 头。整图解码发生在 CPU 许可之外, // 对 8294400 像素上限的输出要多分配约 33 MiB,且不换来任何保证。 "image::load_from_memory", ], ); } #[test] fn editor_image_generation_returns_never_drop_accumulated_warnings() { let source = concat!( include_str!("editor_project_icon.rs"), 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 = concat!( include_str!("editor_project_icon.rs"), 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 = concat!( include_str!("editor_project_icon.rs"), 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 = concat!( include_str!("editor_project_icon.rs"), include_str!("editor_project.rs") ); assert_function_contains( source, "pub(crate) async fn generate_editor_image_for_owner", "let effective_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 icon_spec_reference_resolution_uses_narrow_verified_lookup() { let source = concat!( include_str!("editor_project_icon.rs"), include_str!("editor_project.rs") ); assert_function_contains( source, "pub(crate) async fn resolve_editor_icon_spec_reference(", "pub(crate) async fn preflight_editor_icon_spec_reference_metadata(", &[ "preflight_editor_icon_spec_reference_metadata(", "read_editor_reference_image_object(", ], ); assert_function_contains( source, "pub(crate) async fn preflight_editor_icon_spec_reference_metadata(", "pub(crate) async fn resolve_editor_reference_object_key_for_owner", &[ "resolve_editor_reference_record_by_id_for_owner(", "EditorIconSpecReferenceMetadata", ], ); assert_function_not_contains( source, "pub(crate) async fn preflight_editor_icon_spec_reference_metadata(", "pub(crate) async fn resolve_editor_reference_object_key_for_owner", &[ "list_editor_projects", "get_editor_asset_library", "parse_editor_reference_image", "read_editor_reference_image_object(", ], ); assert_function_contains( source, "pub(crate) async fn validate_editor_reference_id_for_owner", "pub(crate) async fn resolve_editor_icon_spec_reference", &["resolve_editor_reference_record_by_id_for_owner("], ); assert_function_not_contains( source, "pub(crate) async fn validate_editor_reference_id_for_owner", "pub(crate) async fn resolve_editor_icon_spec_reference", &[ "read_editor_reference_image_object(", "parse_editor_reference_image(", ], ); } #[test] fn inline_icon_spec_initializes_operation_inside_durable_billing_boundary() { let source = include_str!("editor_project_icon.rs"); assert_function_contains_in_order( source, "pub(crate) async fn generate_icon_spec(", "pub(crate) async fn enqueue_icon_spec_generation_for_owner", &[ ".with_inline_operation(", "EDITOR_ICON_SPEC_GENERATION_JOB_KIND", "with_editor_generation_durable_billing_boundary(", "generate_icon_spec_for_owner(", ], ); } #[test] fn queued_icon_spec_preflights_and_persists_canonical_target_before_remote_work() { let source = include_str!("editor_project_icon.rs"); assert_function_contains_in_order( source, "pub(crate) async fn enqueue_icon_spec_generation_for_owner", "pub(crate) async fn generate_icon_spec_for_owner", &[ "validate_icon_spec_request_fields(", "preflight_editor_billable_generation_target(", "payload.project_id = target.project_id;", "payload.asset_folder_id = target.asset_folder_id;", "validate_icon_spec_reference_for_owner(", "resolve_icon_spec_price(", "enqueue_editor_generation_job_for_caller(", ], ); } #[test] fn image_generation_preflight_checks_references_provider_and_pricing() { let source = concat!( include_str!("editor_project_icon.rs"), include_str!("editor_project.rs") ); assert_function_contains_in_order( source, "pub(crate) async fn validate_editor_image_generation_parameters_for_owner", "pub(crate) async fn generate_editor_image_for_owner", &[ "ensure_editor_reference_image_sources_are_stable(", "ensure_editor_reference_image_source_limit(", "parse_editor_reference_image(", "require_openai_image_settings(", "build_openai_image_http_client(", ".editor_generation_pricing()", ], ); assert_function_not_contains( source, "pub(crate) async fn validate_editor_image_generation_parameters_for_owner", "pub(crate) async fn generate_editor_image_for_owner", &[".take(reference_limit)"], ); } #[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 pixel_art_style_prompt_is_scoped_per_generation_path() { // 中文注释:非 PixelArt 必须原样返回——普通图片路径的提示词就是用户原文, // 任何多余追加都会直接落进 editor_project_resource 的 prompt 列。 for scope in [ EditorPixelArtPromptScope::Image, EditorPixelArtPromptScope::Character, EditorPixelArtPromptScope::IconSpritesheet, ] { assert_eq!( apply_editor_pixel_art_style_prompt( "一只猫".to_string(), EditorImageGenerationStyle::None, scope, ), "一只猫" ); } let image_prompt = apply_editor_pixel_art_style_prompt( "一只猫".to_string(), EditorImageGenerationStyle::PixelArt, EditorPixelArtPromptScope::Image, ); assert_eq!(image_prompt, "一只猫\n画面为像素风格"); // 中文注释:角色形象与图标图集生成后都要按纯色抠像,绿幕底必须保持平整, // 因此这两条都不能出现「画面」级别的像素化要求,否则和同一段提示词里的 // 「纯色背景必须平整无纹理、无渐变」互相拆台。 let character_prompt = apply_editor_pixel_art_style_prompt( "一只猫".to_string(), EditorImageGenerationStyle::PixelArt, EditorPixelArtPromptScope::Character, ); assert_eq!(character_prompt, "一只猫\n角色主体为像素风格"); let icon_prompt = apply_editor_pixel_art_style_prompt( "一只猫".to_string(), EditorImageGenerationStyle::PixelArt, EditorPixelArtPromptScope::IconSpritesheet, ); assert_eq!(icon_prompt, "一只猫\n每个图标素材均为像素风格"); for scoped_prompt in [character_prompt.as_str(), icon_prompt.as_str()] { assert!(!scoped_prompt.contains(EDITOR_PIXEL_ART_IMAGE_PROMPT)); } } #[test] fn pixel_art_style_prompt_is_applied_before_provider_call() { let source = concat!( include_str!("editor_project_icon.rs"), include_str!("editor_project.rs") ); // 中文注释:注入必须发生在 provider 调用之前——一旦被挪到之后,提示词约束就完全 // 不起作用,而产物看起来仍然「成功」,只是退化成 snapper 的统一网格马赛克。 assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &[ "let pixel_art_prompt_scope = if is_character_generation {", "EditorPixelArtPromptScope::Character", "EditorPixelArtPromptScope::Image", "apply_editor_pixel_art_style_prompt(", "request_editor_generated_images(", ], ); assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &[ "apply_editor_pixel_art_style_prompt(", "EditorPixelArtPromptScope::IconSpritesheet", "request_editor_generated_images(", ], ); } #[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 = concat!( include_str!("editor_project_icon.rs"), 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(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &[ "let user_prompt = spritesheet_prompt.as_str().to_string();", "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": [] })), image_sequence_frames: None, image_sequence_duration_ms: 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"); 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["assetKind"], json!("character")); 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": [] })), image_sequence_frames: None, image_sequence_duration_ms: 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-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, image_sequence_frames: None, image_sequence_duration_ms: 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, image_sequence_frames: None, image_sequence_duration_ms: 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 = concat!( include_str!("editor_project_icon.rs"), 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, image_sequence_frames: None, image_sequence_duration_ms: 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.descriptions(), ["返回按钮", "设置按钮"]); assert_eq!(descriptions.as_str(), "返回按钮\n设置按钮"); let too_many = (0..101) .map(|index| format!("图标{index}")) .collect::>(); assert!(normalize_icon_descriptions(too_many).is_err()); let too_long = vec!["图".repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)]; let error = normalize_icon_descriptions(too_long).expect_err("overlong description should fail"); assert!(error.body_text().contains("每条图标素材描述")); let aggregate_too_long = (0..EDITOR_ICON_DESCRIPTION_LIMIT) .map(|_| "图".repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS)) .collect::>(); let error = normalize_icon_descriptions(aggregate_too_long) .expect_err("overlong aggregate should fail"); assert!(error.body_text().contains("合计")); let utf8_too_large = vec!["😀".repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS); 8]; let error = normalize_icon_descriptions(utf8_too_large) .expect_err("oversized UTF-8 aggregate should fail"); assert!(error.body_text().contains("UTF-8")); let source = include_str!("editor_project_icon.rs"); assert_function_contains_in_order( source, "pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner", "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", &[ "payload.icon_descriptions =", "normalize_icon_descriptions(payload.icon_descriptions)?.into_descriptions();", "preflight_editor_billable_generation_target(", "preflight_editor_icon_spec_reference_metadata(", "resolve_editor_icon_spritesheet_price(", "enqueue_editor_generation_job_for_caller(", ], ); assert_function_not_contains( source, "pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner", "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", &["read_editor_reference_image_object("], ); assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &[ "ensure_editor_reference_image_source_limit(", "preflight_editor_billable_generation_target(", "payload.project_id = target.project_id;", "payload.asset_folder_id = target.asset_folder_id;", "rebuild_editor_generation_input_references(", "resolve_editor_icon_spec_reference(", "execute_billable_asset_operation_with_cost(", ], ); } #[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 = concat!( include_str!("editor_project_icon.rs"), 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, image_sequence_frames: None, image_sequence_duration_ms: 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, image_sequence_frames: None, image_sequence_duration_ms: 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_without_silent_truncation() { 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())), vec![ "generated-character-drafts/editor/a.png", "generated-character-drafts/editor/b.png", "generated-character-drafts/editor/c.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 atomic_editor_job_result_defers_consumer_compaction_until_queue_context_is_applied() { let caller = EditorGenerationCaller { owner_user_id: "user-1".to_string(), audit_subject_user_id: Some("user-1".to_string()), audit_project_id: Some("project-1".to_string()), phase_reporter: None, operation: Some(EditorGenerationOperationContext { operation_kind: EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(), operation_id: "job-1".to_string(), operation_fingerprint: "a".repeat(64), worker_id: Some("worker-1".to_string()), lease_token: Some("lease-1".to_string()), queue_result_context: Some(EditorGenerationQueueResultContext { consumer: EditorGenerationQueueConsumer::Standard, source_module: "editor-canvas".to_string(), source_entity_id: "project-1".to_string(), }), }), }; let result = json!({ "imageSrc": "/api/assets/read?objectKey=generated/image.png", "objectKey": "generated/image.png", "width": 512, "height": 512, "warning": { "code": "fallback", "reason": "测试降级" }, }); let payload: Value = serde_json::from_str( editor_generation_job_result_payload_json(&caller, result.clone()) .expect("queue caller should build a result payload") .as_str(), ) .expect("atomic queue result should be valid JSON"); assert_eq!(payload[ATOMIC_EDITOR_GENERATION_RAW_RESULT_FIELD], result); assert!(payload.get("result").is_none()); assert!(payload.get("editor-agent-tool-call-result").is_none()); } #[test] fn atomic_job_result_keeps_only_the_target_consumer_contract() { let result = json!({ "ok": true, "imageSrc": "/api/assets/object/generated.png", "objectKey": "generated/image.png", "width": 512, "height": 512, "prompt": "用户可见提示词", "actualPrompt": "Provider 实际提示词", "provider": "internal-provider", "generationInputs": { "fields": { "prompt": "用户可见提示词" }, "mattingProvider": "internal-provider", }, "resource": { "resourceId": "resource-1", "objectKey": "generated/image.png", "assetObjectId": "asset-object-1", "prompt": "不应复制到 queue", "generationInputs": { "large": "x".repeat(32 * 1024) }, }, "asset": { "assetId": "asset-1", "ownerUserId": "user-1" }, }); let mut ordinary_job = atomic_editor_generation_job_fixture(); let ordinary_context = EditorGenerationQueueResultContext::from_job(&ordinary_job); let ordinary_payload: Value = serde_json::from_str( serialize_atomic_editor_generation_job_result(&ordinary_context, &result) .expect("ordinary queue payload") .as_str(), ) .expect("ordinary queue payload should be JSON"); assert!(ordinary_payload.get("result").is_none()); assert!( ordinary_payload .get("editor-agent-tool-call-result") .is_none() ); ordinary_job.request_payload_json = json!({ "generationInputs": { "source": "editor-agent" }, }) .to_string(); let agent_context = EditorGenerationQueueResultContext::from_job(&ordinary_job); let agent_payload: Value = serde_json::from_str( serialize_atomic_editor_generation_job_result(&agent_context, &result) .expect("agent queue payload") .as_str(), ) .expect("agent queue payload should be JSON"); assert!(agent_payload.get("result").is_none()); assert_eq!( agent_payload["editor-agent-tool-call-result"]["resource"], json!({ "resourceId": "resource-1", "objectKey": "generated/image.png", "assetObjectId": "asset-object-1", }) ); assert!( agent_payload["editor-agent-tool-call-result"] .get("asset") .is_none() ); assert!( agent_payload["editor-agent-tool-call-result"] .get("provider") .is_none() ); ordinary_job.request_payload_json = "{}".to_string(); ordinary_job.dedupe_key = "external-api-generation:editor_image_generation:fingerprint".to_string(); let external_context = EditorGenerationQueueResultContext::from_job(&ordinary_job); let external_payload: Value = serde_json::from_str( serialize_atomic_editor_generation_job_result(&external_context, &result) .expect("external queue payload") .as_str(), ) .expect("external queue payload should be JSON"); assert!( external_payload .get("editor-agent-tool-call-result") .is_none() ); assert_eq!(external_payload["result"]["asset"]["assetId"], "asset-1"); assert_eq!(external_payload["result"]["ok"], true); assert_eq!(external_payload["result"]["prompt"], "用户可见提示词"); assert_eq!( external_payload["result"]["actualPrompt"], "Provider 实际提示词" ); assert!( external_payload["result"]["asset"] .get("ownerUserId") .is_none() ); } #[test] fn atomic_icon_ui_results_keep_required_consumer_fields() { let source = include_str!("editor_project.rs"); 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", ), ( "fn build_editor_source_only_fallback_compact_result", "async fn persist_editor_source_only_fallback_atomically", ), ] { assert_function_contains( source, start, end, &["\"ok\": true", "\"prompt\":", "\"actualPrompt\":"], ); } } #[test] fn source_only_fallback_contract_does_not_depend_on_project_resource() { let source_record = EditorGeneratedAssetRecord { resource: None, asset: None, }; let spritesheet_context = EditorSourceOnlyFallbackResultContext { width: 1024, height: 1024, prompt: "生成图集", actual_prompt: Some("Provider 图集提示词"), model: "gpt-image-2", price_mud_points: 12, }; for operation_kind in [ EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, ] { let result = build_editor_source_only_fallback_compact_result( operation_kind, &source_record, "task-sheet", "/api/assets/object/sheet.png", "generated/sheet.png", "asset-object-sheet", spritesheet_context, None, ); assert_eq!(result["ok"], true); assert_eq!(result["spritesheetWidth"], 1024); assert_eq!(result["spritesheetHeight"], 1024); assert_eq!(result["prompt"], "生成图集"); assert_eq!(result["actualPrompt"], "Provider 图集提示词"); assert_eq!(result["model"], "gpt-image-2"); assert_eq!(result["priceMudPoints"], 12); assert!(result["spritesheetResource"].is_null()); } let image_result = build_editor_source_only_fallback_compact_result( EDITOR_IMAGE_GENERATION_JOB_KIND, &source_record, "task-image", "/api/assets/object/image.png", "generated/image.png", "asset-object-image", EditorSourceOnlyFallbackResultContext { width: 768, height: 1024, prompt: "生成角色", actual_prompt: None, model: "gemini-3.1-flash-image-preview", price_mud_points: 0, }, None, ); assert_eq!(image_result["width"], 768); assert_eq!(image_result["height"], 1024); assert_eq!(image_result["prompt"], "生成角色"); assert_eq!(image_result["model"], "gemini-3.1-flash-image-preview"); assert!(image_result["resource"].is_null()); } #[test] fn atomic_spritesheet_job_result_with_64_large_slices_stays_below_payload_limit() { let large_metadata = "x".repeat(32 * 1024); let icons = (0..64) .map(|index| { json!({ "name": format!("icon-{index}"), "imageSrc": format!("/api/assets/object/icon-{index}.png"), "objectKey": format!("generated/icon-{index}.png"), "width": 64, "height": 64, "resource": { "resourceId": format!("resource-{index}"), "objectKey": format!("generated/icon-{index}.png"), "assetObjectId": format!("asset-object-{index}"), "sourceResourceId": "spritesheet-resource", "prompt": large_metadata.clone(), "generationInputs": { "fields": large_metadata.clone(), "references": large_metadata.clone(), }, "ownerUserId": "user-1", }, "asset": { "assetId": format!("asset-{index}"), "objectKey": format!("generated/icon-{index}.png"), "ownerUserId": "user-1", "prompt": large_metadata.clone(), "generationInputs": large_metadata.clone(), }, }) }) .collect::>(); let result = json!({ "spritesheetImageSrc": "/api/assets/object/sheet.png", "spritesheetWidth": 1024, "spritesheetHeight": 1024, "iconImageSrcs": icons, "spritesheetResource": { "resourceId": "spritesheet-resource", "objectKey": "generated/sheet.png", "generationInputs": large_metadata.clone(), }, "spritesheetAsset": { "assetId": "spritesheet-asset", "generationInputs": large_metadata, }, }); let mut agent_job = atomic_editor_generation_job_fixture(); agent_job.request_payload_json = json!({ "generationInputs": { "source": "editor-agent" }, }) .to_string(); let agent_context = EditorGenerationQueueResultContext::from_job(&agent_job); let agent_payload = serialize_atomic_editor_generation_job_result(&agent_context, &result) .expect("64-slice agent payload should remain compact"); assert!(agent_payload.len() < MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES); assert!(!agent_payload.contains("generationInputs")); assert!(!agent_payload.contains("ownerUserId")); let mut external_job = atomic_editor_generation_job_fixture(); external_job.dedupe_key = "external-api-generation:editor_ui_design_asset_extraction:fingerprint".to_string(); let external_context = EditorGenerationQueueResultContext::from_job(&external_job); let external_payload = serialize_atomic_editor_generation_job_result(&external_context, &result) .expect("64-slice external payload should remain compact"); assert!(external_payload.len() < MAX_EDITOR_GENERATION_JOB_RESULT_PAYLOAD_BYTES); assert!(!external_payload.contains("generationInputs")); assert!(!external_payload.contains("ownerUserId")); } fn atomic_editor_generation_job_fixture() -> ExternalGenerationJobRecord { ExternalGenerationJobRecord { job_id: "job-1".to_string(), dedupe_key: "editor-api-request-generation:editor_image_generation:fingerprint" .to_string(), job_kind: EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(), owner_user_id: "user-1".to_string(), source_module: "editor-canvas".to_string(), source_entity_id: "project-1".to_string(), request_label: "图片画布生成图片".to_string(), request_payload_json: "{}".to_string(), status: "running".to_string(), attempt: 1, max_attempts: 1, last_error_message: None, worker_id: Some("worker-1".to_string()), lease_expires_at: Some("2026-08-07T00:00:00Z".to_string()), available_at: "2026-08-07T00:00:00Z".to_string(), result_payload_json: None, created_at: "2026-08-07T00:00:00Z".to_string(), started_at: Some("2026-08-07T00:00:00Z".to_string()), completed_at: None, updated_at: "2026-08-07T00:00:00Z".to_string(), updated_at_micros: 1_786_060_800_000_000, lease_token: Some("lease-1".to_string()), price_mud_points: 2, refund_ledger_id: None, notification_acknowledged_at: None, notification_acknowledged_at_micros: None, phase: Some("processing".to_string()), } } #[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", "resourceId": "resource-default-spec", "assetKind": "character", "assetKindOverride": "icon" }, { "layerId": "layer-icon-spec", "resourceId": "resource-icon-spec", "assetKind": "icon" } ]); let resources = vec![ editor_project_resource_for_canvas_test("resource-default-spec", "spec", 512, 512), editor_project_resource_for_canvas_test("resource-icon-spec", "icon-spec", 512, 512), ]; let icon_kind = resolve_editor_image_edit_target_layer_asset_kind(&layers, &resources, "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, &resources, "layer-icon-spec", ) .expect("icon spec layer should exist"); assert_eq!(icon_spec_kind, Some("icon-spec")); 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() ); let invalid_override = json!([{ "layerId": "layer-invalid", "resourceId": "resource-icon-spec", "assetKindOverride": "" }]); assert!( resolve_editor_image_edit_target_layer_asset_kind( &invalid_override, &resources, "layer-invalid", ) .is_err() ); } #[test] fn editor_image_edit_guards_before_queue_and_owner_execution() { let source = concat!( include_str!("editor_project_icon.rs"), 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 = concat!( include_str!("editor_project_icon.rs"), 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 = concat!( include_str!("editor_project_icon.rs"), 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.as_ref()", "EditorCanvasLayoutPlan::background_removal", "payload.target_layer_id.as_deref()", "persist_editor_generation_result_atomically", ], ); } #[test] fn editor_icon_spritesheet_request_accepts_id_only_spec_and_stable_extra_references() { let request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ "referenceId": "editor-resource-spec", "referenceImageSrcs": ["/generated-images/editor/style.png"], "iconDescriptions": ["返回按钮"], "screenColor": "#7FB3FF", "segModel": "anime-seg", "assetLabel": " 冒险界面图标 " })) .expect("icon spritesheet request should deserialize references"); assert_eq!(request.reference_id, "editor-resource-spec"); 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!({ "referenceId": "editor-resource-spec", "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", "sourceLayerId": "layer-atlas-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_layer_id, "layer-atlas-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_icon_spritesheet_slice_uses_authoritative_resource_kind() { let layers = json!([ { "layerId": "layer-default", "resourceId": "resource-atlas" }, { "layerId": "layer-override", "resourceId": "resource-image", "assetKindOverride": "icon-spritesheet" }, { "layerId": "layer-blocked", "resourceId": "resource-atlas", "assetKindOverride": "character" } ]); assert_eq!( resolve_editor_icon_spritesheet_source_layer_asset_kind( &layers, "layer-default", "resource-atlas", Some("icon-spritesheet"), ) .expect("resource default should be used"), Some("icon-spritesheet") ); assert_eq!( resolve_editor_icon_spritesheet_source_layer_asset_kind( &layers, "layer-override", "resource-image", Some("character"), ) .expect("layer override must not promote the resource"), Some("character") ); assert_eq!( resolve_editor_icon_spritesheet_source_layer_asset_kind( &layers, "layer-blocked", "resource-atlas", Some("icon-spritesheet"), ) .expect("layer override must not demote the resource"), Some("icon-spritesheet") ); assert!( resolve_editor_icon_spritesheet_source_layer_asset_kind( &layers, "layer-default", "resource-other", Some("icon-spritesheet"), ) .is_err() ); } #[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 = concat!( include_str!("editor_project_icon.rs"), 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", "effective_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(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &[ "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 queued_image_generation_persists_the_frozen_billing_price() { let source = include_str!("editor_project.rs"); assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_image_for_owner_with_prompt_builder", "fn editor_image_generation_billing_asset_kind", &[ "current_external_generation_billing_price_mud_points()", ".editor_generation_pricing()", "execute_billable_asset_operation_with_cost(", "u64::from(effective_price_mud_points)", "generation_cost_mud_points: u64::from(effective_price_mud_points)", ], ); } #[test] fn editor_ui_design_asset_extraction_commits_prepared_bundle_atomically() { let source = include_str!("editor_project.rs"); let start = "pub(crate) async fn extract_editor_ui_design_assets_for_owner"; let end = "async fn repair_editor_project_record_inline_media"; assert_function_contains_in_order( source, start, end, &[ "prepare_editor_generated_image(", "EDITOR_PROVIDER_SOURCE_SLOT", "prepare_editor_generated_asset(", "remove_editor_generated_screen_background_with_bgfilter", "prepare_editor_generated_image(", "prepare_editor_generated_asset(", "prepare_editor_spritesheet_slices_for_generation(", "EditorCanvasLayoutPlan::generation(", "persist_editor_generation_result_atomically(", ], ); assert_function_occurrence_count( source, start, end, "persist_editor_generation_result_atomically(", 1, ); assert_function_occurrence_count( source, start, end, "persist_editor_source_only_fallback_atomically(", 2, ); assert_function_not_contains( source, start, end, &[ "persist_editor_provider_source_image(", "persist_editor_provider_source_resource(", "persist_editor_generated_image(", "persist_editor_generated_asset(", "persist_editor_spritesheet_slices(", "complete_editor_source_only_fallback(", "complete_editor_canvas_generation_with_items(", ], ); } #[test] fn editor_manual_background_removal_reuses_single_account_snapshot_pair() { let source = concat!( include_str!("editor_project_icon.rs"), 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 = concat!( include_str!("editor_project_icon.rs"), include_str!("editor_project.rs") ); assert_function_contains( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", &[ "prepare_editor_generated_image", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", "prepare_editor_generated_asset", "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", &[ "prepare_editor_generated_image", "prepare_editor_generated_asset", "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(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &[ "prepare_editor_generated_image", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_ENABLED", "prepare_editor_generated_asset", "slice_editor_icon_spritesheet_all", "prepare_editor_spritesheet_slices_for_generation", "persist_editor_generation_result_atomically", "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(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &["{ \"title\": \"处理模型\""], ); assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &[ "prepare_editor_generated_image", "prepare_editor_generated_asset", "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", &[ "prepare_editor_generated_image", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", "prepare_editor_generated_asset", "slice_editor_icon_spritesheet_all", "prepare_editor_spritesheet_slices_for_generation", "persist_editor_generation_result_atomically", "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", &[ "prepare_editor_generated_image", "prepare_editor_generated_asset", "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(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &["try_remove_editor_downloaded_image_background"], ); assert_function_not_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &["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 = concat!( include_str!("editor_project_icon.rs"), 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 = concat!( include_str!("editor_project_icon.rs"), 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 = concat!( include_str!("editor_project_icon.rs"), include_str!("editor_project.rs") ); assert_function_contains( source, "async fn persist_editor_source_only_fallback_atomically", "pub(crate) async fn complete_editor_canvas_generation_with_items", &[ "EditorCanvasLayoutPlan::generation", "source_record.record.resource.as_ref()", "merge_editor_generation_warnings", "editor_postprocess_fallback_warning_with_dimension", "persist_editor_generation_result_atomically", ], ); assert_function_not_contains( source, "async fn persist_editor_source_only_fallback_atomically", "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(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", ), ( "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, "persist_editor_source_only_fallback_atomically(", 2, ); assert_function_contains_in_order( source, start, end, &[ "prepare_editor_generated_asset", "caller.report_processing_phase(state).await?", "remove_editor_generated_screen_background_with_bgfilter", "Err(error)", "persist_editor_source_only_fallback_atomically", "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("persist_editor_source_only_fallback_atomically")); 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("prepare_editor_generated_image(")); assert!(success_tail.contains("prepare_editor_generated_asset(")); assert!(success_tail.contains("prepare_editor_spritesheet_slices_for_generation")); assert!(success_tail.contains("persist_editor_generation_result_atomically(")); } } 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 [ "persist_editor_source_only_fallback_atomically", "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 = concat!( include_str!("editor_project_icon.rs"), 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("prepare_editor_generated_image") .expect("character provider source should be prepared"); 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)", "persist_editor_source_only_fallback_atomically", "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("prepare_editor_generated_image(")); assert!(success_tail.contains("prepare_editor_generated_asset(")); assert!(success_tail.contains("persist_editor_generation_result_atomically(")); 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 [ "persist_editor_source_only_fallback_atomically", "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 = concat!( include_str!("editor_project_icon.rs"), 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 = concat!( include_str!("editor_project_icon.rs"), 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(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", ), ( "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, &[ "prepare_editor_generated_image", "remove_editor_generated_screen_background_with_bgfilter", "apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original", "prepare_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(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", ), ( "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", // 中文注释:必须走进程级共享客户端。它自带 connect / total 超时, // 是所有 OSS 读取调用方(含没有 deadline 可传的降级路径)的兜底上界。 ".editor_oss_http_client()", ], ); 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()", // 中文注释:每次新建客户端会同时丢掉超时与连接复用,等于把无界 GET 放回来。 "reqwest::Client::new()", ], ); // 中文注释:写路径与读路径同一条不变式。persist 发生在 CPU 处理之后,处理预算 // 已经不适用,客户端级超时是它唯一的界;退回裸客户端会让 PUT / HEAD 重新无界, // 而这一段是全部 9 条编辑器图片持久化流程共用的。 assert_function_contains( source, "async fn persist_editor_generated_image_data", "async fn persist_editor_provider_source_image", &["state.editor_oss_http_client()"], ); assert_function_not_contains( source, "async fn persist_editor_generated_image_data", "async fn persist_editor_provider_source_image", &["reqwest::Client::new()"], ); // 中文注释:旧包装仍在 upload-only helper 之后 confirm asset object,供兄弟链路 // 保持原行为;完美像素只走 upload-only helper,再把 object/resource/asset/canvas // 交给单个原子 procedure。两层边界必须分别钉住,防止 confirm 再次溜回新链路。 assert_function_contains_in_order( source, "async fn persist_editor_generated_image_data", "async fn upload_editor_generated_image_object_data", &[ "upload_editor_generated_image_object_data", ".confirm_asset_object(uploaded.asset_object)", ], ); assert_function_not_contains( source, "async fn upload_editor_generated_image_object_data", "async fn persist_editor_provider_source_image", &["confirm_asset_object("], ); // 中文注释:调用方拿到的是同一个 AppError,无法自行判断本函数内部走到了哪一步, // 所以「失败发生在持久化开始之后」只能在这里标。整个旧包装的四处分别是 PUT // (响应丢失时字节可能已落盘)、HEAD、asset object 入参构造与旧 confirm。 assert_function_occurrence_count( source, "async fn persist_editor_generated_image_data", "async fn persist_editor_provider_source_image", "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", 4, ); assert_function_occurrence_count( source, "async fn upload_editor_generated_image_object_data", "async fn persist_editor_provider_source_image", "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", 3, ); // 中文注释:prepare 只能计算 request/object key,不得访问 OSS 或谎报已经开始持久化。 assert_function_contains( source, "fn prepare_editor_generated_image_object_data", "struct PersistEditorProviderSourceResourceInput", &["prepare_put_object"], ); assert_function_not_contains( source, "fn prepare_editor_generated_image_object_data", "struct PersistEditorProviderSourceResourceInput", &[ "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", ".put_object(", ".head_object(", ], ); // 中文注释:执行 helper 的标记必须在第一次 PUT 之后才出现。OSS 未配置仍发生在 PUT // 之前,标了是反向谎报——会让客户端对着什么都没落库的失败去核对素材库。 assert_function_contains_in_order( source, "async fn upload_editor_generated_image_object_prepared", "async fn persist_editor_provider_source_image", &[ "result_persistence_started.store(true, Ordering::Release)", ".put_object(http_client, prepared.request)", "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", ], ); } #[test] fn editor_paid_image_postprocess_keeps_provider_outputs_recoverable() { let source = concat!( include_str!("editor_project_icon.rs"), 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", "prepare_editor_generated_image", "persist_editor_generation_result_atomically", ], ); 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", "prepare_editor_generated_image", "persist_editor_generation_result_atomically", ], ); assert_function_occurrence_count( source, "pub(crate) async fn edit_editor_image_for_owner", "pub async fn remove_editor_image_background", "prepare_editor_generated_image(", 1, ); assert_function_occurrence_count( source, "pub(crate) async fn generate_editor_image_for_owner", "fn normalize_editor_image_generation_size", "prepare_editor_generated_image(", 2, ); assert_function_occurrence_count( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn split_editor_icon_spritesheet", "prepare_editor_generated_image(", 2, ); assert_function_occurrence_count( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", "pub async fn split_editor_icon_spritesheet", "prepare_editor_spritesheet_slices_for_generation(", 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", "prepare_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", "prepare_editor_generated_image", "prepare_editor_spritesheet_slices_for_generation", "persist_editor_generation_result_atomically", ], ); 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", &[ "prepare_editor_generated_asset", "remove_editor_generated_screen_background_with_bgfilter", "EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND", "prepare_editor_generated_asset", "slice_editor_icon_spritesheet_all", "prepare_editor_spritesheet_slices_for_generation", "persist_editor_generation_result_atomically", ], ); 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); } #[test] fn optional_folder_generators_restore_their_project_folder_defaults() { assert_eq!( resolve_editor_image_generation_target_folder_id(None, Some("character")).as_deref(), Some(EDITOR_ASSET_DEFAULT_FOLDER_ID) ); assert_eq!( resolve_editor_image_generation_target_folder_id(None, Some("scene")), None ); assert_eq!( resolve_editor_image_generation_target_folder_id( Some("owner:asset-folder:custom".to_string()), Some("character"), ) .as_deref(), Some("owner:asset-folder:custom") ); assert_eq!( resolve_editor_ui_design_asset_extraction_target_folder_id(None).as_deref(), Some(EDITOR_ASSET_DEFAULT_FOLDER_ID) ); let source = include_str!("editor_project.rs"); for (start, end, resolver) in [ ( "pub(crate) async fn enqueue_editor_image_generation_for_owner", "pub(crate) async fn validate_editor_image_generation_parameters_for_owner", "resolve_editor_image_generation_target_folder_id(", ), ( "pub(crate) async fn generate_editor_image_for_owner_with_prompt_builder", "fn editor_image_generation_billing_asset_kind", "resolve_editor_image_generation_target_folder_id(", ), ( "pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner", "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "resolve_editor_ui_design_asset_extraction_target_folder_id(", ), ( "pub(crate) async fn extract_editor_ui_design_assets_for_owner", "pub(crate) fn editor_project_payload_from_record", "resolve_editor_ui_design_asset_extraction_target_folder_id(", ), ] { assert_function_contains(source, start, end, &[resolver]); } } 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" ); } }