Files
Genarrative/server-rs/crates/api-server/src/editor_project.rs
T
kdletters 4e7bcb8f24 补齐生成中间产物持久化与拆图提示
图标和UI图集拆分失败时保留主图集并在内联、队列及刷新路径提示
角色、图片修改、图标UI和角色动作链路把可恢复中间产物写入项目与素材库
外部生成摘要新增独立告警字段并清理成功任务的历史错误
同步共享契约、SpacetimeDB迁移与绑定、OpenAPI、设计文档和回归测试
2026-07-13 21:41:46 +08:00

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