Files
Genarrative/server-rs/crates/api-server/src/editor_project.rs
T
kdletters edc99e55e4
Project CI / Frontend tests (push) Failing after 43s
Project CI / Repository checks (push) Successful in 47s
Project CI / Native shell tests (push) Successful in 2m47s
Project CI / Backend tests (push) Successful in 3m13s
修复跨设备画布旧快照覆盖
按用户隔离画布缓存并等待权威 revision 后开放保存
固化保存重试版本并安全处理认证重载与冲突恢复
合并迟到资源图层且隔离跨账号异步请求
强制 Web 与 External 画布写入使用 expectedRevision CAS
补充竞态回归测试并同步 OpenAPI、Skill 与技术文档
2026-07-29 13:51:49 +08:00

11603 lines
433 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
borrow::Cow,
collections::BTreeMap,
io::Cursor,
time::{Duration, Instant},
};
use axum::{
Json,
extract::{Extension, Path, Query, State},
http::StatusCode,
};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input,
generate_asset_object_id,
};
use platform_image::{
DownloadedImage,
generated_asset_sheets::{
GeneratedAssetSheetConnectedIcon,
slice_generated_icon_spritesheet_all_by_connected_components,
slice_generated_icon_spritesheet_by_connected_components,
},
};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::assets::{
EDITOR_ASSET_LABEL_MAX_CHARS,
EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest,
EditorCanvasGenerationPlaceholderPayload,
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord,
EditorAssetFolderUpdateRecordInput, EditorAssetGroupCohortCompleteRecordInput,
EditorAssetGroupSourceLookupRecordInput, EditorAssetLibraryRecord,
EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput,
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput,
EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput,
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput,
EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord,
ExternalGenerationJobPhaseUpdateError, ExternalGenerationJobPhaseUpdateRecordInput,
SpacetimeClientError,
};
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation_with_cost,
auth::AuthenticatedAccessToken,
editor_generation_queue::{
EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND,
EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND,
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EditorGenerationQueuedResponse,
editor_generation_queue_state, editor_generation_source_entity_id,
enqueue_editor_generation_job,
},
editor_green_screen::{
EditorScreenBackgroundColor, editor_green_screen_asset_prompt_clause,
editor_green_screen_character_prompt_clause, editor_ui_design_asset_extraction_prompt,
remove_editor_generated_green_screen_background,
},
editor_screen_background_decision::{
EditorScreenBackgroundDecision, EditorScreenBackgroundDecisionInput,
EditorScreenBackgroundDecisionKind, resolve_editor_screen_background_color,
},
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
decode_generated_image_asset_data_url, normalize_generated_image_asset_mime,
},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiReferenceImage,
build_openai_image_http_client, create_openai_image_edit_with_references_and_model,
create_openai_image_generation_with_model, create_openai_nanobanana_generate_content,
require_openai_image_settings,
},
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
work_author::{ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID},
};
const EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE: usize = 36;
pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "proj-";
pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
pub(crate) const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-";
pub(crate) const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
const EDITOR_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024;
pub(crate) const EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES: usize = 4 * 1024 * 1024;
pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
const EDITOR_ASSET_METADATA_MAX_BYTES: usize = 64 * 1024;
const EDITOR_ASSET_DEFAULT_FOLDER_ID: &str = "project";
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION: u32 = 4096;
const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS: u64 = 2048 * 2048;
const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES: usize = 64;
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5;
const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image";
const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project";
const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character";
const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image";
const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image";
const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image";
const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image";
const EDITOR_IMAGE_EDIT_ASSET_KIND: &str = "editor_image_edit";
const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet";
const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice";
const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components";
const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed";
const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved";
const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback";
const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1;
const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet";
const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset";
const EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source";
const EDITOR_PROVIDER_SOURCE_SLOT: &str = "provider_source";
pub(crate) const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet";
const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg";
const EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS: u64 = 600;
pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_ENABLED: bool = true;
pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_DISABLED: bool = false;
const EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW: Duration = Duration::from_secs(2);
const EDITOR_BGFILTER_FLAT_LOCAL_RESERVE: Duration = Duration::from_secs(7);
const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material";
const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectCreateRequest {
title: Option<String>,
}
#[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<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: String,
prompt: Option<String>,
actual_prompt: Option<String>,
model: Option<String>,
provider: Option<String>,
task_id: Option<String>,
source_resource_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
}
#[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<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorAssetFolderUpdateRequest {
label: Option<String>,
collapsed: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorAssetCreateRequest {
folder_id: String,
label: String,
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: String,
prompt: Option<String>,
actual_prompt: Option<String>,
model: Option<String>,
provider: Option<String>,
task_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorAssetUpdateRequest {
label: Option<String>,
folder_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorImageGenerationRequest {
pub(crate) prompt: String,
pub(crate) size: Option<String>,
pub(crate) kind: Option<String>,
pub(crate) model: Option<String>,
pub(crate) screen_color: Option<String>,
pub(crate) seg_model: Option<String>,
pub(crate) aspect_ratio: Option<String>,
pub(crate) image_size: Option<String>,
pub(crate) reference_image_srcs: Option<Vec<String>>,
pub(crate) project_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) asset_label: Option<String>,
pub(crate) source_resource_id: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
#[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<String>,
pub(crate) model: Option<String>,
pub(crate) aspect_ratio: Option<String>,
pub(crate) image_size: Option<String>,
pub(crate) reference_image_srcs: Option<Vec<String>>,
pub(crate) project_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) asset_label: Option<String>,
pub(crate) source_resource_id: Option<String>,
pub(crate) target_layer_id: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorBackgroundRemovalRequest {
pub(crate) source_image_src: String,
pub(crate) project_id: Option<String>,
pub(crate) target_layer_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) asset_label: Option<String>,
pub(crate) source_resource_id: Option<String>,
pub(crate) task_id: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetGenerationRequest {
pub(crate) reference_image_src: String,
pub(crate) reference_image_srcs: Option<Vec<String>>,
pub(crate) icon_descriptions: Vec<String>,
pub(crate) model: Option<String>,
pub(crate) screen_color: Option<String>,
pub(crate) seg_model: Option<String>,
pub(crate) aspect_ratio: Option<String>,
pub(crate) image_size: Option<String>,
pub(crate) project_id: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) asset_label: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetSliceRequest {
pub(crate) project_id: String,
pub(crate) source_resource_id: String,
pub(crate) asset_folder_id: Option<String>,
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<String>,
pub(crate) screen_color: Option<String>,
pub(crate) seg_model: Option<String>,
pub(crate) reference_image_srcs: Option<Vec<String>>,
pub(crate) aspect_ratio: String,
pub(crate) image_size: String,
pub(crate) project_id: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) spritesheet_label: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
pub(crate) struct EditorGenerationCaller {
pub(crate) owner_user_id: String,
pub(crate) audit_subject_user_id: Option<String>,
pub(crate) audit_project_id: Option<String>,
pub(crate) phase_reporter: Option<EditorGenerationPhaseReporter>,
}
impl EditorGenerationCaller {
fn from_authenticated(authenticated: &AuthenticatedAccessToken) -> Self {
let owner_user_id = authenticated.claims().user_id().to_string();
Self {
audit_subject_user_id: Some(owner_user_id.clone()),
owner_user_id,
audit_project_id: None,
phase_reporter: None,
}
}
async fn report_processing_phase(&self, state: &AppState) -> Result<(), AppError> {
if let Some(reporter) = self.phase_reporter.as_ref() {
reporter.report_processing(state).await?;
}
Ok(())
}
}
#[derive(Clone, Debug)]
pub(crate) struct EditorGenerationPhaseReporter {
job_id: String,
worker_id: String,
lease_token: String,
}
impl EditorGenerationPhaseReporter {
pub(crate) fn new(job_id: String, worker_id: String, lease_token: String) -> Self {
Self {
job_id,
worker_id,
lease_token,
}
}
pub(crate) async fn report_processing(&self, state: &AppState) -> Result<(), AppError> {
let input = ExternalGenerationJobPhaseUpdateRecordInput {
job_id: self.job_id.clone(),
worker_id: self.worker_id.clone(),
lease_token: self.lease_token.clone(),
phase: "processing".to_string(),
};
let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1;
for attempt in 1..=max_attempts {
match state
.spacetime_client()
.update_external_generation_job_phase(input.clone())
.await
{
Ok(_) => return Ok(()),
Err(error) => {
let will_retry =
should_retry_editor_generation_phase_update(&error, attempt, max_attempts);
tracing::warn!(
provider = "external-generation-phase",
job_id = %self.job_id,
attempt,
max_attempts,
will_retry,
lease_fencing_rejected = error.is_lease_fencing_rejected(),
error = %error,
"external_generation_processing_phase_report_failed"
);
if will_retry {
continue;
}
return Err(map_editor_generation_phase_update_error(error));
}
}
}
unreachable!("phase report retry loop should return on success or final error")
}
}
fn should_retry_editor_generation_phase_update(
error: &ExternalGenerationJobPhaseUpdateError,
attempt: usize,
max_attempts: usize,
) -> bool {
error.is_retryable_transport() && attempt < max_attempts
}
fn map_editor_generation_phase_update_error(
error: ExternalGenerationJobPhaseUpdateError,
) -> AppError {
let status = if error.is_lease_fencing_rejected() {
StatusCode::CONFLICT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": "external-generation-phase",
"message": format!("更新外部生成任务处理阶段失败:{error}"),
}))
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResponse {
project: EditorProjectPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectLayoutSaveResponse {
project_id: String,
canvas_id: String,
revision: u64,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectRecentResponse {
project: Option<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectListResponse {
projects: Vec<EditorProjectPayload>,
}
#[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<EditorProjectResourcePayload>,
next_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
campaign: Option<EditorShowcaseCampaignPayload>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourceListQuery {
cursor: Option<String>,
}
#[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<EditorProjectResourcePayload>,
pub(crate) asset: Option<EditorAssetPayload>,
}
struct EditorCanvasGenerationCompletionResult {
layers: Value,
changed: bool,
}
#[derive(Debug)]
pub(crate) struct PersistEditorGeneratedAssetInput {
pub(crate) project_id: Option<String>,
pub(crate) owner_user_id: String,
pub(crate) folder_id: Option<String>,
pub(crate) label: String,
pub(crate) image_src: String,
pub(crate) object_key: Option<String>,
pub(crate) asset_object_id: Option<String>,
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) prompt: String,
pub(crate) actual_prompt: Option<String>,
pub(crate) model: String,
pub(crate) provider: String,
pub(crate) task_id: String,
pub(crate) group_task_id: Option<String>,
pub(crate) group_task_expected_asset_count: Option<u32>,
pub(crate) source_resource_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) thumbnail_src: Option<String>,
pub(crate) generation_cost_mud_points: u64,
}
pub(crate) struct PersistEditorGeneratedAssetRequest {
pub(crate) project_id: Option<String>,
pub(crate) owner_user_id: String,
pub(crate) folder_id: Option<String>,
pub(crate) label: String,
pub(crate) image_src: String,
pub(crate) object_key: Option<String>,
pub(crate) asset_object_id: Option<String>,
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) prompt: String,
pub(crate) actual_prompt: Option<String>,
pub(crate) model: String,
pub(crate) provider: String,
pub(crate) task_id: String,
pub(crate) source_resource_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) thumbnail_src: Option<String>,
pub(crate) generation_cost_mud_points: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct EditorGeneratedImageStorageProfile {
asset_kind: &'static str,
path_kind: &'static str,
slot: &'static str,
}
pub(crate) struct EditorCanvasGeneratedLayerInput {
pub(crate) layer_id: String,
pub(crate) resource_id: String,
pub(crate) title: String,
pub(crate) src: String,
pub(crate) media_type: Option<String>,
pub(crate) thumbnail_src: Option<String>,
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<String>,
pub(crate) actual_prompt: Option<String>,
pub(crate) model: Option<String>,
pub(crate) provider: Option<String>,
pub(crate) task_id: Option<String>,
pub(crate) object_key: Option<String>,
pub(crate) asset_object_id: Option<String>,
pub(crate) duration_seconds: Option<u32>,
pub(crate) source_resource_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) image_sequence_frames: Option<Value>,
pub(crate) preview_video_path: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorGenerationWarningResponse {
code: &'static str,
reason: String,
}
fn editor_postprocess_fallback_warning(reason: &'static str) -> EditorGenerationWarningResponse {
EditorGenerationWarningResponse {
code: EDITOR_GENERATION_POSTPROCESS_WARNING_CODE,
reason: reason.to_string(),
}
}
fn editor_dimension_restore_warning(error: &AppError) -> EditorGenerationWarningResponse {
let reason = error
.details()
.and_then(|details| details.get("message"))
.and_then(Value::as_str)
.unwrap_or("provider 回图未能归一到目标交付尺寸,已保留实际原图尺寸。")
.to_string();
EditorGenerationWarningResponse {
code: EDITOR_GENERATION_DIMENSION_WARNING_CODE,
reason,
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorImageGenerationResponse {
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: &'static str,
prompt: String,
actual_prompt: Option<String>,
model: String,
provider: &'static str,
task_id: String,
resource: Option<EditorProjectResourcePayload>,
asset: Option<EditorAssetPayload>,
project: Option<EditorProjectPayload>,
#[serde(skip_serializing_if = "Option::is_none")]
warning: Option<EditorGenerationWarningResponse>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorBackgroundRemovalResponse {
image_src: String,
object_key: String,
asset_object_id: String,
width: u32,
height: u32,
source_type: &'static str,
task_id: String,
elapsed_ms: u64,
provider: &'static str,
resource: Option<EditorProjectResourcePayload>,
asset: Option<EditorAssetPayload>,
project: Option<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetIconResponse {
name: String,
image_src: String,
width: u32,
height: u32,
resource: Option<EditorProjectResourcePayload>,
asset: Option<EditorAssetPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetSliceWarningResponse {
code: &'static str,
reason: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetGenerationResponse {
spritesheet_image_src: String,
spritesheet_width: u32,
spritesheet_height: u32,
icon_image_srcs: Vec<EditorIconSpritesheetIconResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
slice_warning: Option<EditorIconSpritesheetSliceWarningResponse>,
prompt: String,
actual_prompt: Option<String>,
model: String,
provider: &'static str,
task_id: String,
price_mud_points: u32,
spritesheet_resource: Option<EditorProjectResourcePayload>,
spritesheet_asset: Option<EditorAssetPayload>,
project: Option<EditorProjectPayload>,
#[serde(skip_serializing_if = "Option::is_none")]
warning: Option<EditorGenerationWarningResponse>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetSliceResponse {
icon_image_srcs: Vec<EditorIconSpritesheetIconResponse>,
project: EditorProjectPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectPayload {
project_id: String,
title: String,
canvas: EditorCanvasPayload,
viewport: EditorCanvasViewportPayload,
layers: Value,
resources: Vec<EditorProjectResourcePayload>,
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<String>,
created_at: String,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourcePayload {
pub(crate) resource_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
showcase_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
asset_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>,
project_id: String,
owner_user_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
author_display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
author_public_user_code: Option<String>,
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: String,
prompt: Option<String>,
actual_prompt: Option<String>,
model: Option<String>,
provider: Option<String>,
task_id: Option<String>,
source_resource_id: Option<String>,
asset_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
showcase_category: Option<String>,
generation_inputs: Option<Value>,
public_showcase_enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
review_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
display_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
like_count: Option<u64>,
generation_cost_mud_points: u64,
#[serde(skip_serializing_if = "Option::is_none")]
refund_mud_points: Option<u64>,
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<String>,
image_width: Option<u32>,
image_height: Option<u32>,
prompt: String,
author: String,
cost_text: String,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorAssetLibraryPayload {
folders: Vec<EditorAssetFolderPayload>,
assets: Vec<EditorAssetPayload>,
}
#[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<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: String,
prompt: Option<String>,
actual_prompt: Option<String>,
model: Option<String>,
provider: Option<String>,
task_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
source_resource_id: Option<String>,
public_showcase_enabled: Option<bool>,
thumbnail_src: Option<String>,
generation_cost_mud_points: u64,
showcase_id: Option<String>,
showcase_review_status: Option<String>,
showcase_display_enabled: Option<bool>,
showcase_like_count: Option<u64>,
created_at: String,
updated_at: String,
}
pub async fn load_recent_editor_project(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Query(query): Query<EditorProjectResourceListQuery>,
) -> Result<Json<Value>, 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<String> {
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<i64> {
let (seconds, micros) = value.trim().strip_suffix('Z')?.split_once('.')?;
if micros.len() != 6 {
return None;
}
seconds
.parse::<i64>()
.ok()?
.checked_mul(1_000_000)?
.checked_add(micros.parse::<i64>().ok()?)
}
pub async fn create_editor_project(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectCreateRequest>,
) -> Result<Json<Value>, 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<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectLayoutSaveRequest>,
) -> Result<Json<Value>, 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<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectRenameRequest>,
) -> Result<Json<Value>, 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<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectResourceCreateRequest>,
) -> Result<Json<Value>, AppError> {
let generation_inputs_json =
serialize_editor_asset_metadata(payload.generation_inputs.clone())?;
let object_key = normalize_editor_object_key(payload.object_key);
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
let resource = state
.spacetime_client()
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
project_id,
owner_user_id: authenticated.claims().user_id().to_string(),
asset_object_id: normalize_optional_string(payload.asset_object_id),
image_src,
object_key,
width: payload.width,
height: payload.height,
source_type: payload.source_type,
prompt: normalize_optional_string(payload.prompt),
actual_prompt: normalize_optional_string(payload.actual_prompt),
model: normalize_optional_string(payload.model),
provider: normalize_optional_string(payload.provider),
task_id: normalize_optional_string(payload.task_id),
source_resource_id: normalize_optional_string(payload.source_resource_id),
asset_kind: normalize_optional_string(payload.asset_kind),
generation_inputs_json,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectResourceResponse {
resource: editor_project_resource_payload_from_record(resource),
},
))
}
pub async fn update_editor_project_resource_showcase(
State(state): State<AppState>,
Path(resource_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectResourceShowcaseUpdateRequest>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorAssetFolderCreateRequest>,
) -> Result<Json<Value>, 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<AppState>,
Path(folder_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorAssetFolderUpdateRequest>,
) -> Result<Json<Value>, 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<AppState>,
Path(folder_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorAssetCreateRequest>,
) -> Result<Json<Value>, AppError> {
let generation_inputs_json =
serialize_editor_asset_metadata(payload.generation_inputs.clone())?;
let object_key = normalize_editor_object_key(payload.object_key);
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
let asset = state
.spacetime_client()
.create_editor_asset(EditorAssetCreateRecordInput {
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
owner_user_id: current_owner_user_id(&authenticated),
folder_id: payload.folder_id,
label: payload.label,
asset_object_id: normalize_optional_string(payload.asset_object_id),
image_src,
object_key,
width: payload.width,
height: payload.height,
source_type: payload.source_type,
prompt: normalize_optional_string(payload.prompt),
actual_prompt: normalize_optional_string(payload.actual_prompt),
model: normalize_optional_string(payload.model),
provider: normalize_optional_string(payload.provider),
task_id: normalize_optional_string(payload.task_id),
asset_kind: normalize_optional_string(payload.asset_kind),
generation_inputs_json,
source_resource_id: None,
now_micros: current_utc_micros(),
thumbnail_src: None,
generation_cost_mud_points: 0,
group_task_id: None,
group_task_expected_asset_count: None,
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorAssetResponse {
asset: editor_asset_payload_from_record(asset),
},
))
}
pub async fn update_editor_asset(
State(state): State<AppState>,
Path(asset_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorAssetUpdateRequest>,
) -> Result<Json<Value>, 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<AppState>,
Path(asset_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(asset_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(showcase_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorShowcaseAssetLikeRequest>,
) -> Result<Json<Value>, AppError> {
let record = state
.spacetime_client()
.toggle_editor_showcase_asset_like(EditorShowcaseAssetLikeToggleRecordInput {
showcase_id,
user_id: current_owner_user_id(&authenticated),
liked: payload.liked,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorShowcaseAssetResponse {
showcase_asset: editor_project_resource_payload_from_showcase_record(record),
},
))
}
pub async fn generate_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorImageGenerationRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-image-generation",
"referenceImageSrcs",
"生成参考图",
)?;
let normalized_kind = payload.kind.as_deref().map(str::trim);
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
let is_publication_material_generation =
matches!(normalized_kind, Some("publication-material"));
let generation_options = normalize_editor_generation_options(
if is_ui_design_generation || is_publication_material_generation {
Some(GPT_IMAGE_2_MODEL)
} else {
payload.model.as_deref()
},
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let price_mud_points = u64::from(
state
.editor_generation_pricing()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(
normalized_kind,
Some(generation_options.model),
Some(generation_options.image_size),
),
);
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-image-generation",
);
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
caller.owner_user_id.as_str(),
EDITOR_IMAGE_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成图片",
price_mud_points,
&payload,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
generate_editor_image_for_owner(&state, &request_context, caller, payload).await
}
pub(crate) async fn generate_editor_image_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorImageGenerationRequest,
) -> Result<Json<Value>, AppError> {
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-image-generation",
"referenceImageSrcs",
"生成参考图",
)?;
let role_setting = payload.prompt.trim().to_string();
if role_setting.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-generation",
"message": "生成提示词不能为空",
})),
);
}
let normalized_kind = payload.kind.as_deref().map(str::trim);
let is_character_generation = matches!(normalized_kind, Some("character"));
// 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时
// 仍白发一次 gpt-5-mini 决策。这里先固化决策需要、但随后会被 payload 消费掉的输入。
let requested_screen_color = payload.screen_color.clone();
let reference_count = payload.reference_image_srcs.as_ref().map_or(0, Vec::len);
let seg_model = if is_character_generation {
Some(parse_editor_bgfilter_seg_model(
payload.seg_model.as_deref(),
)?)
} else {
None
};
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
let is_publication_material_generation =
matches!(normalized_kind, Some("publication-material"));
let generation_options = normalize_editor_generation_options(
if is_ui_design_generation || is_publication_material_generation {
Some(GPT_IMAGE_2_MODEL)
} else {
payload.model.as_deref()
},
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some();
let image_size = resolve_editor_image_request_size(
normalized_kind,
payload.size.as_deref(),
has_dimension_options,
&generation_options,
);
let provider_request_size = resolve_editor_image_provider_request_size(
normalized_kind,
payload.size.as_deref(),
has_dimension_options,
&generation_options,
);
let failure_context = match normalized_kind {
Some("character") => "图片画布生成角色形象",
Some("spec") => "图片画布生成规范",
Some("quick-edit") => "图片画布快速编辑图片",
Some("ui-design") => "图片画布生成UI设计图",
Some("publication-material") => "图片画布生成宣发素材",
_ => "图片画布生成图片",
};
let reference_limit = if matches!(normalized_kind, Some("quick-edit")) {
9
} else {
5
};
let reference_sources = payload
.reference_image_srcs
.unwrap_or_default()
.into_iter()
.map(|source| source.trim().to_string())
.filter(|source| !source.is_empty())
.take(reference_limit)
.collect::<Vec<_>>();
// 决策移入闭包后 payload.reference_image_srcs 已被 reference_sources 消费,
// 这里从过滤后的 reference_sources 预先固化 ui-design 的「是否带参考图」布尔。
let ui_has_references = !reference_sources.is_empty();
let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体");
let reference_images = if reference_sources.is_empty() {
Vec::new()
} else {
let mut parsed_reference_images = Vec::with_capacity(reference_sources.len());
for source in &reference_sources {
parsed_reference_images.push(
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source.as_str())
.await?,
);
}
parsed_reference_images
};
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let image_size = if is_ui_design_generation {
Cow::Owned(generation_options.size.clone())
} else {
image_size
};
let provider_request_size = if is_ui_design_generation {
Cow::Owned(generation_options.provider_size.clone())
} else {
provider_request_size
};
let configured_price_mud_points = state
.editor_generation_pricing()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(
normalized_kind,
Some(generation_options.model),
Some(generation_options.image_size),
);
let generate_operation = async {
// 背景色决策:预扣泥点之后才发起,仅角色生图需要(决策失败→future 返回 Err→失败退款,
// 用户不损失泥点,平台也只在用户确有余额时才付这次 gpt-5-mini 决策)。
let screen_background_decision = if is_character_generation {
Some(
resolve_editor_screen_background_color(
state.llm_client(),
state.editor_agent_llm_client(),
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::Character,
screen_color: requested_screen_color.clone(),
prompt: role_setting.clone(),
icon_descriptions: Vec::new(),
reference_count,
source_image_data_url: None,
},
Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
state,
context: crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
external_call_deadline: request_context.external_call_deadline(),
},
}),
)
.await?,
)
} else {
None
};
let screen_color = screen_background_decision
.as_ref()
.map(|decision| decision.color);
let submitted_prompt = if is_character_generation {
build_editor_character_image_prompt(
role_setting.as_str(),
screen_color.expect("character generation should have screen color"),
)
} else if is_ui_design_generation {
build_editor_ui_design_prompt(role_setting.as_str(), ui_has_references)
} else {
role_setting.clone()
};
let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
create_openai_nanobanana_generate_content(
&http_client,
&settings,
generation_options.model,
submitted_prompt.as_str(),
negative_prompt,
generation_options.aspect_ratio,
generation_options.provider_image_size,
reference_images.as_slice(),
failure_context,
)
.await?
} else if reference_images.is_empty() {
create_openai_image_generation_with_model(
&http_client,
&settings,
generation_options.model,
submitted_prompt.as_str(),
negative_prompt,
provider_request_size.as_ref(),
1,
&[],
failure_context,
)
.await?
} else {
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
submitted_prompt.as_str(),
negative_prompt,
provider_request_size.as_ref(),
1,
reference_images.as_slice(),
failure_context,
)
.await?
};
Ok::<_, AppError>((generated, submitted_prompt, screen_background_decision))
};
let (generated, submitted_prompt, screen_background_decision) =
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
editor_image_generation_billing_asset_kind(normalized_kind),
request_context.request_id(),
u64::from(configured_price_mud_points),
generate_operation,
)
.await?;
let screen_color = screen_background_decision
.as_ref()
.map(|decision| decision.color);
let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs(
payload.generation_inputs.clone(),
screen_background_decision.as_ref(),
);
let asset_label =
resolve_editor_generated_asset_label(payload.asset_label.clone(), role_setting.as_str());
let mut image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图片",
}))
})?;
let (provider_width, provider_height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("素材生成结果不是有效图片:{error}"),
}))
})?;
// 中文注释:nanobanana2 的 2K 是 provider 清晰度档位,16:9 实际可能返回
// 2752x1536;画布业务规格统一使用 512 / 1024 / 2048 长边像素矩阵,持久化前归一,
// 保证不同模型的完成图与生成前占位标注一致。
let (restored_image, dimension_restore_error) = if is_character_generation {
// 角色任务的 provider 原图是独立可复用中间产物,先按原始分辨率保存;
// 透明主结果会在后处理完成后再归一到画布业务规格。
(image, None)
} else {
restore_editor_generated_image_output_dimensions_or_original(
image,
generation_options.model,
image_size.as_ref(),
)
};
let mut dimension_warning = dimension_restore_error
.as_ref()
.map(editor_dimension_restore_warning);
if let Some(error) = dimension_restore_error {
tracing::warn!(
task_id = %generated.task_id,
provider_width,
provider_height,
target_size = %image_size,
error = ?error,
"画板生成图片尺寸恢复失败,保留并上传 provider 原图"
);
}
image = restored_image;
let (delivery_width, delivery_height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((provider_width, provider_height));
let storage_profile = editor_generated_image_storage_profile(normalized_kind);
let mut output_prompt = submitted_prompt.clone();
let mut output_actual_prompt = generated.actual_prompt.clone();
let mut output_model = generation_options.model.to_string();
let mut output_provider = "VectorEngine".to_string();
let mut output_generation_inputs = generation_inputs.clone();
let source_record = if is_character_generation {
let source_persisted = persist_editor_provider_source_image(
state,
caller.owner_user_id.as_str(),
generated.task_id.as_str(),
image,
submitted_prompt.as_str(),
generated.actual_prompt.as_deref(),
storage_profile.asset_kind,
"character-images",
"character-image",
)
.await?;
let source_image_src =
editor_media_src_from_object_key(source_persisted.object_key.as_str());
let source_object_key = source_persisted.object_key.clone();
let source_asset_object_id = source_persisted.asset_object_id.clone();
let source_record = persist_editor_provider_source_resource(
state,
source_persisted,
PersistEditorProviderSourceResourceInput {
project_id: payload.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: payload
.asset_folder_id
.clone()
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
label: editor_generated_asset_variant_label(asset_label.as_str(), "原图"),
width: delivery_width,
height: delivery_height,
prompt: role_setting.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
task_id: generated.task_id.clone(),
source_resource_id: payload.source_resource_id.clone(),
asset_kind: payload
.asset_kind
.clone()
.or_else(|| Some("character".to_string())),
generation_inputs: generation_inputs.clone(),
generation_cost_mud_points: u64::from(configured_price_mud_points),
},
)
.await?;
caller.report_processing_phase(state).await?;
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
external_call_deadline: request_context.external_call_deadline(),
};
let removal = remove_editor_generated_screen_background_with_bgfilter(
state,
source_object_key.as_str(),
screen_color.expect("character generation should have screen color"),
seg_model.expect("character generation should have BgFilter seg model"),
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
&matting_audit,
)
.await;
let removal = match removal {
Ok(removal) => removal,
Err(error) => {
let failure_message = error.body_text();
tracing::warn!(
provider = "editor-character-image",
operation = "background_postprocess",
task_id = %generated.task_id,
error = %failure_message,
"角色原图已保存,但透明背景处理失败,使用原图完成画布"
);
let completed_project = complete_editor_canvas_generation(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
source_record.resource.as_ref(),
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src: source_image_src,
object_key: Some(source_object_key),
asset_object_id: Some(source_asset_object_id),
width: delivery_width,
height: delivery_height,
source_type: "generated",
prompt: role_setting,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
resource: source_record.resource,
asset: source_record.asset,
project: completed_project,
warning: Some(editor_postprocess_fallback_warning(
"生成任务成功,后处理失败。",
)),
},
));
}
};
let removal_provider = removal.provider;
let (restored_removal_image, postprocess_dimension_error) =
apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original(
state,
source_object_key.as_str(),
delivery_width,
delivery_height,
removal.image,
)
.await;
if let Some(error) = postprocess_dimension_error {
tracing::warn!(
task_id = %generated.task_id,
provider = removal_provider,
provider_width,
provider_height,
delivery_width,
delivery_height,
error = ?error,
"角色透明图尺寸恢复失败,保留去背景服务原始输出"
);
}
image = restored_removal_image;
output_prompt = "去除纯色背景".to_string();
output_actual_prompt = None;
output_model = removal.model;
output_provider = removal_provider.to_string();
output_generation_inputs =
build_editor_derived_asset_generation_inputs("角色抠图", &output_model, &source_record);
Some(source_record)
} else {
None
};
if is_character_generation {
let (restored_image, dimension_restore_error) =
restore_editor_generated_image_output_dimensions_or_original(
image,
generation_options.model,
image_size.as_ref(),
);
if let Some(error) = dimension_restore_error {
dimension_warning = Some(editor_dimension_restore_warning(&error));
tracing::warn!(
task_id = %generated.task_id,
provider_width,
provider_height,
target_size = %image_size,
error = ?error,
"画板角色透明主图尺寸恢复失败,保留后处理原图"
);
}
image = restored_image;
}
let (width, height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((1024, 1024));
let persisted = persist_editor_generated_image(
state,
caller.owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
output_prompt.as_str(),
output_actual_prompt.as_deref(),
storage_profile.asset_kind,
storage_profile.path_kind,
"image",
storage_profile.slot,
output_provider.as_str(),
)
.await?;
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let provider_source_resource_id = source_record
.as_ref()
.and_then(|record| record.resource.as_ref())
.map(|resource| resource.resource_id.clone());
let generated_asset = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: payload.asset_folder_id,
label: asset_label,
image_src: image_src.clone(),
object_key: Some(persisted.object_key.clone()),
asset_object_id: Some(persisted.asset_object_id.clone()),
width,
height,
prompt: output_prompt,
actual_prompt: output_actual_prompt,
model: output_model,
provider: output_provider,
task_id: generated.task_id.clone(),
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: provider_source_resource_id.or(payload.source_resource_id),
asset_kind: payload.asset_kind,
generation_inputs: output_generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: if is_character_generation {
0
} else {
u64::from(configured_price_mud_points)
},
},
)
.await?;
let (canvas_items, primary_layer_id) =
if let Some(completion) = payload.canvas_completion.as_ref() {
build_multi_asset_canvas_layer_items(
completion,
generated_asset.resource.as_ref(),
source_record
.as_ref()
.and_then(|record| record.resource.as_ref()),
)
} else {
(Vec::new(), None)
};
let completed_project = complete_editor_canvas_generation_with_items(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
canvas_items,
primary_layer_id,
)
.await?;
Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
width,
height,
source_type: "generated",
prompt: role_setting,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
resource: generated_asset.resource,
asset: generated_asset.asset,
project: completed_project,
warning: dimension_warning,
},
))
}
fn editor_image_generation_billing_asset_kind(normalized_kind: Option<&str>) -> &'static str {
match normalized_kind {
Some("character") => EDITOR_CHARACTER_IMAGE_ASSET_KIND,
Some("spec") => EDITOR_SPEC_IMAGE_ASSET_KIND,
Some("quick-edit") => EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND,
Some("ui-design") => EDITOR_UI_DESIGN_IMAGE_ASSET_KIND,
Some("publication-material") => EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
_ => EDITOR_GENERATED_IMAGE_ASSET_KIND,
}
}
pub(crate) fn apply_editor_screen_background_decision_to_generation_inputs(
generation_inputs: Option<Value>,
decision: Option<&EditorScreenBackgroundDecision>,
) -> Option<Value> {
let Some(decision) = decision else {
return generation_inputs;
};
let mut value = match generation_inputs {
Some(value) if value.is_object() => value,
Some(other) => return Some(other),
None => json!({ "fields": [], "references": [] }),
};
// 抠图背景色是内部自动决策 / 兜底细节,产品契约要求不进用户可见的 generationInputs.fields;
// 只写顶层内部可追踪元数据 screenColorHex(不属于用户输入快照,前端展示不读取)。
value["screenColorHex"] = Value::String(decision.color.hex.to_string());
Some(value)
}
fn build_editor_derived_asset_generation_inputs(
stage: &str,
model: &str,
source: &EditorGeneratedAssetRecord,
) -> Option<Value> {
let reference = source
.resource
.as_ref()
.map(|resource| {
json!({
"title": "输入素材",
"label": source
.asset
.as_ref()
.map(|asset| asset.label.as_str())
.unwrap_or("上一步产物"),
"refType": "project-resource",
"refId": resource.resource_id,
})
})
.or_else(|| {
source.asset.as_ref().map(|asset| {
json!({
"title": "输入素材",
"label": asset.label,
"refType": "asset",
"refId": asset.asset_id,
})
})
});
Some(json!({
"fields": [
{ "title": "处理阶段", "value": stage },
{ "title": "处理模型", "value": model },
],
"references": reference.into_iter().collect::<Vec<_>>(),
}))
}
fn editor_generated_image_storage_profile(
normalized_kind: Option<&str>,
) -> EditorGeneratedImageStorageProfile {
match normalized_kind {
Some("character") => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_CHARACTER_IMAGE_ASSET_KIND,
path_kind: "character-images",
slot: EDITOR_CHARACTER_IMAGE_SLOT,
},
Some("spec") => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_SPEC_IMAGE_ASSET_KIND,
path_kind: "spec-images",
slot: "spec",
},
Some("quick-edit") => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND,
path_kind: "quick-edit-images",
slot: "quick-edit",
},
Some("ui-design") => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_UI_DESIGN_IMAGE_ASSET_KIND,
path_kind: "ui-design-images",
slot: "ui-design",
},
Some("publication-material") => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
path_kind: "publication-materials",
slot: "publication-material",
},
_ => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_GENERATED_IMAGE_ASSET_KIND,
path_kind: "generated-images",
slot: "image",
},
}
}
async fn resolve_editor_image_edit_price(
state: &AppState,
model: &str,
size: &str,
image_size: Option<&str>,
) -> Result<u32, AppError> {
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::<u32>() else {
return "1K";
};
let Ok(height) = height.parse::<u32>() 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::<u32>().ok()?;
let height = height.parse::<u32>().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::<u32>() else {
return false;
};
let Ok(height) = height.parse::<u32>() else {
return false;
};
(64..=4096).contains(&width) && (64..=4096).contains(&height)
}
fn normalize_editor_generation_options(
model: Option<&str>,
aspect_ratio: Option<&str>,
image_size: Option<&str>,
) -> EditorGenerationOptions {
let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(GPT_IMAGE_2_MODEL) => GPT_IMAGE_2_MODEL,
Some(
EDITOR_IMAGE_MODEL_NANOBANANA2
| EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS
| EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS,
) => EDITOR_IMAGE_MODEL_NANOBANANA2,
// 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2;
// 角色 / 图标素材入口由前端显式传入 nanobanana2 默认值。
None => GPT_IMAGE_2_MODEL,
_ => EDITOR_IMAGE_MODEL_NANOBANANA2,
};
let aspect_ratio = normalize_editor_generation_aspect_ratio(aspect_ratio);
let image_size = normalize_editor_generation_image_size(normalized_model, image_size);
let size = editor_generation_delivery_size(aspect_ratio, image_size);
let provider_size =
editor_generation_provider_size_for_model(normalized_model, aspect_ratio, image_size);
let provider_image_size =
editor_generation_provider_image_size_for_model(normalized_model, image_size);
EditorGenerationOptions {
model: normalized_model,
aspect_ratio,
image_size,
size,
provider_size,
provider_image_size,
}
}
fn normalize_editor_generation_aspect_ratio(aspect_ratio: Option<&str>) -> &'static str {
match aspect_ratio
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some("2:3") => "2:3",
Some("3:2") => "3:2",
Some("4:3") => "4:3",
Some("9:16") => "9:16",
Some("16:9") => "16:9",
_ => "1:1",
}
}
fn normalize_editor_generation_image_size(model: &str, image_size: Option<&str>) -> &'static str {
match (
model,
image_size.map(str::trim).filter(|value| !value.is_empty()),
) {
(EDITOR_IMAGE_MODEL_NANOBANANA2, Some("0.5K")) => "0.5K",
(_, Some("2K")) => "2K",
_ => "1K",
}
}
fn editor_generation_delivery_size(aspect_ratio: &str, image_size: &str) -> String {
let long_edge = match image_size {
"0.5K" => 512_u32,
"2K" => 2048_u32,
_ => 1024_u32,
};
let (ratio_width, ratio_height) = match aspect_ratio {
"2:3" => (2_u32, 3_u32),
"3:2" => (3_u32, 2_u32),
"4:3" => (4_u32, 3_u32),
"9:16" => (9_u32, 16_u32),
"16:9" => (16_u32, 9_u32),
_ => (1_u32, 1_u32),
};
let (width, height) = if ratio_width >= ratio_height {
(
long_edge,
(long_edge * ratio_height + ratio_width / 2) / ratio_width,
)
} else {
(
(long_edge * ratio_width + ratio_height / 2) / ratio_height,
long_edge,
)
};
format!("{width}x{height}")
}
fn editor_generation_provider_size_for_model(
model: &str,
aspect_ratio: &str,
image_size: &str,
) -> String {
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
return editor_generation_delivery_size(aspect_ratio, image_size);
}
match (image_size, aspect_ratio) {
("2K", "1:1") => "2048x2048",
("2K", "2:3") => "1376x2048",
("2K", "9:16") => "1152x2048",
("2K", "4:3") => "2048x1536",
("2K", "3:2") => "2048x1376",
("2K", "16:9") => "2048x1152",
("1K", "2:3") => "688x1024",
// 中文注释:9:16 的 1024 长边会低于 provider 最小总像素,按原比例放大并做 16 对齐后直接请求。
("1K", "9:16") => "608x1088",
("1K", "4:3") => "1024x768",
("1K", "3:2") => "1024x688",
("1K", "16:9") => "1088x608",
_ => "1024x1024",
}
.to_string()
}
fn editor_generation_provider_image_size_for_model(
model: &str,
image_size: &'static str,
) -> &'static str {
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
return match image_size {
"0.5K" => "512",
"2K" => "2K",
_ => "1K",
};
}
image_size
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct EditorImageEditDimensions {
target_width: u32,
target_height: u32,
provider_width: u32,
provider_height: u32,
}
fn align_editor_image_edit_dimension(value: u32) -> u32 {
value.saturating_add(15) / 16 * 16
}
fn ensure_editor_image_edit_asset_kind_allowed(asset_kind: Option<&str>) -> Result<(), AppError> {
let Some(asset_kind) = asset_kind.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(());
};
if asset_kind != "icon" {
return Ok(());
}
Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "单个图标不支持快速编辑",
"assetKind": asset_kind,
})),
)
}
fn resolve_editor_image_edit_target_layer_asset_kind<'a>(
layers: &'a Value,
resources: &'a [EditorProjectResourcePayload],
target_layer_id: &str,
) -> Result<Option<&'a str>, AppError> {
let target_layer = layers
.as_array()
.and_then(|items| {
items
.iter()
.find(|item| item.get("layerId").and_then(Value::as_str) == Some(target_layer_id))
})
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "快速编辑目标图层不存在",
"targetLayerId": target_layer_id,
}))
})?;
if let Some(asset_kind) = target_layer.get("assetKind").and_then(Value::as_str) {
return Ok(Some(asset_kind));
}
let resource_id = target_layer.get("resourceId").and_then(Value::as_str);
Ok(resource_id.and_then(|resource_id| {
resources
.iter()
.find(|resource| resource.resource_id == resource_id)
.and_then(|resource| resource.asset_kind.as_deref())
}))
}
async fn ensure_editor_image_edit_source_allowed(
state: &AppState,
owner_user_id: &str,
payload: &EditorImageEditRequest,
) -> Result<(), AppError> {
let Some(project_id) = payload
.project_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref());
};
let Some(target_layer_id) = payload
.target_layer_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref());
};
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.to_string(),
owner_user_id: owner_user_id.to_string(),
})
.await
.map_err(map_editor_project_error)?;
let project = editor_project_payload_from_record(project);
let asset_kind = resolve_editor_image_edit_target_layer_asset_kind(
&project.layers,
&project.resources,
target_layer_id,
)?
.or(payload.asset_kind.as_deref());
ensure_editor_image_edit_asset_kind_allowed(asset_kind)
}
fn encode_editor_image_png(
image: image::DynamicImage,
failure_status: StatusCode,
provider: &str,
failure_message: &str,
) -> Result<Vec<u8>, 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<Vec<u8>, 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<EditorImageEditDimensions, AppError> {
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::<u32>().map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "图片改造目标宽度无效",
}))
})?;
let target_height = target_height.parse::<u32>().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<Option<DownloadedOpenAiImage>, 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<AppError>) {
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<AppError>) {
match restore_editor_generated_image_output_dimensions(&output, model, target_size) {
Ok(Some(restored)) => (restored, None),
Ok(None) => (output, None),
Err(error) => (output, Some(error)),
}
}
fn apply_editor_postprocessed_alpha_to_provider_source(
provider_source: &DownloadedOpenAiImage,
postprocessed: &DownloadedOpenAiImage,
) -> Result<Option<DownloadedOpenAiImage>, AppError> {
let provider_source =
image::load_from_memory(provider_source.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-image-postprocess",
"message": format!("provider 原图不是有效图片:{error}"),
}))
})?;
let postprocessed =
image::load_from_memory(postprocessed.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-image-postprocess",
"message": format!("透明后处理图不是有效图片:{error}"),
}))
})?;
if postprocessed.width() == provider_source.width()
&& postprocessed.height() == provider_source.height()
{
return Ok(None);
}
// 中文注释:图片模型已经按用户所选 K 档直接生成 provider 原图。
// 去背景服务若缩小图片,只重采样其 alpha 蒙版并应用回原始分辨率 RGB,禁止把低分辨率成品整图放大。
let postprocessed = postprocessed.to_rgba8();
let alpha = image::GrayImage::from_fn(postprocessed.width(), postprocessed.height(), |x, y| {
image::Luma([postprocessed.get_pixel(x, y).0[3]])
});
let alpha = image::imageops::resize(
&alpha,
provider_source.width(),
provider_source.height(),
image::imageops::FilterType::Lanczos3,
);
let mut restored = provider_source.to_rgba8();
for (x, y, pixel) in restored.enumerate_pixels_mut() {
pixel.0[3] = alpha.get_pixel(x, y).0[0];
}
Ok(Some(DownloadedOpenAiImage {
bytes: encode_editor_image_png(
image::DynamicImage::ImageRgba8(restored),
StatusCode::BAD_GATEWAY,
"editor-image-postprocess",
"恢复透明蒙版尺寸失败",
)?,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
}))
}
async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original(
state: &AppState,
provider_source_object_key: &str,
provider_width: u32,
provider_height: u32,
postprocessed: DownloadedOpenAiImage,
) -> (DownloadedOpenAiImage, Option<AppError>) {
let postprocessed_dimensions =
image::ImageReader::new(Cursor::new(postprocessed.bytes.as_slice()))
.with_guessed_format()
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-image-postprocess",
"message": format!("无法识别透明后处理图格式:{error}"),
}))
})
.and_then(|reader| {
reader.into_dimensions().map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-image-postprocess",
"message": format!("无法读取透明后处理图尺寸:{error}"),
}))
})
});
match postprocessed_dimensions {
Ok((postprocessed_width, postprocessed_height))
if postprocessed_width == provider_width && postprocessed_height == provider_height =>
{
return (postprocessed, None);
}
Ok(_) => {}
Err(error) => return (postprocessed, Some(error)),
}
// 中文注释:provider 原图已在 BgFilter 调用前交给 OSS 并释放;只有输出尺寸漂移时才短时回读原图回贴 alpha。
let provider_source =
match download_editor_persisted_image_object(state, provider_source_object_key).await {
Ok(provider_source) => provider_source,
Err(error) => return (postprocessed, Some(error)),
};
match apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed) {
Ok(Some(restored)) => (restored, None),
Ok(None) => (postprocessed, None),
Err(error) => (postprocessed, Some(error)),
}
}
fn restore_editor_generated_image_output_dimensions(
output: &DownloadedOpenAiImage,
model: &str,
target_size: &str,
) -> Result<Option<DownloadedOpenAiImage>, 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::<u32>().map_err(|_| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "宽度无效",
}))
})?;
let target_height = target_height.parse::<u32>().map_err(|_| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "高度无效",
}))
})?;
let decoded = image::load_from_memory(output.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("素材生成结果不是有效图片:{error}"),
}))
})?;
if decoded.width() == target_width && decoded.height() == target_height {
return Ok(None);
}
ensure_editor_output_can_restore_without_upscaling(
decoded.width(),
decoded.height(),
target_width,
target_height,
model,
)?;
let restored = decoded.resize_to_fill(
target_width,
target_height,
image::imageops::FilterType::Lanczos3,
);
Ok(Some(DownloadedOpenAiImage {
bytes: encode_editor_image_edit_png(
restored,
StatusCode::BAD_GATEWAY,
"恢复图片生成目标尺寸失败",
)?,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
}))
}
pub async fn edit_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorImageEditRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-image-edit",
"sourceImageSrc",
"待修改图片",
)?;
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-image-edit",
"referenceImageSrcs",
"修改参考图",
)?;
ensure_editor_image_edit_source_allowed(&state, caller.owner_user_id.as_str(), &payload)
.await?;
let generation_options = normalize_editor_image_edit_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
payload.size.as_deref(),
);
let image_size = normalize_editor_image_generation_size(payload.size.as_deref());
let price_mud_points = u64::from(
resolve_editor_image_edit_price(
&state,
generation_options.model,
image_size.as_ref(),
Some(generation_options.image_size),
)
.await?,
);
let source_entity_id =
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-image-edit");
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
caller.owner_user_id.as_str(),
EDITOR_IMAGE_EDIT_JOB_KIND,
source_entity_id,
"图片画布修改图片",
price_mud_points,
&payload,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
edit_editor_image_for_owner(&state, &request_context, caller, payload).await
}
pub(crate) async fn edit_editor_image_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorImageEditRequest,
) -> Result<Json<Value>, AppError> {
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-image-edit",
"sourceImageSrc",
"待修改图片",
)?;
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-image-edit",
"referenceImageSrcs",
"修改参考图",
)?;
ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?;
let prompt = payload.prompt.trim().to_string();
if prompt.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "修改提示词不能为空",
})),
);
}
let generation_options = normalize_editor_image_edit_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
payload.size.as_deref(),
);
let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some();
let requested_image_size = normalize_editor_image_generation_size(payload.size.as_deref());
let delivery_size = if has_dimension_options {
Cow::Owned(generation_options.size.clone())
} else {
requested_image_size.clone()
};
let provider_requested_image_size = if has_dimension_options {
Cow::Owned(generation_options.provider_size.clone())
} else {
requested_image_size.clone()
};
let mut reference_images =
Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len));
reference_images.push(
parse_editor_reference_image(
state,
caller.owner_user_id.as_str(),
payload.source_image_src.as_str(),
)
.await?,
);
for source in
normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref(), 8)
{
reference_images.push(
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source).await?,
);
}
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let edit_dimensions =
if editor_image_edit_uses_nanobanana_generate_content(generation_options.model) {
None
} else {
Some(prepare_editor_image_edit_references(
&mut reference_images,
provider_requested_image_size.as_ref(),
)?)
};
let provider_size = edit_dimensions
.map(|dimensions| {
format!(
"{}x{}",
dimensions.provider_width, dimensions.provider_height
)
})
.unwrap_or_else(|| generation_options.provider_image_size.to_string());
let expected_price_mud_points = resolve_editor_image_edit_price(
state,
generation_options.model,
requested_image_size.as_ref(),
Some(generation_options.image_size),
)
.await?;
let generated = if editor_image_edit_uses_nanobanana_generate_content(generation_options.model)
{
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_image_edit",
request_context.request_id(),
u64::from(expected_price_mud_points),
create_openai_nanobanana_generate_content(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
generation_options.aspect_ratio,
provider_size.as_str(),
reference_images.as_slice(),
"图片画布修改图片",
),
)
.await?
} else {
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_image_edit",
request_context.request_id(),
u64::from(expected_price_mud_points),
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
provider_size.as_str(),
1,
reference_images.as_slice(),
"图片画布修改图片",
),
)
.await?
};
let provider_image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图片",
}))
})?;
let (provider_width, provider_height) =
image::load_from_memory(provider_image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("图片改造结果不是有效图片:{error}"),
}))
})?;
let asset_label =
resolve_editor_generated_asset_label(payload.asset_label.clone(), "图片修改结果");
let (image, dimension_restore_error) = if let Some(edit_dimensions) =
edit_dimensions.as_ref().filter(|_| !has_dimension_options)
{
restore_editor_image_edit_output_dimensions_or_original(provider_image, edit_dimensions)
} else {
restore_editor_generated_image_output_dimensions_or_original(
provider_image,
generation_options.model,
delivery_size.as_ref(),
)
};
let dimension_warning = dimension_restore_error
.as_ref()
.map(editor_dimension_restore_warning);
if let Some(error) = dimension_restore_error {
tracing::warn!(
task_id = %generated.task_id,
provider_width,
provider_height,
target_width = edit_dimensions.map(|dimensions| dimensions.target_width),
target_height = edit_dimensions.map(|dimensions| dimensions.target_height),
target_size = %delivery_size,
error = ?error,
"画板快速编辑尺寸恢复失败,保留并上传 provider 原图"
);
}
let (width, height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((1024, 1024));
let owner_user_id = caller.owner_user_id.clone();
let persisted = persist_editor_generated_image(
state,
owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_IMAGE_EDIT_ASSET_KIND,
"edit-images",
"image",
"edit",
"vector-engine",
)
.await?;
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let generated_asset = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: payload.asset_folder_id,
label: asset_label.clone(),
image_src: image_src.clone(),
object_key: Some(persisted.object_key.clone()),
asset_object_id: Some(persisted.asset_object_id.clone()),
width,
height,
prompt: prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
provider: "VectorEngine".to_string(),
task_id: generated.task_id.clone(),
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: payload.source_resource_id,
asset_kind: payload.asset_kind,
generation_inputs: payload.generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: u64::from(expected_price_mud_points),
},
)
.await?;
let completed_project = if payload.canvas_completion.is_some() {
complete_editor_canvas_generation(
state,
owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
generated_asset.resource.as_ref(),
)
.await?
} else {
complete_editor_canvas_background_removal(
state,
owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.target_layer_id.as_deref(),
generated_asset.resource.as_ref(),
Some(asset_label.as_str()),
true,
)
.await?
};
Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
width,
height,
source_type: "generated",
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
resource: generated_asset.resource,
asset: generated_asset.asset,
project: completed_project,
warning: dimension_warning,
},
))
}
pub async fn remove_editor_image_background(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorBackgroundRemovalRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-background-removal",
"sourceImageSrc",
"待去除背景图片",
)?;
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-background-removal",
);
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
caller.owner_user_id.as_str(),
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
source_entity_id,
"图片画布去除背景",
0,
&payload,
)
.await?;
Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
))
}
pub(crate) async fn remove_editor_image_background_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorBackgroundRemovalRequest,
) -> Result<Json<Value>, AppError> {
let started_at = Instant::now();
caller.report_processing_phase(state).await?;
let source_object_key = resolve_editor_reference_object_key_for_owner(
state,
caller.owner_user_id.as_str(),
payload.source_image_src.as_str(),
)
.await?;
validate_editor_background_removal_source(state, source_object_key.as_str()).await?;
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
external_call_deadline: request_context.external_call_deadline(),
};
let removed = request_editor_background_removal_image_with_bgfilter_worker(
state,
source_object_key.as_str(),
&matting_audit,
)
.await?;
let task_id = normalize_optional_string(payload.task_id.clone())
.unwrap_or_else(|| format!("background-removal-{}", current_utc_micros()));
let persisted = persist_editor_generated_image(
state,
caller.owner_user_id.as_str(),
task_id.as_str(),
&removed.image,
"remove background",
None,
"background-removal",
"background-removal",
"removed-background",
"result",
"bgfilter-complex",
)
.await?;
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let asset_label =
resolve_editor_generated_asset_label(payload.asset_label.clone(), "去背景结果");
let generated_asset = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: payload.asset_folder_id,
label: asset_label.clone(),
image_src: image_src.clone(),
object_key: Some(persisted.object_key.clone()),
asset_object_id: Some(persisted.asset_object_id.clone()),
width: removed.width,
height: removed.height,
prompt: "remove background".to_string(),
actual_prompt: None,
model: "BgFilter complex".to_string(),
provider: "BgFilter".to_string(),
task_id: task_id.clone(),
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: payload.source_resource_id,
asset_kind: payload.asset_kind,
generation_inputs: payload.generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: 0,
},
)
.await?;
let completed_project = if payload.canvas_completion.is_some() {
complete_editor_canvas_generation(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
generated_asset.resource.as_ref(),
)
.await?
} else {
complete_editor_canvas_background_removal(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.target_layer_id.as_deref(),
generated_asset.resource.as_ref(),
Some(asset_label.as_str()),
false,
)
.await?
};
Ok(json_success_body(
Some(&request_context),
EditorBackgroundRemovalResponse {
image_src,
object_key: persisted.object_key,
asset_object_id: persisted.asset_object_id,
width: removed.width,
height: removed.height,
source_type: "generated",
task_id,
elapsed_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
provider: "BgFilter",
resource: generated_asset.resource,
asset: generated_asset.asset,
project: completed_project,
},
))
}
struct EditorBackgroundRemovalImage {
image: DownloadedOpenAiImage,
width: u32,
height: u32,
}
async fn validate_editor_background_removal_source(
state: &AppState,
source_object_key: &str,
) -> Result<(), AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: source_object_key.to_string(),
},
)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
if head.content_length > EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES {
return Err(editor_reference_image_too_large());
}
let content_type =
normalize_editor_background_removal_source_mime_type(head.content_type.as_deref())
.ok_or_else(|| {
editor_background_removal_source_unsupported(
"图片参考图的 Content-Type 不是支持的图片类型。",
)
})?;
let signed_url = sign_editor_private_object_read_url(
state,
source_object_key,
EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS,
)?;
let probe_end = EDITOR_BACKGROUND_REMOVAL_SOURCE_PROBE_BYTES.saturating_sub(1);
let response = http_client
.get(signed_url)
.header(reqwest::header::RANGE, format!("bytes=0-{probe_end}"))
.send()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取图片参考图文件头失败:{error}"),
}))
})?;
if response.status() != StatusCode::PARTIAL_CONTENT {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": "OSS 未按 Range 返回图片文件头。",
"status": response.status().as_u16(),
})),
);
}
if response
.content_length()
.is_none_or(|size| size > EDITOR_BACKGROUND_REMOVAL_SOURCE_PROBE_BYTES)
{
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": "OSS 返回的图片文件头大小异常。",
})),
);
}
let probe = response.bytes().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取图片参考图文件头失败:{error}"),
}))
})?;
validate_editor_background_removal_source_content(content_type, probe.as_ref())
}
fn validate_editor_background_removal_source_content(
content_type: &str,
bytes: &[u8],
) -> Result<(), AppError> {
let detected_type = infer_editor_reference_image_mime_type(bytes).ok_or_else(|| {
editor_background_removal_source_unsupported("图片参考图文件魔数不是支持的图片类型。")
})?;
if detected_type != content_type {
return Err(editor_background_removal_source_unsupported(
"图片参考图的 Content-Type 与文件魔数不一致。",
));
}
Ok(())
}
fn normalize_editor_background_removal_source_mime_type(
content_type: Option<&str>,
) -> Option<&'static str> {
let mime_type = content_type?.split(';').next()?.trim();
if mime_type.eq_ignore_ascii_case("image/png") {
Some("image/png")
} else if mime_type.eq_ignore_ascii_case("image/jpeg") {
Some("image/jpeg")
} else if mime_type.eq_ignore_ascii_case("image/webp") {
Some("image/webp")
} else {
None
}
}
fn editor_background_removal_source_unsupported(message: &str) -> AppError {
AppError::from_status(StatusCode::UNSUPPORTED_MEDIA_TYPE).with_details(json!({
"provider": "editor-reference-image",
"field": "sourceImageSrc",
"message": message,
}))
}
fn editor_bgfilter_flat_deadline_reserve(aliyun_timeout_ms: u64) -> Duration {
Duration::from_millis(aliyun_timeout_ms)
.saturating_add(EDITOR_BGFILTER_FLAT_LOCAL_RESERVE)
.saturating_add(EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW)
}
async fn request_editor_background_removal_image_with_bgfilter_worker(
state: &AppState,
source_object_key: &str,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<EditorBackgroundRemovalImage, AppError> {
// complex 没有 flat fallback,排队上限只预留 2s 传输窗;worker 重启窗口内的
// 连接失败由 client 内部按预算有界重试,避免 max_attempts=1 的队列任务终态失败。
let removed = crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry(
state,
source_object_key,
crate::bgfilter_worker::BgfilterBackgroundMode::Complex,
None,
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_DISABLED,
EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW,
audit,
)
.await
.map_err(crate::bgfilter_worker::BgfilterClientError::into_app_error)?;
Ok(EditorBackgroundRemovalImage {
width: removed.width,
height: removed.height,
image: DownloadedOpenAiImage {
bytes: removed.bytes,
extension: removed.extension,
mime_type: removed.mime_type,
},
})
}
pub(crate) struct EditorScreenBackgroundRemovalOutput {
pub(crate) image: DownloadedOpenAiImage,
pub(crate) provider: &'static str,
pub(crate) model: String,
}
pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(
state: &AppState,
source_object_key: &str,
screen_color: EditorScreenBackgroundColor,
seg_model: &str,
cross_check: bool,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<EditorScreenBackgroundRemovalOutput, AppError> {
// flat 的排队上限从父剩余预算里预扣「阿里云 + 本地 + 传输」的 fallback 时间,
// 保证排队吃不完 fallback 的执行窗口;上限为 0 时 client 直接返回
// deadline_exceeded,本函数随即进入 fallback 链。worker 重启窗口内的连接
// 失败由 client 先做小额重试(≤2 次),仍失败才降级,避免不必要的质量损失。
match crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry(
state,
source_object_key,
crate::bgfilter_worker::BgfilterBackgroundMode::Flat,
Some(screen_color.hex),
seg_model,
cross_check,
editor_bgfilter_flat_deadline_reserve(state.config.aliyun_matting_request_timeout_ms),
audit,
)
.await
{
Ok(image) => {
return Ok(EditorScreenBackgroundRemovalOutput {
image: DownloadedOpenAiImage {
bytes: image.bytes,
extension: image.extension,
mime_type: image.mime_type,
},
provider: "BgFilter",
model: seg_model.to_string(),
});
}
Err(error) if !error.allows_flat_fallback() => {
return Err(error.into_app_error());
}
Err(error)
if audit
.external_call_deadline
.is_some_and(|deadline| Instant::now() >= deadline) =>
{
return Err(error.into_app_error());
}
Err(error) => {
tracing::warn!(
provider = "bgfilter-worker",
worker_code = error.code(),
screen_color = screen_color.hex,
seg_model,
cross_check,
"editor_bgfilter_worker_fallback_to_aliyun_matting"
);
}
}
fallback_editor_screen_background_removal(state, source_object_key, screen_color, audit).await
}
/// BgFilter 内部 worker 对 flat 调用返回可降级错误时的统一兜底链:
/// 先阿里云通用抠图,失败再退本地键色扣除。
async fn fallback_editor_screen_background_removal(
state: &AppState,
source_object_key: &str,
screen_color: EditorScreenBackgroundColor,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<EditorScreenBackgroundRemovalOutput, AppError> {
let aliyun_result = match sign_editor_private_object_read_url(
state,
source_object_key,
EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS,
) {
Ok(source_url) => {
crate::aliyun_matting::segment_image_url_with_aliyun_matting(
state,
source_url.as_str(),
"editor-screen-background",
)
.await
}
Err(error) => Err(error),
};
match aliyun_result {
Ok(image) => Ok(EditorScreenBackgroundRemovalOutput {
image,
provider: "Aliyun Matting",
model: "segment-common-image".to_string(),
}),
Err(error) => {
tracing::warn!(
provider = "aliyun-matting",
screen_color = screen_color.hex,
error = %error,
error_details = ?error.details(),
"editor_aliyun_matting_fallback_to_local_screen_background_removal"
);
if crate::external_api_audit::matting_failure_external_call_attempted(&error) {
crate::external_api_audit::record_matting_external_api_failure(
state,
audit,
"aliyun-matting",
state.config.aliyun_matting_endpoint.clone(),
"editor-screen-background-removal",
crate::external_api_audit::matting_failure_audit_failure_stage(
&error,
"aliyun_segment",
),
crate::external_api_audit::matting_failure_audit_status_code(&error),
crate::external_api_audit::matting_failure_audit_timeout(&error),
crate::external_api_audit::matting_failure_audit_is_transport(&error),
crate::external_api_audit::matting_failure_audit_latency_ms(&error),
error.message().to_string(),
crate::external_api_audit::matting_failure_audit_raw_excerpt(&error),
)
.await;
}
let source_image =
download_editor_persisted_image_object(state, source_object_key).await?;
let output =
remove_editor_generated_green_screen_background(&source_image, screen_color);
drop(source_image);
output.map(|image| EditorScreenBackgroundRemovalOutput {
image,
provider: "Genarrative Local",
model: "screen-color-keying".to_string(),
})
}
}
}
fn sign_editor_private_object_read_url(
state: &AppState,
object_key: &str,
expire_seconds: u64,
) -> Result<String, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: object_key.to_string(),
expire_seconds: Some(expire_seconds),
})
.map(|signed| signed.signed_url)
.map_err(|error| map_oss_error(error, "aliyun-oss"))
}
fn parse_editor_bgfilter_seg_model(value: Option<&str>) -> Result<&'static str, AppError> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(EDITOR_BGFILTER_DEFAULT_SEG_MODEL);
};
match value.to_ascii_lowercase().as_str() {
EDITOR_BGFILTER_DEFAULT_SEG_MODEL => Ok(EDITOR_BGFILTER_DEFAULT_SEG_MODEL),
EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG => Ok(EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG),
_ => Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "bgfilter",
"message": "segModel 必须是 birefnet 或 anime-seg。",
"allowed": [
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG,
],
})),
),
}
}
struct PersistEditorSpritesheetSlicesInput {
owner_user_id: String,
project_id: Option<String>,
asset_folder_id: Option<String>,
source_resource_id: Option<String>,
task_id: String,
group_task_id: Option<String>,
prompt: String,
actual_prompt: Option<String>,
model: String,
provider: String,
generation_inputs: Option<Value>,
path_kind: &'static str,
asset_kind: &'static str,
persistence_provider: &'static str,
}
async fn persist_editor_spritesheet_slices(
state: &AppState,
slices: Vec<GeneratedAssetSheetConnectedIcon>,
input: PersistEditorSpritesheetSlicesInput,
) -> Result<Vec<EditorIconSpritesheetIconResponse>, AppError> {
if slices.is_empty() {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集中未识别到可拆分的独立素材。",
})),
);
}
let expected_asset_count = u32::try_from(slices.len()).map_err(|_| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集拆分产物数量超过支持范围。",
}))
})?;
let mut results = Vec::with_capacity(slices.len());
for (index, slice) in slices.into_iter().enumerate() {
let image = DownloadedOpenAiImage {
bytes: slice.bytes,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let file_stem = format!("asset-{:03}", index + 1);
let persisted = persist_editor_generated_image(
state,
input.owner_user_id.as_str(),
input.task_id.as_str(),
&image,
input.prompt.as_str(),
input.actual_prompt.as_deref(),
input.asset_kind,
input.path_kind,
file_stem.as_str(),
"asset",
input.persistence_provider,
)
.await?;
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let record = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: input.project_id.clone(),
owner_user_id: input.owner_user_id.clone(),
folder_id: input.asset_folder_id.clone(),
label: slice.name.clone(),
image_src: image_src.clone(),
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
width: slice.width,
height: slice.height,
prompt: input.prompt.clone(),
actual_prompt: input.actual_prompt.clone(),
model: input.model.clone(),
provider: input.provider.clone(),
task_id: input.task_id.clone(),
group_task_id: input.group_task_id.clone(),
group_task_expected_asset_count: input
.group_task_id
.as_ref()
.map(|_| expected_asset_count),
source_resource_id: input.source_resource_id.clone(),
asset_kind: Some("icon".to_string()),
generation_inputs: input.generation_inputs.clone(),
thumbnail_src: None,
generation_cost_mud_points: 0,
},
)
.await?;
results.push(EditorIconSpritesheetIconResponse {
name: slice.name,
image_src,
width: slice.width,
height: slice.height,
resource: record.resource,
asset: record.asset,
});
}
if let Some(group_task_id) = input.group_task_id.as_deref() {
let asset_ids = results
.iter()
.filter_map(|result| result.asset.as_ref().map(|asset| asset.asset_id.clone()))
.collect::<Vec<_>>();
state
.spacetime_client()
.complete_editor_asset_group_cohort(EditorAssetGroupCohortCompleteRecordInput {
owner_user_id: input.owner_user_id,
task_id: input.task_id,
group_task_id: group_task_id.to_string(),
expected_asset_count,
asset_ids,
completed_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
}
Ok(results)
}
pub async fn generate_editor_icon_spritesheet(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorIconSpritesheetGenerationRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
ensure_editor_reference_image_source_is_stable(
payload.reference_image_src.as_str(),
"editor-icon-spritesheet",
"referenceImageSrc",
"图标素材规范",
)?;
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-icon-spritesheet",
"referenceImageSrcs",
"图标素材参考图",
)?;
let generation_options = normalize_editor_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let price_mud_points = u64::from(
resolve_editor_icon_spritesheet_price(
&state,
Some(generation_options.model),
Some(generation_options.image_size),
)
.await?,
);
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-icon-spritesheet-generation",
);
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
caller.owner_user_id.as_str(),
EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成图标素材",
price_mud_points,
&payload,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
generate_editor_icon_spritesheet_for_owner(&state, &request_context, caller, payload).await
}
pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorIconSpritesheetGenerationRequest,
) -> Result<Json<Value>, AppError> {
ensure_editor_reference_image_source_is_stable(
payload.reference_image_src.as_str(),
"editor-icon-spritesheet",
"referenceImageSrc",
"图标素材规范",
)?;
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-icon-spritesheet",
"referenceImageSrcs",
"图标素材参考图",
)?;
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
// 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时
// 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。
let requested_screen_color = payload.screen_color.clone();
let reference_count = 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len);
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
let mut reference_images =
Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len));
reference_images.push(
parse_editor_reference_image(
state,
caller.owner_user_id.as_str(),
payload.reference_image_src.as_str(),
)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "referenceImageSrc",
"message": "图标素材规范必须是 objectKey、资源或素材引用。",
}))
})?,
);
for source in
normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref(), 8)
{
reference_images.push(
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "referenceImageSrcs",
"message": "图标素材参考图必须是 objectKey、资源或素材引用。",
}))
})?,
);
}
let generation_options = normalize_editor_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let expected_price_mud_points = resolve_editor_icon_spritesheet_price(
state,
Some(generation_options.model),
Some(generation_options.image_size),
)
.await?;
let size = generation_options.provider_size.as_str();
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let (generated, prompt, screen_background_decision) =
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_icon_spritesheet",
request_context.request_id(),
u64::from(expected_price_mud_points),
async {
// 背景色决策:预扣泥点之后才发起(决策失败→future 返回 Err→失败退款)。
let screen_background_decision = resolve_editor_screen_background_color(
state.llm_client(),
state.editor_agent_llm_client(),
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::IconSpritesheet,
screen_color: requested_screen_color.clone(),
prompt: icon_descriptions.join("\n"),
icon_descriptions: icon_descriptions.clone(),
reference_count,
source_image_data_url: None,
},
Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
state,
context: crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
external_call_deadline: request_context.external_call_deadline(),
},
}),
)
.await?;
let screen_color = screen_background_decision.color;
let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions, screen_color);
let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
create_openai_nanobanana_generate_content(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
None,
generation_options.aspect_ratio,
generation_options.provider_image_size,
reference_images.as_slice(),
"图片画布生成图标素材 spritesheet",
)
.await?
} else {
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
None,
size,
1,
reference_images.as_slice(),
"图片画布生成图标素材 spritesheet",
)
.await?
};
Ok::<_, AppError>((generated, prompt, screen_background_decision))
},
)
.await?;
let screen_color = screen_background_decision.color;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图标 spritesheet",
}))
})?;
let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((512, 512));
let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs(
payload.generation_inputs.clone(),
Some(&screen_background_decision),
);
let spritesheet_label =
resolve_editor_generated_asset_label(payload.asset_label.clone(), "图标素材图集");
let asset_folder_id = payload
.asset_folder_id
.clone()
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string()));
let source_persisted = persist_editor_provider_source_image(
state,
caller.owner_user_id.as_str(),
generated.task_id.as_str(),
image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_ICON_SPRITESHEET_ASSET_KIND,
"icon-spritesheets",
"spritesheet",
)
.await?;
let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str());
let source_object_key = source_persisted.object_key.clone();
let source_record = persist_editor_provider_source_resource(
state,
source_persisted,
PersistEditorProviderSourceResourceInput {
project_id: payload.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: asset_folder_id.clone(),
label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"),
width: source_width,
height: source_height,
prompt: prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
task_id: generated.task_id.clone(),
source_resource_id: None,
asset_kind: Some("icon-spritesheet".to_string()),
generation_inputs: generation_inputs.clone(),
generation_cost_mud_points: u64::from(expected_price_mud_points),
},
)
.await?;
caller.report_processing_phase(state).await?;
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
external_call_deadline: request_context.external_call_deadline(),
};
let removal = remove_editor_generated_screen_background_with_bgfilter(
state,
source_object_key.as_str(),
screen_color,
seg_model,
EDITOR_BGFILTER_CROSS_CHECK_DISABLED,
&matting_audit,
)
.await;
let removal = match removal {
Ok(removal) => removal,
Err(error) => {
let failure_message = error.body_text();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "background_postprocess",
task_id = %generated.task_id,
error = %failure_message,
"图标图集原图已保存,但透明背景处理失败,使用原图完成画布"
);
let completed_project = complete_editor_canvas_generation(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
source_record.resource.as_ref(),
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetGenerationResponse {
spritesheet_image_src: source_image_src,
spritesheet_width: source_width,
spritesheet_height: source_height,
icon_image_srcs: Vec::new(),
slice_warning: None,
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: source_record.resource,
spritesheet_asset: source_record.asset,
project: completed_project,
warning: Some(editor_postprocess_fallback_warning(
"生成任务成功,后处理失败。",
)),
},
));
}
};
let removal_provider = removal.provider;
let (image, postprocess_dimension_error) =
apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original(
state,
source_object_key.as_str(),
source_width,
source_height,
removal.image,
)
.await;
if let Some(error) = postprocess_dimension_error {
tracing::warn!(
task_id = %generated.task_id,
provider = removal_provider,
source_width,
source_height,
error = ?error,
"图标透明图蒙版尺寸恢复失败,保留去背景服务原始输出"
);
}
let matting_generation_inputs = build_editor_derived_asset_generation_inputs(
"图标图集抠图",
&removal.model,
&source_record,
);
let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((512, 512));
let owner_user_id = caller.owner_user_id;
let spritesheet_persisted = persist_editor_generated_image(
state,
owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
"去除纯色背景",
None,
EDITOR_ICON_SPRITESHEET_ASSET_KIND,
"icon-spritesheets",
"spritesheet",
"spritesheet",
removal.provider,
)
.await?;
let spritesheet_image_src =
editor_media_src_from_object_key(spritesheet_persisted.object_key.as_str());
let spritesheet_record = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: asset_folder_id.clone(),
label: spritesheet_label,
image_src: spritesheet_image_src.clone(),
object_key: Some(spritesheet_persisted.object_key.clone()),
asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()),
width: spritesheet_width,
height: spritesheet_height,
prompt: "去除纯色背景".to_string(),
actual_prompt: None,
model: removal.model.clone(),
provider: removal.provider.to_string(),
task_id: generated.task_id.clone(),
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: source_record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
asset_kind: Some("icon-spritesheet".to_string()),
generation_inputs: matting_generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: 0,
},
)
.await?;
let slice_source = DownloadedImage {
bytes: image.bytes.clone(),
mime_type: image.mime_type.clone(),
extension: image.extension.clone(),
};
let (icon_image_srcs, slice_warning) =
match slice_generated_icon_spritesheet_by_connected_components(
&slice_source,
&icon_descriptions,
) {
Ok(icon_slices) => {
match persist_editor_spritesheet_slices(
state,
icon_slices,
PersistEditorSpritesheetSlicesInput {
owner_user_id: owner_user_id.clone(),
project_id: payload.project_id.clone(),
asset_folder_id: asset_folder_id.clone(),
source_resource_id: spritesheet_record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
task_id: generated.task_id.clone(),
group_task_id: None,
prompt: "自动拆分图集".to_string(),
actual_prompt: None,
model: "connected-components".to_string(),
provider: "Genarrative".to_string(),
generation_inputs: build_editor_derived_asset_generation_inputs(
"图集拆分",
"connected-components",
&spritesheet_record,
),
path_kind: "icon-spritesheet-assets",
asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND,
persistence_provider: "vector-engine",
},
)
.await
{
Ok(icon_image_srcs) => (icon_image_srcs, None),
Err(error) => {
let reason = error.body_text();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "persist_automatic_slices",
task_id = %generated.task_id,
reason = %reason,
"图标图集已持久化,但自动拆分素材持久化失败"
);
(
Vec::new(),
Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE,
reason,
}),
)
}
}
}
Err(error) => {
let reason = error.to_string();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "detect_automatic_slices",
task_id = %generated.task_id,
reason = %reason,
"图标图集已持久化,但自动拆分未识别到全部素材"
);
(
Vec::new(),
Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
reason,
}),
)
}
};
let (canvas_items, primary_layer_id) =
if let Some(completion) = payload.canvas_completion.as_ref() {
build_icon_spritesheet_canvas_layer_items(
completion,
spritesheet_record.resource.as_ref(),
source_record.resource.as_ref(),
icon_image_srcs.as_slice(),
)
} else {
(Vec::new(), None)
};
let completed_project = complete_editor_canvas_generation_with_items(
state,
owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
canvas_items,
primary_layer_id,
)
.await?;
Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetGenerationResponse {
spritesheet_image_src,
spritesheet_width,
spritesheet_height,
icon_image_srcs,
slice_warning,
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: spritesheet_record.resource,
spritesheet_asset: spritesheet_record.asset,
project: completed_project,
warning: None,
},
))
}
pub async fn split_editor_icon_spritesheet(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorIconSpritesheetSliceRequest>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = authenticated.claims().user_id().to_string();
let project_id = payload.project_id.trim().to_string();
let source_resource_id = payload.source_resource_id.trim().to_string();
if project_id.is_empty() || source_resource_id.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "projectId 和 sourceResourceId 不能为空。",
})),
);
}
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.clone(),
owner_user_id: owner_user_id.clone(),
})
.await
.map_err(map_editor_project_error)?;
let source_resource = project
.resources
.iter()
.find(|resource| resource.resource_id == source_resource_id)
.cloned()
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "未找到要拆分的图集资源。",
}))
})?;
if source_resource.asset_kind.as_deref() != Some("icon-spritesheet") {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "只有图集素材可以使用拆分功能。",
})),
);
}
let task_id = build_prefixed_uuid_id("editor-atlas-split-");
let group_task_id = resolve_editor_manual_atlas_split_group_task_id(
&state,
owner_user_id.as_str(),
&source_resource,
)
.await?
.unwrap_or_else(|| task_id.clone());
let reference =
parse_editor_reference_image(&state, owner_user_id.as_str(), source_resource_id.as_str())
.await?;
let source = DownloadedImage {
bytes: reference.bytes,
mime_type: reference.mime_type,
extension: "png".to_string(),
};
validate_editor_icon_spritesheet_manual_source(&source)?;
let slices =
slice_generated_icon_spritesheet_all_by_connected_components(&source).map_err(|error| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": error.to_string(),
}))
})?;
validate_editor_icon_spritesheet_manual_slice_count(slices.len())?;
let prompt = source_resource
.prompt
.clone()
.unwrap_or_else(|| "拆分图集素材".to_string());
let actual_prompt = source_resource.actual_prompt.clone();
let model = source_resource
.model
.clone()
.unwrap_or_else(|| "connected-components".to_string());
let provider = source_resource
.provider
.clone()
.unwrap_or_else(|| "Genarrative".to_string());
let generation_inputs = source_resource.generation_inputs.clone();
let icon_image_srcs = persist_editor_spritesheet_slices(
&state,
slices,
PersistEditorSpritesheetSlicesInput {
owner_user_id: owner_user_id.clone(),
project_id: Some(project_id.clone()),
asset_folder_id: payload
.asset_folder_id
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
source_resource_id: Some(source_resource_id),
task_id,
group_task_id: Some(group_task_id),
prompt,
actual_prompt,
model,
provider,
generation_inputs,
path_kind: "icon-spritesheet-slices",
asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND,
persistence_provider: "genarrative",
},
)
.await?;
let canvas_items = build_icon_spritesheet_slice_canvas_layer_items(
&payload.canvas_completion,
icon_image_srcs.as_slice(),
);
let project = complete_editor_canvas_generation_with_items(
&state,
owner_user_id.as_str(),
Some(project_id.as_str()),
Some(&payload.canvas_completion),
canvas_items,
icon_image_srcs
.first()
.and_then(|icon| icon.resource.as_ref())
.map(|resource| generated_canvas_layer_id(resource.resource_id.as_str())),
)
.await?
.ok_or_else(|| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集拆分结果未能写入画布。",
}))
})?;
Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetSliceResponse {
icon_image_srcs,
project,
},
))
}
fn validate_editor_icon_spritesheet_manual_source(
source: &DownloadedImage,
) -> Result<(), AppError> {
let reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice()))
.with_guessed_format()
.map_err(|error| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": format!("无法识别图集图片格式:{error}"),
}))
})?;
let (width, height) = reader.into_dimensions().map_err(|error| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": format!("无法读取图集图片尺寸:{error}"),
}))
})?;
validate_editor_icon_spritesheet_manual_dimensions(width, height)
}
async fn resolve_editor_manual_atlas_split_group_task_id(
state: &AppState,
owner_user_id: &str,
source_resource: &EditorProjectResourceRecord,
) -> Result<Option<String>, AppError> {
let lookup = editor_manual_atlas_split_group_source_lookup_input(
owner_user_id,
source_resource.resource_id.as_str(),
source_resource.source_resource_id.as_deref(),
source_resource.asset_object_id.as_deref(),
source_resource.object_key.as_deref(),
);
let asset = state
.spacetime_client()
.find_editor_asset_group_source(lookup)
.await
.map_err(map_editor_project_error)?;
Ok(asset.and_then(|asset| normalize_optional_string(asset.task_id)))
}
fn editor_manual_atlas_split_group_source_lookup_input(
owner_user_id: &str,
resource_id: &str,
source_resource_id: Option<&str>,
asset_object_id: Option<&str>,
object_key: Option<&str>,
) -> EditorAssetGroupSourceLookupRecordInput {
let mut source_resource_ids = vec![resource_id.to_string()];
if let Some(source_resource_id) =
normalize_optional_string(source_resource_id.map(str::to_string))
{
source_resource_ids.push(source_resource_id);
}
EditorAssetGroupSourceLookupRecordInput {
owner_user_id: owner_user_id.to_string(),
source_resource_ids,
asset_object_id: asset_object_id.map(str::to_string),
object_key: object_key.map(str::to_string),
}
}
fn validate_editor_icon_spritesheet_manual_dimensions(
width: u32,
height: u32,
) -> Result<(), AppError> {
let pixel_count = u64::from(width).saturating_mul(u64::from(height));
if width == 0
|| height == 0
|| width > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION
|| height > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION
|| pixel_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS
{
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集尺寸超过手动拆分限制。",
"width": width,
"height": height,
"maxDimension": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION,
"maxPixels": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS,
})),
);
}
Ok(())
}
fn validate_editor_icon_spritesheet_manual_slice_count(slice_count: usize) -> Result<(), AppError> {
if slice_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集识别出的素材数量超过手动拆分限制。",
"sliceCount": slice_count,
"maxSliceCount": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES,
})),
);
}
Ok(())
}
pub async fn extract_editor_ui_design_assets(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorUiDesignAssetExtractionRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-ui-design-asset-extraction",
"sourceImageSrc",
"UI设计图",
)?;
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-ui-design-asset-extraction",
"referenceImageSrcs",
"UI素材参考图",
)?;
let generation_options = normalize_editor_ui_design_asset_extraction_options(
payload.model.as_deref(),
payload.aspect_ratio.as_str(),
payload.image_size.as_str(),
)?;
let price_mud_points = u64::from(
resolve_editor_ui_design_asset_extraction_price(
&state,
Some(generation_options.model),
Some(generation_options.image_size),
)
.await?,
);
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-ui-design-asset-extraction",
);
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
caller.owner_user_id.as_str(),
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND,
source_entity_id,
"图片画布提取UI设计图素材",
price_mud_points,
&payload,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
extract_editor_ui_design_assets_for_owner(&state, &request_context, caller, payload).await
}
pub(crate) async fn extract_editor_ui_design_assets_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorUiDesignAssetExtractionRequest,
) -> Result<Json<Value>, AppError> {
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-ui-design-asset-extraction",
"sourceImageSrc",
"UI设计图",
)?;
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-ui-design-asset-extraction",
"referenceImageSrcs",
"UI素材参考图",
)?;
// 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时
// 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。
let requested_screen_color = payload.screen_color.clone();
let reference_count = 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len);
let decision_prompt = format!(
"从 UI 设计图提取被红色框选的素材,输出 spritesheet。目标规格 {} {}。",
payload.aspect_ratio, payload.image_size
);
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
let reference_image = parse_editor_reference_image(
state,
caller.owner_user_id.as_str(),
payload.source_image_src.as_str(),
)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-ui-design-asset-extraction",
"field": "sourceImageSrc",
"message": "UI设计图必须是 objectKey、资源或素材引用。",
}))
})?;
let mut reference_images = vec![reference_image];
for source in normalize_editor_reference_image_sources(
payload.reference_image_srcs.as_deref(),
EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT,
) {
reference_images.push(
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-ui-design-asset-extraction",
"field": "referenceImageSrcs",
"message": "UI素材参考图必须是 objectKey、资源或素材引用。",
}))
})?,
);
}
let generation_options = normalize_editor_ui_design_asset_extraction_options(
payload.model.as_deref(),
payload.aspect_ratio.as_str(),
payload.image_size.as_str(),
)?;
let expected_price_mud_points = resolve_editor_ui_design_asset_extraction_price(
state,
Some(generation_options.model),
Some(generation_options.image_size),
)
.await?;
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let (generated, prompt, screen_background_decision) =
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_ui_design_asset_extraction",
request_context.request_id(),
u64::from(expected_price_mud_points),
async {
// 背景色决策:预扣泥点之后才发起(决策失败→future 返回 Err→失败退款)。
let screen_background_decision = resolve_editor_screen_background_color(
state.llm_client(),
state.editor_agent_llm_client(),
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::UiDesignAssetExtraction,
screen_color: requested_screen_color.clone(),
prompt: decision_prompt.clone(),
icon_descriptions: Vec::new(),
reference_count,
source_image_data_url: None,
},
Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
state,
context: crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
external_call_deadline: request_context.external_call_deadline(),
},
}),
)
.await?;
let screen_color = screen_background_decision.color;
let prompt = editor_ui_design_asset_extraction_prompt(screen_color);
let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
create_openai_nanobanana_generate_content(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
None,
generation_options.aspect_ratio,
generation_options.provider_image_size,
reference_images.as_slice(),
"图片画布提取UI设计图素材 spritesheet",
)
.await?
} else {
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
None,
generation_options.provider_size.as_str(),
1,
reference_images.as_slice(),
"图片画布提取UI设计图素材 spritesheet",
)
.await?
};
Ok::<_, AppError>((generated, prompt, screen_background_decision))
},
)
.await?;
let screen_color = screen_background_decision.color;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回 UI 设计图素材 spritesheet",
}))
})?;
let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((512, 512));
let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs(
payload.generation_inputs.clone(),
Some(&screen_background_decision),
);
let source_label =
resolve_editor_generated_asset_label(payload.spritesheet_label.clone(), "UI设计图素材图集");
let asset_folder_id = payload
.asset_folder_id
.clone()
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string()));
let source_persisted = persist_editor_provider_source_image(
state,
caller.owner_user_id.as_str(),
generated.task_id.as_str(),
image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND,
"ui-design-spritesheets",
"spritesheet",
)
.await?;
let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str());
let source_object_key = source_persisted.object_key.clone();
let source_record = persist_editor_provider_source_resource(
state,
source_persisted,
PersistEditorProviderSourceResourceInput {
project_id: payload.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: asset_folder_id.clone(),
label: editor_generated_asset_variant_label(source_label.as_str(), "原图"),
width: source_width,
height: source_height,
prompt: prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
task_id: generated.task_id.clone(),
source_resource_id: None,
asset_kind: Some("icon-spritesheet".to_string()),
generation_inputs: generation_inputs.clone(),
generation_cost_mud_points: u64::from(expected_price_mud_points),
},
)
.await?;
caller.report_processing_phase(state).await?;
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
external_call_deadline: request_context.external_call_deadline(),
};
let removal = remove_editor_generated_screen_background_with_bgfilter(
state,
source_object_key.as_str(),
screen_color,
seg_model,
EDITOR_BGFILTER_CROSS_CHECK_DISABLED,
&matting_audit,
)
.await;
let removal = match removal {
Ok(removal) => removal,
Err(error) => {
let failure_message = error.body_text();
tracing::warn!(
provider = "editor-ui-design-asset-extraction",
operation = "background_postprocess",
task_id = %generated.task_id,
error = %failure_message,
"UI 素材图集原图已保存,但透明背景处理失败,使用原图完成画布"
);
let completed_project = complete_editor_canvas_generation(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
source_record.resource.as_ref(),
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetGenerationResponse {
spritesheet_image_src: source_image_src,
spritesheet_width: source_width,
spritesheet_height: source_height,
icon_image_srcs: Vec::new(),
slice_warning: None,
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: source_record.resource,
spritesheet_asset: source_record.asset,
project: completed_project,
warning: Some(editor_postprocess_fallback_warning(
"生成任务成功,后处理失败。",
)),
},
));
}
};
let removal_provider = removal.provider;
let (image, postprocess_dimension_error) =
apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original(
state,
source_object_key.as_str(),
source_width,
source_height,
removal.image,
)
.await;
if let Some(error) = postprocess_dimension_error {
tracing::warn!(
task_id = %generated.task_id,
provider = removal_provider,
source_width,
source_height,
error = ?error,
"UI 素材透明图蒙版尺寸恢复失败,保留去背景服务原始输出"
);
}
let matting_generation_inputs =
build_editor_derived_asset_generation_inputs("UI图集抠图", &removal.model, &source_record);
let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((512, 512));
let owner_user_id = caller.owner_user_id;
let spritesheet_persisted = persist_editor_generated_image(
state,
owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
"去除纯色背景",
None,
EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND,
"ui-design-spritesheets",
"spritesheet",
"spritesheet",
removal.provider,
)
.await?;
let spritesheet_image_src =
editor_media_src_from_object_key(spritesheet_persisted.object_key.as_str());
let spritesheet_record = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: asset_folder_id.clone(),
label: source_label,
image_src: spritesheet_image_src.clone(),
object_key: Some(spritesheet_persisted.object_key.clone()),
asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()),
width: spritesheet_width,
height: spritesheet_height,
prompt: "去除纯色背景".to_string(),
actual_prompt: None,
model: removal.model.clone(),
provider: removal.provider.to_string(),
task_id: generated.task_id.clone(),
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: source_record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
asset_kind: Some("icon-spritesheet".to_string()),
generation_inputs: matting_generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: 0,
},
)
.await?;
let slice_source = DownloadedImage {
bytes: image.bytes.clone(),
mime_type: image.mime_type.clone(),
extension: image.extension.clone(),
};
let (icon_image_srcs, slice_warning) =
match slice_generated_icon_spritesheet_all_by_connected_components(&slice_source) {
Ok(icon_slices) => {
match persist_editor_spritesheet_slices(
state,
icon_slices,
PersistEditorSpritesheetSlicesInput {
owner_user_id: owner_user_id.clone(),
project_id: payload.project_id.clone(),
asset_folder_id: asset_folder_id.clone(),
source_resource_id: spritesheet_record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
task_id: generated.task_id.clone(),
group_task_id: None,
prompt: "自动拆分图集".to_string(),
actual_prompt: None,
model: "connected-components".to_string(),
provider: "Genarrative".to_string(),
generation_inputs: build_editor_derived_asset_generation_inputs(
"图集拆分",
"connected-components",
&spritesheet_record,
),
path_kind: "ui-design-assets",
asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND,
persistence_provider: "vector-engine",
},
)
.await
{
Ok(icon_image_srcs) => (icon_image_srcs, None),
Err(error) => {
let reason = error.body_text();
tracing::warn!(
provider = "editor-ui-design-asset-extraction",
operation = "persist_slices",
task_id = %generated.task_id,
reason = %reason,
"UI 设计图透明图集已持久化,但独立素材持久化失败"
);
(
Vec::new(),
Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE,
reason,
}),
)
}
}
}
Err(error) => {
let reason = error.to_string();
tracing::warn!(
provider = "editor-ui-design-asset-extraction",
operation = "detect_slices",
task_id = %generated.task_id,
reason = %reason,
"UI 设计图透明图集已持久化,但未识别到可拆分素材"
);
(
Vec::new(),
Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
reason,
}),
)
}
};
let (canvas_items, primary_layer_id) =
if let Some(completion) = payload.canvas_completion.as_ref() {
build_icon_spritesheet_canvas_layer_items(
completion,
spritesheet_record.resource.as_ref(),
source_record.resource.as_ref(),
icon_image_srcs.as_slice(),
)
} else {
(Vec::new(), None)
};
let completed_project = complete_editor_canvas_generation_with_items(
state,
owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
canvas_items,
primary_layer_id,
)
.await?;
Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetGenerationResponse {
spritesheet_image_src,
spritesheet_width,
spritesheet_height,
icon_image_srcs,
slice_warning,
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: spritesheet_record.resource,
spritesheet_asset: spritesheet_record.asset,
project: completed_project,
warning: None,
},
))
}
async fn repair_editor_project_record_inline_media(
state: &AppState,
mut record: EditorProjectRecord,
) -> EditorProjectRecord {
let owner_user_id = record.owner_user_id.clone();
let mut repaired_resources = Vec::with_capacity(record.resources.len());
for resource in record.resources {
repaired_resources.push(
repair_editor_project_resource_inline_media(state, owner_user_id.as_str(), resource)
.await,
);
}
record.resources = repaired_resources;
record
}
async fn repair_editor_asset_library_inline_media(
state: &AppState,
owner_user_id: &str,
mut record: EditorAssetLibraryRecord,
) -> EditorAssetLibraryRecord {
let mut repaired_assets = Vec::with_capacity(record.assets.len());
for asset in record.assets {
repaired_assets.push(repair_editor_asset_inline_media(state, owner_user_id, asset).await);
}
record.assets = repaired_assets;
record
}
async fn repair_editor_project_resource_inline_media(
state: &AppState,
owner_user_id: &str,
resource: EditorProjectResourceRecord,
) -> EditorProjectResourceRecord {
if !should_repair_editor_inline_image(
resource.image_src.as_str(),
resource.object_key.as_deref(),
) {
return resource;
}
let repair = persist_editor_legacy_inline_image(
state,
owner_user_id,
resource.image_src.as_str(),
resource
.task_id
.as_deref()
.unwrap_or(resource.resource_id.as_str()),
resource
.prompt
.as_deref()
.or(resource.actual_prompt.as_deref())
.unwrap_or("历史画布内联图片修复"),
resource.actual_prompt.as_deref(),
resource.asset_kind.as_deref(),
"legacy-resources",
resource.resource_id.as_str(),
)
.await;
let persisted = match repair {
Ok(persisted) => persisted,
Err(error) => {
tracing::warn!(
resource_id = %resource.resource_id,
error = %error,
"repair legacy inline editor project resource media failed"
);
return resource;
}
};
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let repaired = state
.spacetime_client()
.repair_editor_project_resource_media(EditorProjectResourceMediaRepairRecordInput {
resource_id: resource.resource_id.clone(),
owner_user_id: owner_user_id.to_string(),
asset_object_id: persisted.asset_object_id.clone(),
image_src: image_src.clone(),
object_key: persisted.object_key.clone(),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error);
match repaired {
Ok(record) => record,
Err(error) => {
tracing::warn!(
resource_id = %resource.resource_id,
error = %error,
"persist legacy inline editor project resource repair failed"
);
EditorProjectResourceRecord {
image_src,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
..resource
}
}
}
}
async fn repair_editor_asset_inline_media(
state: &AppState,
owner_user_id: &str,
asset: EditorAssetRecord,
) -> EditorAssetRecord {
if !should_repair_editor_inline_image(asset.image_src.as_str(), asset.object_key.as_deref()) {
return asset;
}
let repair = persist_editor_legacy_inline_image(
state,
owner_user_id,
asset.image_src.as_str(),
asset.task_id.as_deref().unwrap_or(asset.asset_id.as_str()),
asset.prompt.as_deref().unwrap_or(asset.label.as_str()),
asset.actual_prompt.as_deref(),
asset.asset_kind.as_deref(),
"legacy-assets",
asset.asset_id.as_str(),
)
.await;
let persisted = match repair {
Ok(persisted) => persisted,
Err(error) => {
tracing::warn!(
asset_id = %asset.asset_id,
error = %error,
"repair legacy inline editor asset media failed"
);
return asset;
}
};
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let repaired = state
.spacetime_client()
.repair_editor_asset_media(EditorAssetMediaRepairRecordInput {
asset_id: asset.asset_id.clone(),
owner_user_id: owner_user_id.to_string(),
asset_object_id: persisted.asset_object_id.clone(),
image_src: image_src.clone(),
object_key: persisted.object_key.clone(),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error);
match repaired {
Ok(record) => record,
Err(error) => {
tracing::warn!(
asset_id = %asset.asset_id,
error = %error,
"persist legacy inline editor asset repair failed"
);
EditorAssetRecord {
image_src,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
..asset
}
}
}
}
async fn persist_editor_legacy_inline_image(
state: &AppState,
owner_user_id: &str,
image_src: &str,
task_id: &str,
prompt: &str,
actual_prompt: Option<&str>,
asset_kind: Option<&str>,
path_kind: &str,
file_stem: &str,
) -> Result<PersistedEditorGeneratedImage, AppError> {
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<EditorProjectResourcePayload> = record
.resources
.into_iter()
.map(editor_project_resource_payload_from_record)
.collect();
let canvas = editor_canvas_payload_from_record(record.canvas, resources.as_slice());
let layers = sanitize_editor_payload_media(record.layers, resources.as_slice());
EditorProjectPayload {
project_id: record.project_id,
title: record.title,
canvas,
viewport: EditorCanvasViewportPayload {
x: record.viewport.x,
y: record.viewport.y,
scale: record.viewport.scale,
},
layers,
resources,
updated_at: record.updated_at,
}
}
fn editor_canvas_payload_from_record(
record: EditorCanvasRecord,
resources: &[EditorProjectResourcePayload],
) -> EditorCanvasPayload {
EditorCanvasPayload {
canvas_id: record.canvas_id,
project_id: record.project_id,
title: record.title,
viewport: EditorCanvasViewportPayload {
x: record.viewport.x,
y: record.viewport.y,
scale: record.viewport.scale,
},
layers: sanitize_editor_payload_media(record.layers, resources),
revision: record.revision,
layout_storage_version: record.layout_storage_version,
background_color: record.background_color,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
pub(crate) fn editor_project_resource_payload_from_record(
record: EditorProjectResourceRecord,
) -> EditorProjectResourcePayload {
let image_src =
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
EditorProjectResourcePayload {
resource_id: record.resource_id,
showcase_id: None,
asset_id: None,
label: None,
project_id: record.project_id,
owner_user_id: record.owner_user_id,
author_display_name: None,
author_public_user_code: None,
image_src,
object_key: record.object_key,
asset_object_id: record.asset_object_id,
width: record.width,
height: record.height,
source_type: record.source_type,
prompt: record.prompt,
actual_prompt: record.actual_prompt,
model: record.model,
provider: record.provider,
task_id: record.task_id,
source_resource_id: record.source_resource_id,
asset_kind: record.asset_kind,
showcase_category: None,
generation_inputs: record
.generation_inputs
.map(sanitize_editor_payload_inline_media),
public_showcase_enabled: record.public_showcase_enabled,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn editor_project_resource_payload_from_showcase_record(
record: EditorShowcaseAssetRecord,
) -> EditorProjectResourcePayload {
let image_src =
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
EditorProjectResourcePayload {
resource_id: record.showcase_id.clone(),
showcase_id: Some(record.showcase_id),
asset_id: Some(record.asset_id),
label: Some(record.label),
project_id: "editor-showcase".to_string(),
owner_user_id: record.owner_user_id,
author_display_name: None,
author_public_user_code: None,
image_src,
object_key: record.object_key,
asset_object_id: record.asset_object_id,
width: record.width,
height: record.height,
source_type: record.source_type,
prompt: record.prompt,
actual_prompt: record.actual_prompt,
model: record.model,
provider: record.provider,
task_id: record.task_id,
source_resource_id: record.source_resource_id,
asset_kind: record.asset_kind,
showcase_category: record.showcase_category,
generation_inputs: record
.generation_inputs
.map(sanitize_editor_payload_inline_media),
public_showcase_enabled: record.display_enabled,
review_status: Some(record.review_status),
display_enabled: Some(record.display_enabled),
like_count: Some(record.like_count),
generation_cost_mud_points: record.generation_cost_mud_points,
refund_mud_points: Some(record.refund_mud_points),
created_at: record.submitted_at,
updated_at: record.updated_at,
}
}
fn editor_project_resource_public_payload_from_record(
state: &AppState,
record: EditorShowcaseAssetRecord,
) -> EditorProjectResourcePayload {
let mut payload = editor_project_resource_payload_from_showcase_record(record);
let (display_name, public_user_code) =
resolve_editor_project_resource_public_author(state, payload.owner_user_id.as_str());
payload.author_display_name = display_name;
payload.author_public_user_code = public_user_code;
payload
}
fn resolve_editor_project_resource_public_author(
state: &AppState,
owner_user_id: &str,
) -> (Option<String>, Option<String>) {
let Some(owner_user_id) = normalize_optional_string(Some(owner_user_id.to_string())) else {
return (None, Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string()));
};
if owner_user_id == ORPHAN_WORK_OWNER_USER_ID {
return (None, Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string()));
}
match state.auth_user_service().get_user_by_id(&owner_user_id) {
Ok(Some(user)) => (
normalize_optional_string(Some(user.display_name)),
normalize_optional_string(Some(user.public_user_code)),
),
Ok(None) | Err(_) => (None, Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string())),
}
}
pub(crate) fn editor_asset_library_payload_from_record(
record: EditorAssetLibraryRecord,
) -> EditorAssetLibraryPayload {
EditorAssetLibraryPayload {
folders: record
.folders
.into_iter()
.map(editor_asset_folder_payload_from_record)
.collect(),
assets: record
.assets
.into_iter()
.map(editor_asset_payload_from_record)
.collect(),
}
}
pub(crate) fn editor_asset_folder_payload_from_record(
record: EditorAssetFolderRecord,
) -> EditorAssetFolderPayload {
EditorAssetFolderPayload {
folder_id: record.folder_id,
label: record.label,
sort_order: record.sort_order,
collapsed: record.collapsed,
system_default: record.system_default,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload {
let image_src =
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
EditorAssetPayload {
asset_id: record.asset_id,
folder_id: record.folder_id,
label: record.label,
image_src,
object_key: record.object_key,
asset_object_id: record.asset_object_id,
width: record.width,
height: record.height,
source_type: record.source_type,
prompt: record.prompt,
actual_prompt: record.actual_prompt,
model: record.model,
provider: record.provider,
task_id: record.task_id,
asset_kind: record.asset_kind,
generation_inputs: record
.generation_inputs
.map(sanitize_editor_payload_inline_media),
source_resource_id: record.source_resource_id,
public_showcase_enabled: record.public_showcase_enabled,
thumbnail_src: normalize_optional_string(record.thumbnail_src),
generation_cost_mud_points: record.generation_cost_mud_points,
showcase_id: record.showcase_id,
showcase_review_status: record.showcase_review_status,
showcase_display_enabled: record.showcase_display_enabled,
showcase_like_count: record.showcase_like_count,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn editor_showcase_campaign_payload_from_record(
record: EditorShowcaseCampaignConfigRecord,
) -> EditorShowcaseCampaignPayload {
EditorShowcaseCampaignPayload {
enabled: record.enabled,
title: record.title,
image_src: record.image_src,
image_object_key: record.image_object_key,
image_width: record.image_width,
image_height: record.image_height,
prompt: record.prompt,
author: record.author,
cost_text: record.cost_text,
updated_at: record.updated_at,
}
}
#[derive(Clone, Debug)]
struct EditorPayloadMediaReference {
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
asset_kind: Option<String>,
}
fn sanitize_editor_payload_media(
mut value: Value,
resources: &[EditorProjectResourcePayload],
) -> Value {
let resource_media: BTreeMap<String, EditorPayloadMediaReference> = resources
.iter()
.filter(|resource| !resource.image_src.trim().is_empty())
.map(|resource| {
(
resource.resource_id.clone(),
EditorPayloadMediaReference {
image_src: resource.image_src.clone(),
object_key: resource.object_key.clone(),
asset_object_id: resource.asset_object_id.clone(),
asset_kind: resource.asset_kind.clone(),
},
)
})
.collect();
sanitize_editor_payload_media_value(&mut value, None, &resource_media);
value
}
fn sanitize_editor_payload_inline_media(mut value: Value) -> Value {
sanitize_editor_payload_media_value(&mut value, None, &BTreeMap::new());
value
}
fn sanitize_editor_payload_media_value(
value: &mut Value,
inherited_media: Option<&EditorPayloadMediaReference>,
resource_media: &BTreeMap<String, EditorPayloadMediaReference>,
) {
match value {
Value::Object(object) => {
let current_media = object
.get("resourceId")
.and_then(Value::as_str)
.and_then(|resource_id| resource_media.get(resource_id))
.cloned()
.or_else(|| inherited_media.cloned());
if let Some(media) = current_media.as_ref() {
for field in ["src", "imageSrc", "thumbnailSrc", "previewVideoPath"] {
rewrite_or_remove_inline_media_field(object, field, media);
}
fill_missing_media_identity_field(object, "objectKey", media.object_key.as_ref());
fill_missing_media_identity_field(
object,
"assetObjectId",
media.asset_object_id.as_ref(),
);
if object.get("assetKind").and_then(Value::as_str)
== Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND)
{
set_optional_json_string_field(object, "assetKind", media.asset_kind.as_ref());
}
}
for child in object.values_mut() {
sanitize_editor_payload_media_value(child, current_media.as_ref(), resource_media);
}
object.retain(|_, child| !child.is_null());
}
Value::Array(items) => {
for item in items.iter_mut() {
sanitize_editor_payload_media_value(item, inherited_media, resource_media);
}
items.retain(|item| !item.is_null());
}
Value::String(item) if is_forbidden_editor_persisted_media_src(item) => {
*value = Value::Null;
}
_ => {}
}
}
fn rewrite_or_remove_inline_media_field(
object: &mut serde_json::Map<String, Value>,
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<String, Value>,
field: &str,
value: Option<&String>,
) {
let Some(value) = value
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let should_fill = object
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|current| !current.is_empty())
.is_none();
if should_fill {
object.insert(field.to_string(), Value::String(value.to_string()));
}
}
pub(crate) async fn persist_editor_generated_asset(
state: &AppState,
input: PersistEditorGeneratedAssetInput,
) -> Result<EditorGeneratedAssetRecord, AppError> {
let generation_inputs_json = serialize_editor_asset_metadata(input.generation_inputs.clone())?;
let object_key = normalize_editor_object_key(input.object_key);
let asset_object_id = normalize_optional_string(input.asset_object_id);
let image_src = normalize_editor_persisted_media_src(input.image_src, object_key.as_deref())?;
let now_micros = current_utc_micros();
let resource = if let Some(project_id) = normalize_optional_string(input.project_id.clone()) {
let record = state
.spacetime_client()
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
project_id,
owner_user_id: input.owner_user_id.clone(),
asset_object_id: asset_object_id.clone(),
image_src: image_src.clone(),
object_key: object_key.clone(),
width: input.width,
height: input.height,
source_type: "generated".to_string(),
prompt: Some(input.prompt.clone()),
actual_prompt: input.actual_prompt.clone(),
model: Some(input.model.clone()),
provider: Some(input.provider.clone()),
task_id: Some(input.task_id.clone()),
source_resource_id: normalize_optional_string(input.source_resource_id.clone()),
asset_kind: normalize_optional_string(input.asset_kind.clone()),
generation_inputs_json: generation_inputs_json.clone(),
updated_at_micros: now_micros,
})
.await
.map_err(map_editor_project_error)?;
Some(editor_project_resource_payload_from_record(record))
} else {
None
};
let folder_id =
normalize_generated_asset_folder_id(input.folder_id, input.owner_user_id.as_str());
let created_resource_id = resource
.as_ref()
.map(|resource| resource.resource_id.clone())
.or_else(|| normalize_optional_string(input.source_resource_id.clone()));
let asset = if let Some(folder_id) = folder_id {
let record = state
.spacetime_client()
.create_editor_asset(EditorAssetCreateRecordInput {
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
owner_user_id: input.owner_user_id.clone(),
folder_id,
label: input.label,
asset_object_id,
image_src,
object_key,
width: input.width,
height: input.height,
source_type: "generated".to_string(),
prompt: Some(input.prompt),
actual_prompt: input.actual_prompt,
model: Some(input.model),
provider: Some(input.provider),
task_id: Some(input.task_id),
asset_kind: normalize_optional_string(input.asset_kind),
generation_inputs_json,
source_resource_id: created_resource_id,
generation_cost_mud_points: input.generation_cost_mud_points,
now_micros,
thumbnail_src: normalize_optional_string(input.thumbnail_src),
group_task_id: normalize_optional_string(input.group_task_id),
group_task_expected_asset_count: input.group_task_expected_asset_count,
})
.await
.map_err(map_editor_project_error)?;
Some(editor_asset_payload_from_record(record))
} else {
None
};
Ok(EditorGeneratedAssetRecord { resource, asset })
}
pub(crate) async fn persist_editor_generated_media_asset(
state: &AppState,
input: PersistEditorGeneratedAssetRequest,
) -> Result<
(
Option<EditorProjectResourcePayload>,
Option<EditorAssetPayload>,
),
AppError,
> {
let persisted = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: input.project_id,
owner_user_id: input.owner_user_id,
folder_id: input.folder_id,
label: input.label,
image_src: input.image_src,
object_key: input.object_key,
asset_object_id: input.asset_object_id,
width: input.width,
height: input.height,
prompt: input.prompt,
actual_prompt: input.actual_prompt,
model: input.model,
provider: input.provider,
task_id: input.task_id,
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: input.source_resource_id,
asset_kind: input.asset_kind,
generation_inputs: input.generation_inputs,
thumbnail_src: input.thumbnail_src,
generation_cost_mud_points: input.generation_cost_mud_points,
},
)
.await?;
Ok((persisted.resource, persisted.asset))
}
pub(crate) async fn complete_editor_canvas_generation(
state: &AppState,
owner_user_id: &str,
project_id: Option<&str>,
completion: Option<&EditorCanvasGenerationCompletionRequest>,
resource: Option<&EditorProjectResourcePayload>,
) -> Result<Option<EditorProjectPayload>, AppError> {
let (Some(project_id), Some(completion), Some(resource)) = (project_id, completion, resource)
else {
return Ok(None);
};
let project_id = project_id.trim();
if project_id.is_empty() {
return Ok(None);
}
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.to_string(),
owner_user_id: owner_user_id.to_string(),
})
.await
.map_err(map_editor_project_error)?;
let project = repair_editor_project_record_inline_media(state, project).await;
let expected_revision = project.canvas.revision;
let viewport = project.viewport.clone();
let project_payload = editor_project_payload_from_record(project);
let Some(placeholder) =
resolve_canvas_completion_placeholder(project_payload.layers.clone(), completion)?
else {
return Ok(None);
};
let layer_id = generated_canvas_layer_id(resource.resource_id.as_str());
let item =
build_generated_canvas_layer_item(completion, &placeholder, resource, layer_id.as_str(), 0);
let completion_result = apply_editor_canvas_generation_items(
project_payload.layers,
completion,
placeholder,
vec![item],
Some(layer_id),
)?;
if !completion_result.changed {
return Ok(None);
}
let saved = save_editor_project_layout_with_revision_and_get(
state,
project_id,
owner_user_id,
viewport,
completion_result.layers,
expected_revision,
)
.await?;
Ok(Some(editor_project_payload_from_record(saved)))
}
pub(crate) async fn complete_editor_canvas_generation_with_items(
state: &AppState,
owner_user_id: &str,
project_id: Option<&str>,
completion: Option<&EditorCanvasGenerationCompletionRequest>,
items: Vec<Value>,
primary_layer_id: Option<String>,
) -> Result<Option<EditorProjectPayload>, AppError> {
let (Some(project_id), Some(completion)) = (project_id, completion) else {
return Ok(None);
};
let project_id = project_id.trim();
if project_id.is_empty() || items.is_empty() {
return Ok(None);
}
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.to_string(),
owner_user_id: owner_user_id.to_string(),
})
.await
.map_err(map_editor_project_error)?;
let project = repair_editor_project_record_inline_media(state, project).await;
let expected_revision = project.canvas.revision;
let viewport = project.viewport.clone();
let project_payload = editor_project_payload_from_record(project);
let Some(placeholder) =
resolve_canvas_completion_placeholder(project_payload.layers.clone(), completion)?
else {
return Ok(None);
};
let items =
translate_editor_canvas_generation_items(items, &completion.placeholder, &placeholder);
let completion_result = apply_editor_canvas_generation_items(
project_payload.layers,
completion,
placeholder,
items,
primary_layer_id,
)?;
if !completion_result.changed {
return Ok(None);
}
let saved = save_editor_project_layout_with_revision_and_get(
state,
project_id,
owner_user_id,
viewport,
completion_result.layers,
expected_revision,
)
.await?;
Ok(Some(editor_project_payload_from_record(saved)))
}
pub(crate) async fn complete_editor_canvas_background_removal(
state: &AppState,
owner_user_id: &str,
project_id: Option<&str>,
target_layer_id: Option<&str>,
resource: Option<&EditorProjectResourcePayload>,
replacement_title: Option<&str>,
recenter_replacement: bool,
) -> Result<Option<EditorProjectPayload>, AppError> {
let (Some(project_id), Some(target_layer_id), Some(resource)) =
(project_id, target_layer_id, resource)
else {
return Ok(None);
};
let project_id = project_id.trim();
let target_layer_id = target_layer_id.trim();
if project_id.is_empty() || target_layer_id.is_empty() {
return Ok(None);
}
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.to_string(),
owner_user_id: owner_user_id.to_string(),
})
.await
.map_err(map_editor_project_error)?;
let project = repair_editor_project_record_inline_media(state, project).await;
let expected_revision = project.canvas.revision;
let viewport = project.viewport.clone();
let project_payload = editor_project_payload_from_record(project);
let Some(layers) = replace_editor_canvas_layer_with_resource(
project_payload.layers,
target_layer_id,
resource,
replacement_title,
recenter_replacement,
) else {
return Ok(None);
};
let saved = save_editor_project_layout_with_revision_and_get(
state,
project_id,
owner_user_id,
viewport,
layers,
expected_revision,
)
.await?;
Ok(Some(editor_project_payload_from_record(saved)))
}
pub(crate) async fn save_editor_project_layout_with_revision_and_get(
state: &AppState,
project_id: &str,
owner_user_id: &str,
viewport: EditorCanvasViewportRecord,
layers: Value,
expected_revision: u64,
) -> Result<EditorProjectRecord, AppError> {
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<Value> {
let mut items = match layers {
Value::Array(items) => items,
_ => return None,
};
let mut changed = false;
for item in &mut items {
let Some(object) = item.as_object_mut() else {
continue;
};
let is_target = object
.get("layerId")
.and_then(Value::as_str)
.is_some_and(|layer_id| layer_id == target_layer_id);
if !is_target {
continue;
}
let replacement_width = f64::from(resource.width.max(1));
let replacement_height = f64::from(resource.height.max(1));
let centered_position = recenter_replacement
.then(|| {
object
.get("x")
.and_then(Value::as_f64)
.zip(object.get("y").and_then(Value::as_f64))
.zip(object.get("width").and_then(Value::as_f64))
.zip(object.get("height").and_then(Value::as_f64))
.map(|(((x, y), width), height)| {
(
x + width / 2.0 - replacement_width / 2.0,
y + height / 2.0 - replacement_height / 2.0,
)
})
})
.flatten();
object.insert(
"resourceId".to_string(),
Value::String(resource.resource_id.clone()),
);
if let Some(title) = replacement_title
.map(str::trim)
.filter(|title| !title.is_empty())
{
object.insert("title".to_string(), Value::String(title.to_string()));
}
object.insert("src".to_string(), Value::String(resource.image_src.clone()));
if let Some((x, y)) = centered_position {
object.insert("x".to_string(), json!(x));
object.insert("y".to_string(), json!(y));
}
object.insert("width".to_string(), json!(resource.width.max(1)));
object.insert("height".to_string(), json!(resource.height.max(1)));
object.insert("originalWidth".to_string(), json!(resource.width.max(1)));
object.insert("originalHeight".to_string(), json!(resource.height.max(1)));
object.insert(
"sourceType".to_string(),
Value::String(resource.source_type.clone()),
);
object.insert("mediaType".to_string(), Value::String("image".to_string()));
set_optional_json_string_field(object, "prompt", resource.prompt.as_ref());
set_optional_json_string_field(object, "actualPrompt", resource.actual_prompt.as_ref());
set_optional_json_string_field(object, "model", resource.model.as_ref());
set_optional_json_string_field(object, "provider", resource.provider.as_ref());
set_optional_json_string_field(object, "taskId", resource.task_id.as_ref());
set_optional_json_string_field(object, "objectKey", resource.object_key.as_ref());
set_optional_json_string_field(object, "assetObjectId", resource.asset_object_id.as_ref());
set_optional_json_string_field(
object,
"sourceResourceId",
resource.source_resource_id.as_ref(),
);
set_optional_json_string_field(object, "assetKind", resource.asset_kind.as_ref());
set_optional_json_value_field(
object,
"generationInputs",
resource.generation_inputs.clone(),
);
object.remove("sourceAssetId");
object.remove("thumbnailSrc");
object.remove("imageSequenceFrames");
object.remove("previewVideoPath");
object.remove("durationSeconds");
changed = true;
break;
}
changed.then_some(Value::Array(items))
}
fn set_optional_json_string_field(
object: &mut serde_json::Map<String, Value>,
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<String, Value>,
field: &str,
value: Option<Value>,
) {
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<Value>,
submitted_placeholder: &EditorCanvasGenerationPlaceholderPayload,
resolved_placeholder: &EditorCanvasGenerationPlaceholderPayload,
) -> Vec<Value> {
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<Option<EditorCanvasGenerationPlaceholderPayload>, 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<Value>,
primary_layer_id: Option<String>,
) -> Result<EditorCanvasGenerationCompletionResult, AppError> {
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<String> {
completion
.dialog_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn build_generated_canvas_layer_item(
completion: &EditorCanvasGenerationCompletionRequest,
placeholder: &EditorCanvasGenerationPlaceholderPayload,
resource: &EditorProjectResourcePayload,
generated_layer_id: &str,
z_index: i64,
) -> Value {
let original_width = positive_f64(resource.width as f64, placeholder.original_width);
let original_height = positive_f64(resource.height as f64, placeholder.original_height);
let width = positive_f64(original_width, placeholder.width);
let height = positive_f64(original_height, placeholder.height);
let placeholder_width = positive_f64(placeholder.width, width);
let placeholder_height = positive_f64(placeholder.height, height);
let x = placeholder.x + placeholder_width / 2.0 - width / 2.0;
let y = placeholder.y + placeholder_height / 2.0 - height / 2.0;
let asset_kind = resource.asset_kind.as_deref();
json!({
"layerId": generated_layer_id,
"resourceId": resource.resource_id,
"title": completion.title.trim(),
"src": resource.image_src,
"x": x,
"y": y,
"width": width,
"height": height,
"originalWidth": original_width,
"originalHeight": original_height,
"zIndex": z_index,
"sourceType": "generated",
"mediaType": generated_canvas_media_type(asset_kind),
"prompt": resource.prompt,
"actualPrompt": resource.actual_prompt,
"model": resource.model,
"provider": resource.provider,
"taskId": resource.task_id,
"objectKey": resource.object_key,
"assetObjectId": resource.asset_object_id,
"sourceResourceId": resource.source_resource_id,
"assetKind": resource.asset_kind,
"generationInputs": resource.generation_inputs,
})
}
pub(crate) fn build_editor_canvas_generated_layer_item(
completion: &EditorCanvasGenerationCompletionRequest,
input: EditorCanvasGeneratedLayerInput,
) -> Value {
let placeholder = &completion.placeholder;
let original_width = positive_f64(input.original_width as f64, placeholder.original_width);
let original_height = positive_f64(input.original_height as f64, placeholder.original_height);
let width = positive_f64(input.display_width, placeholder.width);
let height = positive_f64(input.display_height, placeholder.height);
let placeholder_width = positive_f64(placeholder.width, width);
let placeholder_height = positive_f64(placeholder.height, height);
let x = placeholder.x + placeholder_width / 2.0 - width / 2.0;
let y = placeholder.y + placeholder_height / 2.0 - height / 2.0;
json!({
"layerId": input.layer_id,
"resourceId": input.resource_id,
"title": input.title,
"src": input.src,
"mediaType": input.media_type,
"thumbnailSrc": input.thumbnail_src,
"x": x,
"y": y,
"width": width,
"height": height,
"originalWidth": original_width,
"originalHeight": original_height,
"sourceType": input.source_type,
"prompt": input.prompt,
"actualPrompt": input.actual_prompt,
"model": input.model,
"provider": input.provider,
"taskId": input.task_id,
"objectKey": input.object_key,
"assetObjectId": input.asset_object_id,
"durationSeconds": input.duration_seconds,
"sourceResourceId": input.source_resource_id,
"assetKind": input.asset_kind,
"generationInputs": input.generation_inputs,
"imageSequenceFrames": input.image_sequence_frames,
"previewVideoPath": input.preview_video_path,
})
}
fn positive_f64(value: f64, fallback: f64) -> f64 {
if value.is_finite() && value > 0.0 {
value.round().max(1.0)
} else if fallback.is_finite() && fallback > 0.0 {
fallback.round().max(1.0)
} else {
1.0
}
}
fn generated_canvas_layer_id(resource_id: &str) -> String {
format!("layer-editor-resource-{resource_id}")
}
fn generated_canvas_media_type(asset_kind: Option<&str>) -> &'static str {
match asset_kind {
Some("video") => "video",
Some("sound-effect") | Some("background-music") => "audio",
_ => "image",
}
}
fn build_primary_canvas_layer_items(
completion: &EditorCanvasGenerationCompletionRequest,
primary_resource: Option<&EditorProjectResourcePayload>,
) -> (Vec<Value>, Option<String>) {
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<Value>, Option<String>) {
let (mut items, primary_layer_id) =
build_primary_canvas_layer_items(completion, primary_resource);
let Some(primary_item) = items.first() else {
return (items, primary_layer_id);
};
let primary_right = primary_item
.get("x")
.and_then(Value::as_f64)
.unwrap_or(completion.placeholder.x)
+ primary_item
.get("width")
.and_then(Value::as_f64)
.unwrap_or(completion.placeholder.width.max(1.0));
let primary_y = primary_item
.get("y")
.and_then(Value::as_f64)
.unwrap_or(completion.placeholder.y);
if let Some(source_resource) = source_resource {
let source_layer_id = generated_canvas_layer_id(source_resource.resource_id.as_str());
let mut source_item = build_generated_canvas_layer_item(
completion,
&completion.placeholder,
source_resource,
source_layer_id.as_str(),
0,
);
if let Some(object) = source_item.as_object_mut() {
object.insert("x".to_string(), json!(primary_right + 32.0));
object.insert("y".to_string(), json!(primary_y));
object.insert(
"title".to_string(),
json!(format!("{}(原图)", completion.title.trim())),
);
}
items.push(source_item);
}
(items, primary_layer_id)
}
fn build_icon_spritesheet_canvas_layer_items(
completion: &EditorCanvasGenerationCompletionRequest,
spritesheet_resource: Option<&EditorProjectResourcePayload>,
source_resource: Option<&EditorProjectResourcePayload>,
icons: &[EditorIconSpritesheetIconResponse],
) -> (Vec<Value>, Option<String>) {
let placeholder = completion.placeholder.clone();
let (mut items, primary_layer_id) =
build_multi_asset_canvas_layer_items(completion, spritesheet_resource, source_resource);
if items.is_empty() {
return (items, primary_layer_id);
}
let start_x = items
.iter()
.filter_map(|item| Some(item.get("x")?.as_f64()? + item.get("width")?.as_f64()?))
.fold(placeholder.x, f64::max)
+ 32.0;
items.extend(build_icon_slice_canvas_layer_items_from_origin(
completion,
icons,
start_x,
placeholder.y,
));
(items, primary_layer_id)
}
fn build_icon_spritesheet_slice_canvas_layer_items(
completion: &EditorCanvasGenerationCompletionRequest,
icons: &[EditorIconSpritesheetIconResponse],
) -> Vec<Value> {
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<Value> {
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<EditorCanvasGenerationPlaceholderPayload> {
if !generation_dialog_item_matches(item, dialog_id) {
return None;
}
serde_json::from_value(
item.get("dialog")?
.get("placeholder")
.cloned()
.unwrap_or(Value::Null),
)
.ok()
}
fn normalize_generated_asset_folder_id(
folder_id: Option<String>,
owner_user_id: &str,
) -> Option<String> {
let normalized = normalize_optional_string(folder_id);
match normalized.as_deref() {
None => None,
Some("project") => Some(default_editor_asset_folder_id(owner_user_id)),
Some(value) if value.starts_with("folder-") => {
Some(default_editor_asset_folder_id(owner_user_id))
}
Some(_) => normalized,
}
}
fn default_editor_asset_folder_id(owner_user_id: &str) -> String {
format!("{owner_user_id}:asset-folder:{EDITOR_ASSET_DEFAULT_FOLDER_ID}")
}
fn normalize_editor_object_key(value: Option<String>) -> Option<String> {
normalize_optional_string(value)
.map(|item| item.trim_start_matches('/').to_string())
.filter(|item| !item.is_empty())
}
fn editor_media_src_from_object_key(object_key: &str) -> String {
format!("/{}", object_key.trim().trim_start_matches('/'))
}
fn normalize_editor_record_media_src(image_src: String, object_key: Option<&str>) -> String {
if let Some(media_src) = object_key
.map(str::trim)
.filter(|value| !value.is_empty())
.map(editor_media_src_from_object_key)
{
return media_src;
}
if is_forbidden_editor_persisted_media_src(image_src.as_str()) {
return String::new();
}
image_src
}
pub(crate) fn normalize_editor_persisted_media_src(
image_src: String,
object_key: Option<&str>,
) -> Result<String, AppError> {
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<String, AppError> {
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<Value>,
) -> Result<Option<String>, AppError> {
let Some(metadata) = metadata else {
return Ok(None);
};
if metadata.is_null() {
return Ok(None);
}
if value_contains_forbidden_editor_persisted_media(&metadata) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
"message": "资产元数据不能包含内联媒体 Data URL。",
})),
);
}
let payload = serde_json::to_string(&metadata).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
"message": format!("资产元数据无法序列化:{error}"),
}))
})?;
if payload.len() > EDITOR_ASSET_METADATA_MAX_BYTES {
return Err(
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({
"provider": "editor-project",
"message": "资产元数据过大",
})),
);
}
Ok(Some(payload))
}
pub(crate) fn normalize_optional_string(value: Option<String>) -> Option<String> {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}
fn resolve_editor_generated_asset_label(value: Option<String>, fallback: &str) -> String {
normalize_optional_string(value)
.unwrap_or_else(|| fallback.to_string())
.chars()
.take(EDITOR_ASSET_LABEL_MAX_CHARS)
.collect()
}
fn editor_generated_asset_variant_label(label: &str, variant: &str) -> String {
let suffix = format!("({variant})");
let base_char_limit = EDITOR_ASSET_LABEL_MAX_CHARS.saturating_sub(suffix.chars().count());
let base = label
.trim()
.chars()
.take(base_char_limit)
.collect::<String>();
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::<String>()
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
if normalized.is_empty() {
fallback.to_string()
} else {
normalized
}
}
fn normalize_icon_descriptions(descriptions: Vec<String>) -> Result<Vec<String>, AppError> {
let normalized = descriptions
.into_iter()
.map(|description| description.trim().to_string())
.filter(|description| !description.is_empty())
.take(EDITOR_ICON_DESCRIPTION_LIMIT.saturating_add(1))
.collect::<Vec<_>>();
if normalized.is_empty() || normalized.len() > EDITOR_ICON_DESCRIPTION_LIMIT {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"message": "图标素材描述数量必须在 1 到 100 个之间。",
})),
);
}
Ok(normalized)
}
async fn resolve_editor_icon_spritesheet_price(
state: &AppState,
model: Option<&str>,
image_size: Option<&str>,
) -> Result<u32, AppError> {
let expected_price_mud_points = state
.editor_generation_pricing()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(Some("icon"), model, image_size);
Ok(expected_price_mud_points)
}
fn editor_ui_design_asset_extraction_bad_request(message: impl Into<String>) -> 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<EditorGenerationOptions, AppError> {
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<u32, AppError> {
let expected_price_mud_points = state
.editor_generation_pricing()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(Some("icon"), model, image_size);
Ok(expected_price_mud_points)
}
fn build_editor_icon_spritesheet_prompt(
icon_descriptions: &[String],
screen_color: EditorScreenBackgroundColor,
) -> String {
format!(
"参考图1的图标素材规范,{};禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}",
editor_green_screen_asset_prompt_clause(screen_color),
icon_descriptions.join("、")
)
}
fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool) -> String {
let mut prompt = vec![
"生成玩法UI原型图".to_string(),
format!("【用户输入】{}", user_input.trim()),
];
if has_icon_spec_reference {
prompt.push("参考图1为图标素材规范,请在UI图标、按钮符号、描边、材质、圆角、阴影和状态层级上严格遵循参考图1的素材规范。".to_string());
}
prompt.join("\n")
}
fn build_editor_character_image_prompt(
role_setting: &str,
screen_color: EditorScreenBackgroundColor,
) -> String {
vec![
format!("按照角色描述生成游戏角色立绘。严格基于图1的角色美术视觉规范的美术风格、角色头身比、角色朝向等特征。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。{};禁止生成美术视觉规范,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。", editor_green_screen_character_prompt_clause(screen_color)),
format!("角色设定:{}", role_setting.trim()),
]
.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct EditorGenerationOptions {
model: &'static str,
aspect_ratio: &'static str,
image_size: &'static str,
// 中文注释:size 是模型无关的画布交付像素;provider_size 只用于满足 GPT 图片接口的合法像素矩阵。
size: String,
provider_size: String,
provider_image_size: &'static str,
}
pub(crate) struct PersistedEditorGeneratedImage {
pub(crate) object_key: String,
pub(crate) asset_object_id: String,
}
struct PersistEditorProviderSourceResourceInput {
project_id: Option<String>,
owner_user_id: String,
folder_id: Option<String>,
label: String,
width: u32,
height: u32,
prompt: String,
actual_prompt: Option<String>,
model: String,
task_id: String,
source_resource_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
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<PersistedEditorGeneratedImage, AppError> {
persist_editor_generated_image_data(
state,
owner_user_id,
task_id,
GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(image.mime_type.as_str()),
bytes: image.bytes.clone(),
},
prompt,
actual_prompt,
asset_kind,
path_kind,
file_stem,
slot,
provider,
)
.await
}
async fn persist_editor_generated_image_owned(
state: &AppState,
owner_user_id: &str,
task_id: &str,
image: DownloadedOpenAiImage,
prompt: &str,
actual_prompt: Option<&str>,
asset_kind: &str,
path_kind: &str,
file_stem: &str,
slot: &str,
provider: &str,
) -> Result<PersistedEditorGeneratedImage, AppError> {
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<PersistedEditorGeneratedImage, 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 prepared =
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::CharacterDrafts,
path_segments: vec![
"editor".to_string(),
sanitize_editor_storage_segment(path_kind, "generated-images"),
sanitize_editor_storage_segment(task_id, "task"),
],
file_stem: sanitize_editor_storage_segment(file_stem, "image"),
image,
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(asset_kind.to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()),
entity_id: Some(task_id.to_string()),
slot: Some(slot.to_string()),
provider: Some(provider.to_string()),
task_id: Some(task_id.to_string()),
},
extra_metadata: BTreeMap::from([(
"source".to_string(),
"image-canvas-editor".to_string(),
)]),
})
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "generated-image-assets",
"message": format!("准备画板生成图片 OSS 上传请求失败:{error:?}"),
}))
})?;
let persisted_mime_type = prepared.format.mime_type.clone();
let http_client = reqwest::Client::new();
let put_result = oss_client
.put_object(&http_client, prepared.request)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let now_micros = current_utc_micros();
let asset_object = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(now_micros),
head.bucket,
head.object_key.clone(),
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
Some(actual_prompt.unwrap_or(prompt).to_string()),
asset_kind.to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
None,
Some(task_id.to_string()),
now_micros,
)
.map_err(map_editor_asset_field_error)?,
)
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
})?;
Ok(PersistedEditorGeneratedImage {
object_key: head.object_key,
asset_object_id: asset_object.asset_object_id,
})
}
async fn persist_editor_provider_source_image(
state: &AppState,
owner_user_id: &str,
task_id: &str,
image: DownloadedOpenAiImage,
prompt: &str,
actual_prompt: Option<&str>,
asset_kind: &str,
path_kind: &str,
file_stem: &str,
) -> Result<PersistedEditorGeneratedImage, AppError> {
persist_editor_generated_image_owned(
state,
owner_user_id,
task_id,
image,
prompt,
actual_prompt,
asset_kind,
path_kind,
&format!("{file_stem}-provider-source"),
EDITOR_PROVIDER_SOURCE_SLOT,
"vector-engine",
)
.await
}
async fn persist_editor_provider_source_resource(
state: &AppState,
persisted: PersistedEditorGeneratedImage,
input: PersistEditorProviderSourceResourceInput,
) -> Result<EditorGeneratedAssetRecord, AppError> {
persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: input.project_id,
owner_user_id: input.owner_user_id,
folder_id: input.folder_id,
label: input.label,
image_src: editor_media_src_from_object_key(persisted.object_key.as_str()),
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
width: input.width,
height: input.height,
prompt: input.prompt,
actual_prompt: input.actual_prompt,
model: input.model,
provider: "VectorEngine".to_string(),
task_id: input.task_id,
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: input.source_resource_id,
asset_kind: input.asset_kind,
generation_inputs: input.generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: input.generation_cost_mud_points,
},
)
.await
}
const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS: u64 = 300;
const EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES: u64 = 32 * 1024 * 1024;
const EDITOR_BACKGROUND_REMOVAL_SOURCE_PROBE_BYTES: u64 = 16;
fn is_editor_inline_media_source(source: &str) -> bool {
source
.trim_start()
.as_bytes()
.get(..5)
.is_some_and(|prefix| {
prefix.eq_ignore_ascii_case(b"data:") || prefix.eq_ignore_ascii_case(b"blob:")
})
}
fn ensure_editor_reference_image_source_is_stable(
source: &str,
provider: &str,
field: &str,
label: &str,
) -> Result<(), AppError> {
if !is_editor_inline_media_source(source) {
return Ok(());
}
Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": provider,
"field": field,
"message": format!("{label}必须先上传 OSS,并使用 objectKey、项目资源 ID 或素材 ID 引用。"),
})),
)
}
fn ensure_editor_reference_image_sources_are_stable(
sources: Option<&[String]>,
provider: &str,
field: &str,
label: &str,
) -> Result<(), AppError> {
for source in sources.unwrap_or(&[]) {
ensure_editor_reference_image_source_is_stable(source, provider, field, label)?;
}
Ok(())
}
pub(crate) async fn parse_editor_reference_image(
state: &AppState,
owner_user_id: &str,
source: &str,
) -> Result<OpenAiReferenceImage, AppError> {
let object_key =
resolve_editor_reference_object_key_for_owner(state, owner_user_id, source).await?;
read_editor_reference_image_object(state, object_key.as_str()).await
}
pub(crate) async fn resolve_editor_reference_object_key_for_owner(
state: &AppState,
owner_user_id: &str,
source: &str,
) -> Result<String, AppError> {
let object_key = resolve_editor_reference_object_key(state, owner_user_id, source).await?;
ensure_editor_reference_object_key_owned(state, owner_user_id, object_key.as_str()).await?;
Ok(object_key)
}
fn normalize_editor_reference_image_sources(sources: Option<&[String]>, limit: usize) -> Vec<&str> {
sources
.unwrap_or(&[])
.iter()
.map(String::as_str)
.map(str::trim)
.filter(|source| !source.is_empty())
.take(limit)
.collect()
}
pub(crate) fn normalize_editor_reference_object_key(source: &str) -> Result<String, AppError> {
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<String, AppError> {
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<Option<String>, AppError> {
if source.is_empty() {
return Ok(None);
}
let projects = state
.spacetime_client()
.list_editor_projects(owner_user_id.to_string())
.await
.map_err(map_editor_project_error)?;
if let Some(object_key) = projects
.iter()
.flat_map(|project| project.resources.iter())
.find_map(|resource| editor_reference_resource_object_key(resource, source))
{
return Ok(Some(object_key));
}
let library = state
.spacetime_client()
.get_editor_asset_library(owner_user_id.to_string(), current_utc_micros())
.await
.map_err(map_editor_project_error)?;
Ok(library
.assets
.iter()
.find_map(|asset| editor_reference_asset_object_key(asset, source)))
}
fn editor_reference_resource_object_key(
resource: &EditorProjectResourceRecord,
source: &str,
) -> Option<String> {
(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<String> {
(asset.asset_id.trim() == source)
.then(|| {
normalize_editor_record_object_key(
asset.object_key.as_deref(),
asset.image_src.as_str(),
)
})
.flatten()
}
fn normalize_editor_record_object_key(object_key: Option<&str>, image_src: &str) -> Option<String> {
object_key
.map(str::trim)
.map(|value| value.trim_start_matches('/'))
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| normalize_editor_reference_object_key(image_src).ok())
}
async fn ensure_editor_reference_object_key_owned(
state: &AppState,
owner_user_id: &str,
object_key: &str,
) -> Result<(), AppError> {
let owner_user_id = owner_user_id.trim();
if owner_user_id.is_empty() {
return Err(editor_reference_image_forbidden(object_key));
}
let projects = state
.spacetime_client()
.list_editor_projects(owner_user_id.to_string())
.await
.map_err(map_editor_project_error)?;
if projects.iter().any(|project| {
project.resources.iter().any(|resource| {
editor_record_object_key_matches(
resource.object_key.as_deref(),
resource.image_src.as_str(),
object_key,
)
})
}) {
return Ok(());
}
let library = state
.spacetime_client()
.get_editor_asset_library(owner_user_id.to_string(), current_utc_micros())
.await
.map_err(map_editor_project_error)?;
if library.assets.iter().any(|asset| {
editor_record_object_key_matches(
asset.object_key.as_deref(),
asset.image_src.as_str(),
object_key,
)
}) {
return Ok(());
}
let Some(oss_client) = state.oss_client() else {
return Err(editor_reference_image_forbidden(object_key));
};
if let Some(asset_object) = state
.spacetime_client()
.get_asset_object_by_location(module_assets::AssetObjectLocationInput {
bucket: oss_client.config_bucket().to_string(),
object_key: object_key.to_string(),
})
.await
.map_err(map_editor_project_error)?
{
validate_editor_reference_asset_object(&asset_object, owner_user_id, state, object_key)?;
return Ok(());
}
Err(editor_reference_image_forbidden(object_key))
}
fn validate_editor_reference_asset_object(
asset_object: &module_assets::AssetObjectRecord,
owner_user_id: &str,
state: &AppState,
object_key: &str,
) -> Result<(), AppError> {
let Some(oss_client) = state.oss_client() else {
return Err(editor_reference_image_forbidden(object_key));
};
let owned = asset_object
.owner_user_id
.as_deref()
.map(str::trim)
.is_some_and(|value| value == owner_user_id);
let bucket_matches = asset_object.bucket.trim() == oss_client.config_bucket().trim();
let key_matches = asset_object.object_key.trim().trim_start_matches('/') == object_key;
if owned && bucket_matches && key_matches {
Ok(())
} else {
Err(editor_reference_image_forbidden(object_key))
}
}
fn editor_record_object_key_matches(
record_object_key: Option<&str>,
image_src: &str,
object_key: &str,
) -> bool {
normalize_editor_record_object_key(record_object_key, image_src)
.is_some_and(|value| value == object_key)
}
fn editor_reference_image_forbidden(object_key: &str) -> AppError {
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "editor-reference-image",
"field": "sourceImageSrc",
"message": "图片参考图不属于当前账号。",
"objectKey": object_key,
}))
}
async fn read_editor_reference_image_object(
state: &AppState,
source: &str,
) -> Result<OpenAiReferenceImage, AppError> {
if source.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-reference-image",
"field": "sourceImageSrc",
"message": "图片参考图不能为空。",
})),
);
}
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: source.to_string(),
expire_seconds: Some(EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS),
})
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let mut response = reqwest::Client::new()
.get(signed.signed_url.as_str())
.send()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取图片参考图失败:{error}"),
}))
})?;
let status = response.status();
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取图片参考图返回非成功状态:{}", status.as_u16()),
"objectKey": signed.object_key,
})),
);
}
if response
.content_length()
.is_some_and(|size| size > EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES)
{
return Err(editor_reference_image_too_large());
}
let content_type_mime = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(normalize_editor_reference_image_mime_type)
.map(ToOwned::to_owned);
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取图片参考图内容失败:{error}"),
}))
})? {
if bytes.len().saturating_add(chunk.len()) as u64 > EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES {
return Err(editor_reference_image_too_large());
}
bytes.extend_from_slice(chunk.as_ref());
}
if bytes.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-reference-image",
"field": "sourceImageSrc",
"message": "图片参考图为空。",
})),
);
}
let mime_type = content_type_mime
.or_else(|| infer_editor_reference_image_mime_type(bytes.as_slice()).map(ToOwned::to_owned))
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-reference-image",
"field": "sourceImageSrc",
"message": "图片参考图 objectKey 指向的资源不是图片。",
"objectKey": signed.object_key,
}))
})?
.to_string();
let extension = editor_reference_image_extension(mime_type.as_str());
Ok(OpenAiReferenceImage {
bytes,
mime_type,
file_name: format!("editor-reference.{extension}"),
})
}
async fn download_editor_persisted_image_object(
state: &AppState,
object_key: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let image = read_editor_reference_image_object(state, object_key).await?;
Ok(DownloadedOpenAiImage {
extension: editor_reference_image_extension(image.mime_type.as_str()).to_string(),
mime_type: image.mime_type,
bytes: image.bytes,
})
}
fn normalize_editor_reference_image_mime_type(content_type: &str) -> Option<&str> {
let mime_type = content_type.split(';').next()?.trim();
mime_type.starts_with("image/").then_some(mime_type)
}
fn infer_editor_reference_image_mime_type(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return Some("image/png");
}
if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
return Some("image/jpeg");
}
if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
return Some("image/webp");
}
None
}
fn editor_reference_image_extension(mime_type: &str) -> &'static str {
match mime_type {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/webp" => "webp",
_ => "png",
}
}
fn editor_reference_image_too_large() -> AppError {
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({
"provider": "editor-reference-image",
"message": "图片参考图不能超过 32MB。",
}))
}
pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
match error {
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "editor-project",
"message": message,
}))
}
SpacetimeClientError::Procedure(message) if message.contains("不存在") => {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "editor-project",
"message": message,
}))
}
SpacetimeClientError::Procedure(message) if message.contains("版本冲突") => {
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "editor-project",
"message": message,
}))
}
SpacetimeClientError::Procedure(message) => AppError::from_status(StatusCode::BAD_REQUEST)
.with_details(json!({
"provider": "editor-project",
"message": message,
})),
SpacetimeClientError::Runtime(message) => AppError::from_status(StatusCode::BAD_REQUEST)
.with_details(json!({
"provider": "editor-project",
"message": message,
})),
other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": other.to_string(),
})),
}
}
pub(crate) fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
pub(crate) fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{config::AppConfig, editor_green_screen::parse_editor_screen_background_color};
use std::{
io::{Read as _, Write as _},
net::{TcpListener, TcpStream},
sync::mpsc,
thread,
};
fn encode_test_png(width: u32, height: u32) -> Vec<u8> {
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<u8> {
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::<usize>().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<u8>,
) -> (String, mpsc::Receiver<Vec<u8>>, thread::JoinHandle<()>) {
spawn_bgfilter_worker_png_mock_with_delay(response_png, Duration::ZERO)
}
fn spawn_bgfilter_worker_png_mock_with_delay(
response_png: Vec<u8>,
response_delay: Duration,
) -> (String, mpsc::Receiver<Vec<u8>>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener should bind");
let address = listener
.local_addr()
.expect("mock listener should expose address");
let (request_sender, request_receiver) = mpsc::channel();
let server = thread::spawn(move || {
let (mut stream, _) = listener
.accept()
.expect("BgFilter worker request should connect");
let request = read_mock_http_request(&mut stream);
request_sender
.send(request)
.expect("captured request should be delivered");
thread::sleep(response_delay);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
response_png.len()
);
stream
.write_all(headers.as_bytes())
.expect("mock response headers should be written");
stream
.write_all(response_png.as_slice())
.expect("mock response PNG should be written");
stream.flush().expect("mock response should flush");
});
(format!("http://{address}"), request_receiver, server)
}
fn editor_project_resource_for_canvas_test(
resource_id: &str,
asset_kind: &str,
width: u32,
height: u32,
) -> EditorProjectResourcePayload {
EditorProjectResourcePayload {
resource_id: resource_id.to_string(),
showcase_id: None,
asset_id: None,
label: None,
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
author_display_name: None,
author_public_user_code: None,
image_src: format!("/generated-character-drafts/editor/{resource_id}.png"),
object_key: Some(format!(
"generated-character-drafts/editor/{resource_id}.png"
)),
asset_object_id: Some(format!("asset-object-{resource_id}")),
width,
height,
source_type: "generated".to_string(),
prompt: Some("银发游侠".to_string()),
actual_prompt: Some("实际提示词".to_string()),
model: Some("gpt-image-2".to_string()),
provider: Some("VectorEngine".to_string()),
task_id: Some("task-character".to_string()),
source_resource_id: None,
asset_kind: Some(asset_kind.to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-07-13T00:00:00.000Z".to_string(),
updated_at: "2026-07-13T00:00:00.000Z".to_string(),
}
}
#[test]
fn editor_project_procedure_errors_preserve_business_reason_as_bad_request() {
let error = map_editor_project_error(SpacetimeClientError::Procedure(
"只有生成素材可以提交精选审核".to_string(),
));
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("message")),
Some(&json!("只有生成素材可以提交精选审核")),
);
}
#[test]
fn editor_project_revision_conflicts_map_to_http_conflict() {
let error = map_editor_project_error(SpacetimeClientError::Procedure(
"图片画布版本冲突:期望 revision 4,当前为 5".to_string(),
));
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn editor_project_layout_save_request_requires_expected_revision() {
let missing_revision = serde_json::from_value::<EditorProjectLayoutSaveRequest>(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::<EditorProjectLayoutSaveRequest>(json!({
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
"layers": [],
"expectedRevision": 7,
}))
.expect("携带 expectedRevision 的画布保存请求应通过反序列化");
assert_eq!(request.expected_revision, 7);
}
fn manual_screen_background_decision(hex: &str) -> EditorScreenBackgroundDecision {
EditorScreenBackgroundDecision {
color: parse_editor_screen_background_color(Some(hex))
.expect("hex should be a known background color candidate"),
mode:
crate::editor_screen_background_decision::EditorScreenBackgroundDecisionMode::Manual,
attempts: 0,
fallback: false,
}
}
#[test]
fn screen_background_decision_records_hex_without_touching_visible_fields() {
let decision = manual_screen_background_decision("#B0C2E0");
// 用户可见的 fields 不被注入抠图背景色,只写内部 screenColorHex。
let updated = apply_editor_screen_background_decision_to_generation_inputs(
Some(json!({
"fields": [{ "title": "角色设定", "value": "红发骑士" }],
"references": [],
})),
Some(&decision),
)
.expect("generation inputs should be preserved");
assert_eq!(updated["screenColorHex"], json!("#B0C2E0"));
assert_eq!(
updated["fields"],
json!([{ "title": "角色设定", "value": "红发骑士" }]),
);
// 无 generation_inputs 时创建骨架并写入内部元数据,fields 保持为空。
let created =
apply_editor_screen_background_decision_to_generation_inputs(None, Some(&decision))
.expect("missing generation inputs should still record the decision");
assert_eq!(
created,
json!({
"fields": [],
"references": [],
"screenColorHex": "#B0C2E0",
}),
);
}
#[test]
fn screen_background_decision_leaves_existing_visible_fields_untouched() {
let decision = manual_screen_background_decision("#CFEFFF");
// 即使旧数据的 fields 里残留了抠图背景色,后端也不修改用户可见字段。
let updated = apply_editor_screen_background_decision_to_generation_inputs(
Some(json!({
"fields": [{ "title": "抠图背景色", "value": "旧值" }],
"references": [],
})),
Some(&decision),
)
.expect("generation inputs should be preserved");
assert_eq!(
updated["fields"],
json!([{ "title": "抠图背景色", "value": "旧值" }]),
);
assert_eq!(updated["screenColorHex"], json!("#CFEFFF"));
}
fn apply_editor_canvas_generation_completion(
layers: Value,
completion: &EditorCanvasGenerationCompletionRequest,
resource: &EditorProjectResourcePayload,
) -> Result<EditorCanvasGenerationCompletionResult, AppError> {
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<Value>,
primary_layer_id: Option<String>,
) -> Result<EditorCanvasGenerationCompletionResult, AppError> {
let placeholder = resolve_canvas_completion_placeholder(layers.clone(), completion)?
.unwrap_or_else(|| completion.placeholder.clone());
let items =
translate_editor_canvas_generation_items(items, &completion.placeholder, &placeholder);
apply_editor_canvas_generation_items(
layers,
completion,
placeholder,
items,
primary_layer_id,
)
}
#[test]
fn editor_project_id_prefix_uses_proj_for_new_ids() {
assert_eq!(EDITOR_PROJECT_ID_PREFIX, "proj-");
}
#[test]
fn editor_record_media_src_prefers_object_key_over_legacy_inline_media() {
assert_eq!(
normalize_editor_record_media_src(
"data:image/png;base64,too-large".to_string(),
Some("/generated-character-drafts/editor/character.png")
),
"/generated-character-drafts/editor/character.png"
);
}
#[test]
fn editor_record_media_src_drops_legacy_inline_media_without_object_key() {
assert_eq!(
normalize_editor_record_media_src("data:image/png;base64,too-large".to_string(), None),
""
);
assert_eq!(
normalize_editor_record_media_src("blob:http://localhost/preview".to_string(), None),
""
);
}
fn test_editor_showcase_asset_record(owner_user_id: &str) -> EditorShowcaseAssetRecord {
EditorShowcaseAssetRecord {
showcase_id: "showcase-1".to_string(),
asset_id: "asset-1".to_string(),
owner_user_id: owner_user_id.to_string(),
label: "精选素材".to_string(),
asset_object_id: None,
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
object_key: None,
width: 512,
height: 512,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: None,
source_resource_id: None,
asset_kind: None,
generation_inputs: None,
thumbnail_src: None,
generation_cost_mud_points: 10,
refund_mud_points: 5,
review_status: "approved".to_string(),
display_enabled: true,
like_count: 0,
asset_deleted_while_pending: false,
reviewed_by_admin_user_id: Some("admin-1".to_string()),
review_note: None,
refund_ledger_id: Some("editor-showcase-refund:showcase-1".to_string()),
refund_completed_at: Some("2026-06-23T00:00:00.000Z".to_string()),
submitted_at: "2026-06-23T00:00:00.000Z".to_string(),
reviewed_at: Some("2026-06-23T00:00:00.000Z".to_string()),
approved_at: Some("2026-06-23T00:00:00.000Z".to_string()),
rejected_at: None,
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
showcase_category: Some("characters".to_string()),
}
}
#[test]
fn editor_project_resource_payload_includes_owner_user_id() {
let payload = editor_project_resource_payload_from_record(EditorProjectResourceRecord {
resource_id: "resource-1".to_string(),
project_id: "project-1".to_string(),
owner_user_id: "user-author-1".to_string(),
asset_object_id: None,
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
object_key: None,
width: 512,
height: 512,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: None,
source_resource_id: None,
asset_kind: None,
generation_inputs: None,
public_showcase_enabled: true,
created_at: "2026-06-23T00:00:00.000Z".to_string(),
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
});
let value = serde_json::to_value(payload).expect("payload should serialize");
assert_eq!(value["ownerUserId"], json!("user-author-1"));
}
#[test]
fn editor_project_resource_showcase_cursor_uses_microsecond_timestamp() {
let record = EditorShowcaseAssetRecord {
showcase_id: "resource-1".to_string(),
approved_at: Some("1713686401.234567Z".to_string()),
updated_at: "1713686401.234567Z".to_string(),
..test_editor_showcase_asset_record("user-author-1")
};
assert_eq!(
editor_project_resource_showcase_cursor(&record).as_deref(),
Some("1713686401234567:resource-1")
);
assert_eq!(parse_editor_timestamp_micros("bad-time"), None);
}
#[test]
fn editor_project_resource_public_payload_uses_display_author_fields() {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.auth_user_service()
.ensure_orphan_work_owner_user("user-author-1", "author-1", "作者昵称", "SY-00000042")
.expect("author should be inserted");
let payload = editor_project_resource_public_payload_from_record(
&state,
test_editor_showcase_asset_record("user-author-1"),
);
let value = serde_json::to_value(payload).expect("payload should serialize");
assert_eq!(value["ownerUserId"], json!("user-author-1"));
assert_eq!(value["authorDisplayName"], json!("作者昵称"));
assert_eq!(value["authorPublicUserCode"], json!("SY-00000042"));
}
#[test]
fn editor_project_resource_public_payload_falls_back_to_public_user_code() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let payload = editor_project_resource_public_payload_from_record(
&state,
test_editor_showcase_asset_record("missing-user"),
);
let value = serde_json::to_value(payload).expect("payload should serialize");
assert_eq!(value["ownerUserId"], json!("missing-user"));
assert!(value.get("authorDisplayName").is_none());
assert_eq!(value["authorPublicUserCode"], json!("SY-00000000"));
}
#[test]
fn editor_payload_media_sanitizer_replaces_inline_layer_src_from_resource() {
let resources = vec![EditorProjectResourcePayload {
resource_id: "resource-1".to_string(),
showcase_id: None,
asset_id: None,
label: None,
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
author_display_name: None,
author_public_user_code: None,
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
object_key: Some("generated-character-drafts/editor/spec.png".to_string()),
asset_object_id: Some("asset-object-1".to_string()),
width: 512,
height: 512,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: None,
source_resource_id: None,
asset_kind: Some("spec".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-06-23T00:00:00.000Z".to_string(),
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
}];
let sanitized = sanitize_editor_payload_media(
json!([
{
"layerId": "layer-1",
"resourceId": "resource-1",
"src": "data:image/png;base64,layer",
"thumbnailSrc": "data:image/png;base64,thumbnail",
"imageSequenceFrames": [
{ "frameIndex": 0, "imageSrc": "data:image/png;base64,frame" }
],
"generationInputs": {
"references": [
{
"resourceId": "resource-1",
"src": "data:image/png;base64,reference"
}
]
}
}
]),
resources.as_slice(),
);
let payload = serde_json::to_string(&sanitized).expect("payload serializes");
assert!(!payload.contains("data:image"));
assert_eq!(
sanitized[0]["src"],
json!("/generated-character-drafts/editor/spec.png")
);
assert_eq!(
sanitized[0]["thumbnailSrc"],
json!("/generated-character-drafts/editor/spec.png")
);
assert_eq!(
sanitized[0]["imageSequenceFrames"][0]["imageSrc"],
json!("/generated-character-drafts/editor/spec.png")
);
assert_eq!(
sanitized[0]["objectKey"],
json!("generated-character-drafts/editor/spec.png")
);
assert_eq!(
sanitized[0]["generationInputs"]["references"][0]["src"],
json!("/generated-character-drafts/editor/spec.png")
);
}
#[test]
fn editor_canvas_generation_completion_saves_after_sanitizing_legacy_inline_layers() {
let existing_resources = vec![EditorProjectResourcePayload {
resource_id: "resource-spec".to_string(),
showcase_id: None,
asset_id: None,
label: None,
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
author_display_name: None,
author_public_user_code: None,
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
object_key: Some("generated-character-drafts/editor/spec.png".to_string()),
asset_object_id: Some("asset-object-spec".to_string()),
width: 512,
height: 512,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: None,
source_resource_id: None,
asset_kind: Some("spec".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-06-23T00:00:00.000Z".to_string(),
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
}];
let dirty_layers = json!([
{
"layerId": "layer-spec",
"resourceId": "resource-spec",
"title": "规范图",
"src": "data:image/png;base64,old-spec",
"thumbnailSrc": "data:image/png;base64,old-thumbnail"
},
{
"layerId": "generation-dialog:dialog-character",
"resourceId": "generation-dialog:dialog-character",
"itemType": "generation-dialog",
"dialog": {
"id": "dialog-character",
"mode": "character",
"status": "generating",
"placeholder": {
"x": 200,
"y": 160,
"width": 420,
"height": 560,
"originalWidth": 1024,
"originalHeight": 1536
}
}
}
]);
let layers = sanitize_editor_payload_media(dirty_layers, existing_resources.as_slice());
assert!(
!serde_json::to_string(&layers)
.unwrap()
.contains("data:image")
);
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-character".to_string()),
title: "角色形象 1".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 200.0,
y: 160.0,
width: 420.0,
height: 560.0,
original_width: 1024.0,
original_height: 1536.0,
},
};
let generated_resource = EditorProjectResourcePayload {
resource_id: "editor-resource-character".to_string(),
showcase_id: None,
asset_id: None,
label: None,
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
author_display_name: None,
author_public_user_code: None,
image_src: "/generated-character-drafts/editor/character.png".to_string(),
object_key: Some("generated-character-drafts/editor/character.png".to_string()),
asset_object_id: Some("asset-object-character".to_string()),
width: 1024,
height: 1536,
source_type: "generated".to_string(),
prompt: Some("银发游侠".to_string()),
actual_prompt: Some("实际提示词".to_string()),
model: Some("gpt-image-2".to_string()),
provider: Some("VectorEngine".to_string()),
task_id: Some("task-character".to_string()),
source_resource_id: None,
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-06-23T00:00:00.000Z".to_string(),
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
};
let completed =
apply_editor_canvas_generation_completion(layers, &completion, &generated_resource)
.expect("completion should apply to sanitized legacy layout");
assert!(completed.changed);
serialize_editor_layers(completed.layers).expect("sanitized completion should save");
}
#[test]
fn editor_persisted_media_src_rejects_inline_media_without_object_key() {
let rejected = normalize_editor_persisted_media_src(
"data:image/png;base64,too-large".to_string(),
None,
)
.expect_err("inline image media should be rejected");
assert!(rejected.body_text().contains("objectKey"));
assert!(
normalize_editor_persisted_media_src("data:video/mp4;base64,AAAA".to_string(), None)
.is_err()
);
assert!(
normalize_editor_persisted_media_src("data:audio/mp3;base64,AAAA".to_string(), None)
.is_err()
);
assert!(
normalize_editor_persisted_media_src("blob:http://localhost/preview".to_string(), None)
.is_err()
);
assert_eq!(
normalize_editor_persisted_media_src(
"data:image/png;base64,legacy".to_string(),
Some("generated-character-drafts/editor/spec.png")
)
.expect("objectKey-backed legacy rows should normalize"),
"/generated-character-drafts/editor/spec.png"
);
}
#[test]
fn editor_layout_serialization_rejects_nested_inline_media() {
let error = serialize_editor_layers(json!([
{
"layerId": "layer-animation",
"thumbnailSrc": "/generated-character-drafts/editor/frame-1.png",
"imageSequenceFrames": [
{
"frameIndex": 1,
"imageSrc": "data:image/png;base64,frame"
}
]
}
]))
.expect_err("layout should reject nested inline image data");
assert!(error.body_text().contains("图层布局"));
assert!(
serialize_editor_layers(json!([
{
"layerId": "layer-animation",
"thumbnailSrc": "/generated-character-drafts/editor/frame-1.png",
"imageSequenceFrames": [
{
"frameIndex": 1,
"imageSrc": "/generated-character-drafts/editor/frame-1.png"
}
],
"previewVideoPath": "/generated-character-drafts/editor/preview.mp4"
}
]))
.is_ok()
);
}
#[test]
fn editor_layout_serialization_accepts_payload_up_to_two_mib() {
let payload = json!("x".repeat(EDITOR_LAYOUT_MAX_BYTES - 2));
let serialized = serialize_editor_layers(payload).expect("two MiB layout should save");
assert_eq!(serialized.len(), EDITOR_LAYOUT_MAX_BYTES);
}
#[test]
fn editor_layout_serialization_rejects_payload_over_two_mib() {
let payload = json!("x".repeat(EDITOR_LAYOUT_MAX_BYTES - 1));
let error = serialize_editor_layers(payload).expect_err("oversized layout should fail");
assert!(error.body_text().contains("图层布局过大"));
}
#[test]
fn editor_asset_metadata_serialization_rejects_nested_inline_media() {
let error = serialize_editor_asset_metadata(Some(json!({
"references": [
{
"title": "参考图",
"src": "data:image/png;base64,reference"
}
]
})))
.expect_err("metadata should reject nested inline image data");
assert!(error.body_text().contains("资产元数据"));
assert!(
serialize_editor_asset_metadata(Some(json!({
"references": [
{
"title": "参考图",
"src": "/generated-character-drafts/editor/reference.png"
}
]
})))
.is_ok()
);
}
#[test]
fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() {
assert_eq!(normalize_editor_image_generation_size(None), "1024x1024");
assert_eq!(
normalize_editor_image_generation_size(Some("1536x1024")),
"1536x1024"
);
assert_eq!(
normalize_editor_image_generation_size(Some("1024x1536")),
"1024x1536"
);
assert_eq!(
normalize_editor_image_generation_size(Some("2048x1152")),
"2048x1152"
);
assert_eq!(
normalize_editor_image_generation_size(Some("640x640")),
"640x640"
);
assert_eq!(
normalize_editor_image_generation_size(Some("bad-size")),
"1024x1024"
);
}
#[tokio::test]
async fn editor_image_edit_price_uses_selected_model_and_size() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert_eq!(
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "1024x1024", None)
.await
.unwrap(),
3
);
assert_eq!(
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "2048x1152", None)
.await
.unwrap(),
5
);
assert_eq!(
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "2048x1152", Some("1K"),)
.await
.unwrap(),
5
);
assert_eq!(
resolve_editor_image_edit_price(
&state,
EDITOR_IMAGE_MODEL_NANOBANANA2,
"2048x2048",
Some("2K"),
)
.await
.unwrap(),
24
);
assert_eq!(
resolve_editor_image_edit_price(
&state,
EDITOR_IMAGE_MODEL_NANOBANANA2,
"512x512",
Some("0.5K"),
)
.await
.unwrap(),
8
);
assert!(editor_image_edit_uses_nanobanana_generate_content(
EDITOR_IMAGE_MODEL_NANOBANANA2
));
assert!(!editor_image_edit_uses_nanobanana_generate_content(
GPT_IMAGE_2_MODEL
));
}
#[test]
fn editor_image_edit_aligns_provider_images_and_restores_source_dimensions() {
fn encode_png(width: u32, height: u32) -> Vec<u8> {
let image = image::DynamicImage::new_rgba8(width, height);
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, image::ImageFormat::Png)
.expect("test image should encode");
bytes.into_inner()
}
let mut references = vec![
OpenAiReferenceImage {
bytes: encode_png(1537, 1025),
mime_type: "image/png".to_string(),
file_name: "source.png".to_string(),
},
OpenAiReferenceImage {
bytes: encode_png(501, 333),
mime_type: "image/png".to_string(),
file_name: "style.png".to_string(),
},
];
let dimensions = prepare_editor_image_edit_references(&mut references, "1537x1025")
.expect("references should align for provider");
assert_eq!(dimensions.target_width, 1537);
assert_eq!(dimensions.target_height, 1025);
assert_eq!(dimensions.provider_width, 1552);
assert_eq!(dimensions.provider_height, 1040);
let source = image::load_from_memory(references[0].bytes.as_slice()).unwrap();
assert_eq!((source.width(), source.height()), (1552, 1040));
let extra = image::load_from_memory(references[1].bytes.as_slice()).unwrap();
assert_eq!((extra.width(), extra.height()), (512, 336));
let provider_output = DownloadedOpenAiImage {
bytes: encode_png(1552, 1040),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let restored = restore_editor_image_edit_output_dimensions(&provider_output, &dimensions)
.expect("provider output should restore source dimensions")
.expect("aligned provider output should require a transformation");
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
assert_eq!(
(restored_image.width(), restored_image.height()),
(1537, 1025)
);
assert_eq!(restored.mime_type, "image/png");
assert_eq!(restored.extension, "png");
}
#[test]
fn static_image_postprocess_restores_only_alpha_at_provider_dimensions() {
let provider_source = image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 10, 20, 255]));
let mut provider_source_bytes = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(provider_source)
.write_to(&mut provider_source_bytes, image::ImageFormat::Png)
.expect("test image should encode");
let provider_source = DownloadedOpenAiImage {
bytes: provider_source_bytes.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let postprocessed = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 240, 77]));
let mut postprocessed_bytes = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(postprocessed)
.write_to(&mut postprocessed_bytes, image::ImageFormat::Png)
.expect("test image should encode");
let postprocessed = DownloadedOpenAiImage {
bytes: postprocessed_bytes.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let restored =
apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed)
.expect("postprocessed alpha should restore provider dimensions")
.expect("smaller alpha mask should be applied to provider source");
let restored_image = image::load_from_memory(restored.bytes.as_slice())
.unwrap()
.to_rgba8();
assert_eq!((restored_image.width(), restored_image.height()), (4, 4));
assert_eq!(restored_image.get_pixel(1, 1).0, [200, 10, 20, 77]);
assert_eq!(restored.mime_type, "image/png");
assert_eq!(restored.extension, "png");
}
#[test]
fn publication_material_generation_restores_provider_output_to_workflow_dimensions() {
let image = image::DynamicImage::new_rgba8(944, 704);
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, image::ImageFormat::Png)
.expect("test image should encode");
let provider_output = DownloadedOpenAiImage {
bytes: bytes.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let restored = restore_editor_generated_image_output_dimensions(
&provider_output,
GPT_IMAGE_2_MODEL,
"720x540",
)
.expect("provider output should restore delivery dimensions")
.expect("mismatched provider output should require a transformation");
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
assert_eq!(
(restored_image.width(), restored_image.height()),
(720, 540)
);
assert_eq!(restored.mime_type, "image/png");
assert_eq!(restored.extension, "png");
}
#[test]
fn nanobanana_generation_restores_shared_business_pixel_size() {
let image = image::DynamicImage::new_rgba8(2752, 1536);
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, image::ImageFormat::Png)
.expect("test image should encode");
let provider_output = DownloadedOpenAiImage {
bytes: bytes.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let (restored, restore_error) =
restore_editor_generated_image_output_dimensions_or_original(
provider_output,
EDITOR_IMAGE_MODEL_NANOBANANA2,
"2048x1152",
);
assert!(restore_error.is_none());
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
assert_eq!(
(restored_image.width(), restored_image.height()),
(2048, 1152)
);
}
#[test]
fn provider_1k_under_delivery_is_not_upscaled_to_2k() {
let image = image::DynamicImage::new_rgba8(1024, 576);
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, image::ImageFormat::Png)
.expect("test image should encode");
let provider_output = DownloadedOpenAiImage {
bytes: bytes.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let expected_bytes = provider_output.bytes.clone();
let (delivery_output, restore_error) =
restore_editor_generated_image_output_dimensions_or_original(
provider_output,
EDITOR_IMAGE_MODEL_NANOBANANA2,
"2048x1152",
);
let restore_error = restore_error.expect("under delivery should be recorded");
assert_eq!(delivery_output.bytes, expected_bytes);
assert_eq!(
restore_error
.details()
.and_then(|details| details.get("code")),
Some(&json!("provider-image-under-delivery"))
);
let warning = editor_dimension_restore_warning(&restore_error);
assert_eq!(warning.code, EDITOR_GENERATION_DIMENSION_WARNING_CODE);
assert!(warning.reason.contains("禁止放大伪造清晰度档位"));
let delivery_image = image::load_from_memory(delivery_output.bytes.as_slice()).unwrap();
assert_eq!(
(delivery_image.width(), delivery_image.height()),
(1024, 576)
);
}
#[test]
fn generated_dimension_restore_allows_only_slight_aspect_ratio_crop() {
ensure_editor_output_can_restore_without_upscaling(
2064,
1152,
2048,
1152,
"test-generation",
)
.expect("provider rounding drift should allow slight crop");
let error = ensure_editor_output_can_restore_without_upscaling(
2048,
2048,
2048,
1152,
"test-generation",
)
.expect_err("large aspect ratio crop should be rejected");
assert_eq!(
error.details().and_then(|details| details.get("code")),
Some(&json!("provider-image-aspect-ratio-mismatch"))
);
}
#[test]
fn failed_dimension_restore_keeps_provider_output_for_single_upload() {
let image = image::DynamicImage::new_rgba8(896, 896);
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, image::ImageFormat::Png)
.expect("test image should encode");
let provider_output = DownloadedOpenAiImage {
bytes: bytes.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let expected_bytes = provider_output.bytes.clone();
let (delivery_output, restore_error) =
restore_editor_generated_image_output_dimensions_or_original(
provider_output,
GPT_IMAGE_2_MODEL,
"invalid-size",
);
assert!(restore_error.is_some());
assert_eq!(delivery_output.bytes, expected_bytes);
assert_eq!(delivery_output.mime_type, "image/png");
assert_eq!(delivery_output.extension, "png");
}
#[test]
fn editor_generation_dimensions_follow_model_options() {
let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K"));
assert_eq!(default_generation.model, GPT_IMAGE_2_MODEL);
assert_eq!(default_generation.size, "1024x1024");
let nanobanana = normalize_editor_generation_options(
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
Some("1:1"),
Some("0.5K"),
);
assert_eq!(nanobanana.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(nanobanana.size, "512x512");
assert_eq!(nanobanana.aspect_ratio, "1:1");
assert_eq!(nanobanana.image_size, "0.5K");
assert_eq!(nanobanana.provider_image_size, "512");
let nanobanana_alias =
normalize_editor_generation_options(Some("nanobanana2"), Some("1:1"), Some("0.5K"));
assert_eq!(nanobanana_alias.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(nanobanana_alias.size, "512x512");
assert_eq!(nanobanana_alias.provider_image_size, "512");
let legacy_nanobanana_alias =
normalize_editor_generation_options(Some("nano-banana"), Some("16:9"), Some("2K"));
assert_eq!(
legacy_nanobanana_alias.model,
EDITOR_IMAGE_MODEL_NANOBANANA2
);
assert_eq!(legacy_nanobanana_alias.size, "2048x1152");
assert_eq!(legacy_nanobanana_alias.aspect_ratio, "16:9");
let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K"));
assert_eq!(gpt.model, GPT_IMAGE_2_MODEL);
assert_eq!(gpt.size, "683x1024");
assert_eq!(gpt.provider_size, "688x1024");
assert_eq!(gpt.aspect_ratio, "2:3");
assert_eq!(gpt.image_size, "1K");
assert_eq!(gpt.provider_image_size, "1K");
let nanobanana_cover =
normalize_editor_generation_options(Some("nanobanana2"), Some("4:3"), Some("1K"));
assert_eq!(nanobanana_cover.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(nanobanana_cover.size, "1024x768");
assert_eq!(nanobanana_cover.aspect_ratio, "4:3");
let gpt_cover =
normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K"));
assert_eq!(gpt_cover.model, GPT_IMAGE_2_MODEL);
assert_eq!(gpt_cover.size, "1024x768");
assert_eq!(gpt_cover.provider_size, "1024x768");
assert_eq!(gpt_cover.aspect_ratio, "4:3");
let gpt_landscape_2k =
normalize_editor_generation_options(Some("gpt-image-2"), Some("16:9"), Some("2K"));
assert_eq!(gpt_landscape_2k.model, GPT_IMAGE_2_MODEL);
assert_eq!(gpt_landscape_2k.size, "2048x1152");
assert_eq!(gpt_landscape_2k.aspect_ratio, "16:9");
assert_eq!(gpt_landscape_2k.image_size, "2K");
let gpt_portrait_2k =
normalize_editor_generation_options(Some("gpt-image-2"), Some("9:16"), Some("2K"));
assert_eq!(gpt_portrait_2k.model, GPT_IMAGE_2_MODEL);
assert_eq!(gpt_portrait_2k.size, "1152x2048");
assert_eq!(gpt_portrait_2k.aspect_ratio, "9:16");
assert_eq!(gpt_portrait_2k.image_size, "2K");
for (image_size, aspect_ratio, expected_size) in [
("1K", "1:1", "1024x1024"),
("1K", "4:3", "1024x768"),
("1K", "3:2", "1024x683"),
("1K", "2:3", "683x1024"),
("1K", "9:16", "576x1024"),
("1K", "16:9", "1024x576"),
("2K", "1:1", "2048x2048"),
("2K", "4:3", "2048x1536"),
("2K", "3:2", "2048x1365"),
("2K", "2:3", "1365x2048"),
("2K", "9:16", "1152x2048"),
("2K", "16:9", "2048x1152"),
] {
let options = normalize_editor_generation_options(
Some(GPT_IMAGE_2_MODEL),
Some(aspect_ratio),
Some(image_size),
);
assert_eq!(options.size, expected_size);
}
for (image_size, aspect_ratio, expected_delivery_size) in [
("1K", "16:9", "1024x576"),
("1K", "2:3", "683x1024"),
("2K", "3:2", "2048x1365"),
("2K", "9:16", "1152x2048"),
] {
let gpt = normalize_editor_generation_options(
Some(GPT_IMAGE_2_MODEL),
Some(aspect_ratio),
Some(image_size),
);
let nanobanana = normalize_editor_generation_options(
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
Some(aspect_ratio),
Some(image_size),
);
assert_eq!(gpt.size, expected_delivery_size);
assert_eq!(nanobanana.size, expected_delivery_size);
assert_eq!(gpt.size, nanobanana.size);
}
let gpt_landscape_1k =
normalize_editor_generation_options(Some(GPT_IMAGE_2_MODEL), Some("16:9"), Some("1K"));
assert_eq!(gpt_landscape_1k.provider_size, "1088x608");
assert_eq!(gpt_landscape_1k.size, "1024x576");
for (image_size, aspect_ratio, expected_size) in [
("0.5K", "1:1", "512x512"),
("0.5K", "4:3", "512x384"),
("0.5K", "3:2", "512x341"),
("0.5K", "2:3", "341x512"),
("0.5K", "9:16", "288x512"),
("0.5K", "16:9", "512x288"),
("1K", "1:1", "1024x1024"),
("1K", "4:3", "1024x768"),
("1K", "3:2", "1024x683"),
("1K", "2:3", "683x1024"),
("1K", "9:16", "576x1024"),
("1K", "16:9", "1024x576"),
("2K", "1:1", "2048x2048"),
("2K", "4:3", "2048x1536"),
("2K", "3:2", "2048x1365"),
("2K", "2:3", "1365x2048"),
("2K", "9:16", "1152x2048"),
("2K", "16:9", "2048x1152"),
] {
let options = normalize_editor_generation_options(
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
Some(aspect_ratio),
Some(image_size),
);
assert_eq!(options.size, expected_size);
}
let fallback = normalize_editor_generation_options(
Some("unknown-model"),
Some("bad-ratio"),
Some("bad-size"),
);
assert_eq!(fallback.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(fallback.size, "1024x1024");
assert_eq!(fallback.aspect_ratio, "1:1");
assert_eq!(fallback.image_size, "1K");
assert_eq!(fallback.provider_size, "1024x1024");
assert_eq!(fallback.provider_image_size, "1K");
}
#[test]
fn publication_material_request_size_keeps_workflow_pixels() {
let options =
normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K"));
assert_eq!(
resolve_editor_image_request_size(
Some("publication-material"),
Some("720x540"),
true,
&options,
),
"720x540"
);
assert_eq!(
resolve_editor_image_request_size(Some("generate"), Some("720x540"), true, &options),
"1024x768"
);
assert_eq!(
resolve_editor_image_provider_request_size(
Some("publication-material"),
Some("720x540"),
true,
&options,
),
"720x540"
);
}
#[test]
fn publication_material_generation_is_locked_to_gpt_image_2() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"let configured_price_mud_points",
&[
"is_publication_material_generation",
"Some(GPT_IMAGE_2_MODEL)",
],
);
}
#[test]
fn editor_ui_design_prompt_uses_fixed_user_input_block_and_optional_icon_spec() {
let prompt = build_editor_ui_design_prompt("二消玩法,主界面和结算弹窗", true);
assert!(prompt.contains("生成玩法UI原型图"));
assert!(prompt.contains("【用户输入】二消玩法,主界面和结算弹窗"));
assert!(prompt.contains("参考图1为图标素材规范"));
let no_reference_prompt = build_editor_ui_design_prompt("只要战斗HUD", false);
assert!(!no_reference_prompt.contains("参考图1为图标素材规范"));
}
#[test]
fn editor_character_image_prompt_appends_user_role_setting() {
let prompt = build_editor_character_image_prompt(
"菜市场卖菜大妈",
parse_editor_screen_background_color(Some("#FFD6C2"))
.expect("screen color should parse"),
);
assert!(prompt.contains("严格基于图1的角色美术视觉规范"));
assert!(prompt.contains("生成游戏角色立绘"));
assert!(prompt.contains("背景固定为单一纯色背景 暖浅桃色 #FFD6C2 / RGB(255,214,194)"));
assert!(prompt.contains("纯色背景必须平整无纹理、无渐变、无阴影"));
assert!(
prompt.contains("角色主体及其服饰、道具的颜色必须与背景色明显区分,不得带与背景色相同或相近的描边、投影或反光")
);
assert!(prompt.contains("禁止镜头透视"));
assert!(prompt.contains("角色设定:菜市场卖菜大妈"));
}
#[test]
fn editor_canvas_generation_completion_inserts_result_layer_and_keeps_composer_closed() {
let layers = json!([
{
"layerId": "layer-existing",
"resourceId": "resource-existing",
"zIndex": 4
},
{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-character",
"resourceId": "generation-dialog:dialog-character",
"dialog": {
"id": "dialog-character",
"mode": "character",
"prompt": "银发游侠",
"status": "generating",
"composerOpen": false,
"errorMessage": "旧错误",
"placeholder": {
"x": 200,
"y": 160,
"width": 420,
"height": 560,
"originalWidth": 1024,
"originalHeight": 1536
}
}
}
]);
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-character".to_string()),
title: "角色形象 1".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 200.0,
y: 160.0,
width: 420.0,
height: 560.0,
original_width: 1024.0,
original_height: 1536.0,
},
};
let resource = EditorProjectResourcePayload {
resource_id: "editor-resource-character".to_string(),
showcase_id: None,
asset_id: None,
label: None,
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
author_display_name: None,
author_public_user_code: None,
image_src: "data:image/png;base64,character".to_string(),
object_key: Some("generated-character-drafts/editor/character.png".to_string()),
asset_object_id: Some("asset-object-1".to_string()),
width: 1024,
height: 1536,
source_type: "generated".to_string(),
prompt: Some("银发游侠".to_string()),
actual_prompt: Some("实际提示词".to_string()),
model: Some("gpt-image-2".to_string()),
provider: Some("VectorEngine".to_string()),
task_id: Some("task-character".to_string()),
source_resource_id: Some("resource-spec".to_string()),
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: Some(json!({
"fields": [{"title": "角色设定", "value": "银发游侠"}],
"references": []
})),
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-06-21T12:16:22.000Z".to_string(),
updated_at: "2026-06-21T12:16:22.000Z".to_string(),
};
let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource)
.expect("completion should apply");
assert!(completed.changed);
let items = completed
.layers
.as_array()
.expect("layers should stay array");
let result_layer = items
.iter()
.find(|item| item["resourceId"] == "editor-resource-character")
.expect("result layer should be inserted");
assert_eq!(
result_layer["layerId"],
json!("layer-editor-resource-editor-resource-character")
);
assert_eq!(result_layer["title"], json!("角色形象 1"));
assert_eq!(result_layer["x"], json!(-102.0));
assert_eq!(result_layer["y"], json!(-328.0));
assert_eq!(result_layer["originalWidth"], json!(1024.0));
assert_eq!(result_layer["originalHeight"], json!(1536.0));
assert_eq!(result_layer["zIndex"], json!(5));
assert_eq!(result_layer["mediaType"], json!("image"));
assert_eq!(
result_layer["src"],
json!("data:image/png;base64,character")
);
let dialog = items
.iter()
.find(|item| item["itemType"] == "generation-dialog")
.and_then(|item| item.get("dialog"))
.expect("dialog should remain");
assert_eq!(dialog["status"], json!("idle"));
assert_eq!(dialog["composerOpen"], json!(false));
assert_eq!(
dialog["generatedLayerId"],
json!("layer-editor-resource-editor-resource-character")
);
assert!(dialog.get("errorMessage").is_none());
}
#[test]
fn editor_canvas_background_removal_replaces_target_layer_resource() {
let layers = json!([
{
"layerId": "layer-source",
"resourceId": "resource-source",
"title": "源图",
"x": 120,
"y": 140,
"width": 320,
"height": 240,
"originalWidth": 1024,
"originalHeight": 768,
"zIndex": 6,
"locked": true,
"sourceAssetId": "asset-old",
"thumbnailSrc": "/old-thumb.png"
},
{
"layerId": "layer-other",
"resourceId": "resource-other"
}
]);
let resource = EditorProjectResourcePayload {
resource_id: "editor-resource-cutout".to_string(),
showcase_id: None,
asset_id: None,
label: None,
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
author_display_name: None,
author_public_user_code: None,
image_src: "/generated-character-drafts/editor/background-removal/result.png"
.to_string(),
object_key: Some(
"generated-character-drafts/editor/background-removal/result.png".to_string(),
),
asset_object_id: Some("asset-object-cutout".to_string()),
width: 512,
height: 768,
source_type: "generated".to_string(),
prompt: Some("remove background".to_string()),
actual_prompt: None,
model: Some("BgFilter complex".to_string()),
provider: Some("BgFilter".to_string()),
task_id: Some("extgen-cutout".to_string()),
source_resource_id: Some("resource-source".to_string()),
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: Some(json!({
"fields": [{"title": "prompt", "value": "角色"}],
"references": []
})),
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-06-29T00:00:00.000Z".to_string(),
updated_at: "2026-06-29T00:00:00.000Z".to_string(),
};
let replaced = replace_editor_canvas_layer_with_resource(
layers.clone(),
"layer-source",
&resource,
None,
false,
)
.expect("target layer should be replaced");
let items = replaced.as_array().expect("layers should stay array");
let layer = items
.iter()
.find(|item| item["layerId"] == "layer-source")
.expect("source layer should remain");
assert_eq!(items.len(), 2);
assert_eq!(layer["resourceId"], json!("editor-resource-cutout"));
assert_eq!(
layer["src"],
json!("/generated-character-drafts/editor/background-removal/result.png")
);
assert_eq!(layer["width"], json!(512));
assert_eq!(layer["height"], json!(768));
assert_eq!(layer["x"], json!(120));
assert_eq!(layer["y"], json!(140));
assert_eq!(layer["zIndex"], json!(6));
assert_eq!(layer["locked"], json!(true));
assert_eq!(layer["taskId"], json!("extgen-cutout"));
assert_eq!(layer["sourceResourceId"], json!("resource-source"));
assert_eq!(layer["assetKind"], json!("character"));
assert_eq!(layer["title"], json!("源图"));
assert!(layer.get("sourceAssetId").is_none());
assert!(layer.get("thumbnailSrc").is_none());
let recentered = replace_editor_canvas_layer_with_resource(
layers,
"layer-source",
&resource,
Some("勇者快速编辑"),
true,
)
.expect("quick edit replacement should recenter the layer");
let recentered_layer = recentered
.as_array()
.and_then(|items| items.iter().find(|item| item["layerId"] == "layer-source"))
.expect("quick edit source layer should remain");
assert_eq!(recentered_layer["x"], json!(24.0));
assert_eq!(recentered_layer["y"], json!(-124.0));
assert_eq!(recentered_layer["title"], json!("勇者快速编辑"));
assert_eq!(recentered_layer["x"].as_f64().unwrap() + 512.0 / 2.0, 280.0);
assert_eq!(recentered_layer["y"].as_f64().unwrap() + 768.0 / 2.0, 260.0);
}
#[test]
fn editor_canvas_generation_completion_is_idempotent_for_existing_resource_layer() {
let layers = json!([
{
"layerId": "layer-existing-result",
"resourceId": "editor-resource-character",
"zIndex": 7
},
{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-character",
"resourceId": "generation-dialog:dialog-character",
"dialog": {
"id": "dialog-character",
"mode": "character",
"prompt": "银发游侠",
"status": "generating",
"composerOpen": false
}
}
]);
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-character".to_string()),
title: "角色形象 1".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 0.0,
y: 0.0,
width: 1.0,
height: 1.0,
original_width: 1.0,
original_height: 1.0,
},
};
let resource = EditorProjectResourcePayload {
resource_id: "editor-resource-character".to_string(),
showcase_id: None,
asset_id: None,
label: None,
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
author_display_name: None,
author_public_user_code: None,
image_src: "data:image/png;base64,character".to_string(),
object_key: None,
asset_object_id: None,
width: 1024,
height: 1024,
source_type: "generated".to_string(),
prompt: Some("银发游侠".to_string()),
actual_prompt: None,
model: Some("gpt-image-2".to_string()),
provider: Some("VectorEngine".to_string()),
task_id: Some("task-character".to_string()),
source_resource_id: None,
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-06-21T12:16:22.000Z".to_string(),
updated_at: "2026-06-21T12:16:22.000Z".to_string(),
};
let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource)
.expect("completion should apply");
assert!(completed.changed);
let items = completed
.layers
.as_array()
.expect("layers should stay array");
assert_eq!(
items
.iter()
.filter(|item| item["resourceId"] == "editor-resource-character")
.count(),
1
);
let dialog = items
.iter()
.find(|item| item["itemType"] == "generation-dialog")
.and_then(|item| item.get("dialog"))
.expect("dialog should remain");
assert_eq!(dialog["generatedLayerId"], json!("layer-existing-result"));
}
#[test]
fn editor_canvas_generation_completion_uses_latest_layout_placeholder() {
let layers = json!([
{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-character",
"resourceId": "generation-dialog:dialog-character",
"dialog": {
"id": "dialog-character",
"mode": "character",
"prompt": "银发游侠",
"status": "generating",
"composerOpen": false,
"placeholder": {
"x": 500,
"y": 400,
"width": 420,
"height": 560,
"originalWidth": 1024,
"originalHeight": 1536
}
}
}
]);
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-character".to_string()),
title: "角色形象 1".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 0.0,
y: 0.0,
width: 1.0,
height: 1.0,
original_width: 1.0,
original_height: 1.0,
},
};
let resource = EditorProjectResourcePayload {
resource_id: "editor-resource-character".to_string(),
showcase_id: None,
asset_id: None,
label: None,
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
author_display_name: None,
author_public_user_code: None,
image_src: "data:image/png;base64,character".to_string(),
object_key: None,
asset_object_id: None,
width: 1024,
height: 1536,
source_type: "generated".to_string(),
prompt: Some("银发游侠".to_string()),
actual_prompt: None,
model: Some("gpt-image-2".to_string()),
provider: Some("VectorEngine".to_string()),
task_id: Some("task-character".to_string()),
source_resource_id: None,
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-06-21T12:16:22.000Z".to_string(),
updated_at: "2026-06-21T12:16:22.000Z".to_string(),
};
let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource)
.expect("completion should apply");
let result_layer = completed
.layers
.as_array()
.expect("layers should stay array")
.iter()
.find(|item| item["resourceId"] == "editor-resource-character")
.expect("result layer should be inserted");
assert_eq!(result_layer["x"], json!(198.0));
assert_eq!(result_layer["y"], json!(-88.0));
}
#[test]
fn editor_canvas_generation_multi_item_completion_uses_latest_layout_placeholder() {
let layers = json!([
{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-icons",
"resourceId": "generation-dialog:dialog-icons",
"dialog": {
"id": "dialog-icons",
"mode": "icon",
"prompt": "返回按钮",
"status": "generating",
"composerOpen": false,
"placeholder": {
"x": 300,
"y": 240,
"width": 200,
"height": 200,
"originalWidth": 200,
"originalHeight": 200
}
}
}
]);
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-icons".to_string()),
title: "图标素材图集".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 100.0,
y: 80.0,
width: 200.0,
height: 200.0,
original_width: 200.0,
original_height: 200.0,
},
};
let item = json!({
"layerId": "layer-icon-sheet",
"resourceId": "resource-icon-sheet",
"title": "图标素材图集",
"x": 150.0,
"y": 130.0,
"width": 100.0,
"height": 100.0
});
let completed = apply_editor_canvas_generation_multi_items(
layers,
&completion,
vec![item],
Some("layer-icon-sheet".to_string()),
)
.expect("multi-item completion should apply");
let result_layer = completed
.layers
.as_array()
.expect("layers should stay array")
.iter()
.find(|item| item["resourceId"] == "resource-icon-sheet")
.expect("result layer should be inserted");
assert_eq!(result_layer["x"], json!(350.0));
assert_eq!(result_layer["y"], json!(290.0));
}
#[test]
fn editor_canvas_character_generation_places_processed_result_and_provider_source() {
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-character".to_string()),
title: "角色形象 1".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 100.0,
y: 80.0,
width: 200.0,
height: 300.0,
original_width: 400.0,
original_height: 600.0,
},
};
let processed = editor_project_resource_for_canvas_test(
"resource-character-cutout",
"character",
400,
600,
);
let source = editor_project_resource_for_canvas_test(
"resource-character-source",
"character",
400,
600,
);
let (items, primary_layer_id) =
build_multi_asset_canvas_layer_items(&completion, Some(&processed), Some(&source));
assert_eq!(items.len(), 2);
assert_eq!(
primary_layer_id.as_deref(),
Some("layer-editor-resource-resource-character-cutout")
);
assert_eq!(items[0]["resourceId"], json!("resource-character-cutout"));
assert_eq!(items[0]["x"], json!(0.0));
assert_eq!(items[0]["y"], json!(-70.0));
assert_eq!(items[1]["resourceId"], json!("resource-character-source"));
assert_eq!(items[1]["title"], json!("角色形象 1(原图)"));
assert_eq!(items[1]["assetKind"], json!("character"));
assert_eq!(items[1]["x"], json!(432.0));
assert_eq!(items[1]["y"], json!(-70.0));
let layers = json!([{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-character",
"resourceId": "generation-dialog:dialog-character",
"dialog": {
"id": "dialog-character",
"status": "generating",
"placeholder": completion.placeholder.clone()
}
}]);
let completed = apply_editor_canvas_generation_multi_items(
layers,
&completion,
items,
primary_layer_id,
)
.expect("multi-asset completion should apply");
let completed_items = completed
.layers
.as_array()
.expect("layers should stay array");
assert_eq!(
completed_items
.iter()
.filter(|item| item
.get("resourceId")
.and_then(Value::as_str)
.is_some_and(|id| id.starts_with("resource-character-")))
.count(),
2
);
let dialog = completed_items
.iter()
.find(|item| item["itemType"] == "generation-dialog")
.and_then(|item| item.get("dialog"))
.expect("generation dialog should remain");
assert_eq!(
dialog["generatedLayerId"],
json!("layer-editor-resource-resource-character-cutout")
);
}
#[test]
fn editor_canvas_postprocess_fallback_only_anchors_provider_source() {
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-character-fallback".to_string()),
title: "角色形象 1".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 100.0,
y: 80.0,
width: 200.0,
height: 300.0,
original_width: 400.0,
original_height: 600.0,
},
};
let source = editor_project_resource_for_canvas_test(
"resource-character-source",
"character",
400,
600,
);
let source_layer_id = generated_canvas_layer_id(source.resource_id.as_str());
let source_item = build_generated_canvas_layer_item(
&completion,
&completion.placeholder,
&source,
source_layer_id.as_str(),
0,
);
let layers = json!([{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-character-fallback",
"resourceId": "generation-dialog:dialog-character-fallback",
"dialog": {
"id": "dialog-character-fallback",
"status": "generating",
"placeholder": completion.placeholder.clone()
}
}]);
let completed = apply_editor_canvas_generation_multi_items(
layers,
&completion,
vec![source_item],
Some(source_layer_id.clone()),
)
.expect("source-only fallback should complete the generation dialog");
let completed_items = completed
.layers
.as_array()
.expect("layers should stay array");
let generated_resources = completed_items
.iter()
.filter(|item| {
item.get("resourceId")
.and_then(Value::as_str)
.is_some_and(|resource_id| resource_id.starts_with("resource-character-"))
})
.collect::<Vec<_>>();
assert_eq!(generated_resources.len(), 1);
assert_eq!(
generated_resources[0]["resourceId"],
json!("resource-character-source")
);
let dialog = completed_items
.iter()
.find(|item| item["itemType"] == "generation-dialog")
.and_then(|item| item.get("dialog"))
.expect("generation dialog should remain");
assert_eq!(dialog["generatedLayerId"], json!(source_layer_id));
}
#[test]
fn editor_canvas_spritesheet_items_include_processed_sheet_provider_source_and_slices() {
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-ui-assets".to_string()),
title: "UI素材图集".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 100.0,
y: 80.0,
width: 256.0,
height: 256.0,
original_width: 512.0,
original_height: 512.0,
},
};
let processed = editor_project_resource_for_canvas_test(
"resource-ui-sheet",
"icon-spritesheet",
512,
512,
);
let source = editor_project_resource_for_canvas_test(
"resource-ui-sheet-source",
"icon-spritesheet",
512,
512,
);
let slice = editor_project_resource_for_canvas_test("resource-ui-slice", "icon", 128, 128);
let icons = vec![EditorIconSpritesheetIconResponse {
name: "开始按钮".to_string(),
image_src: slice.image_src.clone(),
width: 128,
height: 128,
resource: Some(slice),
asset: None,
}];
let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items(
&completion,
Some(&processed),
Some(&source),
icons.as_slice(),
);
assert_eq!(items.len(), 3);
assert_eq!(
primary_layer_id.as_deref(),
Some("layer-editor-resource-resource-ui-sheet")
);
assert_eq!(items[0]["resourceId"], json!("resource-ui-sheet"));
assert_eq!(items[1]["resourceId"], json!("resource-ui-sheet-source"));
assert_eq!(items[1]["title"], json!("UI素材图集(原图)"));
assert_eq!(items[2]["resourceId"], json!("resource-ui-slice"));
let source_right = items[1]["x"].as_f64().unwrap() + items[1]["width"].as_f64().unwrap();
assert!(items[2]["x"].as_f64().unwrap() > source_right);
}
#[test]
fn editor_canvas_spritesheet_items_keep_processed_sheet_and_provider_source_without_slices() {
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-icon-sheet".to_string()),
title: "图标素材图集".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 100.0,
y: 80.0,
width: 256.0,
height: 256.0,
original_width: 512.0,
original_height: 512.0,
},
};
let processed = editor_project_resource_for_canvas_test(
"resource-icon-sheet",
"icon-spritesheet",
512,
512,
);
let source = editor_project_resource_for_canvas_test(
"resource-icon-sheet-source",
"icon-spritesheet",
512,
512,
);
let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items(
&completion,
Some(&processed),
Some(&source),
&[],
);
assert_eq!(items.len(), 2);
assert_eq!(items[0]["resourceId"], json!("resource-icon-sheet"));
assert_eq!(items[1]["resourceId"], json!("resource-icon-sheet-source"));
assert_eq!(items[1]["title"], json!("图标素材图集(原图)"));
assert_eq!(
primary_layer_id.as_deref(),
Some("layer-editor-resource-resource-icon-sheet")
);
}
#[test]
fn manual_icon_spritesheet_limits_reject_large_images_and_excessive_slices() {
assert!(validate_editor_icon_spritesheet_manual_dimensions(2048, 2048).is_ok());
assert!(validate_editor_icon_spritesheet_manual_dimensions(4096, 1024).is_ok());
assert!(validate_editor_icon_spritesheet_manual_dimensions(2049, 2048).is_err());
assert!(validate_editor_icon_spritesheet_manual_dimensions(4097, 1).is_err());
assert!(validate_editor_icon_spritesheet_manual_slice_count(64).is_ok());
assert!(validate_editor_icon_spritesheet_manual_slice_count(65).is_err());
}
#[test]
fn editor_canvas_generation_completion_skips_when_dialog_was_removed() {
let layers = json!([
{
"layerId": "layer-existing",
"resourceId": "resource-existing",
"zIndex": 4
}
]);
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-character".to_string()),
title: "角色形象 1".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 200.0,
y: 160.0,
width: 420.0,
height: 560.0,
original_width: 1024.0,
original_height: 1536.0,
},
};
let resource = EditorProjectResourcePayload {
resource_id: "editor-resource-character".to_string(),
showcase_id: None,
asset_id: None,
label: None,
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
author_display_name: None,
author_public_user_code: None,
image_src: "data:image/png;base64,character".to_string(),
object_key: None,
asset_object_id: None,
width: 1024,
height: 1536,
source_type: "generated".to_string(),
prompt: Some("银发游侠".to_string()),
actual_prompt: None,
model: Some("gpt-image-2".to_string()),
provider: Some("VectorEngine".to_string()),
task_id: Some("task-character".to_string()),
source_resource_id: None,
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-06-21T12:16:22.000Z".to_string(),
updated_at: "2026-06-21T12:16:22.000Z".to_string(),
};
let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource)
.expect("completion should be ignored");
assert!(!completed.changed);
assert_eq!(
completed.layers,
json!([
{
"layerId": "layer-existing",
"resourceId": "resource-existing",
"zIndex": 4
}
])
);
}
#[test]
fn editor_icon_spritesheet_prompt_uses_ordered_descriptions_and_size_tiers() {
let descriptions = vec![
"返回按钮".to_string(),
"设置按钮".to_string(),
"下一关按钮".to_string(),
];
let prompt = build_editor_icon_spritesheet_prompt(
&descriptions,
parse_editor_screen_background_color(Some("#7FB3FF"))
.expect("screen color should parse"),
);
assert!(prompt.contains("参考图1的图标素材规范"));
assert!(prompt.contains("背景必须是单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255)"));
assert!(prompt.contains("平整无纹理、无渐变、无阴影"));
assert!(
prompt.contains("素材主体及其配色必须与背景色明显区分,不要出现与背景色相同或相近的描边、底板、投影或反光")
);
assert!(prompt.contains("返回按钮、设置按钮、下一关按钮"));
}
#[test]
fn editor_icon_spritesheet_prompt_accepts_new_background_colors() {
let prompt = build_editor_icon_spritesheet_prompt(
&["发光能量按钮".to_string()],
parse_editor_screen_background_color(Some("#A8F7F0"))
.expect("new screen color should parse"),
);
assert!(prompt.contains("背景必须是单一纯色背景 高对比浅青 #A8F7F0 / RGB(168,247,240)"));
}
#[test]
fn editor_ui_design_asset_extraction_prompt_is_fixed() {
assert_eq!(
editor_ui_design_asset_extraction_prompt(
parse_editor_screen_background_color(None)
.expect("default screen color should parse")
),
"仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 浅雾蓝 #CFEFFF / RGB(207,239,255)。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材主体及其配色必须与背景色明显区分,不要出现与背景色相同或相近的描边、底板、投影或反光。"
);
}
#[test]
fn editor_icon_description_validation_filters_empty_and_rejects_more_than_limit() {
let descriptions = normalize_icon_descriptions(vec![
" 返回按钮 ".to_string(),
" ".to_string(),
"设置按钮".to_string(),
])
.expect("valid descriptions should pass");
assert_eq!(descriptions, vec!["返回按钮", "设置按钮"]);
let too_many = (0..101)
.map(|index| format!("图标{index}"))
.collect::<Vec<_>>();
assert!(normalize_icon_descriptions(too_many).is_err());
}
#[test]
fn editor_reference_inline_media_is_not_an_object_reference() {
for inline_source in ["data:image/png;base64,AAAA", "blob:browser-only"] {
let error = normalize_editor_reference_object_key(inline_source)
.expect_err("inline image should require upload before generation");
assert!(error.body_text().contains("objectKey"));
}
let source = include_str!("editor_project.rs");
assert_function_contains_in_order(
source,
"async fn resolve_editor_reference_object_key(",
"async fn find_editor_reference_object_key_by_registered_id",
&[
"ensure_editor_reference_image_source_is_stable",
"normalize_editor_reference_object_key",
],
);
}
#[test]
fn editor_reference_resource_id_resolves_registered_object_key() {
let resource = EditorProjectResourceRecord {
resource_id: "editor-resource-icon-spec".to_string(),
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
asset_object_id: Some("asset-object-1".to_string()),
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
object_key: None,
width: 512,
height: 512,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: None,
source_resource_id: None,
asset_kind: Some("icon-spec".to_string()),
generation_inputs: None,
public_showcase_enabled: true,
created_at: "2026-06-23T00:00:00.000Z".to_string(),
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
};
assert_eq!(
editor_reference_resource_object_key(&resource, "editor-resource-icon-spec"),
Some("generated-character-drafts/editor/spec.png".to_string())
);
assert_eq!(
editor_reference_resource_object_key(&resource, "missing"),
None
);
}
#[test]
fn editor_reference_asset_id_resolves_registered_object_key() {
let asset = EditorAssetRecord {
asset_id: "editor-asset-icon-spec".to_string(),
folder_id: "project".to_string(),
label: "图标规范".to_string(),
asset_object_id: Some("asset-object-1".to_string()),
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
thumbnail_src: None,
object_key: None,
width: 512,
height: 512,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: None,
asset_kind: Some("icon-spec".to_string()),
generation_inputs: None,
source_resource_id: None,
public_showcase_enabled: Some(true),
generation_cost_mud_points: 0,
showcase_id: None,
showcase_review_status: None,
showcase_display_enabled: None,
showcase_like_count: None,
created_at: "2026-06-23T00:00:00.000Z".to_string(),
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
};
assert_eq!(
editor_reference_asset_object_key(&asset, "editor-asset-icon-spec"),
Some("generated-character-drafts/editor/spec.png".to_string())
);
assert_eq!(editor_reference_asset_object_key(&asset, "missing"), None);
}
#[test]
fn editor_reference_image_sources_are_trimmed_and_limited() {
let sources = vec![
" generated-character-drafts/editor/a.png ".to_string(),
"".to_string(),
"generated-character-drafts/editor/b.png".to_string(),
"generated-character-drafts/editor/c.png".to_string(),
];
assert_eq!(
normalize_editor_reference_image_sources(Some(sources.as_slice()), 2),
vec![
"generated-character-drafts/editor/a.png",
"generated-character-drafts/editor/b.png"
]
);
}
#[test]
fn editor_reference_object_key_requires_generated_prefix() {
assert_eq!(
normalize_editor_reference_object_key("/generated-character-drafts/editor/ref.png")
.unwrap(),
"generated-character-drafts/editor/ref.png"
);
assert!(normalize_editor_reference_object_key("/private/editor/ref.png").is_err());
}
#[test]
fn editor_reference_object_key_matches_registered_media() {
assert!(editor_record_object_key_matches(
Some("/generated-character-drafts/editor/ref.png"),
"",
"generated-character-drafts/editor/ref.png",
));
assert!(!editor_record_object_key_matches(
Some("generated-character-drafts/editor/other.png"),
"",
"generated-character-drafts/editor/ref.png",
));
}
#[test]
fn editor_reference_asset_object_requires_matching_owner_bucket_and_key() {
let state = AppState::new(AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-beijing.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-ak".to_string()),
oss_access_key_secret: Some("test-sk".to_string()),
..AppConfig::default()
})
.expect("state should build");
let asset_object = module_assets::AssetObjectRecord {
asset_object_id: "assetobj_reference_1".to_string(),
bucket: "genarrative-assets".to_string(),
object_key: "generated-character-drafts/editor/ref.png".to_string(),
access_policy: module_assets::AssetObjectAccessPolicy::Private,
content_type: Some("image/png".to_string()),
content_length: 1024,
content_hash: None,
version: 1,
source_job_id: None,
owner_user_id: Some("user-1".to_string()),
profile_id: None,
entity_id: None,
asset_kind: "editor_generated_image".to_string(),
created_at: "2026-06-27T00:00:00Z".to_string(),
updated_at: "2026-06-27T00:00:00Z".to_string(),
};
assert!(
validate_editor_reference_asset_object(
&asset_object,
"user-1",
&state,
"generated-character-drafts/editor/ref.png",
)
.is_ok()
);
assert!(
validate_editor_reference_asset_object(
&asset_object,
"user-2",
&state,
"generated-character-drafts/editor/ref.png",
)
.is_err()
);
}
#[test]
fn editor_reference_image_mime_type_falls_back_to_magic_bytes() {
assert_eq!(
infer_editor_reference_image_mime_type(b"\x89PNG\r\n\x1a\nrest"),
Some("image/png")
);
assert_eq!(
infer_editor_reference_image_mime_type(&[0xff, 0xd8, 0xff, 0xe0]),
Some("image/jpeg")
);
assert_eq!(
infer_editor_reference_image_mime_type(b"RIFFxxxxWEBPrest"),
Some("image/webp")
);
assert_eq!(infer_editor_reference_image_mime_type(b"not image"), None);
}
#[test]
fn editor_background_removal_source_requires_supported_content_type_and_matching_magic() {
assert_eq!(
normalize_editor_background_removal_source_mime_type(Some("IMAGE/PNG; charset=utf-8")),
Some("image/png")
);
assert_eq!(
normalize_editor_background_removal_source_mime_type(Some("image/gif")),
None
);
assert_eq!(
normalize_editor_background_removal_source_mime_type(Some("application/pdf")),
None
);
assert_eq!(
normalize_editor_background_removal_source_mime_type(None),
None
);
assert!(
validate_editor_background_removal_source_content(
"image/png",
b"\x89PNG\r\n\x1a\nrest"
)
.is_ok()
);
assert_eq!(
validate_editor_background_removal_source_content(
"image/png",
&[0xff, 0xd8, 0xff, 0xe0]
)
.expect_err("mismatched image content should be rejected")
.status_code(),
StatusCode::UNSUPPORTED_MEDIA_TYPE
);
assert_eq!(
validate_editor_background_removal_source_content("image/png", b"not image")
.expect_err("non-image content should be rejected")
.status_code(),
StatusCode::UNSUPPORTED_MEDIA_TYPE
);
}
#[test]
fn editor_character_postprocess_fallback_response_keeps_source_and_warning() {
let response = EditorImageGenerationResponse {
image_src: "/api/assets/source-character.png".to_string(),
object_key: Some("generated/editor/character-source.png".to_string()),
asset_object_id: Some("asset-object-character-source".to_string()),
width: 1024,
height: 1536,
source_type: "generated",
prompt: "角色形象".to_string(),
actual_prompt: Some("角色形象 actual".to_string()),
model: GPT_IMAGE_2_MODEL.to_string(),
provider: "VectorEngine",
task_id: "task-character-source".to_string(),
resource: None,
asset: None,
project: None,
warning: Some(editor_postprocess_fallback_warning(
"生成任务成功,后处理失败。",
)),
};
let payload = serde_json::to_value(response).expect("response should serialize");
assert_eq!(
payload["imageSrc"],
json!("/api/assets/source-character.png")
);
assert_eq!(
payload["objectKey"],
json!("generated/editor/character-source.png")
);
assert_eq!(
payload["warning"]["code"],
json!(EDITOR_GENERATION_POSTPROCESS_WARNING_CODE)
);
assert_eq!(
payload["warning"]["reason"],
json!("生成任务成功,后处理失败。")
);
}
#[tokio::test]
async fn editor_icon_spritesheet_price_is_resolved_from_generation_config() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert_eq!(
resolve_editor_icon_spritesheet_price(
&state,
Some("gemini-3.1-flash-image-preview"),
Some("1K")
)
.await
.unwrap(),
12
);
assert_eq!(
resolve_editor_icon_spritesheet_price(&state, Some("gpt-image-2"), Some("1K"))
.await
.unwrap(),
3
);
let response = EditorIconSpritesheetGenerationResponse {
spritesheet_image_src: "data:image/png;base64,sheet".to_string(),
spritesheet_width: 512,
spritesheet_height: 512,
icon_image_srcs: Vec::new(),
slice_warning: Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
reason: "图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。".to_string(),
}),
prompt: "图标 prompt".to_string(),
actual_prompt: Some("图标 prompt".to_string()),
model: "gemini-3.1-flash-image-preview".to_string(),
provider: "VectorEngine",
task_id: "task-icons".to_string(),
price_mud_points: crate::editor_generation_config::editor_image_generation_mud_points(
Some("icon"),
None,
Some("1K"),
),
spritesheet_resource: None,
spritesheet_asset: None,
project: None,
warning: None,
};
let payload = serde_json::to_value(response).expect("response should serialize");
assert_eq!(payload["priceMudPoints"], json!(12));
assert_eq!(payload["iconImageSrcs"], json!([]));
assert_eq!(
payload["sliceWarning"]["code"],
json!("insufficient-connected-components")
);
assert_eq!(
payload["sliceWarning"]["reason"],
json!("图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。")
);
}
#[tokio::test]
async fn editor_ui_design_asset_extraction_price_uses_selected_model_and_size() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request: EditorUiDesignAssetExtractionRequest = serde_json::from_value(json!({
"sourceImageSrc": "/generated-images/editor/ui-design.png",
"model": "gpt-image-2",
"screenColor": "#E6D8FF",
"segModel": "anime-seg",
"aspectRatio": "1:1",
"imageSize": "2K"
}))
.expect("UI extraction request should deserialize dimensions");
assert_eq!(request.screen_color.as_deref(), Some("#E6D8FF"));
assert_eq!(request.seg_model.as_deref(), Some("anime-seg"));
let generation_options = normalize_editor_ui_design_asset_extraction_options(
request.model.as_deref(),
request.aspect_ratio.as_str(),
request.image_size.as_str(),
)
.expect("UI extraction dimensions should pass");
assert_eq!(generation_options.model, GPT_IMAGE_2_MODEL);
assert_eq!(generation_options.size, "2048x2048");
assert_eq!(
resolve_editor_ui_design_asset_extraction_price(
&state,
Some(generation_options.model),
Some(generation_options.image_size)
)
.await
.unwrap(),
5
);
let default_generation =
normalize_editor_ui_design_asset_extraction_options(None, "1:1", "1K")
.expect("default UI extraction options should pass");
assert_eq!(default_generation.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
let blank_model_generation =
normalize_editor_ui_design_asset_extraction_options(Some(" "), "1:1", "1K")
.expect("blank UI extraction model should use default");
assert_eq!(blank_model_generation.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(
resolve_editor_ui_design_asset_extraction_price(
&state,
Some(default_generation.model),
Some(default_generation.image_size),
)
.await
.unwrap(),
12
);
let ratio_error =
match normalize_editor_ui_design_asset_extraction_options(None, "16:9", "2K") {
Ok(_) => panic!("UI extraction ratio should stay square"),
Err(error) => error,
};
assert!(ratio_error.body_text().contains("aspectRatio"));
let size_error =
match normalize_editor_ui_design_asset_extraction_options(None, "1:1", "0.5K") {
Ok(_) => panic!("UI extraction size should be 1K or 2K"),
Err(error) => error,
};
assert!(size_error.body_text().contains("imageSize"));
}
#[test]
fn editor_image_generation_request_omits_price_mud_points() {
let request: EditorImageGenerationRequest = serde_json::from_value(json!({
"prompt": "生成图片",
"model": "gpt-image-2",
"screenColor": "#CFEFFF",
"segModel": "anime-seg",
"aspectRatio": "1:1",
"imageSize": "1K"
}))
.expect("image generation request should deserialize without price");
assert_eq!(request.prompt, "生成图片");
assert_eq!(request.screen_color.as_deref(), Some("#CFEFFF"));
assert_eq!(request.seg_model.as_deref(), Some("anime-seg"));
}
#[test]
fn editor_image_edit_request_omits_price_mud_points() {
let request: EditorImageEditRequest = serde_json::from_value(json!({
"prompt": "修改图片",
"sourceImageSrc": "/generated-images/editor/source.png",
"size": "2048x1152",
"model": "gemini-3.1-flash-image-preview",
"aspectRatio": "16:9",
"imageSize": "2K",
"referenceImageSrcs": ["/generated-images/editor/style.png"],
"targetLayerId": "layer-source"
}))
.expect("image edit request should deserialize without price");
assert_eq!(request.prompt, "修改图片");
assert_eq!(request.size.as_deref(), Some("2048x1152"));
assert_eq!(
request.model.as_deref(),
Some("gemini-3.1-flash-image-preview")
);
assert_eq!(request.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(request.image_size.as_deref(), Some("2K"));
let legacy_options = normalize_editor_image_edit_generation_options(
request.model.as_deref(),
None,
None,
Some("2048x1365"),
);
assert_eq!(legacy_options.aspect_ratio, "3:2");
assert_eq!(legacy_options.image_size, "2K");
assert_eq!(
request.reference_image_srcs,
Some(vec!["/generated-images/editor/style.png".to_string()])
);
assert_eq!(request.target_layer_id.as_deref(), Some("layer-source"));
}
#[test]
fn editor_image_edit_rejects_individual_icons_but_allows_spritesheets_and_specs() {
let error = ensure_editor_image_edit_asset_kind_allowed(Some("icon"))
.expect_err("individual icon assets should not support quick edit");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("message")),
Some(&json!("单个图标不支持快速编辑")),
);
assert_eq!(
error.details().and_then(|details| details.get("assetKind")),
Some(&json!("icon")),
);
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spritesheet")).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spec")).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("image")).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(None).is_ok());
let layers = json!([
{ "layerId": "layer-icon", "assetKind": "icon" },
{ "layerId": "layer-icon-spec", "assetKind": "icon-spec" }
]);
let icon_kind =
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon")
.expect("icon layer should exist");
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_kind).is_err());
let icon_spec_kind =
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon-spec")
.expect("icon spec layer should exist");
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_spec_kind).is_ok());
assert!(
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "missing").is_err()
);
}
#[test]
fn editor_image_edit_guards_before_queue_and_owner_execution() {
let source = include_str!("editor_project.rs");
assert_function_contains_in_order(
source,
"pub async fn edit_editor_image(",
"pub(crate) async fn edit_editor_image_for_owner",
&[
"ensure_editor_image_edit_source_allowed(",
"enqueue_editor_generation_job",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn edit_editor_image_for_owner",
"pub async fn remove_editor_image_background",
&[
"ensure_editor_image_edit_source_allowed(",
"parse_editor_reference_image",
],
);
}
#[test]
fn editor_inline_media_sources_are_rejected_before_queueing() {
let source = include_str!("editor_project.rs");
for (start, end) in [
(
"pub async fn generate_editor_image(",
"pub(crate) async fn generate_editor_image_for_owner",
),
(
"pub async fn edit_editor_image(",
"pub(crate) async fn edit_editor_image_for_owner",
),
(
"pub async fn remove_editor_image_background(",
"pub(crate) async fn remove_editor_image_background_for_owner",
),
(
"pub async fn generate_editor_icon_spritesheet(",
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
),
(
"pub async fn extract_editor_ui_design_assets(",
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
),
] {
assert_function_contains_in_order(
source,
start,
end,
&[
"ensure_editor_reference_image_source",
"enqueue_editor_generation_job",
],
);
}
for inline_source in [
"data:image/png;base64,AAAA",
" DATA:image/png;base64,AAAA",
"blob:browser-only",
] {
let error = ensure_editor_reference_image_source_is_stable(
inline_source,
"editor-test",
"sourceImageSrc",
"测试图片",
)
.expect_err("inline media must require upload before generation");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert!(error.body_text().contains("先上传 OSS"));
}
assert!(
ensure_editor_reference_image_source_is_stable(
"/generated-images/editor/reference.png",
"editor-test",
"sourceImageSrc",
"测试图片",
)
.is_ok()
);
}
#[test]
fn editor_image_edit_can_complete_by_replacing_target_layer() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"pub(crate) async fn edit_editor_image_for_owner",
"pub async fn remove_editor_image_background",
&[
"payload.canvas_completion.is_some()",
"complete_editor_canvas_background_removal",
"payload.target_layer_id.as_deref()",
],
);
}
#[test]
fn editor_icon_spritesheet_request_accepts_stable_extra_references() {
let request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
"referenceImageSrc": "/generated-images/editor/spec.png",
"referenceImageSrcs": ["/generated-images/editor/style.png"],
"iconDescriptions": ["返回按钮"],
"screenColor": "#7FB3FF",
"segModel": "anime-seg",
"assetLabel": " 冒险界面图标 "
}))
.expect("icon spritesheet request should deserialize references");
assert_eq!(
request.reference_image_srcs,
Some(vec!["/generated-images/editor/style.png".to_string()])
);
assert_eq!(request.screen_color.as_deref(), Some("#7FB3FF"));
assert_eq!(request.seg_model.as_deref(), Some("anime-seg"));
assert_eq!(request.asset_label.as_deref(), Some(" 冒险界面图标 "));
}
#[test]
fn editor_generated_asset_label_trims_custom_name_and_falls_back_for_blank_input() {
assert_eq!(
resolve_editor_generated_asset_label(
Some(" 冒险界面图标 ".to_string()),
"图标素材图集"
),
"冒险界面图标"
);
assert_eq!(
resolve_editor_generated_asset_label(Some(" ".to_string()), "图标素材图集"),
"图标素材图集"
);
assert_eq!(
resolve_editor_generated_asset_label(Some("图".repeat(81)), "图标素材图集")
.chars()
.count(),
EDITOR_ASSET_LABEL_MAX_CHARS
);
assert_eq!(
editor_generated_asset_variant_label("冒险界面图标", "原图"),
"冒险界面图标(原图)"
);
let long_variant = editor_generated_asset_variant_label("图".repeat(80).as_str(), "原图");
assert_eq!(long_variant.chars().count(), EDITOR_ASSET_LABEL_MAX_CHARS);
assert!(long_variant.ends_with("(原图)"));
}
#[test]
fn editor_icon_spritesheet_slice_request_requires_project_source_and_layout() {
let request: EditorIconSpritesheetSliceRequest = serde_json::from_value(json!({
"projectId": "project-1",
"sourceResourceId": "resource-atlas-1",
"assetFolderId": "project",
"canvasCompletion": {
"title": "拆分图集",
"placeholder": {
"x": 120.0,
"y": 80.0,
"width": 360.0,
"height": 360.0,
"originalWidth": 1024.0,
"originalHeight": 1024.0
}
}
}))
.expect("spritesheet slice request should deserialize");
assert_eq!(request.project_id, "project-1");
assert_eq!(request.source_resource_id, "resource-atlas-1");
assert_eq!(request.asset_folder_id.as_deref(), Some("project"));
assert_eq!(request.canvas_completion.title, "拆分图集");
assert_eq!(request.canvas_completion.placeholder.width, 360.0);
}
#[test]
fn editor_manual_atlas_split_lookup_keeps_original_provenance_after_project_copy() {
let mut copied_resource = editor_project_resource_for_canvas_test(
"copied-resource-atlas-1",
"icon-spritesheet",
1024,
1024,
);
copied_resource.source_resource_id = Some("resource-atlas-1".to_string());
copied_resource.asset_object_id = Some("asset-object-atlas-1".to_string());
copied_resource.object_key = Some("generated/icon-atlas-1.png".to_string());
let lookup = editor_manual_atlas_split_group_source_lookup_input(
"user-1",
copied_resource.resource_id.as_str(),
copied_resource.source_resource_id.as_deref(),
copied_resource.asset_object_id.as_deref(),
copied_resource.object_key.as_deref(),
);
assert_eq!(lookup.owner_user_id, "user-1");
assert_eq!(
lookup.source_resource_ids,
vec!["copied-resource-atlas-1", "resource-atlas-1"]
);
assert_eq!(
lookup.asset_object_id.as_deref(),
Some("asset-object-atlas-1")
);
assert_eq!(
lookup.object_key.as_deref(),
Some("generated/icon-atlas-1.png")
);
}
#[test]
fn editor_bgfilter_seg_model_parser_defaults_and_rejects_unknown() {
assert_eq!(
parse_editor_bgfilter_seg_model(None).expect("default should parse"),
EDITOR_BGFILTER_DEFAULT_SEG_MODEL
);
assert_eq!(
parse_editor_bgfilter_seg_model(Some("Anime-Seg")).expect("anime seg should parse"),
EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG
);
assert!(parse_editor_bgfilter_seg_model(Some("u2net")).is_err());
}
#[test]
fn editor_bgfilter_worker_budget_covers_two_attempts_and_parent_reserves() {
let config = AppConfig::default();
// N=16、est=5s → attempt=160s、callBudget=321s,全部由公式运行时派生。
assert_eq!(config.bgfilter_provider_attempt_timeout_ms(), 160_000);
assert_eq!(config.bgfilter_call_budget_ms(), 321_000);
assert_eq!(
editor_bgfilter_flat_deadline_reserve(30_000),
Duration::from_secs(39)
);
// complex:父剩余 400s − 2s 传输窗 − 321s callBudget → 排队上限约 77s。
let complex_deadline = Instant::now() + Duration::from_secs(400);
let complex_wait = crate::bgfilter_worker::max_queue_wait_ms(
config.bgfilter_call_budget_ms(),
Some(complex_deadline),
EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW,
);
assert!((76_500..=77_000).contains(&complex_wait));
// flat:再扣 39s fallback 预留 → 约 40s,排队吃不掉 fallback 的执行窗口。
let flat_deadline = Instant::now() + Duration::from_secs(400);
let flat_wait = crate::bgfilter_worker::max_queue_wait_ms(
config.bgfilter_call_budget_ms(),
Some(flat_deadline),
editor_bgfilter_flat_deadline_reserve(30_000),
);
assert!((39_500..=40_000).contains(&flat_wait));
// 父剩余放不下一次完整调用(排队 + callBudget + 预留)时必须为 0:不发请求直接降级。
let tight_deadline = Instant::now() + Duration::from_secs(100);
assert_eq!(
crate::bgfilter_worker::max_queue_wait_ms(
config.bgfilter_call_budget_ms(),
Some(tight_deadline),
editor_bgfilter_flat_deadline_reserve(30_000),
),
0
);
}
#[tokio::test]
async fn editor_flat_bgfilter_expired_parent_deadline_does_not_start_fallback() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: Some("user-deadline".to_string()),
profile_id: None,
request_id: Some("request-deadline".to_string()),
external_call_deadline: Instant::now().checked_sub(Duration::from_millis(1)),
};
let error = match remove_editor_generated_screen_background_with_bgfilter(
&state,
"generated-character-drafts/editor/source.png",
parse_editor_screen_background_color(Some("#CFEFFF"))
.expect("screen color should parse"),
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
&audit,
)
.await
{
Ok(_) => panic!("expired parent deadline should stop before flat fallback"),
Err(error) => error,
};
assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT);
assert_eq!(
error
.details()
.and_then(|details| details.get("workerCode")),
Some(&json!("deadline_exceeded"))
);
}
#[tokio::test]
async fn editor_flat_bgfilter_background_removal_uses_internal_worker_once() {
let response_png = encode_test_png(3, 2);
let (base_url, request_receiver, server) = spawn_bgfilter_worker_png_mock_with_delay(
response_png.clone(),
Duration::from_millis(100),
);
let state = AppState::new(AppConfig {
bgfilter_worker_base_url: base_url,
bgfilter_internal_token: Some("flat-internal-token".to_string()),
..AppConfig::default()
})
.expect("state should build");
let audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: Some("user-flat".to_string()),
profile_id: Some("project-flat".to_string()),
request_id: Some("request-flat".to_string()),
external_call_deadline: None,
};
let removed = remove_editor_generated_screen_background_with_bgfilter(
&state,
"generated-character-drafts/editor/source.png",
parse_editor_screen_background_color(Some("#CFEFFF"))
.expect("screen color should parse"),
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
&audit,
)
.await
.expect("internal worker PNG response should decode");
let request = request_receiver
.recv_timeout(Duration::from_secs(1))
.expect("mock server should capture request");
server.join().expect("mock server should stop cleanly");
let request_text = String::from_utf8_lossy(request.as_slice());
let lowercase_request = request_text.to_ascii_lowercase();
let payload = parse_mock_http_json_body(request.as_slice());
assert!(
request_text.starts_with("POST /internal/bgfilter/v1/remove-background HTTP/1.1\r\n")
);
assert!(lowercase_request.contains("\r\nauthorization: bearer flat-internal-token\r\n"));
let internal_request_id = payload["requestId"]
.as_str()
.expect("internal request id should be a string");
assert!(uuid::Uuid::parse_str(internal_request_id).is_ok());
assert_ne!(internal_request_id, "request-flat");
assert_eq!(
payload["sourceObjectKey"],
json!("generated-character-drafts/editor/source.png")
);
assert_eq!(payload["backgroundMode"], json!("flat"));
assert_eq!(payload["screenColor"], json!("#CFEFFF"));
assert_eq!(payload["segModel"], json!("birefnet"));
assert_eq!(payload["crossCheck"], json!(true));
// 无绝对 deadline 时排队额度取 callBudget 等长的有界默认值;两者都由 N/est 公式派生。
assert_eq!(payload["maxQueueWaitMs"], json!(321_000));
assert_eq!(payload["callBudgetMs"], json!(321_000));
assert_eq!(payload["auditContext"]["userId"], json!("user-flat"));
assert_eq!(payload["auditContext"]["requestId"], json!("request-flat"));
assert_eq!(removed.image.bytes, response_png);
assert_eq!(removed.image.mime_type, "image/png");
assert_eq!(removed.image.extension, "png");
assert_eq!(removed.provider, "BgFilter");
}
#[tokio::test]
async fn editor_complex_bgfilter_background_removal_uses_internal_worker_once() {
let response_png = encode_test_png(3, 2);
let (base_url, request_receiver, server) =
spawn_bgfilter_worker_png_mock(response_png.clone());
let state = AppState::new(AppConfig {
bgfilter_worker_base_url: base_url,
bgfilter_internal_token: Some("complex-internal-token".to_string()),
..AppConfig::default()
})
.expect("state should build");
let audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: Some("user-complex".to_string()),
profile_id: None,
request_id: Some("request-complex".to_string()),
external_call_deadline: None,
};
let removed = request_editor_background_removal_image_with_bgfilter_worker(
&state,
"generated-character-drafts/editor/manual-source.png",
&audit,
)
.await
.expect("internal worker PNG response should decode");
let request = request_receiver
.recv_timeout(Duration::from_secs(1))
.expect("mock server should capture request");
server.join().expect("mock server should stop cleanly");
let request_text = String::from_utf8_lossy(request.as_slice());
let lowercase_request = request_text.to_ascii_lowercase();
let payload = parse_mock_http_json_body(request.as_slice());
assert!(
request_text.starts_with("POST /internal/bgfilter/v1/remove-background HTTP/1.1\r\n")
);
assert!(lowercase_request.contains("\r\nauthorization: bearer complex-internal-token\r\n"));
let internal_request_id = payload["requestId"]
.as_str()
.expect("internal request id should be a string");
assert!(uuid::Uuid::parse_str(internal_request_id).is_ok());
assert_ne!(internal_request_id, "request-complex");
assert_eq!(
payload["sourceObjectKey"],
json!("generated-character-drafts/editor/manual-source.png")
);
assert_eq!(payload["backgroundMode"], json!("complex"));
assert!(payload["screenColor"].is_null());
assert_eq!(payload["segModel"], json!("birefnet"));
assert_eq!(payload["crossCheck"], json!(false));
assert_eq!(payload["maxQueueWaitMs"], json!(321_000));
assert_eq!(payload["callBudgetMs"], json!(321_000));
assert_eq!(
payload["auditContext"]["requestId"],
json!("request-complex")
);
assert_eq!((removed.width, removed.height), (3, 2));
assert_eq!(removed.image.mime_type, "image/png");
assert_eq!(removed.image.extension, "png");
assert_eq!(removed.image.bytes, response_png);
}
#[test]
fn editor_image_and_spritesheet_generation_are_billable() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
&[
"execute_billable_asset_operation_with_cost",
"configured_price_mud_points",
],
);
assert_function_contains(
source,
"pub(crate) async fn edit_editor_image_for_owner",
"pub async fn generate_editor_icon_spritesheet",
&[
"execute_billable_asset_operation_with_cost",
"expected_price_mud_points",
],
);
assert_function_contains(
source,
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
&[
"execute_billable_asset_operation_with_cost",
"expected_price_mud_points",
],
);
assert_function_contains(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&[
"execute_billable_asset_operation_with_cost",
"expected_price_mud_points",
],
);
}
#[test]
fn editor_canvas_screen_background_generation_uses_bgfilter_postprocess() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
&[
"persist_editor_provider_source_image",
"caller.report_processing_phase(state).await?",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_ENABLED",
"persist_editor_provider_source_resource",
"build_multi_asset_canvas_layer_items",
"if is_character_generation",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
&[
"persist_editor_provider_source_image",
"persist_editor_provider_source_resource",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_ENABLED",
],
);
assert_function_contains(
source,
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
&[
"persist_editor_provider_source_image",
"caller.report_processing_phase(state).await?",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
"persist_editor_provider_source_resource",
"slice_generated_icon_spritesheet_by_connected_components",
"persist_editor_spritesheet_slices",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
&[
"persist_editor_provider_source_image",
"persist_editor_provider_source_resource",
"caller.report_processing_phase(state).await?",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn remove_editor_image_background_for_owner",
"struct EditorBackgroundRemovalImage",
&[
"caller.report_processing_phase(state).await?",
"resolve_editor_reference_object_key_for_owner",
"validate_editor_background_removal_source",
"request_editor_background_removal_image_with_bgfilter_worker",
],
);
assert_function_contains(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&[
"persist_editor_provider_source_image",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
"persist_editor_provider_source_resource",
"slice_generated_icon_spritesheet_all_by_connected_components",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&[
"persist_editor_provider_source_image",
"persist_editor_provider_source_resource",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"async fn fallback_editor_screen_background_removal",
&[
"crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry",
"crate::bgfilter_worker::BgfilterBackgroundMode::Flat",
"editor_bgfilter_flat_deadline_reserve",
"state.config.aliyun_matting_request_timeout_ms",
"error.allows_flat_fallback()",
"fallback_editor_screen_background_removal",
],
);
assert_function_not_contains(
source,
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"async fn fallback_editor_screen_background_removal",
&[
"for attempt in",
concat!("EDITOR_BGFILTER_", "RETRY_COUNT"),
concat!("editor_bgfilter_", "circuit"),
"reqwest::multipart",
concat!("editor_bgfilter_", "http_client"),
],
);
assert_function_contains_in_order(
source,
"async fn fallback_editor_screen_background_removal",
"fn sign_editor_private_object_read_url",
&[
"segment_image_url_with_aliyun_matting",
"download_editor_persisted_image_object",
"remove_editor_generated_green_screen_background",
"drop(source_image)",
],
);
assert_function_contains(
source,
"async fn request_editor_background_removal_image_with_bgfilter_worker",
"pub(crate) struct EditorScreenBackgroundRemovalOutput",
&[
"crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry",
"crate::bgfilter_worker::BgfilterBackgroundMode::Complex",
"EDITOR_BGFILTER_DEFAULT_SEG_MODEL",
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
"EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW",
".map_err(crate::bgfilter_worker::BgfilterClientError::into_app_error)?",
],
);
assert_function_not_contains(
source,
"async fn request_editor_background_removal_image_with_bgfilter_worker",
"pub(crate) struct EditorScreenBackgroundRemovalOutput",
&[
"for attempt in",
"fallback_editor_screen_background_removal",
"reqwest::multipart",
concat!("editor_bgfilter_", "http_client"),
concat!("editor_bgfilter_", "base_url"),
concat!("editor_bgfilter_", "token"),
],
);
assert!(!source.contains(concat!("const EDITOR_BGFILTER_", "RETRY_COUNT")));
assert!(!source.contains(concat!("static EDITOR_BGFILTER_", "CIRCUIT")));
assert_function_not_contains(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
&["try_remove_editor_downloaded_image_background"],
);
assert_function_not_contains(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
&["remove_editor_generated_green_screen_background"],
);
assert_function_not_contains(
source,
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
&["try_remove_editor_downloaded_image_background"],
);
assert_function_not_contains(
source,
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
&["remove_editor_generated_green_screen_background"],
);
assert_function_not_contains(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&["try_remove_editor_downloaded_image_background"],
);
assert_function_not_contains(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&["remove_editor_generated_green_screen_background"],
);
assert_function_not_contains(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
&[
concat!("prepare_editor_", "character_image_for_response"),
"Err(_)",
],
);
assert_function_not_contains(
source,
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
&[
concat!("prepare_editor_", "icon_spritesheet_image_for_persist"),
"Err(_)",
],
);
assert_function_not_contains(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&["Err(_) => image"],
);
}
#[test]
fn editor_flat_bgfilter_parent_calls_internal_worker_once_before_fallback() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"async fn fallback_editor_screen_background_removal",
&[
"crate::bgfilter_worker::request_bgfilter_worker",
"fallback_editor_screen_background_removal",
],
);
assert_function_not_contains(
source,
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"async fn fallback_editor_screen_background_removal",
&[
"for attempt in",
"will_retry",
concat!("EDITOR_BGFILTER_", "RETRY_COUNT"),
],
);
}
#[test]
fn editor_processing_phase_retries_transport_once_but_not_fencing() {
let source = include_str!("editor_project.rs");
assert!(source.contains("const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1;"));
assert_function_contains_in_order(
source,
"pub(crate) async fn report_processing(&self, state: &AppState)",
"fn map_editor_generation_phase_update_error",
&[
"let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1",
"for attempt in 1..=max_attempts",
".update_external_generation_job_phase(input.clone())",
"should_retry_editor_generation_phase_update",
"if will_retry",
"continue",
"map_editor_generation_phase_update_error(error)",
],
);
assert_function_contains(
source,
"fn map_editor_generation_phase_update_error",
"#[derive(Debug, Serialize)]",
&["error.is_lease_fencing_rejected()", "StatusCode::CONFLICT"],
);
}
#[test]
fn editor_processing_phase_retry_policy_stops_after_one_transport_retry() {
let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1;
let transport_errors = [
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Build(
"initial websocket connect failed".to_string(),
)),
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::ConnectDropped),
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Timeout(
spacetime_client::SpacetimeClientStage::ProcedureResult,
)),
];
for error in &transport_errors {
assert!(should_retry_editor_generation_phase_update(
error,
1,
max_attempts
));
assert!(!should_retry_editor_generation_phase_update(
error,
max_attempts,
max_attempts
));
}
let lease = ExternalGenerationJobPhaseUpdateError::LeaseFencingRejected(
"lease token 不匹配".to_string(),
);
assert!(!should_retry_editor_generation_phase_update(
&lease,
1,
max_attempts
));
let rejected = ExternalGenerationJobPhaseUpdateError::Rejected("phase 非法".to_string());
assert!(!should_retry_editor_generation_phase_update(
&rejected,
1,
max_attempts
));
for error in [
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Procedure(
"procedure rejected".to_string(),
)),
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Runtime(
"runtime invariant failed".to_string(),
)),
] {
assert!(!should_retry_editor_generation_phase_update(
&error,
1,
max_attempts
));
}
}
#[test]
fn editor_static_postprocess_failure_completes_canvas_with_source_only() {
let source = include_str!("editor_project.rs");
for (start, end) in [
(
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
),
(
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
),
(
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
),
] {
assert_function_contains_in_order(
source,
start,
end,
&[
"persist_editor_provider_source_resource",
"caller.report_processing_phase(state).await?",
"remove_editor_generated_screen_background_with_bgfilter",
"Err(error)",
"complete_editor_canvas_generation",
"source_record.resource.as_ref()",
"warning: Some(editor_postprocess_fallback_warning",
],
);
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let function_tail = &source[start_index..];
let end_index = function_tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &function_tail[..end_index];
let fallback_start = body
.find("let removal = match removal {")
.unwrap_or_else(|| panic!("{start} should match only the postprocess result"));
let success_marker = if start.contains("generate_editor_image_for_owner") {
"image = restored_removal_image;"
} else {
"let removal_provider = removal.provider;"
};
let fallback_end = body[fallback_start..]
.find(success_marker)
.map(|offset| fallback_start + offset)
.unwrap_or_else(|| {
panic!("{start} should resume its processed-image success path")
});
let fallback = &body[fallback_start..fallback_end];
assert!(fallback.contains("return Ok(json_success_body"));
for forbidden in [
"persist_editor_generated_image(",
"persist_editor_generated_asset(",
"slice_generated_icon_spritesheet",
"persist_editor_spritesheet_slices",
] {
assert!(
!fallback.contains(forbidden),
"{start} fallback must not absorb later failure scope: {forbidden}"
);
}
if start.contains("spritesheet") || start.contains("ui_design") {
let success_tail = &body[fallback_end..];
assert!(success_tail.contains("persist_editor_generated_image("));
assert!(success_tail.contains("persist_editor_generated_asset("));
assert!(success_tail.contains("persist_editor_spritesheet_slices"));
}
}
}
#[test]
fn editor_character_postprocess_fallback_is_inline_and_does_not_swallow_other_failures() {
let source = include_str!("editor_project.rs");
let start = source
.find("pub(crate) async fn generate_editor_image_for_owner")
.expect("character generation function should exist");
let function_tail = &source[start..];
let end = function_tail
.find("fn normalize_editor_image_generation_size")
.expect("character generation function end marker should exist");
let body = &function_tail[..end];
let phase = body
.find("caller.report_processing_phase(state).await?")
.expect("phase report should remain fallible");
let removal = body
.find("let removal = remove_editor_generated_screen_background_with_bgfilter")
.expect("transparent background postprocess should exist");
let fallback_start = body
.find("let removal = match removal {")
.expect("only the postprocess result should be matched");
let fallback_end = body[fallback_start..]
.find("image = restored_removal_image;")
.map(|offset| fallback_start + offset)
.expect("processed-image success path should follow the fallback match");
let fallback = &body[fallback_start..fallback_end];
assert!(
phase < removal,
"phase errors must fail before fallback is considered"
);
for snippet in [
"Err(error)",
"complete_editor_canvas_generation",
"source_record.resource.as_ref()",
"return Ok(json_success_body",
"warning: Some(editor_postprocess_fallback_warning",
] {
assert!(
fallback.contains(snippet),
"character postprocess fallback should contain {snippet}"
);
}
for forbidden in [
"persist_editor_generated_image(",
"persist_editor_generated_asset(",
"slice_generated_icon_spritesheet",
"build_multi_asset_canvas_layer_items",
] {
assert!(
!fallback.contains(forbidden),
"character postprocess fallback must not absorb later failure scope: {forbidden}"
);
}
let success_tail = &body[fallback_end..];
assert!(success_tail.contains("persist_editor_generated_image("));
assert!(success_tail.contains("persist_editor_generated_asset("));
assert!(success_tail.contains(".await?;"));
}
#[test]
fn editor_manual_background_removal_calls_internal_worker_once_without_fallback() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"async fn request_editor_background_removal_image_with_bgfilter_worker",
"pub(crate) struct EditorScreenBackgroundRemovalOutput",
&[
"crate::bgfilter_worker::request_bgfilter_worker",
"BgfilterBackgroundMode::Complex",
],
);
assert_function_not_contains(
source,
"async fn request_editor_background_removal_image_with_bgfilter_worker",
"pub(crate) struct EditorScreenBackgroundRemovalOutput",
&[
"for attempt in",
"will_retry",
"fallback_editor_screen_background_removal",
],
);
}
#[test]
fn editor_matting_releases_source_buffers_at_oss_boundaries() {
let source = include_str!("editor_project.rs");
for (start, end) in [
(
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
),
(
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
),
(
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
),
] {
assert_function_contains_in_order(
source,
start,
end,
&[
"persist_editor_provider_source_image",
"remove_editor_generated_screen_background_with_bgfilter",
"apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original",
"persist_editor_generated_image",
],
);
}
assert_function_contains(
source,
"async fn persist_editor_provider_source_image",
"async fn persist_editor_provider_source_resource",
&["persist_editor_generated_image_owned"],
);
assert_function_contains_in_order(
source,
"async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original",
"fn restore_editor_generated_image_output_dimensions",
&[
"into_dimensions",
"return (postprocessed, None)",
"download_editor_persisted_image_object",
"apply_editor_postprocessed_alpha_to_provider_source",
],
);
for (start, end) in [
(
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
),
(
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
),
] {
assert_function_contains(
source,
start,
end,
&["bytes: image.bytes.clone()", "slice_source"],
);
assert_function_not_contains(
source,
start,
end,
&[
"download_editor_persisted_image_object",
"drop(slice_source)",
],
);
}
assert_function_not_contains(
source,
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"async fn fallback_editor_screen_background_removal",
&["fallback_image"],
);
assert_function_contains(
source,
"async fn persist_editor_generated_image_owned",
"async fn persist_editor_generated_image_data",
&["bytes: image.bytes"],
);
assert_function_not_contains(
source,
"async fn persist_editor_generated_image_owned",
"async fn persist_editor_generated_image_data",
&["image.bytes.clone()"],
);
assert_function_contains(
source,
"async fn read_editor_reference_image_object",
"async fn download_editor_persisted_image_object",
&["response.chunk().await", "bytes.extend_from_slice"],
);
assert_function_not_contains(
source,
"async fn read_editor_reference_image_object",
"async fn download_editor_persisted_image_object",
&["response.bytes().await", "bytes.to_vec()"],
);
}
#[test]
fn editor_paid_image_postprocess_keeps_provider_outputs_recoverable() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
&["EDITOR_ASSET_DEFAULT_FOLDER_ID"],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn edit_editor_image_for_owner",
"pub async fn remove_editor_image_background",
&[
"let provider_image",
"restore_editor_image_edit_output_dimensions_or_original",
"persist_editor_generated_image",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
&[
"let mut image",
"restore_editor_generated_image_output_dimensions_or_original",
"persist_editor_generated_image",
],
);
assert_function_occurrence_count(
source,
"pub(crate) async fn edit_editor_image_for_owner",
"pub async fn remove_editor_image_background",
"persist_editor_generated_image(",
1,
);
assert_function_occurrence_count(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
"persist_editor_generated_image(",
1,
);
assert_function_contains_in_order(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&[
"persist_editor_provider_source_resource",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND",
"persist_editor_generated_asset",
"slice_generated_icon_spritesheet_all_by_connected_components",
"persist_editor_spritesheet_slices",
],
);
assert_function_contains(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&[
"EDITOR_ASSET_DEFAULT_FOLDER_ID",
"EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS",
"EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE",
],
);
}
#[test]
fn generated_asset_folder_id_normalizes_project_and_legacy_local_folder() {
assert_eq!(
normalize_generated_asset_folder_id(Some("project".to_string()), "user-1"),
Some("user-1:asset-folder:project".to_string())
);
assert_eq!(
normalize_generated_asset_folder_id(Some(" folder-temporary ".to_string()), "user-1"),
Some("user-1:asset-folder:project".to_string())
);
assert_eq!(
normalize_generated_asset_folder_id(Some("folder-real".to_string()), "user-1"),
Some("user-1:asset-folder:project".to_string())
);
assert_eq!(
normalize_generated_asset_folder_id(
Some("user-1:asset-folder:project".to_string()),
"user-1",
),
Some("user-1:asset-folder:project".to_string())
);
assert_eq!(normalize_generated_asset_folder_id(None, "user-1"), None);
}
fn assert_function_contains(source: &str, start: &str, end: &str, snippets: &[&str]) {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let tail = &source[start_index..];
let end_index = tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &tail[..end_index];
for snippet in snippets {
assert!(
body.contains(snippet),
"function body starting with {start} should contain {snippet}"
);
}
}
fn assert_function_contains_in_order(source: &str, start: &str, end: &str, snippets: &[&str]) {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let tail = &source[start_index..];
let end_index = tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &tail[..end_index];
let mut cursor = 0;
for snippet in snippets {
let next = body[cursor..].find(snippet).unwrap_or_else(|| {
panic!(
"function body starting with {start} should contain {snippet} after offset {cursor}"
)
});
cursor += next + snippet.len();
}
}
fn assert_function_not_contains(source: &str, start: &str, end: &str, snippets: &[&str]) {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let tail = &source[start_index..];
let end_index = tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &tail[..end_index];
for snippet in snippets {
assert!(
!body.contains(snippet),
"function body starting with {start} should not contain {snippet}"
);
}
}
fn assert_function_occurrence_count(
source: &str,
start: &str,
end: &str,
snippet: &str,
expected_count: usize,
) {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let tail = &source[start_index..];
let end_index = tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &tail[..end_index];
assert_eq!(
body.matches(snippet).count(),
expected_count,
"function body starting with {start} should contain {snippet} exactly {expected_count} times"
);
}
}