Files
Genarrative/server-rs/crates/api-server/src/editor_project.rs
T
kdletters d0278f0370 热修图片画布布局上限至2MiB
将 API 与 SpacetimeDB 的画布布局校验上限统一提高到 2 MiB
为站内与外部画布保存接口增加 4 MiB 请求体上限
补充 2 MiB 边界通过和超限拒绝测试
2026-07-19 17:54:09 +08:00

11081 lines
413 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
borrow::Cow,
collections::BTreeMap,
io::Cursor,
sync::{Mutex, OnceLock},
time::{Duration, Instant},
};
use axum::{
Json,
extract::{Extension, Path, Query, State},
http::StatusCode,
};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input,
generate_asset_object_id,
};
use platform_image::{
DownloadedImage,
generated_asset_sheets::{
GeneratedAssetSheetConnectedIcon,
slice_generated_icon_spritesheet_all_by_connected_components,
slice_generated_icon_spritesheet_by_connected_components,
},
};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::assets::{
EDITOR_ASSET_LABEL_MAX_CHARS,
EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest,
EditorCanvasGenerationPlaceholderPayload,
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord,
EditorAssetFolderUpdateRecordInput, EditorAssetGroupCohortCompleteRecordInput,
EditorAssetGroupSourceLookupRecordInput, EditorAssetLibraryRecord,
EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput,
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput,
EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput,
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput,
EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord,
ExternalGenerationJobPhaseUpdateError, ExternalGenerationJobPhaseUpdateRecordInput,
SpacetimeClientError,
};
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation_with_cost,
auth::AuthenticatedAccessToken,
editor_generation_queue::{
EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND,
EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND,
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EditorGenerationQueuedResponse,
editor_generation_queue_state, editor_generation_source_entity_id,
enqueue_editor_generation_job,
},
editor_green_screen::{
EditorScreenBackgroundColor, editor_green_screen_asset_prompt_clause,
editor_green_screen_character_prompt_clause, editor_ui_design_asset_extraction_prompt,
remove_editor_generated_green_screen_background,
},
editor_screen_background_decision::{
EditorScreenBackgroundDecision, EditorScreenBackgroundDecisionInput,
EditorScreenBackgroundDecisionKind, resolve_editor_screen_background_color,
},
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
decode_generated_image_asset_data_url, normalize_generated_image_asset_mime,
},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiReferenceImage,
build_openai_image_http_client, create_openai_image_edit_with_references_and_model,
create_openai_image_generation_with_model, create_openai_nanobanana_generate_content,
require_openai_image_settings,
},
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
work_author::{ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID},
};
const EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE: usize = 36;
pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "proj-";
pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
pub(crate) const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-";
pub(crate) const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
const EDITOR_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024;
pub(crate) const EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES: usize = 4 * 1024 * 1024;
pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
const EDITOR_ASSET_METADATA_MAX_BYTES: usize = 64 * 1024;
const EDITOR_ASSET_DEFAULT_FOLDER_ID: &str = "project";
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION: u32 = 4096;
const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS: u64 = 2048 * 2048;
const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES: usize = 64;
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5;
const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image";
const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project";
const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character";
const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image";
const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image";
const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image";
const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image";
const EDITOR_IMAGE_EDIT_ASSET_KIND: &str = "editor_image_edit";
const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet";
const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice";
const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components";
const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed";
const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved";
const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1;
const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet";
const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset";
const EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source";
const EDITOR_PROVIDER_SOURCE_SLOT: &str = "provider_source";
pub(crate) const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet";
const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg";
const EDITOR_BGFILTER_BACKGROUND_MODE_FLAT: &str = "flat";
const EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX: &str = "complex";
pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_ENABLED: bool = true;
pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_DISABLED: bool = false;
const EDITOR_BGFILTER_RETRY_COUNT: usize = 1;
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) aspect_ratio: Option<String>,
pub(crate) image_size: Option<String>,
pub(crate) reference_image_srcs: Option<Vec<String>>,
pub(crate) project_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) asset_label: Option<String>,
pub(crate) source_resource_id: Option<String>,
pub(crate) target_layer_id: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorBackgroundRemovalRequest {
pub(crate) source_image_src: String,
pub(crate) project_id: Option<String>,
pub(crate) target_layer_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) asset_label: Option<String>,
pub(crate) source_resource_id: Option<String>,
pub(crate) task_id: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetGenerationRequest {
pub(crate) reference_image_src: String,
pub(crate) reference_image_srcs: Option<Vec<String>>,
pub(crate) icon_descriptions: Vec<String>,
pub(crate) model: Option<String>,
pub(crate) screen_color: Option<String>,
pub(crate) seg_model: Option<String>,
pub(crate) aspect_ratio: Option<String>,
pub(crate) image_size: Option<String>,
pub(crate) project_id: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) asset_label: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetSliceRequest {
pub(crate) project_id: String,
pub(crate) source_resource_id: String,
pub(crate) asset_folder_id: Option<String>,
pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorUiDesignAssetExtractionRequest {
pub(crate) source_image_src: String,
pub(crate) model: Option<String>,
pub(crate) screen_color: Option<String>,
pub(crate) seg_model: Option<String>,
pub(crate) reference_image_srcs: Option<Vec<String>>,
pub(crate) aspect_ratio: String,
pub(crate) image_size: String,
pub(crate) project_id: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) spritesheet_label: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
pub(crate) struct EditorGenerationCaller {
pub(crate) owner_user_id: String,
pub(crate) audit_subject_user_id: Option<String>,
pub(crate) audit_project_id: Option<String>,
pub(crate) phase_reporter: Option<EditorGenerationPhaseReporter>,
}
impl EditorGenerationCaller {
fn from_authenticated(authenticated: &AuthenticatedAccessToken) -> Self {
let owner_user_id = authenticated.claims().user_id().to_string();
Self {
audit_subject_user_id: Some(owner_user_id.clone()),
owner_user_id,
audit_project_id: None,
phase_reporter: None,
}
}
async fn report_processing_phase(&self, state: &AppState) -> Result<(), AppError> {
if let Some(reporter) = self.phase_reporter.as_ref() {
reporter.report_processing(state).await?;
}
Ok(())
}
}
#[derive(Clone, Debug)]
pub(crate) struct EditorGenerationPhaseReporter {
job_id: String,
worker_id: String,
lease_token: String,
}
impl EditorGenerationPhaseReporter {
pub(crate) fn new(job_id: String, worker_id: String, lease_token: String) -> Self {
Self {
job_id,
worker_id,
lease_token,
}
}
pub(crate) async fn report_processing(&self, state: &AppState) -> Result<(), AppError> {
let input = ExternalGenerationJobPhaseUpdateRecordInput {
job_id: self.job_id.clone(),
worker_id: self.worker_id.clone(),
lease_token: self.lease_token.clone(),
phase: "processing".to_string(),
};
let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1;
for attempt in 1..=max_attempts {
match state
.spacetime_client()
.update_external_generation_job_phase(input.clone())
.await
{
Ok(_) => return Ok(()),
Err(error) => {
let will_retry =
should_retry_editor_generation_phase_update(&error, attempt, max_attempts);
tracing::warn!(
provider = "external-generation-phase",
job_id = %self.job_id,
attempt,
max_attempts,
will_retry,
lease_fencing_rejected = error.is_lease_fencing_rejected(),
error = %error,
"external_generation_processing_phase_report_failed"
);
if will_retry {
continue;
}
return Err(map_editor_generation_phase_update_error(error));
}
}
}
unreachable!("phase report retry loop should return on success or final error")
}
}
fn should_retry_editor_generation_phase_update(
error: &ExternalGenerationJobPhaseUpdateError,
attempt: usize,
max_attempts: usize,
) -> bool {
error.is_retryable_transport() && attempt < max_attempts
}
fn map_editor_generation_phase_update_error(
error: ExternalGenerationJobPhaseUpdateError,
) -> AppError {
let status = if error.is_lease_fencing_rejected() {
StatusCode::CONFLICT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": "external-generation-phase",
"message": format!("更新外部生成任务处理阶段失败:{error}"),
}))
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResponse {
project: EditorProjectPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectLayoutSaveResponse {
project_id: String,
canvas_id: String,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectRecentResponse {
project: Option<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectListResponse {
projects: Vec<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectDeleteResponse {
deleted_project_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourceResponse {
resource: EditorProjectResourcePayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourceListResponse {
resources: Vec<EditorProjectResourcePayload>,
next_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
campaign: Option<EditorShowcaseCampaignPayload>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourceListQuery {
cursor: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorAssetLibraryResponse {
library: EditorAssetLibraryPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorAssetFolderResponse {
folder: EditorAssetFolderPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorAssetFolderDeleteResponse {
library: EditorAssetLibraryPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorAssetResponse {
asset: EditorAssetPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorShowcaseAssetResponse {
showcase_asset: EditorProjectResourcePayload,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorShowcaseAssetLikeRequest {
liked: bool,
}
#[derive(Debug)]
pub(crate) struct EditorGeneratedAssetRecord {
pub(crate) resource: Option<EditorProjectResourcePayload>,
pub(crate) asset: Option<EditorAssetPayload>,
}
struct EditorCanvasGenerationCompletionResult {
layers: Value,
changed: bool,
}
#[derive(Debug)]
pub(crate) struct PersistEditorGeneratedAssetInput {
pub(crate) project_id: Option<String>,
pub(crate) owner_user_id: String,
pub(crate) folder_id: Option<String>,
pub(crate) label: String,
pub(crate) image_src: String,
pub(crate) object_key: Option<String>,
pub(crate) asset_object_id: Option<String>,
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) prompt: String,
pub(crate) actual_prompt: Option<String>,
pub(crate) model: String,
pub(crate) provider: String,
pub(crate) task_id: String,
pub(crate) group_task_id: Option<String>,
pub(crate) group_task_expected_asset_count: Option<u32>,
pub(crate) source_resource_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) thumbnail_src: Option<String>,
pub(crate) generation_cost_mud_points: u64,
}
pub(crate) struct PersistEditorGeneratedAssetRequest {
pub(crate) project_id: Option<String>,
pub(crate) owner_user_id: String,
pub(crate) folder_id: Option<String>,
pub(crate) label: String,
pub(crate) image_src: String,
pub(crate) object_key: Option<String>,
pub(crate) asset_object_id: Option<String>,
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) prompt: String,
pub(crate) actual_prompt: Option<String>,
pub(crate) model: String,
pub(crate) provider: String,
pub(crate) task_id: String,
pub(crate) source_resource_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) thumbnail_src: Option<String>,
pub(crate) generation_cost_mud_points: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct EditorGeneratedImageStorageProfile {
asset_kind: &'static str,
path_kind: &'static str,
slot: &'static str,
}
pub(crate) struct EditorCanvasGeneratedLayerInput {
pub(crate) layer_id: String,
pub(crate) resource_id: String,
pub(crate) title: String,
pub(crate) src: String,
pub(crate) media_type: Option<String>,
pub(crate) thumbnail_src: Option<String>,
pub(crate) original_width: u32,
pub(crate) original_height: u32,
pub(crate) display_width: f64,
pub(crate) display_height: f64,
pub(crate) source_type: String,
pub(crate) prompt: Option<String>,
pub(crate) actual_prompt: Option<String>,
pub(crate) model: Option<String>,
pub(crate) provider: Option<String>,
pub(crate) task_id: Option<String>,
pub(crate) object_key: Option<String>,
pub(crate) asset_object_id: Option<String>,
pub(crate) duration_seconds: Option<u32>,
pub(crate) source_resource_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) image_sequence_frames: Option<Value>,
pub(crate) preview_video_path: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorGenerationWarningResponse {
code: &'static str,
reason: String,
}
fn editor_postprocess_fallback_warning(reason: &'static str) -> EditorGenerationWarningResponse {
EditorGenerationWarningResponse {
code: EDITOR_GENERATION_POSTPROCESS_WARNING_CODE,
reason: reason.to_string(),
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorImageGenerationResponse {
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: &'static str,
prompt: String,
actual_prompt: Option<String>,
model: String,
provider: &'static str,
task_id: String,
resource: Option<EditorProjectResourcePayload>,
asset: Option<EditorAssetPayload>,
project: Option<EditorProjectPayload>,
#[serde(skip_serializing_if = "Option::is_none")]
warning: Option<EditorGenerationWarningResponse>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorBackgroundRemovalResponse {
image_src: String,
object_key: String,
asset_object_id: String,
width: u32,
height: u32,
source_type: &'static str,
task_id: String,
elapsed_ms: u64,
provider: &'static str,
resource: Option<EditorProjectResourcePayload>,
asset: Option<EditorAssetPayload>,
project: Option<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetIconResponse {
name: String,
image_src: String,
width: u32,
height: u32,
resource: Option<EditorProjectResourcePayload>,
asset: Option<EditorAssetPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetSliceWarningResponse {
code: &'static str,
reason: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetGenerationResponse {
spritesheet_image_src: String,
spritesheet_width: u32,
spritesheet_height: u32,
icon_image_srcs: Vec<EditorIconSpritesheetIconResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
slice_warning: Option<EditorIconSpritesheetSliceWarningResponse>,
prompt: String,
actual_prompt: Option<String>,
model: String,
provider: &'static str,
task_id: String,
price_mud_points: u32,
spritesheet_resource: Option<EditorProjectResourcePayload>,
spritesheet_asset: Option<EditorAssetPayload>,
project: Option<EditorProjectPayload>,
#[serde(skip_serializing_if = "Option::is_none")]
warning: Option<EditorGenerationWarningResponse>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetSliceResponse {
icon_image_srcs: Vec<EditorIconSpritesheetIconResponse>,
project: EditorProjectPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectPayload {
project_id: String,
title: String,
canvas: EditorCanvasPayload,
viewport: EditorCanvasViewportPayload,
layers: Value,
resources: Vec<EditorProjectResourcePayload>,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorCanvasPayload {
canvas_id: String,
project_id: String,
title: String,
viewport: EditorCanvasViewportPayload,
layers: Value,
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,
group_task_id: None,
group_task_expected_asset_count: None,
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorAssetResponse {
asset: editor_asset_payload_from_record(asset),
},
))
}
pub async fn update_editor_asset(
State(state): State<AppState>,
Path(asset_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorAssetUpdateRequest>,
) -> Result<Json<Value>, AppError> {
let asset = state
.spacetime_client()
.update_editor_asset(EditorAssetUpdateRecordInput {
asset_id,
owner_user_id: current_owner_user_id(&authenticated),
label: normalize_optional_string(payload.label),
folder_id: normalize_optional_string(payload.folder_id),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorAssetResponse {
asset: editor_asset_payload_from_record(asset),
},
))
}
pub async fn delete_editor_asset(
State(state): State<AppState>,
Path(asset_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let asset = state
.spacetime_client()
.delete_editor_asset(EditorAssetDeleteRecordInput {
asset_id,
owner_user_id: current_owner_user_id(&authenticated),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorAssetResponse {
asset: editor_asset_payload_from_record(asset),
},
))
}
pub async fn submit_editor_asset_showcase(
State(state): State<AppState>,
Path(asset_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let record = state
.spacetime_client()
.submit_editor_showcase_asset(EditorShowcaseAssetSubmitRecordInput {
asset_id,
owner_user_id: current_owner_user_id(&authenticated),
now_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorShowcaseAssetResponse {
showcase_asset: editor_project_resource_payload_from_showcase_record(record),
},
))
}
pub async fn toggle_editor_showcase_asset_like(
State(state): State<AppState>,
Path(showcase_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorShowcaseAssetLikeRequest>,
) -> Result<Json<Value>, AppError> {
let record = state
.spacetime_client()
.toggle_editor_showcase_asset_like(EditorShowcaseAssetLikeToggleRecordInput {
showcase_id,
user_id: current_owner_user_id(&authenticated),
liked: payload.liked,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorShowcaseAssetResponse {
showcase_asset: editor_project_resource_payload_from_showcase_record(record),
},
))
}
pub async fn generate_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorImageGenerationRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-image-generation",
"referenceImageSrcs",
"生成参考图",
)?;
let normalized_kind = payload.kind.as_deref().map(str::trim);
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
let is_publication_material_generation =
matches!(normalized_kind, Some("publication-material"));
let generation_options = normalize_editor_generation_options(
if is_ui_design_generation || is_publication_material_generation {
Some(GPT_IMAGE_2_MODEL)
} else {
payload.model.as_deref()
},
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let price_mud_points = u64::from(
state
.editor_generation_pricing()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(
normalized_kind,
Some(generation_options.model),
Some(generation_options.image_size),
),
);
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-image-generation",
);
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
caller.owner_user_id.as_str(),
EDITOR_IMAGE_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成图片",
price_mud_points,
&payload,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
generate_editor_image_for_owner(&state, &request_context, caller, payload).await
}
pub(crate) async fn generate_editor_image_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorImageGenerationRequest,
) -> Result<Json<Value>, AppError> {
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-image-generation",
"referenceImageSrcs",
"生成参考图",
)?;
let role_setting = payload.prompt.trim().to_string();
if role_setting.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-generation",
"message": "生成提示词不能为空",
})),
);
}
let normalized_kind = payload.kind.as_deref().map(str::trim);
let is_character_generation = matches!(normalized_kind, Some("character"));
// 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时
// 仍白发一次 gpt-5-mini 决策。这里先固化决策需要、但随后会被 payload 消费掉的输入。
let requested_screen_color = payload.screen_color.clone();
let reference_count = payload.reference_image_srcs.as_ref().map_or(0, Vec::len);
let seg_model = if is_character_generation {
Some(parse_editor_bgfilter_seg_model(
payload.seg_model.as_deref(),
)?)
} else {
None
};
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
let is_publication_material_generation =
matches!(normalized_kind, Some("publication-material"));
let generation_options = normalize_editor_generation_options(
if is_ui_design_generation || is_publication_material_generation {
Some(GPT_IMAGE_2_MODEL)
} else {
payload.model.as_deref()
},
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some();
let image_size = resolve_editor_image_request_size(
normalized_kind,
payload.size.as_deref(),
has_dimension_options,
&generation_options,
);
let failure_context = match normalized_kind {
Some("character") => "图片画布生成角色形象",
Some("spec") => "图片画布生成规范",
Some("quick-edit") => "图片画布快速编辑图片",
Some("ui-design") => "图片画布生成UI设计图",
Some("publication-material") => "图片画布生成宣发素材",
_ => "图片画布生成图片",
};
let reference_limit = if matches!(normalized_kind, Some("quick-edit")) {
9
} else {
5
};
let reference_sources = payload
.reference_image_srcs
.unwrap_or_default()
.into_iter()
.map(|source| source.trim().to_string())
.filter(|source| !source.is_empty())
.take(reference_limit)
.collect::<Vec<_>>();
// 决策移入闭包后 payload.reference_image_srcs 已被 reference_sources 消费,
// 这里从过滤后的 reference_sources 预先固化 ui-design 的「是否带参考图」布尔。
let ui_has_references = !reference_sources.is_empty();
let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体");
let reference_images = if reference_sources.is_empty() {
Vec::new()
} else {
let mut parsed_reference_images = Vec::with_capacity(reference_sources.len());
for source in &reference_sources {
parsed_reference_images.push(
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source.as_str())
.await?,
);
}
parsed_reference_images
};
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let image_size = if is_ui_design_generation {
Cow::Owned(generation_options.size.clone())
} else {
image_size
};
let 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 asset_label =
resolve_editor_generated_asset_label(payload.asset_label.clone(), role_setting.as_str());
let mut image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图片",
}))
})?;
let (provider_width, provider_height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("素材生成结果不是有效图片:{error}"),
}))
})?;
let storage_profile = editor_generated_image_storage_profile(normalized_kind);
let mut output_prompt = submitted_prompt.clone();
let mut output_actual_prompt = generated.actual_prompt.clone();
let mut output_model = generation_options.model.to_string();
let mut output_provider = "VectorEngine".to_string();
let mut output_generation_inputs = generation_inputs.clone();
let source_record = if is_character_generation {
let source_persisted = persist_editor_provider_source_image(
state,
caller.owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
submitted_prompt.as_str(),
generated.actual_prompt.as_deref(),
storage_profile.asset_kind,
"character-images",
"character-image",
)
.await?;
let source_image_src =
editor_media_src_from_object_key(source_persisted.object_key.as_str());
let source_object_key = source_persisted.object_key.clone();
let source_asset_object_id = source_persisted.asset_object_id.clone();
let source_record = persist_editor_provider_source_resource(
state,
source_persisted,
PersistEditorProviderSourceResourceInput {
project_id: payload.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: payload
.asset_folder_id
.clone()
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
label: editor_generated_asset_variant_label(asset_label.as_str(), "原图"),
width: provider_width,
height: provider_height,
prompt: role_setting.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
task_id: generated.task_id.clone(),
source_resource_id: payload.source_resource_id.clone(),
asset_kind: payload
.asset_kind
.clone()
.or_else(|| Some("character".to_string())),
generation_inputs: generation_inputs.clone(),
generation_cost_mud_points: u64::from(configured_price_mud_points),
},
)
.await?;
caller.report_processing_phase(state).await?;
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
};
let removal = 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"),
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
&matting_audit,
)
.await;
let removal = match removal {
Ok(removal) => removal,
Err(error) => {
let failure_message = error.body_text();
tracing::warn!(
provider = "editor-character-image",
operation = "background_postprocess",
task_id = %generated.task_id,
error = %failure_message,
"角色原图已保存,但透明背景处理失败,使用原图完成画布"
);
let completed_project = complete_editor_canvas_generation(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
source_record.resource.as_ref(),
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src: source_image_src,
object_key: Some(source_object_key),
asset_object_id: Some(source_asset_object_id),
width: provider_width,
height: provider_height,
source_type: "generated",
prompt: role_setting,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
resource: source_record.resource,
asset: source_record.asset,
project: completed_project,
warning: Some(editor_postprocess_fallback_warning(
"生成任务成功,后处理失败。",
)),
},
));
}
};
image = removal.image;
output_prompt = "去除纯色背景".to_string();
output_actual_prompt = None;
output_model = removal.model;
output_provider = removal.provider.to_string();
output_generation_inputs =
build_editor_derived_asset_generation_inputs("角色抠图", &output_model, &source_record);
Some(source_record)
} else {
None
};
// TODO the image size passed to api is already normalized, should remove those normalize
// and let resize here to get the proper size
let (restored_image, dimension_restore_error) =
restore_editor_generated_image_output_dimensions_or_original(
image,
generation_options.model,
image_size.as_ref(),
);
if let Some(error) = dimension_restore_error {
tracing::warn!(
task_id = %generated.task_id,
error = ?error,
"画板生成图片尺寸恢复失败,保留并上传 provider 原图"
);
}
image = restored_image;
let (width, height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((1024, 1024));
let persisted = persist_editor_generated_image(
state,
caller.owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
output_prompt.as_str(),
output_actual_prompt.as_deref(),
storage_profile.asset_kind,
storage_profile.path_kind,
"image",
storage_profile.slot,
output_provider.as_str(),
)
.await?;
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let provider_source_resource_id = source_record
.as_ref()
.and_then(|record| record.resource.as_ref())
.map(|resource| resource.resource_id.clone());
let generated_asset = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: payload.asset_folder_id,
label: asset_label,
image_src: image_src.clone(),
object_key: Some(persisted.object_key.clone()),
asset_object_id: Some(persisted.asset_object_id.clone()),
width,
height,
prompt: output_prompt,
actual_prompt: output_actual_prompt,
model: output_model,
provider: output_provider,
task_id: generated.task_id.clone(),
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: provider_source_resource_id.or(payload.source_resource_id),
asset_kind: payload.asset_kind,
generation_inputs: output_generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: if is_character_generation {
0
} else {
u64::from(configured_price_mud_points)
},
},
)
.await?;
let (canvas_items, primary_layer_id) =
if let Some(completion) = payload.canvas_completion.as_ref() {
build_multi_asset_canvas_layer_items(
completion,
generated_asset.resource.as_ref(),
source_record
.as_ref()
.and_then(|record| record.resource.as_ref()),
)
} else {
(Vec::new(), None)
};
let completed_project = complete_editor_canvas_generation_with_items(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
canvas_items,
primary_layer_id,
)
.await?;
Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
width,
height,
source_type: "generated",
prompt: role_setting,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
resource: generated_asset.resource,
asset: generated_asset.asset,
project: completed_project,
warning: None,
},
))
}
fn editor_image_generation_billing_asset_kind(normalized_kind: Option<&str>) -> &'static str {
match normalized_kind {
Some("character") => EDITOR_CHARACTER_IMAGE_ASSET_KIND,
Some("spec") => EDITOR_SPEC_IMAGE_ASSET_KIND,
Some("quick-edit") => EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND,
Some("ui-design") => EDITOR_UI_DESIGN_IMAGE_ASSET_KIND,
Some("publication-material") => EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
_ => EDITOR_GENERATED_IMAGE_ASSET_KIND,
}
}
pub(crate) fn apply_editor_screen_background_decision_to_generation_inputs(
generation_inputs: Option<Value>,
decision: Option<&EditorScreenBackgroundDecision>,
) -> Option<Value> {
let Some(decision) = decision else {
return generation_inputs;
};
let mut value = match generation_inputs {
Some(value) if value.is_object() => value,
Some(other) => return Some(other),
None => json!({ "fields": [], "references": [] }),
};
// 抠图背景色是内部自动决策 / 兜底细节,产品契约要求不进用户可见的 generationInputs.fields;
// 只写顶层内部可追踪元数据 screenColorHex(不属于用户输入快照,前端展示不读取)。
value["screenColorHex"] = Value::String(decision.color.hex.to_string());
Some(value)
}
fn build_editor_derived_asset_generation_inputs(
stage: &str,
model: &str,
source: &EditorGeneratedAssetRecord,
) -> Option<Value> {
let reference = source
.resource
.as_ref()
.map(|resource| {
json!({
"title": "输入素材",
"label": source
.asset
.as_ref()
.map(|asset| asset.label.as_str())
.unwrap_or("上一步产物"),
"refType": "project-resource",
"refId": resource.resource_id,
})
})
.or_else(|| {
source.asset.as_ref().map(|asset| {
json!({
"title": "输入素材",
"label": asset.label,
"refType": "asset",
"refId": asset.asset_id,
})
})
});
Some(json!({
"fields": [
{ "title": "处理阶段", "value": stage },
{ "title": "处理模型", "value": model },
],
"references": reference.into_iter().collect::<Vec<_>>(),
}))
}
fn editor_generated_image_storage_profile(
normalized_kind: Option<&str>,
) -> EditorGeneratedImageStorageProfile {
match normalized_kind {
Some("character") => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_CHARACTER_IMAGE_ASSET_KIND,
path_kind: "character-images",
slot: EDITOR_CHARACTER_IMAGE_SLOT,
},
Some("spec") => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_SPEC_IMAGE_ASSET_KIND,
path_kind: "spec-images",
slot: "spec",
},
Some("quick-edit") => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND,
path_kind: "quick-edit-images",
slot: "quick-edit",
},
Some("ui-design") => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_UI_DESIGN_IMAGE_ASSET_KIND,
path_kind: "ui-design-images",
slot: "ui-design",
},
Some("publication-material") => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
path_kind: "publication-materials",
slot: "publication-material",
},
_ => EditorGeneratedImageStorageProfile {
asset_kind: EDITOR_GENERATED_IMAGE_ASSET_KIND,
path_kind: "generated-images",
slot: "image",
},
}
}
async fn resolve_editor_image_edit_price(
state: &AppState,
model: &str,
size: &str,
image_size: Option<&str>,
) -> Result<u32, AppError> {
let price_size = if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
image_size
.map(|value| normalize_editor_generation_image_size(model, Some(value)))
.unwrap_or_else(|| editor_image_price_size_from_pixels(size))
} else {
editor_image_price_size_from_pixels(size)
};
let expected_price_mud_points = state
.editor_generation_pricing()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(Some("quick-edit"), Some(model), Some(price_size));
Ok(expected_price_mud_points)
}
fn editor_image_price_size_from_pixels(size: &str) -> &'static str {
let Some((width, height)) = size.split_once('x') else {
return "1K";
};
let Ok(width) = width.parse::<u32>() else {
return "1K";
};
let Ok(height) = height.parse::<u32>() else {
return "1K";
};
if width.max(height) > 1536 { "2K" } else { "1K" }
}
fn editor_image_edit_uses_nanobanana_generate_content(model: &str) -> bool {
model == EDITOR_IMAGE_MODEL_NANOBANANA2
}
fn normalize_editor_image_edit_generation_options(
model: Option<&str>,
aspect_ratio: Option<&str>,
image_size: Option<&str>,
legacy_size: Option<&str>,
) -> EditorGenerationOptions {
let model_options = normalize_editor_generation_options(model, None, None);
let inferred_aspect_ratio = aspect_ratio
.filter(|value| !value.trim().is_empty())
.or_else(|| infer_editor_image_edit_aspect_ratio(legacy_size));
let inferred_image_size = image_size
.filter(|value| !value.trim().is_empty())
.or_else(|| infer_editor_image_edit_size_tier(model_options.model, legacy_size));
normalize_editor_generation_options(model, inferred_aspect_ratio, inferred_image_size)
}
fn infer_editor_image_edit_aspect_ratio(size: Option<&str>) -> Option<&'static str> {
let (width, height) = parse_editor_image_edit_pixel_size(size?)?;
let source_ratio = f64::from(width) / f64::from(height);
[
("1:1", 1.0),
("4:3", 4.0 / 3.0),
("3:2", 3.0 / 2.0),
("2:3", 2.0 / 3.0),
("9:16", 9.0 / 16.0),
("16:9", 16.0 / 9.0),
]
.into_iter()
.min_by(|(_, left), (_, right)| {
(left - source_ratio)
.abs()
.total_cmp(&(right - source_ratio).abs())
})
.map(|(label, _)| label)
}
fn infer_editor_image_edit_size_tier(model: &str, size: Option<&str>) -> Option<&'static str> {
let (width, height) = parse_editor_image_edit_pixel_size(size?)?;
let long_edge = width.max(height);
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 && long_edge <= 768 {
return Some("0.5K");
}
Some(if long_edge > 1536 { "2K" } else { "1K" })
}
fn parse_editor_image_edit_pixel_size(size: &str) -> Option<(u32, u32)> {
let normalized = size.trim().replace('*', "x");
let (width, height) = normalized.split_once('x')?;
let width = width.parse::<u32>().ok()?;
let height = height.parse::<u32>().ok()?;
(width > 0 && height > 0).then_some((width, height))
}
fn normalize_editor_image_generation_size(size: Option<&str>) -> Cow<'static, str> {
match size.map(str::trim).filter(|value| !value.is_empty()) {
Some("1024x1024") | Some("1024*1024") | Some("1:1") => Cow::Borrowed("1024x1024"),
Some("1536x1024") | Some("1536*1024") | Some("16:9") => Cow::Borrowed("1536x1024"),
Some("2048x1152") | Some("2048*1152") | Some("1920x1080") | Some("1920*1080")
| Some("2k-16:9") => Cow::Borrowed("2048x1152"),
Some("1024x1536") | Some("1024*1536") | Some("9:16") => Cow::Borrowed("1024x1536"),
Some(value) if is_editor_custom_image_size(value) => Cow::Owned(value.to_string()),
_ => Cow::Borrowed(EDITOR_IMAGE_GENERATION_SIZE),
}
}
fn resolve_editor_image_request_size(
normalized_kind: Option<&str>,
payload_size: Option<&str>,
has_dimension_options: bool,
generation_options: &EditorGenerationOptions,
) -> Cow<'static, str> {
let legacy_size = normalize_editor_image_generation_size(payload_size);
let has_explicit_payload_size = payload_size
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if matches!(normalized_kind, Some("publication-material")) && has_explicit_payload_size {
return legacy_size;
}
if has_dimension_options {
return Cow::Owned(generation_options.size.clone());
}
legacy_size
}
fn 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 asset_kind != "icon" {
return Ok(());
}
Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "单个图标不支持快速编辑",
"assetKind": asset_kind,
})),
)
}
fn resolve_editor_image_edit_target_layer_asset_kind<'a>(
layers: &'a Value,
resources: &'a [EditorProjectResourcePayload],
target_layer_id: &str,
) -> Result<Option<&'a str>, AppError> {
let target_layer = layers
.as_array()
.and_then(|items| {
items
.iter()
.find(|item| item.get("layerId").and_then(Value::as_str) == Some(target_layer_id))
})
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "快速编辑目标图层不存在",
"targetLayerId": target_layer_id,
}))
})?;
if let Some(asset_kind) = target_layer.get("assetKind").and_then(Value::as_str) {
return Ok(Some(asset_kind));
}
let resource_id = target_layer.get("resourceId").and_then(Value::as_str);
Ok(resource_id.and_then(|resource_id| {
resources
.iter()
.find(|resource| resource.resource_id == resource_id)
.and_then(|resource| resource.asset_kind.as_deref())
}))
}
async fn ensure_editor_image_edit_source_allowed(
state: &AppState,
owner_user_id: &str,
payload: &EditorImageEditRequest,
) -> Result<(), AppError> {
let Some(project_id) = payload
.project_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref());
};
let Some(target_layer_id) = payload
.target_layer_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref());
};
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.to_string(),
owner_user_id: owner_user_id.to_string(),
})
.await
.map_err(map_editor_project_error)?;
let project = editor_project_payload_from_record(project);
let asset_kind = resolve_editor_image_edit_target_layer_asset_kind(
&project.layers,
&project.resources,
target_layer_id,
)?
.or(payload.asset_kind.as_deref());
ensure_editor_image_edit_asset_kind_allowed(asset_kind)
}
fn encode_editor_image_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<Option<DownloadedOpenAiImage>, AppError> {
let decoded = image::load_from_memory(output.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("图片改造结果不是有效图片:{error}"),
}))
})?;
if decoded.width() == dimensions.target_width && decoded.height() == dimensions.target_height {
return Ok(None);
}
let restored = if decoded.width() == dimensions.provider_width
&& decoded.height() == dimensions.provider_height
{
decoded.crop_imm(0, 0, dimensions.target_width, dimensions.target_height)
} else {
decoded.resize_to_fill(
dimensions.target_width,
dimensions.target_height,
image::imageops::FilterType::Lanczos3,
)
};
Ok(Some(DownloadedOpenAiImage {
bytes: encode_editor_image_edit_png(
restored,
StatusCode::BAD_GATEWAY,
"恢复图片改造结果目标尺寸失败",
)?,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
}))
}
fn restore_editor_image_edit_output_dimensions_or_original(
output: DownloadedOpenAiImage,
dimensions: &EditorImageEditDimensions,
) -> (DownloadedOpenAiImage, Option<AppError>) {
match restore_editor_image_edit_output_dimensions(&output, dimensions) {
Ok(Some(restored)) => (restored, None),
Ok(None) => (output, None),
Err(error) => (output, Some(error)),
}
}
fn restore_editor_generated_image_output_dimensions_or_original(
output: DownloadedOpenAiImage,
model: &str,
target_size: &str,
) -> (DownloadedOpenAiImage, Option<AppError>) {
match restore_editor_generated_image_output_dimensions(&output, model, target_size) {
Ok(Some(restored)) => (restored, None),
Ok(None) => (output, None),
Err(error) => (output, Some(error)),
}
}
fn restore_editor_generated_image_output_dimensions(
output: &DownloadedOpenAiImage,
model: &str,
target_size: &str,
) -> Result<Option<DownloadedOpenAiImage>, AppError> {
// nanobanana 使用标量清晰度档位和独立比例;provider 回图已经是目标几何尺寸,
// 不能再把 512 / 1024 / 2K 当作 WIDTHxHEIGHT 解析。
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
return Ok(None);
}
let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "尺寸无效",
}))
})?;
let target_width = target_width.parse::<u32>().map_err(|_| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "宽度无效",
}))
})?;
let target_height = target_height.parse::<u32>().map_err(|_| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "高度无效",
}))
})?;
let decoded = image::load_from_memory(output.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("素材生成结果不是有效图片:{error}"),
}))
})?;
if decoded.width() == target_width && decoded.height() == target_height {
return Ok(None);
}
let restored = decoded.resize_to_fill(
target_width,
target_height,
image::imageops::FilterType::Lanczos3,
);
Ok(Some(DownloadedOpenAiImage {
bytes: encode_editor_image_edit_png(
restored,
StatusCode::BAD_GATEWAY,
"恢复宣发素材交付尺寸失败",
)?,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
}))
}
pub async fn edit_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorImageEditRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-image-edit",
"sourceImageSrc",
"待修改图片",
)?;
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-image-edit",
"referenceImageSrcs",
"修改参考图",
)?;
ensure_editor_image_edit_source_allowed(&state, caller.owner_user_id.as_str(), &payload)
.await?;
let generation_options = normalize_editor_image_edit_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
payload.size.as_deref(),
);
let image_size = normalize_editor_image_generation_size(payload.size.as_deref());
let price_mud_points = u64::from(
resolve_editor_image_edit_price(
&state,
generation_options.model,
image_size.as_ref(),
Some(generation_options.image_size),
)
.await?,
);
let source_entity_id =
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-image-edit");
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
caller.owner_user_id.as_str(),
EDITOR_IMAGE_EDIT_JOB_KIND,
source_entity_id,
"图片画布修改图片",
price_mud_points,
&payload,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
edit_editor_image_for_owner(&state, &request_context, caller, payload).await
}
pub(crate) async fn edit_editor_image_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorImageEditRequest,
) -> Result<Json<Value>, AppError> {
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-image-edit",
"sourceImageSrc",
"待修改图片",
)?;
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-image-edit",
"referenceImageSrcs",
"修改参考图",
)?;
ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?;
let prompt = payload.prompt.trim().to_string();
if prompt.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "修改提示词不能为空",
})),
);
}
let generation_options = normalize_editor_image_edit_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
payload.size.as_deref(),
);
let 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 =
if editor_image_edit_uses_nanobanana_generate_content(generation_options.model) {
None
} else {
Some(prepare_editor_image_edit_references(
&mut reference_images,
requested_image_size.as_ref(),
)?)
};
let provider_size = edit_dimensions
.map(|dimensions| {
format!(
"{}x{}",
dimensions.provider_width, dimensions.provider_height
)
})
.unwrap_or_else(|| generation_options.provider_image_size.to_string());
let expected_price_mud_points = resolve_editor_image_edit_price(
state,
generation_options.model,
requested_image_size.as_ref(),
Some(generation_options.image_size),
)
.await?;
let generated = if editor_image_edit_uses_nanobanana_generate_content(generation_options.model)
{
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_image_edit",
request_context.request_id(),
u64::from(expected_price_mud_points),
create_openai_nanobanana_generate_content(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
generation_options.aspect_ratio,
provider_size.as_str(),
reference_images.as_slice(),
"图片画布修改图片",
),
)
.await?
} else {
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_image_edit",
request_context.request_id(),
u64::from(expected_price_mud_points),
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
provider_size.as_str(),
1,
reference_images.as_slice(),
"图片画布修改图片",
),
)
.await?
};
let provider_image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图片",
}))
})?;
let (provider_width, provider_height) =
image::load_from_memory(provider_image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("图片改造结果不是有效图片:{error}"),
}))
})?;
let asset_label =
resolve_editor_generated_asset_label(payload.asset_label.clone(), "图片修改结果");
let (image, dimension_restore_error) = if let Some(edit_dimensions) = edit_dimensions.as_ref() {
restore_editor_image_edit_output_dimensions_or_original(provider_image, edit_dimensions)
} else {
(provider_image, None)
};
if let Some(error) = dimension_restore_error {
tracing::warn!(
task_id = %generated.task_id,
provider_width,
provider_height,
target_width = edit_dimensions.map(|dimensions| dimensions.target_width),
target_height = edit_dimensions.map(|dimensions| dimensions.target_height),
error = ?error,
"画板快速编辑尺寸恢复失败,保留并上传 provider 原图"
);
}
let (width, height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((1024, 1024));
let owner_user_id = caller.owner_user_id.clone();
let persisted = persist_editor_generated_image(
state,
owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_IMAGE_EDIT_ASSET_KIND,
"edit-images",
"image",
"edit",
"vector-engine",
)
.await?;
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let generated_asset = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: payload.asset_folder_id,
label: asset_label.clone(),
image_src: image_src.clone(),
object_key: Some(persisted.object_key.clone()),
asset_object_id: Some(persisted.asset_object_id.clone()),
width,
height,
prompt: prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
provider: "VectorEngine".to_string(),
task_id: generated.task_id.clone(),
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: payload.source_resource_id,
asset_kind: payload.asset_kind,
generation_inputs: payload.generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: u64::from(expected_price_mud_points),
},
)
.await?;
let completed_project = if payload.canvas_completion.is_some() {
complete_editor_canvas_generation(
state,
owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
generated_asset.resource.as_ref(),
)
.await?
} else {
complete_editor_canvas_background_removal(
state,
owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.target_layer_id.as_deref(),
generated_asset.resource.as_ref(),
Some(asset_label.as_str()),
true,
)
.await?
};
Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
width,
height,
source_type: "generated",
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
resource: generated_asset.resource,
asset: generated_asset.asset,
project: completed_project,
warning: None,
},
))
}
pub async fn remove_editor_image_background(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorBackgroundRemovalRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-background-removal",
"sourceImageSrc",
"待去除背景图片",
)?;
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-background-removal",
);
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
caller.owner_user_id.as_str(),
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
source_entity_id,
"图片画布去除背景",
0,
&payload,
)
.await?;
Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
))
}
pub(crate) async fn remove_editor_image_background_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorBackgroundRemovalRequest,
) -> Result<Json<Value>, AppError> {
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-background-removal",
"sourceImageSrc",
"待去除背景图片",
)?;
let started_at = Instant::now();
caller.report_processing_phase(state).await?;
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_with_retry(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",
"bgfilter-complex",
)
.await?;
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let asset_label =
resolve_editor_generated_asset_label(payload.asset_label.clone(), "去背景结果");
let generated_asset = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: payload.asset_folder_id,
label: asset_label.clone(),
image_src: image_src.clone(),
object_key: Some(persisted.object_key.clone()),
asset_object_id: Some(persisted.asset_object_id.clone()),
width: removed.width,
height: removed.height,
prompt: "remove background".to_string(),
actual_prompt: None,
model: "BgFilter complex".to_string(),
provider: "BgFilter".to_string(),
task_id: task_id.clone(),
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: payload.source_resource_id,
asset_kind: payload.asset_kind,
generation_inputs: payload.generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: 0,
},
)
.await?;
let completed_project = if payload.canvas_completion.is_some() {
complete_editor_canvas_generation(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
generated_asset.resource.as_ref(),
)
.await?
} else {
complete_editor_canvas_background_removal(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.target_layer_id.as_deref(),
generated_asset.resource.as_ref(),
Some(asset_label.as_str()),
false,
)
.await?
};
Ok(json_success_body(
Some(&request_context),
EditorBackgroundRemovalResponse {
image_src,
object_key: persisted.object_key,
asset_object_id: persisted.asset_object_id,
width: removed.width,
height: removed.height,
source_type: "generated",
task_id,
elapsed_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
provider: "BgFilter",
resource: generated_asset.resource,
asset: generated_asset.asset,
project: completed_project,
},
))
}
struct EditorBackgroundRemovalImage {
image: DownloadedOpenAiImage,
width: u32,
height: u32,
}
async fn request_editor_background_removal_image_with_retry(
state: &AppState,
source_image: OpenAiReferenceImage,
) -> Result<EditorBackgroundRemovalImage, AppError> {
let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1;
let mut final_error = None;
for attempt in 1..=max_attempts {
match request_editor_background_removal_image(state, source_image.clone()).await {
Ok(removed) => return Ok(removed),
Err(error) => {
let will_retry = attempt < max_attempts;
tracing::warn!(
provider = "bgfilter",
background_mode = EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX,
attempt,
max_attempts,
will_retry,
error = %error,
error_details = ?error.details(),
"editor_background_removal_request_attempt_failed"
);
if will_retry {
continue;
}
final_error = Some(error);
}
}
}
Err(final_error.expect("BgFilter complex retry loop should retain its final error"))
}
pub(crate) struct EditorScreenBackgroundRemovalOutput {
pub(crate) image: DownloadedOpenAiImage,
pub(crate) provider: &'static str,
pub(crate) model: String,
}
pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(
state: &AppState,
image: &DownloadedOpenAiImage,
screen_color: EditorScreenBackgroundColor,
seg_model: &str,
cross_check: bool,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<EditorScreenBackgroundRemovalOutput, AppError> {
remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
state,
image,
screen_color,
seg_model,
cross_check,
state.config.editor_bgfilter_request_timeout_ms,
audit,
)
.await
}
pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
state: &AppState,
image: &DownloadedOpenAiImage,
screen_color: EditorScreenBackgroundColor,
seg_model: &str,
cross_check: bool,
request_timeout_ms: u64,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<EditorScreenBackgroundRemovalOutput, AppError> {
if let Some(remaining) = editor_bgfilter_circuit_open_remaining(state) {
// 熔断打开只跳过 BgFilter 调用本身,仍要走阿里云兜底;绝不能在 cooldown 内直接退化到
// 低质量本地扣色,否则整段冷却期从「双供应商降级」退化成单一本地键色扣除。
tracing::warn!(
provider = "bgfilter",
screen_color = screen_color.hex,
seg_model,
cross_check,
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;
}
let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1;
let mut final_error = None;
for attempt in 1..=max_attempts {
match request_editor_generated_screen_background_with_bgfilter(
state,
image,
screen_color,
seg_model,
cross_check,
request_timeout_ms,
)
.await
{
Ok(image) => {
record_editor_bgfilter_success();
return Ok(EditorScreenBackgroundRemovalOutput {
image,
provider: "BgFilter",
model: seg_model.to_string(),
});
}
Err(error) => {
record_editor_bgfilter_failure(state);
let will_retry = attempt < max_attempts;
tracing::warn!(
provider = "bgfilter",
screen_color = screen_color.hex,
seg_model,
cross_check,
attempt,
max_attempts,
will_retry,
error = %error,
error_details = ?error.details(),
"editor_bgfilter_request_attempt_failed"
);
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;
if will_retry {
continue;
}
final_error = Some(error);
}
}
}
if let Some(error) = final_error {
tracing::warn!(
provider = "bgfilter",
screen_color = screen_color.hex,
seg_model,
cross_check,
error = %error,
error_details = ?error.details(),
"editor_bgfilter_fallback_to_aliyun_matting"
);
return fallback_editor_screen_background_removal(state, image, screen_color, audit).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<EditorScreenBackgroundRemovalOutput, AppError> {
match crate::aliyun_matting::segment_image_with_aliyun_matting(
state,
image,
"editor-screen-background",
)
.await
{
Ok(image) => Ok(EditorScreenBackgroundRemovalOutput {
image,
provider: "Aliyun Matting",
model: "segment-common-image".to_string(),
}),
Err(error) => {
tracing::warn!(
provider = "aliyun-matting",
screen_color = screen_color.hex,
error = %error,
error_details = ?error.details(),
"editor_aliyun_matting_fallback_to_local_screen_background_removal"
);
if crate::external_api_audit::matting_failure_external_call_attempted(&error) {
crate::external_api_audit::record_matting_external_api_failure(
state,
audit,
"aliyun-matting",
state.config.aliyun_matting_endpoint.clone(),
"editor-screen-background-removal",
"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).map(|image| {
EditorScreenBackgroundRemovalOutput {
image,
provider: "Genarrative Local",
model: "screen-color-keying".to_string(),
}
})
}
}
}
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,
cross_check: bool,
request_timeout_ms: u64,
) -> 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 = 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,
background_mode = EDITOR_BGFILTER_BACKGROUND_MODE_FLAT,
cross_check,
input_bytes,
timeout_ms,
"editor_bgfilter_request_start"
);
let http_client = state.editor_bgfilter_http_client();
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())
.text("background_mode", EDITOR_BGFILTER_BACKGROUND_MODE_FLAT)
.text(
"cross_check",
editor_bgfilter_cross_check_form_value(cross_check),
);
let mut request = http_client
.post(url.as_str())
.timeout(Duration::from_millis(timeout_ms))
.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 upstream_cross_check = response
.headers()
.get("x-bgfilter-cross-check")
.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,
cross_check,
upstream_screen_color = upstream_screen_color.as_deref().unwrap_or(""),
upstream_seg_model = upstream_seg_model.as_deref().unwrap_or(""),
upstream_cross_check = upstream_cross_check.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_bgfilter_endpoint(state)?;
let call_id = format!("background-removal-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 = 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,
background_mode = EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX,
cross_check = EDITOR_BGFILTER_CROSS_CHECK_DISABLED,
input_bytes,
timeout_ms,
"editor_background_removal_request_start"
);
let http_client = state.editor_bgfilter_http_client();
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": "bgfilter",
"message": format!("图片 MIME 类型无效:{error}"),
}))
})?;
let form = reqwest::multipart::Form::new()
.part("file", file_part)
.text("background_mode", EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX)
.text("seg_model", EDITOR_BGFILTER_DEFAULT_SEG_MODEL)
.text(
"cross_check",
editor_bgfilter_cross_check_form_value(EDITOR_BGFILTER_CROSS_CHECK_DISABLED),
);
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| {
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_bgfilter_error(error, request_started_at.elapsed().as_millis() as u64)
})?;
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": "bgfilter",
"message": "BgFilter 服务返回非成功状态",
"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-bgfilter-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": "bgfilter",
"message": "BgFilter 服务未返回图片",
})),
);
}
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_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 editor_bgfilter_cross_check_form_value(enabled: bool) -> &'static str {
if enabled { "on" } else { "off" }
}
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, "bgfilter", "BgFilter", 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, "bgfilter")
}
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,
group_task_id: Option<String>,
prompt: String,
actual_prompt: Option<String>,
model: String,
provider: String,
generation_inputs: Option<Value>,
path_kind: &'static str,
asset_kind: &'static str,
persistence_provider: &'static str,
}
async fn persist_editor_spritesheet_slices(
state: &AppState,
slices: Vec<GeneratedAssetSheetConnectedIcon>,
input: PersistEditorSpritesheetSlicesInput,
) -> Result<Vec<EditorIconSpritesheetIconResponse>, AppError> {
if slices.is_empty() {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集中未识别到可拆分的独立素材。",
})),
);
}
let expected_asset_count = u32::try_from(slices.len()).map_err(|_| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集拆分产物数量超过支持范围。",
}))
})?;
let mut results = Vec::with_capacity(slices.len());
for (index, slice) in slices.into_iter().enumerate() {
let image = DownloadedOpenAiImage {
bytes: slice.bytes,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let file_stem = format!("asset-{:03}", index + 1);
let persisted = persist_editor_generated_image(
state,
input.owner_user_id.as_str(),
input.task_id.as_str(),
&image,
input.prompt.as_str(),
input.actual_prompt.as_deref(),
input.asset_kind,
input.path_kind,
file_stem.as_str(),
"asset",
input.persistence_provider,
)
.await?;
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let record = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: input.project_id.clone(),
owner_user_id: input.owner_user_id.clone(),
folder_id: input.asset_folder_id.clone(),
label: slice.name.clone(),
image_src: image_src.clone(),
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
width: slice.width,
height: slice.height,
prompt: input.prompt.clone(),
actual_prompt: input.actual_prompt.clone(),
model: input.model.clone(),
provider: input.provider.clone(),
task_id: input.task_id.clone(),
group_task_id: input.group_task_id.clone(),
group_task_expected_asset_count: input
.group_task_id
.as_ref()
.map(|_| expected_asset_count),
source_resource_id: input.source_resource_id.clone(),
asset_kind: Some("icon".to_string()),
generation_inputs: input.generation_inputs.clone(),
thumbnail_src: None,
generation_cost_mud_points: 0,
},
)
.await?;
results.push(EditorIconSpritesheetIconResponse {
name: slice.name,
image_src,
width: slice.width,
height: slice.height,
resource: record.resource,
asset: record.asset,
});
}
if let Some(group_task_id) = input.group_task_id.as_deref() {
let asset_ids = results
.iter()
.filter_map(|result| result.asset.as_ref().map(|asset| asset.asset_id.clone()))
.collect::<Vec<_>>();
state
.spacetime_client()
.complete_editor_asset_group_cohort(EditorAssetGroupCohortCompleteRecordInput {
owner_user_id: input.owner_user_id,
task_id: input.task_id,
group_task_id: group_task_id.to_string(),
expected_asset_count,
asset_ids,
completed_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
}
Ok(results)
}
pub async fn generate_editor_icon_spritesheet(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorIconSpritesheetGenerationRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
ensure_editor_reference_image_source_is_stable(
payload.reference_image_src.as_str(),
"editor-icon-spritesheet",
"referenceImageSrc",
"图标素材规范",
)?;
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-icon-spritesheet",
"referenceImageSrcs",
"图标素材参考图",
)?;
let generation_options = normalize_editor_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let price_mud_points = u64::from(
resolve_editor_icon_spritesheet_price(
&state,
Some(generation_options.model),
Some(generation_options.image_size),
)
.await?,
);
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-icon-spritesheet-generation",
);
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
caller.owner_user_id.as_str(),
EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成图标素材",
price_mud_points,
&payload,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
generate_editor_icon_spritesheet_for_owner(&state, &request_context, caller, payload).await
}
pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorIconSpritesheetGenerationRequest,
) -> Result<Json<Value>, AppError> {
ensure_editor_reference_image_source_is_stable(
payload.reference_image_src.as_str(),
"editor-icon-spritesheet",
"referenceImageSrc",
"图标素材规范",
)?;
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-icon-spritesheet",
"referenceImageSrcs",
"图标素材参考图",
)?;
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
// 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时
// 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。
let requested_screen_color = payload.screen_color.clone();
let reference_count = 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len);
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
let mut reference_images =
Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len));
reference_images.push(
parse_editor_reference_image(
state,
caller.owner_user_id.as_str(),
payload.reference_image_src.as_str(),
)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "referenceImageSrc",
"message": "图标素材规范必须是 objectKey、资源或素材引用。",
}))
})?,
);
for source in
normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref(), 8)
{
reference_images.push(
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "referenceImageSrcs",
"message": "图标素材参考图必须是 objectKey、资源或素材引用。",
}))
})?,
);
}
let generation_options = normalize_editor_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let expected_price_mud_points = resolve_editor_icon_spritesheet_price(
state,
Some(generation_options.model),
Some(generation_options.image_size),
)
.await?;
let size = generation_options.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 spritesheet_label =
resolve_editor_generated_asset_label(payload.asset_label.clone(), "图标素材图集");
let asset_folder_id = payload
.asset_folder_id
.clone()
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string()));
let source_persisted = persist_editor_provider_source_image(
state,
caller.owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_ICON_SPRITESHEET_ASSET_KIND,
"icon-spritesheets",
"spritesheet",
)
.await?;
let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str());
let source_record = persist_editor_provider_source_resource(
state,
source_persisted,
PersistEditorProviderSourceResourceInput {
project_id: payload.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: asset_folder_id.clone(),
label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"),
width: source_width,
height: source_height,
prompt: prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
task_id: generated.task_id.clone(),
source_resource_id: None,
asset_kind: Some("icon-spritesheet".to_string()),
generation_inputs: generation_inputs.clone(),
generation_cost_mud_points: u64::from(expected_price_mud_points),
},
)
.await?;
caller.report_processing_phase(state).await?;
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
};
let removal = remove_editor_generated_screen_background_with_bgfilter(
state,
&image,
screen_color,
seg_model,
EDITOR_BGFILTER_CROSS_CHECK_DISABLED,
&matting_audit,
)
.await;
let removal = match removal {
Ok(removal) => removal,
Err(error) => {
let failure_message = error.body_text();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "background_postprocess",
task_id = %generated.task_id,
error = %failure_message,
"图标图集原图已保存,但透明背景处理失败,使用原图完成画布"
);
let completed_project = complete_editor_canvas_generation(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
source_record.resource.as_ref(),
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetGenerationResponse {
spritesheet_image_src: source_image_src,
spritesheet_width: source_width,
spritesheet_height: source_height,
icon_image_srcs: Vec::new(),
slice_warning: None,
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: source_record.resource,
spritesheet_asset: source_record.asset,
project: completed_project,
warning: Some(editor_postprocess_fallback_warning(
"生成任务成功,后处理失败。",
)),
},
));
}
};
let image = removal.image;
let matting_generation_inputs = build_editor_derived_asset_generation_inputs(
"图标图集抠图",
&removal.model,
&source_record,
);
let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((512, 512));
let owner_user_id = caller.owner_user_id;
let spritesheet_persisted = persist_editor_generated_image(
state,
owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
"去除纯色背景",
None,
EDITOR_ICON_SPRITESHEET_ASSET_KIND,
"icon-spritesheets",
"spritesheet",
"spritesheet",
removal.provider,
)
.await?;
let spritesheet_image_src =
editor_media_src_from_object_key(spritesheet_persisted.object_key.as_str());
let spritesheet_record = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: asset_folder_id.clone(),
label: spritesheet_label,
image_src: spritesheet_image_src.clone(),
object_key: Some(spritesheet_persisted.object_key.clone()),
asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()),
width: spritesheet_width,
height: spritesheet_height,
prompt: "去除纯色背景".to_string(),
actual_prompt: None,
model: removal.model.clone(),
provider: removal.provider.to_string(),
task_id: generated.task_id.clone(),
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: source_record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
asset_kind: Some("icon-spritesheet".to_string()),
generation_inputs: matting_generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: 0,
},
)
.await?;
let slice_source = DownloadedImage {
bytes: image.bytes.clone(),
mime_type: image.mime_type.clone(),
extension: image.extension.clone(),
};
let (icon_image_srcs, slice_warning) =
match slice_generated_icon_spritesheet_by_connected_components(
&slice_source,
&icon_descriptions,
) {
Ok(icon_slices) => {
match persist_editor_spritesheet_slices(
state,
icon_slices,
PersistEditorSpritesheetSlicesInput {
owner_user_id: owner_user_id.clone(),
project_id: payload.project_id.clone(),
asset_folder_id: asset_folder_id.clone(),
source_resource_id: spritesheet_record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
task_id: generated.task_id.clone(),
group_task_id: None,
prompt: "自动拆分图集".to_string(),
actual_prompt: None,
model: "connected-components".to_string(),
provider: "Genarrative".to_string(),
generation_inputs: build_editor_derived_asset_generation_inputs(
"图集拆分",
"connected-components",
&spritesheet_record,
),
path_kind: "icon-spritesheet-assets",
asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND,
persistence_provider: "vector-engine",
},
)
.await
{
Ok(icon_image_srcs) => (icon_image_srcs, None),
Err(error) => {
let reason = error.body_text();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "persist_automatic_slices",
task_id = %generated.task_id,
reason = %reason,
"图标图集已持久化,但自动拆分素材持久化失败"
);
(
Vec::new(),
Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE,
reason,
}),
)
}
}
}
Err(error) => {
let reason = error.to_string();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "detect_automatic_slices",
task_id = %generated.task_id,
reason = %reason,
"图标图集已持久化,但自动拆分未识别到全部素材"
);
(
Vec::new(),
Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
reason,
}),
)
}
};
let (canvas_items, primary_layer_id) =
if let Some(completion) = payload.canvas_completion.as_ref() {
build_icon_spritesheet_canvas_layer_items(
completion,
spritesheet_record.resource.as_ref(),
source_record.resource.as_ref(),
icon_image_srcs.as_slice(),
)
} else {
(Vec::new(), None)
};
let completed_project = complete_editor_canvas_generation_with_items(
state,
owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
canvas_items,
primary_layer_id,
)
.await?;
Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetGenerationResponse {
spritesheet_image_src,
spritesheet_width,
spritesheet_height,
icon_image_srcs,
slice_warning,
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: spritesheet_record.resource,
spritesheet_asset: spritesheet_record.asset,
project: completed_project,
warning: None,
},
))
}
pub async fn split_editor_icon_spritesheet(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorIconSpritesheetSliceRequest>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = authenticated.claims().user_id().to_string();
let project_id = payload.project_id.trim().to_string();
let source_resource_id = payload.source_resource_id.trim().to_string();
if project_id.is_empty() || source_resource_id.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "projectId 和 sourceResourceId 不能为空。",
})),
);
}
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.clone(),
owner_user_id: owner_user_id.clone(),
})
.await
.map_err(map_editor_project_error)?;
let source_resource = project
.resources
.iter()
.find(|resource| resource.resource_id == source_resource_id)
.cloned()
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "未找到要拆分的图集资源。",
}))
})?;
if source_resource.asset_kind.as_deref() != Some("icon-spritesheet") {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "只有图集素材可以使用拆分功能。",
})),
);
}
let task_id = build_prefixed_uuid_id("editor-atlas-split-");
let group_task_id = resolve_editor_manual_atlas_split_group_task_id(
&state,
owner_user_id.as_str(),
&source_resource,
)
.await?
.unwrap_or_else(|| task_id.clone());
let reference =
parse_editor_reference_image(&state, owner_user_id.as_str(), source_resource_id.as_str())
.await?;
let source = DownloadedImage {
bytes: reference.bytes,
mime_type: reference.mime_type,
extension: "png".to_string(),
};
validate_editor_icon_spritesheet_manual_source(&source)?;
let slices =
slice_generated_icon_spritesheet_all_by_connected_components(&source).map_err(|error| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": error.to_string(),
}))
})?;
validate_editor_icon_spritesheet_manual_slice_count(slices.len())?;
let prompt = source_resource
.prompt
.clone()
.unwrap_or_else(|| "拆分图集素材".to_string());
let actual_prompt = source_resource.actual_prompt.clone();
let model = source_resource
.model
.clone()
.unwrap_or_else(|| "connected-components".to_string());
let provider = source_resource
.provider
.clone()
.unwrap_or_else(|| "Genarrative".to_string());
let generation_inputs = source_resource.generation_inputs.clone();
let icon_image_srcs = persist_editor_spritesheet_slices(
&state,
slices,
PersistEditorSpritesheetSlicesInput {
owner_user_id: owner_user_id.clone(),
project_id: Some(project_id.clone()),
asset_folder_id: payload
.asset_folder_id
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
source_resource_id: Some(source_resource_id),
task_id,
group_task_id: Some(group_task_id),
prompt,
actual_prompt,
model,
provider,
generation_inputs,
path_kind: "icon-spritesheet-slices",
asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND,
persistence_provider: "genarrative",
},
)
.await?;
let canvas_items = build_icon_spritesheet_slice_canvas_layer_items(
&payload.canvas_completion,
icon_image_srcs.as_slice(),
);
let project = complete_editor_canvas_generation_with_items(
&state,
owner_user_id.as_str(),
Some(project_id.as_str()),
Some(&payload.canvas_completion),
canvas_items,
icon_image_srcs
.first()
.and_then(|icon| icon.resource.as_ref())
.map(|resource| generated_canvas_layer_id(resource.resource_id.as_str())),
)
.await?
.ok_or_else(|| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集拆分结果未能写入画布。",
}))
})?;
Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetSliceResponse {
icon_image_srcs,
project,
},
))
}
fn validate_editor_icon_spritesheet_manual_source(
source: &DownloadedImage,
) -> Result<(), AppError> {
let reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice()))
.with_guessed_format()
.map_err(|error| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": format!("无法识别图集图片格式:{error}"),
}))
})?;
let (width, height) = reader.into_dimensions().map_err(|error| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": format!("无法读取图集图片尺寸:{error}"),
}))
})?;
validate_editor_icon_spritesheet_manual_dimensions(width, height)
}
async fn resolve_editor_manual_atlas_split_group_task_id(
state: &AppState,
owner_user_id: &str,
source_resource: &EditorProjectResourceRecord,
) -> Result<Option<String>, AppError> {
let lookup = editor_manual_atlas_split_group_source_lookup_input(
owner_user_id,
source_resource.resource_id.as_str(),
source_resource.source_resource_id.as_deref(),
source_resource.asset_object_id.as_deref(),
source_resource.object_key.as_deref(),
);
let asset = state
.spacetime_client()
.find_editor_asset_group_source(lookup)
.await
.map_err(map_editor_project_error)?;
Ok(asset.and_then(|asset| normalize_optional_string(asset.task_id)))
}
fn editor_manual_atlas_split_group_source_lookup_input(
owner_user_id: &str,
resource_id: &str,
source_resource_id: Option<&str>,
asset_object_id: Option<&str>,
object_key: Option<&str>,
) -> EditorAssetGroupSourceLookupRecordInput {
let mut source_resource_ids = vec![resource_id.to_string()];
if let Some(source_resource_id) =
normalize_optional_string(source_resource_id.map(str::to_string))
{
source_resource_ids.push(source_resource_id);
}
EditorAssetGroupSourceLookupRecordInput {
owner_user_id: owner_user_id.to_string(),
source_resource_ids,
asset_object_id: asset_object_id.map(str::to_string),
object_key: object_key.map(str::to_string),
}
}
fn validate_editor_icon_spritesheet_manual_dimensions(
width: u32,
height: u32,
) -> Result<(), AppError> {
let pixel_count = u64::from(width).saturating_mul(u64::from(height));
if width == 0
|| height == 0
|| width > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION
|| height > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION
|| pixel_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS
{
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集尺寸超过手动拆分限制。",
"width": width,
"height": height,
"maxDimension": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION,
"maxPixels": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS,
})),
);
}
Ok(())
}
fn validate_editor_icon_spritesheet_manual_slice_count(slice_count: usize) -> Result<(), AppError> {
if slice_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集识别出的素材数量超过手动拆分限制。",
"sliceCount": slice_count,
"maxSliceCount": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES,
})),
);
}
Ok(())
}
pub async fn extract_editor_ui_design_assets(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorUiDesignAssetExtractionRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-ui-design-asset-extraction",
"sourceImageSrc",
"UI设计图",
)?;
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-ui-design-asset-extraction",
"referenceImageSrcs",
"UI素材参考图",
)?;
let generation_options = normalize_editor_ui_design_asset_extraction_options(
payload.model.as_deref(),
payload.aspect_ratio.as_str(),
payload.image_size.as_str(),
)?;
let price_mud_points = u64::from(
resolve_editor_ui_design_asset_extraction_price(
&state,
Some(generation_options.model),
Some(generation_options.image_size),
)
.await?,
);
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-ui-design-asset-extraction",
);
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
caller.owner_user_id.as_str(),
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND,
source_entity_id,
"图片画布提取UI设计图素材",
price_mud_points,
&payload,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
extract_editor_ui_design_assets_for_owner(&state, &request_context, caller, payload).await
}
pub(crate) async fn extract_editor_ui_design_assets_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorUiDesignAssetExtractionRequest,
) -> Result<Json<Value>, AppError> {
ensure_editor_reference_image_source_is_stable(
payload.source_image_src.as_str(),
"editor-ui-design-asset-extraction",
"sourceImageSrc",
"UI设计图",
)?;
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-ui-design-asset-extraction",
"referenceImageSrcs",
"UI素材参考图",
)?;
// 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时
// 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。
let requested_screen_color = payload.screen_color.clone();
let reference_count = 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len);
let decision_prompt = format!(
"从 UI 设计图提取被红色框选的素材,输出 spritesheet。目标规格 {} {}。",
payload.aspect_ratio, payload.image_size
);
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
let reference_image = parse_editor_reference_image(
state,
caller.owner_user_id.as_str(),
payload.source_image_src.as_str(),
)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-ui-design-asset-extraction",
"field": "sourceImageSrc",
"message": "UI设计图必须是 objectKey、资源或素材引用。",
}))
})?;
let mut reference_images = vec![reference_image];
for source in normalize_editor_reference_image_sources(
payload.reference_image_srcs.as_deref(),
EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT,
) {
reference_images.push(
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-ui-design-asset-extraction",
"field": "referenceImageSrcs",
"message": "UI素材参考图必须是 objectKey、资源或素材引用。",
}))
})?,
);
}
let generation_options = normalize_editor_ui_design_asset_extraction_options(
payload.model.as_deref(),
payload.aspect_ratio.as_str(),
payload.image_size.as_str(),
)?;
let expected_price_mud_points = resolve_editor_ui_design_asset_extraction_price(
state,
Some(generation_options.model),
Some(generation_options.image_size),
)
.await?;
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let (generated, prompt, screen_background_decision) =
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_ui_design_asset_extraction",
request_context.request_id(),
u64::from(expected_price_mud_points),
async {
// 背景色决策:预扣泥点之后才发起(决策失败→future 返回 Err→失败退款)。
let screen_background_decision = resolve_editor_screen_background_color(
state.llm_client(),
state.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 =
resolve_editor_generated_asset_label(payload.spritesheet_label.clone(), "UI设计图素材图集");
let asset_folder_id = payload
.asset_folder_id
.clone()
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string()));
let source_persisted = persist_editor_provider_source_image(
state,
caller.owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND,
"ui-design-spritesheets",
"spritesheet",
)
.await?;
let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str());
let source_record = persist_editor_provider_source_resource(
state,
source_persisted,
PersistEditorProviderSourceResourceInput {
project_id: payload.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: asset_folder_id.clone(),
label: editor_generated_asset_variant_label(source_label.as_str(), "原图"),
width: source_width,
height: source_height,
prompt: prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
task_id: generated.task_id.clone(),
source_resource_id: None,
asset_kind: Some("icon-spritesheet".to_string()),
generation_inputs: generation_inputs.clone(),
generation_cost_mud_points: u64::from(expected_price_mud_points),
},
)
.await?;
caller.report_processing_phase(state).await?;
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
};
let removal = remove_editor_generated_screen_background_with_bgfilter(
state,
&image,
screen_color,
seg_model,
EDITOR_BGFILTER_CROSS_CHECK_DISABLED,
&matting_audit,
)
.await;
let removal = match removal {
Ok(removal) => removal,
Err(error) => {
let failure_message = error.body_text();
tracing::warn!(
provider = "editor-ui-design-asset-extraction",
operation = "background_postprocess",
task_id = %generated.task_id,
error = %failure_message,
"UI 素材图集原图已保存,但透明背景处理失败,使用原图完成画布"
);
let completed_project = complete_editor_canvas_generation(
state,
caller.owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
source_record.resource.as_ref(),
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetGenerationResponse {
spritesheet_image_src: source_image_src,
spritesheet_width: source_width,
spritesheet_height: source_height,
icon_image_srcs: Vec::new(),
slice_warning: None,
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: source_record.resource,
spritesheet_asset: source_record.asset,
project: completed_project,
warning: Some(editor_postprocess_fallback_warning(
"生成任务成功,后处理失败。",
)),
},
));
}
};
let image = removal.image;
let matting_generation_inputs =
build_editor_derived_asset_generation_inputs("UI图集抠图", &removal.model, &source_record);
let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((512, 512));
let owner_user_id = caller.owner_user_id;
let spritesheet_persisted = persist_editor_generated_image(
state,
owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
"去除纯色背景",
None,
EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND,
"ui-design-spritesheets",
"spritesheet",
"spritesheet",
removal.provider,
)
.await?;
let spritesheet_image_src =
editor_media_src_from_object_key(spritesheet_persisted.object_key.as_str());
let spritesheet_record = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: asset_folder_id.clone(),
label: source_label,
image_src: spritesheet_image_src.clone(),
object_key: Some(spritesheet_persisted.object_key.clone()),
asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()),
width: spritesheet_width,
height: spritesheet_height,
prompt: "去除纯色背景".to_string(),
actual_prompt: None,
model: removal.model.clone(),
provider: removal.provider.to_string(),
task_id: generated.task_id.clone(),
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: source_record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
asset_kind: Some("icon-spritesheet".to_string()),
generation_inputs: matting_generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: 0,
},
)
.await?;
let slice_source = DownloadedImage {
bytes: image.bytes.clone(),
mime_type: image.mime_type.clone(),
extension: image.extension.clone(),
};
let (icon_image_srcs, slice_warning) =
match slice_generated_icon_spritesheet_all_by_connected_components(&slice_source) {
Ok(icon_slices) => {
match persist_editor_spritesheet_slices(
state,
icon_slices,
PersistEditorSpritesheetSlicesInput {
owner_user_id: owner_user_id.clone(),
project_id: payload.project_id.clone(),
asset_folder_id: asset_folder_id.clone(),
source_resource_id: spritesheet_record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
task_id: generated.task_id.clone(),
group_task_id: None,
prompt: "自动拆分图集".to_string(),
actual_prompt: None,
model: "connected-components".to_string(),
provider: "Genarrative".to_string(),
generation_inputs: build_editor_derived_asset_generation_inputs(
"图集拆分",
"connected-components",
&spritesheet_record,
),
path_kind: "ui-design-assets",
asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND,
persistence_provider: "vector-engine",
},
)
.await
{
Ok(icon_image_srcs) => (icon_image_srcs, None),
Err(error) => {
let reason = error.body_text();
tracing::warn!(
provider = "editor-ui-design-asset-extraction",
operation = "persist_slices",
task_id = %generated.task_id,
reason = %reason,
"UI 设计图透明图集已持久化,但独立素材持久化失败"
);
(
Vec::new(),
Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE,
reason,
}),
)
}
}
}
Err(error) => {
let reason = error.to_string();
tracing::warn!(
provider = "editor-ui-design-asset-extraction",
operation = "detect_slices",
task_id = %generated.task_id,
reason = %reason,
"UI 设计图透明图集已持久化,但未识别到可拆分素材"
);
(
Vec::new(),
Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
reason,
}),
)
}
};
let (canvas_items, primary_layer_id) =
if let Some(completion) = payload.canvas_completion.as_ref() {
build_icon_spritesheet_canvas_layer_items(
completion,
spritesheet_record.resource.as_ref(),
source_record.resource.as_ref(),
icon_image_srcs.as_slice(),
)
} else {
(Vec::new(), None)
};
let completed_project = complete_editor_canvas_generation_with_items(
state,
owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
canvas_items,
primary_layer_id,
)
.await?;
Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetGenerationResponse {
spritesheet_image_src,
spritesheet_width,
spritesheet_height,
icon_image_srcs,
slice_warning,
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: spritesheet_record.resource,
spritesheet_asset: spritesheet_record.asset,
project: completed_project,
warning: None,
},
))
}
async fn repair_editor_project_record_inline_media(
state: &AppState,
mut record: EditorProjectRecord,
) -> EditorProjectRecord {
let owner_user_id = record.owner_user_id.clone();
let mut repaired_resources = Vec::with_capacity(record.resources.len());
for resource in record.resources {
repaired_resources.push(
repair_editor_project_resource_inline_media(state, owner_user_id.as_str(), resource)
.await,
);
}
record.resources = repaired_resources;
record
}
async fn repair_editor_asset_library_inline_media(
state: &AppState,
owner_user_id: &str,
mut record: EditorAssetLibraryRecord,
) -> EditorAssetLibraryRecord {
let mut repaired_assets = Vec::with_capacity(record.assets.len());
for asset in record.assets {
repaired_assets.push(repair_editor_asset_inline_media(state, owner_user_id, asset).await);
}
record.assets = repaired_assets;
record
}
async fn repair_editor_project_resource_inline_media(
state: &AppState,
owner_user_id: &str,
resource: EditorProjectResourceRecord,
) -> EditorProjectResourceRecord {
if !should_repair_editor_inline_image(
resource.image_src.as_str(),
resource.object_key.as_deref(),
) {
return resource;
}
let repair = persist_editor_legacy_inline_image(
state,
owner_user_id,
resource.image_src.as_str(),
resource
.task_id
.as_deref()
.unwrap_or(resource.resource_id.as_str()),
resource
.prompt
.as_deref()
.or(resource.actual_prompt.as_deref())
.unwrap_or("历史画布内联图片修复"),
resource.actual_prompt.as_deref(),
resource.asset_kind.as_deref(),
"legacy-resources",
resource.resource_id.as_str(),
)
.await;
let persisted = match repair {
Ok(persisted) => persisted,
Err(error) => {
tracing::warn!(
resource_id = %resource.resource_id,
error = %error,
"repair legacy inline editor project resource media failed"
);
return resource;
}
};
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let repaired = state
.spacetime_client()
.repair_editor_project_resource_media(EditorProjectResourceMediaRepairRecordInput {
resource_id: resource.resource_id.clone(),
owner_user_id: owner_user_id.to_string(),
asset_object_id: persisted.asset_object_id.clone(),
image_src: image_src.clone(),
object_key: persisted.object_key.clone(),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error);
match repaired {
Ok(record) => record,
Err(error) => {
tracing::warn!(
resource_id = %resource.resource_id,
error = %error,
"persist legacy inline editor project resource repair failed"
);
EditorProjectResourceRecord {
image_src,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
..resource
}
}
}
}
async fn repair_editor_asset_inline_media(
state: &AppState,
owner_user_id: &str,
asset: EditorAssetRecord,
) -> EditorAssetRecord {
if !should_repair_editor_inline_image(asset.image_src.as_str(), asset.object_key.as_deref()) {
return asset;
}
let repair = persist_editor_legacy_inline_image(
state,
owner_user_id,
asset.image_src.as_str(),
asset.task_id.as_deref().unwrap_or(asset.asset_id.as_str()),
asset.prompt.as_deref().unwrap_or(asset.label.as_str()),
asset.actual_prompt.as_deref(),
asset.asset_kind.as_deref(),
"legacy-assets",
asset.asset_id.as_str(),
)
.await;
let persisted = match repair {
Ok(persisted) => persisted,
Err(error) => {
tracing::warn!(
asset_id = %asset.asset_id,
error = %error,
"repair legacy inline editor asset media failed"
);
return asset;
}
};
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let repaired = state
.spacetime_client()
.repair_editor_asset_media(EditorAssetMediaRepairRecordInput {
asset_id: asset.asset_id.clone(),
owner_user_id: owner_user_id.to_string(),
asset_object_id: persisted.asset_object_id.clone(),
image_src: image_src.clone(),
object_key: persisted.object_key.clone(),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error);
match repaired {
Ok(record) => record,
Err(error) => {
tracing::warn!(
asset_id = %asset.asset_id,
error = %error,
"persist legacy inline editor asset repair failed"
);
EditorAssetRecord {
image_src,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
..asset
}
}
}
}
async fn persist_editor_legacy_inline_image(
state: &AppState,
owner_user_id: &str,
image_src: &str,
task_id: &str,
prompt: &str,
actual_prompt: Option<&str>,
asset_kind: Option<&str>,
path_kind: &str,
file_stem: &str,
) -> Result<PersistedEditorGeneratedImage, AppError> {
let decoded = decode_generated_image_asset_data_url(image_src).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
"message": format!("历史画布内联图片无法解析:{error:?}"),
}))
})?;
let image = DownloadedOpenAiImage {
bytes: decoded.bytes,
mime_type: decoded.format.mime_type,
extension: decoded.format.extension,
};
persist_editor_generated_image(
state,
owner_user_id,
task_id,
&image,
prompt,
actual_prompt,
asset_kind.unwrap_or(EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND),
path_kind,
file_stem,
"legacy-repair",
"vector-engine",
)
.await
}
fn should_repair_editor_inline_image(image_src: &str, object_key: Option<&str>) -> bool {
object_key
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
&& is_editor_inline_image_src(image_src)
}
fn is_editor_inline_image_src(value: &str) -> bool {
value
.trim_start()
.to_ascii_lowercase()
.starts_with("data:image/")
}
pub(crate) fn editor_project_payload_from_record(
record: EditorProjectRecord,
) -> EditorProjectPayload {
let resources: Vec<EditorProjectResourcePayload> = record
.resources
.into_iter()
.map(editor_project_resource_payload_from_record)
.collect();
let canvas = editor_canvas_payload_from_record(record.canvas, resources.as_slice());
let layers = sanitize_editor_payload_media(record.layers, resources.as_slice());
EditorProjectPayload {
project_id: record.project_id,
title: record.title,
canvas,
viewport: EditorCanvasViewportPayload {
x: record.viewport.x,
y: record.viewport.y,
scale: record.viewport.scale,
},
layers,
resources,
updated_at: record.updated_at,
}
}
fn editor_canvas_payload_from_record(
record: EditorCanvasRecord,
resources: &[EditorProjectResourcePayload],
) -> EditorCanvasPayload {
EditorCanvasPayload {
canvas_id: record.canvas_id,
project_id: record.project_id,
title: record.title,
viewport: EditorCanvasViewportPayload {
x: record.viewport.x,
y: record.viewport.y,
scale: record.viewport.scale,
},
layers: sanitize_editor_payload_media(record.layers, resources),
created_at: record.created_at,
updated_at: record.updated_at,
}
}
pub(crate) fn editor_project_resource_payload_from_record(
record: EditorProjectResourceRecord,
) -> EditorProjectResourcePayload {
let image_src =
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
EditorProjectResourcePayload {
resource_id: record.resource_id,
showcase_id: None,
asset_id: None,
label: None,
project_id: record.project_id,
owner_user_id: record.owner_user_id,
author_display_name: None,
author_public_user_code: None,
image_src,
object_key: record.object_key,
asset_object_id: record.asset_object_id,
width: record.width,
height: record.height,
source_type: record.source_type,
prompt: record.prompt,
actual_prompt: record.actual_prompt,
model: record.model,
provider: record.provider,
task_id: record.task_id,
source_resource_id: record.source_resource_id,
asset_kind: record.asset_kind,
showcase_category: None,
generation_inputs: record
.generation_inputs
.map(sanitize_editor_payload_inline_media),
public_showcase_enabled: record.public_showcase_enabled,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn editor_project_resource_payload_from_showcase_record(
record: EditorShowcaseAssetRecord,
) -> EditorProjectResourcePayload {
let image_src =
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
EditorProjectResourcePayload {
resource_id: record.showcase_id.clone(),
showcase_id: Some(record.showcase_id),
asset_id: Some(record.asset_id),
label: Some(record.label),
project_id: "editor-showcase".to_string(),
owner_user_id: record.owner_user_id,
author_display_name: None,
author_public_user_code: None,
image_src,
object_key: record.object_key,
asset_object_id: record.asset_object_id,
width: record.width,
height: record.height,
source_type: record.source_type,
prompt: record.prompt,
actual_prompt: record.actual_prompt,
model: record.model,
provider: record.provider,
task_id: record.task_id,
source_resource_id: record.source_resource_id,
asset_kind: record.asset_kind,
showcase_category: record.showcase_category,
generation_inputs: record
.generation_inputs
.map(sanitize_editor_payload_inline_media),
public_showcase_enabled: record.display_enabled,
review_status: Some(record.review_status),
display_enabled: Some(record.display_enabled),
like_count: Some(record.like_count),
generation_cost_mud_points: record.generation_cost_mud_points,
refund_mud_points: Some(record.refund_mud_points),
created_at: record.submitted_at,
updated_at: record.updated_at,
}
}
fn editor_project_resource_public_payload_from_record(
state: &AppState,
record: EditorShowcaseAssetRecord,
) -> EditorProjectResourcePayload {
let mut payload = editor_project_resource_payload_from_showcase_record(record);
let (display_name, public_user_code) =
resolve_editor_project_resource_public_author(state, payload.owner_user_id.as_str());
payload.author_display_name = display_name;
payload.author_public_user_code = public_user_code;
payload
}
fn resolve_editor_project_resource_public_author(
state: &AppState,
owner_user_id: &str,
) -> (Option<String>, Option<String>) {
let Some(owner_user_id) = normalize_optional_string(Some(owner_user_id.to_string())) else {
return (None, Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string()));
};
if owner_user_id == ORPHAN_WORK_OWNER_USER_ID {
return (None, Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string()));
}
match state.auth_user_service().get_user_by_id(&owner_user_id) {
Ok(Some(user)) => (
normalize_optional_string(Some(user.display_name)),
normalize_optional_string(Some(user.public_user_code)),
),
Ok(None) | Err(_) => (None, Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string())),
}
}
pub(crate) fn editor_asset_library_payload_from_record(
record: EditorAssetLibraryRecord,
) -> EditorAssetLibraryPayload {
EditorAssetLibraryPayload {
folders: record
.folders
.into_iter()
.map(editor_asset_folder_payload_from_record)
.collect(),
assets: record
.assets
.into_iter()
.map(editor_asset_payload_from_record)
.collect(),
}
}
pub(crate) fn editor_asset_folder_payload_from_record(
record: EditorAssetFolderRecord,
) -> EditorAssetFolderPayload {
EditorAssetFolderPayload {
folder_id: record.folder_id,
label: record.label,
sort_order: record.sort_order,
collapsed: record.collapsed,
system_default: record.system_default,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload {
let image_src =
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
EditorAssetPayload {
asset_id: record.asset_id,
folder_id: record.folder_id,
label: record.label,
image_src,
object_key: record.object_key,
asset_object_id: record.asset_object_id,
width: record.width,
height: record.height,
source_type: record.source_type,
prompt: record.prompt,
actual_prompt: record.actual_prompt,
model: record.model,
provider: record.provider,
task_id: record.task_id,
asset_kind: record.asset_kind,
generation_inputs: record
.generation_inputs
.map(sanitize_editor_payload_inline_media),
source_resource_id: record.source_resource_id,
public_showcase_enabled: record.public_showcase_enabled,
thumbnail_src: normalize_optional_string(record.thumbnail_src),
generation_cost_mud_points: record.generation_cost_mud_points,
showcase_id: record.showcase_id,
showcase_review_status: record.showcase_review_status,
showcase_display_enabled: record.showcase_display_enabled,
showcase_like_count: record.showcase_like_count,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn editor_showcase_campaign_payload_from_record(
record: EditorShowcaseCampaignConfigRecord,
) -> EditorShowcaseCampaignPayload {
EditorShowcaseCampaignPayload {
enabled: record.enabled,
title: record.title,
image_src: record.image_src,
image_object_key: record.image_object_key,
image_width: record.image_width,
image_height: record.image_height,
prompt: record.prompt,
author: record.author,
cost_text: record.cost_text,
updated_at: record.updated_at,
}
}
#[derive(Clone, Debug)]
struct EditorPayloadMediaReference {
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
asset_kind: Option<String>,
}
fn sanitize_editor_payload_media(
mut value: Value,
resources: &[EditorProjectResourcePayload],
) -> Value {
let resource_media: BTreeMap<String, EditorPayloadMediaReference> = resources
.iter()
.filter(|resource| !resource.image_src.trim().is_empty())
.map(|resource| {
(
resource.resource_id.clone(),
EditorPayloadMediaReference {
image_src: resource.image_src.clone(),
object_key: resource.object_key.clone(),
asset_object_id: resource.asset_object_id.clone(),
asset_kind: resource.asset_kind.clone(),
},
)
})
.collect();
sanitize_editor_payload_media_value(&mut value, None, &resource_media);
value
}
fn sanitize_editor_payload_inline_media(mut value: Value) -> Value {
sanitize_editor_payload_media_value(&mut value, None, &BTreeMap::new());
value
}
fn sanitize_editor_payload_media_value(
value: &mut Value,
inherited_media: Option<&EditorPayloadMediaReference>,
resource_media: &BTreeMap<String, EditorPayloadMediaReference>,
) {
match value {
Value::Object(object) => {
let current_media = object
.get("resourceId")
.and_then(Value::as_str)
.and_then(|resource_id| resource_media.get(resource_id))
.cloned()
.or_else(|| inherited_media.cloned());
if let Some(media) = current_media.as_ref() {
for field in ["src", "imageSrc", "thumbnailSrc", "previewVideoPath"] {
rewrite_or_remove_inline_media_field(object, field, media);
}
fill_missing_media_identity_field(object, "objectKey", media.object_key.as_ref());
fill_missing_media_identity_field(
object,
"assetObjectId",
media.asset_object_id.as_ref(),
);
if object.get("assetKind").and_then(Value::as_str)
== Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND)
{
set_optional_json_string_field(object, "assetKind", media.asset_kind.as_ref());
}
}
for child in object.values_mut() {
sanitize_editor_payload_media_value(child, current_media.as_ref(), resource_media);
}
object.retain(|_, child| !child.is_null());
}
Value::Array(items) => {
for item in items.iter_mut() {
sanitize_editor_payload_media_value(item, inherited_media, resource_media);
}
items.retain(|item| !item.is_null());
}
Value::String(item) if is_forbidden_editor_persisted_media_src(item) => {
*value = Value::Null;
}
_ => {}
}
}
fn rewrite_or_remove_inline_media_field(
object: &mut serde_json::Map<String, Value>,
field: &str,
media: &EditorPayloadMediaReference,
) {
let Some(Value::String(current)) = object.get(field) else {
return;
};
if !is_forbidden_editor_persisted_media_src(current) {
return;
}
if media.image_src.trim().is_empty() {
object.remove(field);
} else {
object.insert(field.to_string(), Value::String(media.image_src.clone()));
}
}
fn fill_missing_media_identity_field(
object: &mut serde_json::Map<String, Value>,
field: &str,
value: Option<&String>,
) {
let Some(value) = value
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let should_fill = object
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|current| !current.is_empty())
.is_none();
if should_fill {
object.insert(field.to_string(), Value::String(value.to_string()));
}
}
pub(crate) async fn persist_editor_generated_asset(
state: &AppState,
input: PersistEditorGeneratedAssetInput,
) -> Result<EditorGeneratedAssetRecord, AppError> {
let generation_inputs_json = serialize_editor_asset_metadata(input.generation_inputs.clone())?;
let object_key = normalize_editor_object_key(input.object_key);
let asset_object_id = normalize_optional_string(input.asset_object_id);
let image_src = normalize_editor_persisted_media_src(input.image_src, object_key.as_deref())?;
let now_micros = current_utc_micros();
let resource = if let Some(project_id) = normalize_optional_string(input.project_id.clone()) {
let record = state
.spacetime_client()
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
project_id,
owner_user_id: input.owner_user_id.clone(),
asset_object_id: asset_object_id.clone(),
image_src: image_src.clone(),
object_key: object_key.clone(),
width: input.width,
height: input.height,
source_type: "generated".to_string(),
prompt: Some(input.prompt.clone()),
actual_prompt: input.actual_prompt.clone(),
model: Some(input.model.clone()),
provider: Some(input.provider.clone()),
task_id: Some(input.task_id.clone()),
source_resource_id: normalize_optional_string(input.source_resource_id.clone()),
asset_kind: normalize_optional_string(input.asset_kind.clone()),
generation_inputs_json: generation_inputs_json.clone(),
updated_at_micros: now_micros,
})
.await
.map_err(map_editor_project_error)?;
Some(editor_project_resource_payload_from_record(record))
} else {
None
};
let folder_id =
normalize_generated_asset_folder_id(input.folder_id, input.owner_user_id.as_str());
let created_resource_id = resource
.as_ref()
.map(|resource| resource.resource_id.clone())
.or_else(|| normalize_optional_string(input.source_resource_id.clone()));
let asset = if let Some(folder_id) = folder_id {
let record = state
.spacetime_client()
.create_editor_asset(EditorAssetCreateRecordInput {
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
owner_user_id: input.owner_user_id.clone(),
folder_id,
label: input.label,
asset_object_id,
image_src,
object_key,
width: input.width,
height: input.height,
source_type: "generated".to_string(),
prompt: Some(input.prompt),
actual_prompt: input.actual_prompt,
model: Some(input.model),
provider: Some(input.provider),
task_id: Some(input.task_id),
asset_kind: normalize_optional_string(input.asset_kind),
generation_inputs_json,
source_resource_id: created_resource_id,
generation_cost_mud_points: input.generation_cost_mud_points,
now_micros,
thumbnail_src: normalize_optional_string(input.thumbnail_src),
group_task_id: normalize_optional_string(input.group_task_id),
group_task_expected_asset_count: input.group_task_expected_asset_count,
})
.await
.map_err(map_editor_project_error)?;
Some(editor_asset_payload_from_record(record))
} else {
None
};
Ok(EditorGeneratedAssetRecord { resource, asset })
}
pub(crate) async fn persist_editor_generated_media_asset(
state: &AppState,
input: PersistEditorGeneratedAssetRequest,
) -> Result<
(
Option<EditorProjectResourcePayload>,
Option<EditorAssetPayload>,
),
AppError,
> {
let persisted = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: input.project_id,
owner_user_id: input.owner_user_id,
folder_id: input.folder_id,
label: input.label,
image_src: input.image_src,
object_key: input.object_key,
asset_object_id: input.asset_object_id,
width: input.width,
height: input.height,
prompt: input.prompt,
actual_prompt: input.actual_prompt,
model: input.model,
provider: input.provider,
task_id: input.task_id,
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: input.source_resource_id,
asset_kind: input.asset_kind,
generation_inputs: input.generation_inputs,
thumbnail_src: input.thumbnail_src,
generation_cost_mud_points: input.generation_cost_mud_points,
},
)
.await?;
Ok((persisted.resource, persisted.asset))
}
pub(crate) async fn complete_editor_canvas_generation(
state: &AppState,
owner_user_id: &str,
project_id: Option<&str>,
completion: Option<&EditorCanvasGenerationCompletionRequest>,
resource: Option<&EditorProjectResourcePayload>,
) -> Result<Option<EditorProjectPayload>, AppError> {
let (Some(project_id), Some(completion), Some(resource)) = (project_id, completion, resource)
else {
return Ok(None);
};
let project_id = project_id.trim();
if project_id.is_empty() {
return Ok(None);
}
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.to_string(),
owner_user_id: owner_user_id.to_string(),
})
.await
.map_err(map_editor_project_error)?;
let project = repair_editor_project_record_inline_media(state, project).await;
let 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>,
replacement_title: Option<&str>,
recenter_replacement: bool,
) -> Result<Option<EditorProjectPayload>, AppError> {
let (Some(project_id), Some(target_layer_id), Some(resource)) =
(project_id, target_layer_id, resource)
else {
return Ok(None);
};
let project_id = project_id.trim();
let target_layer_id = target_layer_id.trim();
if project_id.is_empty() || target_layer_id.is_empty() {
return Ok(None);
}
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.to_string(),
owner_user_id: owner_user_id.to_string(),
})
.await
.map_err(map_editor_project_error)?;
let project = repair_editor_project_record_inline_media(state, project).await;
let viewport = project.viewport.clone();
let project_payload = editor_project_payload_from_record(project);
let Some(layers) = replace_editor_canvas_layer_with_resource(
project_payload.layers,
target_layer_id,
resource,
replacement_title,
recenter_replacement,
) else {
return Ok(None);
};
let saved = 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,
replacement_title: Option<&str>,
recenter_replacement: bool,
) -> Option<Value> {
let mut items = match layers {
Value::Array(items) => items,
_ => return None,
};
let mut changed = false;
for item in &mut items {
let Some(object) = item.as_object_mut() else {
continue;
};
let is_target = object
.get("layerId")
.and_then(Value::as_str)
.is_some_and(|layer_id| layer_id == target_layer_id);
if !is_target {
continue;
}
let replacement_width = f64::from(resource.width.max(1));
let replacement_height = f64::from(resource.height.max(1));
let centered_position = recenter_replacement
.then(|| {
object
.get("x")
.and_then(Value::as_f64)
.zip(object.get("y").and_then(Value::as_f64))
.zip(object.get("width").and_then(Value::as_f64))
.zip(object.get("height").and_then(Value::as_f64))
.map(|(((x, y), width), height)| {
(
x + width / 2.0 - replacement_width / 2.0,
y + height / 2.0 - replacement_height / 2.0,
)
})
})
.flatten();
object.insert(
"resourceId".to_string(),
Value::String(resource.resource_id.clone()),
);
if let Some(title) = replacement_title
.map(str::trim)
.filter(|title| !title.is_empty())
{
object.insert("title".to_string(), Value::String(title.to_string()));
}
object.insert("src".to_string(), Value::String(resource.image_src.clone()));
if let Some((x, y)) = centered_position {
object.insert("x".to_string(), json!(x));
object.insert("y".to_string(), json!(y));
}
object.insert("width".to_string(), json!(resource.width.max(1)));
object.insert("height".to_string(), json!(resource.height.max(1)));
object.insert("originalWidth".to_string(), json!(resource.width.max(1)));
object.insert("originalHeight".to_string(), json!(resource.height.max(1)));
object.insert(
"sourceType".to_string(),
Value::String(resource.source_type.clone()),
);
object.insert("mediaType".to_string(), Value::String("image".to_string()));
set_optional_json_string_field(object, "prompt", resource.prompt.as_ref());
set_optional_json_string_field(object, "actualPrompt", resource.actual_prompt.as_ref());
set_optional_json_string_field(object, "model", resource.model.as_ref());
set_optional_json_string_field(object, "provider", resource.provider.as_ref());
set_optional_json_string_field(object, "taskId", resource.task_id.as_ref());
set_optional_json_string_field(object, "objectKey", resource.object_key.as_ref());
set_optional_json_string_field(object, "assetObjectId", resource.asset_object_id.as_ref());
set_optional_json_string_field(
object,
"sourceResourceId",
resource.source_resource_id.as_ref(),
);
set_optional_json_string_field(object, "assetKind", resource.asset_kind.as_ref());
set_optional_json_value_field(
object,
"generationInputs",
resource.generation_inputs.clone(),
);
object.remove("sourceAssetId");
object.remove("thumbnailSrc");
object.remove("imageSequenceFrames");
object.remove("previewVideoPath");
object.remove("durationSeconds");
changed = true;
break;
}
changed.then_some(Value::Array(items))
}
fn set_optional_json_string_field(
object: &mut serde_json::Map<String, Value>,
field: &str,
value: Option<&String>,
) {
match value
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(value) => {
object.insert(field.to_string(), Value::String(value.to_string()));
}
None => {
object.remove(field);
}
}
}
fn set_optional_json_value_field(
object: &mut serde_json::Map<String, Value>,
field: &str,
value: Option<Value>,
) {
match value {
Some(value) if !value.is_null() => {
object.insert(field.to_string(), value);
}
_ => {
object.remove(field);
}
}
}
fn translate_editor_canvas_generation_items(
mut items: Vec<Value>,
submitted_placeholder: &EditorCanvasGenerationPlaceholderPayload,
resolved_placeholder: &EditorCanvasGenerationPlaceholderPayload,
) -> Vec<Value> {
let dx = resolved_placeholder.x - submitted_placeholder.x;
let dy = resolved_placeholder.y - submitted_placeholder.y;
if !dx.is_finite() || !dy.is_finite() || (dx == 0.0 && dy == 0.0) {
return items;
}
for item in &mut items {
let Some(object) = item.as_object_mut() else {
continue;
};
if let Some(x) = object.get("x").and_then(Value::as_f64)
&& (x + dx).is_finite()
{
object.insert("x".to_string(), json!(x + dx));
}
if let Some(y) = object.get("y").and_then(Value::as_f64)
&& (y + dy).is_finite()
{
object.insert("y".to_string(), json!(y + dy));
}
}
items
}
fn resolve_canvas_completion_placeholder(
layers: Value,
completion: &EditorCanvasGenerationCompletionRequest,
) -> Result<Option<EditorCanvasGenerationPlaceholderPayload>, AppError> {
let dialog_id = normalized_canvas_completion_dialog_id(completion);
let items = match layers {
Value::Array(items) => items,
_ => Vec::new(),
};
if let Some(dialog_id) = dialog_id.as_deref() {
return items
.iter()
.find(|item| generation_dialog_item_matches(item, dialog_id))
.map(|item| {
generation_dialog_item_placeholder(item, dialog_id)
.unwrap_or_else(|| completion.placeholder.clone())
})
.map(Some)
.map_or(Ok(None), Ok);
}
Ok(Some(completion.placeholder.clone()))
}
fn apply_editor_canvas_generation_items(
layers: Value,
completion: &EditorCanvasGenerationCompletionRequest,
_placeholder: EditorCanvasGenerationPlaceholderPayload,
generated_items: Vec<Value>,
primary_layer_id: Option<String>,
) -> Result<EditorCanvasGenerationCompletionResult, AppError> {
let mut items = match layers {
Value::Array(items) => items,
_ => Vec::new(),
};
let dialog_id = normalized_canvas_completion_dialog_id(completion);
if let Some(dialog_id) = dialog_id.as_deref()
&& !items
.iter()
.any(|item| generation_dialog_item_matches(item, dialog_id))
{
return Ok(EditorCanvasGenerationCompletionResult {
layers: Value::Array(items),
changed: false,
});
}
let mut changed = false;
let mut next_z_index = next_canvas_layer_z_index(&items);
let mut completed_layer_id = primary_layer_id;
for mut item in generated_items {
let existing_layer_id =
item.get("resourceId")
.and_then(Value::as_str)
.and_then(|resource_id| {
items.iter().find_map(|existing| {
let existing_resource_id =
existing.get("resourceId").and_then(Value::as_str)?;
if existing_resource_id == resource_id {
existing
.get("layerId")
.and_then(Value::as_str)
.map(str::to_string)
} else {
None
}
})
});
if let Some(existing_layer_id) = existing_layer_id {
completed_layer_id = Some(existing_layer_id);
continue;
}
let needs_z_index = match item.get("zIndex").and_then(Value::as_i64) {
Some(value) => value <= 0,
None => true,
};
if needs_z_index && let Some(object) = item.as_object_mut() {
object.insert("zIndex".to_string(), json!(next_z_index));
next_z_index += 1;
}
if completed_layer_id.is_none() {
completed_layer_id = item
.get("layerId")
.and_then(Value::as_str)
.map(str::to_string);
}
items.push(item);
changed = true;
}
if let Some(dialog_id) = dialog_id.as_deref() {
for item in &mut items {
if generation_dialog_item_matches(item, dialog_id)
&& let Some(dialog) = item.get_mut("dialog").and_then(Value::as_object_mut)
{
dialog.insert("status".to_string(), json!("idle"));
dialog.insert("composerOpen".to_string(), json!(false));
if let Some(completed_layer_id) = completed_layer_id.as_deref() {
dialog.insert("generatedLayerId".to_string(), json!(completed_layer_id));
}
dialog.remove("errorMessage");
changed = true;
}
}
}
Ok(EditorCanvasGenerationCompletionResult {
layers: Value::Array(items),
changed,
})
}
fn normalized_canvas_completion_dialog_id(
completion: &EditorCanvasGenerationCompletionRequest,
) -> Option<String> {
completion
.dialog_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn build_generated_canvas_layer_item(
completion: &EditorCanvasGenerationCompletionRequest,
placeholder: &EditorCanvasGenerationPlaceholderPayload,
resource: &EditorProjectResourcePayload,
generated_layer_id: &str,
z_index: i64,
) -> Value {
let original_width = positive_f64(resource.width as f64, placeholder.original_width);
let original_height = positive_f64(resource.height as f64, placeholder.original_height);
let width = positive_f64(original_width, placeholder.width);
let height = positive_f64(original_height, placeholder.height);
let placeholder_width = positive_f64(placeholder.width, width);
let placeholder_height = positive_f64(placeholder.height, height);
let x = placeholder.x + placeholder_width / 2.0 - width / 2.0;
let y = placeholder.y + placeholder_height / 2.0 - height / 2.0;
let asset_kind = resource.asset_kind.as_deref();
json!({
"layerId": generated_layer_id,
"resourceId": resource.resource_id,
"title": completion.title.trim(),
"src": resource.image_src,
"x": x,
"y": y,
"width": width,
"height": height,
"originalWidth": original_width,
"originalHeight": original_height,
"zIndex": z_index,
"sourceType": "generated",
"mediaType": generated_canvas_media_type(asset_kind),
"prompt": resource.prompt,
"actualPrompt": resource.actual_prompt,
"model": resource.model,
"provider": resource.provider,
"taskId": resource.task_id,
"objectKey": resource.object_key,
"assetObjectId": resource.asset_object_id,
"sourceResourceId": resource.source_resource_id,
"assetKind": resource.asset_kind,
"generationInputs": resource.generation_inputs,
})
}
pub(crate) fn build_editor_canvas_generated_layer_item(
completion: &EditorCanvasGenerationCompletionRequest,
input: EditorCanvasGeneratedLayerInput,
) -> Value {
let placeholder = &completion.placeholder;
let original_width = positive_f64(input.original_width as f64, placeholder.original_width);
let original_height = positive_f64(input.original_height as f64, placeholder.original_height);
let width = positive_f64(input.display_width, placeholder.width);
let height = positive_f64(input.display_height, placeholder.height);
let placeholder_width = positive_f64(placeholder.width, width);
let placeholder_height = positive_f64(placeholder.height, height);
let x = placeholder.x + placeholder_width / 2.0 - width / 2.0;
let y = placeholder.y + placeholder_height / 2.0 - height / 2.0;
json!({
"layerId": input.layer_id,
"resourceId": input.resource_id,
"title": input.title,
"src": input.src,
"mediaType": input.media_type,
"thumbnailSrc": input.thumbnail_src,
"x": x,
"y": y,
"width": width,
"height": height,
"originalWidth": original_width,
"originalHeight": original_height,
"sourceType": input.source_type,
"prompt": input.prompt,
"actualPrompt": input.actual_prompt,
"model": input.model,
"provider": input.provider,
"taskId": input.task_id,
"objectKey": input.object_key,
"assetObjectId": input.asset_object_id,
"durationSeconds": input.duration_seconds,
"sourceResourceId": input.source_resource_id,
"assetKind": input.asset_kind,
"generationInputs": input.generation_inputs,
"imageSequenceFrames": input.image_sequence_frames,
"previewVideoPath": input.preview_video_path,
})
}
fn positive_f64(value: f64, fallback: f64) -> f64 {
if value.is_finite() && value > 0.0 {
value.round().max(1.0)
} else if fallback.is_finite() && fallback > 0.0 {
fallback.round().max(1.0)
} else {
1.0
}
}
fn generated_canvas_layer_id(resource_id: &str) -> String {
format!("layer-editor-resource-{resource_id}")
}
fn generated_canvas_media_type(asset_kind: Option<&str>) -> &'static str {
match asset_kind {
Some("video") => "video",
Some("sound-effect") | Some("background-music") => "audio",
_ => "image",
}
}
fn build_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())
}
fn resolve_editor_generated_asset_label(value: Option<String>, fallback: &str) -> String {
normalize_optional_string(value)
.unwrap_or_else(|| fallback.to_string())
.chars()
.take(EDITOR_ASSET_LABEL_MAX_CHARS)
.collect()
}
fn editor_generated_asset_variant_label(label: &str, variant: &str) -> String {
let suffix = format!("({variant})");
let base_char_limit = EDITOR_ASSET_LABEL_MAX_CHARS.saturating_sub(suffix.chars().count());
let base = label
.trim()
.chars()
.take(base_char_limit)
.collect::<String>();
format!("{base}{suffix}")
}
pub(crate) fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|character| match character {
'a'..='z' | '0'..='9' | '-' | '_' => character,
'A'..='Z' => character.to_ascii_lowercase(),
_ => '-',
})
.collect::<String>()
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
if normalized.is_empty() {
fallback.to_string()
} else {
normalized
}
}
fn normalize_icon_descriptions(descriptions: Vec<String>) -> Result<Vec<String>, AppError> {
let normalized = descriptions
.into_iter()
.map(|description| description.trim().to_string())
.filter(|description| !description.is_empty())
.take(EDITOR_ICON_DESCRIPTION_LIMIT.saturating_add(1))
.collect::<Vec<_>>();
if normalized.is_empty() || normalized.len() > EDITOR_ICON_DESCRIPTION_LIMIT {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"message": "图标素材描述数量必须在 1 到 100 个之间。",
})),
);
}
Ok(normalized)
}
async fn resolve_editor_icon_spritesheet_price(
state: &AppState,
model: Option<&str>,
image_size: Option<&str>,
) -> Result<u32, AppError> {
let expected_price_mud_points = state
.editor_generation_pricing()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(Some("icon"), model, image_size);
Ok(expected_price_mud_points)
}
fn editor_ui_design_asset_extraction_bad_request(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-ui-design-asset-extraction",
"message": message.into(),
}))
}
fn normalize_editor_ui_design_asset_extraction_options(
model: Option<&str>,
aspect_ratio: &str,
image_size: &str,
) -> Result<EditorGenerationOptions, AppError> {
if aspect_ratio.trim() != "1:1" {
return Err(editor_ui_design_asset_extraction_bad_request(
"aspectRatio 必须固定为 1:1。",
));
}
let normalized_image_size = match image_size.trim().to_ascii_uppercase().as_str() {
"1K" => "1K",
"2K" => "2K",
_ => {
return Err(editor_ui_design_asset_extraction_bad_request(
"imageSize 仅支持 1K 或 2K。",
));
}
};
let model = model
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(EDITOR_IMAGE_MODEL_NANOBANANA2);
Ok(normalize_editor_generation_options(
Some(model),
Some("1:1"),
Some(normalized_image_size),
))
}
async fn resolve_editor_ui_design_asset_extraction_price(
state: &AppState,
model: Option<&str>,
image_size: Option<&str>,
) -> Result<u32, AppError> {
let expected_price_mud_points = state
.editor_generation_pricing()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(Some("icon"), model, image_size);
Ok(expected_price_mud_points)
}
fn build_editor_icon_spritesheet_prompt(
icon_descriptions: &[String],
screen_color: EditorScreenBackgroundColor,
) -> String {
format!(
"参考图1的图标素材规范,{};禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}",
editor_green_screen_asset_prompt_clause(screen_color),
icon_descriptions.join("、")
)
}
fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool) -> String {
let mut prompt = vec![
"生成玩法UI原型图".to_string(),
format!("【用户输入】{}", user_input.trim()),
];
if has_icon_spec_reference {
prompt.push("参考图1为图标素材规范,请在UI图标、按钮符号、描边、材质、圆角、阴影和状态层级上严格遵循参考图1的素材规范。".to_string());
}
prompt.join("\n")
}
fn build_editor_character_image_prompt(
role_setting: &str,
screen_color: EditorScreenBackgroundColor,
) -> String {
vec![
format!("按照角色描述生成游戏角色立绘。严格基于图1的角色美术视觉规范的美术风格、角色头身比、角色朝向等特征。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。{};禁止生成美术视觉规范,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。", editor_green_screen_character_prompt_clause(screen_color)),
format!("角色设定:{}", role_setting.trim()),
]
.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct EditorGenerationOptions {
model: &'static str,
aspect_ratio: &'static str,
image_size: &'static str,
size: String,
provider_image_size: &'static str,
}
pub(crate) struct PersistedEditorGeneratedImage {
pub(crate) object_key: String,
pub(crate) asset_object_id: String,
}
struct PersistEditorProviderSourceResourceInput {
project_id: Option<String>,
owner_user_id: String,
folder_id: Option<String>,
label: String,
width: u32,
height: u32,
prompt: String,
actual_prompt: Option<String>,
model: String,
task_id: String,
source_resource_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
generation_cost_mud_points: u64,
}
pub(crate) async fn persist_editor_generated_image(
state: &AppState,
owner_user_id: &str,
task_id: &str,
image: &DownloadedOpenAiImage,
prompt: &str,
actual_prompt: Option<&str>,
asset_kind: &str,
path_kind: &str,
file_stem: &str,
slot: &str,
provider: &str,
) -> Result<PersistedEditorGeneratedImage, AppError> {
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_provider_source_image(
state: &AppState,
owner_user_id: &str,
task_id: &str,
image: &DownloadedOpenAiImage,
prompt: &str,
actual_prompt: Option<&str>,
asset_kind: &str,
path_kind: &str,
file_stem: &str,
) -> Result<PersistedEditorGeneratedImage, AppError> {
persist_editor_generated_image(
state,
owner_user_id,
task_id,
image,
prompt,
actual_prompt,
asset_kind,
path_kind,
&format!("{file_stem}-provider-source"),
EDITOR_PROVIDER_SOURCE_SLOT,
"vector-engine",
)
.await
}
async fn persist_editor_provider_source_resource(
state: &AppState,
persisted: PersistedEditorGeneratedImage,
input: PersistEditorProviderSourceResourceInput,
) -> Result<EditorGeneratedAssetRecord, AppError> {
persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: input.project_id,
owner_user_id: input.owner_user_id,
folder_id: input.folder_id,
label: input.label,
image_src: editor_media_src_from_object_key(persisted.object_key.as_str()),
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
width: input.width,
height: input.height,
prompt: input.prompt,
actual_prompt: input.actual_prompt,
model: input.model,
provider: "VectorEngine".to_string(),
task_id: input.task_id,
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: input.source_resource_id,
asset_kind: input.asset_kind,
generation_inputs: input.generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: input.generation_cost_mud_points,
},
)
.await
}
const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS: u64 = 300;
const EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES: u64 = 32 * 1024 * 1024;
const EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
const EDITOR_BACKGROUND_REMOVAL_MAX_IMAGE_DIMENSION: u32 = 8192;
fn is_editor_inline_media_source(source: &str) -> bool {
let normalized = source.trim_start().to_ascii_lowercase();
normalized.starts_with("data:") || normalized.starts_with("blob:")
}
fn ensure_editor_reference_image_source_is_stable(
source: &str,
provider: &str,
field: &str,
label: &str,
) -> Result<(), AppError> {
if !is_editor_inline_media_source(source) {
return Ok(());
}
Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": provider,
"field": field,
"message": format!("{label}必须先上传 OSS,并使用 objectKey、项目资源 ID 或素材 ID 引用。"),
})),
)
}
fn ensure_editor_reference_image_sources_are_stable(
sources: Option<&[String]>,
provider: &str,
field: &str,
label: &str,
) -> Result<(), AppError> {
for source in sources.unwrap_or(&[]) {
ensure_editor_reference_image_source_is_stable(source, provider, field, label)?;
}
Ok(())
}
pub(crate) async fn parse_editor_reference_image(
state: &AppState,
owner_user_id: &str,
source: &str,
) -> Result<OpenAiReferenceImage, AppError> {
let trimmed = source.trim();
if is_editor_inline_media_source(trimmed) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-reference-image",
"field": "sourceImageSrc",
"message": "图片参考图必须先上传 OSS,并使用 objectKey、项目资源 ID 或素材 ID 引用。",
})),
);
}
let object_key =
resolve_editor_reference_object_key_for_owner(state, owner_user_id, trimmed).await?;
read_editor_reference_image_object(state, object_key.as_str()).await
}
pub(crate) async fn resolve_editor_reference_object_key_for_owner(
state: &AppState,
owner_user_id: &str,
source: &str,
) -> Result<String, AppError> {
let object_key = resolve_editor_reference_object_key(state, owner_user_id, source).await?;
ensure_editor_reference_object_key_owned(state, owner_user_id, object_key.as_str()).await?;
Ok(object_key)
}
fn normalize_editor_reference_image_sources(sources: Option<&[String]>, limit: usize) -> Vec<&str> {
sources
.unwrap_or(&[])
.iter()
.map(String::as_str)
.map(str::trim)
.filter(|source| !source.is_empty())
.take(limit)
.collect()
}
fn normalize_editor_reference_object_key(source: &str) -> Result<String, AppError> {
let object_key = source.trim().trim_start_matches('/').to_string();
if object_key.is_empty() || LegacyAssetPrefix::from_object_key(object_key.as_str()).is_none() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-reference-image",
"field": "sourceImageSrc",
"message": "图片参考图必须是已登记的 generated objectKey、项目资源或素材引用。",
})),
);
}
Ok(object_key)
}
async fn resolve_editor_reference_object_key(
state: &AppState,
owner_user_id: &str,
source: &str,
) -> Result<String, AppError> {
match normalize_editor_reference_object_key(source) {
Ok(object_key) => Ok(object_key),
Err(error) => {
find_editor_reference_object_key_by_registered_id(state, owner_user_id, source.trim())
.await?
.ok_or(error)
}
}
}
async fn find_editor_reference_object_key_by_registered_id(
state: &AppState,
owner_user_id: &str,
source: &str,
) -> Result<Option<String>, AppError> {
if source.is_empty() {
return Ok(None);
}
let projects = state
.spacetime_client()
.list_editor_projects(owner_user_id.to_string())
.await
.map_err(map_editor_project_error)?;
if let Some(object_key) = projects
.iter()
.flat_map(|project| project.resources.iter())
.find_map(|resource| editor_reference_resource_object_key(resource, source))
{
return Ok(Some(object_key));
}
let library = state
.spacetime_client()
.get_editor_asset_library(owner_user_id.to_string(), current_utc_micros())
.await
.map_err(map_editor_project_error)?;
Ok(library
.assets
.iter()
.find_map(|asset| editor_reference_asset_object_key(asset, source)))
}
fn editor_reference_resource_object_key(
resource: &EditorProjectResourceRecord,
source: &str,
) -> Option<String> {
(resource.resource_id.trim() == source)
.then(|| {
normalize_editor_record_object_key(
resource.object_key.as_deref(),
resource.image_src.as_str(),
)
})
.flatten()
}
fn editor_reference_asset_object_key(asset: &EditorAssetRecord, source: &str) -> Option<String> {
(asset.asset_id.trim() == source)
.then(|| {
normalize_editor_record_object_key(
asset.object_key.as_deref(),
asset.image_src.as_str(),
)
})
.flatten()
}
fn normalize_editor_record_object_key(object_key: Option<&str>, image_src: &str) -> Option<String> {
object_key
.map(str::trim)
.map(|value| value.trim_start_matches('/'))
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| normalize_editor_reference_object_key(image_src).ok())
}
async fn ensure_editor_reference_object_key_owned(
state: &AppState,
owner_user_id: &str,
object_key: &str,
) -> Result<(), AppError> {
let owner_user_id = owner_user_id.trim();
if owner_user_id.is_empty() {
return Err(editor_reference_image_forbidden(object_key));
}
let projects = state
.spacetime_client()
.list_editor_projects(owner_user_id.to_string())
.await
.map_err(map_editor_project_error)?;
if projects.iter().any(|project| {
project.resources.iter().any(|resource| {
editor_record_object_key_matches(
resource.object_key.as_deref(),
resource.image_src.as_str(),
object_key,
)
})
}) {
return Ok(());
}
let library = state
.spacetime_client()
.get_editor_asset_library(owner_user_id.to_string(), current_utc_micros())
.await
.map_err(map_editor_project_error)?;
if library.assets.iter().any(|asset| {
editor_record_object_key_matches(
asset.object_key.as_deref(),
asset.image_src.as_str(),
object_key,
)
}) {
return Ok(());
}
let Some(oss_client) = state.oss_client() else {
return Err(editor_reference_image_forbidden(object_key));
};
if let Some(asset_object) = state
.spacetime_client()
.get_asset_object_by_location(module_assets::AssetObjectLocationInput {
bucket: oss_client.config_bucket().to_string(),
object_key: object_key.to_string(),
})
.await
.map_err(map_editor_project_error)?
{
validate_editor_reference_asset_object(&asset_object, owner_user_id, state, object_key)?;
return Ok(());
}
Err(editor_reference_image_forbidden(object_key))
}
fn validate_editor_reference_asset_object(
asset_object: &module_assets::AssetObjectRecord,
owner_user_id: &str,
state: &AppState,
object_key: &str,
) -> Result<(), AppError> {
let Some(oss_client) = state.oss_client() else {
return Err(editor_reference_image_forbidden(object_key));
};
let owned = asset_object
.owner_user_id
.as_deref()
.map(str::trim)
.is_some_and(|value| value == owner_user_id);
let bucket_matches = asset_object.bucket.trim() == oss_client.config_bucket().trim();
let key_matches = asset_object.object_key.trim().trim_start_matches('/') == object_key;
if owned && bucket_matches && key_matches {
Ok(())
} else {
Err(editor_reference_image_forbidden(object_key))
}
}
fn editor_record_object_key_matches(
record_object_key: Option<&str>,
image_src: &str,
object_key: &str,
) -> bool {
normalize_editor_record_object_key(record_object_key, image_src)
.is_some_and(|value| value == object_key)
}
fn editor_reference_image_forbidden(object_key: &str) -> AppError {
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "editor-reference-image",
"field": "sourceImageSrc",
"message": "图片参考图不属于当前账号。",
"objectKey": object_key,
}))
}
async fn read_editor_reference_image_object(
state: &AppState,
source: &str,
) -> Result<OpenAiReferenceImage, AppError> {
if source.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-reference-image",
"field": "sourceImageSrc",
"message": "图片参考图不能为空。",
})),
);
}
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: source.to_string(),
expire_seconds: Some(EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS),
})
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let 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};
use std::{
io::{Read as _, Write as _},
net::{TcpListener, TcpStream},
sync::mpsc,
thread,
};
fn encode_test_png(width: u32, height: u32) -> Vec<u8> {
let image = image::DynamicImage::new_rgba8(width, height);
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, image::ImageFormat::Png)
.expect("test PNG should encode");
bytes.into_inner()
}
fn read_mock_http_request(stream: &mut TcpStream) -> Vec<u8> {
stream
.set_read_timeout(Some(Duration::from_secs(1)))
.expect("mock request read timeout should be set");
let mut request = Vec::new();
let mut chunk = [0_u8; 1024];
let mut expected_total = None;
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(bytes_read) => {
request.extend_from_slice(&chunk[..bytes_read]);
if expected_total.is_none()
&& let Some(header_end) = request
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
{
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
expected_total = Some(header_end + content_length);
}
if expected_total.is_some_and(|total| request.len() >= total) {
break;
}
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut =>
{
break;
}
Err(error) => panic!("mock server failed to read request: {error}"),
}
}
request
}
fn spawn_bgfilter_png_mock(
response_png: Vec<u8>,
) -> (String, mpsc::Receiver<Vec<u8>>, thread::JoinHandle<()>) {
spawn_bgfilter_png_mock_with_delay(response_png, Duration::ZERO)
}
fn spawn_bgfilter_png_mock_with_delay(
response_png: Vec<u8>,
response_delay: Duration,
) -> (String, mpsc::Receiver<Vec<u8>>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener should bind");
let address = listener
.local_addr()
.expect("mock listener should expose address");
let (request_sender, request_receiver) = mpsc::channel();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("BgFilter request should connect");
let request = read_mock_http_request(&mut stream);
request_sender
.send(request)
.expect("captured request should be delivered");
thread::sleep(response_delay);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nX-BgFilter-Elapsed-Ms: 9\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
response_png.len()
);
stream
.write_all(headers.as_bytes())
.expect("mock response headers should be written");
stream
.write_all(response_png.as_slice())
.expect("mock response PNG should be written");
stream.flush().expect("mock response should flush");
});
(format!("http://{address}"), request_receiver, server)
}
fn editor_project_resource_for_canvas_test(
resource_id: &str,
asset_kind: &str,
width: u32,
height: u32,
) -> EditorProjectResourcePayload {
EditorProjectResourcePayload {
resource_id: resource_id.to_string(),
showcase_id: None,
asset_id: None,
label: None,
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
author_display_name: None,
author_public_user_code: None,
image_src: format!("/generated-character-drafts/editor/{resource_id}.png"),
object_key: Some(format!(
"generated-character-drafts/editor/{resource_id}.png"
)),
asset_object_id: Some(format!("asset-object-{resource_id}")),
width,
height,
source_type: "generated".to_string(),
prompt: Some("银发游侠".to_string()),
actual_prompt: Some("实际提示词".to_string()),
model: Some("gpt-image-2".to_string()),
provider: Some("VectorEngine".to_string()),
task_id: Some("task-character".to_string()),
source_resource_id: None,
asset_kind: Some(asset_kind.to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-07-13T00:00:00.000Z".to_string(),
updated_at: "2026-07-13T00:00:00.000Z".to_string(),
}
}
#[test]
fn editor_project_procedure_errors_preserve_business_reason_as_bad_request() {
let error = map_editor_project_error(SpacetimeClientError::Procedure(
"只有生成素材可以提交精选审核".to_string(),
));
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("message")),
Some(&json!("只有生成素材可以提交精选审核")),
);
}
#[test]
fn 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_layout_serialization_accepts_payload_up_to_two_mib() {
let payload = json!("x".repeat(EDITOR_LAYOUT_MAX_BYTES - 2));
let serialized = serialize_editor_layers(payload).expect("two MiB layout should save");
assert_eq!(serialized.len(), EDITOR_LAYOUT_MAX_BYTES);
}
#[test]
fn editor_layout_serialization_rejects_payload_over_two_mib() {
let payload = json!("x".repeat(EDITOR_LAYOUT_MAX_BYTES - 1));
let error = serialize_editor_layers(payload).expect_err("oversized layout should fail");
assert!(error.body_text().contains("图层布局过大"));
}
#[test]
fn editor_asset_metadata_serialization_rejects_nested_inline_media() {
let error = serialize_editor_asset_metadata(Some(json!({
"references": [
{
"title": "参考图",
"src": "data:image/png;base64,reference"
}
]
})))
.expect_err("metadata should reject nested inline image data");
assert!(error.body_text().contains("资产元数据"));
assert!(
serialize_editor_asset_metadata(Some(json!({
"references": [
{
"title": "参考图",
"src": "/generated-character-drafts/editor/reference.png"
}
]
})))
.is_ok()
);
}
#[test]
fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() {
assert_eq!(normalize_editor_image_generation_size(None), "1024x1024");
assert_eq!(
normalize_editor_image_generation_size(Some("1536x1024")),
"1536x1024"
);
assert_eq!(
normalize_editor_image_generation_size(Some("1024x1536")),
"1024x1536"
);
assert_eq!(
normalize_editor_image_generation_size(Some("2048x1152")),
"2048x1152"
);
assert_eq!(
normalize_editor_image_generation_size(Some("640x640")),
"640x640"
);
assert_eq!(
normalize_editor_image_generation_size(Some("bad-size")),
"1024x1024"
);
}
#[tokio::test]
async fn editor_image_edit_price_uses_selected_model_and_size() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert_eq!(
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "1024x1024", None)
.await
.unwrap(),
3
);
assert_eq!(
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "2048x1152", None)
.await
.unwrap(),
5
);
assert_eq!(
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "2048x1152", Some("1K"),)
.await
.unwrap(),
5
);
assert_eq!(
resolve_editor_image_edit_price(
&state,
EDITOR_IMAGE_MODEL_NANOBANANA2,
"2048x2048",
Some("2K"),
)
.await
.unwrap(),
24
);
assert_eq!(
resolve_editor_image_edit_price(
&state,
EDITOR_IMAGE_MODEL_NANOBANANA2,
"512x512",
Some("0.5K"),
)
.await
.unwrap(),
8
);
assert!(editor_image_edit_uses_nanobanana_generate_content(
EDITOR_IMAGE_MODEL_NANOBANANA2
));
assert!(!editor_image_edit_uses_nanobanana_generate_content(
GPT_IMAGE_2_MODEL
));
}
#[test]
fn editor_image_edit_aligns_provider_images_and_restores_source_dimensions() {
fn encode_png(width: u32, height: u32) -> Vec<u8> {
let image = image::DynamicImage::new_rgba8(width, height);
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, image::ImageFormat::Png)
.expect("test image should encode");
bytes.into_inner()
}
let mut references = vec![
OpenAiReferenceImage {
bytes: encode_png(1537, 1025),
mime_type: "image/png".to_string(),
file_name: "source.png".to_string(),
},
OpenAiReferenceImage {
bytes: encode_png(501, 333),
mime_type: "image/png".to_string(),
file_name: "style.png".to_string(),
},
];
let dimensions = prepare_editor_image_edit_references(&mut references, "1537x1025")
.expect("references should align for provider");
assert_eq!(dimensions.target_width, 1537);
assert_eq!(dimensions.target_height, 1025);
assert_eq!(dimensions.provider_width, 1552);
assert_eq!(dimensions.provider_height, 1040);
let source = image::load_from_memory(references[0].bytes.as_slice()).unwrap();
assert_eq!((source.width(), source.height()), (1552, 1040));
let extra = image::load_from_memory(references[1].bytes.as_slice()).unwrap();
assert_eq!((extra.width(), extra.height()), (512, 336));
let provider_output = DownloadedOpenAiImage {
bytes: encode_png(1552, 1040),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let restored = restore_editor_image_edit_output_dimensions(&provider_output, &dimensions)
.expect("provider output should restore source dimensions")
.expect("aligned provider output should require a transformation");
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
assert_eq!(
(restored_image.width(), restored_image.height()),
(1537, 1025)
);
assert_eq!(restored.mime_type, "image/png");
assert_eq!(restored.extension, "png");
}
#[test]
fn publication_material_generation_restores_provider_output_to_workflow_dimensions() {
let image = image::DynamicImage::new_rgba8(944, 704);
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, image::ImageFormat::Png)
.expect("test image should encode");
let provider_output = DownloadedOpenAiImage {
bytes: bytes.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let restored = restore_editor_generated_image_output_dimensions(
&provider_output,
GPT_IMAGE_2_MODEL,
"720x540",
)
.expect("provider output should restore delivery dimensions")
.expect("mismatched provider output should require a transformation");
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
assert_eq!(
(restored_image.width(), restored_image.height()),
(720, 540)
);
assert_eq!(restored.mime_type, "image/png");
assert_eq!(restored.extension, "png");
}
#[test]
fn nanobanana_generation_keeps_provider_dimensions_for_scalar_size_tiers() {
let image = image::DynamicImage::new_rgba8(896, 896);
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, image::ImageFormat::Png)
.expect("test image should encode");
let provider_output = DownloadedOpenAiImage {
bytes: bytes.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let (restored, restore_error) =
restore_editor_generated_image_output_dimensions_or_original(
provider_output,
EDITOR_IMAGE_MODEL_NANOBANANA2,
"1024",
);
assert!(restore_error.is_none());
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
assert_eq!(
(restored_image.width(), restored_image.height()),
(896, 896)
);
}
#[test]
fn failed_dimension_restore_keeps_provider_output_for_single_upload() {
let image = image::DynamicImage::new_rgba8(896, 896);
let mut bytes = Cursor::new(Vec::new());
image
.write_to(&mut bytes, image::ImageFormat::Png)
.expect("test image should encode");
let provider_output = DownloadedOpenAiImage {
bytes: bytes.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let expected_bytes = provider_output.bytes.clone();
let (delivery_output, restore_error) =
restore_editor_generated_image_output_dimensions_or_original(
provider_output,
GPT_IMAGE_2_MODEL,
"invalid-size",
);
assert!(restore_error.is_some());
assert_eq!(delivery_output.bytes, expected_bytes);
assert_eq!(delivery_output.mime_type, "image/png");
assert_eq!(delivery_output.extension, "png");
}
#[test]
fn editor_generation_dimensions_follow_model_options() {
let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K"));
assert_eq!(default_generation.model, GPT_IMAGE_2_MODEL);
assert_eq!(default_generation.size, "1024x1024");
let nanobanana = normalize_editor_generation_options(
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
Some("1:1"),
Some("0.5K"),
);
assert_eq!(nanobanana.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(nanobanana.size, "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("BgFilter complex".to_string()),
provider: Some("BgFilter".to_string()),
task_id: Some("extgen-cutout".to_string()),
source_resource_id: Some("resource-source".to_string()),
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: Some(json!({
"fields": [{"title": "prompt", "value": "角色"}],
"references": []
})),
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-06-29T00:00:00.000Z".to_string(),
updated_at: "2026-06-29T00:00:00.000Z".to_string(),
};
let replaced = replace_editor_canvas_layer_with_resource(
layers.clone(),
"layer-source",
&resource,
None,
false,
)
.expect("target layer should be replaced");
let items = replaced.as_array().expect("layers should stay array");
let layer = items
.iter()
.find(|item| item["layerId"] == "layer-source")
.expect("source layer should remain");
assert_eq!(items.len(), 2);
assert_eq!(layer["resourceId"], json!("editor-resource-cutout"));
assert_eq!(
layer["src"],
json!("/generated-character-drafts/editor/background-removal/result.png")
);
assert_eq!(layer["width"], json!(512));
assert_eq!(layer["height"], json!(768));
assert_eq!(layer["x"], json!(120));
assert_eq!(layer["y"], json!(140));
assert_eq!(layer["zIndex"], json!(6));
assert_eq!(layer["locked"], json!(true));
assert_eq!(layer["taskId"], json!("extgen-cutout"));
assert_eq!(layer["sourceResourceId"], json!("resource-source"));
assert_eq!(layer["assetKind"], json!("character"));
assert_eq!(layer["title"], json!("源图"));
assert!(layer.get("sourceAssetId").is_none());
assert!(layer.get("thumbnailSrc").is_none());
let recentered = replace_editor_canvas_layer_with_resource(
layers,
"layer-source",
&resource,
Some("勇者快速编辑"),
true,
)
.expect("quick edit replacement should recenter the layer");
let recentered_layer = recentered
.as_array()
.and_then(|items| items.iter().find(|item| item["layerId"] == "layer-source"))
.expect("quick edit source layer should remain");
assert_eq!(recentered_layer["x"], json!(24.0));
assert_eq!(recentered_layer["y"], json!(-124.0));
assert_eq!(recentered_layer["title"], json!("勇者快速编辑"));
assert_eq!(recentered_layer["x"].as_f64().unwrap() + 512.0 / 2.0, 280.0);
assert_eq!(recentered_layer["y"].as_f64().unwrap() + 768.0 / 2.0, 260.0);
}
#[test]
fn editor_canvas_generation_completion_is_idempotent_for_existing_resource_layer() {
let layers = json!([
{
"layerId": "layer-existing-result",
"resourceId": "editor-resource-character",
"zIndex": 7
},
{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-character",
"resourceId": "generation-dialog:dialog-character",
"dialog": {
"id": "dialog-character",
"mode": "character",
"prompt": "银发游侠",
"status": "generating",
"composerOpen": false
}
}
]);
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-character".to_string()),
title: "角色形象 1".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 0.0,
y: 0.0,
width: 1.0,
height: 1.0,
original_width: 1.0,
original_height: 1.0,
},
};
let resource = EditorProjectResourcePayload {
resource_id: "editor-resource-character".to_string(),
showcase_id: None,
asset_id: None,
label: None,
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
author_display_name: None,
author_public_user_code: None,
image_src: "data:image/png;base64,character".to_string(),
object_key: None,
asset_object_id: None,
width: 1024,
height: 1024,
source_type: "generated".to_string(),
prompt: Some("银发游侠".to_string()),
actual_prompt: None,
model: Some("gpt-image-2".to_string()),
provider: Some("VectorEngine".to_string()),
task_id: Some("task-character".to_string()),
source_resource_id: None,
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-06-21T12:16:22.000Z".to_string(),
updated_at: "2026-06-21T12:16:22.000Z".to_string(),
};
let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource)
.expect("completion should apply");
assert!(completed.changed);
let items = completed
.layers
.as_array()
.expect("layers should stay array");
assert_eq!(
items
.iter()
.filter(|item| item["resourceId"] == "editor-resource-character")
.count(),
1
);
let dialog = items
.iter()
.find(|item| item["itemType"] == "generation-dialog")
.and_then(|item| item.get("dialog"))
.expect("dialog should remain");
assert_eq!(dialog["generatedLayerId"], json!("layer-existing-result"));
}
#[test]
fn editor_canvas_generation_completion_uses_latest_layout_placeholder() {
let layers = json!([
{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-character",
"resourceId": "generation-dialog:dialog-character",
"dialog": {
"id": "dialog-character",
"mode": "character",
"prompt": "银发游侠",
"status": "generating",
"composerOpen": false,
"placeholder": {
"x": 500,
"y": 400,
"width": 420,
"height": 560,
"originalWidth": 1024,
"originalHeight": 1536
}
}
}
]);
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-character".to_string()),
title: "角色形象 1".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 0.0,
y: 0.0,
width: 1.0,
height: 1.0,
original_width: 1.0,
original_height: 1.0,
},
};
let resource = EditorProjectResourcePayload {
resource_id: "editor-resource-character".to_string(),
showcase_id: None,
asset_id: None,
label: None,
project_id: "editor-project-1".to_string(),
owner_user_id: "user-1".to_string(),
author_display_name: None,
author_public_user_code: None,
image_src: "data:image/png;base64,character".to_string(),
object_key: None,
asset_object_id: None,
width: 1024,
height: 1536,
source_type: "generated".to_string(),
prompt: Some("银发游侠".to_string()),
actual_prompt: None,
model: Some("gpt-image-2".to_string()),
provider: Some("VectorEngine".to_string()),
task_id: Some("task-character".to_string()),
source_resource_id: None,
asset_kind: Some("character".to_string()),
showcase_category: None,
generation_inputs: None,
public_showcase_enabled: true,
review_status: None,
display_enabled: None,
like_count: None,
generation_cost_mud_points: 0,
refund_mud_points: None,
created_at: "2026-06-21T12:16:22.000Z".to_string(),
updated_at: "2026-06-21T12:16:22.000Z".to_string(),
};
let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource)
.expect("completion should apply");
let result_layer = completed
.layers
.as_array()
.expect("layers should stay array")
.iter()
.find(|item| item["resourceId"] == "editor-resource-character")
.expect("result layer should be inserted");
assert_eq!(result_layer["x"], json!(198.0));
assert_eq!(result_layer["y"], json!(-88.0));
}
#[test]
fn editor_canvas_generation_multi_item_completion_uses_latest_layout_placeholder() {
let layers = json!([
{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-icons",
"resourceId": "generation-dialog:dialog-icons",
"dialog": {
"id": "dialog-icons",
"mode": "icon",
"prompt": "返回按钮",
"status": "generating",
"composerOpen": false,
"placeholder": {
"x": 300,
"y": 240,
"width": 200,
"height": 200,
"originalWidth": 200,
"originalHeight": 200
}
}
}
]);
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-icons".to_string()),
title: "图标素材图集".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 100.0,
y: 80.0,
width: 200.0,
height: 200.0,
original_width: 200.0,
original_height: 200.0,
},
};
let item = json!({
"layerId": "layer-icon-sheet",
"resourceId": "resource-icon-sheet",
"title": "图标素材图集",
"x": 150.0,
"y": 130.0,
"width": 100.0,
"height": 100.0
});
let completed = apply_editor_canvas_generation_multi_items(
layers,
&completion,
vec![item],
Some("layer-icon-sheet".to_string()),
)
.expect("multi-item completion should apply");
let result_layer = completed
.layers
.as_array()
.expect("layers should stay array")
.iter()
.find(|item| item["resourceId"] == "resource-icon-sheet")
.expect("result layer should be inserted");
assert_eq!(result_layer["x"], json!(350.0));
assert_eq!(result_layer["y"], json!(290.0));
}
#[test]
fn editor_canvas_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",
"character",
400,
600,
);
let (items, primary_layer_id) =
build_multi_asset_canvas_layer_items(&completion, Some(&processed), Some(&source));
assert_eq!(items.len(), 2);
assert_eq!(
primary_layer_id.as_deref(),
Some("layer-editor-resource-resource-character-cutout")
);
assert_eq!(items[0]["resourceId"], json!("resource-character-cutout"));
assert_eq!(items[0]["x"], json!(0.0));
assert_eq!(items[0]["y"], json!(-70.0));
assert_eq!(items[1]["resourceId"], json!("resource-character-source"));
assert_eq!(items[1]["title"], json!("角色形象 1(原图)"));
assert_eq!(items[1]["assetKind"], json!("character"));
assert_eq!(items[1]["x"], json!(432.0));
assert_eq!(items[1]["y"], json!(-70.0));
let layers = json!([{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-character",
"resourceId": "generation-dialog:dialog-character",
"dialog": {
"id": "dialog-character",
"status": "generating",
"placeholder": completion.placeholder.clone()
}
}]);
let completed = apply_editor_canvas_generation_multi_items(
layers,
&completion,
items,
primary_layer_id,
)
.expect("multi-asset completion should apply");
let completed_items = completed
.layers
.as_array()
.expect("layers should stay array");
assert_eq!(
completed_items
.iter()
.filter(|item| item
.get("resourceId")
.and_then(Value::as_str)
.is_some_and(|id| id.starts_with("resource-character-")))
.count(),
2
);
let dialog = completed_items
.iter()
.find(|item| item["itemType"] == "generation-dialog")
.and_then(|item| item.get("dialog"))
.expect("generation dialog should remain");
assert_eq!(
dialog["generatedLayerId"],
json!("layer-editor-resource-resource-character-cutout")
);
}
#[test]
fn editor_canvas_postprocess_fallback_only_anchors_provider_source() {
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: Some("dialog-character-fallback".to_string()),
title: "角色形象 1".to_string(),
placeholder: EditorCanvasGenerationPlaceholderPayload {
x: 100.0,
y: 80.0,
width: 200.0,
height: 300.0,
original_width: 400.0,
original_height: 600.0,
},
};
let source = editor_project_resource_for_canvas_test(
"resource-character-source",
"character",
400,
600,
);
let source_layer_id = generated_canvas_layer_id(source.resource_id.as_str());
let source_item = build_generated_canvas_layer_item(
&completion,
&completion.placeholder,
&source,
source_layer_id.as_str(),
0,
);
let layers = json!([{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-character-fallback",
"resourceId": "generation-dialog:dialog-character-fallback",
"dialog": {
"id": "dialog-character-fallback",
"status": "generating",
"placeholder": completion.placeholder.clone()
}
}]);
let completed = apply_editor_canvas_generation_multi_items(
layers,
&completion,
vec![source_item],
Some(source_layer_id.clone()),
)
.expect("source-only fallback should complete the generation dialog");
let completed_items = completed
.layers
.as_array()
.expect("layers should stay array");
let generated_resources = completed_items
.iter()
.filter(|item| {
item.get("resourceId")
.and_then(Value::as_str)
.is_some_and(|resource_id| resource_id.starts_with("resource-character-"))
})
.collect::<Vec<_>>();
assert_eq!(generated_resources.len(), 1);
assert_eq!(
generated_resources[0]["resourceId"],
json!("resource-character-source")
);
let dialog = completed_items
.iter()
.find(|item| item["itemType"] == "generation-dialog")
.and_then(|item| item.get("dialog"))
.expect("generation dialog should remain");
assert_eq!(dialog["generatedLayerId"], json!(source_layer_id));
}
#[test]
fn editor_canvas_spritesheet_items_include_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",
"icon-spritesheet",
512,
512,
);
let slice = editor_project_resource_for_canvas_test("resource-ui-slice", "icon", 128, 128);
let icons = vec![EditorIconSpritesheetIconResponse {
name: "开始按钮".to_string(),
image_src: slice.image_src.clone(),
width: 128,
height: 128,
resource: Some(slice),
asset: None,
}];
let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items(
&completion,
Some(&processed),
Some(&source),
icons.as_slice(),
);
assert_eq!(items.len(), 3);
assert_eq!(
primary_layer_id.as_deref(),
Some("layer-editor-resource-resource-ui-sheet")
);
assert_eq!(items[0]["resourceId"], json!("resource-ui-sheet"));
assert_eq!(items[1]["resourceId"], json!("resource-ui-source"));
assert_eq!(items[1]["assetKind"], json!("icon-spritesheet"));
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",
"icon-spritesheet",
512,
512,
);
let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items(
&completion,
Some(&processed),
Some(&source),
&[],
);
assert_eq!(items.len(), 2);
assert_eq!(items[0]["resourceId"], json!("resource-icon-sheet"));
assert_eq!(items[1]["resourceId"], json!("resource-icon-source"));
assert_eq!(items[1]["assetKind"], json!("icon-spritesheet"));
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_is_not_an_object_reference() {
let error = normalize_editor_reference_object_key("data:image/png;base64,AAAA")
.expect_err("inline image should require upload before generation");
assert!(error.body_text().contains("objectKey"));
}
#[test]
fn editor_reference_resource_id_resolves_registered_object_key() {
let resource = EditorProjectResourceRecord {
resource_id: "editor-resource-icon-spec".to_string(),
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
asset_object_id: Some("asset-object-1".to_string()),
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
object_key: None,
width: 512,
height: 512,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: None,
source_resource_id: None,
asset_kind: Some("icon-spec".to_string()),
generation_inputs: None,
public_showcase_enabled: true,
created_at: "2026-06-23T00:00:00.000Z".to_string(),
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
};
assert_eq!(
editor_reference_resource_object_key(&resource, "editor-resource-icon-spec"),
Some("generated-character-drafts/editor/spec.png".to_string())
);
assert_eq!(
editor_reference_resource_object_key(&resource, "missing"),
None
);
}
#[test]
fn editor_reference_asset_id_resolves_registered_object_key() {
let asset = EditorAssetRecord {
asset_id: "editor-asset-icon-spec".to_string(),
folder_id: "project".to_string(),
label: "图标规范".to_string(),
asset_object_id: Some("asset-object-1".to_string()),
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
thumbnail_src: None,
object_key: None,
width: 512,
height: 512,
source_type: "generated".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: None,
asset_kind: Some("icon-spec".to_string()),
generation_inputs: None,
source_resource_id: None,
public_showcase_enabled: Some(true),
generation_cost_mud_points: 0,
showcase_id: None,
showcase_review_status: None,
showcase_display_enabled: None,
showcase_like_count: None,
created_at: "2026-06-23T00:00:00.000Z".to_string(),
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
};
assert_eq!(
editor_reference_asset_object_key(&asset, "editor-asset-icon-spec"),
Some("generated-character-drafts/editor/spec.png".to_string())
);
assert_eq!(editor_reference_asset_object_key(&asset, "missing"), None);
}
#[test]
fn editor_reference_image_sources_are_trimmed_and_limited() {
let sources = vec![
" generated-character-drafts/editor/a.png ".to_string(),
"".to_string(),
"generated-character-drafts/editor/b.png".to_string(),
"generated-character-drafts/editor/c.png".to_string(),
];
assert_eq!(
normalize_editor_reference_image_sources(Some(sources.as_slice()), 2),
vec![
"generated-character-drafts/editor/a.png",
"generated-character-drafts/editor/b.png"
]
);
}
#[test]
fn editor_reference_object_key_requires_generated_prefix() {
assert_eq!(
normalize_editor_reference_object_key("/generated-character-drafts/editor/ref.png")
.unwrap(),
"generated-character-drafts/editor/ref.png"
);
assert!(normalize_editor_reference_object_key("/private/editor/ref.png").is_err());
}
#[test]
fn editor_reference_object_key_matches_registered_media() {
assert!(editor_record_object_key_matches(
Some("/generated-character-drafts/editor/ref.png"),
"",
"generated-character-drafts/editor/ref.png",
));
assert!(!editor_record_object_key_matches(
Some("generated-character-drafts/editor/other.png"),
"",
"generated-character-drafts/editor/ref.png",
));
}
#[test]
fn editor_reference_asset_object_requires_matching_owner_bucket_and_key() {
let state = AppState::new(AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-beijing.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-ak".to_string()),
oss_access_key_secret: Some("test-sk".to_string()),
..AppConfig::default()
})
.expect("state should build");
let asset_object = module_assets::AssetObjectRecord {
asset_object_id: "assetobj_reference_1".to_string(),
bucket: "genarrative-assets".to_string(),
object_key: "generated-character-drafts/editor/ref.png".to_string(),
access_policy: module_assets::AssetObjectAccessPolicy::Private,
content_type: Some("image/png".to_string()),
content_length: 1024,
content_hash: None,
version: 1,
source_job_id: None,
owner_user_id: Some("user-1".to_string()),
profile_id: None,
entity_id: None,
asset_kind: "editor_generated_image".to_string(),
created_at: "2026-06-27T00:00:00Z".to_string(),
updated_at: "2026-06-27T00:00:00Z".to_string(),
};
assert!(
validate_editor_reference_asset_object(
&asset_object,
"user-1",
&state,
"generated-character-drafts/editor/ref.png",
)
.is_ok()
);
assert!(
validate_editor_reference_asset_object(
&asset_object,
"user-2",
&state,
"generated-character-drafts/editor/ref.png",
)
.is_err()
);
}
#[test]
fn editor_reference_image_mime_type_falls_back_to_magic_bytes() {
assert_eq!(
infer_editor_reference_image_mime_type(b"\x89PNG\r\n\x1a\nrest"),
Some("image/png")
);
assert_eq!(
infer_editor_reference_image_mime_type(&[0xff, 0xd8, 0xff, 0xe0]),
Some("image/jpeg")
);
assert_eq!(
infer_editor_reference_image_mime_type(b"RIFFxxxxWEBPrest"),
Some("image/webp")
);
assert_eq!(infer_editor_reference_image_mime_type(b"not image"), None);
}
#[test]
fn editor_character_postprocess_fallback_response_keeps_source_and_warning() {
let response = EditorImageGenerationResponse {
image_src: "/api/assets/source-character.png".to_string(),
object_key: Some("generated/editor/character-source.png".to_string()),
asset_object_id: Some("asset-object-character-source".to_string()),
width: 1024,
height: 1536,
source_type: "generated",
prompt: "角色形象".to_string(),
actual_prompt: Some("角色形象 actual".to_string()),
model: GPT_IMAGE_2_MODEL.to_string(),
provider: "VectorEngine",
task_id: "task-character-source".to_string(),
resource: None,
asset: None,
project: None,
warning: Some(editor_postprocess_fallback_warning(
"生成任务成功,后处理失败。",
)),
};
let payload = serde_json::to_value(response).expect("response should serialize");
assert_eq!(
payload["imageSrc"],
json!("/api/assets/source-character.png")
);
assert_eq!(
payload["objectKey"],
json!("generated/editor/character-source.png")
);
assert_eq!(
payload["warning"]["code"],
json!(EDITOR_GENERATION_POSTPROCESS_WARNING_CODE)
);
assert_eq!(
payload["warning"]["reason"],
json!("生成任务成功,后处理失败。")
);
}
#[tokio::test]
async fn editor_icon_spritesheet_price_is_resolved_from_generation_config() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert_eq!(
resolve_editor_icon_spritesheet_price(
&state,
Some("gemini-3.1-flash-image-preview"),
Some("1K")
)
.await
.unwrap(),
12
);
assert_eq!(
resolve_editor_icon_spritesheet_price(&state, Some("gpt-image-2"), Some("1K"))
.await
.unwrap(),
3
);
let response = EditorIconSpritesheetGenerationResponse {
spritesheet_image_src: "data:image/png;base64,sheet".to_string(),
spritesheet_width: 512,
spritesheet_height: 512,
icon_image_srcs: Vec::new(),
slice_warning: Some(EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
reason: "图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。".to_string(),
}),
prompt: "图标 prompt".to_string(),
actual_prompt: Some("图标 prompt".to_string()),
model: "gemini-3.1-flash-image-preview".to_string(),
provider: "VectorEngine",
task_id: "task-icons".to_string(),
price_mud_points: crate::editor_generation_config::editor_image_generation_mud_points(
Some("icon"),
None,
Some("1K"),
),
spritesheet_resource: None,
spritesheet_asset: None,
project: None,
warning: None,
};
let payload = serde_json::to_value(response).expect("response should serialize");
assert_eq!(payload["priceMudPoints"], json!(12));
assert_eq!(payload["iconImageSrcs"], json!([]));
assert_eq!(
payload["sliceWarning"]["code"],
json!("insufficient-connected-components")
);
assert_eq!(
payload["sliceWarning"]["reason"],
json!("图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。")
);
}
#[tokio::test]
async fn editor_ui_design_asset_extraction_price_uses_selected_model_and_size() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request: EditorUiDesignAssetExtractionRequest = serde_json::from_value(json!({
"sourceImageSrc": "/generated-images/editor/ui-design.png",
"model": "gpt-image-2",
"screenColor": "#E6D8FF",
"segModel": "anime-seg",
"aspectRatio": "1:1",
"imageSize": "2K"
}))
.expect("UI extraction request should deserialize dimensions");
assert_eq!(request.screen_color.as_deref(), Some("#E6D8FF"));
assert_eq!(request.seg_model.as_deref(), Some("anime-seg"));
let generation_options = normalize_editor_ui_design_asset_extraction_options(
request.model.as_deref(),
request.aspect_ratio.as_str(),
request.image_size.as_str(),
)
.expect("UI extraction dimensions should pass");
assert_eq!(generation_options.model, GPT_IMAGE_2_MODEL);
assert_eq!(generation_options.size, "2048x2048");
assert_eq!(
resolve_editor_ui_design_asset_extraction_price(
&state,
Some(generation_options.model),
Some(generation_options.image_size)
)
.await
.unwrap(),
5
);
let default_generation =
normalize_editor_ui_design_asset_extraction_options(None, "1:1", "1K")
.expect("default UI extraction options should pass");
assert_eq!(default_generation.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
let blank_model_generation =
normalize_editor_ui_design_asset_extraction_options(Some(" "), "1:1", "1K")
.expect("blank UI extraction model should use default");
assert_eq!(blank_model_generation.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(
resolve_editor_ui_design_asset_extraction_price(
&state,
Some(default_generation.model),
Some(default_generation.image_size),
)
.await
.unwrap(),
12
);
let ratio_error =
match normalize_editor_ui_design_asset_extraction_options(None, "16:9", "2K") {
Ok(_) => panic!("UI extraction ratio should stay square"),
Err(error) => error,
};
assert!(ratio_error.body_text().contains("aspectRatio"));
let size_error =
match normalize_editor_ui_design_asset_extraction_options(None, "1:1", "0.5K") {
Ok(_) => panic!("UI extraction size should be 1K or 2K"),
Err(error) => error,
};
assert!(size_error.body_text().contains("imageSize"));
}
#[test]
fn editor_image_generation_request_omits_price_mud_points() {
let request: EditorImageGenerationRequest = serde_json::from_value(json!({
"prompt": "生成图片",
"model": "gpt-image-2",
"screenColor": "#CFEFFF",
"segModel": "anime-seg",
"aspectRatio": "1:1",
"imageSize": "1K"
}))
.expect("image generation request should deserialize without price");
assert_eq!(request.prompt, "生成图片");
assert_eq!(request.screen_color.as_deref(), Some("#CFEFFF"));
assert_eq!(request.seg_model.as_deref(), Some("anime-seg"));
}
#[test]
fn editor_image_edit_request_omits_price_mud_points() {
let request: EditorImageEditRequest = serde_json::from_value(json!({
"prompt": "修改图片",
"sourceImageSrc": "/generated-images/editor/source.png",
"size": "2048x1152",
"model": "gemini-3.1-flash-image-preview",
"aspectRatio": "16:9",
"imageSize": "2K",
"referenceImageSrcs": ["/generated-images/editor/style.png"],
"targetLayerId": "layer-source"
}))
.expect("image edit request should deserialize without price");
assert_eq!(request.prompt, "修改图片");
assert_eq!(request.size.as_deref(), Some("2048x1152"));
assert_eq!(
request.model.as_deref(),
Some("gemini-3.1-flash-image-preview")
);
assert_eq!(request.aspect_ratio.as_deref(), Some("16:9"));
assert_eq!(request.image_size.as_deref(), Some("2K"));
let legacy_options = normalize_editor_image_edit_generation_options(
request.model.as_deref(),
None,
None,
Some("2048x1365"),
);
assert_eq!(legacy_options.aspect_ratio, "3:2");
assert_eq!(legacy_options.image_size, "2K");
assert_eq!(
request.reference_image_srcs,
Some(vec!["/generated-images/editor/style.png".to_string()])
);
assert_eq!(request.target_layer_id.as_deref(), Some("layer-source"));
}
#[test]
fn editor_image_edit_rejects_individual_icons_but_allows_spritesheets_and_specs() {
let error = ensure_editor_image_edit_asset_kind_allowed(Some("icon"))
.expect_err("individual icon assets should not support quick edit");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("message")),
Some(&json!("单个图标不支持快速编辑")),
);
assert_eq!(
error.details().and_then(|details| details.get("assetKind")),
Some(&json!("icon")),
);
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spritesheet")).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spec")).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("image")).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(None).is_ok());
let layers = json!([
{ "layerId": "layer-icon", "assetKind": "icon" },
{ "layerId": "layer-icon-spec", "assetKind": "icon-spec" }
]);
let icon_kind =
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon")
.expect("icon layer should exist");
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_kind).is_err());
let icon_spec_kind =
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon-spec")
.expect("icon spec layer should exist");
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_spec_kind).is_ok());
assert!(
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "missing").is_err()
);
}
#[test]
fn editor_image_edit_guards_before_queue_and_owner_execution() {
let source = include_str!("editor_project.rs");
assert_function_contains_in_order(
source,
"pub async fn edit_editor_image(",
"pub(crate) async fn edit_editor_image_for_owner",
&[
"ensure_editor_image_edit_source_allowed(",
"enqueue_editor_generation_job",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn edit_editor_image_for_owner",
"pub async fn remove_editor_image_background",
&[
"ensure_editor_image_edit_source_allowed(",
"parse_editor_reference_image",
],
);
}
#[test]
fn editor_inline_media_sources_are_rejected_before_queueing() {
let source = include_str!("editor_project.rs");
for (start, end) in [
(
"pub async fn generate_editor_image(",
"pub(crate) async fn generate_editor_image_for_owner",
),
(
"pub async fn edit_editor_image(",
"pub(crate) async fn edit_editor_image_for_owner",
),
(
"pub async fn remove_editor_image_background(",
"pub(crate) async fn remove_editor_image_background_for_owner",
),
(
"pub async fn generate_editor_icon_spritesheet(",
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
),
(
"pub async fn extract_editor_ui_design_assets(",
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
),
] {
assert_function_contains_in_order(
source,
start,
end,
&[
"ensure_editor_reference_image_source",
"enqueue_editor_generation_job",
],
);
}
for inline_source in [
"data:image/png;base64,AAAA",
" DATA:image/png;base64,AAAA",
"blob:browser-only",
] {
let error = ensure_editor_reference_image_source_is_stable(
inline_source,
"editor-test",
"sourceImageSrc",
"测试图片",
)
.expect_err("inline media must require upload before generation");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert!(error.body_text().contains("先上传 OSS"));
}
assert!(
ensure_editor_reference_image_source_is_stable(
"/generated-images/editor/reference.png",
"editor-test",
"sourceImageSrc",
"测试图片",
)
.is_ok()
);
}
#[test]
fn editor_image_edit_can_complete_by_replacing_target_layer() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"pub(crate) async fn edit_editor_image_for_owner",
"pub async fn remove_editor_image_background",
&[
"payload.canvas_completion.is_some()",
"complete_editor_canvas_background_removal",
"payload.target_layer_id.as_deref()",
],
);
}
#[test]
fn editor_icon_spritesheet_request_accepts_stable_extra_references() {
let request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
"referenceImageSrc": "/generated-images/editor/spec.png",
"referenceImageSrcs": ["/generated-images/editor/style.png"],
"iconDescriptions": ["返回按钮"],
"screenColor": "#7FB3FF",
"segModel": "anime-seg",
"assetLabel": " 冒险界面图标 "
}))
.expect("icon spritesheet request should deserialize references");
assert_eq!(
request.reference_image_srcs,
Some(vec!["/generated-images/editor/style.png".to_string()])
);
assert_eq!(request.screen_color.as_deref(), Some("#7FB3FF"));
assert_eq!(request.seg_model.as_deref(), Some("anime-seg"));
assert_eq!(request.asset_label.as_deref(), Some(" 冒险界面图标 "));
}
#[test]
fn editor_generated_asset_label_trims_custom_name_and_falls_back_for_blank_input() {
assert_eq!(
resolve_editor_generated_asset_label(
Some(" 冒险界面图标 ".to_string()),
"图标素材图集"
),
"冒险界面图标"
);
assert_eq!(
resolve_editor_generated_asset_label(Some(" ".to_string()), "图标素材图集"),
"图标素材图集"
);
assert_eq!(
resolve_editor_generated_asset_label(Some("图".repeat(81)), "图标素材图集")
.chars()
.count(),
EDITOR_ASSET_LABEL_MAX_CHARS
);
assert_eq!(
editor_generated_asset_variant_label("冒险界面图标", "原图"),
"冒险界面图标(原图)"
);
let long_variant = editor_generated_asset_variant_label("图".repeat(80).as_str(), "原图");
assert_eq!(long_variant.chars().count(), EDITOR_ASSET_LABEL_MAX_CHARS);
assert!(long_variant.ends_with("(原图)"));
}
#[test]
fn editor_icon_spritesheet_slice_request_requires_project_source_and_layout() {
let request: EditorIconSpritesheetSliceRequest = serde_json::from_value(json!({
"projectId": "project-1",
"sourceResourceId": "resource-atlas-1",
"assetFolderId": "project",
"canvasCompletion": {
"title": "拆分图集",
"placeholder": {
"x": 120.0,
"y": 80.0,
"width": 360.0,
"height": 360.0,
"originalWidth": 1024.0,
"originalHeight": 1024.0
}
}
}))
.expect("spritesheet slice request should deserialize");
assert_eq!(request.project_id, "project-1");
assert_eq!(request.source_resource_id, "resource-atlas-1");
assert_eq!(request.asset_folder_id.as_deref(), Some("project"));
assert_eq!(request.canvas_completion.title, "拆分图集");
assert_eq!(request.canvas_completion.placeholder.width, 360.0);
}
#[test]
fn editor_manual_atlas_split_lookup_keeps_original_provenance_after_project_copy() {
let mut copied_resource = editor_project_resource_for_canvas_test(
"copied-resource-atlas-1",
"icon-spritesheet",
1024,
1024,
);
copied_resource.source_resource_id = Some("resource-atlas-1".to_string());
copied_resource.asset_object_id = Some("asset-object-atlas-1".to_string());
copied_resource.object_key = Some("generated/icon-atlas-1.png".to_string());
let lookup = editor_manual_atlas_split_group_source_lookup_input(
"user-1",
copied_resource.resource_id.as_str(),
copied_resource.source_resource_id.as_deref(),
copied_resource.asset_object_id.as_deref(),
copied_resource.object_key.as_deref(),
);
assert_eq!(lookup.owner_user_id, "user-1");
assert_eq!(
lookup.source_resource_ids,
vec!["copied-resource-atlas-1", "resource-atlas-1"]
);
assert_eq!(
lookup.asset_object_id.as_deref(),
Some("asset-object-atlas-1")
);
assert_eq!(
lookup.object_key.as_deref(),
Some("generated/icon-atlas-1.png")
);
}
#[test]
fn editor_bgfilter_seg_model_parser_defaults_and_rejects_unknown() {
assert_eq!(
parse_editor_bgfilter_seg_model(None).expect("default should parse"),
EDITOR_BGFILTER_DEFAULT_SEG_MODEL
);
assert_eq!(
parse_editor_bgfilter_seg_model(Some("Anime-Seg")).expect("anime seg should parse"),
EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG
);
assert!(parse_editor_bgfilter_seg_model(Some("u2net")).is_err());
}
#[test]
fn editor_bgfilter_cross_check_form_value_matches_service_contract() {
assert_eq!(editor_bgfilter_cross_check_form_value(true), "on");
assert_eq!(editor_bgfilter_cross_check_form_value(false), "off");
}
#[test]
fn editor_bgfilter_background_mode_matches_service_contract() {
assert_eq!(EDITOR_BGFILTER_BACKGROUND_MODE_FLAT, "flat");
assert_eq!(EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX, "complex");
}
#[tokio::test]
async fn editor_generated_bgfilter_request_timeout_overrides_shared_client_default() {
let response_png = encode_test_png(3, 2);
let (base_url, request_receiver, server) =
spawn_bgfilter_png_mock_with_delay(response_png.clone(), Duration::from_millis(100));
let state = AppState::new(AppConfig {
editor_bgfilter_base_url: base_url,
editor_bgfilter_request_timeout_ms: 20,
..AppConfig::default()
})
.expect("state should build");
let source_image = DownloadedOpenAiImage {
bytes: encode_test_png(2, 2),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let removed = request_editor_generated_screen_background_with_bgfilter(
&state,
&source_image,
parse_editor_screen_background_color(Some("#CFEFFF"))
.expect("screen color should parse"),
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
2_000,
)
.await
.expect("request timeout override should outlive the shared client default");
request_receiver
.recv_timeout(Duration::from_secs(1))
.expect("mock server should capture request");
server.join().expect("mock server should stop cleanly");
assert_eq!(removed.bytes, response_png);
assert_eq!(removed.mime_type, "image/png");
assert_eq!(removed.extension, "png");
}
#[tokio::test]
async fn editor_manual_background_removal_sends_complex_bgfilter_http_contract() {
let response_png = encode_test_png(3, 2);
let (base_url, request_receiver, server) = spawn_bgfilter_png_mock(response_png.clone());
let state = AppState::new(AppConfig {
editor_bgfilter_base_url: base_url,
editor_bgfilter_token: Some("manual-complex-token".to_string()),
editor_bgfilter_request_timeout_ms: 5_000,
..AppConfig::default()
})
.expect("state should build");
let source_image = OpenAiReferenceImage {
bytes: encode_test_png(2, 2),
mime_type: "image/png".to_string(),
file_name: "manual-source.png".to_string(),
};
let removed = request_editor_background_removal_image_with_retry(&state, source_image)
.await
.expect("BgFilter PNG response should decode");
let request = request_receiver
.recv_timeout(Duration::from_secs(1))
.expect("mock server should capture request");
server.join().expect("mock server should stop cleanly");
let request_text = String::from_utf8_lossy(request.as_slice());
let lowercase_request = request_text.to_ascii_lowercase();
assert!(request_text.starts_with("POST /remove-background HTTP/1.1\r\n"));
assert!(
lowercase_request.contains("\r\nx-genarrative-image-token: manual-complex-token\r\n")
);
assert!(request_text.contains("name=\"file\"; filename=\"manual-source.png\""));
assert!(request_text.contains("name=\"background_mode\"\r\n\r\ncomplex\r\n"));
assert!(request_text.contains("name=\"seg_model\"\r\n\r\nbirefnet\r\n"));
assert!(request_text.contains("name=\"cross_check\"\r\n\r\noff\r\n"));
assert!(!request_text.contains("name=\"screen_color\""));
assert_eq!((removed.width, removed.height), (3, 2));
assert_eq!(removed.image.mime_type, "image/png");
assert_eq!(removed.image.extension, "png");
assert_eq!(removed.image.bytes, response_png);
let provider_error = decode_editor_background_removal_image(b"not an image")
.expect_err("invalid BgFilter response should fail decoding");
assert_eq!(
provider_error
.details()
.and_then(|details| details.get("provider")),
Some(&json!("bgfilter"))
);
}
#[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_provider_source_image",
"caller.report_processing_phase(state).await?",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_ENABLED",
"persist_editor_provider_source_resource",
"if is_character_generation",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
&[
"persist_editor_provider_source_image",
"persist_editor_provider_source_resource",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_ENABLED",
],
);
assert_function_contains(
source,
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
&[
"persist_editor_provider_source_image",
"caller.report_processing_phase(state).await?",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
"persist_editor_provider_source_resource",
"slice_generated_icon_spritesheet_by_connected_components",
"persist_editor_spritesheet_slices",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
&[
"persist_editor_provider_source_image",
"persist_editor_provider_source_resource",
"caller.report_processing_phase(state).await?",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn remove_editor_image_background_for_owner",
"struct EditorBackgroundRemovalImage",
&[
"caller.report_processing_phase(state).await?",
"request_editor_background_removal_image_with_retry",
],
);
assert_function_contains(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&[
"persist_editor_provider_source_image",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
"persist_editor_provider_source_resource",
"slice_generated_icon_spritesheet_all_by_connected_components",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&[
"persist_editor_provider_source_image",
"persist_editor_provider_source_resource",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
],
);
assert_function_contains(
source,
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
&[
"state.config.editor_bgfilter_request_timeout_ms",
"remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
],
);
assert_function_contains_in_order(
source,
"async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"async fn fallback_editor_screen_background_removal",
&[
// 熔断打开分支必须先委派统一兜底链(阿里云→本地),不能直接退化本地扣色;
// BgFilter 调用失败分支同样走该兜底链。
"editor_bgfilter_circuit_open_remaining",
"fallback_editor_screen_background_removal",
"EDITOR_BGFILTER_RETRY_COUNT + 1",
"request_editor_generated_screen_background_with_bgfilter",
"will_retry",
"record_matting_external_api_failure",
"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",
"request_timeout_ms.max(1)",
"state.editor_bgfilter_http_client()",
".timeout(Duration::from_millis(timeout_ms))",
"\"screen_color\"",
"\"seg_model\"",
"seg_model.to_string()",
"\"background_mode\"",
"EDITOR_BGFILTER_BACKGROUND_MODE_FLAT",
"\"cross_check\"",
"editor_bgfilter_cross_check_form_value(cross_check)",
],
);
assert_function_not_contains(
source,
"async fn request_editor_generated_screen_background_with_bgfilter",
"async fn request_editor_background_removal_image(",
&["reqwest::Client::builder"],
);
assert_function_contains(
source,
"async fn request_editor_background_removal_image(",
"fn editor_bgfilter_endpoint",
&[
"editor_bgfilter_endpoint",
"editor_bgfilter_request_timeout_ms.max(1)",
"state.editor_bgfilter_http_client()",
"editor_bgfilter_token",
"\"background_mode\"",
"EDITOR_BGFILTER_BACKGROUND_MODE_COMPLEX",
"\"seg_model\"",
"EDITOR_BGFILTER_DEFAULT_SEG_MODEL",
"\"cross_check\"",
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
"\"provider\": \"bgfilter\"",
],
);
assert_function_not_contains(
source,
"async fn request_editor_background_removal_image(",
"fn editor_bgfilter_endpoint",
&["\"screen_color\"", "reqwest::Client::builder"],
);
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_bgfilter_retries_once_before_fallback() {
let source = include_str!("editor_project.rs");
assert!(source.contains("const EDITOR_BGFILTER_RETRY_COUNT: usize = 1;"));
assert_function_contains_in_order(
source,
"async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"async fn fallback_editor_screen_background_removal",
&[
"let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1",
"for attempt in 1..=max_attempts",
"request_editor_generated_screen_background_with_bgfilter",
"let will_retry = attempt < max_attempts",
"if will_retry",
"continue",
"final_error = Some(error)",
"fallback_editor_screen_background_removal",
],
);
}
#[test]
fn editor_processing_phase_retries_transport_once_but_not_fencing() {
let source = include_str!("editor_project.rs");
assert!(source.contains("const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1;"));
assert_function_contains_in_order(
source,
"pub(crate) async fn report_processing(&self, state: &AppState)",
"fn map_editor_generation_phase_update_error",
&[
"let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1",
"for attempt in 1..=max_attempts",
".update_external_generation_job_phase(input.clone())",
"should_retry_editor_generation_phase_update",
"if will_retry",
"continue",
"map_editor_generation_phase_update_error(error)",
],
);
assert_function_contains(
source,
"fn map_editor_generation_phase_update_error",
"#[derive(Debug, Serialize)]",
&["error.is_lease_fencing_rejected()", "StatusCode::CONFLICT"],
);
}
#[test]
fn editor_processing_phase_retry_policy_stops_after_one_transport_retry() {
let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1;
let transport_errors = [
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Build(
"initial websocket connect failed".to_string(),
)),
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::ConnectDropped),
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Timeout(
spacetime_client::SpacetimeClientStage::ProcedureResult,
)),
];
for error in &transport_errors {
assert!(should_retry_editor_generation_phase_update(
error,
1,
max_attempts
));
assert!(!should_retry_editor_generation_phase_update(
error,
max_attempts,
max_attempts
));
}
let lease = ExternalGenerationJobPhaseUpdateError::LeaseFencingRejected(
"lease token 不匹配".to_string(),
);
assert!(!should_retry_editor_generation_phase_update(
&lease,
1,
max_attempts
));
let rejected = ExternalGenerationJobPhaseUpdateError::Rejected("phase 非法".to_string());
assert!(!should_retry_editor_generation_phase_update(
&rejected,
1,
max_attempts
));
for error in [
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Procedure(
"procedure rejected".to_string(),
)),
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Runtime(
"runtime invariant failed".to_string(),
)),
] {
assert!(!should_retry_editor_generation_phase_update(
&error,
1,
max_attempts
));
}
}
#[test]
fn editor_static_postprocess_failure_completes_canvas_with_source_only() {
let source = include_str!("editor_project.rs");
for (start, end) in [
(
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
),
(
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
"pub async fn extract_editor_ui_design_assets",
),
(
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
),
] {
assert_function_contains_in_order(
source,
start,
end,
&[
"persist_editor_provider_source_resource",
"caller.report_processing_phase(state).await?",
"remove_editor_generated_screen_background_with_bgfilter",
"Err(error)",
"complete_editor_canvas_generation",
"source_record.resource.as_ref()",
"warning: Some(editor_postprocess_fallback_warning",
],
);
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let function_tail = &source[start_index..];
let end_index = function_tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &function_tail[..end_index];
let fallback_start = body
.find("let removal = match removal {")
.unwrap_or_else(|| panic!("{start} should match only the postprocess result"));
let success_marker = if start.contains("generate_editor_image_for_owner") {
"image = removal.image;"
} else {
"let image = removal.image;"
};
let fallback_end = body[fallback_start..]
.find(success_marker)
.map(|offset| fallback_start + offset)
.unwrap_or_else(|| {
panic!("{start} should resume its processed-image success path")
});
let fallback = &body[fallback_start..fallback_end];
assert!(fallback.contains("return Ok(json_success_body"));
for forbidden in [
"persist_editor_generated_image(",
"persist_editor_generated_asset(",
"slice_generated_icon_spritesheet",
"persist_editor_spritesheet_slices",
] {
assert!(
!fallback.contains(forbidden),
"{start} fallback must not absorb later failure scope: {forbidden}"
);
}
if start.contains("spritesheet") || start.contains("ui_design") {
let success_tail = &body[fallback_end..];
assert!(success_tail.contains("persist_editor_generated_image("));
assert!(success_tail.contains("persist_editor_generated_asset("));
assert!(success_tail.contains("persist_editor_spritesheet_slices"));
}
}
}
#[test]
fn editor_character_postprocess_fallback_is_inline_and_does_not_swallow_other_failures() {
let source = include_str!("editor_project.rs");
let start = source
.find("pub(crate) async fn generate_editor_image_for_owner")
.expect("character generation function should exist");
let function_tail = &source[start..];
let end = function_tail
.find("fn normalize_editor_image_generation_size")
.expect("character generation function end marker should exist");
let body = &function_tail[..end];
let phase = body
.find("caller.report_processing_phase(state).await?")
.expect("phase report should remain fallible");
let removal = body
.find("let removal = remove_editor_generated_screen_background_with_bgfilter")
.expect("transparent background postprocess should exist");
let fallback_start = body
.find("let removal = match removal {")
.expect("only the postprocess result should be matched");
let fallback_end = body[fallback_start..]
.find("image = removal.image;")
.map(|offset| fallback_start + offset)
.expect("processed-image success path should follow the fallback match");
let fallback = &body[fallback_start..fallback_end];
assert!(
phase < removal,
"phase errors must fail before fallback is considered"
);
for snippet in [
"Err(error)",
"complete_editor_canvas_generation",
"source_record.resource.as_ref()",
"return Ok(json_success_body",
"warning: Some(editor_postprocess_fallback_warning",
] {
assert!(
fallback.contains(snippet),
"character postprocess fallback should contain {snippet}"
);
}
for forbidden in [
"persist_editor_generated_image(",
"persist_editor_generated_asset(",
"slice_generated_icon_spritesheet",
"build_multi_asset_canvas_layer_items",
] {
assert!(
!fallback.contains(forbidden),
"character postprocess fallback must not absorb later failure scope: {forbidden}"
);
}
let success_tail = &body[fallback_end..];
assert!(success_tail.contains("persist_editor_generated_image("));
assert!(success_tail.contains("persist_editor_generated_asset("));
assert!(success_tail.contains(".await?;"));
}
#[test]
fn editor_manual_background_removal_retries_once() {
let source = include_str!("editor_project.rs");
assert_function_contains_in_order(
source,
"async fn request_editor_background_removal_image_with_retry",
"async fn request_editor_background_removal_image(",
&[
"let max_attempts = EDITOR_BGFILTER_RETRY_COUNT + 1",
"for attempt in 1..=max_attempts",
"request_editor_background_removal_image(state, source_image.clone())",
"let will_retry = attempt < max_attempts",
"if will_retry",
"continue",
"final_error = Some(error)",
],
);
}
#[test]
fn editor_paid_image_postprocess_keeps_provider_outputs_recoverable() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
&["EDITOR_ASSET_DEFAULT_FOLDER_ID"],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn edit_editor_image_for_owner",
"pub async fn remove_editor_image_background",
&[
"let provider_image",
"restore_editor_image_edit_output_dimensions_or_original",
"persist_editor_generated_image",
],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
&[
"let mut image",
"restore_editor_generated_image_output_dimensions_or_original",
"persist_editor_generated_image",
],
);
assert_function_occurrence_count(
source,
"pub(crate) async fn edit_editor_image_for_owner",
"pub async fn remove_editor_image_background",
"persist_editor_generated_image(",
1,
);
assert_function_occurrence_count(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"fn normalize_editor_image_generation_size",
"persist_editor_generated_image(",
1,
);
assert_function_contains_in_order(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&[
"persist_editor_provider_source_resource",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND",
"persist_editor_generated_asset",
"slice_generated_icon_spritesheet_all_by_connected_components",
"persist_editor_spritesheet_slices",
],
);
assert_function_contains(
source,
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
"pub(crate) fn editor_project_payload_from_record",
&[
"EDITOR_ASSET_DEFAULT_FOLDER_ID",
"EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS",
"EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE",
],
);
}
#[test]
fn generated_asset_folder_id_normalizes_project_and_legacy_local_folder() {
assert_eq!(
normalize_generated_asset_folder_id(Some("project".to_string()), "user-1"),
Some("user-1:asset-folder:project".to_string())
);
assert_eq!(
normalize_generated_asset_folder_id(Some(" folder-temporary ".to_string()), "user-1"),
Some("user-1:asset-folder:project".to_string())
);
assert_eq!(
normalize_generated_asset_folder_id(Some("folder-real".to_string()), "user-1"),
Some("user-1:asset-folder:project".to_string())
);
assert_eq!(
normalize_generated_asset_folder_id(
Some("user-1:asset-folder:project".to_string()),
"user-1",
),
Some("user-1:asset-folder:project".to_string())
);
assert_eq!(normalize_generated_asset_folder_id(None, "user-1"), None);
}
fn assert_function_contains(source: &str, start: &str, end: &str, snippets: &[&str]) {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let tail = &source[start_index..];
let end_index = tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &tail[..end_index];
for snippet in snippets {
assert!(
body.contains(snippet),
"function body starting with {start} should contain {snippet}"
);
}
}
fn assert_function_contains_in_order(source: &str, start: &str, end: &str, snippets: &[&str]) {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let tail = &source[start_index..];
let end_index = tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &tail[..end_index];
let mut cursor = 0;
for snippet in snippets {
let next = body[cursor..].find(snippet).unwrap_or_else(|| {
panic!(
"function body starting with {start} should contain {snippet} after offset {cursor}"
)
});
cursor += next + snippet.len();
}
}
fn assert_function_not_contains(source: &str, start: &str, end: &str, snippets: &[&str]) {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let tail = &source[start_index..];
let end_index = tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &tail[..end_index];
for snippet in snippets {
assert!(
!body.contains(snippet),
"function body starting with {start} should not contain {snippet}"
);
}
}
fn assert_function_occurrence_count(
source: &str,
start: &str,
end: &str,
snippet: &str,
expected_count: usize,
) {
let start_index = source
.find(start)
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
let tail = &source[start_index..];
let end_index = tail
.find(end)
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
let body = &tail[..end_index];
assert_eq!(
body.matches(snippet).count(),
expected_count,
"function body starting with {start} should contain {snippet} exactly {expected_count} times"
);
}
}