4e7bcb8f24
图标和UI图集拆分失败时保留主图集并在内联、队列及刷新路径提示 角色、图片修改、图标UI和角色动作链路把可恢复中间产物写入项目与素材库 外部生成摘要新增独立告警字段并清理成功任务的历史错误 同步共享契约、SpacetimeDB迁移与绑定、OpenAPI、设计文档和回归测试
9244 lines
342 KiB
Rust
9244 lines
342 KiB
Rust
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}"
|
||
);
|
||
}
|
||
}
|
||
}
|