edc99e55e4
按用户隔离画布缓存并等待权威 revision 后开放保存 固化保存重试版本并安全处理认证重载与冲突恢复 合并迟到资源图层且隔离跨账号异步请求 强制 Web 与 External 画布写入使用 expectedRevision CAS 补充竞态回归测试并同步 OpenAPI、Skill 与技术文档
11603 lines
433 KiB
Rust
11603 lines
433 KiB
Rust
use std::{
|
||
borrow::Cow,
|
||
collections::BTreeMap,
|
||
io::Cursor,
|
||
time::{Duration, Instant},
|
||
};
|
||
|
||
use axum::{
|
||
Json,
|
||
extract::{Extension, Path, Query, State},
|
||
http::StatusCode,
|
||
};
|
||
use module_assets::{
|
||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input,
|
||
generate_asset_object_id,
|
||
};
|
||
use platform_image::{
|
||
DownloadedImage,
|
||
generated_asset_sheets::{
|
||
GeneratedAssetSheetConnectedIcon,
|
||
slice_generated_icon_spritesheet_all_by_connected_components,
|
||
slice_generated_icon_spritesheet_by_connected_components,
|
||
},
|
||
};
|
||
use platform_oss::{
|
||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value, json};
|
||
use shared_contracts::assets::{
|
||
EDITOR_ASSET_LABEL_MAX_CHARS,
|
||
EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest,
|
||
EditorCanvasGenerationPlaceholderPayload,
|
||
};
|
||
use shared_kernel::build_prefixed_uuid_id;
|
||
use spacetime_client::{
|
||
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
|
||
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord,
|
||
EditorAssetFolderUpdateRecordInput, EditorAssetGroupCohortCompleteRecordInput,
|
||
EditorAssetGroupSourceLookupRecordInput, EditorAssetLibraryRecord,
|
||
EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput,
|
||
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
|
||
EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
|
||
EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||
EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput,
|
||
EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput,
|
||
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
|
||
EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput,
|
||
EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord,
|
||
ExternalGenerationJobPhaseUpdateError, ExternalGenerationJobPhaseUpdateRecordInput,
|
||
SpacetimeClientError,
|
||
};
|
||
|
||
use crate::{
|
||
api_response::json_success_body,
|
||
asset_billing::execute_billable_asset_operation_with_cost,
|
||
auth::AuthenticatedAccessToken,
|
||
editor_generation_queue::{
|
||
EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND,
|
||
EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND,
|
||
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EditorGenerationQueuedResponse,
|
||
editor_generation_queue_state, editor_generation_source_entity_id,
|
||
enqueue_editor_generation_job,
|
||
},
|
||
editor_green_screen::{
|
||
EditorScreenBackgroundColor, editor_green_screen_asset_prompt_clause,
|
||
editor_green_screen_character_prompt_clause, editor_ui_design_asset_extraction_prompt,
|
||
remove_editor_generated_green_screen_background,
|
||
},
|
||
editor_screen_background_decision::{
|
||
EditorScreenBackgroundDecision, EditorScreenBackgroundDecisionInput,
|
||
EditorScreenBackgroundDecisionKind, resolve_editor_screen_background_color,
|
||
},
|
||
generated_image_assets::{
|
||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
|
||
decode_generated_image_asset_data_url, normalize_generated_image_asset_mime,
|
||
},
|
||
http_error::AppError,
|
||
openai_image_generation::{
|
||
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiReferenceImage,
|
||
build_openai_image_http_client, create_openai_image_edit_with_references_and_model,
|
||
create_openai_image_generation_with_model, create_openai_nanobanana_generate_content,
|
||
require_openai_image_settings,
|
||
},
|
||
platform_errors::map_oss_error,
|
||
request_context::RequestContext,
|
||
state::AppState,
|
||
work_author::{ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID},
|
||
};
|
||
|
||
const EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE: usize = 36;
|
||
|
||
pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "proj-";
|
||
pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
|
||
pub(crate) const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-";
|
||
pub(crate) const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
|
||
const EDITOR_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024;
|
||
pub(crate) const EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||
pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
|
||
const EDITOR_ASSET_METADATA_MAX_BYTES: usize = 64 * 1024;
|
||
const EDITOR_ASSET_DEFAULT_FOLDER_ID: &str = "project";
|
||
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
|
||
const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
|
||
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
|
||
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
|
||
const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
|
||
const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION: u32 = 4096;
|
||
const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS: u64 = 2048 * 2048;
|
||
const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES: usize = 64;
|
||
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5;
|
||
const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image";
|
||
const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project";
|
||
const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character";
|
||
const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image";
|
||
const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image";
|
||
const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image";
|
||
const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image";
|
||
const EDITOR_IMAGE_EDIT_ASSET_KIND: &str = "editor_image_edit";
|
||
const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet";
|
||
const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice";
|
||
const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components";
|
||
const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed";
|
||
const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved";
|
||
const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback";
|
||
const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1;
|
||
const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet";
|
||
const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset";
|
||
const EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source";
|
||
const EDITOR_PROVIDER_SOURCE_SLOT: &str = "provider_source";
|
||
pub(crate) const EDITOR_BGFILTER_DEFAULT_SEG_MODEL: &str = "birefnet";
|
||
const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg";
|
||
const EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS: u64 = 600;
|
||
pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_ENABLED: bool = true;
|
||
pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_DISABLED: bool = false;
|
||
const EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW: Duration = Duration::from_secs(2);
|
||
const EDITOR_BGFILTER_FLAT_LOCAL_RESERVE: Duration = Duration::from_secs(7);
|
||
const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material";
|
||
const EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND: &str = "editor_legacy_inline_image";
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectCreateRequest {
|
||
title: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorCanvasViewportPayload {
|
||
x: f64,
|
||
y: f64,
|
||
scale: f64,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectLayoutSaveRequest {
|
||
pub(crate) viewport: EditorCanvasViewportPayload,
|
||
pub(crate) layers: Value,
|
||
pub(crate) expected_revision: u64,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectRenameRequest {
|
||
title: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectResourceCreateRequest {
|
||
image_src: String,
|
||
object_key: Option<String>,
|
||
asset_object_id: Option<String>,
|
||
width: u32,
|
||
height: u32,
|
||
source_type: String,
|
||
prompt: Option<String>,
|
||
actual_prompt: Option<String>,
|
||
model: Option<String>,
|
||
provider: Option<String>,
|
||
task_id: Option<String>,
|
||
source_resource_id: Option<String>,
|
||
asset_kind: Option<String>,
|
||
generation_inputs: Option<Value>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectResourceShowcaseUpdateRequest {
|
||
public_showcase_enabled: bool,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAssetFolderCreateRequest {
|
||
label: String,
|
||
sort_order: Option<u32>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAssetFolderUpdateRequest {
|
||
label: Option<String>,
|
||
collapsed: Option<bool>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAssetCreateRequest {
|
||
folder_id: String,
|
||
label: String,
|
||
image_src: String,
|
||
object_key: Option<String>,
|
||
asset_object_id: Option<String>,
|
||
width: u32,
|
||
height: u32,
|
||
source_type: String,
|
||
prompt: Option<String>,
|
||
actual_prompt: Option<String>,
|
||
model: Option<String>,
|
||
provider: Option<String>,
|
||
task_id: Option<String>,
|
||
asset_kind: Option<String>,
|
||
generation_inputs: Option<Value>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAssetUpdateRequest {
|
||
label: Option<String>,
|
||
folder_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorImageGenerationRequest {
|
||
pub(crate) prompt: String,
|
||
pub(crate) size: Option<String>,
|
||
pub(crate) kind: Option<String>,
|
||
pub(crate) model: Option<String>,
|
||
pub(crate) screen_color: Option<String>,
|
||
pub(crate) seg_model: Option<String>,
|
||
pub(crate) aspect_ratio: Option<String>,
|
||
pub(crate) image_size: Option<String>,
|
||
pub(crate) reference_image_srcs: Option<Vec<String>>,
|
||
pub(crate) project_id: Option<String>,
|
||
pub(crate) asset_kind: Option<String>,
|
||
pub(crate) generation_inputs: Option<Value>,
|
||
pub(crate) asset_folder_id: Option<String>,
|
||
pub(crate) asset_label: Option<String>,
|
||
pub(crate) source_resource_id: Option<String>,
|
||
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorImageEditRequest {
|
||
pub(crate) prompt: String,
|
||
pub(crate) source_image_src: String,
|
||
pub(crate) size: Option<String>,
|
||
pub(crate) model: Option<String>,
|
||
pub(crate) aspect_ratio: Option<String>,
|
||
pub(crate) image_size: Option<String>,
|
||
pub(crate) reference_image_srcs: Option<Vec<String>>,
|
||
pub(crate) project_id: Option<String>,
|
||
pub(crate) asset_kind: Option<String>,
|
||
pub(crate) generation_inputs: Option<Value>,
|
||
pub(crate) asset_folder_id: Option<String>,
|
||
pub(crate) asset_label: Option<String>,
|
||
pub(crate) source_resource_id: Option<String>,
|
||
pub(crate) target_layer_id: Option<String>,
|
||
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorBackgroundRemovalRequest {
|
||
pub(crate) source_image_src: String,
|
||
pub(crate) project_id: Option<String>,
|
||
pub(crate) target_layer_id: Option<String>,
|
||
pub(crate) asset_kind: Option<String>,
|
||
pub(crate) generation_inputs: Option<Value>,
|
||
pub(crate) asset_folder_id: Option<String>,
|
||
pub(crate) asset_label: Option<String>,
|
||
pub(crate) source_resource_id: Option<String>,
|
||
pub(crate) task_id: Option<String>,
|
||
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorIconSpritesheetGenerationRequest {
|
||
pub(crate) reference_image_src: String,
|
||
pub(crate) reference_image_srcs: Option<Vec<String>>,
|
||
pub(crate) icon_descriptions: Vec<String>,
|
||
pub(crate) model: Option<String>,
|
||
pub(crate) screen_color: Option<String>,
|
||
pub(crate) seg_model: Option<String>,
|
||
pub(crate) aspect_ratio: Option<String>,
|
||
pub(crate) image_size: Option<String>,
|
||
pub(crate) project_id: Option<String>,
|
||
pub(crate) generation_inputs: Option<Value>,
|
||
pub(crate) asset_folder_id: Option<String>,
|
||
pub(crate) asset_label: Option<String>,
|
||
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorIconSpritesheetSliceRequest {
|
||
pub(crate) project_id: String,
|
||
pub(crate) source_resource_id: String,
|
||
pub(crate) asset_folder_id: Option<String>,
|
||
pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorUiDesignAssetExtractionRequest {
|
||
pub(crate) source_image_src: String,
|
||
pub(crate) model: Option<String>,
|
||
pub(crate) screen_color: Option<String>,
|
||
pub(crate) seg_model: Option<String>,
|
||
pub(crate) reference_image_srcs: Option<Vec<String>>,
|
||
pub(crate) aspect_ratio: String,
|
||
pub(crate) image_size: String,
|
||
pub(crate) project_id: Option<String>,
|
||
pub(crate) generation_inputs: Option<Value>,
|
||
pub(crate) asset_folder_id: Option<String>,
|
||
pub(crate) spritesheet_label: Option<String>,
|
||
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
|
||
}
|
||
|
||
pub(crate) struct EditorGenerationCaller {
|
||
pub(crate) owner_user_id: String,
|
||
pub(crate) audit_subject_user_id: Option<String>,
|
||
pub(crate) audit_project_id: Option<String>,
|
||
pub(crate) phase_reporter: Option<EditorGenerationPhaseReporter>,
|
||
}
|
||
|
||
impl EditorGenerationCaller {
|
||
fn from_authenticated(authenticated: &AuthenticatedAccessToken) -> Self {
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
Self {
|
||
audit_subject_user_id: Some(owner_user_id.clone()),
|
||
owner_user_id,
|
||
audit_project_id: None,
|
||
phase_reporter: None,
|
||
}
|
||
}
|
||
|
||
async fn report_processing_phase(&self, state: &AppState) -> Result<(), AppError> {
|
||
if let Some(reporter) = self.phase_reporter.as_ref() {
|
||
reporter.report_processing(state).await?;
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct EditorGenerationPhaseReporter {
|
||
job_id: String,
|
||
worker_id: String,
|
||
lease_token: String,
|
||
}
|
||
|
||
impl EditorGenerationPhaseReporter {
|
||
pub(crate) fn new(job_id: String, worker_id: String, lease_token: String) -> Self {
|
||
Self {
|
||
job_id,
|
||
worker_id,
|
||
lease_token,
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn report_processing(&self, state: &AppState) -> Result<(), AppError> {
|
||
let input = ExternalGenerationJobPhaseUpdateRecordInput {
|
||
job_id: self.job_id.clone(),
|
||
worker_id: self.worker_id.clone(),
|
||
lease_token: self.lease_token.clone(),
|
||
phase: "processing".to_string(),
|
||
};
|
||
let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1;
|
||
for attempt in 1..=max_attempts {
|
||
match state
|
||
.spacetime_client()
|
||
.update_external_generation_job_phase(input.clone())
|
||
.await
|
||
{
|
||
Ok(_) => return Ok(()),
|
||
Err(error) => {
|
||
let will_retry =
|
||
should_retry_editor_generation_phase_update(&error, attempt, max_attempts);
|
||
tracing::warn!(
|
||
provider = "external-generation-phase",
|
||
job_id = %self.job_id,
|
||
attempt,
|
||
max_attempts,
|
||
will_retry,
|
||
lease_fencing_rejected = error.is_lease_fencing_rejected(),
|
||
error = %error,
|
||
"external_generation_processing_phase_report_failed"
|
||
);
|
||
if will_retry {
|
||
continue;
|
||
}
|
||
return Err(map_editor_generation_phase_update_error(error));
|
||
}
|
||
}
|
||
}
|
||
unreachable!("phase report retry loop should return on success or final error")
|
||
}
|
||
}
|
||
|
||
fn should_retry_editor_generation_phase_update(
|
||
error: &ExternalGenerationJobPhaseUpdateError,
|
||
attempt: usize,
|
||
max_attempts: usize,
|
||
) -> bool {
|
||
error.is_retryable_transport() && attempt < max_attempts
|
||
}
|
||
|
||
fn map_editor_generation_phase_update_error(
|
||
error: ExternalGenerationJobPhaseUpdateError,
|
||
) -> AppError {
|
||
let status = if error.is_lease_fencing_rejected() {
|
||
StatusCode::CONFLICT
|
||
} else {
|
||
StatusCode::BAD_GATEWAY
|
||
};
|
||
AppError::from_status(status).with_details(json!({
|
||
"provider": "external-generation-phase",
|
||
"message": format!("更新外部生成任务处理阶段失败:{error}"),
|
||
}))
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectResponse {
|
||
project: EditorProjectPayload,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectLayoutSaveResponse {
|
||
project_id: String,
|
||
canvas_id: String,
|
||
revision: u64,
|
||
updated_at: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectRecentResponse {
|
||
project: Option<EditorProjectPayload>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectListResponse {
|
||
projects: Vec<EditorProjectPayload>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectDeleteResponse {
|
||
deleted_project_id: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectResourceResponse {
|
||
resource: EditorProjectResourcePayload,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectResourceListResponse {
|
||
resources: Vec<EditorProjectResourcePayload>,
|
||
next_cursor: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
campaign: Option<EditorShowcaseCampaignPayload>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectResourceListQuery {
|
||
cursor: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAssetLibraryResponse {
|
||
library: EditorAssetLibraryPayload,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAssetFolderResponse {
|
||
folder: EditorAssetFolderPayload,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAssetFolderDeleteResponse {
|
||
library: EditorAssetLibraryPayload,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAssetResponse {
|
||
asset: EditorAssetPayload,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorShowcaseAssetResponse {
|
||
showcase_asset: EditorProjectResourcePayload,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorShowcaseAssetLikeRequest {
|
||
liked: bool,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub(crate) struct EditorGeneratedAssetRecord {
|
||
pub(crate) resource: Option<EditorProjectResourcePayload>,
|
||
pub(crate) asset: Option<EditorAssetPayload>,
|
||
}
|
||
|
||
struct EditorCanvasGenerationCompletionResult {
|
||
layers: Value,
|
||
changed: bool,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub(crate) struct PersistEditorGeneratedAssetInput {
|
||
pub(crate) project_id: Option<String>,
|
||
pub(crate) owner_user_id: String,
|
||
pub(crate) folder_id: Option<String>,
|
||
pub(crate) label: String,
|
||
pub(crate) image_src: String,
|
||
pub(crate) object_key: Option<String>,
|
||
pub(crate) asset_object_id: Option<String>,
|
||
pub(crate) width: u32,
|
||
pub(crate) height: u32,
|
||
pub(crate) prompt: String,
|
||
pub(crate) actual_prompt: Option<String>,
|
||
pub(crate) model: String,
|
||
pub(crate) provider: String,
|
||
pub(crate) task_id: String,
|
||
pub(crate) group_task_id: Option<String>,
|
||
pub(crate) group_task_expected_asset_count: Option<u32>,
|
||
pub(crate) source_resource_id: Option<String>,
|
||
pub(crate) asset_kind: Option<String>,
|
||
pub(crate) generation_inputs: Option<Value>,
|
||
pub(crate) thumbnail_src: Option<String>,
|
||
pub(crate) generation_cost_mud_points: u64,
|
||
}
|
||
|
||
pub(crate) struct PersistEditorGeneratedAssetRequest {
|
||
pub(crate) project_id: Option<String>,
|
||
pub(crate) owner_user_id: String,
|
||
pub(crate) folder_id: Option<String>,
|
||
pub(crate) label: String,
|
||
pub(crate) image_src: String,
|
||
pub(crate) object_key: Option<String>,
|
||
pub(crate) asset_object_id: Option<String>,
|
||
pub(crate) width: u32,
|
||
pub(crate) height: u32,
|
||
pub(crate) prompt: String,
|
||
pub(crate) actual_prompt: Option<String>,
|
||
pub(crate) model: String,
|
||
pub(crate) provider: String,
|
||
pub(crate) task_id: String,
|
||
pub(crate) source_resource_id: Option<String>,
|
||
pub(crate) asset_kind: Option<String>,
|
||
pub(crate) generation_inputs: Option<Value>,
|
||
pub(crate) thumbnail_src: Option<String>,
|
||
pub(crate) generation_cost_mud_points: u64,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
struct EditorGeneratedImageStorageProfile {
|
||
asset_kind: &'static str,
|
||
path_kind: &'static str,
|
||
slot: &'static str,
|
||
}
|
||
|
||
pub(crate) struct EditorCanvasGeneratedLayerInput {
|
||
pub(crate) layer_id: String,
|
||
pub(crate) resource_id: String,
|
||
pub(crate) title: String,
|
||
pub(crate) src: String,
|
||
pub(crate) media_type: Option<String>,
|
||
pub(crate) thumbnail_src: Option<String>,
|
||
pub(crate) original_width: u32,
|
||
pub(crate) original_height: u32,
|
||
pub(crate) display_width: f64,
|
||
pub(crate) display_height: f64,
|
||
pub(crate) source_type: String,
|
||
pub(crate) prompt: Option<String>,
|
||
pub(crate) actual_prompt: Option<String>,
|
||
pub(crate) model: Option<String>,
|
||
pub(crate) provider: Option<String>,
|
||
pub(crate) task_id: Option<String>,
|
||
pub(crate) object_key: Option<String>,
|
||
pub(crate) asset_object_id: Option<String>,
|
||
pub(crate) duration_seconds: Option<u32>,
|
||
pub(crate) source_resource_id: Option<String>,
|
||
pub(crate) asset_kind: Option<String>,
|
||
pub(crate) generation_inputs: Option<Value>,
|
||
pub(crate) image_sequence_frames: Option<Value>,
|
||
pub(crate) preview_video_path: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorGenerationWarningResponse {
|
||
code: &'static str,
|
||
reason: String,
|
||
}
|
||
|
||
fn editor_postprocess_fallback_warning(reason: &'static str) -> EditorGenerationWarningResponse {
|
||
EditorGenerationWarningResponse {
|
||
code: EDITOR_GENERATION_POSTPROCESS_WARNING_CODE,
|
||
reason: reason.to_string(),
|
||
}
|
||
}
|
||
|
||
fn editor_dimension_restore_warning(error: &AppError) -> EditorGenerationWarningResponse {
|
||
let reason = error
|
||
.details()
|
||
.and_then(|details| details.get("message"))
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("provider 回图未能归一到目标交付尺寸,已保留实际原图尺寸。")
|
||
.to_string();
|
||
EditorGenerationWarningResponse {
|
||
code: EDITOR_GENERATION_DIMENSION_WARNING_CODE,
|
||
reason,
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorImageGenerationResponse {
|
||
image_src: String,
|
||
object_key: Option<String>,
|
||
asset_object_id: Option<String>,
|
||
width: u32,
|
||
height: u32,
|
||
source_type: &'static str,
|
||
prompt: String,
|
||
actual_prompt: Option<String>,
|
||
model: String,
|
||
provider: &'static str,
|
||
task_id: String,
|
||
resource: Option<EditorProjectResourcePayload>,
|
||
asset: Option<EditorAssetPayload>,
|
||
project: Option<EditorProjectPayload>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
warning: Option<EditorGenerationWarningResponse>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorBackgroundRemovalResponse {
|
||
image_src: String,
|
||
object_key: String,
|
||
asset_object_id: String,
|
||
width: u32,
|
||
height: u32,
|
||
source_type: &'static str,
|
||
task_id: String,
|
||
elapsed_ms: u64,
|
||
provider: &'static str,
|
||
resource: Option<EditorProjectResourcePayload>,
|
||
asset: Option<EditorAssetPayload>,
|
||
project: Option<EditorProjectPayload>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorIconSpritesheetIconResponse {
|
||
name: String,
|
||
image_src: String,
|
||
width: u32,
|
||
height: u32,
|
||
resource: Option<EditorProjectResourcePayload>,
|
||
asset: Option<EditorAssetPayload>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorIconSpritesheetSliceWarningResponse {
|
||
code: &'static str,
|
||
reason: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorIconSpritesheetGenerationResponse {
|
||
spritesheet_image_src: String,
|
||
spritesheet_width: u32,
|
||
spritesheet_height: u32,
|
||
icon_image_srcs: Vec<EditorIconSpritesheetIconResponse>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
slice_warning: Option<EditorIconSpritesheetSliceWarningResponse>,
|
||
prompt: String,
|
||
actual_prompt: Option<String>,
|
||
model: String,
|
||
provider: &'static str,
|
||
task_id: String,
|
||
price_mud_points: u32,
|
||
spritesheet_resource: Option<EditorProjectResourcePayload>,
|
||
spritesheet_asset: Option<EditorAssetPayload>,
|
||
project: Option<EditorProjectPayload>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
warning: Option<EditorGenerationWarningResponse>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorIconSpritesheetSliceResponse {
|
||
icon_image_srcs: Vec<EditorIconSpritesheetIconResponse>,
|
||
project: EditorProjectPayload,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectPayload {
|
||
project_id: String,
|
||
title: String,
|
||
canvas: EditorCanvasPayload,
|
||
viewport: EditorCanvasViewportPayload,
|
||
layers: Value,
|
||
resources: Vec<EditorProjectResourcePayload>,
|
||
updated_at: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorCanvasPayload {
|
||
canvas_id: String,
|
||
project_id: String,
|
||
title: String,
|
||
viewport: EditorCanvasViewportPayload,
|
||
layers: Value,
|
||
revision: u64,
|
||
layout_storage_version: u32,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
background_color: Option<String>,
|
||
created_at: String,
|
||
updated_at: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorProjectResourcePayload {
|
||
pub(crate) resource_id: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
showcase_id: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
asset_id: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
label: Option<String>,
|
||
project_id: String,
|
||
owner_user_id: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
author_display_name: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
author_public_user_code: Option<String>,
|
||
image_src: String,
|
||
object_key: Option<String>,
|
||
asset_object_id: Option<String>,
|
||
width: u32,
|
||
height: u32,
|
||
source_type: String,
|
||
prompt: Option<String>,
|
||
actual_prompt: Option<String>,
|
||
model: Option<String>,
|
||
provider: Option<String>,
|
||
task_id: Option<String>,
|
||
source_resource_id: Option<String>,
|
||
asset_kind: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
showcase_category: Option<String>,
|
||
generation_inputs: Option<Value>,
|
||
public_showcase_enabled: bool,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
review_status: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
display_enabled: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
like_count: Option<u64>,
|
||
generation_cost_mud_points: u64,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
refund_mud_points: Option<u64>,
|
||
created_at: String,
|
||
updated_at: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorShowcaseCampaignPayload {
|
||
enabled: bool,
|
||
title: String,
|
||
image_src: String,
|
||
image_object_key: Option<String>,
|
||
image_width: Option<u32>,
|
||
image_height: Option<u32>,
|
||
prompt: String,
|
||
author: String,
|
||
cost_text: String,
|
||
updated_at: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAssetLibraryPayload {
|
||
folders: Vec<EditorAssetFolderPayload>,
|
||
assets: Vec<EditorAssetPayload>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAssetFolderPayload {
|
||
folder_id: String,
|
||
label: String,
|
||
sort_order: u32,
|
||
collapsed: bool,
|
||
system_default: bool,
|
||
created_at: String,
|
||
updated_at: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAssetPayload {
|
||
asset_id: String,
|
||
folder_id: String,
|
||
label: String,
|
||
image_src: String,
|
||
object_key: Option<String>,
|
||
asset_object_id: Option<String>,
|
||
width: u32,
|
||
height: u32,
|
||
source_type: String,
|
||
prompt: Option<String>,
|
||
actual_prompt: Option<String>,
|
||
model: Option<String>,
|
||
provider: Option<String>,
|
||
task_id: Option<String>,
|
||
asset_kind: Option<String>,
|
||
generation_inputs: Option<Value>,
|
||
source_resource_id: Option<String>,
|
||
public_showcase_enabled: Option<bool>,
|
||
thumbnail_src: Option<String>,
|
||
generation_cost_mud_points: u64,
|
||
showcase_id: Option<String>,
|
||
showcase_review_status: Option<String>,
|
||
showcase_display_enabled: Option<bool>,
|
||
showcase_like_count: Option<u64>,
|
||
created_at: String,
|
||
updated_at: String,
|
||
}
|
||
|
||
pub async fn load_recent_editor_project(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let project = state
|
||
.spacetime_client()
|
||
.get_recent_editor_project(owner_user_id)
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
let project = match project {
|
||
Some(project) => Some(editor_project_payload_from_record(
|
||
repair_editor_project_record_inline_media(&state, project).await,
|
||
)),
|
||
None => None,
|
||
};
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorProjectRecentResponse { project },
|
||
))
|
||
}
|
||
|
||
/// 公开读取画布生成模型定价;用于前端按钮泥点展示,后端仍会在提交时重新校验。
|
||
pub async fn get_editor_generation_pricing(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "editor-generation-pricing",
|
||
"message": error.to_string(),
|
||
}))
|
||
})?;
|
||
Ok(json_success_body(Some(&request_context), pricing))
|
||
}
|
||
|
||
pub async fn list_editor_projects(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let project_records = state
|
||
.spacetime_client()
|
||
.list_editor_projects(owner_user_id)
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
let mut projects = Vec::with_capacity(project_records.len());
|
||
for project in project_records {
|
||
projects.push(editor_project_payload_from_record(
|
||
repair_editor_project_record_inline_media(&state, project).await,
|
||
));
|
||
}
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorProjectListResponse { projects },
|
||
))
|
||
}
|
||
|
||
pub async fn list_public_editor_project_resources(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Query(query): Query<EditorProjectResourceListQuery>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let mut resource_records = state
|
||
.spacetime_client()
|
||
.list_public_editor_showcase_assets(EditorShowcaseAssetPublicListRecordInput {
|
||
limit: (EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE + 1) as u32,
|
||
cursor: normalize_optional_string(query.cursor),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
let next_cursor = if resource_records.len() > EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE {
|
||
resource_records
|
||
.get(EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE - 1)
|
||
.and_then(editor_project_resource_showcase_cursor)
|
||
} else {
|
||
None
|
||
};
|
||
resource_records.truncate(EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE);
|
||
|
||
let resources = resource_records
|
||
.into_iter()
|
||
.map(|record| editor_project_resource_public_payload_from_record(&state, record))
|
||
.collect();
|
||
let campaign = state
|
||
.spacetime_client()
|
||
.get_editor_showcase_campaign_config(EditorShowcaseCampaignConfigGetRecordInput {
|
||
include_disabled: false,
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?
|
||
.map(editor_showcase_campaign_payload_from_record);
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorProjectResourceListResponse {
|
||
resources,
|
||
next_cursor,
|
||
campaign,
|
||
},
|
||
))
|
||
}
|
||
|
||
fn editor_project_resource_showcase_cursor(record: &EditorShowcaseAssetRecord) -> Option<String> {
|
||
let created_at = record
|
||
.approved_at
|
||
.as_deref()
|
||
.unwrap_or(record.updated_at.as_str());
|
||
let created_at_micros = parse_editor_timestamp_micros(created_at)?;
|
||
Some(format!("{created_at_micros}:{}", record.showcase_id))
|
||
}
|
||
|
||
fn parse_editor_timestamp_micros(value: &str) -> Option<i64> {
|
||
let (seconds, micros) = value.trim().strip_suffix('Z')?.split_once('.')?;
|
||
if micros.len() != 6 {
|
||
return None;
|
||
}
|
||
seconds
|
||
.parse::<i64>()
|
||
.ok()?
|
||
.checked_mul(1_000_000)?
|
||
.checked_add(micros.parse::<i64>().ok()?)
|
||
}
|
||
|
||
pub async fn create_editor_project(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorProjectCreateRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let now_micros = current_utc_micros();
|
||
let title = payload
|
||
.title
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string());
|
||
let project = state
|
||
.spacetime_client()
|
||
.create_editor_project(EditorProjectCreateRecordInput {
|
||
project_id: build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX),
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
title,
|
||
now_micros,
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorProjectResponse {
|
||
project: editor_project_payload_from_record(project),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_editor_project(
|
||
State(state): State<AppState>,
|
||
Path(project_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let project = state
|
||
.spacetime_client()
|
||
.get_editor_project(EditorProjectGetRecordInput {
|
||
project_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
let project = repair_editor_project_record_inline_media(&state, project).await;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorProjectResponse {
|
||
project: editor_project_payload_from_record(project),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn save_editor_project_layout(
|
||
State(state): State<AppState>,
|
||
Path(project_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorProjectLayoutSaveRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let expected_revision = payload.expected_revision;
|
||
let viewport = payload.viewport.into_record();
|
||
let layers_json = serialize_editor_layers(payload.layers)?;
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let updated_at_micros = current_utc_micros();
|
||
let ack = state
|
||
.spacetime_client()
|
||
.save_editor_project_layout_v2_ack(EditorProjectLayoutSaveV2RecordInput {
|
||
project_id,
|
||
owner_user_id,
|
||
viewport,
|
||
layers_json,
|
||
expected_revision,
|
||
updated_at_micros,
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorProjectLayoutSaveResponse {
|
||
project_id: ack.project_id,
|
||
canvas_id: ack.canvas_id,
|
||
revision: ack.revision,
|
||
updated_at: ack.updated_at,
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn rename_editor_project(
|
||
State(state): State<AppState>,
|
||
Path(project_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorProjectRenameRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let project = state
|
||
.spacetime_client()
|
||
.rename_editor_project(EditorProjectRenameRecordInput {
|
||
project_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
title: payload.title,
|
||
updated_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorProjectResponse {
|
||
project: editor_project_payload_from_record(project),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn delete_editor_project(
|
||
State(state): State<AppState>,
|
||
Path(project_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let deleted_project_id = state
|
||
.spacetime_client()
|
||
.delete_editor_project(EditorProjectDeleteRecordInput {
|
||
project_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorProjectDeleteResponse { deleted_project_id },
|
||
))
|
||
}
|
||
|
||
pub async fn create_editor_project_resource(
|
||
State(state): State<AppState>,
|
||
Path(project_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorProjectResourceCreateRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let generation_inputs_json =
|
||
serialize_editor_asset_metadata(payload.generation_inputs.clone())?;
|
||
let object_key = normalize_editor_object_key(payload.object_key);
|
||
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
|
||
let resource = state
|
||
.spacetime_client()
|
||
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
|
||
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
|
||
project_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
asset_object_id: normalize_optional_string(payload.asset_object_id),
|
||
image_src,
|
||
object_key,
|
||
width: payload.width,
|
||
height: payload.height,
|
||
source_type: payload.source_type,
|
||
prompt: normalize_optional_string(payload.prompt),
|
||
actual_prompt: normalize_optional_string(payload.actual_prompt),
|
||
model: normalize_optional_string(payload.model),
|
||
provider: normalize_optional_string(payload.provider),
|
||
task_id: normalize_optional_string(payload.task_id),
|
||
source_resource_id: normalize_optional_string(payload.source_resource_id),
|
||
asset_kind: normalize_optional_string(payload.asset_kind),
|
||
generation_inputs_json,
|
||
updated_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorProjectResourceResponse {
|
||
resource: editor_project_resource_payload_from_record(resource),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn update_editor_project_resource_showcase(
|
||
State(state): State<AppState>,
|
||
Path(resource_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorProjectResourceShowcaseUpdateRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let resource = state
|
||
.spacetime_client()
|
||
.update_editor_project_resource_showcase(EditorProjectResourceShowcaseUpdateRecordInput {
|
||
resource_id,
|
||
owner_user_id: current_owner_user_id(&authenticated),
|
||
public_showcase_enabled: payload.public_showcase_enabled,
|
||
updated_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorProjectResourceResponse {
|
||
resource: editor_project_resource_payload_from_record(resource),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_editor_asset_library(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let owner_user_id = current_owner_user_id(&authenticated);
|
||
let library = state
|
||
.spacetime_client()
|
||
.get_editor_asset_library(owner_user_id.clone(), current_utc_micros())
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
let library =
|
||
repair_editor_asset_library_inline_media(&state, owner_user_id.as_str(), library).await;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorAssetLibraryResponse {
|
||
library: editor_asset_library_payload_from_record(library),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn create_editor_asset_folder(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorAssetFolderCreateRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let folder = state
|
||
.spacetime_client()
|
||
.create_editor_asset_folder(EditorAssetFolderCreateRecordInput {
|
||
folder_id: build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX),
|
||
owner_user_id: current_owner_user_id(&authenticated),
|
||
label: payload.label,
|
||
sort_order: payload.sort_order.unwrap_or(100),
|
||
now_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorAssetFolderResponse {
|
||
folder: editor_asset_folder_payload_from_record(folder),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn update_editor_asset_folder(
|
||
State(state): State<AppState>,
|
||
Path(folder_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorAssetFolderUpdateRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let folder = state
|
||
.spacetime_client()
|
||
.update_editor_asset_folder(EditorAssetFolderUpdateRecordInput {
|
||
folder_id,
|
||
owner_user_id: current_owner_user_id(&authenticated),
|
||
label: normalize_optional_string(payload.label),
|
||
collapsed: payload.collapsed,
|
||
updated_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorAssetFolderResponse {
|
||
folder: editor_asset_folder_payload_from_record(folder),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn delete_editor_asset_folder(
|
||
State(state): State<AppState>,
|
||
Path(folder_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let library = state
|
||
.spacetime_client()
|
||
.delete_editor_asset_folder(EditorAssetFolderDeleteRecordInput {
|
||
folder_id,
|
||
owner_user_id: current_owner_user_id(&authenticated),
|
||
updated_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorAssetFolderDeleteResponse {
|
||
library: editor_asset_library_payload_from_record(library),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn create_editor_asset(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorAssetCreateRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let generation_inputs_json =
|
||
serialize_editor_asset_metadata(payload.generation_inputs.clone())?;
|
||
let object_key = normalize_editor_object_key(payload.object_key);
|
||
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
|
||
let asset = state
|
||
.spacetime_client()
|
||
.create_editor_asset(EditorAssetCreateRecordInput {
|
||
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
|
||
owner_user_id: current_owner_user_id(&authenticated),
|
||
folder_id: payload.folder_id,
|
||
label: payload.label,
|
||
asset_object_id: normalize_optional_string(payload.asset_object_id),
|
||
image_src,
|
||
object_key,
|
||
width: payload.width,
|
||
height: payload.height,
|
||
source_type: payload.source_type,
|
||
prompt: normalize_optional_string(payload.prompt),
|
||
actual_prompt: normalize_optional_string(payload.actual_prompt),
|
||
model: normalize_optional_string(payload.model),
|
||
provider: normalize_optional_string(payload.provider),
|
||
task_id: normalize_optional_string(payload.task_id),
|
||
asset_kind: normalize_optional_string(payload.asset_kind),
|
||
generation_inputs_json,
|
||
source_resource_id: None,
|
||
now_micros: current_utc_micros(),
|
||
thumbnail_src: None,
|
||
generation_cost_mud_points: 0,
|
||
group_task_id: None,
|
||
group_task_expected_asset_count: None,
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorAssetResponse {
|
||
asset: editor_asset_payload_from_record(asset),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn update_editor_asset(
|
||
State(state): State<AppState>,
|
||
Path(asset_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorAssetUpdateRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let asset = state
|
||
.spacetime_client()
|
||
.update_editor_asset(EditorAssetUpdateRecordInput {
|
||
asset_id,
|
||
owner_user_id: current_owner_user_id(&authenticated),
|
||
label: normalize_optional_string(payload.label),
|
||
folder_id: normalize_optional_string(payload.folder_id),
|
||
updated_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorAssetResponse {
|
||
asset: editor_asset_payload_from_record(asset),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn delete_editor_asset(
|
||
State(state): State<AppState>,
|
||
Path(asset_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let asset = state
|
||
.spacetime_client()
|
||
.delete_editor_asset(EditorAssetDeleteRecordInput {
|
||
asset_id,
|
||
owner_user_id: current_owner_user_id(&authenticated),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorAssetResponse {
|
||
asset: editor_asset_payload_from_record(asset),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn submit_editor_asset_showcase(
|
||
State(state): State<AppState>,
|
||
Path(asset_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let record = state
|
||
.spacetime_client()
|
||
.submit_editor_showcase_asset(EditorShowcaseAssetSubmitRecordInput {
|
||
asset_id,
|
||
owner_user_id: current_owner_user_id(&authenticated),
|
||
now_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorShowcaseAssetResponse {
|
||
showcase_asset: editor_project_resource_payload_from_showcase_record(record),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn toggle_editor_showcase_asset_like(
|
||
State(state): State<AppState>,
|
||
Path(showcase_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorShowcaseAssetLikeRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let record = state
|
||
.spacetime_client()
|
||
.toggle_editor_showcase_asset_like(EditorShowcaseAssetLikeToggleRecordInput {
|
||
showcase_id,
|
||
user_id: current_owner_user_id(&authenticated),
|
||
liked: payload.liked,
|
||
updated_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorShowcaseAssetResponse {
|
||
showcase_asset: editor_project_resource_payload_from_showcase_record(record),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn generate_editor_image(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorImageGenerationRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
|
||
if !state.config.external_generation_mode.is_inline() {
|
||
ensure_editor_reference_image_sources_are_stable(
|
||
payload.reference_image_srcs.as_deref(),
|
||
"editor-image-generation",
|
||
"referenceImageSrcs",
|
||
"生成参考图",
|
||
)?;
|
||
let normalized_kind = payload.kind.as_deref().map(str::trim);
|
||
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
|
||
let is_publication_material_generation =
|
||
matches!(normalized_kind, Some("publication-material"));
|
||
let generation_options = normalize_editor_generation_options(
|
||
if is_ui_design_generation || is_publication_material_generation {
|
||
Some(GPT_IMAGE_2_MODEL)
|
||
} else {
|
||
payload.model.as_deref()
|
||
},
|
||
payload.aspect_ratio.as_deref(),
|
||
payload.image_size.as_deref(),
|
||
);
|
||
let price_mud_points = u64::from(
|
||
state
|
||
.editor_generation_pricing()
|
||
.await
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "editor-generation-pricing",
|
||
"message": error.to_string(),
|
||
}))
|
||
})?
|
||
.image_generation_mud_points(
|
||
normalized_kind,
|
||
Some(generation_options.model),
|
||
Some(generation_options.image_size),
|
||
),
|
||
);
|
||
let source_entity_id = editor_generation_source_entity_id(
|
||
payload.project_id.as_deref(),
|
||
"editor-image-generation",
|
||
);
|
||
let queue_job = enqueue_editor_generation_job(
|
||
&state,
|
||
&request_context,
|
||
caller.owner_user_id.as_str(),
|
||
EDITOR_IMAGE_GENERATION_JOB_KIND,
|
||
source_entity_id,
|
||
"图片画布生成图片",
|
||
price_mud_points,
|
||
&payload,
|
||
)
|
||
.await?;
|
||
return Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorGenerationQueuedResponse {
|
||
queue_state: editor_generation_queue_state(queue_job),
|
||
},
|
||
));
|
||
}
|
||
generate_editor_image_for_owner(&state, &request_context, caller, payload).await
|
||
}
|
||
|
||
pub(crate) async fn generate_editor_image_for_owner(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
caller: EditorGenerationCaller,
|
||
payload: EditorImageGenerationRequest,
|
||
) -> Result<Json<Value>, AppError> {
|
||
ensure_editor_reference_image_sources_are_stable(
|
||
payload.reference_image_srcs.as_deref(),
|
||
"editor-image-generation",
|
||
"referenceImageSrcs",
|
||
"生成参考图",
|
||
)?;
|
||
let role_setting = payload.prompt.trim().to_string();
|
||
if role_setting.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-image-generation",
|
||
"message": "生成提示词不能为空",
|
||
})),
|
||
);
|
||
}
|
||
|
||
let normalized_kind = payload.kind.as_deref().map(str::trim);
|
||
let is_character_generation = matches!(normalized_kind, Some("character"));
|
||
// 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时
|
||
// 仍白发一次 gpt-5-mini 决策。这里先固化决策需要、但随后会被 payload 消费掉的输入。
|
||
let requested_screen_color = payload.screen_color.clone();
|
||
let reference_count = payload.reference_image_srcs.as_ref().map_or(0, Vec::len);
|
||
let seg_model = if is_character_generation {
|
||
Some(parse_editor_bgfilter_seg_model(
|
||
payload.seg_model.as_deref(),
|
||
)?)
|
||
} else {
|
||
None
|
||
};
|
||
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
|
||
let is_publication_material_generation =
|
||
matches!(normalized_kind, Some("publication-material"));
|
||
let generation_options = normalize_editor_generation_options(
|
||
if is_ui_design_generation || is_publication_material_generation {
|
||
Some(GPT_IMAGE_2_MODEL)
|
||
} else {
|
||
payload.model.as_deref()
|
||
},
|
||
payload.aspect_ratio.as_deref(),
|
||
payload.image_size.as_deref(),
|
||
);
|
||
let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some();
|
||
let image_size = resolve_editor_image_request_size(
|
||
normalized_kind,
|
||
payload.size.as_deref(),
|
||
has_dimension_options,
|
||
&generation_options,
|
||
);
|
||
let provider_request_size = resolve_editor_image_provider_request_size(
|
||
normalized_kind,
|
||
payload.size.as_deref(),
|
||
has_dimension_options,
|
||
&generation_options,
|
||
);
|
||
let failure_context = match normalized_kind {
|
||
Some("character") => "图片画布生成角色形象",
|
||
Some("spec") => "图片画布生成规范",
|
||
Some("quick-edit") => "图片画布快速编辑图片",
|
||
Some("ui-design") => "图片画布生成UI设计图",
|
||
Some("publication-material") => "图片画布生成宣发素材",
|
||
_ => "图片画布生成图片",
|
||
};
|
||
let reference_limit = if matches!(normalized_kind, Some("quick-edit")) {
|
||
9
|
||
} else {
|
||
5
|
||
};
|
||
let reference_sources = payload
|
||
.reference_image_srcs
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.map(|source| source.trim().to_string())
|
||
.filter(|source| !source.is_empty())
|
||
.take(reference_limit)
|
||
.collect::<Vec<_>>();
|
||
// 决策移入闭包后 payload.reference_image_srcs 已被 reference_sources 消费,
|
||
// 这里从过滤后的 reference_sources 预先固化 ui-design 的「是否带参考图」布尔。
|
||
let ui_has_references = !reference_sources.is_empty();
|
||
let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体");
|
||
let reference_images = if reference_sources.is_empty() {
|
||
Vec::new()
|
||
} else {
|
||
let mut parsed_reference_images = Vec::with_capacity(reference_sources.len());
|
||
for source in &reference_sources {
|
||
parsed_reference_images.push(
|
||
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source.as_str())
|
||
.await?,
|
||
);
|
||
}
|
||
parsed_reference_images
|
||
};
|
||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||
request_context,
|
||
caller.audit_subject_user_id.clone(),
|
||
caller
|
||
.audit_project_id
|
||
.clone()
|
||
.or_else(|| payload.project_id.clone()),
|
||
);
|
||
let http_client = build_openai_image_http_client(&settings)?;
|
||
let image_size = if is_ui_design_generation {
|
||
Cow::Owned(generation_options.size.clone())
|
||
} else {
|
||
image_size
|
||
};
|
||
let provider_request_size = if is_ui_design_generation {
|
||
Cow::Owned(generation_options.provider_size.clone())
|
||
} else {
|
||
provider_request_size
|
||
};
|
||
let configured_price_mud_points = state
|
||
.editor_generation_pricing()
|
||
.await
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "editor-generation-pricing",
|
||
"message": error.to_string(),
|
||
}))
|
||
})?
|
||
.image_generation_mud_points(
|
||
normalized_kind,
|
||
Some(generation_options.model),
|
||
Some(generation_options.image_size),
|
||
);
|
||
let generate_operation = async {
|
||
// 背景色决策:预扣泥点之后才发起,仅角色生图需要(决策失败→future 返回 Err→失败退款,
|
||
// 用户不损失泥点,平台也只在用户确有余额时才付这次 gpt-5-mini 决策)。
|
||
let screen_background_decision = if is_character_generation {
|
||
Some(
|
||
resolve_editor_screen_background_color(
|
||
state.llm_client(),
|
||
state.editor_agent_llm_client(),
|
||
EditorScreenBackgroundDecisionInput {
|
||
kind: EditorScreenBackgroundDecisionKind::Character,
|
||
screen_color: requested_screen_color.clone(),
|
||
prompt: role_setting.clone(),
|
||
icon_descriptions: Vec::new(),
|
||
reference_count,
|
||
source_image_data_url: None,
|
||
},
|
||
Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
|
||
state,
|
||
context: crate::external_api_audit::ExternalApiAuditContext {
|
||
user_id: caller.audit_subject_user_id.clone(),
|
||
profile_id: caller
|
||
.audit_project_id
|
||
.clone()
|
||
.or_else(|| payload.project_id.clone()),
|
||
request_id: Some(request_context.request_id().to_string()),
|
||
external_call_deadline: request_context.external_call_deadline(),
|
||
},
|
||
}),
|
||
)
|
||
.await?,
|
||
)
|
||
} else {
|
||
None
|
||
};
|
||
let screen_color = screen_background_decision
|
||
.as_ref()
|
||
.map(|decision| decision.color);
|
||
let submitted_prompt = if is_character_generation {
|
||
build_editor_character_image_prompt(
|
||
role_setting.as_str(),
|
||
screen_color.expect("character generation should have screen color"),
|
||
)
|
||
} else if is_ui_design_generation {
|
||
build_editor_ui_design_prompt(role_setting.as_str(), ui_has_references)
|
||
} else {
|
||
role_setting.clone()
|
||
};
|
||
let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
|
||
create_openai_nanobanana_generate_content(
|
||
&http_client,
|
||
&settings,
|
||
generation_options.model,
|
||
submitted_prompt.as_str(),
|
||
negative_prompt,
|
||
generation_options.aspect_ratio,
|
||
generation_options.provider_image_size,
|
||
reference_images.as_slice(),
|
||
failure_context,
|
||
)
|
||
.await?
|
||
} else if reference_images.is_empty() {
|
||
create_openai_image_generation_with_model(
|
||
&http_client,
|
||
&settings,
|
||
generation_options.model,
|
||
submitted_prompt.as_str(),
|
||
negative_prompt,
|
||
provider_request_size.as_ref(),
|
||
1,
|
||
&[],
|
||
failure_context,
|
||
)
|
||
.await?
|
||
} else {
|
||
create_openai_image_edit_with_references_and_model(
|
||
&http_client,
|
||
&settings,
|
||
generation_options.model,
|
||
submitted_prompt.as_str(),
|
||
negative_prompt,
|
||
provider_request_size.as_ref(),
|
||
1,
|
||
reference_images.as_slice(),
|
||
failure_context,
|
||
)
|
||
.await?
|
||
};
|
||
Ok::<_, AppError>((generated, submitted_prompt, screen_background_decision))
|
||
};
|
||
let (generated, submitted_prompt, screen_background_decision) =
|
||
execute_billable_asset_operation_with_cost(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
editor_image_generation_billing_asset_kind(normalized_kind),
|
||
request_context.request_id(),
|
||
u64::from(configured_price_mud_points),
|
||
generate_operation,
|
||
)
|
||
.await?;
|
||
let screen_color = screen_background_decision
|
||
.as_ref()
|
||
.map(|decision| decision.color);
|
||
let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs(
|
||
payload.generation_inputs.clone(),
|
||
screen_background_decision.as_ref(),
|
||
);
|
||
let asset_label =
|
||
resolve_editor_generated_asset_label(payload.asset_label.clone(), role_setting.as_str());
|
||
let mut image = generated.images.into_iter().next().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "VectorEngine 未返回图片",
|
||
}))
|
||
})?;
|
||
let (provider_width, provider_height) = image::load_from_memory(image.bytes.as_slice())
|
||
.map(|image| (image.width(), image.height()))
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": format!("素材生成结果不是有效图片:{error}"),
|
||
}))
|
||
})?;
|
||
// 中文注释:nanobanana2 的 2K 是 provider 清晰度档位,16:9 实际可能返回
|
||
// 2752x1536;画布业务规格统一使用 512 / 1024 / 2048 长边像素矩阵,持久化前归一,
|
||
// 保证不同模型的完成图与生成前占位标注一致。
|
||
let (restored_image, dimension_restore_error) = if is_character_generation {
|
||
// 角色任务的 provider 原图是独立可复用中间产物,先按原始分辨率保存;
|
||
// 透明主结果会在后处理完成后再归一到画布业务规格。
|
||
(image, None)
|
||
} else {
|
||
restore_editor_generated_image_output_dimensions_or_original(
|
||
image,
|
||
generation_options.model,
|
||
image_size.as_ref(),
|
||
)
|
||
};
|
||
let mut dimension_warning = dimension_restore_error
|
||
.as_ref()
|
||
.map(editor_dimension_restore_warning);
|
||
if let Some(error) = dimension_restore_error {
|
||
tracing::warn!(
|
||
task_id = %generated.task_id,
|
||
provider_width,
|
||
provider_height,
|
||
target_size = %image_size,
|
||
error = ?error,
|
||
"画板生成图片尺寸恢复失败,保留并上传 provider 原图"
|
||
);
|
||
}
|
||
image = restored_image;
|
||
let (delivery_width, delivery_height) = image::load_from_memory(image.bytes.as_slice())
|
||
.map(|image| (image.width(), image.height()))
|
||
.unwrap_or((provider_width, provider_height));
|
||
let storage_profile = editor_generated_image_storage_profile(normalized_kind);
|
||
let mut output_prompt = submitted_prompt.clone();
|
||
let mut output_actual_prompt = generated.actual_prompt.clone();
|
||
let mut output_model = generation_options.model.to_string();
|
||
let mut output_provider = "VectorEngine".to_string();
|
||
let mut output_generation_inputs = generation_inputs.clone();
|
||
let source_record = if is_character_generation {
|
||
let source_persisted = persist_editor_provider_source_image(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
generated.task_id.as_str(),
|
||
image,
|
||
submitted_prompt.as_str(),
|
||
generated.actual_prompt.as_deref(),
|
||
storage_profile.asset_kind,
|
||
"character-images",
|
||
"character-image",
|
||
)
|
||
.await?;
|
||
let source_image_src =
|
||
editor_media_src_from_object_key(source_persisted.object_key.as_str());
|
||
let source_object_key = source_persisted.object_key.clone();
|
||
let source_asset_object_id = source_persisted.asset_object_id.clone();
|
||
let source_record = persist_editor_provider_source_resource(
|
||
state,
|
||
source_persisted,
|
||
PersistEditorProviderSourceResourceInput {
|
||
project_id: payload.project_id.clone(),
|
||
owner_user_id: caller.owner_user_id.clone(),
|
||
folder_id: payload
|
||
.asset_folder_id
|
||
.clone()
|
||
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
|
||
label: editor_generated_asset_variant_label(asset_label.as_str(), "原图"),
|
||
width: delivery_width,
|
||
height: delivery_height,
|
||
prompt: role_setting.clone(),
|
||
actual_prompt: generated.actual_prompt.clone(),
|
||
model: generation_options.model.to_string(),
|
||
task_id: generated.task_id.clone(),
|
||
source_resource_id: payload.source_resource_id.clone(),
|
||
asset_kind: payload
|
||
.asset_kind
|
||
.clone()
|
||
.or_else(|| Some("character".to_string())),
|
||
generation_inputs: generation_inputs.clone(),
|
||
generation_cost_mud_points: u64::from(configured_price_mud_points),
|
||
},
|
||
)
|
||
.await?;
|
||
caller.report_processing_phase(state).await?;
|
||
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
|
||
user_id: caller.audit_subject_user_id.clone(),
|
||
profile_id: caller
|
||
.audit_project_id
|
||
.clone()
|
||
.or_else(|| payload.project_id.clone()),
|
||
request_id: Some(request_context.request_id().to_string()),
|
||
external_call_deadline: request_context.external_call_deadline(),
|
||
};
|
||
let removal = remove_editor_generated_screen_background_with_bgfilter(
|
||
state,
|
||
source_object_key.as_str(),
|
||
screen_color.expect("character generation should have screen color"),
|
||
seg_model.expect("character generation should have BgFilter seg model"),
|
||
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
|
||
&matting_audit,
|
||
)
|
||
.await;
|
||
let removal = match removal {
|
||
Ok(removal) => removal,
|
||
Err(error) => {
|
||
let failure_message = error.body_text();
|
||
tracing::warn!(
|
||
provider = "editor-character-image",
|
||
operation = "background_postprocess",
|
||
task_id = %generated.task_id,
|
||
error = %failure_message,
|
||
"角色原图已保存,但透明背景处理失败,使用原图完成画布"
|
||
);
|
||
let completed_project = complete_editor_canvas_generation(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
payload.project_id.as_deref(),
|
||
payload.canvas_completion.as_ref(),
|
||
source_record.resource.as_ref(),
|
||
)
|
||
.await?;
|
||
return Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorImageGenerationResponse {
|
||
image_src: source_image_src,
|
||
object_key: Some(source_object_key),
|
||
asset_object_id: Some(source_asset_object_id),
|
||
width: delivery_width,
|
||
height: delivery_height,
|
||
source_type: "generated",
|
||
prompt: role_setting,
|
||
actual_prompt: generated.actual_prompt,
|
||
model: generation_options.model.to_string(),
|
||
provider: "VectorEngine",
|
||
task_id: generated.task_id,
|
||
resource: source_record.resource,
|
||
asset: source_record.asset,
|
||
project: completed_project,
|
||
warning: Some(editor_postprocess_fallback_warning(
|
||
"生成任务成功,后处理失败。",
|
||
)),
|
||
},
|
||
));
|
||
}
|
||
};
|
||
let removal_provider = removal.provider;
|
||
let (restored_removal_image, postprocess_dimension_error) =
|
||
apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original(
|
||
state,
|
||
source_object_key.as_str(),
|
||
delivery_width,
|
||
delivery_height,
|
||
removal.image,
|
||
)
|
||
.await;
|
||
if let Some(error) = postprocess_dimension_error {
|
||
tracing::warn!(
|
||
task_id = %generated.task_id,
|
||
provider = removal_provider,
|
||
provider_width,
|
||
provider_height,
|
||
delivery_width,
|
||
delivery_height,
|
||
error = ?error,
|
||
"角色透明图尺寸恢复失败,保留去背景服务原始输出"
|
||
);
|
||
}
|
||
image = restored_removal_image;
|
||
output_prompt = "去除纯色背景".to_string();
|
||
output_actual_prompt = None;
|
||
output_model = removal.model;
|
||
output_provider = removal_provider.to_string();
|
||
output_generation_inputs =
|
||
build_editor_derived_asset_generation_inputs("角色抠图", &output_model, &source_record);
|
||
Some(source_record)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if is_character_generation {
|
||
let (restored_image, dimension_restore_error) =
|
||
restore_editor_generated_image_output_dimensions_or_original(
|
||
image,
|
||
generation_options.model,
|
||
image_size.as_ref(),
|
||
);
|
||
if let Some(error) = dimension_restore_error {
|
||
dimension_warning = Some(editor_dimension_restore_warning(&error));
|
||
tracing::warn!(
|
||
task_id = %generated.task_id,
|
||
provider_width,
|
||
provider_height,
|
||
target_size = %image_size,
|
||
error = ?error,
|
||
"画板角色透明主图尺寸恢复失败,保留后处理原图"
|
||
);
|
||
}
|
||
image = restored_image;
|
||
}
|
||
|
||
let (width, height) = image::load_from_memory(image.bytes.as_slice())
|
||
.map(|image| (image.width(), image.height()))
|
||
.unwrap_or((1024, 1024));
|
||
let persisted = persist_editor_generated_image(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
generated.task_id.as_str(),
|
||
&image,
|
||
output_prompt.as_str(),
|
||
output_actual_prompt.as_deref(),
|
||
storage_profile.asset_kind,
|
||
storage_profile.path_kind,
|
||
"image",
|
||
storage_profile.slot,
|
||
output_provider.as_str(),
|
||
)
|
||
.await?;
|
||
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
|
||
let provider_source_resource_id = source_record
|
||
.as_ref()
|
||
.and_then(|record| record.resource.as_ref())
|
||
.map(|resource| resource.resource_id.clone());
|
||
let generated_asset = persist_editor_generated_asset(
|
||
state,
|
||
PersistEditorGeneratedAssetInput {
|
||
project_id: payload.project_id.clone(),
|
||
owner_user_id: caller.owner_user_id.clone(),
|
||
folder_id: payload.asset_folder_id,
|
||
label: asset_label,
|
||
image_src: image_src.clone(),
|
||
object_key: Some(persisted.object_key.clone()),
|
||
asset_object_id: Some(persisted.asset_object_id.clone()),
|
||
width,
|
||
height,
|
||
prompt: output_prompt,
|
||
actual_prompt: output_actual_prompt,
|
||
model: output_model,
|
||
provider: output_provider,
|
||
task_id: generated.task_id.clone(),
|
||
group_task_id: None,
|
||
group_task_expected_asset_count: None,
|
||
source_resource_id: provider_source_resource_id.or(payload.source_resource_id),
|
||
asset_kind: payload.asset_kind,
|
||
generation_inputs: output_generation_inputs,
|
||
thumbnail_src: None,
|
||
generation_cost_mud_points: if is_character_generation {
|
||
0
|
||
} else {
|
||
u64::from(configured_price_mud_points)
|
||
},
|
||
},
|
||
)
|
||
.await?;
|
||
let (canvas_items, primary_layer_id) =
|
||
if let Some(completion) = payload.canvas_completion.as_ref() {
|
||
build_multi_asset_canvas_layer_items(
|
||
completion,
|
||
generated_asset.resource.as_ref(),
|
||
source_record
|
||
.as_ref()
|
||
.and_then(|record| record.resource.as_ref()),
|
||
)
|
||
} else {
|
||
(Vec::new(), None)
|
||
};
|
||
let completed_project = complete_editor_canvas_generation_with_items(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
payload.project_id.as_deref(),
|
||
payload.canvas_completion.as_ref(),
|
||
canvas_items,
|
||
primary_layer_id,
|
||
)
|
||
.await?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorImageGenerationResponse {
|
||
image_src,
|
||
object_key: Some(persisted.object_key),
|
||
asset_object_id: Some(persisted.asset_object_id),
|
||
width,
|
||
height,
|
||
source_type: "generated",
|
||
prompt: role_setting,
|
||
actual_prompt: generated.actual_prompt,
|
||
model: generation_options.model.to_string(),
|
||
provider: "VectorEngine",
|
||
task_id: generated.task_id,
|
||
resource: generated_asset.resource,
|
||
asset: generated_asset.asset,
|
||
project: completed_project,
|
||
warning: dimension_warning,
|
||
},
|
||
))
|
||
}
|
||
|
||
fn editor_image_generation_billing_asset_kind(normalized_kind: Option<&str>) -> &'static str {
|
||
match normalized_kind {
|
||
Some("character") => EDITOR_CHARACTER_IMAGE_ASSET_KIND,
|
||
Some("spec") => EDITOR_SPEC_IMAGE_ASSET_KIND,
|
||
Some("quick-edit") => EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND,
|
||
Some("ui-design") => EDITOR_UI_DESIGN_IMAGE_ASSET_KIND,
|
||
Some("publication-material") => EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
|
||
_ => EDITOR_GENERATED_IMAGE_ASSET_KIND,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn apply_editor_screen_background_decision_to_generation_inputs(
|
||
generation_inputs: Option<Value>,
|
||
decision: Option<&EditorScreenBackgroundDecision>,
|
||
) -> Option<Value> {
|
||
let Some(decision) = decision else {
|
||
return generation_inputs;
|
||
};
|
||
let mut value = match generation_inputs {
|
||
Some(value) if value.is_object() => value,
|
||
Some(other) => return Some(other),
|
||
None => json!({ "fields": [], "references": [] }),
|
||
};
|
||
// 抠图背景色是内部自动决策 / 兜底细节,产品契约要求不进用户可见的 generationInputs.fields;
|
||
// 只写顶层内部可追踪元数据 screenColorHex(不属于用户输入快照,前端展示不读取)。
|
||
value["screenColorHex"] = Value::String(decision.color.hex.to_string());
|
||
Some(value)
|
||
}
|
||
|
||
fn build_editor_derived_asset_generation_inputs(
|
||
stage: &str,
|
||
model: &str,
|
||
source: &EditorGeneratedAssetRecord,
|
||
) -> Option<Value> {
|
||
let reference = source
|
||
.resource
|
||
.as_ref()
|
||
.map(|resource| {
|
||
json!({
|
||
"title": "输入素材",
|
||
"label": source
|
||
.asset
|
||
.as_ref()
|
||
.map(|asset| asset.label.as_str())
|
||
.unwrap_or("上一步产物"),
|
||
"refType": "project-resource",
|
||
"refId": resource.resource_id,
|
||
})
|
||
})
|
||
.or_else(|| {
|
||
source.asset.as_ref().map(|asset| {
|
||
json!({
|
||
"title": "输入素材",
|
||
"label": asset.label,
|
||
"refType": "asset",
|
||
"refId": asset.asset_id,
|
||
})
|
||
})
|
||
});
|
||
Some(json!({
|
||
"fields": [
|
||
{ "title": "处理阶段", "value": stage },
|
||
{ "title": "处理模型", "value": model },
|
||
],
|
||
"references": reference.into_iter().collect::<Vec<_>>(),
|
||
}))
|
||
}
|
||
|
||
fn editor_generated_image_storage_profile(
|
||
normalized_kind: Option<&str>,
|
||
) -> EditorGeneratedImageStorageProfile {
|
||
match normalized_kind {
|
||
Some("character") => EditorGeneratedImageStorageProfile {
|
||
asset_kind: EDITOR_CHARACTER_IMAGE_ASSET_KIND,
|
||
path_kind: "character-images",
|
||
slot: EDITOR_CHARACTER_IMAGE_SLOT,
|
||
},
|
||
Some("spec") => EditorGeneratedImageStorageProfile {
|
||
asset_kind: EDITOR_SPEC_IMAGE_ASSET_KIND,
|
||
path_kind: "spec-images",
|
||
slot: "spec",
|
||
},
|
||
Some("quick-edit") => EditorGeneratedImageStorageProfile {
|
||
asset_kind: EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND,
|
||
path_kind: "quick-edit-images",
|
||
slot: "quick-edit",
|
||
},
|
||
Some("ui-design") => EditorGeneratedImageStorageProfile {
|
||
asset_kind: EDITOR_UI_DESIGN_IMAGE_ASSET_KIND,
|
||
path_kind: "ui-design-images",
|
||
slot: "ui-design",
|
||
},
|
||
Some("publication-material") => EditorGeneratedImageStorageProfile {
|
||
asset_kind: EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
|
||
path_kind: "publication-materials",
|
||
slot: "publication-material",
|
||
},
|
||
_ => EditorGeneratedImageStorageProfile {
|
||
asset_kind: EDITOR_GENERATED_IMAGE_ASSET_KIND,
|
||
path_kind: "generated-images",
|
||
slot: "image",
|
||
},
|
||
}
|
||
}
|
||
|
||
async fn resolve_editor_image_edit_price(
|
||
state: &AppState,
|
||
model: &str,
|
||
size: &str,
|
||
image_size: Option<&str>,
|
||
) -> Result<u32, AppError> {
|
||
let price_size = if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
|
||
image_size
|
||
.map(|value| normalize_editor_generation_image_size(model, Some(value)))
|
||
.unwrap_or_else(|| editor_image_price_size_from_pixels(size))
|
||
} else {
|
||
editor_image_price_size_from_pixels(size)
|
||
};
|
||
let expected_price_mud_points = state
|
||
.editor_generation_pricing()
|
||
.await
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "editor-generation-pricing",
|
||
"message": error.to_string(),
|
||
}))
|
||
})?
|
||
.image_generation_mud_points(Some("quick-edit"), Some(model), Some(price_size));
|
||
Ok(expected_price_mud_points)
|
||
}
|
||
|
||
fn editor_image_price_size_from_pixels(size: &str) -> &'static str {
|
||
let Some((width, height)) = size.split_once('x') else {
|
||
return "1K";
|
||
};
|
||
let Ok(width) = width.parse::<u32>() else {
|
||
return "1K";
|
||
};
|
||
let Ok(height) = height.parse::<u32>() else {
|
||
return "1K";
|
||
};
|
||
if width.max(height) > 1536 { "2K" } else { "1K" }
|
||
}
|
||
|
||
fn editor_image_edit_uses_nanobanana_generate_content(model: &str) -> bool {
|
||
model == EDITOR_IMAGE_MODEL_NANOBANANA2
|
||
}
|
||
|
||
fn normalize_editor_image_edit_generation_options(
|
||
model: Option<&str>,
|
||
aspect_ratio: Option<&str>,
|
||
image_size: Option<&str>,
|
||
legacy_size: Option<&str>,
|
||
) -> EditorGenerationOptions {
|
||
let model_options = normalize_editor_generation_options(model, None, None);
|
||
let inferred_aspect_ratio = aspect_ratio
|
||
.filter(|value| !value.trim().is_empty())
|
||
.or_else(|| infer_editor_image_edit_aspect_ratio(legacy_size));
|
||
let inferred_image_size = image_size
|
||
.filter(|value| !value.trim().is_empty())
|
||
.or_else(|| infer_editor_image_edit_size_tier(model_options.model, legacy_size));
|
||
normalize_editor_generation_options(model, inferred_aspect_ratio, inferred_image_size)
|
||
}
|
||
|
||
fn infer_editor_image_edit_aspect_ratio(size: Option<&str>) -> Option<&'static str> {
|
||
let (width, height) = parse_editor_image_edit_pixel_size(size?)?;
|
||
let source_ratio = f64::from(width) / f64::from(height);
|
||
[
|
||
("1:1", 1.0),
|
||
("4:3", 4.0 / 3.0),
|
||
("3:2", 3.0 / 2.0),
|
||
("2:3", 2.0 / 3.0),
|
||
("9:16", 9.0 / 16.0),
|
||
("16:9", 16.0 / 9.0),
|
||
]
|
||
.into_iter()
|
||
.min_by(|(_, left), (_, right)| {
|
||
(left - source_ratio)
|
||
.abs()
|
||
.total_cmp(&(right - source_ratio).abs())
|
||
})
|
||
.map(|(label, _)| label)
|
||
}
|
||
|
||
fn infer_editor_image_edit_size_tier(model: &str, size: Option<&str>) -> Option<&'static str> {
|
||
let (width, height) = parse_editor_image_edit_pixel_size(size?)?;
|
||
let long_edge = width.max(height);
|
||
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 && long_edge <= 768 {
|
||
return Some("0.5K");
|
||
}
|
||
Some(if long_edge > 1536 { "2K" } else { "1K" })
|
||
}
|
||
|
||
fn parse_editor_image_edit_pixel_size(size: &str) -> Option<(u32, u32)> {
|
||
let normalized = size.trim().replace('*', "x");
|
||
let (width, height) = normalized.split_once('x')?;
|
||
let width = width.parse::<u32>().ok()?;
|
||
let height = height.parse::<u32>().ok()?;
|
||
(width > 0 && height > 0).then_some((width, height))
|
||
}
|
||
|
||
fn normalize_editor_image_generation_size(size: Option<&str>) -> Cow<'static, str> {
|
||
match size.map(str::trim).filter(|value| !value.is_empty()) {
|
||
Some("1024x1024") | Some("1024*1024") | Some("1:1") => Cow::Borrowed("1024x1024"),
|
||
Some("1536x1024") | Some("1536*1024") | Some("16:9") => Cow::Borrowed("1536x1024"),
|
||
Some("2048x1152") | Some("2048*1152") | Some("1920x1080") | Some("1920*1080")
|
||
| Some("2k-16:9") => Cow::Borrowed("2048x1152"),
|
||
Some("1024x1536") | Some("1024*1536") | Some("9:16") => Cow::Borrowed("1024x1536"),
|
||
Some(value) if is_editor_custom_image_size(value) => Cow::Owned(value.to_string()),
|
||
_ => Cow::Borrowed(EDITOR_IMAGE_GENERATION_SIZE),
|
||
}
|
||
}
|
||
|
||
fn resolve_editor_image_request_size(
|
||
normalized_kind: Option<&str>,
|
||
payload_size: Option<&str>,
|
||
has_dimension_options: bool,
|
||
generation_options: &EditorGenerationOptions,
|
||
) -> Cow<'static, str> {
|
||
let legacy_size = normalize_editor_image_generation_size(payload_size);
|
||
let has_explicit_payload_size = payload_size
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty());
|
||
if matches!(normalized_kind, Some("publication-material")) && has_explicit_payload_size {
|
||
return legacy_size;
|
||
}
|
||
if has_dimension_options {
|
||
return Cow::Owned(generation_options.size.clone());
|
||
}
|
||
legacy_size
|
||
}
|
||
|
||
fn resolve_editor_image_provider_request_size(
|
||
normalized_kind: Option<&str>,
|
||
payload_size: Option<&str>,
|
||
has_dimension_options: bool,
|
||
generation_options: &EditorGenerationOptions,
|
||
) -> Cow<'static, str> {
|
||
let legacy_size = normalize_editor_image_generation_size(payload_size);
|
||
let has_explicit_payload_size = payload_size
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty());
|
||
if matches!(normalized_kind, Some("publication-material")) && has_explicit_payload_size {
|
||
return legacy_size;
|
||
}
|
||
if has_dimension_options {
|
||
return Cow::Owned(generation_options.provider_size.clone());
|
||
}
|
||
legacy_size
|
||
}
|
||
|
||
fn is_editor_custom_image_size(value: &str) -> bool {
|
||
let Some((width, height)) = value.split_once('x') else {
|
||
return false;
|
||
};
|
||
let Ok(width) = width.parse::<u32>() else {
|
||
return false;
|
||
};
|
||
let Ok(height) = height.parse::<u32>() else {
|
||
return false;
|
||
};
|
||
(64..=4096).contains(&width) && (64..=4096).contains(&height)
|
||
}
|
||
|
||
fn normalize_editor_generation_options(
|
||
model: Option<&str>,
|
||
aspect_ratio: Option<&str>,
|
||
image_size: Option<&str>,
|
||
) -> EditorGenerationOptions {
|
||
let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) {
|
||
Some(GPT_IMAGE_2_MODEL) => GPT_IMAGE_2_MODEL,
|
||
Some(
|
||
EDITOR_IMAGE_MODEL_NANOBANANA2
|
||
| EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS
|
||
| EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS,
|
||
) => EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||
// 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2;
|
||
// 角色 / 图标素材入口由前端显式传入 nanobanana2 默认值。
|
||
None => GPT_IMAGE_2_MODEL,
|
||
_ => EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||
};
|
||
let aspect_ratio = normalize_editor_generation_aspect_ratio(aspect_ratio);
|
||
let image_size = normalize_editor_generation_image_size(normalized_model, image_size);
|
||
let size = editor_generation_delivery_size(aspect_ratio, image_size);
|
||
let provider_size =
|
||
editor_generation_provider_size_for_model(normalized_model, aspect_ratio, image_size);
|
||
let provider_image_size =
|
||
editor_generation_provider_image_size_for_model(normalized_model, image_size);
|
||
|
||
EditorGenerationOptions {
|
||
model: normalized_model,
|
||
aspect_ratio,
|
||
image_size,
|
||
size,
|
||
provider_size,
|
||
provider_image_size,
|
||
}
|
||
}
|
||
|
||
fn normalize_editor_generation_aspect_ratio(aspect_ratio: Option<&str>) -> &'static str {
|
||
match aspect_ratio
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
{
|
||
Some("2:3") => "2:3",
|
||
Some("3:2") => "3:2",
|
||
Some("4:3") => "4:3",
|
||
Some("9:16") => "9:16",
|
||
Some("16:9") => "16:9",
|
||
_ => "1:1",
|
||
}
|
||
}
|
||
|
||
fn normalize_editor_generation_image_size(model: &str, image_size: Option<&str>) -> &'static str {
|
||
match (
|
||
model,
|
||
image_size.map(str::trim).filter(|value| !value.is_empty()),
|
||
) {
|
||
(EDITOR_IMAGE_MODEL_NANOBANANA2, Some("0.5K")) => "0.5K",
|
||
(_, Some("2K")) => "2K",
|
||
_ => "1K",
|
||
}
|
||
}
|
||
|
||
fn editor_generation_delivery_size(aspect_ratio: &str, image_size: &str) -> String {
|
||
let long_edge = match image_size {
|
||
"0.5K" => 512_u32,
|
||
"2K" => 2048_u32,
|
||
_ => 1024_u32,
|
||
};
|
||
let (ratio_width, ratio_height) = match aspect_ratio {
|
||
"2:3" => (2_u32, 3_u32),
|
||
"3:2" => (3_u32, 2_u32),
|
||
"4:3" => (4_u32, 3_u32),
|
||
"9:16" => (9_u32, 16_u32),
|
||
"16:9" => (16_u32, 9_u32),
|
||
_ => (1_u32, 1_u32),
|
||
};
|
||
let (width, height) = if ratio_width >= ratio_height {
|
||
(
|
||
long_edge,
|
||
(long_edge * ratio_height + ratio_width / 2) / ratio_width,
|
||
)
|
||
} else {
|
||
(
|
||
(long_edge * ratio_width + ratio_height / 2) / ratio_height,
|
||
long_edge,
|
||
)
|
||
};
|
||
format!("{width}x{height}")
|
||
}
|
||
|
||
fn editor_generation_provider_size_for_model(
|
||
model: &str,
|
||
aspect_ratio: &str,
|
||
image_size: &str,
|
||
) -> String {
|
||
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
|
||
return editor_generation_delivery_size(aspect_ratio, image_size);
|
||
}
|
||
match (image_size, aspect_ratio) {
|
||
("2K", "1:1") => "2048x2048",
|
||
("2K", "2:3") => "1376x2048",
|
||
("2K", "9:16") => "1152x2048",
|
||
("2K", "4:3") => "2048x1536",
|
||
("2K", "3:2") => "2048x1376",
|
||
("2K", "16:9") => "2048x1152",
|
||
("1K", "2:3") => "688x1024",
|
||
// 中文注释:9:16 的 1024 长边会低于 provider 最小总像素,按原比例放大并做 16 对齐后直接请求。
|
||
("1K", "9:16") => "608x1088",
|
||
("1K", "4:3") => "1024x768",
|
||
("1K", "3:2") => "1024x688",
|
||
("1K", "16:9") => "1088x608",
|
||
_ => "1024x1024",
|
||
}
|
||
.to_string()
|
||
}
|
||
|
||
fn editor_generation_provider_image_size_for_model(
|
||
model: &str,
|
||
image_size: &'static str,
|
||
) -> &'static str {
|
||
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
|
||
return match image_size {
|
||
"0.5K" => "512",
|
||
"2K" => "2K",
|
||
_ => "1K",
|
||
};
|
||
}
|
||
|
||
image_size
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
struct EditorImageEditDimensions {
|
||
target_width: u32,
|
||
target_height: u32,
|
||
provider_width: u32,
|
||
provider_height: u32,
|
||
}
|
||
|
||
fn align_editor_image_edit_dimension(value: u32) -> u32 {
|
||
value.saturating_add(15) / 16 * 16
|
||
}
|
||
|
||
fn ensure_editor_image_edit_asset_kind_allowed(asset_kind: Option<&str>) -> Result<(), AppError> {
|
||
let Some(asset_kind) = asset_kind.map(str::trim).filter(|value| !value.is_empty()) else {
|
||
return Ok(());
|
||
};
|
||
if asset_kind != "icon" {
|
||
return Ok(());
|
||
}
|
||
|
||
Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-image-edit",
|
||
"message": "单个图标不支持快速编辑",
|
||
"assetKind": asset_kind,
|
||
})),
|
||
)
|
||
}
|
||
|
||
fn resolve_editor_image_edit_target_layer_asset_kind<'a>(
|
||
layers: &'a Value,
|
||
resources: &'a [EditorProjectResourcePayload],
|
||
target_layer_id: &str,
|
||
) -> Result<Option<&'a str>, AppError> {
|
||
let target_layer = layers
|
||
.as_array()
|
||
.and_then(|items| {
|
||
items
|
||
.iter()
|
||
.find(|item| item.get("layerId").and_then(Value::as_str) == Some(target_layer_id))
|
||
})
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-image-edit",
|
||
"message": "快速编辑目标图层不存在",
|
||
"targetLayerId": target_layer_id,
|
||
}))
|
||
})?;
|
||
if let Some(asset_kind) = target_layer.get("assetKind").and_then(Value::as_str) {
|
||
return Ok(Some(asset_kind));
|
||
}
|
||
let resource_id = target_layer.get("resourceId").and_then(Value::as_str);
|
||
Ok(resource_id.and_then(|resource_id| {
|
||
resources
|
||
.iter()
|
||
.find(|resource| resource.resource_id == resource_id)
|
||
.and_then(|resource| resource.asset_kind.as_deref())
|
||
}))
|
||
}
|
||
|
||
async fn ensure_editor_image_edit_source_allowed(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
payload: &EditorImageEditRequest,
|
||
) -> Result<(), AppError> {
|
||
let Some(project_id) = payload
|
||
.project_id
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
else {
|
||
return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref());
|
||
};
|
||
let Some(target_layer_id) = payload
|
||
.target_layer_id
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
else {
|
||
return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref());
|
||
};
|
||
let project = state
|
||
.spacetime_client()
|
||
.get_editor_project(EditorProjectGetRecordInput {
|
||
project_id: project_id.to_string(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
let project = editor_project_payload_from_record(project);
|
||
let asset_kind = resolve_editor_image_edit_target_layer_asset_kind(
|
||
&project.layers,
|
||
&project.resources,
|
||
target_layer_id,
|
||
)?
|
||
.or(payload.asset_kind.as_deref());
|
||
ensure_editor_image_edit_asset_kind_allowed(asset_kind)
|
||
}
|
||
|
||
fn encode_editor_image_png(
|
||
image: image::DynamicImage,
|
||
failure_status: StatusCode,
|
||
provider: &str,
|
||
failure_message: &str,
|
||
) -> Result<Vec<u8>, AppError> {
|
||
let mut bytes = Cursor::new(Vec::new());
|
||
image
|
||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||
.map_err(|error| {
|
||
AppError::from_status(failure_status).with_details(json!({
|
||
"provider": provider,
|
||
"message": format!("{failure_message}:{error}"),
|
||
}))
|
||
})?;
|
||
Ok(bytes.into_inner())
|
||
}
|
||
|
||
fn encode_editor_image_edit_png(
|
||
image: image::DynamicImage,
|
||
failure_status: StatusCode,
|
||
failure_message: &str,
|
||
) -> Result<Vec<u8>, AppError> {
|
||
encode_editor_image_png(image, failure_status, "editor-image-edit", failure_message)
|
||
}
|
||
|
||
fn prepare_editor_image_edit_references(
|
||
reference_images: &mut [OpenAiReferenceImage],
|
||
target_size: &str,
|
||
) -> Result<EditorImageEditDimensions, AppError> {
|
||
let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-image-edit",
|
||
"message": "图片改造目标尺寸无效",
|
||
}))
|
||
})?;
|
||
let target_width = target_width.parse::<u32>().map_err(|_| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-image-edit",
|
||
"message": "图片改造目标宽度无效",
|
||
}))
|
||
})?;
|
||
let target_height = target_height.parse::<u32>().map_err(|_| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-image-edit",
|
||
"message": "图片改造目标高度无效",
|
||
}))
|
||
})?;
|
||
let provider_width = align_editor_image_edit_dimension(target_width);
|
||
let provider_height = align_editor_image_edit_dimension(target_height);
|
||
if reference_images.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-image-edit",
|
||
"message": "图片改造缺少原图",
|
||
})),
|
||
);
|
||
}
|
||
for (index, reference) in reference_images.iter_mut().enumerate() {
|
||
let decoded = image::load_from_memory(reference.bytes.as_slice()).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-image-edit",
|
||
"message": format!("图片改造参考图不是有效图片:{error}"),
|
||
"referenceIndex": index,
|
||
}))
|
||
})?;
|
||
let width = decoded.width();
|
||
let height = decoded.height();
|
||
let reference_provider_width = align_editor_image_edit_dimension(width);
|
||
let reference_provider_height = align_editor_image_edit_dimension(height);
|
||
if reference_provider_width == width && reference_provider_height == height {
|
||
continue;
|
||
}
|
||
|
||
// 中文注释:只在 provider 边界向右、向下复制边缘像素补齐到 16 倍数,
|
||
// 不缩放、不裁切用户原图;生成结果持久化前会恢复到业务目标精确尺寸。
|
||
let source = decoded.to_rgba8();
|
||
let mut aligned =
|
||
image::RgbaImage::new(reference_provider_width, reference_provider_height);
|
||
for y in 0..reference_provider_height {
|
||
let source_y = y.min(height.saturating_sub(1));
|
||
for x in 0..reference_provider_width {
|
||
let source_x = x.min(width.saturating_sub(1));
|
||
aligned.put_pixel(x, y, *source.get_pixel(source_x, source_y));
|
||
}
|
||
}
|
||
reference.bytes = encode_editor_image_edit_png(
|
||
image::DynamicImage::ImageRgba8(aligned),
|
||
StatusCode::BAD_REQUEST,
|
||
"图片改造参考图 16 对齐失败",
|
||
)?;
|
||
reference.mime_type = "image/png".to_string();
|
||
reference.file_name = format!("editor-image-edit-reference-{}.png", index + 1);
|
||
}
|
||
|
||
Ok(EditorImageEditDimensions {
|
||
target_width,
|
||
target_height,
|
||
provider_width,
|
||
provider_height,
|
||
})
|
||
}
|
||
|
||
fn ensure_editor_output_can_restore_without_upscaling(
|
||
source_width: u32,
|
||
source_height: u32,
|
||
target_width: u32,
|
||
target_height: u32,
|
||
operation: &str,
|
||
) -> Result<(), AppError> {
|
||
if target_width == 0 || target_height == 0 {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"code": "invalid-delivery-size",
|
||
"message": "目标交付尺寸必须大于 0",
|
||
"operation": operation,
|
||
"targetWidth": target_width,
|
||
"targetHeight": target_height,
|
||
})),
|
||
);
|
||
}
|
||
if source_width < target_width || source_height < target_height {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"code": "provider-image-under-delivery",
|
||
"message": "provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位",
|
||
"operation": operation,
|
||
"sourceWidth": source_width,
|
||
"sourceHeight": source_height,
|
||
"targetWidth": target_width,
|
||
"targetHeight": target_height,
|
||
})),
|
||
);
|
||
}
|
||
|
||
let source_ratio = f64::from(source_width) / f64::from(source_height);
|
||
let target_ratio = f64::from(target_width) / f64::from(target_height);
|
||
let crop_fraction = if source_ratio >= target_ratio {
|
||
1.0 - target_ratio / source_ratio
|
||
} else {
|
||
1.0 - source_ratio / target_ratio
|
||
};
|
||
if crop_fraction > 0.05 {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"code": "provider-image-aspect-ratio-mismatch",
|
||
"message": "provider 回图比例偏差过大,禁止大幅裁切为目标交付尺寸",
|
||
"operation": operation,
|
||
"sourceWidth": source_width,
|
||
"sourceHeight": source_height,
|
||
"targetWidth": target_width,
|
||
"targetHeight": target_height,
|
||
"cropFraction": crop_fraction,
|
||
})),
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn restore_editor_image_edit_output_dimensions(
|
||
output: &DownloadedOpenAiImage,
|
||
dimensions: &EditorImageEditDimensions,
|
||
) -> Result<Option<DownloadedOpenAiImage>, AppError> {
|
||
let decoded = image::load_from_memory(output.bytes.as_slice()).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": format!("图片改造结果不是有效图片:{error}"),
|
||
}))
|
||
})?;
|
||
if decoded.width() == dimensions.target_width && decoded.height() == dimensions.target_height {
|
||
return Ok(None);
|
||
}
|
||
ensure_editor_output_can_restore_without_upscaling(
|
||
decoded.width(),
|
||
decoded.height(),
|
||
dimensions.target_width,
|
||
dimensions.target_height,
|
||
"editor-image-edit",
|
||
)?;
|
||
|
||
let restored = if decoded.width() == dimensions.provider_width
|
||
&& decoded.height() == dimensions.provider_height
|
||
{
|
||
decoded.crop_imm(0, 0, dimensions.target_width, dimensions.target_height)
|
||
} else {
|
||
decoded.resize_to_fill(
|
||
dimensions.target_width,
|
||
dimensions.target_height,
|
||
image::imageops::FilterType::Lanczos3,
|
||
)
|
||
};
|
||
Ok(Some(DownloadedOpenAiImage {
|
||
bytes: encode_editor_image_edit_png(
|
||
restored,
|
||
StatusCode::BAD_GATEWAY,
|
||
"恢复图片改造结果目标尺寸失败",
|
||
)?,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
}))
|
||
}
|
||
|
||
fn restore_editor_image_edit_output_dimensions_or_original(
|
||
output: DownloadedOpenAiImage,
|
||
dimensions: &EditorImageEditDimensions,
|
||
) -> (DownloadedOpenAiImage, Option<AppError>) {
|
||
match restore_editor_image_edit_output_dimensions(&output, dimensions) {
|
||
Ok(Some(restored)) => (restored, None),
|
||
Ok(None) => (output, None),
|
||
Err(error) => (output, Some(error)),
|
||
}
|
||
}
|
||
|
||
fn restore_editor_generated_image_output_dimensions_or_original(
|
||
output: DownloadedOpenAiImage,
|
||
model: &str,
|
||
target_size: &str,
|
||
) -> (DownloadedOpenAiImage, Option<AppError>) {
|
||
match restore_editor_generated_image_output_dimensions(&output, model, target_size) {
|
||
Ok(Some(restored)) => (restored, None),
|
||
Ok(None) => (output, None),
|
||
Err(error) => (output, Some(error)),
|
||
}
|
||
}
|
||
|
||
fn apply_editor_postprocessed_alpha_to_provider_source(
|
||
provider_source: &DownloadedOpenAiImage,
|
||
postprocessed: &DownloadedOpenAiImage,
|
||
) -> Result<Option<DownloadedOpenAiImage>, AppError> {
|
||
let provider_source =
|
||
image::load_from_memory(provider_source.bytes.as_slice()).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "editor-image-postprocess",
|
||
"message": format!("provider 原图不是有效图片:{error}"),
|
||
}))
|
||
})?;
|
||
let postprocessed =
|
||
image::load_from_memory(postprocessed.bytes.as_slice()).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "editor-image-postprocess",
|
||
"message": format!("透明后处理图不是有效图片:{error}"),
|
||
}))
|
||
})?;
|
||
if postprocessed.width() == provider_source.width()
|
||
&& postprocessed.height() == provider_source.height()
|
||
{
|
||
return Ok(None);
|
||
}
|
||
|
||
// 中文注释:图片模型已经按用户所选 K 档直接生成 provider 原图。
|
||
// 去背景服务若缩小图片,只重采样其 alpha 蒙版并应用回原始分辨率 RGB,禁止把低分辨率成品整图放大。
|
||
let postprocessed = postprocessed.to_rgba8();
|
||
let alpha = image::GrayImage::from_fn(postprocessed.width(), postprocessed.height(), |x, y| {
|
||
image::Luma([postprocessed.get_pixel(x, y).0[3]])
|
||
});
|
||
let alpha = image::imageops::resize(
|
||
&alpha,
|
||
provider_source.width(),
|
||
provider_source.height(),
|
||
image::imageops::FilterType::Lanczos3,
|
||
);
|
||
let mut restored = provider_source.to_rgba8();
|
||
for (x, y, pixel) in restored.enumerate_pixels_mut() {
|
||
pixel.0[3] = alpha.get_pixel(x, y).0[0];
|
||
}
|
||
Ok(Some(DownloadedOpenAiImage {
|
||
bytes: encode_editor_image_png(
|
||
image::DynamicImage::ImageRgba8(restored),
|
||
StatusCode::BAD_GATEWAY,
|
||
"editor-image-postprocess",
|
||
"恢复透明蒙版尺寸失败",
|
||
)?,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
}))
|
||
}
|
||
|
||
async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original(
|
||
state: &AppState,
|
||
provider_source_object_key: &str,
|
||
provider_width: u32,
|
||
provider_height: u32,
|
||
postprocessed: DownloadedOpenAiImage,
|
||
) -> (DownloadedOpenAiImage, Option<AppError>) {
|
||
let postprocessed_dimensions =
|
||
image::ImageReader::new(Cursor::new(postprocessed.bytes.as_slice()))
|
||
.with_guessed_format()
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "editor-image-postprocess",
|
||
"message": format!("无法识别透明后处理图格式:{error}"),
|
||
}))
|
||
})
|
||
.and_then(|reader| {
|
||
reader.into_dimensions().map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "editor-image-postprocess",
|
||
"message": format!("无法读取透明后处理图尺寸:{error}"),
|
||
}))
|
||
})
|
||
});
|
||
match postprocessed_dimensions {
|
||
Ok((postprocessed_width, postprocessed_height))
|
||
if postprocessed_width == provider_width && postprocessed_height == provider_height =>
|
||
{
|
||
return (postprocessed, None);
|
||
}
|
||
Ok(_) => {}
|
||
Err(error) => return (postprocessed, Some(error)),
|
||
}
|
||
|
||
// 中文注释:provider 原图已在 BgFilter 调用前交给 OSS 并释放;只有输出尺寸漂移时才短时回读原图回贴 alpha。
|
||
let provider_source =
|
||
match download_editor_persisted_image_object(state, provider_source_object_key).await {
|
||
Ok(provider_source) => provider_source,
|
||
Err(error) => return (postprocessed, Some(error)),
|
||
};
|
||
match apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed) {
|
||
Ok(Some(restored)) => (restored, None),
|
||
Ok(None) => (postprocessed, None),
|
||
Err(error) => (postprocessed, Some(error)),
|
||
}
|
||
}
|
||
|
||
fn restore_editor_generated_image_output_dimensions(
|
||
output: &DownloadedOpenAiImage,
|
||
model: &str,
|
||
target_size: &str,
|
||
) -> Result<Option<DownloadedOpenAiImage>, AppError> {
|
||
let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "尺寸无效",
|
||
}))
|
||
})?;
|
||
let target_width = target_width.parse::<u32>().map_err(|_| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "宽度无效",
|
||
}))
|
||
})?;
|
||
let target_height = target_height.parse::<u32>().map_err(|_| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "高度无效",
|
||
}))
|
||
})?;
|
||
let decoded = image::load_from_memory(output.bytes.as_slice()).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": format!("素材生成结果不是有效图片:{error}"),
|
||
}))
|
||
})?;
|
||
if decoded.width() == target_width && decoded.height() == target_height {
|
||
return Ok(None);
|
||
}
|
||
ensure_editor_output_can_restore_without_upscaling(
|
||
decoded.width(),
|
||
decoded.height(),
|
||
target_width,
|
||
target_height,
|
||
model,
|
||
)?;
|
||
|
||
let restored = decoded.resize_to_fill(
|
||
target_width,
|
||
target_height,
|
||
image::imageops::FilterType::Lanczos3,
|
||
);
|
||
Ok(Some(DownloadedOpenAiImage {
|
||
bytes: encode_editor_image_edit_png(
|
||
restored,
|
||
StatusCode::BAD_GATEWAY,
|
||
"恢复图片生成目标尺寸失败",
|
||
)?,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
}))
|
||
}
|
||
|
||
pub async fn edit_editor_image(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorImageEditRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
|
||
if !state.config.external_generation_mode.is_inline() {
|
||
ensure_editor_reference_image_source_is_stable(
|
||
payload.source_image_src.as_str(),
|
||
"editor-image-edit",
|
||
"sourceImageSrc",
|
||
"待修改图片",
|
||
)?;
|
||
ensure_editor_reference_image_sources_are_stable(
|
||
payload.reference_image_srcs.as_deref(),
|
||
"editor-image-edit",
|
||
"referenceImageSrcs",
|
||
"修改参考图",
|
||
)?;
|
||
ensure_editor_image_edit_source_allowed(&state, caller.owner_user_id.as_str(), &payload)
|
||
.await?;
|
||
let generation_options = normalize_editor_image_edit_generation_options(
|
||
payload.model.as_deref(),
|
||
payload.aspect_ratio.as_deref(),
|
||
payload.image_size.as_deref(),
|
||
payload.size.as_deref(),
|
||
);
|
||
let image_size = normalize_editor_image_generation_size(payload.size.as_deref());
|
||
let price_mud_points = u64::from(
|
||
resolve_editor_image_edit_price(
|
||
&state,
|
||
generation_options.model,
|
||
image_size.as_ref(),
|
||
Some(generation_options.image_size),
|
||
)
|
||
.await?,
|
||
);
|
||
let source_entity_id =
|
||
editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-image-edit");
|
||
let queue_job = enqueue_editor_generation_job(
|
||
&state,
|
||
&request_context,
|
||
caller.owner_user_id.as_str(),
|
||
EDITOR_IMAGE_EDIT_JOB_KIND,
|
||
source_entity_id,
|
||
"图片画布修改图片",
|
||
price_mud_points,
|
||
&payload,
|
||
)
|
||
.await?;
|
||
return Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorGenerationQueuedResponse {
|
||
queue_state: editor_generation_queue_state(queue_job),
|
||
},
|
||
));
|
||
}
|
||
edit_editor_image_for_owner(&state, &request_context, caller, payload).await
|
||
}
|
||
|
||
pub(crate) async fn edit_editor_image_for_owner(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
caller: EditorGenerationCaller,
|
||
payload: EditorImageEditRequest,
|
||
) -> Result<Json<Value>, AppError> {
|
||
ensure_editor_reference_image_source_is_stable(
|
||
payload.source_image_src.as_str(),
|
||
"editor-image-edit",
|
||
"sourceImageSrc",
|
||
"待修改图片",
|
||
)?;
|
||
ensure_editor_reference_image_sources_are_stable(
|
||
payload.reference_image_srcs.as_deref(),
|
||
"editor-image-edit",
|
||
"referenceImageSrcs",
|
||
"修改参考图",
|
||
)?;
|
||
ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?;
|
||
let prompt = payload.prompt.trim().to_string();
|
||
if prompt.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-image-edit",
|
||
"message": "修改提示词不能为空",
|
||
})),
|
||
);
|
||
}
|
||
let generation_options = normalize_editor_image_edit_generation_options(
|
||
payload.model.as_deref(),
|
||
payload.aspect_ratio.as_deref(),
|
||
payload.image_size.as_deref(),
|
||
payload.size.as_deref(),
|
||
);
|
||
let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some();
|
||
let requested_image_size = normalize_editor_image_generation_size(payload.size.as_deref());
|
||
let delivery_size = if has_dimension_options {
|
||
Cow::Owned(generation_options.size.clone())
|
||
} else {
|
||
requested_image_size.clone()
|
||
};
|
||
let provider_requested_image_size = if has_dimension_options {
|
||
Cow::Owned(generation_options.provider_size.clone())
|
||
} else {
|
||
requested_image_size.clone()
|
||
};
|
||
let mut reference_images =
|
||
Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len));
|
||
reference_images.push(
|
||
parse_editor_reference_image(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
payload.source_image_src.as_str(),
|
||
)
|
||
.await?,
|
||
);
|
||
for source in
|
||
normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref(), 8)
|
||
{
|
||
reference_images.push(
|
||
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source).await?,
|
||
);
|
||
}
|
||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||
request_context,
|
||
caller.audit_subject_user_id.clone(),
|
||
caller
|
||
.audit_project_id
|
||
.clone()
|
||
.or_else(|| payload.project_id.clone()),
|
||
);
|
||
let http_client = build_openai_image_http_client(&settings)?;
|
||
let edit_dimensions =
|
||
if editor_image_edit_uses_nanobanana_generate_content(generation_options.model) {
|
||
None
|
||
} else {
|
||
Some(prepare_editor_image_edit_references(
|
||
&mut reference_images,
|
||
provider_requested_image_size.as_ref(),
|
||
)?)
|
||
};
|
||
let provider_size = edit_dimensions
|
||
.map(|dimensions| {
|
||
format!(
|
||
"{}x{}",
|
||
dimensions.provider_width, dimensions.provider_height
|
||
)
|
||
})
|
||
.unwrap_or_else(|| generation_options.provider_image_size.to_string());
|
||
let expected_price_mud_points = resolve_editor_image_edit_price(
|
||
state,
|
||
generation_options.model,
|
||
requested_image_size.as_ref(),
|
||
Some(generation_options.image_size),
|
||
)
|
||
.await?;
|
||
let generated = if editor_image_edit_uses_nanobanana_generate_content(generation_options.model)
|
||
{
|
||
execute_billable_asset_operation_with_cost(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
"editor_image_edit",
|
||
request_context.request_id(),
|
||
u64::from(expected_price_mud_points),
|
||
create_openai_nanobanana_generate_content(
|
||
&http_client,
|
||
&settings,
|
||
generation_options.model,
|
||
prompt.as_str(),
|
||
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
|
||
generation_options.aspect_ratio,
|
||
provider_size.as_str(),
|
||
reference_images.as_slice(),
|
||
"图片画布修改图片",
|
||
),
|
||
)
|
||
.await?
|
||
} else {
|
||
execute_billable_asset_operation_with_cost(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
"editor_image_edit",
|
||
request_context.request_id(),
|
||
u64::from(expected_price_mud_points),
|
||
create_openai_image_edit_with_references_and_model(
|
||
&http_client,
|
||
&settings,
|
||
generation_options.model,
|
||
prompt.as_str(),
|
||
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
|
||
provider_size.as_str(),
|
||
1,
|
||
reference_images.as_slice(),
|
||
"图片画布修改图片",
|
||
),
|
||
)
|
||
.await?
|
||
};
|
||
let provider_image = generated.images.into_iter().next().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "VectorEngine 未返回图片",
|
||
}))
|
||
})?;
|
||
let (provider_width, provider_height) =
|
||
image::load_from_memory(provider_image.bytes.as_slice())
|
||
.map(|image| (image.width(), image.height()))
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": format!("图片改造结果不是有效图片:{error}"),
|
||
}))
|
||
})?;
|
||
let asset_label =
|
||
resolve_editor_generated_asset_label(payload.asset_label.clone(), "图片修改结果");
|
||
let (image, dimension_restore_error) = if let Some(edit_dimensions) =
|
||
edit_dimensions.as_ref().filter(|_| !has_dimension_options)
|
||
{
|
||
restore_editor_image_edit_output_dimensions_or_original(provider_image, edit_dimensions)
|
||
} else {
|
||
restore_editor_generated_image_output_dimensions_or_original(
|
||
provider_image,
|
||
generation_options.model,
|
||
delivery_size.as_ref(),
|
||
)
|
||
};
|
||
let dimension_warning = dimension_restore_error
|
||
.as_ref()
|
||
.map(editor_dimension_restore_warning);
|
||
if let Some(error) = dimension_restore_error {
|
||
tracing::warn!(
|
||
task_id = %generated.task_id,
|
||
provider_width,
|
||
provider_height,
|
||
target_width = edit_dimensions.map(|dimensions| dimensions.target_width),
|
||
target_height = edit_dimensions.map(|dimensions| dimensions.target_height),
|
||
target_size = %delivery_size,
|
||
error = ?error,
|
||
"画板快速编辑尺寸恢复失败,保留并上传 provider 原图"
|
||
);
|
||
}
|
||
let (width, height) = image::load_from_memory(image.bytes.as_slice())
|
||
.map(|image| (image.width(), image.height()))
|
||
.unwrap_or((1024, 1024));
|
||
let owner_user_id = caller.owner_user_id.clone();
|
||
let persisted = persist_editor_generated_image(
|
||
state,
|
||
owner_user_id.as_str(),
|
||
generated.task_id.as_str(),
|
||
&image,
|
||
prompt.as_str(),
|
||
generated.actual_prompt.as_deref(),
|
||
EDITOR_IMAGE_EDIT_ASSET_KIND,
|
||
"edit-images",
|
||
"image",
|
||
"edit",
|
||
"vector-engine",
|
||
)
|
||
.await?;
|
||
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
|
||
let generated_asset = persist_editor_generated_asset(
|
||
state,
|
||
PersistEditorGeneratedAssetInput {
|
||
project_id: payload.project_id.clone(),
|
||
owner_user_id: owner_user_id.clone(),
|
||
folder_id: payload.asset_folder_id,
|
||
label: asset_label.clone(),
|
||
image_src: image_src.clone(),
|
||
object_key: Some(persisted.object_key.clone()),
|
||
asset_object_id: Some(persisted.asset_object_id.clone()),
|
||
width,
|
||
height,
|
||
prompt: prompt.clone(),
|
||
actual_prompt: generated.actual_prompt.clone(),
|
||
model: generation_options.model.to_string(),
|
||
provider: "VectorEngine".to_string(),
|
||
task_id: generated.task_id.clone(),
|
||
group_task_id: None,
|
||
group_task_expected_asset_count: None,
|
||
source_resource_id: payload.source_resource_id,
|
||
asset_kind: payload.asset_kind,
|
||
generation_inputs: payload.generation_inputs,
|
||
thumbnail_src: None,
|
||
generation_cost_mud_points: u64::from(expected_price_mud_points),
|
||
},
|
||
)
|
||
.await?;
|
||
let completed_project = if payload.canvas_completion.is_some() {
|
||
complete_editor_canvas_generation(
|
||
state,
|
||
owner_user_id.as_str(),
|
||
payload.project_id.as_deref(),
|
||
payload.canvas_completion.as_ref(),
|
||
generated_asset.resource.as_ref(),
|
||
)
|
||
.await?
|
||
} else {
|
||
complete_editor_canvas_background_removal(
|
||
state,
|
||
owner_user_id.as_str(),
|
||
payload.project_id.as_deref(),
|
||
payload.target_layer_id.as_deref(),
|
||
generated_asset.resource.as_ref(),
|
||
Some(asset_label.as_str()),
|
||
true,
|
||
)
|
||
.await?
|
||
};
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorImageGenerationResponse {
|
||
image_src,
|
||
object_key: Some(persisted.object_key),
|
||
asset_object_id: Some(persisted.asset_object_id),
|
||
width,
|
||
height,
|
||
source_type: "generated",
|
||
prompt,
|
||
actual_prompt: generated.actual_prompt,
|
||
model: generation_options.model.to_string(),
|
||
provider: "VectorEngine",
|
||
task_id: generated.task_id,
|
||
resource: generated_asset.resource,
|
||
asset: generated_asset.asset,
|
||
project: completed_project,
|
||
warning: dimension_warning,
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn remove_editor_image_background(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorBackgroundRemovalRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
|
||
ensure_editor_reference_image_source_is_stable(
|
||
payload.source_image_src.as_str(),
|
||
"editor-background-removal",
|
||
"sourceImageSrc",
|
||
"待去除背景图片",
|
||
)?;
|
||
let source_entity_id = editor_generation_source_entity_id(
|
||
payload.project_id.as_deref(),
|
||
"editor-background-removal",
|
||
);
|
||
let queue_job = enqueue_editor_generation_job(
|
||
&state,
|
||
&request_context,
|
||
caller.owner_user_id.as_str(),
|
||
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
|
||
source_entity_id,
|
||
"图片画布去除背景",
|
||
0,
|
||
&payload,
|
||
)
|
||
.await?;
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorGenerationQueuedResponse {
|
||
queue_state: editor_generation_queue_state(queue_job),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub(crate) async fn remove_editor_image_background_for_owner(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
caller: EditorGenerationCaller,
|
||
payload: EditorBackgroundRemovalRequest,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let started_at = Instant::now();
|
||
caller.report_processing_phase(state).await?;
|
||
let source_object_key = resolve_editor_reference_object_key_for_owner(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
payload.source_image_src.as_str(),
|
||
)
|
||
.await?;
|
||
validate_editor_background_removal_source(state, source_object_key.as_str()).await?;
|
||
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
|
||
user_id: caller.audit_subject_user_id.clone(),
|
||
profile_id: caller
|
||
.audit_project_id
|
||
.clone()
|
||
.or_else(|| payload.project_id.clone()),
|
||
request_id: Some(request_context.request_id().to_string()),
|
||
external_call_deadline: request_context.external_call_deadline(),
|
||
};
|
||
let removed = request_editor_background_removal_image_with_bgfilter_worker(
|
||
state,
|
||
source_object_key.as_str(),
|
||
&matting_audit,
|
||
)
|
||
.await?;
|
||
let task_id = normalize_optional_string(payload.task_id.clone())
|
||
.unwrap_or_else(|| format!("background-removal-{}", current_utc_micros()));
|
||
let persisted = persist_editor_generated_image(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
task_id.as_str(),
|
||
&removed.image,
|
||
"remove background",
|
||
None,
|
||
"background-removal",
|
||
"background-removal",
|
||
"removed-background",
|
||
"result",
|
||
"bgfilter-complex",
|
||
)
|
||
.await?;
|
||
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
|
||
let asset_label =
|
||
resolve_editor_generated_asset_label(payload.asset_label.clone(), "去背景结果");
|
||
let generated_asset = persist_editor_generated_asset(
|
||
state,
|
||
PersistEditorGeneratedAssetInput {
|
||
project_id: payload.project_id.clone(),
|
||
owner_user_id: caller.owner_user_id.clone(),
|
||
folder_id: payload.asset_folder_id,
|
||
label: asset_label.clone(),
|
||
image_src: image_src.clone(),
|
||
object_key: Some(persisted.object_key.clone()),
|
||
asset_object_id: Some(persisted.asset_object_id.clone()),
|
||
width: removed.width,
|
||
height: removed.height,
|
||
prompt: "remove background".to_string(),
|
||
actual_prompt: None,
|
||
model: "BgFilter complex".to_string(),
|
||
provider: "BgFilter".to_string(),
|
||
task_id: task_id.clone(),
|
||
group_task_id: None,
|
||
group_task_expected_asset_count: None,
|
||
source_resource_id: payload.source_resource_id,
|
||
asset_kind: payload.asset_kind,
|
||
generation_inputs: payload.generation_inputs,
|
||
thumbnail_src: None,
|
||
generation_cost_mud_points: 0,
|
||
},
|
||
)
|
||
.await?;
|
||
let completed_project = if payload.canvas_completion.is_some() {
|
||
complete_editor_canvas_generation(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
payload.project_id.as_deref(),
|
||
payload.canvas_completion.as_ref(),
|
||
generated_asset.resource.as_ref(),
|
||
)
|
||
.await?
|
||
} else {
|
||
complete_editor_canvas_background_removal(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
payload.project_id.as_deref(),
|
||
payload.target_layer_id.as_deref(),
|
||
generated_asset.resource.as_ref(),
|
||
Some(asset_label.as_str()),
|
||
false,
|
||
)
|
||
.await?
|
||
};
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorBackgroundRemovalResponse {
|
||
image_src,
|
||
object_key: persisted.object_key,
|
||
asset_object_id: persisted.asset_object_id,
|
||
width: removed.width,
|
||
height: removed.height,
|
||
source_type: "generated",
|
||
task_id,
|
||
elapsed_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||
provider: "BgFilter",
|
||
resource: generated_asset.resource,
|
||
asset: generated_asset.asset,
|
||
project: completed_project,
|
||
},
|
||
))
|
||
}
|
||
|
||
struct EditorBackgroundRemovalImage {
|
||
image: DownloadedOpenAiImage,
|
||
width: u32,
|
||
height: u32,
|
||
}
|
||
|
||
async fn validate_editor_background_removal_source(
|
||
state: &AppState,
|
||
source_object_key: &str,
|
||
) -> Result<(), AppError> {
|
||
let oss_client = state.oss_client().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"reason": "OSS 未完成环境变量配置",
|
||
}))
|
||
})?;
|
||
let http_client = reqwest::Client::new();
|
||
let head = oss_client
|
||
.head_object(
|
||
&http_client,
|
||
OssHeadObjectRequest {
|
||
object_key: source_object_key.to_string(),
|
||
},
|
||
)
|
||
.await
|
||
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
|
||
if head.content_length > EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES {
|
||
return Err(editor_reference_image_too_large());
|
||
}
|
||
|
||
let content_type =
|
||
normalize_editor_background_removal_source_mime_type(head.content_type.as_deref())
|
||
.ok_or_else(|| {
|
||
editor_background_removal_source_unsupported(
|
||
"图片参考图的 Content-Type 不是支持的图片类型。",
|
||
)
|
||
})?;
|
||
let signed_url = sign_editor_private_object_read_url(
|
||
state,
|
||
source_object_key,
|
||
EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS,
|
||
)?;
|
||
let probe_end = EDITOR_BACKGROUND_REMOVAL_SOURCE_PROBE_BYTES.saturating_sub(1);
|
||
let response = http_client
|
||
.get(signed_url)
|
||
.header(reqwest::header::RANGE, format!("bytes=0-{probe_end}"))
|
||
.send()
|
||
.await
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": format!("读取图片参考图文件头失败:{error}"),
|
||
}))
|
||
})?;
|
||
if response.status() != StatusCode::PARTIAL_CONTENT {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": "OSS 未按 Range 返回图片文件头。",
|
||
"status": response.status().as_u16(),
|
||
})),
|
||
);
|
||
}
|
||
if response
|
||
.content_length()
|
||
.is_none_or(|size| size > EDITOR_BACKGROUND_REMOVAL_SOURCE_PROBE_BYTES)
|
||
{
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": "OSS 返回的图片文件头大小异常。",
|
||
})),
|
||
);
|
||
}
|
||
let probe = response.bytes().await.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": format!("读取图片参考图文件头失败:{error}"),
|
||
}))
|
||
})?;
|
||
validate_editor_background_removal_source_content(content_type, probe.as_ref())
|
||
}
|
||
|
||
fn validate_editor_background_removal_source_content(
|
||
content_type: &str,
|
||
bytes: &[u8],
|
||
) -> Result<(), AppError> {
|
||
let detected_type = infer_editor_reference_image_mime_type(bytes).ok_or_else(|| {
|
||
editor_background_removal_source_unsupported("图片参考图文件魔数不是支持的图片类型。")
|
||
})?;
|
||
if detected_type != content_type {
|
||
return Err(editor_background_removal_source_unsupported(
|
||
"图片参考图的 Content-Type 与文件魔数不一致。",
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn normalize_editor_background_removal_source_mime_type(
|
||
content_type: Option<&str>,
|
||
) -> Option<&'static str> {
|
||
let mime_type = content_type?.split(';').next()?.trim();
|
||
if mime_type.eq_ignore_ascii_case("image/png") {
|
||
Some("image/png")
|
||
} else if mime_type.eq_ignore_ascii_case("image/jpeg") {
|
||
Some("image/jpeg")
|
||
} else if mime_type.eq_ignore_ascii_case("image/webp") {
|
||
Some("image/webp")
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn editor_background_removal_source_unsupported(message: &str) -> AppError {
|
||
AppError::from_status(StatusCode::UNSUPPORTED_MEDIA_TYPE).with_details(json!({
|
||
"provider": "editor-reference-image",
|
||
"field": "sourceImageSrc",
|
||
"message": message,
|
||
}))
|
||
}
|
||
|
||
fn editor_bgfilter_flat_deadline_reserve(aliyun_timeout_ms: u64) -> Duration {
|
||
Duration::from_millis(aliyun_timeout_ms)
|
||
.saturating_add(EDITOR_BGFILTER_FLAT_LOCAL_RESERVE)
|
||
.saturating_add(EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW)
|
||
}
|
||
|
||
async fn request_editor_background_removal_image_with_bgfilter_worker(
|
||
state: &AppState,
|
||
source_object_key: &str,
|
||
audit: &crate::external_api_audit::ExternalApiAuditContext,
|
||
) -> Result<EditorBackgroundRemovalImage, AppError> {
|
||
// complex 没有 flat fallback,排队上限只预留 2s 传输窗;worker 重启窗口内的
|
||
// 连接失败由 client 内部按预算有界重试,避免 max_attempts=1 的队列任务终态失败。
|
||
let removed = crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry(
|
||
state,
|
||
source_object_key,
|
||
crate::bgfilter_worker::BgfilterBackgroundMode::Complex,
|
||
None,
|
||
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
|
||
EDITOR_BGFILTER_CROSS_CHECK_DISABLED,
|
||
EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW,
|
||
audit,
|
||
)
|
||
.await
|
||
.map_err(crate::bgfilter_worker::BgfilterClientError::into_app_error)?;
|
||
Ok(EditorBackgroundRemovalImage {
|
||
width: removed.width,
|
||
height: removed.height,
|
||
image: DownloadedOpenAiImage {
|
||
bytes: removed.bytes,
|
||
extension: removed.extension,
|
||
mime_type: removed.mime_type,
|
||
},
|
||
})
|
||
}
|
||
|
||
pub(crate) struct EditorScreenBackgroundRemovalOutput {
|
||
pub(crate) image: DownloadedOpenAiImage,
|
||
pub(crate) provider: &'static str,
|
||
pub(crate) model: String,
|
||
}
|
||
|
||
pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(
|
||
state: &AppState,
|
||
source_object_key: &str,
|
||
screen_color: EditorScreenBackgroundColor,
|
||
seg_model: &str,
|
||
cross_check: bool,
|
||
audit: &crate::external_api_audit::ExternalApiAuditContext,
|
||
) -> Result<EditorScreenBackgroundRemovalOutput, AppError> {
|
||
// flat 的排队上限从父剩余预算里预扣「阿里云 + 本地 + 传输」的 fallback 时间,
|
||
// 保证排队吃不完 fallback 的执行窗口;上限为 0 时 client 直接返回
|
||
// deadline_exceeded,本函数随即进入 fallback 链。worker 重启窗口内的连接
|
||
// 失败由 client 先做小额重试(≤2 次),仍失败才降级,避免不必要的质量损失。
|
||
match crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry(
|
||
state,
|
||
source_object_key,
|
||
crate::bgfilter_worker::BgfilterBackgroundMode::Flat,
|
||
Some(screen_color.hex),
|
||
seg_model,
|
||
cross_check,
|
||
editor_bgfilter_flat_deadline_reserve(state.config.aliyun_matting_request_timeout_ms),
|
||
audit,
|
||
)
|
||
.await
|
||
{
|
||
Ok(image) => {
|
||
return Ok(EditorScreenBackgroundRemovalOutput {
|
||
image: DownloadedOpenAiImage {
|
||
bytes: image.bytes,
|
||
extension: image.extension,
|
||
mime_type: image.mime_type,
|
||
},
|
||
provider: "BgFilter",
|
||
model: seg_model.to_string(),
|
||
});
|
||
}
|
||
Err(error) if !error.allows_flat_fallback() => {
|
||
return Err(error.into_app_error());
|
||
}
|
||
Err(error)
|
||
if audit
|
||
.external_call_deadline
|
||
.is_some_and(|deadline| Instant::now() >= deadline) =>
|
||
{
|
||
return Err(error.into_app_error());
|
||
}
|
||
Err(error) => {
|
||
tracing::warn!(
|
||
provider = "bgfilter-worker",
|
||
worker_code = error.code(),
|
||
screen_color = screen_color.hex,
|
||
seg_model,
|
||
cross_check,
|
||
"editor_bgfilter_worker_fallback_to_aliyun_matting"
|
||
);
|
||
}
|
||
}
|
||
|
||
fallback_editor_screen_background_removal(state, source_object_key, screen_color, audit).await
|
||
}
|
||
|
||
/// BgFilter 内部 worker 对 flat 调用返回可降级错误时的统一兜底链:
|
||
/// 先阿里云通用抠图,失败再退本地键色扣除。
|
||
async fn fallback_editor_screen_background_removal(
|
||
state: &AppState,
|
||
source_object_key: &str,
|
||
screen_color: EditorScreenBackgroundColor,
|
||
audit: &crate::external_api_audit::ExternalApiAuditContext,
|
||
) -> Result<EditorScreenBackgroundRemovalOutput, AppError> {
|
||
let aliyun_result = match sign_editor_private_object_read_url(
|
||
state,
|
||
source_object_key,
|
||
EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS,
|
||
) {
|
||
Ok(source_url) => {
|
||
crate::aliyun_matting::segment_image_url_with_aliyun_matting(
|
||
state,
|
||
source_url.as_str(),
|
||
"editor-screen-background",
|
||
)
|
||
.await
|
||
}
|
||
Err(error) => Err(error),
|
||
};
|
||
match aliyun_result {
|
||
Ok(image) => Ok(EditorScreenBackgroundRemovalOutput {
|
||
image,
|
||
provider: "Aliyun Matting",
|
||
model: "segment-common-image".to_string(),
|
||
}),
|
||
Err(error) => {
|
||
tracing::warn!(
|
||
provider = "aliyun-matting",
|
||
screen_color = screen_color.hex,
|
||
error = %error,
|
||
error_details = ?error.details(),
|
||
"editor_aliyun_matting_fallback_to_local_screen_background_removal"
|
||
);
|
||
if crate::external_api_audit::matting_failure_external_call_attempted(&error) {
|
||
crate::external_api_audit::record_matting_external_api_failure(
|
||
state,
|
||
audit,
|
||
"aliyun-matting",
|
||
state.config.aliyun_matting_endpoint.clone(),
|
||
"editor-screen-background-removal",
|
||
crate::external_api_audit::matting_failure_audit_failure_stage(
|
||
&error,
|
||
"aliyun_segment",
|
||
),
|
||
crate::external_api_audit::matting_failure_audit_status_code(&error),
|
||
crate::external_api_audit::matting_failure_audit_timeout(&error),
|
||
crate::external_api_audit::matting_failure_audit_is_transport(&error),
|
||
crate::external_api_audit::matting_failure_audit_latency_ms(&error),
|
||
error.message().to_string(),
|
||
crate::external_api_audit::matting_failure_audit_raw_excerpt(&error),
|
||
)
|
||
.await;
|
||
}
|
||
let source_image =
|
||
download_editor_persisted_image_object(state, source_object_key).await?;
|
||
let output =
|
||
remove_editor_generated_green_screen_background(&source_image, screen_color);
|
||
drop(source_image);
|
||
output.map(|image| EditorScreenBackgroundRemovalOutput {
|
||
image,
|
||
provider: "Genarrative Local",
|
||
model: "screen-color-keying".to_string(),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
fn sign_editor_private_object_read_url(
|
||
state: &AppState,
|
||
object_key: &str,
|
||
expire_seconds: u64,
|
||
) -> Result<String, AppError> {
|
||
let oss_client = state.oss_client().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"reason": "OSS 未完成环境变量配置",
|
||
}))
|
||
})?;
|
||
oss_client
|
||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||
object_key: object_key.to_string(),
|
||
expire_seconds: Some(expire_seconds),
|
||
})
|
||
.map(|signed| signed.signed_url)
|
||
.map_err(|error| map_oss_error(error, "aliyun-oss"))
|
||
}
|
||
|
||
fn parse_editor_bgfilter_seg_model(value: Option<&str>) -> Result<&'static str, AppError> {
|
||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||
return Ok(EDITOR_BGFILTER_DEFAULT_SEG_MODEL);
|
||
};
|
||
match value.to_ascii_lowercase().as_str() {
|
||
EDITOR_BGFILTER_DEFAULT_SEG_MODEL => Ok(EDITOR_BGFILTER_DEFAULT_SEG_MODEL),
|
||
EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG => Ok(EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG),
|
||
_ => Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "bgfilter",
|
||
"message": "segModel 必须是 birefnet 或 anime-seg。",
|
||
"allowed": [
|
||
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
|
||
EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG,
|
||
],
|
||
})),
|
||
),
|
||
}
|
||
}
|
||
|
||
struct PersistEditorSpritesheetSlicesInput {
|
||
owner_user_id: String,
|
||
project_id: Option<String>,
|
||
asset_folder_id: Option<String>,
|
||
source_resource_id: Option<String>,
|
||
task_id: String,
|
||
group_task_id: Option<String>,
|
||
prompt: String,
|
||
actual_prompt: Option<String>,
|
||
model: String,
|
||
provider: String,
|
||
generation_inputs: Option<Value>,
|
||
path_kind: &'static str,
|
||
asset_kind: &'static str,
|
||
persistence_provider: &'static str,
|
||
}
|
||
|
||
async fn persist_editor_spritesheet_slices(
|
||
state: &AppState,
|
||
slices: Vec<GeneratedAssetSheetConnectedIcon>,
|
||
input: PersistEditorSpritesheetSlicesInput,
|
||
) -> Result<Vec<EditorIconSpritesheetIconResponse>, AppError> {
|
||
if slices.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
|
||
"provider": "editor-icon-spritesheet-slicing",
|
||
"message": "图集中未识别到可拆分的独立素材。",
|
||
})),
|
||
);
|
||
}
|
||
|
||
let expected_asset_count = u32::try_from(slices.len()).map_err(|_| {
|
||
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
|
||
"provider": "editor-icon-spritesheet-slicing",
|
||
"message": "图集拆分产物数量超过支持范围。",
|
||
}))
|
||
})?;
|
||
let mut results = Vec::with_capacity(slices.len());
|
||
for (index, slice) in slices.into_iter().enumerate() {
|
||
let image = DownloadedOpenAiImage {
|
||
bytes: slice.bytes,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
let file_stem = format!("asset-{:03}", index + 1);
|
||
let persisted = persist_editor_generated_image(
|
||
state,
|
||
input.owner_user_id.as_str(),
|
||
input.task_id.as_str(),
|
||
&image,
|
||
input.prompt.as_str(),
|
||
input.actual_prompt.as_deref(),
|
||
input.asset_kind,
|
||
input.path_kind,
|
||
file_stem.as_str(),
|
||
"asset",
|
||
input.persistence_provider,
|
||
)
|
||
.await?;
|
||
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
|
||
let record = persist_editor_generated_asset(
|
||
state,
|
||
PersistEditorGeneratedAssetInput {
|
||
project_id: input.project_id.clone(),
|
||
owner_user_id: input.owner_user_id.clone(),
|
||
folder_id: input.asset_folder_id.clone(),
|
||
label: slice.name.clone(),
|
||
image_src: image_src.clone(),
|
||
object_key: Some(persisted.object_key),
|
||
asset_object_id: Some(persisted.asset_object_id),
|
||
width: slice.width,
|
||
height: slice.height,
|
||
prompt: input.prompt.clone(),
|
||
actual_prompt: input.actual_prompt.clone(),
|
||
model: input.model.clone(),
|
||
provider: input.provider.clone(),
|
||
task_id: input.task_id.clone(),
|
||
group_task_id: input.group_task_id.clone(),
|
||
group_task_expected_asset_count: input
|
||
.group_task_id
|
||
.as_ref()
|
||
.map(|_| expected_asset_count),
|
||
source_resource_id: input.source_resource_id.clone(),
|
||
asset_kind: Some("icon".to_string()),
|
||
generation_inputs: input.generation_inputs.clone(),
|
||
thumbnail_src: None,
|
||
generation_cost_mud_points: 0,
|
||
},
|
||
)
|
||
.await?;
|
||
results.push(EditorIconSpritesheetIconResponse {
|
||
name: slice.name,
|
||
image_src,
|
||
width: slice.width,
|
||
height: slice.height,
|
||
resource: record.resource,
|
||
asset: record.asset,
|
||
});
|
||
}
|
||
if let Some(group_task_id) = input.group_task_id.as_deref() {
|
||
let asset_ids = results
|
||
.iter()
|
||
.filter_map(|result| result.asset.as_ref().map(|asset| asset.asset_id.clone()))
|
||
.collect::<Vec<_>>();
|
||
state
|
||
.spacetime_client()
|
||
.complete_editor_asset_group_cohort(EditorAssetGroupCohortCompleteRecordInput {
|
||
owner_user_id: input.owner_user_id,
|
||
task_id: input.task_id,
|
||
group_task_id: group_task_id.to_string(),
|
||
expected_asset_count,
|
||
asset_ids,
|
||
completed_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
}
|
||
Ok(results)
|
||
}
|
||
|
||
pub async fn generate_editor_icon_spritesheet(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorIconSpritesheetGenerationRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
|
||
if !state.config.external_generation_mode.is_inline() {
|
||
ensure_editor_reference_image_source_is_stable(
|
||
payload.reference_image_src.as_str(),
|
||
"editor-icon-spritesheet",
|
||
"referenceImageSrc",
|
||
"图标素材规范",
|
||
)?;
|
||
ensure_editor_reference_image_sources_are_stable(
|
||
payload.reference_image_srcs.as_deref(),
|
||
"editor-icon-spritesheet",
|
||
"referenceImageSrcs",
|
||
"图标素材参考图",
|
||
)?;
|
||
let generation_options = normalize_editor_generation_options(
|
||
payload.model.as_deref(),
|
||
payload.aspect_ratio.as_deref(),
|
||
payload.image_size.as_deref(),
|
||
);
|
||
let price_mud_points = u64::from(
|
||
resolve_editor_icon_spritesheet_price(
|
||
&state,
|
||
Some(generation_options.model),
|
||
Some(generation_options.image_size),
|
||
)
|
||
.await?,
|
||
);
|
||
let source_entity_id = editor_generation_source_entity_id(
|
||
payload.project_id.as_deref(),
|
||
"editor-icon-spritesheet-generation",
|
||
);
|
||
let queue_job = enqueue_editor_generation_job(
|
||
&state,
|
||
&request_context,
|
||
caller.owner_user_id.as_str(),
|
||
EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND,
|
||
source_entity_id,
|
||
"图片画布生成图标素材",
|
||
price_mud_points,
|
||
&payload,
|
||
)
|
||
.await?;
|
||
return Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorGenerationQueuedResponse {
|
||
queue_state: editor_generation_queue_state(queue_job),
|
||
},
|
||
));
|
||
}
|
||
generate_editor_icon_spritesheet_for_owner(&state, &request_context, caller, payload).await
|
||
}
|
||
|
||
pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
caller: EditorGenerationCaller,
|
||
payload: EditorIconSpritesheetGenerationRequest,
|
||
) -> Result<Json<Value>, AppError> {
|
||
ensure_editor_reference_image_source_is_stable(
|
||
payload.reference_image_src.as_str(),
|
||
"editor-icon-spritesheet",
|
||
"referenceImageSrc",
|
||
"图标素材规范",
|
||
)?;
|
||
ensure_editor_reference_image_sources_are_stable(
|
||
payload.reference_image_srcs.as_deref(),
|
||
"editor-icon-spritesheet",
|
||
"referenceImageSrcs",
|
||
"图标素材参考图",
|
||
)?;
|
||
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
|
||
// 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时
|
||
// 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。
|
||
let requested_screen_color = payload.screen_color.clone();
|
||
let reference_count = 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len);
|
||
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
|
||
let mut reference_images =
|
||
Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len));
|
||
reference_images.push(
|
||
parse_editor_reference_image(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
payload.reference_image_src.as_str(),
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
error.with_details(json!({
|
||
"provider": "editor-icon-spritesheet",
|
||
"field": "referenceImageSrc",
|
||
"message": "图标素材规范必须是 objectKey、资源或素材引用。",
|
||
}))
|
||
})?,
|
||
);
|
||
for source in
|
||
normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref(), 8)
|
||
{
|
||
reference_images.push(
|
||
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source)
|
||
.await
|
||
.map_err(|error| {
|
||
error.with_details(json!({
|
||
"provider": "editor-icon-spritesheet",
|
||
"field": "referenceImageSrcs",
|
||
"message": "图标素材参考图必须是 objectKey、资源或素材引用。",
|
||
}))
|
||
})?,
|
||
);
|
||
}
|
||
let generation_options = normalize_editor_generation_options(
|
||
payload.model.as_deref(),
|
||
payload.aspect_ratio.as_deref(),
|
||
payload.image_size.as_deref(),
|
||
);
|
||
let expected_price_mud_points = resolve_editor_icon_spritesheet_price(
|
||
state,
|
||
Some(generation_options.model),
|
||
Some(generation_options.image_size),
|
||
)
|
||
.await?;
|
||
let size = generation_options.provider_size.as_str();
|
||
|
||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||
request_context,
|
||
caller.audit_subject_user_id.clone(),
|
||
caller
|
||
.audit_project_id
|
||
.clone()
|
||
.or_else(|| payload.project_id.clone()),
|
||
);
|
||
let http_client = build_openai_image_http_client(&settings)?;
|
||
let (generated, prompt, screen_background_decision) =
|
||
execute_billable_asset_operation_with_cost(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
"editor_icon_spritesheet",
|
||
request_context.request_id(),
|
||
u64::from(expected_price_mud_points),
|
||
async {
|
||
// 背景色决策:预扣泥点之后才发起(决策失败→future 返回 Err→失败退款)。
|
||
let screen_background_decision = resolve_editor_screen_background_color(
|
||
state.llm_client(),
|
||
state.editor_agent_llm_client(),
|
||
EditorScreenBackgroundDecisionInput {
|
||
kind: EditorScreenBackgroundDecisionKind::IconSpritesheet,
|
||
screen_color: requested_screen_color.clone(),
|
||
prompt: icon_descriptions.join("\n"),
|
||
icon_descriptions: icon_descriptions.clone(),
|
||
reference_count,
|
||
source_image_data_url: None,
|
||
},
|
||
Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
|
||
state,
|
||
context: crate::external_api_audit::ExternalApiAuditContext {
|
||
user_id: caller.audit_subject_user_id.clone(),
|
||
profile_id: caller
|
||
.audit_project_id
|
||
.clone()
|
||
.or_else(|| payload.project_id.clone()),
|
||
request_id: Some(request_context.request_id().to_string()),
|
||
external_call_deadline: request_context.external_call_deadline(),
|
||
},
|
||
}),
|
||
)
|
||
.await?;
|
||
let screen_color = screen_background_decision.color;
|
||
let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions, screen_color);
|
||
let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
|
||
create_openai_nanobanana_generate_content(
|
||
&http_client,
|
||
&settings,
|
||
generation_options.model,
|
||
prompt.as_str(),
|
||
None,
|
||
generation_options.aspect_ratio,
|
||
generation_options.provider_image_size,
|
||
reference_images.as_slice(),
|
||
"图片画布生成图标素材 spritesheet",
|
||
)
|
||
.await?
|
||
} else {
|
||
create_openai_image_edit_with_references_and_model(
|
||
&http_client,
|
||
&settings,
|
||
generation_options.model,
|
||
prompt.as_str(),
|
||
None,
|
||
size,
|
||
1,
|
||
reference_images.as_slice(),
|
||
"图片画布生成图标素材 spritesheet",
|
||
)
|
||
.await?
|
||
};
|
||
Ok::<_, AppError>((generated, prompt, screen_background_decision))
|
||
},
|
||
)
|
||
.await?;
|
||
let screen_color = screen_background_decision.color;
|
||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "VectorEngine 未返回图标 spritesheet",
|
||
}))
|
||
})?;
|
||
let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice())
|
||
.map(|image| (image.width(), image.height()))
|
||
.unwrap_or((512, 512));
|
||
let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs(
|
||
payload.generation_inputs.clone(),
|
||
Some(&screen_background_decision),
|
||
);
|
||
let spritesheet_label =
|
||
resolve_editor_generated_asset_label(payload.asset_label.clone(), "图标素材图集");
|
||
let asset_folder_id = payload
|
||
.asset_folder_id
|
||
.clone()
|
||
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string()));
|
||
let source_persisted = persist_editor_provider_source_image(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
generated.task_id.as_str(),
|
||
image,
|
||
prompt.as_str(),
|
||
generated.actual_prompt.as_deref(),
|
||
EDITOR_ICON_SPRITESHEET_ASSET_KIND,
|
||
"icon-spritesheets",
|
||
"spritesheet",
|
||
)
|
||
.await?;
|
||
let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str());
|
||
let source_object_key = source_persisted.object_key.clone();
|
||
let source_record = persist_editor_provider_source_resource(
|
||
state,
|
||
source_persisted,
|
||
PersistEditorProviderSourceResourceInput {
|
||
project_id: payload.project_id.clone(),
|
||
owner_user_id: caller.owner_user_id.clone(),
|
||
folder_id: asset_folder_id.clone(),
|
||
label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"),
|
||
width: source_width,
|
||
height: source_height,
|
||
prompt: prompt.clone(),
|
||
actual_prompt: generated.actual_prompt.clone(),
|
||
model: generation_options.model.to_string(),
|
||
task_id: generated.task_id.clone(),
|
||
source_resource_id: None,
|
||
asset_kind: Some("icon-spritesheet".to_string()),
|
||
generation_inputs: generation_inputs.clone(),
|
||
generation_cost_mud_points: u64::from(expected_price_mud_points),
|
||
},
|
||
)
|
||
.await?;
|
||
caller.report_processing_phase(state).await?;
|
||
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
|
||
user_id: caller.audit_subject_user_id.clone(),
|
||
profile_id: caller
|
||
.audit_project_id
|
||
.clone()
|
||
.or_else(|| payload.project_id.clone()),
|
||
request_id: Some(request_context.request_id().to_string()),
|
||
external_call_deadline: request_context.external_call_deadline(),
|
||
};
|
||
let removal = remove_editor_generated_screen_background_with_bgfilter(
|
||
state,
|
||
source_object_key.as_str(),
|
||
screen_color,
|
||
seg_model,
|
||
EDITOR_BGFILTER_CROSS_CHECK_DISABLED,
|
||
&matting_audit,
|
||
)
|
||
.await;
|
||
let removal = match removal {
|
||
Ok(removal) => removal,
|
||
Err(error) => {
|
||
let failure_message = error.body_text();
|
||
tracing::warn!(
|
||
provider = "editor-icon-spritesheet",
|
||
operation = "background_postprocess",
|
||
task_id = %generated.task_id,
|
||
error = %failure_message,
|
||
"图标图集原图已保存,但透明背景处理失败,使用原图完成画布"
|
||
);
|
||
let completed_project = complete_editor_canvas_generation(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
payload.project_id.as_deref(),
|
||
payload.canvas_completion.as_ref(),
|
||
source_record.resource.as_ref(),
|
||
)
|
||
.await?;
|
||
return Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorIconSpritesheetGenerationResponse {
|
||
spritesheet_image_src: source_image_src,
|
||
spritesheet_width: source_width,
|
||
spritesheet_height: source_height,
|
||
icon_image_srcs: Vec::new(),
|
||
slice_warning: None,
|
||
prompt,
|
||
actual_prompt: generated.actual_prompt,
|
||
model: generation_options.model.to_string(),
|
||
provider: "VectorEngine",
|
||
task_id: generated.task_id,
|
||
price_mud_points: expected_price_mud_points,
|
||
spritesheet_resource: source_record.resource,
|
||
spritesheet_asset: source_record.asset,
|
||
project: completed_project,
|
||
warning: Some(editor_postprocess_fallback_warning(
|
||
"生成任务成功,后处理失败。",
|
||
)),
|
||
},
|
||
));
|
||
}
|
||
};
|
||
let removal_provider = removal.provider;
|
||
let (image, postprocess_dimension_error) =
|
||
apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original(
|
||
state,
|
||
source_object_key.as_str(),
|
||
source_width,
|
||
source_height,
|
||
removal.image,
|
||
)
|
||
.await;
|
||
if let Some(error) = postprocess_dimension_error {
|
||
tracing::warn!(
|
||
task_id = %generated.task_id,
|
||
provider = removal_provider,
|
||
source_width,
|
||
source_height,
|
||
error = ?error,
|
||
"图标透明图蒙版尺寸恢复失败,保留去背景服务原始输出"
|
||
);
|
||
}
|
||
let matting_generation_inputs = build_editor_derived_asset_generation_inputs(
|
||
"图标图集抠图",
|
||
&removal.model,
|
||
&source_record,
|
||
);
|
||
let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice())
|
||
.map(|image| (image.width(), image.height()))
|
||
.unwrap_or((512, 512));
|
||
let owner_user_id = caller.owner_user_id;
|
||
let spritesheet_persisted = persist_editor_generated_image(
|
||
state,
|
||
owner_user_id.as_str(),
|
||
generated.task_id.as_str(),
|
||
&image,
|
||
"去除纯色背景",
|
||
None,
|
||
EDITOR_ICON_SPRITESHEET_ASSET_KIND,
|
||
"icon-spritesheets",
|
||
"spritesheet",
|
||
"spritesheet",
|
||
removal.provider,
|
||
)
|
||
.await?;
|
||
let spritesheet_image_src =
|
||
editor_media_src_from_object_key(spritesheet_persisted.object_key.as_str());
|
||
let spritesheet_record = persist_editor_generated_asset(
|
||
state,
|
||
PersistEditorGeneratedAssetInput {
|
||
project_id: payload.project_id.clone(),
|
||
owner_user_id: owner_user_id.clone(),
|
||
folder_id: asset_folder_id.clone(),
|
||
label: spritesheet_label,
|
||
image_src: spritesheet_image_src.clone(),
|
||
object_key: Some(spritesheet_persisted.object_key.clone()),
|
||
asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()),
|
||
width: spritesheet_width,
|
||
height: spritesheet_height,
|
||
prompt: "去除纯色背景".to_string(),
|
||
actual_prompt: None,
|
||
model: removal.model.clone(),
|
||
provider: removal.provider.to_string(),
|
||
task_id: generated.task_id.clone(),
|
||
group_task_id: None,
|
||
group_task_expected_asset_count: None,
|
||
source_resource_id: source_record
|
||
.resource
|
||
.as_ref()
|
||
.map(|resource| resource.resource_id.clone()),
|
||
asset_kind: Some("icon-spritesheet".to_string()),
|
||
generation_inputs: matting_generation_inputs,
|
||
thumbnail_src: None,
|
||
generation_cost_mud_points: 0,
|
||
},
|
||
)
|
||
.await?;
|
||
let slice_source = DownloadedImage {
|
||
bytes: image.bytes.clone(),
|
||
mime_type: image.mime_type.clone(),
|
||
extension: image.extension.clone(),
|
||
};
|
||
let (icon_image_srcs, slice_warning) =
|
||
match slice_generated_icon_spritesheet_by_connected_components(
|
||
&slice_source,
|
||
&icon_descriptions,
|
||
) {
|
||
Ok(icon_slices) => {
|
||
match persist_editor_spritesheet_slices(
|
||
state,
|
||
icon_slices,
|
||
PersistEditorSpritesheetSlicesInput {
|
||
owner_user_id: owner_user_id.clone(),
|
||
project_id: payload.project_id.clone(),
|
||
asset_folder_id: asset_folder_id.clone(),
|
||
source_resource_id: spritesheet_record
|
||
.resource
|
||
.as_ref()
|
||
.map(|resource| resource.resource_id.clone()),
|
||
task_id: generated.task_id.clone(),
|
||
group_task_id: None,
|
||
prompt: "自动拆分图集".to_string(),
|
||
actual_prompt: None,
|
||
model: "connected-components".to_string(),
|
||
provider: "Genarrative".to_string(),
|
||
generation_inputs: build_editor_derived_asset_generation_inputs(
|
||
"图集拆分",
|
||
"connected-components",
|
||
&spritesheet_record,
|
||
),
|
||
path_kind: "icon-spritesheet-assets",
|
||
asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND,
|
||
persistence_provider: "vector-engine",
|
||
},
|
||
)
|
||
.await
|
||
{
|
||
Ok(icon_image_srcs) => (icon_image_srcs, None),
|
||
Err(error) => {
|
||
let reason = error.body_text();
|
||
tracing::warn!(
|
||
provider = "editor-icon-spritesheet",
|
||
operation = "persist_automatic_slices",
|
||
task_id = %generated.task_id,
|
||
reason = %reason,
|
||
"图标图集已持久化,但自动拆分素材持久化失败"
|
||
);
|
||
(
|
||
Vec::new(),
|
||
Some(EditorIconSpritesheetSliceWarningResponse {
|
||
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE,
|
||
reason,
|
||
}),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
Err(error) => {
|
||
let reason = error.to_string();
|
||
tracing::warn!(
|
||
provider = "editor-icon-spritesheet",
|
||
operation = "detect_automatic_slices",
|
||
task_id = %generated.task_id,
|
||
reason = %reason,
|
||
"图标图集已持久化,但自动拆分未识别到全部素材"
|
||
);
|
||
(
|
||
Vec::new(),
|
||
Some(EditorIconSpritesheetSliceWarningResponse {
|
||
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
|
||
reason,
|
||
}),
|
||
)
|
||
}
|
||
};
|
||
let (canvas_items, primary_layer_id) =
|
||
if let Some(completion) = payload.canvas_completion.as_ref() {
|
||
build_icon_spritesheet_canvas_layer_items(
|
||
completion,
|
||
spritesheet_record.resource.as_ref(),
|
||
source_record.resource.as_ref(),
|
||
icon_image_srcs.as_slice(),
|
||
)
|
||
} else {
|
||
(Vec::new(), None)
|
||
};
|
||
let completed_project = complete_editor_canvas_generation_with_items(
|
||
state,
|
||
owner_user_id.as_str(),
|
||
payload.project_id.as_deref(),
|
||
payload.canvas_completion.as_ref(),
|
||
canvas_items,
|
||
primary_layer_id,
|
||
)
|
||
.await?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorIconSpritesheetGenerationResponse {
|
||
spritesheet_image_src,
|
||
spritesheet_width,
|
||
spritesheet_height,
|
||
icon_image_srcs,
|
||
slice_warning,
|
||
prompt,
|
||
actual_prompt: generated.actual_prompt,
|
||
model: generation_options.model.to_string(),
|
||
provider: "VectorEngine",
|
||
task_id: generated.task_id,
|
||
price_mud_points: expected_price_mud_points,
|
||
spritesheet_resource: spritesheet_record.resource,
|
||
spritesheet_asset: spritesheet_record.asset,
|
||
project: completed_project,
|
||
warning: None,
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn split_editor_icon_spritesheet(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorIconSpritesheetSliceRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let project_id = payload.project_id.trim().to_string();
|
||
let source_resource_id = payload.source_resource_id.trim().to_string();
|
||
if project_id.is_empty() || source_resource_id.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-icon-spritesheet-slicing",
|
||
"message": "projectId 和 sourceResourceId 不能为空。",
|
||
})),
|
||
);
|
||
}
|
||
|
||
let project = state
|
||
.spacetime_client()
|
||
.get_editor_project(EditorProjectGetRecordInput {
|
||
project_id: project_id.clone(),
|
||
owner_user_id: owner_user_id.clone(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
let source_resource = project
|
||
.resources
|
||
.iter()
|
||
.find(|resource| resource.resource_id == source_resource_id)
|
||
.cloned()
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
|
||
"provider": "editor-icon-spritesheet-slicing",
|
||
"message": "未找到要拆分的图集资源。",
|
||
}))
|
||
})?;
|
||
if source_resource.asset_kind.as_deref() != Some("icon-spritesheet") {
|
||
return Err(
|
||
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
|
||
"provider": "editor-icon-spritesheet-slicing",
|
||
"message": "只有图集素材可以使用拆分功能。",
|
||
})),
|
||
);
|
||
}
|
||
let task_id = build_prefixed_uuid_id("editor-atlas-split-");
|
||
let group_task_id = resolve_editor_manual_atlas_split_group_task_id(
|
||
&state,
|
||
owner_user_id.as_str(),
|
||
&source_resource,
|
||
)
|
||
.await?
|
||
.unwrap_or_else(|| task_id.clone());
|
||
|
||
let reference =
|
||
parse_editor_reference_image(&state, owner_user_id.as_str(), source_resource_id.as_str())
|
||
.await?;
|
||
let source = DownloadedImage {
|
||
bytes: reference.bytes,
|
||
mime_type: reference.mime_type,
|
||
extension: "png".to_string(),
|
||
};
|
||
validate_editor_icon_spritesheet_manual_source(&source)?;
|
||
let slices =
|
||
slice_generated_icon_spritesheet_all_by_connected_components(&source).map_err(|error| {
|
||
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
|
||
"provider": "editor-icon-spritesheet-slicing",
|
||
"message": error.to_string(),
|
||
}))
|
||
})?;
|
||
validate_editor_icon_spritesheet_manual_slice_count(slices.len())?;
|
||
let prompt = source_resource
|
||
.prompt
|
||
.clone()
|
||
.unwrap_or_else(|| "拆分图集素材".to_string());
|
||
let actual_prompt = source_resource.actual_prompt.clone();
|
||
let model = source_resource
|
||
.model
|
||
.clone()
|
||
.unwrap_or_else(|| "connected-components".to_string());
|
||
let provider = source_resource
|
||
.provider
|
||
.clone()
|
||
.unwrap_or_else(|| "Genarrative".to_string());
|
||
let generation_inputs = source_resource.generation_inputs.clone();
|
||
let icon_image_srcs = persist_editor_spritesheet_slices(
|
||
&state,
|
||
slices,
|
||
PersistEditorSpritesheetSlicesInput {
|
||
owner_user_id: owner_user_id.clone(),
|
||
project_id: Some(project_id.clone()),
|
||
asset_folder_id: payload
|
||
.asset_folder_id
|
||
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
|
||
source_resource_id: Some(source_resource_id),
|
||
task_id,
|
||
group_task_id: Some(group_task_id),
|
||
prompt,
|
||
actual_prompt,
|
||
model,
|
||
provider,
|
||
generation_inputs,
|
||
path_kind: "icon-spritesheet-slices",
|
||
asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND,
|
||
persistence_provider: "genarrative",
|
||
},
|
||
)
|
||
.await?;
|
||
let canvas_items = build_icon_spritesheet_slice_canvas_layer_items(
|
||
&payload.canvas_completion,
|
||
icon_image_srcs.as_slice(),
|
||
);
|
||
let project = complete_editor_canvas_generation_with_items(
|
||
&state,
|
||
owner_user_id.as_str(),
|
||
Some(project_id.as_str()),
|
||
Some(&payload.canvas_completion),
|
||
canvas_items,
|
||
icon_image_srcs
|
||
.first()
|
||
.and_then(|icon| icon.resource.as_ref())
|
||
.map(|resource| generated_canvas_layer_id(resource.resource_id.as_str())),
|
||
)
|
||
.await?
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "editor-icon-spritesheet-slicing",
|
||
"message": "图集拆分结果未能写入画布。",
|
||
}))
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorIconSpritesheetSliceResponse {
|
||
icon_image_srcs,
|
||
project,
|
||
},
|
||
))
|
||
}
|
||
|
||
fn validate_editor_icon_spritesheet_manual_source(
|
||
source: &DownloadedImage,
|
||
) -> Result<(), AppError> {
|
||
let reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice()))
|
||
.with_guessed_format()
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
|
||
"provider": "editor-icon-spritesheet-slicing",
|
||
"message": format!("无法识别图集图片格式:{error}"),
|
||
}))
|
||
})?;
|
||
let (width, height) = reader.into_dimensions().map_err(|error| {
|
||
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
|
||
"provider": "editor-icon-spritesheet-slicing",
|
||
"message": format!("无法读取图集图片尺寸:{error}"),
|
||
}))
|
||
})?;
|
||
validate_editor_icon_spritesheet_manual_dimensions(width, height)
|
||
}
|
||
|
||
async fn resolve_editor_manual_atlas_split_group_task_id(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
source_resource: &EditorProjectResourceRecord,
|
||
) -> Result<Option<String>, AppError> {
|
||
let lookup = editor_manual_atlas_split_group_source_lookup_input(
|
||
owner_user_id,
|
||
source_resource.resource_id.as_str(),
|
||
source_resource.source_resource_id.as_deref(),
|
||
source_resource.asset_object_id.as_deref(),
|
||
source_resource.object_key.as_deref(),
|
||
);
|
||
let asset = state
|
||
.spacetime_client()
|
||
.find_editor_asset_group_source(lookup)
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
Ok(asset.and_then(|asset| normalize_optional_string(asset.task_id)))
|
||
}
|
||
|
||
fn editor_manual_atlas_split_group_source_lookup_input(
|
||
owner_user_id: &str,
|
||
resource_id: &str,
|
||
source_resource_id: Option<&str>,
|
||
asset_object_id: Option<&str>,
|
||
object_key: Option<&str>,
|
||
) -> EditorAssetGroupSourceLookupRecordInput {
|
||
let mut source_resource_ids = vec![resource_id.to_string()];
|
||
if let Some(source_resource_id) =
|
||
normalize_optional_string(source_resource_id.map(str::to_string))
|
||
{
|
||
source_resource_ids.push(source_resource_id);
|
||
}
|
||
EditorAssetGroupSourceLookupRecordInput {
|
||
owner_user_id: owner_user_id.to_string(),
|
||
source_resource_ids,
|
||
asset_object_id: asset_object_id.map(str::to_string),
|
||
object_key: object_key.map(str::to_string),
|
||
}
|
||
}
|
||
|
||
fn validate_editor_icon_spritesheet_manual_dimensions(
|
||
width: u32,
|
||
height: u32,
|
||
) -> Result<(), AppError> {
|
||
let pixel_count = u64::from(width).saturating_mul(u64::from(height));
|
||
if width == 0
|
||
|| height == 0
|
||
|| width > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION
|
||
|| height > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION
|
||
|| pixel_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS
|
||
{
|
||
return Err(
|
||
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
|
||
"provider": "editor-icon-spritesheet-slicing",
|
||
"message": "图集尺寸超过手动拆分限制。",
|
||
"width": width,
|
||
"height": height,
|
||
"maxDimension": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION,
|
||
"maxPixels": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS,
|
||
})),
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_editor_icon_spritesheet_manual_slice_count(slice_count: usize) -> Result<(), AppError> {
|
||
if slice_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES {
|
||
return Err(
|
||
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
|
||
"provider": "editor-icon-spritesheet-slicing",
|
||
"message": "图集识别出的素材数量超过手动拆分限制。",
|
||
"sliceCount": slice_count,
|
||
"maxSliceCount": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES,
|
||
})),
|
||
);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn extract_editor_ui_design_assets(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorUiDesignAssetExtractionRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
|
||
if !state.config.external_generation_mode.is_inline() {
|
||
ensure_editor_reference_image_source_is_stable(
|
||
payload.source_image_src.as_str(),
|
||
"editor-ui-design-asset-extraction",
|
||
"sourceImageSrc",
|
||
"UI设计图",
|
||
)?;
|
||
ensure_editor_reference_image_sources_are_stable(
|
||
payload.reference_image_srcs.as_deref(),
|
||
"editor-ui-design-asset-extraction",
|
||
"referenceImageSrcs",
|
||
"UI素材参考图",
|
||
)?;
|
||
let generation_options = normalize_editor_ui_design_asset_extraction_options(
|
||
payload.model.as_deref(),
|
||
payload.aspect_ratio.as_str(),
|
||
payload.image_size.as_str(),
|
||
)?;
|
||
let price_mud_points = u64::from(
|
||
resolve_editor_ui_design_asset_extraction_price(
|
||
&state,
|
||
Some(generation_options.model),
|
||
Some(generation_options.image_size),
|
||
)
|
||
.await?,
|
||
);
|
||
let source_entity_id = editor_generation_source_entity_id(
|
||
payload.project_id.as_deref(),
|
||
"editor-ui-design-asset-extraction",
|
||
);
|
||
let queue_job = enqueue_editor_generation_job(
|
||
&state,
|
||
&request_context,
|
||
caller.owner_user_id.as_str(),
|
||
EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND,
|
||
source_entity_id,
|
||
"图片画布提取UI设计图素材",
|
||
price_mud_points,
|
||
&payload,
|
||
)
|
||
.await?;
|
||
return Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorGenerationQueuedResponse {
|
||
queue_state: editor_generation_queue_state(queue_job),
|
||
},
|
||
));
|
||
}
|
||
extract_editor_ui_design_assets_for_owner(&state, &request_context, caller, payload).await
|
||
}
|
||
|
||
pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
caller: EditorGenerationCaller,
|
||
payload: EditorUiDesignAssetExtractionRequest,
|
||
) -> Result<Json<Value>, AppError> {
|
||
ensure_editor_reference_image_source_is_stable(
|
||
payload.source_image_src.as_str(),
|
||
"editor-ui-design-asset-extraction",
|
||
"sourceImageSrc",
|
||
"UI设计图",
|
||
)?;
|
||
ensure_editor_reference_image_sources_are_stable(
|
||
payload.reference_image_srcs.as_deref(),
|
||
"editor-ui-design-asset-extraction",
|
||
"referenceImageSrcs",
|
||
"UI素材参考图",
|
||
)?;
|
||
// 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时
|
||
// 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。
|
||
let requested_screen_color = payload.screen_color.clone();
|
||
let reference_count = 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len);
|
||
let decision_prompt = format!(
|
||
"从 UI 设计图提取被红色框选的素材,输出 spritesheet。目标规格 {} {}。",
|
||
payload.aspect_ratio, payload.image_size
|
||
);
|
||
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
|
||
let reference_image = parse_editor_reference_image(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
payload.source_image_src.as_str(),
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
error.with_details(json!({
|
||
"provider": "editor-ui-design-asset-extraction",
|
||
"field": "sourceImageSrc",
|
||
"message": "UI设计图必须是 objectKey、资源或素材引用。",
|
||
}))
|
||
})?;
|
||
let mut reference_images = vec![reference_image];
|
||
for source in normalize_editor_reference_image_sources(
|
||
payload.reference_image_srcs.as_deref(),
|
||
EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT,
|
||
) {
|
||
reference_images.push(
|
||
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source)
|
||
.await
|
||
.map_err(|error| {
|
||
error.with_details(json!({
|
||
"provider": "editor-ui-design-asset-extraction",
|
||
"field": "referenceImageSrcs",
|
||
"message": "UI素材参考图必须是 objectKey、资源或素材引用。",
|
||
}))
|
||
})?,
|
||
);
|
||
}
|
||
let generation_options = normalize_editor_ui_design_asset_extraction_options(
|
||
payload.model.as_deref(),
|
||
payload.aspect_ratio.as_str(),
|
||
payload.image_size.as_str(),
|
||
)?;
|
||
let expected_price_mud_points = resolve_editor_ui_design_asset_extraction_price(
|
||
state,
|
||
Some(generation_options.model),
|
||
Some(generation_options.image_size),
|
||
)
|
||
.await?;
|
||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||
request_context,
|
||
caller.audit_subject_user_id.clone(),
|
||
caller
|
||
.audit_project_id
|
||
.clone()
|
||
.or_else(|| payload.project_id.clone()),
|
||
);
|
||
let http_client = build_openai_image_http_client(&settings)?;
|
||
let (generated, prompt, screen_background_decision) =
|
||
execute_billable_asset_operation_with_cost(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
"editor_ui_design_asset_extraction",
|
||
request_context.request_id(),
|
||
u64::from(expected_price_mud_points),
|
||
async {
|
||
// 背景色决策:预扣泥点之后才发起(决策失败→future 返回 Err→失败退款)。
|
||
let screen_background_decision = resolve_editor_screen_background_color(
|
||
state.llm_client(),
|
||
state.editor_agent_llm_client(),
|
||
EditorScreenBackgroundDecisionInput {
|
||
kind: EditorScreenBackgroundDecisionKind::UiDesignAssetExtraction,
|
||
screen_color: requested_screen_color.clone(),
|
||
prompt: decision_prompt.clone(),
|
||
icon_descriptions: Vec::new(),
|
||
reference_count,
|
||
source_image_data_url: None,
|
||
},
|
||
Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
|
||
state,
|
||
context: crate::external_api_audit::ExternalApiAuditContext {
|
||
user_id: caller.audit_subject_user_id.clone(),
|
||
profile_id: caller
|
||
.audit_project_id
|
||
.clone()
|
||
.or_else(|| payload.project_id.clone()),
|
||
request_id: Some(request_context.request_id().to_string()),
|
||
external_call_deadline: request_context.external_call_deadline(),
|
||
},
|
||
}),
|
||
)
|
||
.await?;
|
||
let screen_color = screen_background_decision.color;
|
||
let prompt = editor_ui_design_asset_extraction_prompt(screen_color);
|
||
let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
|
||
create_openai_nanobanana_generate_content(
|
||
&http_client,
|
||
&settings,
|
||
generation_options.model,
|
||
prompt.as_str(),
|
||
None,
|
||
generation_options.aspect_ratio,
|
||
generation_options.provider_image_size,
|
||
reference_images.as_slice(),
|
||
"图片画布提取UI设计图素材 spritesheet",
|
||
)
|
||
.await?
|
||
} else {
|
||
create_openai_image_edit_with_references_and_model(
|
||
&http_client,
|
||
&settings,
|
||
generation_options.model,
|
||
prompt.as_str(),
|
||
None,
|
||
generation_options.provider_size.as_str(),
|
||
1,
|
||
reference_images.as_slice(),
|
||
"图片画布提取UI设计图素材 spritesheet",
|
||
)
|
||
.await?
|
||
};
|
||
Ok::<_, AppError>((generated, prompt, screen_background_decision))
|
||
},
|
||
)
|
||
.await?;
|
||
let screen_color = screen_background_decision.color;
|
||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "VectorEngine 未返回 UI 设计图素材 spritesheet",
|
||
}))
|
||
})?;
|
||
let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice())
|
||
.map(|image| (image.width(), image.height()))
|
||
.unwrap_or((512, 512));
|
||
let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs(
|
||
payload.generation_inputs.clone(),
|
||
Some(&screen_background_decision),
|
||
);
|
||
let source_label =
|
||
resolve_editor_generated_asset_label(payload.spritesheet_label.clone(), "UI设计图素材图集");
|
||
let asset_folder_id = payload
|
||
.asset_folder_id
|
||
.clone()
|
||
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string()));
|
||
let source_persisted = persist_editor_provider_source_image(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
generated.task_id.as_str(),
|
||
image,
|
||
prompt.as_str(),
|
||
generated.actual_prompt.as_deref(),
|
||
EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND,
|
||
"ui-design-spritesheets",
|
||
"spritesheet",
|
||
)
|
||
.await?;
|
||
let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str());
|
||
let source_object_key = source_persisted.object_key.clone();
|
||
let source_record = persist_editor_provider_source_resource(
|
||
state,
|
||
source_persisted,
|
||
PersistEditorProviderSourceResourceInput {
|
||
project_id: payload.project_id.clone(),
|
||
owner_user_id: caller.owner_user_id.clone(),
|
||
folder_id: asset_folder_id.clone(),
|
||
label: editor_generated_asset_variant_label(source_label.as_str(), "原图"),
|
||
width: source_width,
|
||
height: source_height,
|
||
prompt: prompt.clone(),
|
||
actual_prompt: generated.actual_prompt.clone(),
|
||
model: generation_options.model.to_string(),
|
||
task_id: generated.task_id.clone(),
|
||
source_resource_id: None,
|
||
asset_kind: Some("icon-spritesheet".to_string()),
|
||
generation_inputs: generation_inputs.clone(),
|
||
generation_cost_mud_points: u64::from(expected_price_mud_points),
|
||
},
|
||
)
|
||
.await?;
|
||
caller.report_processing_phase(state).await?;
|
||
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
|
||
user_id: caller.audit_subject_user_id.clone(),
|
||
profile_id: caller
|
||
.audit_project_id
|
||
.clone()
|
||
.or_else(|| payload.project_id.clone()),
|
||
request_id: Some(request_context.request_id().to_string()),
|
||
external_call_deadline: request_context.external_call_deadline(),
|
||
};
|
||
let removal = remove_editor_generated_screen_background_with_bgfilter(
|
||
state,
|
||
source_object_key.as_str(),
|
||
screen_color,
|
||
seg_model,
|
||
EDITOR_BGFILTER_CROSS_CHECK_DISABLED,
|
||
&matting_audit,
|
||
)
|
||
.await;
|
||
let removal = match removal {
|
||
Ok(removal) => removal,
|
||
Err(error) => {
|
||
let failure_message = error.body_text();
|
||
tracing::warn!(
|
||
provider = "editor-ui-design-asset-extraction",
|
||
operation = "background_postprocess",
|
||
task_id = %generated.task_id,
|
||
error = %failure_message,
|
||
"UI 素材图集原图已保存,但透明背景处理失败,使用原图完成画布"
|
||
);
|
||
let completed_project = complete_editor_canvas_generation(
|
||
state,
|
||
caller.owner_user_id.as_str(),
|
||
payload.project_id.as_deref(),
|
||
payload.canvas_completion.as_ref(),
|
||
source_record.resource.as_ref(),
|
||
)
|
||
.await?;
|
||
return Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorIconSpritesheetGenerationResponse {
|
||
spritesheet_image_src: source_image_src,
|
||
spritesheet_width: source_width,
|
||
spritesheet_height: source_height,
|
||
icon_image_srcs: Vec::new(),
|
||
slice_warning: None,
|
||
prompt,
|
||
actual_prompt: generated.actual_prompt,
|
||
model: generation_options.model.to_string(),
|
||
provider: "VectorEngine",
|
||
task_id: generated.task_id,
|
||
price_mud_points: expected_price_mud_points,
|
||
spritesheet_resource: source_record.resource,
|
||
spritesheet_asset: source_record.asset,
|
||
project: completed_project,
|
||
warning: Some(editor_postprocess_fallback_warning(
|
||
"生成任务成功,后处理失败。",
|
||
)),
|
||
},
|
||
));
|
||
}
|
||
};
|
||
let removal_provider = removal.provider;
|
||
let (image, postprocess_dimension_error) =
|
||
apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original(
|
||
state,
|
||
source_object_key.as_str(),
|
||
source_width,
|
||
source_height,
|
||
removal.image,
|
||
)
|
||
.await;
|
||
if let Some(error) = postprocess_dimension_error {
|
||
tracing::warn!(
|
||
task_id = %generated.task_id,
|
||
provider = removal_provider,
|
||
source_width,
|
||
source_height,
|
||
error = ?error,
|
||
"UI 素材透明图蒙版尺寸恢复失败,保留去背景服务原始输出"
|
||
);
|
||
}
|
||
let matting_generation_inputs =
|
||
build_editor_derived_asset_generation_inputs("UI图集抠图", &removal.model, &source_record);
|
||
let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice())
|
||
.map(|image| (image.width(), image.height()))
|
||
.unwrap_or((512, 512));
|
||
let owner_user_id = caller.owner_user_id;
|
||
let spritesheet_persisted = persist_editor_generated_image(
|
||
state,
|
||
owner_user_id.as_str(),
|
||
generated.task_id.as_str(),
|
||
&image,
|
||
"去除纯色背景",
|
||
None,
|
||
EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND,
|
||
"ui-design-spritesheets",
|
||
"spritesheet",
|
||
"spritesheet",
|
||
removal.provider,
|
||
)
|
||
.await?;
|
||
let spritesheet_image_src =
|
||
editor_media_src_from_object_key(spritesheet_persisted.object_key.as_str());
|
||
let spritesheet_record = persist_editor_generated_asset(
|
||
state,
|
||
PersistEditorGeneratedAssetInput {
|
||
project_id: payload.project_id.clone(),
|
||
owner_user_id: owner_user_id.clone(),
|
||
folder_id: asset_folder_id.clone(),
|
||
label: source_label,
|
||
image_src: spritesheet_image_src.clone(),
|
||
object_key: Some(spritesheet_persisted.object_key.clone()),
|
||
asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()),
|
||
width: spritesheet_width,
|
||
height: spritesheet_height,
|
||
prompt: "去除纯色背景".to_string(),
|
||
actual_prompt: None,
|
||
model: removal.model.clone(),
|
||
provider: removal.provider.to_string(),
|
||
task_id: generated.task_id.clone(),
|
||
group_task_id: None,
|
||
group_task_expected_asset_count: None,
|
||
source_resource_id: source_record
|
||
.resource
|
||
.as_ref()
|
||
.map(|resource| resource.resource_id.clone()),
|
||
asset_kind: Some("icon-spritesheet".to_string()),
|
||
generation_inputs: matting_generation_inputs,
|
||
thumbnail_src: None,
|
||
generation_cost_mud_points: 0,
|
||
},
|
||
)
|
||
.await?;
|
||
let slice_source = DownloadedImage {
|
||
bytes: image.bytes.clone(),
|
||
mime_type: image.mime_type.clone(),
|
||
extension: image.extension.clone(),
|
||
};
|
||
let (icon_image_srcs, slice_warning) =
|
||
match slice_generated_icon_spritesheet_all_by_connected_components(&slice_source) {
|
||
Ok(icon_slices) => {
|
||
match persist_editor_spritesheet_slices(
|
||
state,
|
||
icon_slices,
|
||
PersistEditorSpritesheetSlicesInput {
|
||
owner_user_id: owner_user_id.clone(),
|
||
project_id: payload.project_id.clone(),
|
||
asset_folder_id: asset_folder_id.clone(),
|
||
source_resource_id: spritesheet_record
|
||
.resource
|
||
.as_ref()
|
||
.map(|resource| resource.resource_id.clone()),
|
||
task_id: generated.task_id.clone(),
|
||
group_task_id: None,
|
||
prompt: "自动拆分图集".to_string(),
|
||
actual_prompt: None,
|
||
model: "connected-components".to_string(),
|
||
provider: "Genarrative".to_string(),
|
||
generation_inputs: build_editor_derived_asset_generation_inputs(
|
||
"图集拆分",
|
||
"connected-components",
|
||
&spritesheet_record,
|
||
),
|
||
path_kind: "ui-design-assets",
|
||
asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND,
|
||
persistence_provider: "vector-engine",
|
||
},
|
||
)
|
||
.await
|
||
{
|
||
Ok(icon_image_srcs) => (icon_image_srcs, None),
|
||
Err(error) => {
|
||
let reason = error.body_text();
|
||
tracing::warn!(
|
||
provider = "editor-ui-design-asset-extraction",
|
||
operation = "persist_slices",
|
||
task_id = %generated.task_id,
|
||
reason = %reason,
|
||
"UI 设计图透明图集已持久化,但独立素材持久化失败"
|
||
);
|
||
(
|
||
Vec::new(),
|
||
Some(EditorIconSpritesheetSliceWarningResponse {
|
||
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE,
|
||
reason,
|
||
}),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
Err(error) => {
|
||
let reason = error.to_string();
|
||
tracing::warn!(
|
||
provider = "editor-ui-design-asset-extraction",
|
||
operation = "detect_slices",
|
||
task_id = %generated.task_id,
|
||
reason = %reason,
|
||
"UI 设计图透明图集已持久化,但未识别到可拆分素材"
|
||
);
|
||
(
|
||
Vec::new(),
|
||
Some(EditorIconSpritesheetSliceWarningResponse {
|
||
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
|
||
reason,
|
||
}),
|
||
)
|
||
}
|
||
};
|
||
let (canvas_items, primary_layer_id) =
|
||
if let Some(completion) = payload.canvas_completion.as_ref() {
|
||
build_icon_spritesheet_canvas_layer_items(
|
||
completion,
|
||
spritesheet_record.resource.as_ref(),
|
||
source_record.resource.as_ref(),
|
||
icon_image_srcs.as_slice(),
|
||
)
|
||
} else {
|
||
(Vec::new(), None)
|
||
};
|
||
let completed_project = complete_editor_canvas_generation_with_items(
|
||
state,
|
||
owner_user_id.as_str(),
|
||
payload.project_id.as_deref(),
|
||
payload.canvas_completion.as_ref(),
|
||
canvas_items,
|
||
primary_layer_id,
|
||
)
|
||
.await?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
EditorIconSpritesheetGenerationResponse {
|
||
spritesheet_image_src,
|
||
spritesheet_width,
|
||
spritesheet_height,
|
||
icon_image_srcs,
|
||
slice_warning,
|
||
prompt,
|
||
actual_prompt: generated.actual_prompt,
|
||
model: generation_options.model.to_string(),
|
||
provider: "VectorEngine",
|
||
task_id: generated.task_id,
|
||
price_mud_points: expected_price_mud_points,
|
||
spritesheet_resource: spritesheet_record.resource,
|
||
spritesheet_asset: spritesheet_record.asset,
|
||
project: completed_project,
|
||
warning: None,
|
||
},
|
||
))
|
||
}
|
||
|
||
async fn repair_editor_project_record_inline_media(
|
||
state: &AppState,
|
||
mut record: EditorProjectRecord,
|
||
) -> EditorProjectRecord {
|
||
let owner_user_id = record.owner_user_id.clone();
|
||
let mut repaired_resources = Vec::with_capacity(record.resources.len());
|
||
for resource in record.resources {
|
||
repaired_resources.push(
|
||
repair_editor_project_resource_inline_media(state, owner_user_id.as_str(), resource)
|
||
.await,
|
||
);
|
||
}
|
||
record.resources = repaired_resources;
|
||
record
|
||
}
|
||
|
||
async fn repair_editor_asset_library_inline_media(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
mut record: EditorAssetLibraryRecord,
|
||
) -> EditorAssetLibraryRecord {
|
||
let mut repaired_assets = Vec::with_capacity(record.assets.len());
|
||
for asset in record.assets {
|
||
repaired_assets.push(repair_editor_asset_inline_media(state, owner_user_id, asset).await);
|
||
}
|
||
record.assets = repaired_assets;
|
||
record
|
||
}
|
||
|
||
async fn repair_editor_project_resource_inline_media(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
resource: EditorProjectResourceRecord,
|
||
) -> EditorProjectResourceRecord {
|
||
if !should_repair_editor_inline_image(
|
||
resource.image_src.as_str(),
|
||
resource.object_key.as_deref(),
|
||
) {
|
||
return resource;
|
||
}
|
||
|
||
let repair = persist_editor_legacy_inline_image(
|
||
state,
|
||
owner_user_id,
|
||
resource.image_src.as_str(),
|
||
resource
|
||
.task_id
|
||
.as_deref()
|
||
.unwrap_or(resource.resource_id.as_str()),
|
||
resource
|
||
.prompt
|
||
.as_deref()
|
||
.or(resource.actual_prompt.as_deref())
|
||
.unwrap_or("历史画布内联图片修复"),
|
||
resource.actual_prompt.as_deref(),
|
||
resource.asset_kind.as_deref(),
|
||
"legacy-resources",
|
||
resource.resource_id.as_str(),
|
||
)
|
||
.await;
|
||
let persisted = match repair {
|
||
Ok(persisted) => persisted,
|
||
Err(error) => {
|
||
tracing::warn!(
|
||
resource_id = %resource.resource_id,
|
||
error = %error,
|
||
"repair legacy inline editor project resource media failed"
|
||
);
|
||
return resource;
|
||
}
|
||
};
|
||
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
|
||
let repaired = state
|
||
.spacetime_client()
|
||
.repair_editor_project_resource_media(EditorProjectResourceMediaRepairRecordInput {
|
||
resource_id: resource.resource_id.clone(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
asset_object_id: persisted.asset_object_id.clone(),
|
||
image_src: image_src.clone(),
|
||
object_key: persisted.object_key.clone(),
|
||
updated_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error);
|
||
match repaired {
|
||
Ok(record) => record,
|
||
Err(error) => {
|
||
tracing::warn!(
|
||
resource_id = %resource.resource_id,
|
||
error = %error,
|
||
"persist legacy inline editor project resource repair failed"
|
||
);
|
||
EditorProjectResourceRecord {
|
||
image_src,
|
||
object_key: Some(persisted.object_key),
|
||
asset_object_id: Some(persisted.asset_object_id),
|
||
..resource
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn repair_editor_asset_inline_media(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
asset: EditorAssetRecord,
|
||
) -> EditorAssetRecord {
|
||
if !should_repair_editor_inline_image(asset.image_src.as_str(), asset.object_key.as_deref()) {
|
||
return asset;
|
||
}
|
||
|
||
let repair = persist_editor_legacy_inline_image(
|
||
state,
|
||
owner_user_id,
|
||
asset.image_src.as_str(),
|
||
asset.task_id.as_deref().unwrap_or(asset.asset_id.as_str()),
|
||
asset.prompt.as_deref().unwrap_or(asset.label.as_str()),
|
||
asset.actual_prompt.as_deref(),
|
||
asset.asset_kind.as_deref(),
|
||
"legacy-assets",
|
||
asset.asset_id.as_str(),
|
||
)
|
||
.await;
|
||
let persisted = match repair {
|
||
Ok(persisted) => persisted,
|
||
Err(error) => {
|
||
tracing::warn!(
|
||
asset_id = %asset.asset_id,
|
||
error = %error,
|
||
"repair legacy inline editor asset media failed"
|
||
);
|
||
return asset;
|
||
}
|
||
};
|
||
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
|
||
let repaired = state
|
||
.spacetime_client()
|
||
.repair_editor_asset_media(EditorAssetMediaRepairRecordInput {
|
||
asset_id: asset.asset_id.clone(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
asset_object_id: persisted.asset_object_id.clone(),
|
||
image_src: image_src.clone(),
|
||
object_key: persisted.object_key.clone(),
|
||
updated_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error);
|
||
match repaired {
|
||
Ok(record) => record,
|
||
Err(error) => {
|
||
tracing::warn!(
|
||
asset_id = %asset.asset_id,
|
||
error = %error,
|
||
"persist legacy inline editor asset repair failed"
|
||
);
|
||
EditorAssetRecord {
|
||
image_src,
|
||
object_key: Some(persisted.object_key),
|
||
asset_object_id: Some(persisted.asset_object_id),
|
||
..asset
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn persist_editor_legacy_inline_image(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
image_src: &str,
|
||
task_id: &str,
|
||
prompt: &str,
|
||
actual_prompt: Option<&str>,
|
||
asset_kind: Option<&str>,
|
||
path_kind: &str,
|
||
file_stem: &str,
|
||
) -> Result<PersistedEditorGeneratedImage, AppError> {
|
||
let decoded = decode_generated_image_asset_data_url(image_src).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": format!("历史画布内联图片无法解析:{error:?}"),
|
||
}))
|
||
})?;
|
||
let image = DownloadedOpenAiImage {
|
||
bytes: decoded.bytes,
|
||
mime_type: decoded.format.mime_type,
|
||
extension: decoded.format.extension,
|
||
};
|
||
persist_editor_generated_image(
|
||
state,
|
||
owner_user_id,
|
||
task_id,
|
||
&image,
|
||
prompt,
|
||
actual_prompt,
|
||
asset_kind.unwrap_or(EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND),
|
||
path_kind,
|
||
file_stem,
|
||
"legacy-repair",
|
||
"vector-engine",
|
||
)
|
||
.await
|
||
}
|
||
|
||
fn should_repair_editor_inline_image(image_src: &str, object_key: Option<&str>) -> bool {
|
||
object_key
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.is_none()
|
||
&& is_editor_inline_image_src(image_src)
|
||
}
|
||
|
||
fn is_editor_inline_image_src(value: &str) -> bool {
|
||
value
|
||
.trim_start()
|
||
.to_ascii_lowercase()
|
||
.starts_with("data:image/")
|
||
}
|
||
|
||
pub(crate) fn editor_project_payload_from_record(
|
||
record: EditorProjectRecord,
|
||
) -> EditorProjectPayload {
|
||
let resources: Vec<EditorProjectResourcePayload> = record
|
||
.resources
|
||
.into_iter()
|
||
.map(editor_project_resource_payload_from_record)
|
||
.collect();
|
||
let canvas = editor_canvas_payload_from_record(record.canvas, resources.as_slice());
|
||
let layers = sanitize_editor_payload_media(record.layers, resources.as_slice());
|
||
EditorProjectPayload {
|
||
project_id: record.project_id,
|
||
title: record.title,
|
||
canvas,
|
||
viewport: EditorCanvasViewportPayload {
|
||
x: record.viewport.x,
|
||
y: record.viewport.y,
|
||
scale: record.viewport.scale,
|
||
},
|
||
layers,
|
||
resources,
|
||
updated_at: record.updated_at,
|
||
}
|
||
}
|
||
|
||
fn editor_canvas_payload_from_record(
|
||
record: EditorCanvasRecord,
|
||
resources: &[EditorProjectResourcePayload],
|
||
) -> EditorCanvasPayload {
|
||
EditorCanvasPayload {
|
||
canvas_id: record.canvas_id,
|
||
project_id: record.project_id,
|
||
title: record.title,
|
||
viewport: EditorCanvasViewportPayload {
|
||
x: record.viewport.x,
|
||
y: record.viewport.y,
|
||
scale: record.viewport.scale,
|
||
},
|
||
layers: sanitize_editor_payload_media(record.layers, resources),
|
||
revision: record.revision,
|
||
layout_storage_version: record.layout_storage_version,
|
||
background_color: record.background_color,
|
||
created_at: record.created_at,
|
||
updated_at: record.updated_at,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn editor_project_resource_payload_from_record(
|
||
record: EditorProjectResourceRecord,
|
||
) -> EditorProjectResourcePayload {
|
||
let image_src =
|
||
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
|
||
EditorProjectResourcePayload {
|
||
resource_id: record.resource_id,
|
||
showcase_id: None,
|
||
asset_id: None,
|
||
label: None,
|
||
project_id: record.project_id,
|
||
owner_user_id: record.owner_user_id,
|
||
author_display_name: None,
|
||
author_public_user_code: None,
|
||
image_src,
|
||
object_key: record.object_key,
|
||
asset_object_id: record.asset_object_id,
|
||
width: record.width,
|
||
height: record.height,
|
||
source_type: record.source_type,
|
||
prompt: record.prompt,
|
||
actual_prompt: record.actual_prompt,
|
||
model: record.model,
|
||
provider: record.provider,
|
||
task_id: record.task_id,
|
||
source_resource_id: record.source_resource_id,
|
||
asset_kind: record.asset_kind,
|
||
showcase_category: None,
|
||
generation_inputs: record
|
||
.generation_inputs
|
||
.map(sanitize_editor_payload_inline_media),
|
||
public_showcase_enabled: record.public_showcase_enabled,
|
||
review_status: None,
|
||
display_enabled: None,
|
||
like_count: None,
|
||
generation_cost_mud_points: 0,
|
||
refund_mud_points: None,
|
||
created_at: record.created_at,
|
||
updated_at: record.updated_at,
|
||
}
|
||
}
|
||
|
||
fn editor_project_resource_payload_from_showcase_record(
|
||
record: EditorShowcaseAssetRecord,
|
||
) -> EditorProjectResourcePayload {
|
||
let image_src =
|
||
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
|
||
EditorProjectResourcePayload {
|
||
resource_id: record.showcase_id.clone(),
|
||
showcase_id: Some(record.showcase_id),
|
||
asset_id: Some(record.asset_id),
|
||
label: Some(record.label),
|
||
project_id: "editor-showcase".to_string(),
|
||
owner_user_id: record.owner_user_id,
|
||
author_display_name: None,
|
||
author_public_user_code: None,
|
||
image_src,
|
||
object_key: record.object_key,
|
||
asset_object_id: record.asset_object_id,
|
||
width: record.width,
|
||
height: record.height,
|
||
source_type: record.source_type,
|
||
prompt: record.prompt,
|
||
actual_prompt: record.actual_prompt,
|
||
model: record.model,
|
||
provider: record.provider,
|
||
task_id: record.task_id,
|
||
source_resource_id: record.source_resource_id,
|
||
asset_kind: record.asset_kind,
|
||
showcase_category: record.showcase_category,
|
||
generation_inputs: record
|
||
.generation_inputs
|
||
.map(sanitize_editor_payload_inline_media),
|
||
public_showcase_enabled: record.display_enabled,
|
||
review_status: Some(record.review_status),
|
||
display_enabled: Some(record.display_enabled),
|
||
like_count: Some(record.like_count),
|
||
generation_cost_mud_points: record.generation_cost_mud_points,
|
||
refund_mud_points: Some(record.refund_mud_points),
|
||
created_at: record.submitted_at,
|
||
updated_at: record.updated_at,
|
||
}
|
||
}
|
||
|
||
fn editor_project_resource_public_payload_from_record(
|
||
state: &AppState,
|
||
record: EditorShowcaseAssetRecord,
|
||
) -> EditorProjectResourcePayload {
|
||
let mut payload = editor_project_resource_payload_from_showcase_record(record);
|
||
let (display_name, public_user_code) =
|
||
resolve_editor_project_resource_public_author(state, payload.owner_user_id.as_str());
|
||
payload.author_display_name = display_name;
|
||
payload.author_public_user_code = public_user_code;
|
||
payload
|
||
}
|
||
|
||
fn resolve_editor_project_resource_public_author(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
) -> (Option<String>, Option<String>) {
|
||
let Some(owner_user_id) = normalize_optional_string(Some(owner_user_id.to_string())) else {
|
||
return (None, Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string()));
|
||
};
|
||
if owner_user_id == ORPHAN_WORK_OWNER_USER_ID {
|
||
return (None, Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string()));
|
||
}
|
||
|
||
match state.auth_user_service().get_user_by_id(&owner_user_id) {
|
||
Ok(Some(user)) => (
|
||
normalize_optional_string(Some(user.display_name)),
|
||
normalize_optional_string(Some(user.public_user_code)),
|
||
),
|
||
Ok(None) | Err(_) => (None, Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string())),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn editor_asset_library_payload_from_record(
|
||
record: EditorAssetLibraryRecord,
|
||
) -> EditorAssetLibraryPayload {
|
||
EditorAssetLibraryPayload {
|
||
folders: record
|
||
.folders
|
||
.into_iter()
|
||
.map(editor_asset_folder_payload_from_record)
|
||
.collect(),
|
||
assets: record
|
||
.assets
|
||
.into_iter()
|
||
.map(editor_asset_payload_from_record)
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn editor_asset_folder_payload_from_record(
|
||
record: EditorAssetFolderRecord,
|
||
) -> EditorAssetFolderPayload {
|
||
EditorAssetFolderPayload {
|
||
folder_id: record.folder_id,
|
||
label: record.label,
|
||
sort_order: record.sort_order,
|
||
collapsed: record.collapsed,
|
||
system_default: record.system_default,
|
||
created_at: record.created_at,
|
||
updated_at: record.updated_at,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload {
|
||
let image_src =
|
||
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
|
||
EditorAssetPayload {
|
||
asset_id: record.asset_id,
|
||
folder_id: record.folder_id,
|
||
label: record.label,
|
||
image_src,
|
||
object_key: record.object_key,
|
||
asset_object_id: record.asset_object_id,
|
||
width: record.width,
|
||
height: record.height,
|
||
source_type: record.source_type,
|
||
prompt: record.prompt,
|
||
actual_prompt: record.actual_prompt,
|
||
model: record.model,
|
||
provider: record.provider,
|
||
task_id: record.task_id,
|
||
asset_kind: record.asset_kind,
|
||
generation_inputs: record
|
||
.generation_inputs
|
||
.map(sanitize_editor_payload_inline_media),
|
||
source_resource_id: record.source_resource_id,
|
||
public_showcase_enabled: record.public_showcase_enabled,
|
||
thumbnail_src: normalize_optional_string(record.thumbnail_src),
|
||
generation_cost_mud_points: record.generation_cost_mud_points,
|
||
showcase_id: record.showcase_id,
|
||
showcase_review_status: record.showcase_review_status,
|
||
showcase_display_enabled: record.showcase_display_enabled,
|
||
showcase_like_count: record.showcase_like_count,
|
||
created_at: record.created_at,
|
||
updated_at: record.updated_at,
|
||
}
|
||
}
|
||
|
||
fn editor_showcase_campaign_payload_from_record(
|
||
record: EditorShowcaseCampaignConfigRecord,
|
||
) -> EditorShowcaseCampaignPayload {
|
||
EditorShowcaseCampaignPayload {
|
||
enabled: record.enabled,
|
||
title: record.title,
|
||
image_src: record.image_src,
|
||
image_object_key: record.image_object_key,
|
||
image_width: record.image_width,
|
||
image_height: record.image_height,
|
||
prompt: record.prompt,
|
||
author: record.author,
|
||
cost_text: record.cost_text,
|
||
updated_at: record.updated_at,
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct EditorPayloadMediaReference {
|
||
image_src: String,
|
||
object_key: Option<String>,
|
||
asset_object_id: Option<String>,
|
||
asset_kind: Option<String>,
|
||
}
|
||
|
||
fn sanitize_editor_payload_media(
|
||
mut value: Value,
|
||
resources: &[EditorProjectResourcePayload],
|
||
) -> Value {
|
||
let resource_media: BTreeMap<String, EditorPayloadMediaReference> = resources
|
||
.iter()
|
||
.filter(|resource| !resource.image_src.trim().is_empty())
|
||
.map(|resource| {
|
||
(
|
||
resource.resource_id.clone(),
|
||
EditorPayloadMediaReference {
|
||
image_src: resource.image_src.clone(),
|
||
object_key: resource.object_key.clone(),
|
||
asset_object_id: resource.asset_object_id.clone(),
|
||
asset_kind: resource.asset_kind.clone(),
|
||
},
|
||
)
|
||
})
|
||
.collect();
|
||
sanitize_editor_payload_media_value(&mut value, None, &resource_media);
|
||
value
|
||
}
|
||
|
||
fn sanitize_editor_payload_inline_media(mut value: Value) -> Value {
|
||
sanitize_editor_payload_media_value(&mut value, None, &BTreeMap::new());
|
||
value
|
||
}
|
||
|
||
fn sanitize_editor_payload_media_value(
|
||
value: &mut Value,
|
||
inherited_media: Option<&EditorPayloadMediaReference>,
|
||
resource_media: &BTreeMap<String, EditorPayloadMediaReference>,
|
||
) {
|
||
match value {
|
||
Value::Object(object) => {
|
||
let current_media = object
|
||
.get("resourceId")
|
||
.and_then(Value::as_str)
|
||
.and_then(|resource_id| resource_media.get(resource_id))
|
||
.cloned()
|
||
.or_else(|| inherited_media.cloned());
|
||
if let Some(media) = current_media.as_ref() {
|
||
for field in ["src", "imageSrc", "thumbnailSrc", "previewVideoPath"] {
|
||
rewrite_or_remove_inline_media_field(object, field, media);
|
||
}
|
||
fill_missing_media_identity_field(object, "objectKey", media.object_key.as_ref());
|
||
fill_missing_media_identity_field(
|
||
object,
|
||
"assetObjectId",
|
||
media.asset_object_id.as_ref(),
|
||
);
|
||
if object.get("assetKind").and_then(Value::as_str)
|
||
== Some(EDITOR_LEGACY_GREEN_SCREEN_SOURCE_ASSET_KIND)
|
||
{
|
||
set_optional_json_string_field(object, "assetKind", media.asset_kind.as_ref());
|
||
}
|
||
}
|
||
for child in object.values_mut() {
|
||
sanitize_editor_payload_media_value(child, current_media.as_ref(), resource_media);
|
||
}
|
||
object.retain(|_, child| !child.is_null());
|
||
}
|
||
Value::Array(items) => {
|
||
for item in items.iter_mut() {
|
||
sanitize_editor_payload_media_value(item, inherited_media, resource_media);
|
||
}
|
||
items.retain(|item| !item.is_null());
|
||
}
|
||
Value::String(item) if is_forbidden_editor_persisted_media_src(item) => {
|
||
*value = Value::Null;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn rewrite_or_remove_inline_media_field(
|
||
object: &mut serde_json::Map<String, Value>,
|
||
field: &str,
|
||
media: &EditorPayloadMediaReference,
|
||
) {
|
||
let Some(Value::String(current)) = object.get(field) else {
|
||
return;
|
||
};
|
||
if !is_forbidden_editor_persisted_media_src(current) {
|
||
return;
|
||
}
|
||
if media.image_src.trim().is_empty() {
|
||
object.remove(field);
|
||
} else {
|
||
object.insert(field.to_string(), Value::String(media.image_src.clone()));
|
||
}
|
||
}
|
||
|
||
fn fill_missing_media_identity_field(
|
||
object: &mut serde_json::Map<String, Value>,
|
||
field: &str,
|
||
value: Option<&String>,
|
||
) {
|
||
let Some(value) = value
|
||
.map(String::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
else {
|
||
return;
|
||
};
|
||
let should_fill = object
|
||
.get(field)
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|current| !current.is_empty())
|
||
.is_none();
|
||
if should_fill {
|
||
object.insert(field.to_string(), Value::String(value.to_string()));
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn persist_editor_generated_asset(
|
||
state: &AppState,
|
||
input: PersistEditorGeneratedAssetInput,
|
||
) -> Result<EditorGeneratedAssetRecord, AppError> {
|
||
let generation_inputs_json = serialize_editor_asset_metadata(input.generation_inputs.clone())?;
|
||
let object_key = normalize_editor_object_key(input.object_key);
|
||
let asset_object_id = normalize_optional_string(input.asset_object_id);
|
||
let image_src = normalize_editor_persisted_media_src(input.image_src, object_key.as_deref())?;
|
||
let now_micros = current_utc_micros();
|
||
let resource = if let Some(project_id) = normalize_optional_string(input.project_id.clone()) {
|
||
let record = state
|
||
.spacetime_client()
|
||
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
|
||
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
|
||
project_id,
|
||
owner_user_id: input.owner_user_id.clone(),
|
||
asset_object_id: asset_object_id.clone(),
|
||
image_src: image_src.clone(),
|
||
object_key: object_key.clone(),
|
||
width: input.width,
|
||
height: input.height,
|
||
source_type: "generated".to_string(),
|
||
prompt: Some(input.prompt.clone()),
|
||
actual_prompt: input.actual_prompt.clone(),
|
||
model: Some(input.model.clone()),
|
||
provider: Some(input.provider.clone()),
|
||
task_id: Some(input.task_id.clone()),
|
||
source_resource_id: normalize_optional_string(input.source_resource_id.clone()),
|
||
asset_kind: normalize_optional_string(input.asset_kind.clone()),
|
||
generation_inputs_json: generation_inputs_json.clone(),
|
||
updated_at_micros: now_micros,
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
Some(editor_project_resource_payload_from_record(record))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let folder_id =
|
||
normalize_generated_asset_folder_id(input.folder_id, input.owner_user_id.as_str());
|
||
let created_resource_id = resource
|
||
.as_ref()
|
||
.map(|resource| resource.resource_id.clone())
|
||
.or_else(|| normalize_optional_string(input.source_resource_id.clone()));
|
||
let asset = if let Some(folder_id) = folder_id {
|
||
let record = state
|
||
.spacetime_client()
|
||
.create_editor_asset(EditorAssetCreateRecordInput {
|
||
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
|
||
owner_user_id: input.owner_user_id.clone(),
|
||
folder_id,
|
||
label: input.label,
|
||
asset_object_id,
|
||
image_src,
|
||
object_key,
|
||
width: input.width,
|
||
height: input.height,
|
||
source_type: "generated".to_string(),
|
||
prompt: Some(input.prompt),
|
||
actual_prompt: input.actual_prompt,
|
||
model: Some(input.model),
|
||
provider: Some(input.provider),
|
||
task_id: Some(input.task_id),
|
||
asset_kind: normalize_optional_string(input.asset_kind),
|
||
generation_inputs_json,
|
||
source_resource_id: created_resource_id,
|
||
generation_cost_mud_points: input.generation_cost_mud_points,
|
||
now_micros,
|
||
thumbnail_src: normalize_optional_string(input.thumbnail_src),
|
||
group_task_id: normalize_optional_string(input.group_task_id),
|
||
group_task_expected_asset_count: input.group_task_expected_asset_count,
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
Some(editor_asset_payload_from_record(record))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
Ok(EditorGeneratedAssetRecord { resource, asset })
|
||
}
|
||
|
||
pub(crate) async fn persist_editor_generated_media_asset(
|
||
state: &AppState,
|
||
input: PersistEditorGeneratedAssetRequest,
|
||
) -> Result<
|
||
(
|
||
Option<EditorProjectResourcePayload>,
|
||
Option<EditorAssetPayload>,
|
||
),
|
||
AppError,
|
||
> {
|
||
let persisted = persist_editor_generated_asset(
|
||
state,
|
||
PersistEditorGeneratedAssetInput {
|
||
project_id: input.project_id,
|
||
owner_user_id: input.owner_user_id,
|
||
folder_id: input.folder_id,
|
||
label: input.label,
|
||
image_src: input.image_src,
|
||
object_key: input.object_key,
|
||
asset_object_id: input.asset_object_id,
|
||
width: input.width,
|
||
height: input.height,
|
||
prompt: input.prompt,
|
||
actual_prompt: input.actual_prompt,
|
||
model: input.model,
|
||
provider: input.provider,
|
||
task_id: input.task_id,
|
||
group_task_id: None,
|
||
group_task_expected_asset_count: None,
|
||
source_resource_id: input.source_resource_id,
|
||
asset_kind: input.asset_kind,
|
||
generation_inputs: input.generation_inputs,
|
||
thumbnail_src: input.thumbnail_src,
|
||
generation_cost_mud_points: input.generation_cost_mud_points,
|
||
},
|
||
)
|
||
.await?;
|
||
Ok((persisted.resource, persisted.asset))
|
||
}
|
||
|
||
pub(crate) async fn complete_editor_canvas_generation(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
project_id: Option<&str>,
|
||
completion: Option<&EditorCanvasGenerationCompletionRequest>,
|
||
resource: Option<&EditorProjectResourcePayload>,
|
||
) -> Result<Option<EditorProjectPayload>, AppError> {
|
||
let (Some(project_id), Some(completion), Some(resource)) = (project_id, completion, resource)
|
||
else {
|
||
return Ok(None);
|
||
};
|
||
let project_id = project_id.trim();
|
||
if project_id.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
|
||
let project = state
|
||
.spacetime_client()
|
||
.get_editor_project(EditorProjectGetRecordInput {
|
||
project_id: project_id.to_string(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
let project = repair_editor_project_record_inline_media(state, project).await;
|
||
let expected_revision = project.canvas.revision;
|
||
let viewport = project.viewport.clone();
|
||
let project_payload = editor_project_payload_from_record(project);
|
||
let Some(placeholder) =
|
||
resolve_canvas_completion_placeholder(project_payload.layers.clone(), completion)?
|
||
else {
|
||
return Ok(None);
|
||
};
|
||
let layer_id = generated_canvas_layer_id(resource.resource_id.as_str());
|
||
let item =
|
||
build_generated_canvas_layer_item(completion, &placeholder, resource, layer_id.as_str(), 0);
|
||
let completion_result = apply_editor_canvas_generation_items(
|
||
project_payload.layers,
|
||
completion,
|
||
placeholder,
|
||
vec![item],
|
||
Some(layer_id),
|
||
)?;
|
||
if !completion_result.changed {
|
||
return Ok(None);
|
||
}
|
||
let saved = save_editor_project_layout_with_revision_and_get(
|
||
state,
|
||
project_id,
|
||
owner_user_id,
|
||
viewport,
|
||
completion_result.layers,
|
||
expected_revision,
|
||
)
|
||
.await?;
|
||
|
||
Ok(Some(editor_project_payload_from_record(saved)))
|
||
}
|
||
|
||
pub(crate) async fn complete_editor_canvas_generation_with_items(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
project_id: Option<&str>,
|
||
completion: Option<&EditorCanvasGenerationCompletionRequest>,
|
||
items: Vec<Value>,
|
||
primary_layer_id: Option<String>,
|
||
) -> Result<Option<EditorProjectPayload>, AppError> {
|
||
let (Some(project_id), Some(completion)) = (project_id, completion) else {
|
||
return Ok(None);
|
||
};
|
||
let project_id = project_id.trim();
|
||
if project_id.is_empty() || items.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
|
||
let project = state
|
||
.spacetime_client()
|
||
.get_editor_project(EditorProjectGetRecordInput {
|
||
project_id: project_id.to_string(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
let project = repair_editor_project_record_inline_media(state, project).await;
|
||
let expected_revision = project.canvas.revision;
|
||
let viewport = project.viewport.clone();
|
||
let project_payload = editor_project_payload_from_record(project);
|
||
let Some(placeholder) =
|
||
resolve_canvas_completion_placeholder(project_payload.layers.clone(), completion)?
|
||
else {
|
||
return Ok(None);
|
||
};
|
||
let items =
|
||
translate_editor_canvas_generation_items(items, &completion.placeholder, &placeholder);
|
||
let completion_result = apply_editor_canvas_generation_items(
|
||
project_payload.layers,
|
||
completion,
|
||
placeholder,
|
||
items,
|
||
primary_layer_id,
|
||
)?;
|
||
if !completion_result.changed {
|
||
return Ok(None);
|
||
}
|
||
let saved = save_editor_project_layout_with_revision_and_get(
|
||
state,
|
||
project_id,
|
||
owner_user_id,
|
||
viewport,
|
||
completion_result.layers,
|
||
expected_revision,
|
||
)
|
||
.await?;
|
||
|
||
Ok(Some(editor_project_payload_from_record(saved)))
|
||
}
|
||
|
||
pub(crate) async fn complete_editor_canvas_background_removal(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
project_id: Option<&str>,
|
||
target_layer_id: Option<&str>,
|
||
resource: Option<&EditorProjectResourcePayload>,
|
||
replacement_title: Option<&str>,
|
||
recenter_replacement: bool,
|
||
) -> Result<Option<EditorProjectPayload>, AppError> {
|
||
let (Some(project_id), Some(target_layer_id), Some(resource)) =
|
||
(project_id, target_layer_id, resource)
|
||
else {
|
||
return Ok(None);
|
||
};
|
||
let project_id = project_id.trim();
|
||
let target_layer_id = target_layer_id.trim();
|
||
if project_id.is_empty() || target_layer_id.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
|
||
let project = state
|
||
.spacetime_client()
|
||
.get_editor_project(EditorProjectGetRecordInput {
|
||
project_id: project_id.to_string(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
let project = repair_editor_project_record_inline_media(state, project).await;
|
||
let expected_revision = project.canvas.revision;
|
||
let viewport = project.viewport.clone();
|
||
let project_payload = editor_project_payload_from_record(project);
|
||
let Some(layers) = replace_editor_canvas_layer_with_resource(
|
||
project_payload.layers,
|
||
target_layer_id,
|
||
resource,
|
||
replacement_title,
|
||
recenter_replacement,
|
||
) else {
|
||
return Ok(None);
|
||
};
|
||
|
||
let saved = save_editor_project_layout_with_revision_and_get(
|
||
state,
|
||
project_id,
|
||
owner_user_id,
|
||
viewport,
|
||
layers,
|
||
expected_revision,
|
||
)
|
||
.await?;
|
||
|
||
Ok(Some(editor_project_payload_from_record(saved)))
|
||
}
|
||
|
||
pub(crate) async fn save_editor_project_layout_with_revision_and_get(
|
||
state: &AppState,
|
||
project_id: &str,
|
||
owner_user_id: &str,
|
||
viewport: EditorCanvasViewportRecord,
|
||
layers: Value,
|
||
expected_revision: u64,
|
||
) -> Result<EditorProjectRecord, AppError> {
|
||
state
|
||
.spacetime_client()
|
||
.save_editor_project_layout_v2(EditorProjectLayoutSaveV2RecordInput {
|
||
project_id: project_id.to_string(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
viewport,
|
||
layers_json: serialize_editor_layers(layers)?,
|
||
expected_revision,
|
||
updated_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)
|
||
}
|
||
|
||
fn replace_editor_canvas_layer_with_resource(
|
||
layers: Value,
|
||
target_layer_id: &str,
|
||
resource: &EditorProjectResourcePayload,
|
||
replacement_title: Option<&str>,
|
||
recenter_replacement: bool,
|
||
) -> Option<Value> {
|
||
let mut items = match layers {
|
||
Value::Array(items) => items,
|
||
_ => return None,
|
||
};
|
||
let mut changed = false;
|
||
for item in &mut items {
|
||
let Some(object) = item.as_object_mut() else {
|
||
continue;
|
||
};
|
||
let is_target = object
|
||
.get("layerId")
|
||
.and_then(Value::as_str)
|
||
.is_some_and(|layer_id| layer_id == target_layer_id);
|
||
if !is_target {
|
||
continue;
|
||
}
|
||
|
||
let replacement_width = f64::from(resource.width.max(1));
|
||
let replacement_height = f64::from(resource.height.max(1));
|
||
let centered_position = recenter_replacement
|
||
.then(|| {
|
||
object
|
||
.get("x")
|
||
.and_then(Value::as_f64)
|
||
.zip(object.get("y").and_then(Value::as_f64))
|
||
.zip(object.get("width").and_then(Value::as_f64))
|
||
.zip(object.get("height").and_then(Value::as_f64))
|
||
.map(|(((x, y), width), height)| {
|
||
(
|
||
x + width / 2.0 - replacement_width / 2.0,
|
||
y + height / 2.0 - replacement_height / 2.0,
|
||
)
|
||
})
|
||
})
|
||
.flatten();
|
||
|
||
object.insert(
|
||
"resourceId".to_string(),
|
||
Value::String(resource.resource_id.clone()),
|
||
);
|
||
if let Some(title) = replacement_title
|
||
.map(str::trim)
|
||
.filter(|title| !title.is_empty())
|
||
{
|
||
object.insert("title".to_string(), Value::String(title.to_string()));
|
||
}
|
||
object.insert("src".to_string(), Value::String(resource.image_src.clone()));
|
||
if let Some((x, y)) = centered_position {
|
||
object.insert("x".to_string(), json!(x));
|
||
object.insert("y".to_string(), json!(y));
|
||
}
|
||
object.insert("width".to_string(), json!(resource.width.max(1)));
|
||
object.insert("height".to_string(), json!(resource.height.max(1)));
|
||
object.insert("originalWidth".to_string(), json!(resource.width.max(1)));
|
||
object.insert("originalHeight".to_string(), json!(resource.height.max(1)));
|
||
object.insert(
|
||
"sourceType".to_string(),
|
||
Value::String(resource.source_type.clone()),
|
||
);
|
||
object.insert("mediaType".to_string(), Value::String("image".to_string()));
|
||
set_optional_json_string_field(object, "prompt", resource.prompt.as_ref());
|
||
set_optional_json_string_field(object, "actualPrompt", resource.actual_prompt.as_ref());
|
||
set_optional_json_string_field(object, "model", resource.model.as_ref());
|
||
set_optional_json_string_field(object, "provider", resource.provider.as_ref());
|
||
set_optional_json_string_field(object, "taskId", resource.task_id.as_ref());
|
||
set_optional_json_string_field(object, "objectKey", resource.object_key.as_ref());
|
||
set_optional_json_string_field(object, "assetObjectId", resource.asset_object_id.as_ref());
|
||
set_optional_json_string_field(
|
||
object,
|
||
"sourceResourceId",
|
||
resource.source_resource_id.as_ref(),
|
||
);
|
||
set_optional_json_string_field(object, "assetKind", resource.asset_kind.as_ref());
|
||
set_optional_json_value_field(
|
||
object,
|
||
"generationInputs",
|
||
resource.generation_inputs.clone(),
|
||
);
|
||
object.remove("sourceAssetId");
|
||
object.remove("thumbnailSrc");
|
||
object.remove("imageSequenceFrames");
|
||
object.remove("previewVideoPath");
|
||
object.remove("durationSeconds");
|
||
changed = true;
|
||
break;
|
||
}
|
||
changed.then_some(Value::Array(items))
|
||
}
|
||
|
||
fn set_optional_json_string_field(
|
||
object: &mut serde_json::Map<String, Value>,
|
||
field: &str,
|
||
value: Option<&String>,
|
||
) {
|
||
match value
|
||
.map(String::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
{
|
||
Some(value) => {
|
||
object.insert(field.to_string(), Value::String(value.to_string()));
|
||
}
|
||
None => {
|
||
object.remove(field);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn set_optional_json_value_field(
|
||
object: &mut serde_json::Map<String, Value>,
|
||
field: &str,
|
||
value: Option<Value>,
|
||
) {
|
||
match value {
|
||
Some(value) if !value.is_null() => {
|
||
object.insert(field.to_string(), value);
|
||
}
|
||
_ => {
|
||
object.remove(field);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn translate_editor_canvas_generation_items(
|
||
mut items: Vec<Value>,
|
||
submitted_placeholder: &EditorCanvasGenerationPlaceholderPayload,
|
||
resolved_placeholder: &EditorCanvasGenerationPlaceholderPayload,
|
||
) -> Vec<Value> {
|
||
let dx = resolved_placeholder.x - submitted_placeholder.x;
|
||
let dy = resolved_placeholder.y - submitted_placeholder.y;
|
||
if !dx.is_finite() || !dy.is_finite() || (dx == 0.0 && dy == 0.0) {
|
||
return items;
|
||
}
|
||
|
||
for item in &mut items {
|
||
let Some(object) = item.as_object_mut() else {
|
||
continue;
|
||
};
|
||
if let Some(x) = object.get("x").and_then(Value::as_f64)
|
||
&& (x + dx).is_finite()
|
||
{
|
||
object.insert("x".to_string(), json!(x + dx));
|
||
}
|
||
if let Some(y) = object.get("y").and_then(Value::as_f64)
|
||
&& (y + dy).is_finite()
|
||
{
|
||
object.insert("y".to_string(), json!(y + dy));
|
||
}
|
||
}
|
||
|
||
items
|
||
}
|
||
|
||
fn resolve_canvas_completion_placeholder(
|
||
layers: Value,
|
||
completion: &EditorCanvasGenerationCompletionRequest,
|
||
) -> Result<Option<EditorCanvasGenerationPlaceholderPayload>, AppError> {
|
||
let dialog_id = normalized_canvas_completion_dialog_id(completion);
|
||
let items = match layers {
|
||
Value::Array(items) => items,
|
||
_ => Vec::new(),
|
||
};
|
||
if let Some(dialog_id) = dialog_id.as_deref() {
|
||
return items
|
||
.iter()
|
||
.find(|item| generation_dialog_item_matches(item, dialog_id))
|
||
.map(|item| {
|
||
generation_dialog_item_placeholder(item, dialog_id)
|
||
.unwrap_or_else(|| completion.placeholder.clone())
|
||
})
|
||
.map(Some)
|
||
.map_or(Ok(None), Ok);
|
||
}
|
||
Ok(Some(completion.placeholder.clone()))
|
||
}
|
||
|
||
fn apply_editor_canvas_generation_items(
|
||
layers: Value,
|
||
completion: &EditorCanvasGenerationCompletionRequest,
|
||
_placeholder: EditorCanvasGenerationPlaceholderPayload,
|
||
generated_items: Vec<Value>,
|
||
primary_layer_id: Option<String>,
|
||
) -> Result<EditorCanvasGenerationCompletionResult, AppError> {
|
||
let mut items = match layers {
|
||
Value::Array(items) => items,
|
||
_ => Vec::new(),
|
||
};
|
||
let dialog_id = normalized_canvas_completion_dialog_id(completion);
|
||
if let Some(dialog_id) = dialog_id.as_deref()
|
||
&& !items
|
||
.iter()
|
||
.any(|item| generation_dialog_item_matches(item, dialog_id))
|
||
{
|
||
return Ok(EditorCanvasGenerationCompletionResult {
|
||
layers: Value::Array(items),
|
||
changed: false,
|
||
});
|
||
}
|
||
|
||
let mut changed = false;
|
||
let mut next_z_index = next_canvas_layer_z_index(&items);
|
||
let mut completed_layer_id = primary_layer_id;
|
||
for mut item in generated_items {
|
||
let existing_layer_id =
|
||
item.get("resourceId")
|
||
.and_then(Value::as_str)
|
||
.and_then(|resource_id| {
|
||
items.iter().find_map(|existing| {
|
||
let existing_resource_id =
|
||
existing.get("resourceId").and_then(Value::as_str)?;
|
||
if existing_resource_id == resource_id {
|
||
existing
|
||
.get("layerId")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
});
|
||
if let Some(existing_layer_id) = existing_layer_id {
|
||
completed_layer_id = Some(existing_layer_id);
|
||
continue;
|
||
}
|
||
let needs_z_index = match item.get("zIndex").and_then(Value::as_i64) {
|
||
Some(value) => value <= 0,
|
||
None => true,
|
||
};
|
||
if needs_z_index && let Some(object) = item.as_object_mut() {
|
||
object.insert("zIndex".to_string(), json!(next_z_index));
|
||
next_z_index += 1;
|
||
}
|
||
if completed_layer_id.is_none() {
|
||
completed_layer_id = item
|
||
.get("layerId")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string);
|
||
}
|
||
items.push(item);
|
||
changed = true;
|
||
}
|
||
|
||
if let Some(dialog_id) = dialog_id.as_deref() {
|
||
for item in &mut items {
|
||
if generation_dialog_item_matches(item, dialog_id)
|
||
&& let Some(dialog) = item.get_mut("dialog").and_then(Value::as_object_mut)
|
||
{
|
||
dialog.insert("status".to_string(), json!("idle"));
|
||
dialog.insert("composerOpen".to_string(), json!(false));
|
||
if let Some(completed_layer_id) = completed_layer_id.as_deref() {
|
||
dialog.insert("generatedLayerId".to_string(), json!(completed_layer_id));
|
||
}
|
||
dialog.remove("errorMessage");
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(EditorCanvasGenerationCompletionResult {
|
||
layers: Value::Array(items),
|
||
changed,
|
||
})
|
||
}
|
||
|
||
fn normalized_canvas_completion_dialog_id(
|
||
completion: &EditorCanvasGenerationCompletionRequest,
|
||
) -> Option<String> {
|
||
completion
|
||
.dialog_id
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
}
|
||
|
||
fn build_generated_canvas_layer_item(
|
||
completion: &EditorCanvasGenerationCompletionRequest,
|
||
placeholder: &EditorCanvasGenerationPlaceholderPayload,
|
||
resource: &EditorProjectResourcePayload,
|
||
generated_layer_id: &str,
|
||
z_index: i64,
|
||
) -> Value {
|
||
let original_width = positive_f64(resource.width as f64, placeholder.original_width);
|
||
let original_height = positive_f64(resource.height as f64, placeholder.original_height);
|
||
let width = positive_f64(original_width, placeholder.width);
|
||
let height = positive_f64(original_height, placeholder.height);
|
||
let placeholder_width = positive_f64(placeholder.width, width);
|
||
let placeholder_height = positive_f64(placeholder.height, height);
|
||
let x = placeholder.x + placeholder_width / 2.0 - width / 2.0;
|
||
let y = placeholder.y + placeholder_height / 2.0 - height / 2.0;
|
||
let asset_kind = resource.asset_kind.as_deref();
|
||
|
||
json!({
|
||
"layerId": generated_layer_id,
|
||
"resourceId": resource.resource_id,
|
||
"title": completion.title.trim(),
|
||
"src": resource.image_src,
|
||
"x": x,
|
||
"y": y,
|
||
"width": width,
|
||
"height": height,
|
||
"originalWidth": original_width,
|
||
"originalHeight": original_height,
|
||
"zIndex": z_index,
|
||
"sourceType": "generated",
|
||
"mediaType": generated_canvas_media_type(asset_kind),
|
||
"prompt": resource.prompt,
|
||
"actualPrompt": resource.actual_prompt,
|
||
"model": resource.model,
|
||
"provider": resource.provider,
|
||
"taskId": resource.task_id,
|
||
"objectKey": resource.object_key,
|
||
"assetObjectId": resource.asset_object_id,
|
||
"sourceResourceId": resource.source_resource_id,
|
||
"assetKind": resource.asset_kind,
|
||
"generationInputs": resource.generation_inputs,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn build_editor_canvas_generated_layer_item(
|
||
completion: &EditorCanvasGenerationCompletionRequest,
|
||
input: EditorCanvasGeneratedLayerInput,
|
||
) -> Value {
|
||
let placeholder = &completion.placeholder;
|
||
let original_width = positive_f64(input.original_width as f64, placeholder.original_width);
|
||
let original_height = positive_f64(input.original_height as f64, placeholder.original_height);
|
||
let width = positive_f64(input.display_width, placeholder.width);
|
||
let height = positive_f64(input.display_height, placeholder.height);
|
||
let placeholder_width = positive_f64(placeholder.width, width);
|
||
let placeholder_height = positive_f64(placeholder.height, height);
|
||
let x = placeholder.x + placeholder_width / 2.0 - width / 2.0;
|
||
let y = placeholder.y + placeholder_height / 2.0 - height / 2.0;
|
||
|
||
json!({
|
||
"layerId": input.layer_id,
|
||
"resourceId": input.resource_id,
|
||
"title": input.title,
|
||
"src": input.src,
|
||
"mediaType": input.media_type,
|
||
"thumbnailSrc": input.thumbnail_src,
|
||
"x": x,
|
||
"y": y,
|
||
"width": width,
|
||
"height": height,
|
||
"originalWidth": original_width,
|
||
"originalHeight": original_height,
|
||
"sourceType": input.source_type,
|
||
"prompt": input.prompt,
|
||
"actualPrompt": input.actual_prompt,
|
||
"model": input.model,
|
||
"provider": input.provider,
|
||
"taskId": input.task_id,
|
||
"objectKey": input.object_key,
|
||
"assetObjectId": input.asset_object_id,
|
||
"durationSeconds": input.duration_seconds,
|
||
"sourceResourceId": input.source_resource_id,
|
||
"assetKind": input.asset_kind,
|
||
"generationInputs": input.generation_inputs,
|
||
"imageSequenceFrames": input.image_sequence_frames,
|
||
"previewVideoPath": input.preview_video_path,
|
||
})
|
||
}
|
||
|
||
fn positive_f64(value: f64, fallback: f64) -> f64 {
|
||
if value.is_finite() && value > 0.0 {
|
||
value.round().max(1.0)
|
||
} else if fallback.is_finite() && fallback > 0.0 {
|
||
fallback.round().max(1.0)
|
||
} else {
|
||
1.0
|
||
}
|
||
}
|
||
|
||
fn generated_canvas_layer_id(resource_id: &str) -> String {
|
||
format!("layer-editor-resource-{resource_id}")
|
||
}
|
||
|
||
fn generated_canvas_media_type(asset_kind: Option<&str>) -> &'static str {
|
||
match asset_kind {
|
||
Some("video") => "video",
|
||
Some("sound-effect") | Some("background-music") => "audio",
|
||
_ => "image",
|
||
}
|
||
}
|
||
|
||
fn build_primary_canvas_layer_items(
|
||
completion: &EditorCanvasGenerationCompletionRequest,
|
||
primary_resource: Option<&EditorProjectResourcePayload>,
|
||
) -> (Vec<Value>, Option<String>) {
|
||
let Some(primary_resource) = primary_resource else {
|
||
return (Vec::new(), None);
|
||
};
|
||
let placeholder = completion.placeholder.clone();
|
||
let primary_layer_id = generated_canvas_layer_id(primary_resource.resource_id.as_str());
|
||
let primary_item = build_generated_canvas_layer_item(
|
||
completion,
|
||
&placeholder,
|
||
primary_resource,
|
||
primary_layer_id.as_str(),
|
||
0,
|
||
);
|
||
(vec![primary_item], Some(primary_layer_id))
|
||
}
|
||
|
||
fn build_multi_asset_canvas_layer_items(
|
||
completion: &EditorCanvasGenerationCompletionRequest,
|
||
primary_resource: Option<&EditorProjectResourcePayload>,
|
||
source_resource: Option<&EditorProjectResourcePayload>,
|
||
) -> (Vec<Value>, Option<String>) {
|
||
let (mut items, primary_layer_id) =
|
||
build_primary_canvas_layer_items(completion, primary_resource);
|
||
let Some(primary_item) = items.first() else {
|
||
return (items, primary_layer_id);
|
||
};
|
||
let primary_right = primary_item
|
||
.get("x")
|
||
.and_then(Value::as_f64)
|
||
.unwrap_or(completion.placeholder.x)
|
||
+ primary_item
|
||
.get("width")
|
||
.and_then(Value::as_f64)
|
||
.unwrap_or(completion.placeholder.width.max(1.0));
|
||
let primary_y = primary_item
|
||
.get("y")
|
||
.and_then(Value::as_f64)
|
||
.unwrap_or(completion.placeholder.y);
|
||
|
||
if let Some(source_resource) = source_resource {
|
||
let source_layer_id = generated_canvas_layer_id(source_resource.resource_id.as_str());
|
||
let mut source_item = build_generated_canvas_layer_item(
|
||
completion,
|
||
&completion.placeholder,
|
||
source_resource,
|
||
source_layer_id.as_str(),
|
||
0,
|
||
);
|
||
if let Some(object) = source_item.as_object_mut() {
|
||
object.insert("x".to_string(), json!(primary_right + 32.0));
|
||
object.insert("y".to_string(), json!(primary_y));
|
||
object.insert(
|
||
"title".to_string(),
|
||
json!(format!("{}(原图)", completion.title.trim())),
|
||
);
|
||
}
|
||
items.push(source_item);
|
||
}
|
||
|
||
(items, primary_layer_id)
|
||
}
|
||
|
||
fn build_icon_spritesheet_canvas_layer_items(
|
||
completion: &EditorCanvasGenerationCompletionRequest,
|
||
spritesheet_resource: Option<&EditorProjectResourcePayload>,
|
||
source_resource: Option<&EditorProjectResourcePayload>,
|
||
icons: &[EditorIconSpritesheetIconResponse],
|
||
) -> (Vec<Value>, Option<String>) {
|
||
let placeholder = completion.placeholder.clone();
|
||
let (mut items, primary_layer_id) =
|
||
build_multi_asset_canvas_layer_items(completion, spritesheet_resource, source_resource);
|
||
if items.is_empty() {
|
||
return (items, primary_layer_id);
|
||
}
|
||
|
||
let start_x = items
|
||
.iter()
|
||
.filter_map(|item| Some(item.get("x")?.as_f64()? + item.get("width")?.as_f64()?))
|
||
.fold(placeholder.x, f64::max)
|
||
+ 32.0;
|
||
items.extend(build_icon_slice_canvas_layer_items_from_origin(
|
||
completion,
|
||
icons,
|
||
start_x,
|
||
placeholder.y,
|
||
));
|
||
|
||
(items, primary_layer_id)
|
||
}
|
||
|
||
fn build_icon_spritesheet_slice_canvas_layer_items(
|
||
completion: &EditorCanvasGenerationCompletionRequest,
|
||
icons: &[EditorIconSpritesheetIconResponse],
|
||
) -> Vec<Value> {
|
||
let placeholder = &completion.placeholder;
|
||
build_icon_slice_canvas_layer_items_from_origin(
|
||
completion,
|
||
icons,
|
||
placeholder.x + placeholder.width.max(1.0) + 32.0,
|
||
placeholder.y,
|
||
)
|
||
}
|
||
|
||
fn build_icon_slice_canvas_layer_items_from_origin(
|
||
completion: &EditorCanvasGenerationCompletionRequest,
|
||
icons: &[EditorIconSpritesheetIconResponse],
|
||
start_x: f64,
|
||
start_y: f64,
|
||
) -> Vec<Value> {
|
||
let placeholder = &completion.placeholder;
|
||
let mut items = Vec::new();
|
||
let mut cursor_x = start_x;
|
||
let mut cursor_y = start_y;
|
||
let mut row_height = 0.0_f64;
|
||
let max_row_width = 560.0_f64;
|
||
let spacing = 24.0_f64;
|
||
|
||
for icon in icons {
|
||
let Some(resource) = icon.resource.as_ref() else {
|
||
continue;
|
||
};
|
||
let width = positive_f64(resource.width as f64, 128.0);
|
||
let height = positive_f64(resource.height as f64, 128.0);
|
||
if cursor_x > start_x && cursor_x + width - start_x > max_row_width {
|
||
cursor_x = start_x;
|
||
cursor_y += row_height + spacing;
|
||
row_height = 0.0;
|
||
}
|
||
let layer_id = generated_canvas_layer_id(resource.resource_id.as_str());
|
||
let mut item = build_generated_canvas_layer_item(
|
||
completion,
|
||
&placeholder,
|
||
resource,
|
||
layer_id.as_str(),
|
||
0,
|
||
);
|
||
if let Some(object) = item.as_object_mut() {
|
||
object.insert("title".to_string(), json!(icon.name));
|
||
object.insert("x".to_string(), json!(cursor_x));
|
||
object.insert("y".to_string(), json!(cursor_y));
|
||
object.insert("width".to_string(), json!(width));
|
||
object.insert("height".to_string(), json!(height));
|
||
object.insert("originalWidth".to_string(), json!(width));
|
||
object.insert("originalHeight".to_string(), json!(height));
|
||
}
|
||
cursor_x += width + spacing;
|
||
row_height = row_height.max(height);
|
||
items.push(item);
|
||
}
|
||
items
|
||
}
|
||
|
||
fn next_canvas_layer_z_index(items: &[Value]) -> i64 {
|
||
items
|
||
.iter()
|
||
.filter_map(|item| item.get("zIndex").and_then(Value::as_i64))
|
||
.max()
|
||
.unwrap_or(0)
|
||
+ 1
|
||
}
|
||
|
||
fn generation_dialog_item_matches(item: &Value, dialog_id: &str) -> bool {
|
||
item.get("itemType").and_then(Value::as_str) == Some("generation-dialog")
|
||
&& item
|
||
.get("dialog")
|
||
.and_then(|dialog| dialog.get("id"))
|
||
.and_then(Value::as_str)
|
||
.is_some_and(|candidate| candidate == dialog_id)
|
||
}
|
||
|
||
fn generation_dialog_item_placeholder(
|
||
item: &Value,
|
||
dialog_id: &str,
|
||
) -> Option<EditorCanvasGenerationPlaceholderPayload> {
|
||
if !generation_dialog_item_matches(item, dialog_id) {
|
||
return None;
|
||
}
|
||
serde_json::from_value(
|
||
item.get("dialog")?
|
||
.get("placeholder")
|
||
.cloned()
|
||
.unwrap_or(Value::Null),
|
||
)
|
||
.ok()
|
||
}
|
||
|
||
fn normalize_generated_asset_folder_id(
|
||
folder_id: Option<String>,
|
||
owner_user_id: &str,
|
||
) -> Option<String> {
|
||
let normalized = normalize_optional_string(folder_id);
|
||
match normalized.as_deref() {
|
||
None => None,
|
||
Some("project") => Some(default_editor_asset_folder_id(owner_user_id)),
|
||
Some(value) if value.starts_with("folder-") => {
|
||
Some(default_editor_asset_folder_id(owner_user_id))
|
||
}
|
||
Some(_) => normalized,
|
||
}
|
||
}
|
||
|
||
fn default_editor_asset_folder_id(owner_user_id: &str) -> String {
|
||
format!("{owner_user_id}:asset-folder:{EDITOR_ASSET_DEFAULT_FOLDER_ID}")
|
||
}
|
||
|
||
fn normalize_editor_object_key(value: Option<String>) -> Option<String> {
|
||
normalize_optional_string(value)
|
||
.map(|item| item.trim_start_matches('/').to_string())
|
||
.filter(|item| !item.is_empty())
|
||
}
|
||
|
||
fn editor_media_src_from_object_key(object_key: &str) -> String {
|
||
format!("/{}", object_key.trim().trim_start_matches('/'))
|
||
}
|
||
|
||
fn normalize_editor_record_media_src(image_src: String, object_key: Option<&str>) -> String {
|
||
if let Some(media_src) = object_key
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(editor_media_src_from_object_key)
|
||
{
|
||
return media_src;
|
||
}
|
||
if is_forbidden_editor_persisted_media_src(image_src.as_str()) {
|
||
return String::new();
|
||
}
|
||
image_src
|
||
}
|
||
|
||
pub(crate) fn normalize_editor_persisted_media_src(
|
||
image_src: String,
|
||
object_key: Option<&str>,
|
||
) -> Result<String, AppError> {
|
||
if let Some(object_key) = object_key.map(str::trim).filter(|value| !value.is_empty()) {
|
||
return Ok(editor_media_src_from_object_key(object_key));
|
||
}
|
||
if is_forbidden_editor_persisted_media_src(image_src.as_str()) {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": "图片画布媒体必须先上传 OSS 并以 objectKey 引用,不能写入 Data URL。",
|
||
})),
|
||
);
|
||
}
|
||
Ok(image_src)
|
||
}
|
||
|
||
fn is_forbidden_editor_persisted_media_src(value: &str) -> bool {
|
||
let normalized = value.trim_start().to_ascii_lowercase();
|
||
let value = normalized.as_str();
|
||
value.starts_with("blob:")
|
||
|| value.starts_with("data:image/")
|
||
|| value.starts_with("data:video/")
|
||
|| value.starts_with("data:audio/")
|
||
}
|
||
|
||
fn value_contains_forbidden_editor_persisted_media(value: &Value) -> bool {
|
||
match value {
|
||
Value::String(item) => is_forbidden_editor_persisted_media_src(item),
|
||
Value::Array(items) => items
|
||
.iter()
|
||
.any(value_contains_forbidden_editor_persisted_media),
|
||
Value::Object(object) => object
|
||
.values()
|
||
.any(value_contains_forbidden_editor_persisted_media),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
impl EditorCanvasViewportPayload {
|
||
pub(crate) fn into_record(self) -> EditorCanvasViewportRecord {
|
||
EditorCanvasViewportRecord {
|
||
x: self.x,
|
||
y: self.y,
|
||
scale: self.scale,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn current_owner_user_id(authenticated: &AuthenticatedAccessToken) -> String {
|
||
authenticated.claims().user_id().to_string()
|
||
}
|
||
|
||
pub(crate) fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
|
||
if value_contains_forbidden_editor_persisted_media(&layers) {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": "图层布局不能包含内联媒体 Data URL。",
|
||
})),
|
||
);
|
||
}
|
||
let payload = serde_json::to_string(&layers).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": format!("图层布局无法序列化:{error}"),
|
||
}))
|
||
})?;
|
||
if payload.len() > EDITOR_LAYOUT_MAX_BYTES {
|
||
return Err(
|
||
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": "图层布局过大",
|
||
})),
|
||
);
|
||
}
|
||
Ok(payload)
|
||
}
|
||
|
||
pub(crate) fn serialize_editor_asset_metadata(
|
||
metadata: Option<Value>,
|
||
) -> Result<Option<String>, AppError> {
|
||
let Some(metadata) = metadata else {
|
||
return Ok(None);
|
||
};
|
||
if metadata.is_null() {
|
||
return Ok(None);
|
||
}
|
||
if value_contains_forbidden_editor_persisted_media(&metadata) {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": "资产元数据不能包含内联媒体 Data URL。",
|
||
})),
|
||
);
|
||
}
|
||
let payload = serde_json::to_string(&metadata).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": format!("资产元数据无法序列化:{error}"),
|
||
}))
|
||
})?;
|
||
if payload.len() > EDITOR_ASSET_METADATA_MAX_BYTES {
|
||
return Err(
|
||
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": "资产元数据过大",
|
||
})),
|
||
);
|
||
}
|
||
Ok(Some(payload))
|
||
}
|
||
|
||
pub(crate) fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||
value
|
||
.map(|item| item.trim().to_string())
|
||
.filter(|item| !item.is_empty())
|
||
}
|
||
|
||
fn resolve_editor_generated_asset_label(value: Option<String>, fallback: &str) -> String {
|
||
normalize_optional_string(value)
|
||
.unwrap_or_else(|| fallback.to_string())
|
||
.chars()
|
||
.take(EDITOR_ASSET_LABEL_MAX_CHARS)
|
||
.collect()
|
||
}
|
||
|
||
fn editor_generated_asset_variant_label(label: &str, variant: &str) -> String {
|
||
let suffix = format!("({variant})");
|
||
let base_char_limit = EDITOR_ASSET_LABEL_MAX_CHARS.saturating_sub(suffix.chars().count());
|
||
let base = label
|
||
.trim()
|
||
.chars()
|
||
.take(base_char_limit)
|
||
.collect::<String>();
|
||
format!("{base}{suffix}")
|
||
}
|
||
|
||
pub(crate) fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String {
|
||
let normalized = value
|
||
.trim()
|
||
.chars()
|
||
.map(|character| match character {
|
||
'a'..='z' | '0'..='9' | '-' | '_' => character,
|
||
'A'..='Z' => character.to_ascii_lowercase(),
|
||
_ => '-',
|
||
})
|
||
.collect::<String>()
|
||
.split('-')
|
||
.filter(|part| !part.is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join("-");
|
||
if normalized.is_empty() {
|
||
fallback.to_string()
|
||
} else {
|
||
normalized
|
||
}
|
||
}
|
||
|
||
fn normalize_icon_descriptions(descriptions: Vec<String>) -> Result<Vec<String>, AppError> {
|
||
let normalized = descriptions
|
||
.into_iter()
|
||
.map(|description| description.trim().to_string())
|
||
.filter(|description| !description.is_empty())
|
||
.take(EDITOR_ICON_DESCRIPTION_LIMIT.saturating_add(1))
|
||
.collect::<Vec<_>>();
|
||
if normalized.is_empty() || normalized.len() > EDITOR_ICON_DESCRIPTION_LIMIT {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-icon-spritesheet",
|
||
"field": "iconDescriptions",
|
||
"message": "图标素材描述数量必须在 1 到 100 个之间。",
|
||
})),
|
||
);
|
||
}
|
||
Ok(normalized)
|
||
}
|
||
|
||
async fn resolve_editor_icon_spritesheet_price(
|
||
state: &AppState,
|
||
model: Option<&str>,
|
||
image_size: Option<&str>,
|
||
) -> Result<u32, AppError> {
|
||
let expected_price_mud_points = state
|
||
.editor_generation_pricing()
|
||
.await
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "editor-generation-pricing",
|
||
"message": error.to_string(),
|
||
}))
|
||
})?
|
||
.image_generation_mud_points(Some("icon"), model, image_size);
|
||
Ok(expected_price_mud_points)
|
||
}
|
||
|
||
fn editor_ui_design_asset_extraction_bad_request(message: impl Into<String>) -> AppError {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-ui-design-asset-extraction",
|
||
"message": message.into(),
|
||
}))
|
||
}
|
||
|
||
fn normalize_editor_ui_design_asset_extraction_options(
|
||
model: Option<&str>,
|
||
aspect_ratio: &str,
|
||
image_size: &str,
|
||
) -> Result<EditorGenerationOptions, AppError> {
|
||
if aspect_ratio.trim() != "1:1" {
|
||
return Err(editor_ui_design_asset_extraction_bad_request(
|
||
"aspectRatio 必须固定为 1:1。",
|
||
));
|
||
}
|
||
let normalized_image_size = match image_size.trim().to_ascii_uppercase().as_str() {
|
||
"1K" => "1K",
|
||
"2K" => "2K",
|
||
_ => {
|
||
return Err(editor_ui_design_asset_extraction_bad_request(
|
||
"imageSize 仅支持 1K 或 2K。",
|
||
));
|
||
}
|
||
};
|
||
let model = model
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or(EDITOR_IMAGE_MODEL_NANOBANANA2);
|
||
Ok(normalize_editor_generation_options(
|
||
Some(model),
|
||
Some("1:1"),
|
||
Some(normalized_image_size),
|
||
))
|
||
}
|
||
|
||
async fn resolve_editor_ui_design_asset_extraction_price(
|
||
state: &AppState,
|
||
model: Option<&str>,
|
||
image_size: Option<&str>,
|
||
) -> Result<u32, AppError> {
|
||
let expected_price_mud_points = state
|
||
.editor_generation_pricing()
|
||
.await
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "editor-generation-pricing",
|
||
"message": error.to_string(),
|
||
}))
|
||
})?
|
||
.image_generation_mud_points(Some("icon"), model, image_size);
|
||
Ok(expected_price_mud_points)
|
||
}
|
||
|
||
fn build_editor_icon_spritesheet_prompt(
|
||
icon_descriptions: &[String],
|
||
screen_color: EditorScreenBackgroundColor,
|
||
) -> String {
|
||
format!(
|
||
"参考图1的图标素材规范,{};禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}",
|
||
editor_green_screen_asset_prompt_clause(screen_color),
|
||
icon_descriptions.join("、")
|
||
)
|
||
}
|
||
|
||
fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool) -> String {
|
||
let mut prompt = vec![
|
||
"生成玩法UI原型图".to_string(),
|
||
format!("【用户输入】{}", user_input.trim()),
|
||
];
|
||
if has_icon_spec_reference {
|
||
prompt.push("参考图1为图标素材规范,请在UI图标、按钮符号、描边、材质、圆角、阴影和状态层级上严格遵循参考图1的素材规范。".to_string());
|
||
}
|
||
prompt.join("\n")
|
||
}
|
||
|
||
fn build_editor_character_image_prompt(
|
||
role_setting: &str,
|
||
screen_color: EditorScreenBackgroundColor,
|
||
) -> String {
|
||
vec![
|
||
format!("按照角色描述生成游戏角色立绘。严格基于图1的角色美术视觉规范的美术风格、角色头身比、角色朝向等特征。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。{};禁止生成美术视觉规范,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。", editor_green_screen_character_prompt_clause(screen_color)),
|
||
format!("角色设定:{}", role_setting.trim()),
|
||
]
|
||
.join("\n")
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
struct EditorGenerationOptions {
|
||
model: &'static str,
|
||
aspect_ratio: &'static str,
|
||
image_size: &'static str,
|
||
// 中文注释:size 是模型无关的画布交付像素;provider_size 只用于满足 GPT 图片接口的合法像素矩阵。
|
||
size: String,
|
||
provider_size: String,
|
||
provider_image_size: &'static str,
|
||
}
|
||
|
||
pub(crate) struct PersistedEditorGeneratedImage {
|
||
pub(crate) object_key: String,
|
||
pub(crate) asset_object_id: String,
|
||
}
|
||
|
||
struct PersistEditorProviderSourceResourceInput {
|
||
project_id: Option<String>,
|
||
owner_user_id: String,
|
||
folder_id: Option<String>,
|
||
label: String,
|
||
width: u32,
|
||
height: u32,
|
||
prompt: String,
|
||
actual_prompt: Option<String>,
|
||
model: String,
|
||
task_id: String,
|
||
source_resource_id: Option<String>,
|
||
asset_kind: Option<String>,
|
||
generation_inputs: Option<Value>,
|
||
generation_cost_mud_points: u64,
|
||
}
|
||
|
||
pub(crate) async fn persist_editor_generated_image(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
task_id: &str,
|
||
image: &DownloadedOpenAiImage,
|
||
prompt: &str,
|
||
actual_prompt: Option<&str>,
|
||
asset_kind: &str,
|
||
path_kind: &str,
|
||
file_stem: &str,
|
||
slot: &str,
|
||
provider: &str,
|
||
) -> Result<PersistedEditorGeneratedImage, AppError> {
|
||
persist_editor_generated_image_data(
|
||
state,
|
||
owner_user_id,
|
||
task_id,
|
||
GeneratedImageAssetDataUrl {
|
||
format: normalize_generated_image_asset_mime(image.mime_type.as_str()),
|
||
bytes: image.bytes.clone(),
|
||
},
|
||
prompt,
|
||
actual_prompt,
|
||
asset_kind,
|
||
path_kind,
|
||
file_stem,
|
||
slot,
|
||
provider,
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn persist_editor_generated_image_owned(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
task_id: &str,
|
||
image: DownloadedOpenAiImage,
|
||
prompt: &str,
|
||
actual_prompt: Option<&str>,
|
||
asset_kind: &str,
|
||
path_kind: &str,
|
||
file_stem: &str,
|
||
slot: &str,
|
||
provider: &str,
|
||
) -> Result<PersistedEditorGeneratedImage, AppError> {
|
||
let image_data = GeneratedImageAssetDataUrl {
|
||
format: normalize_generated_image_asset_mime(image.mime_type.as_str()),
|
||
bytes: image.bytes,
|
||
};
|
||
persist_editor_generated_image_data(
|
||
state,
|
||
owner_user_id,
|
||
task_id,
|
||
image_data,
|
||
prompt,
|
||
actual_prompt,
|
||
asset_kind,
|
||
path_kind,
|
||
file_stem,
|
||
slot,
|
||
provider,
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn persist_editor_generated_image_data(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
task_id: &str,
|
||
image: GeneratedImageAssetDataUrl,
|
||
prompt: &str,
|
||
actual_prompt: Option<&str>,
|
||
asset_kind: &str,
|
||
path_kind: &str,
|
||
file_stem: &str,
|
||
slot: &str,
|
||
provider: &str,
|
||
) -> Result<PersistedEditorGeneratedImage, AppError> {
|
||
let oss_client = state.oss_client().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"reason": "OSS 未完成环境变量配置",
|
||
}))
|
||
})?;
|
||
let prepared =
|
||
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||
prefix: LegacyAssetPrefix::CharacterDrafts,
|
||
path_segments: vec![
|
||
"editor".to_string(),
|
||
sanitize_editor_storage_segment(path_kind, "generated-images"),
|
||
sanitize_editor_storage_segment(task_id, "task"),
|
||
],
|
||
file_stem: sanitize_editor_storage_segment(file_stem, "image"),
|
||
image,
|
||
access: OssObjectAccess::Private,
|
||
metadata: GeneratedImageAssetAdapterMetadata {
|
||
asset_kind: Some(asset_kind.to_string()),
|
||
owner_user_id: Some(owner_user_id.to_string()),
|
||
entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()),
|
||
entity_id: Some(task_id.to_string()),
|
||
slot: Some(slot.to_string()),
|
||
provider: Some(provider.to_string()),
|
||
task_id: Some(task_id.to_string()),
|
||
},
|
||
extra_metadata: BTreeMap::from([(
|
||
"source".to_string(),
|
||
"image-canvas-editor".to_string(),
|
||
)]),
|
||
})
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "generated-image-assets",
|
||
"message": format!("准备画板生成图片 OSS 上传请求失败:{error:?}"),
|
||
}))
|
||
})?;
|
||
let persisted_mime_type = prepared.format.mime_type.clone();
|
||
let http_client = reqwest::Client::new();
|
||
let put_result = oss_client
|
||
.put_object(&http_client, prepared.request)
|
||
.await
|
||
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
|
||
let head = oss_client
|
||
.head_object(
|
||
&http_client,
|
||
OssHeadObjectRequest {
|
||
object_key: put_result.object_key.clone(),
|
||
},
|
||
)
|
||
.await
|
||
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
|
||
let now_micros = current_utc_micros();
|
||
let asset_object = state
|
||
.spacetime_client()
|
||
.confirm_asset_object(
|
||
build_asset_object_upsert_input(
|
||
generate_asset_object_id(now_micros),
|
||
head.bucket,
|
||
head.object_key.clone(),
|
||
AssetObjectAccessPolicy::Private,
|
||
head.content_type.or(Some(persisted_mime_type)),
|
||
head.content_length,
|
||
Some(actual_prompt.unwrap_or(prompt).to_string()),
|
||
asset_kind.to_string(),
|
||
Some(task_id.to_string()),
|
||
Some(owner_user_id.to_string()),
|
||
None,
|
||
Some(task_id.to_string()),
|
||
now_micros,
|
||
)
|
||
.map_err(map_editor_asset_field_error)?,
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
}))
|
||
})?;
|
||
|
||
Ok(PersistedEditorGeneratedImage {
|
||
object_key: head.object_key,
|
||
asset_object_id: asset_object.asset_object_id,
|
||
})
|
||
}
|
||
|
||
async fn persist_editor_provider_source_image(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
task_id: &str,
|
||
image: DownloadedOpenAiImage,
|
||
prompt: &str,
|
||
actual_prompt: Option<&str>,
|
||
asset_kind: &str,
|
||
path_kind: &str,
|
||
file_stem: &str,
|
||
) -> Result<PersistedEditorGeneratedImage, AppError> {
|
||
persist_editor_generated_image_owned(
|
||
state,
|
||
owner_user_id,
|
||
task_id,
|
||
image,
|
||
prompt,
|
||
actual_prompt,
|
||
asset_kind,
|
||
path_kind,
|
||
&format!("{file_stem}-provider-source"),
|
||
EDITOR_PROVIDER_SOURCE_SLOT,
|
||
"vector-engine",
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn persist_editor_provider_source_resource(
|
||
state: &AppState,
|
||
persisted: PersistedEditorGeneratedImage,
|
||
input: PersistEditorProviderSourceResourceInput,
|
||
) -> Result<EditorGeneratedAssetRecord, AppError> {
|
||
persist_editor_generated_asset(
|
||
state,
|
||
PersistEditorGeneratedAssetInput {
|
||
project_id: input.project_id,
|
||
owner_user_id: input.owner_user_id,
|
||
folder_id: input.folder_id,
|
||
label: input.label,
|
||
image_src: editor_media_src_from_object_key(persisted.object_key.as_str()),
|
||
object_key: Some(persisted.object_key),
|
||
asset_object_id: Some(persisted.asset_object_id),
|
||
width: input.width,
|
||
height: input.height,
|
||
prompt: input.prompt,
|
||
actual_prompt: input.actual_prompt,
|
||
model: input.model,
|
||
provider: "VectorEngine".to_string(),
|
||
task_id: input.task_id,
|
||
group_task_id: None,
|
||
group_task_expected_asset_count: None,
|
||
source_resource_id: input.source_resource_id,
|
||
asset_kind: input.asset_kind,
|
||
generation_inputs: input.generation_inputs,
|
||
thumbnail_src: None,
|
||
generation_cost_mud_points: input.generation_cost_mud_points,
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS: u64 = 300;
|
||
const EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES: u64 = 32 * 1024 * 1024;
|
||
const EDITOR_BACKGROUND_REMOVAL_SOURCE_PROBE_BYTES: u64 = 16;
|
||
|
||
fn is_editor_inline_media_source(source: &str) -> bool {
|
||
source
|
||
.trim_start()
|
||
.as_bytes()
|
||
.get(..5)
|
||
.is_some_and(|prefix| {
|
||
prefix.eq_ignore_ascii_case(b"data:") || prefix.eq_ignore_ascii_case(b"blob:")
|
||
})
|
||
}
|
||
|
||
fn ensure_editor_reference_image_source_is_stable(
|
||
source: &str,
|
||
provider: &str,
|
||
field: &str,
|
||
label: &str,
|
||
) -> Result<(), AppError> {
|
||
if !is_editor_inline_media_source(source) {
|
||
return Ok(());
|
||
}
|
||
Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": provider,
|
||
"field": field,
|
||
"message": format!("{label}必须先上传 OSS,并使用 objectKey、项目资源 ID 或素材 ID 引用。"),
|
||
})),
|
||
)
|
||
}
|
||
|
||
fn ensure_editor_reference_image_sources_are_stable(
|
||
sources: Option<&[String]>,
|
||
provider: &str,
|
||
field: &str,
|
||
label: &str,
|
||
) -> Result<(), AppError> {
|
||
for source in sources.unwrap_or(&[]) {
|
||
ensure_editor_reference_image_source_is_stable(source, provider, field, label)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) async fn parse_editor_reference_image(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
source: &str,
|
||
) -> Result<OpenAiReferenceImage, AppError> {
|
||
let object_key =
|
||
resolve_editor_reference_object_key_for_owner(state, owner_user_id, source).await?;
|
||
read_editor_reference_image_object(state, object_key.as_str()).await
|
||
}
|
||
|
||
pub(crate) async fn resolve_editor_reference_object_key_for_owner(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
source: &str,
|
||
) -> Result<String, AppError> {
|
||
let object_key = resolve_editor_reference_object_key(state, owner_user_id, source).await?;
|
||
ensure_editor_reference_object_key_owned(state, owner_user_id, object_key.as_str()).await?;
|
||
Ok(object_key)
|
||
}
|
||
|
||
fn normalize_editor_reference_image_sources(sources: Option<&[String]>, limit: usize) -> Vec<&str> {
|
||
sources
|
||
.unwrap_or(&[])
|
||
.iter()
|
||
.map(String::as_str)
|
||
.map(str::trim)
|
||
.filter(|source| !source.is_empty())
|
||
.take(limit)
|
||
.collect()
|
||
}
|
||
|
||
pub(crate) fn normalize_editor_reference_object_key(source: &str) -> Result<String, AppError> {
|
||
let object_key = source.trim().trim_start_matches('/').to_string();
|
||
if object_key.is_empty() || LegacyAssetPrefix::from_object_key(object_key.as_str()).is_none() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-reference-image",
|
||
"field": "sourceImageSrc",
|
||
"message": "图片参考图必须是已登记的 generated objectKey、项目资源或素材引用。",
|
||
})),
|
||
);
|
||
}
|
||
Ok(object_key)
|
||
}
|
||
|
||
async fn resolve_editor_reference_object_key(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
source: &str,
|
||
) -> Result<String, AppError> {
|
||
ensure_editor_reference_image_source_is_stable(
|
||
source,
|
||
"editor-reference-image",
|
||
"sourceImageSrc",
|
||
"图片参考图",
|
||
)?;
|
||
match normalize_editor_reference_object_key(source) {
|
||
Ok(object_key) => Ok(object_key),
|
||
Err(error) => {
|
||
find_editor_reference_object_key_by_registered_id(state, owner_user_id, source.trim())
|
||
.await?
|
||
.ok_or(error)
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn find_editor_reference_object_key_by_registered_id(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
source: &str,
|
||
) -> Result<Option<String>, AppError> {
|
||
if source.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
let projects = state
|
||
.spacetime_client()
|
||
.list_editor_projects(owner_user_id.to_string())
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
if let Some(object_key) = projects
|
||
.iter()
|
||
.flat_map(|project| project.resources.iter())
|
||
.find_map(|resource| editor_reference_resource_object_key(resource, source))
|
||
{
|
||
return Ok(Some(object_key));
|
||
}
|
||
let library = state
|
||
.spacetime_client()
|
||
.get_editor_asset_library(owner_user_id.to_string(), current_utc_micros())
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
Ok(library
|
||
.assets
|
||
.iter()
|
||
.find_map(|asset| editor_reference_asset_object_key(asset, source)))
|
||
}
|
||
|
||
fn editor_reference_resource_object_key(
|
||
resource: &EditorProjectResourceRecord,
|
||
source: &str,
|
||
) -> Option<String> {
|
||
(resource.resource_id.trim() == source)
|
||
.then(|| {
|
||
normalize_editor_record_object_key(
|
||
resource.object_key.as_deref(),
|
||
resource.image_src.as_str(),
|
||
)
|
||
})
|
||
.flatten()
|
||
}
|
||
|
||
fn editor_reference_asset_object_key(asset: &EditorAssetRecord, source: &str) -> Option<String> {
|
||
(asset.asset_id.trim() == source)
|
||
.then(|| {
|
||
normalize_editor_record_object_key(
|
||
asset.object_key.as_deref(),
|
||
asset.image_src.as_str(),
|
||
)
|
||
})
|
||
.flatten()
|
||
}
|
||
|
||
fn normalize_editor_record_object_key(object_key: Option<&str>, image_src: &str) -> Option<String> {
|
||
object_key
|
||
.map(str::trim)
|
||
.map(|value| value.trim_start_matches('/'))
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
.or_else(|| normalize_editor_reference_object_key(image_src).ok())
|
||
}
|
||
|
||
async fn ensure_editor_reference_object_key_owned(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
object_key: &str,
|
||
) -> Result<(), AppError> {
|
||
let owner_user_id = owner_user_id.trim();
|
||
if owner_user_id.is_empty() {
|
||
return Err(editor_reference_image_forbidden(object_key));
|
||
}
|
||
let projects = state
|
||
.spacetime_client()
|
||
.list_editor_projects(owner_user_id.to_string())
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
if projects.iter().any(|project| {
|
||
project.resources.iter().any(|resource| {
|
||
editor_record_object_key_matches(
|
||
resource.object_key.as_deref(),
|
||
resource.image_src.as_str(),
|
||
object_key,
|
||
)
|
||
})
|
||
}) {
|
||
return Ok(());
|
||
}
|
||
let library = state
|
||
.spacetime_client()
|
||
.get_editor_asset_library(owner_user_id.to_string(), current_utc_micros())
|
||
.await
|
||
.map_err(map_editor_project_error)?;
|
||
if library.assets.iter().any(|asset| {
|
||
editor_record_object_key_matches(
|
||
asset.object_key.as_deref(),
|
||
asset.image_src.as_str(),
|
||
object_key,
|
||
)
|
||
}) {
|
||
return Ok(());
|
||
}
|
||
let Some(oss_client) = state.oss_client() else {
|
||
return Err(editor_reference_image_forbidden(object_key));
|
||
};
|
||
if let Some(asset_object) = state
|
||
.spacetime_client()
|
||
.get_asset_object_by_location(module_assets::AssetObjectLocationInput {
|
||
bucket: oss_client.config_bucket().to_string(),
|
||
object_key: object_key.to_string(),
|
||
})
|
||
.await
|
||
.map_err(map_editor_project_error)?
|
||
{
|
||
validate_editor_reference_asset_object(&asset_object, owner_user_id, state, object_key)?;
|
||
return Ok(());
|
||
}
|
||
Err(editor_reference_image_forbidden(object_key))
|
||
}
|
||
|
||
fn validate_editor_reference_asset_object(
|
||
asset_object: &module_assets::AssetObjectRecord,
|
||
owner_user_id: &str,
|
||
state: &AppState,
|
||
object_key: &str,
|
||
) -> Result<(), AppError> {
|
||
let Some(oss_client) = state.oss_client() else {
|
||
return Err(editor_reference_image_forbidden(object_key));
|
||
};
|
||
let owned = asset_object
|
||
.owner_user_id
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.is_some_and(|value| value == owner_user_id);
|
||
let bucket_matches = asset_object.bucket.trim() == oss_client.config_bucket().trim();
|
||
let key_matches = asset_object.object_key.trim().trim_start_matches('/') == object_key;
|
||
if owned && bucket_matches && key_matches {
|
||
Ok(())
|
||
} else {
|
||
Err(editor_reference_image_forbidden(object_key))
|
||
}
|
||
}
|
||
|
||
fn editor_record_object_key_matches(
|
||
record_object_key: Option<&str>,
|
||
image_src: &str,
|
||
object_key: &str,
|
||
) -> bool {
|
||
normalize_editor_record_object_key(record_object_key, image_src)
|
||
.is_some_and(|value| value == object_key)
|
||
}
|
||
|
||
fn editor_reference_image_forbidden(object_key: &str) -> AppError {
|
||
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
|
||
"provider": "editor-reference-image",
|
||
"field": "sourceImageSrc",
|
||
"message": "图片参考图不属于当前账号。",
|
||
"objectKey": object_key,
|
||
}))
|
||
}
|
||
|
||
async fn read_editor_reference_image_object(
|
||
state: &AppState,
|
||
source: &str,
|
||
) -> Result<OpenAiReferenceImage, AppError> {
|
||
if source.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-reference-image",
|
||
"field": "sourceImageSrc",
|
||
"message": "图片参考图不能为空。",
|
||
})),
|
||
);
|
||
}
|
||
let oss_client = state.oss_client().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"reason": "OSS 未完成环境变量配置",
|
||
}))
|
||
})?;
|
||
let signed = oss_client
|
||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||
object_key: source.to_string(),
|
||
expire_seconds: Some(EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS),
|
||
})
|
||
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
|
||
let mut response = reqwest::Client::new()
|
||
.get(signed.signed_url.as_str())
|
||
.send()
|
||
.await
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": format!("读取图片参考图失败:{error}"),
|
||
}))
|
||
})?;
|
||
let status = response.status();
|
||
if !status.is_success() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": format!("读取图片参考图返回非成功状态:{}", status.as_u16()),
|
||
"objectKey": signed.object_key,
|
||
})),
|
||
);
|
||
}
|
||
if response
|
||
.content_length()
|
||
.is_some_and(|size| size > EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES)
|
||
{
|
||
return Err(editor_reference_image_too_large());
|
||
}
|
||
let content_type_mime = response
|
||
.headers()
|
||
.get(reqwest::header::CONTENT_TYPE)
|
||
.and_then(|value| value.to_str().ok())
|
||
.and_then(normalize_editor_reference_image_mime_type)
|
||
.map(ToOwned::to_owned);
|
||
let mut bytes = Vec::new();
|
||
while let Some(chunk) = response.chunk().await.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": format!("读取图片参考图内容失败:{error}"),
|
||
}))
|
||
})? {
|
||
if bytes.len().saturating_add(chunk.len()) as u64 > EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES {
|
||
return Err(editor_reference_image_too_large());
|
||
}
|
||
bytes.extend_from_slice(chunk.as_ref());
|
||
}
|
||
if bytes.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-reference-image",
|
||
"field": "sourceImageSrc",
|
||
"message": "图片参考图为空。",
|
||
})),
|
||
);
|
||
}
|
||
let mime_type = content_type_mime
|
||
.or_else(|| infer_editor_reference_image_mime_type(bytes.as_slice()).map(ToOwned::to_owned))
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "editor-reference-image",
|
||
"field": "sourceImageSrc",
|
||
"message": "图片参考图 objectKey 指向的资源不是图片。",
|
||
"objectKey": signed.object_key,
|
||
}))
|
||
})?
|
||
.to_string();
|
||
let extension = editor_reference_image_extension(mime_type.as_str());
|
||
Ok(OpenAiReferenceImage {
|
||
bytes,
|
||
mime_type,
|
||
file_name: format!("editor-reference.{extension}"),
|
||
})
|
||
}
|
||
|
||
async fn download_editor_persisted_image_object(
|
||
state: &AppState,
|
||
object_key: &str,
|
||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||
let image = read_editor_reference_image_object(state, object_key).await?;
|
||
Ok(DownloadedOpenAiImage {
|
||
extension: editor_reference_image_extension(image.mime_type.as_str()).to_string(),
|
||
mime_type: image.mime_type,
|
||
bytes: image.bytes,
|
||
})
|
||
}
|
||
|
||
fn normalize_editor_reference_image_mime_type(content_type: &str) -> Option<&str> {
|
||
let mime_type = content_type.split(';').next()?.trim();
|
||
mime_type.starts_with("image/").then_some(mime_type)
|
||
}
|
||
|
||
fn infer_editor_reference_image_mime_type(bytes: &[u8]) -> Option<&'static str> {
|
||
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||
return Some("image/png");
|
||
}
|
||
if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
|
||
return Some("image/jpeg");
|
||
}
|
||
if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
|
||
return Some("image/webp");
|
||
}
|
||
None
|
||
}
|
||
|
||
fn editor_reference_image_extension(mime_type: &str) -> &'static str {
|
||
match mime_type {
|
||
"image/jpeg" => "jpg",
|
||
"image/png" => "png",
|
||
"image/webp" => "webp",
|
||
_ => "png",
|
||
}
|
||
}
|
||
|
||
fn editor_reference_image_too_large() -> AppError {
|
||
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({
|
||
"provider": "editor-reference-image",
|
||
"message": "图片参考图不能超过 32MB。",
|
||
}))
|
||
}
|
||
|
||
pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
|
||
match error {
|
||
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
|
||
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": message,
|
||
}))
|
||
}
|
||
SpacetimeClientError::Procedure(message) if message.contains("不存在") => {
|
||
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": message,
|
||
}))
|
||
}
|
||
SpacetimeClientError::Procedure(message) if message.contains("版本冲突") => {
|
||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": message,
|
||
}))
|
||
}
|
||
SpacetimeClientError::Procedure(message) => AppError::from_status(StatusCode::BAD_REQUEST)
|
||
.with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": message,
|
||
})),
|
||
SpacetimeClientError::Runtime(message) => AppError::from_status(StatusCode::BAD_REQUEST)
|
||
.with_details(json!({
|
||
"provider": "editor-project",
|
||
"message": message,
|
||
})),
|
||
other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": other.to_string(),
|
||
})),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "asset-object",
|
||
"message": error.to_string(),
|
||
}))
|
||
}
|
||
|
||
pub(crate) fn current_utc_micros() -> i64 {
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
||
let duration = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.expect("system clock should be after unix epoch");
|
||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::{config::AppConfig, editor_green_screen::parse_editor_screen_background_color};
|
||
use std::{
|
||
io::{Read as _, Write as _},
|
||
net::{TcpListener, TcpStream},
|
||
sync::mpsc,
|
||
thread,
|
||
};
|
||
|
||
fn encode_test_png(width: u32, height: u32) -> Vec<u8> {
|
||
let image = image::DynamicImage::new_rgba8(width, height);
|
||
let mut bytes = Cursor::new(Vec::new());
|
||
image
|
||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||
.expect("test PNG should encode");
|
||
bytes.into_inner()
|
||
}
|
||
|
||
fn read_mock_http_request(stream: &mut TcpStream) -> Vec<u8> {
|
||
stream
|
||
.set_read_timeout(Some(Duration::from_secs(1)))
|
||
.expect("mock request read timeout should be set");
|
||
let mut request = Vec::new();
|
||
let mut chunk = [0_u8; 1024];
|
||
let mut expected_total = None;
|
||
|
||
loop {
|
||
match stream.read(&mut chunk) {
|
||
Ok(0) => break,
|
||
Ok(bytes_read) => {
|
||
request.extend_from_slice(&chunk[..bytes_read]);
|
||
if expected_total.is_none()
|
||
&& let Some(header_end) = request
|
||
.windows(4)
|
||
.position(|window| window == b"\r\n\r\n")
|
||
.map(|index| index + 4)
|
||
{
|
||
let headers = String::from_utf8_lossy(&request[..header_end]);
|
||
let content_length = headers
|
||
.lines()
|
||
.find_map(|line| {
|
||
let (name, value) = line.split_once(':')?;
|
||
name.eq_ignore_ascii_case("content-length")
|
||
.then(|| value.trim().parse::<usize>().ok())
|
||
.flatten()
|
||
})
|
||
.unwrap_or(0);
|
||
expected_total = Some(header_end + content_length);
|
||
}
|
||
if expected_total.is_some_and(|total| request.len() >= total) {
|
||
break;
|
||
}
|
||
}
|
||
Err(error)
|
||
if error.kind() == std::io::ErrorKind::WouldBlock
|
||
|| error.kind() == std::io::ErrorKind::TimedOut =>
|
||
{
|
||
break;
|
||
}
|
||
Err(error) => panic!("mock server failed to read request: {error}"),
|
||
}
|
||
}
|
||
|
||
request
|
||
}
|
||
|
||
fn parse_mock_http_json_body(request: &[u8]) -> Value {
|
||
let body_start = request
|
||
.windows(4)
|
||
.position(|window| window == b"\r\n\r\n")
|
||
.map(|index| index + 4)
|
||
.expect("mock request should contain an HTTP header terminator");
|
||
serde_json::from_slice(&request[body_start..])
|
||
.expect("mock request body should be valid JSON")
|
||
}
|
||
|
||
fn spawn_bgfilter_worker_png_mock(
|
||
response_png: Vec<u8>,
|
||
) -> (String, mpsc::Receiver<Vec<u8>>, thread::JoinHandle<()>) {
|
||
spawn_bgfilter_worker_png_mock_with_delay(response_png, Duration::ZERO)
|
||
}
|
||
|
||
fn spawn_bgfilter_worker_png_mock_with_delay(
|
||
response_png: Vec<u8>,
|
||
response_delay: Duration,
|
||
) -> (String, mpsc::Receiver<Vec<u8>>, thread::JoinHandle<()>) {
|
||
let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener should bind");
|
||
let address = listener
|
||
.local_addr()
|
||
.expect("mock listener should expose address");
|
||
let (request_sender, request_receiver) = mpsc::channel();
|
||
let server = thread::spawn(move || {
|
||
let (mut stream, _) = listener
|
||
.accept()
|
||
.expect("BgFilter worker request should connect");
|
||
let request = read_mock_http_request(&mut stream);
|
||
request_sender
|
||
.send(request)
|
||
.expect("captured request should be delivered");
|
||
thread::sleep(response_delay);
|
||
let headers = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||
response_png.len()
|
||
);
|
||
stream
|
||
.write_all(headers.as_bytes())
|
||
.expect("mock response headers should be written");
|
||
stream
|
||
.write_all(response_png.as_slice())
|
||
.expect("mock response PNG should be written");
|
||
stream.flush().expect("mock response should flush");
|
||
});
|
||
|
||
(format!("http://{address}"), request_receiver, server)
|
||
}
|
||
|
||
fn editor_project_resource_for_canvas_test(
|
||
resource_id: &str,
|
||
asset_kind: &str,
|
||
width: u32,
|
||
height: u32,
|
||
) -> EditorProjectResourcePayload {
|
||
EditorProjectResourcePayload {
|
||
resource_id: resource_id.to_string(),
|
||
showcase_id: None,
|
||
asset_id: None,
|
||
label: None,
|
||
project_id: "editor-project-1".to_string(),
|
||
owner_user_id: "user-1".to_string(),
|
||
author_display_name: None,
|
||
author_public_user_code: None,
|
||
image_src: format!("/generated-character-drafts/editor/{resource_id}.png"),
|
||
object_key: Some(format!(
|
||
"generated-character-drafts/editor/{resource_id}.png"
|
||
)),
|
||
asset_object_id: Some(format!("asset-object-{resource_id}")),
|
||
width,
|
||
height,
|
||
source_type: "generated".to_string(),
|
||
prompt: Some("银发游侠".to_string()),
|
||
actual_prompt: Some("实际提示词".to_string()),
|
||
model: Some("gpt-image-2".to_string()),
|
||
provider: Some("VectorEngine".to_string()),
|
||
task_id: Some("task-character".to_string()),
|
||
source_resource_id: None,
|
||
asset_kind: Some(asset_kind.to_string()),
|
||
showcase_category: None,
|
||
generation_inputs: None,
|
||
public_showcase_enabled: true,
|
||
review_status: None,
|
||
display_enabled: None,
|
||
like_count: None,
|
||
generation_cost_mud_points: 0,
|
||
refund_mud_points: None,
|
||
created_at: "2026-07-13T00:00:00.000Z".to_string(),
|
||
updated_at: "2026-07-13T00:00:00.000Z".to_string(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn editor_project_procedure_errors_preserve_business_reason_as_bad_request() {
|
||
let error = map_editor_project_error(SpacetimeClientError::Procedure(
|
||
"只有生成素材可以提交精选审核".to_string(),
|
||
));
|
||
|
||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||
assert_eq!(
|
||
error.details().and_then(|details| details.get("message")),
|
||
Some(&json!("只有生成素材可以提交精选审核")),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_project_revision_conflicts_map_to_http_conflict() {
|
||
let error = map_editor_project_error(SpacetimeClientError::Procedure(
|
||
"图片画布版本冲突:期望 revision 4,当前为 5".to_string(),
|
||
));
|
||
|
||
assert_eq!(error.status_code(), StatusCode::CONFLICT);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_project_layout_save_request_requires_expected_revision() {
|
||
let missing_revision = serde_json::from_value::<EditorProjectLayoutSaveRequest>(json!({
|
||
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
|
||
"layers": [],
|
||
}))
|
||
.expect_err("现役画布保存缺少 expectedRevision 时必须在进入写路径前失败");
|
||
assert!(missing_revision.to_string().contains("expectedRevision"));
|
||
|
||
let request = serde_json::from_value::<EditorProjectLayoutSaveRequest>(json!({
|
||
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
|
||
"layers": [],
|
||
"expectedRevision": 7,
|
||
}))
|
||
.expect("携带 expectedRevision 的画布保存请求应通过反序列化");
|
||
assert_eq!(request.expected_revision, 7);
|
||
}
|
||
|
||
fn manual_screen_background_decision(hex: &str) -> EditorScreenBackgroundDecision {
|
||
EditorScreenBackgroundDecision {
|
||
color: parse_editor_screen_background_color(Some(hex))
|
||
.expect("hex should be a known background color candidate"),
|
||
mode:
|
||
crate::editor_screen_background_decision::EditorScreenBackgroundDecisionMode::Manual,
|
||
attempts: 0,
|
||
fallback: false,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn screen_background_decision_records_hex_without_touching_visible_fields() {
|
||
let decision = manual_screen_background_decision("#B0C2E0");
|
||
|
||
// 用户可见的 fields 不被注入抠图背景色,只写内部 screenColorHex。
|
||
let updated = apply_editor_screen_background_decision_to_generation_inputs(
|
||
Some(json!({
|
||
"fields": [{ "title": "角色设定", "value": "红发骑士" }],
|
||
"references": [],
|
||
})),
|
||
Some(&decision),
|
||
)
|
||
.expect("generation inputs should be preserved");
|
||
assert_eq!(updated["screenColorHex"], json!("#B0C2E0"));
|
||
assert_eq!(
|
||
updated["fields"],
|
||
json!([{ "title": "角色设定", "value": "红发骑士" }]),
|
||
);
|
||
|
||
// 无 generation_inputs 时创建骨架并写入内部元数据,fields 保持为空。
|
||
let created =
|
||
apply_editor_screen_background_decision_to_generation_inputs(None, Some(&decision))
|
||
.expect("missing generation inputs should still record the decision");
|
||
assert_eq!(
|
||
created,
|
||
json!({
|
||
"fields": [],
|
||
"references": [],
|
||
"screenColorHex": "#B0C2E0",
|
||
}),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn screen_background_decision_leaves_existing_visible_fields_untouched() {
|
||
let decision = manual_screen_background_decision("#CFEFFF");
|
||
|
||
// 即使旧数据的 fields 里残留了抠图背景色,后端也不修改用户可见字段。
|
||
let updated = apply_editor_screen_background_decision_to_generation_inputs(
|
||
Some(json!({
|
||
"fields": [{ "title": "抠图背景色", "value": "旧值" }],
|
||
"references": [],
|
||
})),
|
||
Some(&decision),
|
||
)
|
||
.expect("generation inputs should be preserved");
|
||
assert_eq!(
|
||
updated["fields"],
|
||
json!([{ "title": "抠图背景色", "value": "旧值" }]),
|
||
);
|
||
assert_eq!(updated["screenColorHex"], json!("#CFEFFF"));
|
||
}
|
||
|
||
fn apply_editor_canvas_generation_completion(
|
||
layers: Value,
|
||
completion: &EditorCanvasGenerationCompletionRequest,
|
||
resource: &EditorProjectResourcePayload,
|
||
) -> Result<EditorCanvasGenerationCompletionResult, AppError> {
|
||
let placeholder = resolve_canvas_completion_placeholder(layers.clone(), completion)?
|
||
.unwrap_or_else(|| completion.placeholder.clone());
|
||
let layer_id = generated_canvas_layer_id(resource.resource_id.as_str());
|
||
let item = build_generated_canvas_layer_item(
|
||
completion,
|
||
&placeholder,
|
||
resource,
|
||
layer_id.as_str(),
|
||
0,
|
||
);
|
||
apply_editor_canvas_generation_items(
|
||
layers,
|
||
completion,
|
||
placeholder,
|
||
vec![item],
|
||
Some(layer_id),
|
||
)
|
||
}
|
||
|
||
fn apply_editor_canvas_generation_multi_items(
|
||
layers: Value,
|
||
completion: &EditorCanvasGenerationCompletionRequest,
|
||
items: Vec<Value>,
|
||
primary_layer_id: Option<String>,
|
||
) -> Result<EditorCanvasGenerationCompletionResult, AppError> {
|
||
let placeholder = resolve_canvas_completion_placeholder(layers.clone(), completion)?
|
||
.unwrap_or_else(|| completion.placeholder.clone());
|
||
let items =
|
||
translate_editor_canvas_generation_items(items, &completion.placeholder, &placeholder);
|
||
apply_editor_canvas_generation_items(
|
||
layers,
|
||
completion,
|
||
placeholder,
|
||
items,
|
||
primary_layer_id,
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn editor_project_id_prefix_uses_proj_for_new_ids() {
|
||
assert_eq!(EDITOR_PROJECT_ID_PREFIX, "proj-");
|
||
}
|
||
|
||
#[test]
|
||
fn editor_record_media_src_prefers_object_key_over_legacy_inline_media() {
|
||
assert_eq!(
|
||
normalize_editor_record_media_src(
|
||
"data:image/png;base64,too-large".to_string(),
|
||
Some("/generated-character-drafts/editor/character.png")
|
||
),
|
||
"/generated-character-drafts/editor/character.png"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_record_media_src_drops_legacy_inline_media_without_object_key() {
|
||
assert_eq!(
|
||
normalize_editor_record_media_src("data:image/png;base64,too-large".to_string(), None),
|
||
""
|
||
);
|
||
assert_eq!(
|
||
normalize_editor_record_media_src("blob:http://localhost/preview".to_string(), None),
|
||
""
|
||
);
|
||
}
|
||
|
||
fn test_editor_showcase_asset_record(owner_user_id: &str) -> EditorShowcaseAssetRecord {
|
||
EditorShowcaseAssetRecord {
|
||
showcase_id: "showcase-1".to_string(),
|
||
asset_id: "asset-1".to_string(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
label: "精选素材".to_string(),
|
||
asset_object_id: None,
|
||
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
|
||
object_key: None,
|
||
width: 512,
|
||
height: 512,
|
||
source_type: "generated".to_string(),
|
||
prompt: None,
|
||
actual_prompt: None,
|
||
model: None,
|
||
provider: None,
|
||
task_id: None,
|
||
source_resource_id: None,
|
||
asset_kind: None,
|
||
generation_inputs: None,
|
||
thumbnail_src: None,
|
||
generation_cost_mud_points: 10,
|
||
refund_mud_points: 5,
|
||
review_status: "approved".to_string(),
|
||
display_enabled: true,
|
||
like_count: 0,
|
||
asset_deleted_while_pending: false,
|
||
reviewed_by_admin_user_id: Some("admin-1".to_string()),
|
||
review_note: None,
|
||
refund_ledger_id: Some("editor-showcase-refund:showcase-1".to_string()),
|
||
refund_completed_at: Some("2026-06-23T00:00:00.000Z".to_string()),
|
||
submitted_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
reviewed_at: Some("2026-06-23T00:00:00.000Z".to_string()),
|
||
approved_at: Some("2026-06-23T00:00:00.000Z".to_string()),
|
||
rejected_at: None,
|
||
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
showcase_category: Some("characters".to_string()),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn editor_project_resource_payload_includes_owner_user_id() {
|
||
let payload = editor_project_resource_payload_from_record(EditorProjectResourceRecord {
|
||
resource_id: "resource-1".to_string(),
|
||
project_id: "project-1".to_string(),
|
||
owner_user_id: "user-author-1".to_string(),
|
||
asset_object_id: None,
|
||
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
|
||
object_key: None,
|
||
width: 512,
|
||
height: 512,
|
||
source_type: "generated".to_string(),
|
||
prompt: None,
|
||
actual_prompt: None,
|
||
model: None,
|
||
provider: None,
|
||
task_id: None,
|
||
source_resource_id: None,
|
||
asset_kind: None,
|
||
generation_inputs: None,
|
||
public_showcase_enabled: true,
|
||
created_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
});
|
||
|
||
let value = serde_json::to_value(payload).expect("payload should serialize");
|
||
|
||
assert_eq!(value["ownerUserId"], json!("user-author-1"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_project_resource_showcase_cursor_uses_microsecond_timestamp() {
|
||
let record = EditorShowcaseAssetRecord {
|
||
showcase_id: "resource-1".to_string(),
|
||
approved_at: Some("1713686401.234567Z".to_string()),
|
||
updated_at: "1713686401.234567Z".to_string(),
|
||
..test_editor_showcase_asset_record("user-author-1")
|
||
};
|
||
|
||
assert_eq!(
|
||
editor_project_resource_showcase_cursor(&record).as_deref(),
|
||
Some("1713686401234567:resource-1")
|
||
);
|
||
assert_eq!(parse_editor_timestamp_micros("bad-time"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_project_resource_public_payload_uses_display_author_fields() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
state
|
||
.auth_user_service()
|
||
.ensure_orphan_work_owner_user("user-author-1", "author-1", "作者昵称", "SY-00000042")
|
||
.expect("author should be inserted");
|
||
|
||
let payload = editor_project_resource_public_payload_from_record(
|
||
&state,
|
||
test_editor_showcase_asset_record("user-author-1"),
|
||
);
|
||
|
||
let value = serde_json::to_value(payload).expect("payload should serialize");
|
||
|
||
assert_eq!(value["ownerUserId"], json!("user-author-1"));
|
||
assert_eq!(value["authorDisplayName"], json!("作者昵称"));
|
||
assert_eq!(value["authorPublicUserCode"], json!("SY-00000042"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_project_resource_public_payload_falls_back_to_public_user_code() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
let payload = editor_project_resource_public_payload_from_record(
|
||
&state,
|
||
test_editor_showcase_asset_record("missing-user"),
|
||
);
|
||
|
||
let value = serde_json::to_value(payload).expect("payload should serialize");
|
||
|
||
assert_eq!(value["ownerUserId"], json!("missing-user"));
|
||
assert!(value.get("authorDisplayName").is_none());
|
||
assert_eq!(value["authorPublicUserCode"], json!("SY-00000000"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_payload_media_sanitizer_replaces_inline_layer_src_from_resource() {
|
||
let resources = vec![EditorProjectResourcePayload {
|
||
resource_id: "resource-1".to_string(),
|
||
showcase_id: None,
|
||
asset_id: None,
|
||
label: None,
|
||
project_id: "project-1".to_string(),
|
||
owner_user_id: "user-1".to_string(),
|
||
author_display_name: None,
|
||
author_public_user_code: None,
|
||
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
|
||
object_key: Some("generated-character-drafts/editor/spec.png".to_string()),
|
||
asset_object_id: Some("asset-object-1".to_string()),
|
||
width: 512,
|
||
height: 512,
|
||
source_type: "generated".to_string(),
|
||
prompt: None,
|
||
actual_prompt: None,
|
||
model: None,
|
||
provider: None,
|
||
task_id: None,
|
||
source_resource_id: None,
|
||
asset_kind: Some("spec".to_string()),
|
||
showcase_category: None,
|
||
generation_inputs: None,
|
||
public_showcase_enabled: true,
|
||
review_status: None,
|
||
display_enabled: None,
|
||
like_count: None,
|
||
generation_cost_mud_points: 0,
|
||
refund_mud_points: None,
|
||
created_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
}];
|
||
|
||
let sanitized = sanitize_editor_payload_media(
|
||
json!([
|
||
{
|
||
"layerId": "layer-1",
|
||
"resourceId": "resource-1",
|
||
"src": "data:image/png;base64,layer",
|
||
"thumbnailSrc": "data:image/png;base64,thumbnail",
|
||
"imageSequenceFrames": [
|
||
{ "frameIndex": 0, "imageSrc": "data:image/png;base64,frame" }
|
||
],
|
||
"generationInputs": {
|
||
"references": [
|
||
{
|
||
"resourceId": "resource-1",
|
||
"src": "data:image/png;base64,reference"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]),
|
||
resources.as_slice(),
|
||
);
|
||
let payload = serde_json::to_string(&sanitized).expect("payload serializes");
|
||
|
||
assert!(!payload.contains("data:image"));
|
||
assert_eq!(
|
||
sanitized[0]["src"],
|
||
json!("/generated-character-drafts/editor/spec.png")
|
||
);
|
||
assert_eq!(
|
||
sanitized[0]["thumbnailSrc"],
|
||
json!("/generated-character-drafts/editor/spec.png")
|
||
);
|
||
assert_eq!(
|
||
sanitized[0]["imageSequenceFrames"][0]["imageSrc"],
|
||
json!("/generated-character-drafts/editor/spec.png")
|
||
);
|
||
assert_eq!(
|
||
sanitized[0]["objectKey"],
|
||
json!("generated-character-drafts/editor/spec.png")
|
||
);
|
||
assert_eq!(
|
||
sanitized[0]["generationInputs"]["references"][0]["src"],
|
||
json!("/generated-character-drafts/editor/spec.png")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_canvas_generation_completion_saves_after_sanitizing_legacy_inline_layers() {
|
||
let existing_resources = vec![EditorProjectResourcePayload {
|
||
resource_id: "resource-spec".to_string(),
|
||
showcase_id: None,
|
||
asset_id: None,
|
||
label: None,
|
||
project_id: "project-1".to_string(),
|
||
owner_user_id: "user-1".to_string(),
|
||
author_display_name: None,
|
||
author_public_user_code: None,
|
||
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
|
||
object_key: Some("generated-character-drafts/editor/spec.png".to_string()),
|
||
asset_object_id: Some("asset-object-spec".to_string()),
|
||
width: 512,
|
||
height: 512,
|
||
source_type: "generated".to_string(),
|
||
prompt: None,
|
||
actual_prompt: None,
|
||
model: None,
|
||
provider: None,
|
||
task_id: None,
|
||
source_resource_id: None,
|
||
asset_kind: Some("spec".to_string()),
|
||
showcase_category: None,
|
||
generation_inputs: None,
|
||
public_showcase_enabled: true,
|
||
review_status: None,
|
||
display_enabled: None,
|
||
like_count: None,
|
||
generation_cost_mud_points: 0,
|
||
refund_mud_points: None,
|
||
created_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
}];
|
||
let dirty_layers = json!([
|
||
{
|
||
"layerId": "layer-spec",
|
||
"resourceId": "resource-spec",
|
||
"title": "规范图",
|
||
"src": "data:image/png;base64,old-spec",
|
||
"thumbnailSrc": "data:image/png;base64,old-thumbnail"
|
||
},
|
||
{
|
||
"layerId": "generation-dialog:dialog-character",
|
||
"resourceId": "generation-dialog:dialog-character",
|
||
"itemType": "generation-dialog",
|
||
"dialog": {
|
||
"id": "dialog-character",
|
||
"mode": "character",
|
||
"status": "generating",
|
||
"placeholder": {
|
||
"x": 200,
|
||
"y": 160,
|
||
"width": 420,
|
||
"height": 560,
|
||
"originalWidth": 1024,
|
||
"originalHeight": 1536
|
||
}
|
||
}
|
||
}
|
||
]);
|
||
let layers = sanitize_editor_payload_media(dirty_layers, existing_resources.as_slice());
|
||
assert!(
|
||
!serde_json::to_string(&layers)
|
||
.unwrap()
|
||
.contains("data:image")
|
||
);
|
||
|
||
let completion = EditorCanvasGenerationCompletionRequest {
|
||
dialog_id: Some("dialog-character".to_string()),
|
||
title: "角色形象 1".to_string(),
|
||
placeholder: EditorCanvasGenerationPlaceholderPayload {
|
||
x: 200.0,
|
||
y: 160.0,
|
||
width: 420.0,
|
||
height: 560.0,
|
||
original_width: 1024.0,
|
||
original_height: 1536.0,
|
||
},
|
||
};
|
||
let generated_resource = EditorProjectResourcePayload {
|
||
resource_id: "editor-resource-character".to_string(),
|
||
showcase_id: None,
|
||
asset_id: None,
|
||
label: None,
|
||
project_id: "editor-project-1".to_string(),
|
||
owner_user_id: "user-1".to_string(),
|
||
author_display_name: None,
|
||
author_public_user_code: None,
|
||
image_src: "/generated-character-drafts/editor/character.png".to_string(),
|
||
object_key: Some("generated-character-drafts/editor/character.png".to_string()),
|
||
asset_object_id: Some("asset-object-character".to_string()),
|
||
width: 1024,
|
||
height: 1536,
|
||
source_type: "generated".to_string(),
|
||
prompt: Some("银发游侠".to_string()),
|
||
actual_prompt: Some("实际提示词".to_string()),
|
||
model: Some("gpt-image-2".to_string()),
|
||
provider: Some("VectorEngine".to_string()),
|
||
task_id: Some("task-character".to_string()),
|
||
source_resource_id: None,
|
||
asset_kind: Some("character".to_string()),
|
||
showcase_category: None,
|
||
generation_inputs: None,
|
||
public_showcase_enabled: true,
|
||
review_status: None,
|
||
display_enabled: None,
|
||
like_count: None,
|
||
generation_cost_mud_points: 0,
|
||
refund_mud_points: None,
|
||
created_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
};
|
||
|
||
let completed =
|
||
apply_editor_canvas_generation_completion(layers, &completion, &generated_resource)
|
||
.expect("completion should apply to sanitized legacy layout");
|
||
|
||
assert!(completed.changed);
|
||
serialize_editor_layers(completed.layers).expect("sanitized completion should save");
|
||
}
|
||
|
||
#[test]
|
||
fn editor_persisted_media_src_rejects_inline_media_without_object_key() {
|
||
let rejected = normalize_editor_persisted_media_src(
|
||
"data:image/png;base64,too-large".to_string(),
|
||
None,
|
||
)
|
||
.expect_err("inline image media should be rejected");
|
||
assert!(rejected.body_text().contains("objectKey"));
|
||
|
||
assert!(
|
||
normalize_editor_persisted_media_src("data:video/mp4;base64,AAAA".to_string(), None)
|
||
.is_err()
|
||
);
|
||
assert!(
|
||
normalize_editor_persisted_media_src("data:audio/mp3;base64,AAAA".to_string(), None)
|
||
.is_err()
|
||
);
|
||
assert!(
|
||
normalize_editor_persisted_media_src("blob:http://localhost/preview".to_string(), None)
|
||
.is_err()
|
||
);
|
||
assert_eq!(
|
||
normalize_editor_persisted_media_src(
|
||
"data:image/png;base64,legacy".to_string(),
|
||
Some("generated-character-drafts/editor/spec.png")
|
||
)
|
||
.expect("objectKey-backed legacy rows should normalize"),
|
||
"/generated-character-drafts/editor/spec.png"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_layout_serialization_rejects_nested_inline_media() {
|
||
let error = serialize_editor_layers(json!([
|
||
{
|
||
"layerId": "layer-animation",
|
||
"thumbnailSrc": "/generated-character-drafts/editor/frame-1.png",
|
||
"imageSequenceFrames": [
|
||
{
|
||
"frameIndex": 1,
|
||
"imageSrc": "data:image/png;base64,frame"
|
||
}
|
||
]
|
||
}
|
||
]))
|
||
.expect_err("layout should reject nested inline image data");
|
||
|
||
assert!(error.body_text().contains("图层布局"));
|
||
assert!(
|
||
serialize_editor_layers(json!([
|
||
{
|
||
"layerId": "layer-animation",
|
||
"thumbnailSrc": "/generated-character-drafts/editor/frame-1.png",
|
||
"imageSequenceFrames": [
|
||
{
|
||
"frameIndex": 1,
|
||
"imageSrc": "/generated-character-drafts/editor/frame-1.png"
|
||
}
|
||
],
|
||
"previewVideoPath": "/generated-character-drafts/editor/preview.mp4"
|
||
}
|
||
]))
|
||
.is_ok()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_layout_serialization_accepts_payload_up_to_two_mib() {
|
||
let payload = json!("x".repeat(EDITOR_LAYOUT_MAX_BYTES - 2));
|
||
|
||
let serialized = serialize_editor_layers(payload).expect("two MiB layout should save");
|
||
|
||
assert_eq!(serialized.len(), EDITOR_LAYOUT_MAX_BYTES);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_layout_serialization_rejects_payload_over_two_mib() {
|
||
let payload = json!("x".repeat(EDITOR_LAYOUT_MAX_BYTES - 1));
|
||
|
||
let error = serialize_editor_layers(payload).expect_err("oversized layout should fail");
|
||
|
||
assert!(error.body_text().contains("图层布局过大"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_asset_metadata_serialization_rejects_nested_inline_media() {
|
||
let error = serialize_editor_asset_metadata(Some(json!({
|
||
"references": [
|
||
{
|
||
"title": "参考图",
|
||
"src": "data:image/png;base64,reference"
|
||
}
|
||
]
|
||
})))
|
||
.expect_err("metadata should reject nested inline image data");
|
||
|
||
assert!(error.body_text().contains("资产元数据"));
|
||
assert!(
|
||
serialize_editor_asset_metadata(Some(json!({
|
||
"references": [
|
||
{
|
||
"title": "参考图",
|
||
"src": "/generated-character-drafts/editor/reference.png"
|
||
}
|
||
]
|
||
})))
|
||
.is_ok()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() {
|
||
assert_eq!(normalize_editor_image_generation_size(None), "1024x1024");
|
||
assert_eq!(
|
||
normalize_editor_image_generation_size(Some("1536x1024")),
|
||
"1536x1024"
|
||
);
|
||
assert_eq!(
|
||
normalize_editor_image_generation_size(Some("1024x1536")),
|
||
"1024x1536"
|
||
);
|
||
assert_eq!(
|
||
normalize_editor_image_generation_size(Some("2048x1152")),
|
||
"2048x1152"
|
||
);
|
||
assert_eq!(
|
||
normalize_editor_image_generation_size(Some("640x640")),
|
||
"640x640"
|
||
);
|
||
assert_eq!(
|
||
normalize_editor_image_generation_size(Some("bad-size")),
|
||
"1024x1024"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_image_edit_price_uses_selected_model_and_size() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
|
||
assert_eq!(
|
||
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "1024x1024", None)
|
||
.await
|
||
.unwrap(),
|
||
3
|
||
);
|
||
assert_eq!(
|
||
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "2048x1152", None)
|
||
.await
|
||
.unwrap(),
|
||
5
|
||
);
|
||
assert_eq!(
|
||
resolve_editor_image_edit_price(&state, GPT_IMAGE_2_MODEL, "2048x1152", Some("1K"),)
|
||
.await
|
||
.unwrap(),
|
||
5
|
||
);
|
||
assert_eq!(
|
||
resolve_editor_image_edit_price(
|
||
&state,
|
||
EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||
"2048x2048",
|
||
Some("2K"),
|
||
)
|
||
.await
|
||
.unwrap(),
|
||
24
|
||
);
|
||
assert_eq!(
|
||
resolve_editor_image_edit_price(
|
||
&state,
|
||
EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||
"512x512",
|
||
Some("0.5K"),
|
||
)
|
||
.await
|
||
.unwrap(),
|
||
8
|
||
);
|
||
assert!(editor_image_edit_uses_nanobanana_generate_content(
|
||
EDITOR_IMAGE_MODEL_NANOBANANA2
|
||
));
|
||
assert!(!editor_image_edit_uses_nanobanana_generate_content(
|
||
GPT_IMAGE_2_MODEL
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_image_edit_aligns_provider_images_and_restores_source_dimensions() {
|
||
fn encode_png(width: u32, height: u32) -> Vec<u8> {
|
||
let image = image::DynamicImage::new_rgba8(width, height);
|
||
let mut bytes = Cursor::new(Vec::new());
|
||
image
|
||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||
.expect("test image should encode");
|
||
bytes.into_inner()
|
||
}
|
||
|
||
let mut references = vec![
|
||
OpenAiReferenceImage {
|
||
bytes: encode_png(1537, 1025),
|
||
mime_type: "image/png".to_string(),
|
||
file_name: "source.png".to_string(),
|
||
},
|
||
OpenAiReferenceImage {
|
||
bytes: encode_png(501, 333),
|
||
mime_type: "image/png".to_string(),
|
||
file_name: "style.png".to_string(),
|
||
},
|
||
];
|
||
|
||
let dimensions = prepare_editor_image_edit_references(&mut references, "1537x1025")
|
||
.expect("references should align for provider");
|
||
assert_eq!(dimensions.target_width, 1537);
|
||
assert_eq!(dimensions.target_height, 1025);
|
||
assert_eq!(dimensions.provider_width, 1552);
|
||
assert_eq!(dimensions.provider_height, 1040);
|
||
let source = image::load_from_memory(references[0].bytes.as_slice()).unwrap();
|
||
assert_eq!((source.width(), source.height()), (1552, 1040));
|
||
let extra = image::load_from_memory(references[1].bytes.as_slice()).unwrap();
|
||
assert_eq!((extra.width(), extra.height()), (512, 336));
|
||
|
||
let provider_output = DownloadedOpenAiImage {
|
||
bytes: encode_png(1552, 1040),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
let restored = restore_editor_image_edit_output_dimensions(&provider_output, &dimensions)
|
||
.expect("provider output should restore source dimensions")
|
||
.expect("aligned provider output should require a transformation");
|
||
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
|
||
assert_eq!(
|
||
(restored_image.width(), restored_image.height()),
|
||
(1537, 1025)
|
||
);
|
||
assert_eq!(restored.mime_type, "image/png");
|
||
assert_eq!(restored.extension, "png");
|
||
}
|
||
|
||
#[test]
|
||
fn static_image_postprocess_restores_only_alpha_at_provider_dimensions() {
|
||
let provider_source = image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 10, 20, 255]));
|
||
let mut provider_source_bytes = Cursor::new(Vec::new());
|
||
image::DynamicImage::ImageRgba8(provider_source)
|
||
.write_to(&mut provider_source_bytes, image::ImageFormat::Png)
|
||
.expect("test image should encode");
|
||
let provider_source = DownloadedOpenAiImage {
|
||
bytes: provider_source_bytes.into_inner(),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
|
||
let postprocessed = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 240, 77]));
|
||
let mut postprocessed_bytes = Cursor::new(Vec::new());
|
||
image::DynamicImage::ImageRgba8(postprocessed)
|
||
.write_to(&mut postprocessed_bytes, image::ImageFormat::Png)
|
||
.expect("test image should encode");
|
||
let postprocessed = DownloadedOpenAiImage {
|
||
bytes: postprocessed_bytes.into_inner(),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
|
||
let restored =
|
||
apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed)
|
||
.expect("postprocessed alpha should restore provider dimensions")
|
||
.expect("smaller alpha mask should be applied to provider source");
|
||
let restored_image = image::load_from_memory(restored.bytes.as_slice())
|
||
.unwrap()
|
||
.to_rgba8();
|
||
|
||
assert_eq!((restored_image.width(), restored_image.height()), (4, 4));
|
||
assert_eq!(restored_image.get_pixel(1, 1).0, [200, 10, 20, 77]);
|
||
assert_eq!(restored.mime_type, "image/png");
|
||
assert_eq!(restored.extension, "png");
|
||
}
|
||
|
||
#[test]
|
||
fn publication_material_generation_restores_provider_output_to_workflow_dimensions() {
|
||
let image = image::DynamicImage::new_rgba8(944, 704);
|
||
let mut bytes = Cursor::new(Vec::new());
|
||
image
|
||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||
.expect("test image should encode");
|
||
|
||
let provider_output = DownloadedOpenAiImage {
|
||
bytes: bytes.into_inner(),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
let restored = restore_editor_generated_image_output_dimensions(
|
||
&provider_output,
|
||
GPT_IMAGE_2_MODEL,
|
||
"720x540",
|
||
)
|
||
.expect("provider output should restore delivery dimensions")
|
||
.expect("mismatched provider output should require a transformation");
|
||
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
|
||
|
||
assert_eq!(
|
||
(restored_image.width(), restored_image.height()),
|
||
(720, 540)
|
||
);
|
||
assert_eq!(restored.mime_type, "image/png");
|
||
assert_eq!(restored.extension, "png");
|
||
}
|
||
|
||
#[test]
|
||
fn nanobanana_generation_restores_shared_business_pixel_size() {
|
||
let image = image::DynamicImage::new_rgba8(2752, 1536);
|
||
let mut bytes = Cursor::new(Vec::new());
|
||
image
|
||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||
.expect("test image should encode");
|
||
|
||
let provider_output = DownloadedOpenAiImage {
|
||
bytes: bytes.into_inner(),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
let (restored, restore_error) =
|
||
restore_editor_generated_image_output_dimensions_or_original(
|
||
provider_output,
|
||
EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||
"2048x1152",
|
||
);
|
||
assert!(restore_error.is_none());
|
||
let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap();
|
||
|
||
assert_eq!(
|
||
(restored_image.width(), restored_image.height()),
|
||
(2048, 1152)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn provider_1k_under_delivery_is_not_upscaled_to_2k() {
|
||
let image = image::DynamicImage::new_rgba8(1024, 576);
|
||
let mut bytes = Cursor::new(Vec::new());
|
||
image
|
||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||
.expect("test image should encode");
|
||
let provider_output = DownloadedOpenAiImage {
|
||
bytes: bytes.into_inner(),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
let expected_bytes = provider_output.bytes.clone();
|
||
|
||
let (delivery_output, restore_error) =
|
||
restore_editor_generated_image_output_dimensions_or_original(
|
||
provider_output,
|
||
EDITOR_IMAGE_MODEL_NANOBANANA2,
|
||
"2048x1152",
|
||
);
|
||
|
||
let restore_error = restore_error.expect("under delivery should be recorded");
|
||
assert_eq!(delivery_output.bytes, expected_bytes);
|
||
assert_eq!(
|
||
restore_error
|
||
.details()
|
||
.and_then(|details| details.get("code")),
|
||
Some(&json!("provider-image-under-delivery"))
|
||
);
|
||
let warning = editor_dimension_restore_warning(&restore_error);
|
||
assert_eq!(warning.code, EDITOR_GENERATION_DIMENSION_WARNING_CODE);
|
||
assert!(warning.reason.contains("禁止放大伪造清晰度档位"));
|
||
let delivery_image = image::load_from_memory(delivery_output.bytes.as_slice()).unwrap();
|
||
assert_eq!(
|
||
(delivery_image.width(), delivery_image.height()),
|
||
(1024, 576)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn generated_dimension_restore_allows_only_slight_aspect_ratio_crop() {
|
||
ensure_editor_output_can_restore_without_upscaling(
|
||
2064,
|
||
1152,
|
||
2048,
|
||
1152,
|
||
"test-generation",
|
||
)
|
||
.expect("provider rounding drift should allow slight crop");
|
||
|
||
let error = ensure_editor_output_can_restore_without_upscaling(
|
||
2048,
|
||
2048,
|
||
2048,
|
||
1152,
|
||
"test-generation",
|
||
)
|
||
.expect_err("large aspect ratio crop should be rejected");
|
||
assert_eq!(
|
||
error.details().and_then(|details| details.get("code")),
|
||
Some(&json!("provider-image-aspect-ratio-mismatch"))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn failed_dimension_restore_keeps_provider_output_for_single_upload() {
|
||
let image = image::DynamicImage::new_rgba8(896, 896);
|
||
let mut bytes = Cursor::new(Vec::new());
|
||
image
|
||
.write_to(&mut bytes, image::ImageFormat::Png)
|
||
.expect("test image should encode");
|
||
let provider_output = DownloadedOpenAiImage {
|
||
bytes: bytes.into_inner(),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
let expected_bytes = provider_output.bytes.clone();
|
||
|
||
let (delivery_output, restore_error) =
|
||
restore_editor_generated_image_output_dimensions_or_original(
|
||
provider_output,
|
||
GPT_IMAGE_2_MODEL,
|
||
"invalid-size",
|
||
);
|
||
|
||
assert!(restore_error.is_some());
|
||
assert_eq!(delivery_output.bytes, expected_bytes);
|
||
assert_eq!(delivery_output.mime_type, "image/png");
|
||
assert_eq!(delivery_output.extension, "png");
|
||
}
|
||
|
||
#[test]
|
||
fn editor_generation_dimensions_follow_model_options() {
|
||
let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K"));
|
||
assert_eq!(default_generation.model, GPT_IMAGE_2_MODEL);
|
||
assert_eq!(default_generation.size, "1024x1024");
|
||
|
||
let nanobanana = normalize_editor_generation_options(
|
||
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
|
||
Some("1:1"),
|
||
Some("0.5K"),
|
||
);
|
||
assert_eq!(nanobanana.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
|
||
assert_eq!(nanobanana.size, "512x512");
|
||
assert_eq!(nanobanana.aspect_ratio, "1:1");
|
||
assert_eq!(nanobanana.image_size, "0.5K");
|
||
assert_eq!(nanobanana.provider_image_size, "512");
|
||
|
||
let nanobanana_alias =
|
||
normalize_editor_generation_options(Some("nanobanana2"), Some("1:1"), Some("0.5K"));
|
||
assert_eq!(nanobanana_alias.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
|
||
assert_eq!(nanobanana_alias.size, "512x512");
|
||
assert_eq!(nanobanana_alias.provider_image_size, "512");
|
||
|
||
let legacy_nanobanana_alias =
|
||
normalize_editor_generation_options(Some("nano-banana"), Some("16:9"), Some("2K"));
|
||
assert_eq!(
|
||
legacy_nanobanana_alias.model,
|
||
EDITOR_IMAGE_MODEL_NANOBANANA2
|
||
);
|
||
assert_eq!(legacy_nanobanana_alias.size, "2048x1152");
|
||
assert_eq!(legacy_nanobanana_alias.aspect_ratio, "16:9");
|
||
|
||
let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K"));
|
||
assert_eq!(gpt.model, GPT_IMAGE_2_MODEL);
|
||
assert_eq!(gpt.size, "683x1024");
|
||
assert_eq!(gpt.provider_size, "688x1024");
|
||
assert_eq!(gpt.aspect_ratio, "2:3");
|
||
assert_eq!(gpt.image_size, "1K");
|
||
assert_eq!(gpt.provider_image_size, "1K");
|
||
|
||
let nanobanana_cover =
|
||
normalize_editor_generation_options(Some("nanobanana2"), Some("4:3"), Some("1K"));
|
||
assert_eq!(nanobanana_cover.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
|
||
assert_eq!(nanobanana_cover.size, "1024x768");
|
||
assert_eq!(nanobanana_cover.aspect_ratio, "4:3");
|
||
|
||
let gpt_cover =
|
||
normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K"));
|
||
assert_eq!(gpt_cover.model, GPT_IMAGE_2_MODEL);
|
||
assert_eq!(gpt_cover.size, "1024x768");
|
||
assert_eq!(gpt_cover.provider_size, "1024x768");
|
||
assert_eq!(gpt_cover.aspect_ratio, "4:3");
|
||
|
||
let gpt_landscape_2k =
|
||
normalize_editor_generation_options(Some("gpt-image-2"), Some("16:9"), Some("2K"));
|
||
assert_eq!(gpt_landscape_2k.model, GPT_IMAGE_2_MODEL);
|
||
assert_eq!(gpt_landscape_2k.size, "2048x1152");
|
||
assert_eq!(gpt_landscape_2k.aspect_ratio, "16:9");
|
||
assert_eq!(gpt_landscape_2k.image_size, "2K");
|
||
|
||
let gpt_portrait_2k =
|
||
normalize_editor_generation_options(Some("gpt-image-2"), Some("9:16"), Some("2K"));
|
||
assert_eq!(gpt_portrait_2k.model, GPT_IMAGE_2_MODEL);
|
||
assert_eq!(gpt_portrait_2k.size, "1152x2048");
|
||
assert_eq!(gpt_portrait_2k.aspect_ratio, "9:16");
|
||
assert_eq!(gpt_portrait_2k.image_size, "2K");
|
||
|
||
for (image_size, aspect_ratio, expected_size) in [
|
||
("1K", "1:1", "1024x1024"),
|
||
("1K", "4:3", "1024x768"),
|
||
("1K", "3:2", "1024x683"),
|
||
("1K", "2:3", "683x1024"),
|
||
("1K", "9:16", "576x1024"),
|
||
("1K", "16:9", "1024x576"),
|
||
("2K", "1:1", "2048x2048"),
|
||
("2K", "4:3", "2048x1536"),
|
||
("2K", "3:2", "2048x1365"),
|
||
("2K", "2:3", "1365x2048"),
|
||
("2K", "9:16", "1152x2048"),
|
||
("2K", "16:9", "2048x1152"),
|
||
] {
|
||
let options = normalize_editor_generation_options(
|
||
Some(GPT_IMAGE_2_MODEL),
|
||
Some(aspect_ratio),
|
||
Some(image_size),
|
||
);
|
||
assert_eq!(options.size, expected_size);
|
||
}
|
||
|
||
for (image_size, aspect_ratio, expected_delivery_size) in [
|
||
("1K", "16:9", "1024x576"),
|
||
("1K", "2:3", "683x1024"),
|
||
("2K", "3:2", "2048x1365"),
|
||
("2K", "9:16", "1152x2048"),
|
||
] {
|
||
let gpt = normalize_editor_generation_options(
|
||
Some(GPT_IMAGE_2_MODEL),
|
||
Some(aspect_ratio),
|
||
Some(image_size),
|
||
);
|
||
let nanobanana = normalize_editor_generation_options(
|
||
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
|
||
Some(aspect_ratio),
|
||
Some(image_size),
|
||
);
|
||
assert_eq!(gpt.size, expected_delivery_size);
|
||
assert_eq!(nanobanana.size, expected_delivery_size);
|
||
assert_eq!(gpt.size, nanobanana.size);
|
||
}
|
||
|
||
let gpt_landscape_1k =
|
||
normalize_editor_generation_options(Some(GPT_IMAGE_2_MODEL), Some("16:9"), Some("1K"));
|
||
assert_eq!(gpt_landscape_1k.provider_size, "1088x608");
|
||
assert_eq!(gpt_landscape_1k.size, "1024x576");
|
||
|
||
for (image_size, aspect_ratio, expected_size) in [
|
||
("0.5K", "1:1", "512x512"),
|
||
("0.5K", "4:3", "512x384"),
|
||
("0.5K", "3:2", "512x341"),
|
||
("0.5K", "2:3", "341x512"),
|
||
("0.5K", "9:16", "288x512"),
|
||
("0.5K", "16:9", "512x288"),
|
||
("1K", "1:1", "1024x1024"),
|
||
("1K", "4:3", "1024x768"),
|
||
("1K", "3:2", "1024x683"),
|
||
("1K", "2:3", "683x1024"),
|
||
("1K", "9:16", "576x1024"),
|
||
("1K", "16:9", "1024x576"),
|
||
("2K", "1:1", "2048x2048"),
|
||
("2K", "4:3", "2048x1536"),
|
||
("2K", "3:2", "2048x1365"),
|
||
("2K", "2:3", "1365x2048"),
|
||
("2K", "9:16", "1152x2048"),
|
||
("2K", "16:9", "2048x1152"),
|
||
] {
|
||
let options = normalize_editor_generation_options(
|
||
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
|
||
Some(aspect_ratio),
|
||
Some(image_size),
|
||
);
|
||
assert_eq!(options.size, expected_size);
|
||
}
|
||
|
||
let fallback = normalize_editor_generation_options(
|
||
Some("unknown-model"),
|
||
Some("bad-ratio"),
|
||
Some("bad-size"),
|
||
);
|
||
assert_eq!(fallback.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
|
||
assert_eq!(fallback.size, "1024x1024");
|
||
assert_eq!(fallback.aspect_ratio, "1:1");
|
||
assert_eq!(fallback.image_size, "1K");
|
||
assert_eq!(fallback.provider_size, "1024x1024");
|
||
assert_eq!(fallback.provider_image_size, "1K");
|
||
}
|
||
|
||
#[test]
|
||
fn publication_material_request_size_keeps_workflow_pixels() {
|
||
let options =
|
||
normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K"));
|
||
|
||
assert_eq!(
|
||
resolve_editor_image_request_size(
|
||
Some("publication-material"),
|
||
Some("720x540"),
|
||
true,
|
||
&options,
|
||
),
|
||
"720x540"
|
||
);
|
||
assert_eq!(
|
||
resolve_editor_image_request_size(Some("generate"), Some("720x540"), true, &options),
|
||
"1024x768"
|
||
);
|
||
assert_eq!(
|
||
resolve_editor_image_provider_request_size(
|
||
Some("publication-material"),
|
||
Some("720x540"),
|
||
true,
|
||
&options,
|
||
),
|
||
"720x540"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn publication_material_generation_is_locked_to_gpt_image_2() {
|
||
let source = include_str!("editor_project.rs");
|
||
|
||
assert_function_contains(
|
||
source,
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
"let configured_price_mud_points",
|
||
&[
|
||
"is_publication_material_generation",
|
||
"Some(GPT_IMAGE_2_MODEL)",
|
||
],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_ui_design_prompt_uses_fixed_user_input_block_and_optional_icon_spec() {
|
||
let prompt = build_editor_ui_design_prompt("二消玩法,主界面和结算弹窗", true);
|
||
|
||
assert!(prompt.contains("生成玩法UI原型图"));
|
||
assert!(prompt.contains("【用户输入】二消玩法,主界面和结算弹窗"));
|
||
assert!(prompt.contains("参考图1为图标素材规范"));
|
||
|
||
let no_reference_prompt = build_editor_ui_design_prompt("只要战斗HUD", false);
|
||
assert!(!no_reference_prompt.contains("参考图1为图标素材规范"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_character_image_prompt_appends_user_role_setting() {
|
||
let prompt = build_editor_character_image_prompt(
|
||
"菜市场卖菜大妈",
|
||
parse_editor_screen_background_color(Some("#FFD6C2"))
|
||
.expect("screen color should parse"),
|
||
);
|
||
|
||
assert!(prompt.contains("严格基于图1的角色美术视觉规范"));
|
||
assert!(prompt.contains("生成游戏角色立绘"));
|
||
assert!(prompt.contains("背景固定为单一纯色背景 暖浅桃色 #FFD6C2 / RGB(255,214,194)"));
|
||
assert!(prompt.contains("纯色背景必须平整无纹理、无渐变、无阴影"));
|
||
assert!(
|
||
prompt.contains("角色主体及其服饰、道具的颜色必须与背景色明显区分,不得带与背景色相同或相近的描边、投影或反光")
|
||
);
|
||
assert!(prompt.contains("禁止镜头透视"));
|
||
assert!(prompt.contains("角色设定:菜市场卖菜大妈"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_canvas_generation_completion_inserts_result_layer_and_keeps_composer_closed() {
|
||
let layers = json!([
|
||
{
|
||
"layerId": "layer-existing",
|
||
"resourceId": "resource-existing",
|
||
"zIndex": 4
|
||
},
|
||
{
|
||
"itemType": "generation-dialog",
|
||
"layerId": "generation-dialog:dialog-character",
|
||
"resourceId": "generation-dialog:dialog-character",
|
||
"dialog": {
|
||
"id": "dialog-character",
|
||
"mode": "character",
|
||
"prompt": "银发游侠",
|
||
"status": "generating",
|
||
"composerOpen": false,
|
||
"errorMessage": "旧错误",
|
||
"placeholder": {
|
||
"x": 200,
|
||
"y": 160,
|
||
"width": 420,
|
||
"height": 560,
|
||
"originalWidth": 1024,
|
||
"originalHeight": 1536
|
||
}
|
||
}
|
||
}
|
||
]);
|
||
let completion = EditorCanvasGenerationCompletionRequest {
|
||
dialog_id: Some("dialog-character".to_string()),
|
||
title: "角色形象 1".to_string(),
|
||
placeholder: EditorCanvasGenerationPlaceholderPayload {
|
||
x: 200.0,
|
||
y: 160.0,
|
||
width: 420.0,
|
||
height: 560.0,
|
||
original_width: 1024.0,
|
||
original_height: 1536.0,
|
||
},
|
||
};
|
||
let resource = EditorProjectResourcePayload {
|
||
resource_id: "editor-resource-character".to_string(),
|
||
showcase_id: None,
|
||
asset_id: None,
|
||
label: None,
|
||
project_id: "editor-project-1".to_string(),
|
||
owner_user_id: "user-1".to_string(),
|
||
author_display_name: None,
|
||
author_public_user_code: None,
|
||
image_src: "data:image/png;base64,character".to_string(),
|
||
object_key: Some("generated-character-drafts/editor/character.png".to_string()),
|
||
asset_object_id: Some("asset-object-1".to_string()),
|
||
width: 1024,
|
||
height: 1536,
|
||
source_type: "generated".to_string(),
|
||
prompt: Some("银发游侠".to_string()),
|
||
actual_prompt: Some("实际提示词".to_string()),
|
||
model: Some("gpt-image-2".to_string()),
|
||
provider: Some("VectorEngine".to_string()),
|
||
task_id: Some("task-character".to_string()),
|
||
source_resource_id: Some("resource-spec".to_string()),
|
||
asset_kind: Some("character".to_string()),
|
||
showcase_category: None,
|
||
generation_inputs: Some(json!({
|
||
"fields": [{"title": "角色设定", "value": "银发游侠"}],
|
||
"references": []
|
||
})),
|
||
public_showcase_enabled: true,
|
||
review_status: None,
|
||
display_enabled: None,
|
||
like_count: None,
|
||
generation_cost_mud_points: 0,
|
||
refund_mud_points: None,
|
||
created_at: "2026-06-21T12:16:22.000Z".to_string(),
|
||
updated_at: "2026-06-21T12:16:22.000Z".to_string(),
|
||
};
|
||
|
||
let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource)
|
||
.expect("completion should apply");
|
||
assert!(completed.changed);
|
||
let items = completed
|
||
.layers
|
||
.as_array()
|
||
.expect("layers should stay array");
|
||
let result_layer = items
|
||
.iter()
|
||
.find(|item| item["resourceId"] == "editor-resource-character")
|
||
.expect("result layer should be inserted");
|
||
|
||
assert_eq!(
|
||
result_layer["layerId"],
|
||
json!("layer-editor-resource-editor-resource-character")
|
||
);
|
||
assert_eq!(result_layer["title"], json!("角色形象 1"));
|
||
assert_eq!(result_layer["x"], json!(-102.0));
|
||
assert_eq!(result_layer["y"], json!(-328.0));
|
||
assert_eq!(result_layer["originalWidth"], json!(1024.0));
|
||
assert_eq!(result_layer["originalHeight"], json!(1536.0));
|
||
assert_eq!(result_layer["zIndex"], json!(5));
|
||
assert_eq!(result_layer["mediaType"], json!("image"));
|
||
assert_eq!(
|
||
result_layer["src"],
|
||
json!("data:image/png;base64,character")
|
||
);
|
||
|
||
let dialog = items
|
||
.iter()
|
||
.find(|item| item["itemType"] == "generation-dialog")
|
||
.and_then(|item| item.get("dialog"))
|
||
.expect("dialog should remain");
|
||
assert_eq!(dialog["status"], json!("idle"));
|
||
assert_eq!(dialog["composerOpen"], json!(false));
|
||
assert_eq!(
|
||
dialog["generatedLayerId"],
|
||
json!("layer-editor-resource-editor-resource-character")
|
||
);
|
||
assert!(dialog.get("errorMessage").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn editor_canvas_background_removal_replaces_target_layer_resource() {
|
||
let layers = json!([
|
||
{
|
||
"layerId": "layer-source",
|
||
"resourceId": "resource-source",
|
||
"title": "源图",
|
||
"x": 120,
|
||
"y": 140,
|
||
"width": 320,
|
||
"height": 240,
|
||
"originalWidth": 1024,
|
||
"originalHeight": 768,
|
||
"zIndex": 6,
|
||
"locked": true,
|
||
"sourceAssetId": "asset-old",
|
||
"thumbnailSrc": "/old-thumb.png"
|
||
},
|
||
{
|
||
"layerId": "layer-other",
|
||
"resourceId": "resource-other"
|
||
}
|
||
]);
|
||
let resource = EditorProjectResourcePayload {
|
||
resource_id: "editor-resource-cutout".to_string(),
|
||
showcase_id: None,
|
||
asset_id: None,
|
||
label: None,
|
||
project_id: "editor-project-1".to_string(),
|
||
owner_user_id: "user-1".to_string(),
|
||
author_display_name: None,
|
||
author_public_user_code: None,
|
||
image_src: "/generated-character-drafts/editor/background-removal/result.png"
|
||
.to_string(),
|
||
object_key: Some(
|
||
"generated-character-drafts/editor/background-removal/result.png".to_string(),
|
||
),
|
||
asset_object_id: Some("asset-object-cutout".to_string()),
|
||
width: 512,
|
||
height: 768,
|
||
source_type: "generated".to_string(),
|
||
prompt: Some("remove background".to_string()),
|
||
actual_prompt: None,
|
||
model: Some("BgFilter complex".to_string()),
|
||
provider: Some("BgFilter".to_string()),
|
||
task_id: Some("extgen-cutout".to_string()),
|
||
source_resource_id: Some("resource-source".to_string()),
|
||
asset_kind: Some("character".to_string()),
|
||
showcase_category: None,
|
||
generation_inputs: Some(json!({
|
||
"fields": [{"title": "prompt", "value": "角色"}],
|
||
"references": []
|
||
})),
|
||
public_showcase_enabled: true,
|
||
review_status: None,
|
||
display_enabled: None,
|
||
like_count: None,
|
||
generation_cost_mud_points: 0,
|
||
refund_mud_points: None,
|
||
created_at: "2026-06-29T00:00:00.000Z".to_string(),
|
||
updated_at: "2026-06-29T00:00:00.000Z".to_string(),
|
||
};
|
||
|
||
let replaced = replace_editor_canvas_layer_with_resource(
|
||
layers.clone(),
|
||
"layer-source",
|
||
&resource,
|
||
None,
|
||
false,
|
||
)
|
||
.expect("target layer should be replaced");
|
||
let items = replaced.as_array().expect("layers should stay array");
|
||
let layer = items
|
||
.iter()
|
||
.find(|item| item["layerId"] == "layer-source")
|
||
.expect("source layer should remain");
|
||
|
||
assert_eq!(items.len(), 2);
|
||
assert_eq!(layer["resourceId"], json!("editor-resource-cutout"));
|
||
assert_eq!(
|
||
layer["src"],
|
||
json!("/generated-character-drafts/editor/background-removal/result.png")
|
||
);
|
||
assert_eq!(layer["width"], json!(512));
|
||
assert_eq!(layer["height"], json!(768));
|
||
assert_eq!(layer["x"], json!(120));
|
||
assert_eq!(layer["y"], json!(140));
|
||
assert_eq!(layer["zIndex"], json!(6));
|
||
assert_eq!(layer["locked"], json!(true));
|
||
assert_eq!(layer["taskId"], json!("extgen-cutout"));
|
||
assert_eq!(layer["sourceResourceId"], json!("resource-source"));
|
||
assert_eq!(layer["assetKind"], json!("character"));
|
||
assert_eq!(layer["title"], json!("源图"));
|
||
assert!(layer.get("sourceAssetId").is_none());
|
||
assert!(layer.get("thumbnailSrc").is_none());
|
||
|
||
let recentered = replace_editor_canvas_layer_with_resource(
|
||
layers,
|
||
"layer-source",
|
||
&resource,
|
||
Some("勇者快速编辑"),
|
||
true,
|
||
)
|
||
.expect("quick edit replacement should recenter the layer");
|
||
let recentered_layer = recentered
|
||
.as_array()
|
||
.and_then(|items| items.iter().find(|item| item["layerId"] == "layer-source"))
|
||
.expect("quick edit source layer should remain");
|
||
assert_eq!(recentered_layer["x"], json!(24.0));
|
||
assert_eq!(recentered_layer["y"], json!(-124.0));
|
||
assert_eq!(recentered_layer["title"], json!("勇者快速编辑"));
|
||
assert_eq!(recentered_layer["x"].as_f64().unwrap() + 512.0 / 2.0, 280.0);
|
||
assert_eq!(recentered_layer["y"].as_f64().unwrap() + 768.0 / 2.0, 260.0);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_canvas_generation_completion_is_idempotent_for_existing_resource_layer() {
|
||
let layers = json!([
|
||
{
|
||
"layerId": "layer-existing-result",
|
||
"resourceId": "editor-resource-character",
|
||
"zIndex": 7
|
||
},
|
||
{
|
||
"itemType": "generation-dialog",
|
||
"layerId": "generation-dialog:dialog-character",
|
||
"resourceId": "generation-dialog:dialog-character",
|
||
"dialog": {
|
||
"id": "dialog-character",
|
||
"mode": "character",
|
||
"prompt": "银发游侠",
|
||
"status": "generating",
|
||
"composerOpen": false
|
||
}
|
||
}
|
||
]);
|
||
let completion = EditorCanvasGenerationCompletionRequest {
|
||
dialog_id: Some("dialog-character".to_string()),
|
||
title: "角色形象 1".to_string(),
|
||
placeholder: EditorCanvasGenerationPlaceholderPayload {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1.0,
|
||
height: 1.0,
|
||
original_width: 1.0,
|
||
original_height: 1.0,
|
||
},
|
||
};
|
||
let resource = EditorProjectResourcePayload {
|
||
resource_id: "editor-resource-character".to_string(),
|
||
showcase_id: None,
|
||
asset_id: None,
|
||
label: None,
|
||
project_id: "editor-project-1".to_string(),
|
||
owner_user_id: "user-1".to_string(),
|
||
author_display_name: None,
|
||
author_public_user_code: None,
|
||
image_src: "data:image/png;base64,character".to_string(),
|
||
object_key: None,
|
||
asset_object_id: None,
|
||
width: 1024,
|
||
height: 1024,
|
||
source_type: "generated".to_string(),
|
||
prompt: Some("银发游侠".to_string()),
|
||
actual_prompt: None,
|
||
model: Some("gpt-image-2".to_string()),
|
||
provider: Some("VectorEngine".to_string()),
|
||
task_id: Some("task-character".to_string()),
|
||
source_resource_id: None,
|
||
asset_kind: Some("character".to_string()),
|
||
showcase_category: None,
|
||
generation_inputs: None,
|
||
public_showcase_enabled: true,
|
||
review_status: None,
|
||
display_enabled: None,
|
||
like_count: None,
|
||
generation_cost_mud_points: 0,
|
||
refund_mud_points: None,
|
||
created_at: "2026-06-21T12:16:22.000Z".to_string(),
|
||
updated_at: "2026-06-21T12:16:22.000Z".to_string(),
|
||
};
|
||
|
||
let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource)
|
||
.expect("completion should apply");
|
||
assert!(completed.changed);
|
||
let items = completed
|
||
.layers
|
||
.as_array()
|
||
.expect("layers should stay array");
|
||
|
||
assert_eq!(
|
||
items
|
||
.iter()
|
||
.filter(|item| item["resourceId"] == "editor-resource-character")
|
||
.count(),
|
||
1
|
||
);
|
||
let dialog = items
|
||
.iter()
|
||
.find(|item| item["itemType"] == "generation-dialog")
|
||
.and_then(|item| item.get("dialog"))
|
||
.expect("dialog should remain");
|
||
assert_eq!(dialog["generatedLayerId"], json!("layer-existing-result"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_canvas_generation_completion_uses_latest_layout_placeholder() {
|
||
let layers = json!([
|
||
{
|
||
"itemType": "generation-dialog",
|
||
"layerId": "generation-dialog:dialog-character",
|
||
"resourceId": "generation-dialog:dialog-character",
|
||
"dialog": {
|
||
"id": "dialog-character",
|
||
"mode": "character",
|
||
"prompt": "银发游侠",
|
||
"status": "generating",
|
||
"composerOpen": false,
|
||
"placeholder": {
|
||
"x": 500,
|
||
"y": 400,
|
||
"width": 420,
|
||
"height": 560,
|
||
"originalWidth": 1024,
|
||
"originalHeight": 1536
|
||
}
|
||
}
|
||
}
|
||
]);
|
||
let completion = EditorCanvasGenerationCompletionRequest {
|
||
dialog_id: Some("dialog-character".to_string()),
|
||
title: "角色形象 1".to_string(),
|
||
placeholder: EditorCanvasGenerationPlaceholderPayload {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1.0,
|
||
height: 1.0,
|
||
original_width: 1.0,
|
||
original_height: 1.0,
|
||
},
|
||
};
|
||
let resource = EditorProjectResourcePayload {
|
||
resource_id: "editor-resource-character".to_string(),
|
||
showcase_id: None,
|
||
asset_id: None,
|
||
label: None,
|
||
project_id: "editor-project-1".to_string(),
|
||
owner_user_id: "user-1".to_string(),
|
||
author_display_name: None,
|
||
author_public_user_code: None,
|
||
image_src: "data:image/png;base64,character".to_string(),
|
||
object_key: None,
|
||
asset_object_id: None,
|
||
width: 1024,
|
||
height: 1536,
|
||
source_type: "generated".to_string(),
|
||
prompt: Some("银发游侠".to_string()),
|
||
actual_prompt: None,
|
||
model: Some("gpt-image-2".to_string()),
|
||
provider: Some("VectorEngine".to_string()),
|
||
task_id: Some("task-character".to_string()),
|
||
source_resource_id: None,
|
||
asset_kind: Some("character".to_string()),
|
||
showcase_category: None,
|
||
generation_inputs: None,
|
||
public_showcase_enabled: true,
|
||
review_status: None,
|
||
display_enabled: None,
|
||
like_count: None,
|
||
generation_cost_mud_points: 0,
|
||
refund_mud_points: None,
|
||
created_at: "2026-06-21T12:16:22.000Z".to_string(),
|
||
updated_at: "2026-06-21T12:16:22.000Z".to_string(),
|
||
};
|
||
|
||
let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource)
|
||
.expect("completion should apply");
|
||
let result_layer = completed
|
||
.layers
|
||
.as_array()
|
||
.expect("layers should stay array")
|
||
.iter()
|
||
.find(|item| item["resourceId"] == "editor-resource-character")
|
||
.expect("result layer should be inserted");
|
||
|
||
assert_eq!(result_layer["x"], json!(198.0));
|
||
assert_eq!(result_layer["y"], json!(-88.0));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_canvas_generation_multi_item_completion_uses_latest_layout_placeholder() {
|
||
let layers = json!([
|
||
{
|
||
"itemType": "generation-dialog",
|
||
"layerId": "generation-dialog:dialog-icons",
|
||
"resourceId": "generation-dialog:dialog-icons",
|
||
"dialog": {
|
||
"id": "dialog-icons",
|
||
"mode": "icon",
|
||
"prompt": "返回按钮",
|
||
"status": "generating",
|
||
"composerOpen": false,
|
||
"placeholder": {
|
||
"x": 300,
|
||
"y": 240,
|
||
"width": 200,
|
||
"height": 200,
|
||
"originalWidth": 200,
|
||
"originalHeight": 200
|
||
}
|
||
}
|
||
}
|
||
]);
|
||
let completion = EditorCanvasGenerationCompletionRequest {
|
||
dialog_id: Some("dialog-icons".to_string()),
|
||
title: "图标素材图集".to_string(),
|
||
placeholder: EditorCanvasGenerationPlaceholderPayload {
|
||
x: 100.0,
|
||
y: 80.0,
|
||
width: 200.0,
|
||
height: 200.0,
|
||
original_width: 200.0,
|
||
original_height: 200.0,
|
||
},
|
||
};
|
||
let item = json!({
|
||
"layerId": "layer-icon-sheet",
|
||
"resourceId": "resource-icon-sheet",
|
||
"title": "图标素材图集",
|
||
"x": 150.0,
|
||
"y": 130.0,
|
||
"width": 100.0,
|
||
"height": 100.0
|
||
});
|
||
|
||
let completed = apply_editor_canvas_generation_multi_items(
|
||
layers,
|
||
&completion,
|
||
vec![item],
|
||
Some("layer-icon-sheet".to_string()),
|
||
)
|
||
.expect("multi-item completion should apply");
|
||
let result_layer = completed
|
||
.layers
|
||
.as_array()
|
||
.expect("layers should stay array")
|
||
.iter()
|
||
.find(|item| item["resourceId"] == "resource-icon-sheet")
|
||
.expect("result layer should be inserted");
|
||
|
||
assert_eq!(result_layer["x"], json!(350.0));
|
||
assert_eq!(result_layer["y"], json!(290.0));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_canvas_character_generation_places_processed_result_and_provider_source() {
|
||
let completion = EditorCanvasGenerationCompletionRequest {
|
||
dialog_id: Some("dialog-character".to_string()),
|
||
title: "角色形象 1".to_string(),
|
||
placeholder: EditorCanvasGenerationPlaceholderPayload {
|
||
x: 100.0,
|
||
y: 80.0,
|
||
width: 200.0,
|
||
height: 300.0,
|
||
original_width: 400.0,
|
||
original_height: 600.0,
|
||
},
|
||
};
|
||
let processed = editor_project_resource_for_canvas_test(
|
||
"resource-character-cutout",
|
||
"character",
|
||
400,
|
||
600,
|
||
);
|
||
let source = editor_project_resource_for_canvas_test(
|
||
"resource-character-source",
|
||
"character",
|
||
400,
|
||
600,
|
||
);
|
||
let (items, primary_layer_id) =
|
||
build_multi_asset_canvas_layer_items(&completion, Some(&processed), Some(&source));
|
||
|
||
assert_eq!(items.len(), 2);
|
||
assert_eq!(
|
||
primary_layer_id.as_deref(),
|
||
Some("layer-editor-resource-resource-character-cutout")
|
||
);
|
||
assert_eq!(items[0]["resourceId"], json!("resource-character-cutout"));
|
||
assert_eq!(items[0]["x"], json!(0.0));
|
||
assert_eq!(items[0]["y"], json!(-70.0));
|
||
assert_eq!(items[1]["resourceId"], json!("resource-character-source"));
|
||
assert_eq!(items[1]["title"], json!("角色形象 1(原图)"));
|
||
assert_eq!(items[1]["assetKind"], json!("character"));
|
||
assert_eq!(items[1]["x"], json!(432.0));
|
||
assert_eq!(items[1]["y"], json!(-70.0));
|
||
|
||
let layers = json!([{
|
||
"itemType": "generation-dialog",
|
||
"layerId": "generation-dialog:dialog-character",
|
||
"resourceId": "generation-dialog:dialog-character",
|
||
"dialog": {
|
||
"id": "dialog-character",
|
||
"status": "generating",
|
||
"placeholder": completion.placeholder.clone()
|
||
}
|
||
}]);
|
||
let completed = apply_editor_canvas_generation_multi_items(
|
||
layers,
|
||
&completion,
|
||
items,
|
||
primary_layer_id,
|
||
)
|
||
.expect("multi-asset completion should apply");
|
||
let completed_items = completed
|
||
.layers
|
||
.as_array()
|
||
.expect("layers should stay array");
|
||
assert_eq!(
|
||
completed_items
|
||
.iter()
|
||
.filter(|item| item
|
||
.get("resourceId")
|
||
.and_then(Value::as_str)
|
||
.is_some_and(|id| id.starts_with("resource-character-")))
|
||
.count(),
|
||
2
|
||
);
|
||
let dialog = completed_items
|
||
.iter()
|
||
.find(|item| item["itemType"] == "generation-dialog")
|
||
.and_then(|item| item.get("dialog"))
|
||
.expect("generation dialog should remain");
|
||
assert_eq!(
|
||
dialog["generatedLayerId"],
|
||
json!("layer-editor-resource-resource-character-cutout")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_canvas_postprocess_fallback_only_anchors_provider_source() {
|
||
let completion = EditorCanvasGenerationCompletionRequest {
|
||
dialog_id: Some("dialog-character-fallback".to_string()),
|
||
title: "角色形象 1".to_string(),
|
||
placeholder: EditorCanvasGenerationPlaceholderPayload {
|
||
x: 100.0,
|
||
y: 80.0,
|
||
width: 200.0,
|
||
height: 300.0,
|
||
original_width: 400.0,
|
||
original_height: 600.0,
|
||
},
|
||
};
|
||
let source = editor_project_resource_for_canvas_test(
|
||
"resource-character-source",
|
||
"character",
|
||
400,
|
||
600,
|
||
);
|
||
let source_layer_id = generated_canvas_layer_id(source.resource_id.as_str());
|
||
let source_item = build_generated_canvas_layer_item(
|
||
&completion,
|
||
&completion.placeholder,
|
||
&source,
|
||
source_layer_id.as_str(),
|
||
0,
|
||
);
|
||
let layers = json!([{
|
||
"itemType": "generation-dialog",
|
||
"layerId": "generation-dialog:dialog-character-fallback",
|
||
"resourceId": "generation-dialog:dialog-character-fallback",
|
||
"dialog": {
|
||
"id": "dialog-character-fallback",
|
||
"status": "generating",
|
||
"placeholder": completion.placeholder.clone()
|
||
}
|
||
}]);
|
||
|
||
let completed = apply_editor_canvas_generation_multi_items(
|
||
layers,
|
||
&completion,
|
||
vec![source_item],
|
||
Some(source_layer_id.clone()),
|
||
)
|
||
.expect("source-only fallback should complete the generation dialog");
|
||
let completed_items = completed
|
||
.layers
|
||
.as_array()
|
||
.expect("layers should stay array");
|
||
let generated_resources = completed_items
|
||
.iter()
|
||
.filter(|item| {
|
||
item.get("resourceId")
|
||
.and_then(Value::as_str)
|
||
.is_some_and(|resource_id| resource_id.starts_with("resource-character-"))
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(generated_resources.len(), 1);
|
||
assert_eq!(
|
||
generated_resources[0]["resourceId"],
|
||
json!("resource-character-source")
|
||
);
|
||
let dialog = completed_items
|
||
.iter()
|
||
.find(|item| item["itemType"] == "generation-dialog")
|
||
.and_then(|item| item.get("dialog"))
|
||
.expect("generation dialog should remain");
|
||
assert_eq!(dialog["generatedLayerId"], json!(source_layer_id));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_canvas_spritesheet_items_include_processed_sheet_provider_source_and_slices() {
|
||
let completion = EditorCanvasGenerationCompletionRequest {
|
||
dialog_id: Some("dialog-ui-assets".to_string()),
|
||
title: "UI素材图集".to_string(),
|
||
placeholder: EditorCanvasGenerationPlaceholderPayload {
|
||
x: 100.0,
|
||
y: 80.0,
|
||
width: 256.0,
|
||
height: 256.0,
|
||
original_width: 512.0,
|
||
original_height: 512.0,
|
||
},
|
||
};
|
||
let processed = editor_project_resource_for_canvas_test(
|
||
"resource-ui-sheet",
|
||
"icon-spritesheet",
|
||
512,
|
||
512,
|
||
);
|
||
let source = editor_project_resource_for_canvas_test(
|
||
"resource-ui-sheet-source",
|
||
"icon-spritesheet",
|
||
512,
|
||
512,
|
||
);
|
||
let slice = editor_project_resource_for_canvas_test("resource-ui-slice", "icon", 128, 128);
|
||
let icons = vec![EditorIconSpritesheetIconResponse {
|
||
name: "开始按钮".to_string(),
|
||
image_src: slice.image_src.clone(),
|
||
width: 128,
|
||
height: 128,
|
||
resource: Some(slice),
|
||
asset: None,
|
||
}];
|
||
|
||
let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items(
|
||
&completion,
|
||
Some(&processed),
|
||
Some(&source),
|
||
icons.as_slice(),
|
||
);
|
||
|
||
assert_eq!(items.len(), 3);
|
||
assert_eq!(
|
||
primary_layer_id.as_deref(),
|
||
Some("layer-editor-resource-resource-ui-sheet")
|
||
);
|
||
assert_eq!(items[0]["resourceId"], json!("resource-ui-sheet"));
|
||
assert_eq!(items[1]["resourceId"], json!("resource-ui-sheet-source"));
|
||
assert_eq!(items[1]["title"], json!("UI素材图集(原图)"));
|
||
assert_eq!(items[2]["resourceId"], json!("resource-ui-slice"));
|
||
let source_right = items[1]["x"].as_f64().unwrap() + items[1]["width"].as_f64().unwrap();
|
||
assert!(items[2]["x"].as_f64().unwrap() > source_right);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_canvas_spritesheet_items_keep_processed_sheet_and_provider_source_without_slices() {
|
||
let completion = EditorCanvasGenerationCompletionRequest {
|
||
dialog_id: Some("dialog-icon-sheet".to_string()),
|
||
title: "图标素材图集".to_string(),
|
||
placeholder: EditorCanvasGenerationPlaceholderPayload {
|
||
x: 100.0,
|
||
y: 80.0,
|
||
width: 256.0,
|
||
height: 256.0,
|
||
original_width: 512.0,
|
||
original_height: 512.0,
|
||
},
|
||
};
|
||
let processed = editor_project_resource_for_canvas_test(
|
||
"resource-icon-sheet",
|
||
"icon-spritesheet",
|
||
512,
|
||
512,
|
||
);
|
||
let source = editor_project_resource_for_canvas_test(
|
||
"resource-icon-sheet-source",
|
||
"icon-spritesheet",
|
||
512,
|
||
512,
|
||
);
|
||
let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items(
|
||
&completion,
|
||
Some(&processed),
|
||
Some(&source),
|
||
&[],
|
||
);
|
||
|
||
assert_eq!(items.len(), 2);
|
||
assert_eq!(items[0]["resourceId"], json!("resource-icon-sheet"));
|
||
assert_eq!(items[1]["resourceId"], json!("resource-icon-sheet-source"));
|
||
assert_eq!(items[1]["title"], json!("图标素材图集(原图)"));
|
||
assert_eq!(
|
||
primary_layer_id.as_deref(),
|
||
Some("layer-editor-resource-resource-icon-sheet")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn manual_icon_spritesheet_limits_reject_large_images_and_excessive_slices() {
|
||
assert!(validate_editor_icon_spritesheet_manual_dimensions(2048, 2048).is_ok());
|
||
assert!(validate_editor_icon_spritesheet_manual_dimensions(4096, 1024).is_ok());
|
||
assert!(validate_editor_icon_spritesheet_manual_dimensions(2049, 2048).is_err());
|
||
assert!(validate_editor_icon_spritesheet_manual_dimensions(4097, 1).is_err());
|
||
assert!(validate_editor_icon_spritesheet_manual_slice_count(64).is_ok());
|
||
assert!(validate_editor_icon_spritesheet_manual_slice_count(65).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn editor_canvas_generation_completion_skips_when_dialog_was_removed() {
|
||
let layers = json!([
|
||
{
|
||
"layerId": "layer-existing",
|
||
"resourceId": "resource-existing",
|
||
"zIndex": 4
|
||
}
|
||
]);
|
||
let completion = EditorCanvasGenerationCompletionRequest {
|
||
dialog_id: Some("dialog-character".to_string()),
|
||
title: "角色形象 1".to_string(),
|
||
placeholder: EditorCanvasGenerationPlaceholderPayload {
|
||
x: 200.0,
|
||
y: 160.0,
|
||
width: 420.0,
|
||
height: 560.0,
|
||
original_width: 1024.0,
|
||
original_height: 1536.0,
|
||
},
|
||
};
|
||
let resource = EditorProjectResourcePayload {
|
||
resource_id: "editor-resource-character".to_string(),
|
||
showcase_id: None,
|
||
asset_id: None,
|
||
label: None,
|
||
project_id: "editor-project-1".to_string(),
|
||
owner_user_id: "user-1".to_string(),
|
||
author_display_name: None,
|
||
author_public_user_code: None,
|
||
image_src: "data:image/png;base64,character".to_string(),
|
||
object_key: None,
|
||
asset_object_id: None,
|
||
width: 1024,
|
||
height: 1536,
|
||
source_type: "generated".to_string(),
|
||
prompt: Some("银发游侠".to_string()),
|
||
actual_prompt: None,
|
||
model: Some("gpt-image-2".to_string()),
|
||
provider: Some("VectorEngine".to_string()),
|
||
task_id: Some("task-character".to_string()),
|
||
source_resource_id: None,
|
||
asset_kind: Some("character".to_string()),
|
||
showcase_category: None,
|
||
generation_inputs: None,
|
||
public_showcase_enabled: true,
|
||
review_status: None,
|
||
display_enabled: None,
|
||
like_count: None,
|
||
generation_cost_mud_points: 0,
|
||
refund_mud_points: None,
|
||
created_at: "2026-06-21T12:16:22.000Z".to_string(),
|
||
updated_at: "2026-06-21T12:16:22.000Z".to_string(),
|
||
};
|
||
|
||
let completed = apply_editor_canvas_generation_completion(layers, &completion, &resource)
|
||
.expect("completion should be ignored");
|
||
|
||
assert!(!completed.changed);
|
||
assert_eq!(
|
||
completed.layers,
|
||
json!([
|
||
{
|
||
"layerId": "layer-existing",
|
||
"resourceId": "resource-existing",
|
||
"zIndex": 4
|
||
}
|
||
])
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_icon_spritesheet_prompt_uses_ordered_descriptions_and_size_tiers() {
|
||
let descriptions = vec![
|
||
"返回按钮".to_string(),
|
||
"设置按钮".to_string(),
|
||
"下一关按钮".to_string(),
|
||
];
|
||
let prompt = build_editor_icon_spritesheet_prompt(
|
||
&descriptions,
|
||
parse_editor_screen_background_color(Some("#7FB3FF"))
|
||
.expect("screen color should parse"),
|
||
);
|
||
|
||
assert!(prompt.contains("参考图1的图标素材规范"));
|
||
assert!(prompt.contains("背景必须是单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255)"));
|
||
assert!(prompt.contains("平整无纹理、无渐变、无阴影"));
|
||
assert!(
|
||
prompt.contains("素材主体及其配色必须与背景色明显区分,不要出现与背景色相同或相近的描边、底板、投影或反光")
|
||
);
|
||
assert!(prompt.contains("返回按钮、设置按钮、下一关按钮"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_icon_spritesheet_prompt_accepts_new_background_colors() {
|
||
let prompt = build_editor_icon_spritesheet_prompt(
|
||
&["发光能量按钮".to_string()],
|
||
parse_editor_screen_background_color(Some("#A8F7F0"))
|
||
.expect("new screen color should parse"),
|
||
);
|
||
|
||
assert!(prompt.contains("背景必须是单一纯色背景 高对比浅青 #A8F7F0 / RGB(168,247,240)"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_ui_design_asset_extraction_prompt_is_fixed() {
|
||
assert_eq!(
|
||
editor_ui_design_asset_extraction_prompt(
|
||
parse_editor_screen_background_color(None)
|
||
.expect("default screen color should parse")
|
||
),
|
||
"仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 浅雾蓝 #CFEFFF / RGB(207,239,255)。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材主体及其配色必须与背景色明显区分,不要出现与背景色相同或相近的描边、底板、投影或反光。"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_icon_description_validation_filters_empty_and_rejects_more_than_limit() {
|
||
let descriptions = normalize_icon_descriptions(vec![
|
||
" 返回按钮 ".to_string(),
|
||
" ".to_string(),
|
||
"设置按钮".to_string(),
|
||
])
|
||
.expect("valid descriptions should pass");
|
||
assert_eq!(descriptions, vec!["返回按钮", "设置按钮"]);
|
||
|
||
let too_many = (0..101)
|
||
.map(|index| format!("图标{index}"))
|
||
.collect::<Vec<_>>();
|
||
assert!(normalize_icon_descriptions(too_many).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn editor_reference_inline_media_is_not_an_object_reference() {
|
||
for inline_source in ["data:image/png;base64,AAAA", "blob:browser-only"] {
|
||
let error = normalize_editor_reference_object_key(inline_source)
|
||
.expect_err("inline image should require upload before generation");
|
||
assert!(error.body_text().contains("objectKey"));
|
||
}
|
||
|
||
let source = include_str!("editor_project.rs");
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"async fn resolve_editor_reference_object_key(",
|
||
"async fn find_editor_reference_object_key_by_registered_id",
|
||
&[
|
||
"ensure_editor_reference_image_source_is_stable",
|
||
"normalize_editor_reference_object_key",
|
||
],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_reference_resource_id_resolves_registered_object_key() {
|
||
let resource = EditorProjectResourceRecord {
|
||
resource_id: "editor-resource-icon-spec".to_string(),
|
||
project_id: "project-1".to_string(),
|
||
owner_user_id: "user-1".to_string(),
|
||
asset_object_id: Some("asset-object-1".to_string()),
|
||
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
|
||
object_key: None,
|
||
width: 512,
|
||
height: 512,
|
||
source_type: "generated".to_string(),
|
||
prompt: None,
|
||
actual_prompt: None,
|
||
model: None,
|
||
provider: None,
|
||
task_id: None,
|
||
source_resource_id: None,
|
||
asset_kind: Some("icon-spec".to_string()),
|
||
generation_inputs: None,
|
||
public_showcase_enabled: true,
|
||
created_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
};
|
||
|
||
assert_eq!(
|
||
editor_reference_resource_object_key(&resource, "editor-resource-icon-spec"),
|
||
Some("generated-character-drafts/editor/spec.png".to_string())
|
||
);
|
||
assert_eq!(
|
||
editor_reference_resource_object_key(&resource, "missing"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_reference_asset_id_resolves_registered_object_key() {
|
||
let asset = EditorAssetRecord {
|
||
asset_id: "editor-asset-icon-spec".to_string(),
|
||
folder_id: "project".to_string(),
|
||
label: "图标规范".to_string(),
|
||
asset_object_id: Some("asset-object-1".to_string()),
|
||
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
|
||
thumbnail_src: None,
|
||
object_key: None,
|
||
width: 512,
|
||
height: 512,
|
||
source_type: "generated".to_string(),
|
||
prompt: None,
|
||
actual_prompt: None,
|
||
model: None,
|
||
provider: None,
|
||
task_id: None,
|
||
asset_kind: Some("icon-spec".to_string()),
|
||
generation_inputs: None,
|
||
source_resource_id: None,
|
||
public_showcase_enabled: Some(true),
|
||
generation_cost_mud_points: 0,
|
||
showcase_id: None,
|
||
showcase_review_status: None,
|
||
showcase_display_enabled: None,
|
||
showcase_like_count: None,
|
||
created_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||
};
|
||
|
||
assert_eq!(
|
||
editor_reference_asset_object_key(&asset, "editor-asset-icon-spec"),
|
||
Some("generated-character-drafts/editor/spec.png".to_string())
|
||
);
|
||
assert_eq!(editor_reference_asset_object_key(&asset, "missing"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_reference_image_sources_are_trimmed_and_limited() {
|
||
let sources = vec![
|
||
" generated-character-drafts/editor/a.png ".to_string(),
|
||
"".to_string(),
|
||
"generated-character-drafts/editor/b.png".to_string(),
|
||
"generated-character-drafts/editor/c.png".to_string(),
|
||
];
|
||
|
||
assert_eq!(
|
||
normalize_editor_reference_image_sources(Some(sources.as_slice()), 2),
|
||
vec![
|
||
"generated-character-drafts/editor/a.png",
|
||
"generated-character-drafts/editor/b.png"
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_reference_object_key_requires_generated_prefix() {
|
||
assert_eq!(
|
||
normalize_editor_reference_object_key("/generated-character-drafts/editor/ref.png")
|
||
.unwrap(),
|
||
"generated-character-drafts/editor/ref.png"
|
||
);
|
||
assert!(normalize_editor_reference_object_key("/private/editor/ref.png").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn editor_reference_object_key_matches_registered_media() {
|
||
assert!(editor_record_object_key_matches(
|
||
Some("/generated-character-drafts/editor/ref.png"),
|
||
"",
|
||
"generated-character-drafts/editor/ref.png",
|
||
));
|
||
assert!(!editor_record_object_key_matches(
|
||
Some("generated-character-drafts/editor/other.png"),
|
||
"",
|
||
"generated-character-drafts/editor/ref.png",
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_reference_asset_object_requires_matching_owner_bucket_and_key() {
|
||
let state = AppState::new(AppConfig {
|
||
oss_bucket: Some("genarrative-assets".to_string()),
|
||
oss_endpoint: Some("oss-cn-beijing.aliyuncs.com".to_string()),
|
||
oss_access_key_id: Some("test-ak".to_string()),
|
||
oss_access_key_secret: Some("test-sk".to_string()),
|
||
..AppConfig::default()
|
||
})
|
||
.expect("state should build");
|
||
let asset_object = module_assets::AssetObjectRecord {
|
||
asset_object_id: "assetobj_reference_1".to_string(),
|
||
bucket: "genarrative-assets".to_string(),
|
||
object_key: "generated-character-drafts/editor/ref.png".to_string(),
|
||
access_policy: module_assets::AssetObjectAccessPolicy::Private,
|
||
content_type: Some("image/png".to_string()),
|
||
content_length: 1024,
|
||
content_hash: None,
|
||
version: 1,
|
||
source_job_id: None,
|
||
owner_user_id: Some("user-1".to_string()),
|
||
profile_id: None,
|
||
entity_id: None,
|
||
asset_kind: "editor_generated_image".to_string(),
|
||
created_at: "2026-06-27T00:00:00Z".to_string(),
|
||
updated_at: "2026-06-27T00:00:00Z".to_string(),
|
||
};
|
||
|
||
assert!(
|
||
validate_editor_reference_asset_object(
|
||
&asset_object,
|
||
"user-1",
|
||
&state,
|
||
"generated-character-drafts/editor/ref.png",
|
||
)
|
||
.is_ok()
|
||
);
|
||
assert!(
|
||
validate_editor_reference_asset_object(
|
||
&asset_object,
|
||
"user-2",
|
||
&state,
|
||
"generated-character-drafts/editor/ref.png",
|
||
)
|
||
.is_err()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_reference_image_mime_type_falls_back_to_magic_bytes() {
|
||
assert_eq!(
|
||
infer_editor_reference_image_mime_type(b"\x89PNG\r\n\x1a\nrest"),
|
||
Some("image/png")
|
||
);
|
||
assert_eq!(
|
||
infer_editor_reference_image_mime_type(&[0xff, 0xd8, 0xff, 0xe0]),
|
||
Some("image/jpeg")
|
||
);
|
||
assert_eq!(
|
||
infer_editor_reference_image_mime_type(b"RIFFxxxxWEBPrest"),
|
||
Some("image/webp")
|
||
);
|
||
assert_eq!(infer_editor_reference_image_mime_type(b"not image"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_background_removal_source_requires_supported_content_type_and_matching_magic() {
|
||
assert_eq!(
|
||
normalize_editor_background_removal_source_mime_type(Some("IMAGE/PNG; charset=utf-8")),
|
||
Some("image/png")
|
||
);
|
||
assert_eq!(
|
||
normalize_editor_background_removal_source_mime_type(Some("image/gif")),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
normalize_editor_background_removal_source_mime_type(Some("application/pdf")),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
normalize_editor_background_removal_source_mime_type(None),
|
||
None
|
||
);
|
||
|
||
assert!(
|
||
validate_editor_background_removal_source_content(
|
||
"image/png",
|
||
b"\x89PNG\r\n\x1a\nrest"
|
||
)
|
||
.is_ok()
|
||
);
|
||
assert_eq!(
|
||
validate_editor_background_removal_source_content(
|
||
"image/png",
|
||
&[0xff, 0xd8, 0xff, 0xe0]
|
||
)
|
||
.expect_err("mismatched image content should be rejected")
|
||
.status_code(),
|
||
StatusCode::UNSUPPORTED_MEDIA_TYPE
|
||
);
|
||
assert_eq!(
|
||
validate_editor_background_removal_source_content("image/png", b"not image")
|
||
.expect_err("non-image content should be rejected")
|
||
.status_code(),
|
||
StatusCode::UNSUPPORTED_MEDIA_TYPE
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_character_postprocess_fallback_response_keeps_source_and_warning() {
|
||
let response = EditorImageGenerationResponse {
|
||
image_src: "/api/assets/source-character.png".to_string(),
|
||
object_key: Some("generated/editor/character-source.png".to_string()),
|
||
asset_object_id: Some("asset-object-character-source".to_string()),
|
||
width: 1024,
|
||
height: 1536,
|
||
source_type: "generated",
|
||
prompt: "角色形象".to_string(),
|
||
actual_prompt: Some("角色形象 actual".to_string()),
|
||
model: GPT_IMAGE_2_MODEL.to_string(),
|
||
provider: "VectorEngine",
|
||
task_id: "task-character-source".to_string(),
|
||
resource: None,
|
||
asset: None,
|
||
project: None,
|
||
warning: Some(editor_postprocess_fallback_warning(
|
||
"生成任务成功,后处理失败。",
|
||
)),
|
||
};
|
||
|
||
let payload = serde_json::to_value(response).expect("response should serialize");
|
||
|
||
assert_eq!(
|
||
payload["imageSrc"],
|
||
json!("/api/assets/source-character.png")
|
||
);
|
||
assert_eq!(
|
||
payload["objectKey"],
|
||
json!("generated/editor/character-source.png")
|
||
);
|
||
assert_eq!(
|
||
payload["warning"]["code"],
|
||
json!(EDITOR_GENERATION_POSTPROCESS_WARNING_CODE)
|
||
);
|
||
assert_eq!(
|
||
payload["warning"]["reason"],
|
||
json!("生成任务成功,后处理失败。")
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_icon_spritesheet_price_is_resolved_from_generation_config() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
assert_eq!(
|
||
resolve_editor_icon_spritesheet_price(
|
||
&state,
|
||
Some("gemini-3.1-flash-image-preview"),
|
||
Some("1K")
|
||
)
|
||
.await
|
||
.unwrap(),
|
||
12
|
||
);
|
||
assert_eq!(
|
||
resolve_editor_icon_spritesheet_price(&state, Some("gpt-image-2"), Some("1K"))
|
||
.await
|
||
.unwrap(),
|
||
3
|
||
);
|
||
let response = EditorIconSpritesheetGenerationResponse {
|
||
spritesheet_image_src: "data:image/png;base64,sheet".to_string(),
|
||
spritesheet_width: 512,
|
||
spritesheet_height: 512,
|
||
icon_image_srcs: Vec::new(),
|
||
slice_warning: Some(EditorIconSpritesheetSliceWarningResponse {
|
||
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
|
||
reason: "图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。".to_string(),
|
||
}),
|
||
prompt: "图标 prompt".to_string(),
|
||
actual_prompt: Some("图标 prompt".to_string()),
|
||
model: "gemini-3.1-flash-image-preview".to_string(),
|
||
provider: "VectorEngine",
|
||
task_id: "task-icons".to_string(),
|
||
price_mud_points: crate::editor_generation_config::editor_image_generation_mud_points(
|
||
Some("icon"),
|
||
None,
|
||
Some("1K"),
|
||
),
|
||
spritesheet_resource: None,
|
||
spritesheet_asset: None,
|
||
project: None,
|
||
warning: None,
|
||
};
|
||
let payload = serde_json::to_value(response).expect("response should serialize");
|
||
|
||
assert_eq!(payload["priceMudPoints"], json!(12));
|
||
assert_eq!(payload["iconImageSrcs"], json!([]));
|
||
assert_eq!(
|
||
payload["sliceWarning"]["code"],
|
||
json!("insufficient-connected-components")
|
||
);
|
||
assert_eq!(
|
||
payload["sliceWarning"]["reason"],
|
||
json!("图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。")
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_ui_design_asset_extraction_price_uses_selected_model_and_size() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
let request: EditorUiDesignAssetExtractionRequest = serde_json::from_value(json!({
|
||
"sourceImageSrc": "/generated-images/editor/ui-design.png",
|
||
"model": "gpt-image-2",
|
||
"screenColor": "#E6D8FF",
|
||
"segModel": "anime-seg",
|
||
"aspectRatio": "1:1",
|
||
"imageSize": "2K"
|
||
}))
|
||
.expect("UI extraction request should deserialize dimensions");
|
||
assert_eq!(request.screen_color.as_deref(), Some("#E6D8FF"));
|
||
assert_eq!(request.seg_model.as_deref(), Some("anime-seg"));
|
||
let generation_options = normalize_editor_ui_design_asset_extraction_options(
|
||
request.model.as_deref(),
|
||
request.aspect_ratio.as_str(),
|
||
request.image_size.as_str(),
|
||
)
|
||
.expect("UI extraction dimensions should pass");
|
||
|
||
assert_eq!(generation_options.model, GPT_IMAGE_2_MODEL);
|
||
assert_eq!(generation_options.size, "2048x2048");
|
||
assert_eq!(
|
||
resolve_editor_ui_design_asset_extraction_price(
|
||
&state,
|
||
Some(generation_options.model),
|
||
Some(generation_options.image_size)
|
||
)
|
||
.await
|
||
.unwrap(),
|
||
5
|
||
);
|
||
let default_generation =
|
||
normalize_editor_ui_design_asset_extraction_options(None, "1:1", "1K")
|
||
.expect("default UI extraction options should pass");
|
||
assert_eq!(default_generation.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
|
||
let blank_model_generation =
|
||
normalize_editor_ui_design_asset_extraction_options(Some(" "), "1:1", "1K")
|
||
.expect("blank UI extraction model should use default");
|
||
assert_eq!(blank_model_generation.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
|
||
assert_eq!(
|
||
resolve_editor_ui_design_asset_extraction_price(
|
||
&state,
|
||
Some(default_generation.model),
|
||
Some(default_generation.image_size),
|
||
)
|
||
.await
|
||
.unwrap(),
|
||
12
|
||
);
|
||
let ratio_error =
|
||
match normalize_editor_ui_design_asset_extraction_options(None, "16:9", "2K") {
|
||
Ok(_) => panic!("UI extraction ratio should stay square"),
|
||
Err(error) => error,
|
||
};
|
||
assert!(ratio_error.body_text().contains("aspectRatio"));
|
||
let size_error =
|
||
match normalize_editor_ui_design_asset_extraction_options(None, "1:1", "0.5K") {
|
||
Ok(_) => panic!("UI extraction size should be 1K or 2K"),
|
||
Err(error) => error,
|
||
};
|
||
assert!(size_error.body_text().contains("imageSize"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_image_generation_request_omits_price_mud_points() {
|
||
let request: EditorImageGenerationRequest = serde_json::from_value(json!({
|
||
"prompt": "生成图片",
|
||
"model": "gpt-image-2",
|
||
"screenColor": "#CFEFFF",
|
||
"segModel": "anime-seg",
|
||
"aspectRatio": "1:1",
|
||
"imageSize": "1K"
|
||
}))
|
||
.expect("image generation request should deserialize without price");
|
||
|
||
assert_eq!(request.prompt, "生成图片");
|
||
assert_eq!(request.screen_color.as_deref(), Some("#CFEFFF"));
|
||
assert_eq!(request.seg_model.as_deref(), Some("anime-seg"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_image_edit_request_omits_price_mud_points() {
|
||
let request: EditorImageEditRequest = serde_json::from_value(json!({
|
||
"prompt": "修改图片",
|
||
"sourceImageSrc": "/generated-images/editor/source.png",
|
||
"size": "2048x1152",
|
||
"model": "gemini-3.1-flash-image-preview",
|
||
"aspectRatio": "16:9",
|
||
"imageSize": "2K",
|
||
"referenceImageSrcs": ["/generated-images/editor/style.png"],
|
||
"targetLayerId": "layer-source"
|
||
}))
|
||
.expect("image edit request should deserialize without price");
|
||
|
||
assert_eq!(request.prompt, "修改图片");
|
||
assert_eq!(request.size.as_deref(), Some("2048x1152"));
|
||
assert_eq!(
|
||
request.model.as_deref(),
|
||
Some("gemini-3.1-flash-image-preview")
|
||
);
|
||
assert_eq!(request.aspect_ratio.as_deref(), Some("16:9"));
|
||
assert_eq!(request.image_size.as_deref(), Some("2K"));
|
||
let legacy_options = normalize_editor_image_edit_generation_options(
|
||
request.model.as_deref(),
|
||
None,
|
||
None,
|
||
Some("2048x1365"),
|
||
);
|
||
assert_eq!(legacy_options.aspect_ratio, "3:2");
|
||
assert_eq!(legacy_options.image_size, "2K");
|
||
assert_eq!(
|
||
request.reference_image_srcs,
|
||
Some(vec!["/generated-images/editor/style.png".to_string()])
|
||
);
|
||
assert_eq!(request.target_layer_id.as_deref(), Some("layer-source"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_image_edit_rejects_individual_icons_but_allows_spritesheets_and_specs() {
|
||
let error = ensure_editor_image_edit_asset_kind_allowed(Some("icon"))
|
||
.expect_err("individual icon assets should not support quick edit");
|
||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||
assert_eq!(
|
||
error.details().and_then(|details| details.get("message")),
|
||
Some(&json!("单个图标不支持快速编辑")),
|
||
);
|
||
assert_eq!(
|
||
error.details().and_then(|details| details.get("assetKind")),
|
||
Some(&json!("icon")),
|
||
);
|
||
|
||
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spritesheet")).is_ok());
|
||
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spec")).is_ok());
|
||
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("image")).is_ok());
|
||
assert!(ensure_editor_image_edit_asset_kind_allowed(None).is_ok());
|
||
|
||
let layers = json!([
|
||
{ "layerId": "layer-icon", "assetKind": "icon" },
|
||
{ "layerId": "layer-icon-spec", "assetKind": "icon-spec" }
|
||
]);
|
||
let icon_kind =
|
||
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon")
|
||
.expect("icon layer should exist");
|
||
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_kind).is_err());
|
||
let icon_spec_kind =
|
||
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon-spec")
|
||
.expect("icon spec layer should exist");
|
||
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_spec_kind).is_ok());
|
||
assert!(
|
||
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "missing").is_err()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_image_edit_guards_before_queue_and_owner_execution() {
|
||
let source = include_str!("editor_project.rs");
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"pub async fn edit_editor_image(",
|
||
"pub(crate) async fn edit_editor_image_for_owner",
|
||
&[
|
||
"ensure_editor_image_edit_source_allowed(",
|
||
"enqueue_editor_generation_job",
|
||
],
|
||
);
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"pub(crate) async fn edit_editor_image_for_owner",
|
||
"pub async fn remove_editor_image_background",
|
||
&[
|
||
"ensure_editor_image_edit_source_allowed(",
|
||
"parse_editor_reference_image",
|
||
],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_inline_media_sources_are_rejected_before_queueing() {
|
||
let source = include_str!("editor_project.rs");
|
||
for (start, end) in [
|
||
(
|
||
"pub async fn generate_editor_image(",
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
),
|
||
(
|
||
"pub async fn edit_editor_image(",
|
||
"pub(crate) async fn edit_editor_image_for_owner",
|
||
),
|
||
(
|
||
"pub async fn remove_editor_image_background(",
|
||
"pub(crate) async fn remove_editor_image_background_for_owner",
|
||
),
|
||
(
|
||
"pub async fn generate_editor_icon_spritesheet(",
|
||
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
|
||
),
|
||
(
|
||
"pub async fn extract_editor_ui_design_assets(",
|
||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||
),
|
||
] {
|
||
assert_function_contains_in_order(
|
||
source,
|
||
start,
|
||
end,
|
||
&[
|
||
"ensure_editor_reference_image_source",
|
||
"enqueue_editor_generation_job",
|
||
],
|
||
);
|
||
}
|
||
|
||
for inline_source in [
|
||
"data:image/png;base64,AAAA",
|
||
" DATA:image/png;base64,AAAA",
|
||
"blob:browser-only",
|
||
] {
|
||
let error = ensure_editor_reference_image_source_is_stable(
|
||
inline_source,
|
||
"editor-test",
|
||
"sourceImageSrc",
|
||
"测试图片",
|
||
)
|
||
.expect_err("inline media must require upload before generation");
|
||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||
assert!(error.body_text().contains("先上传 OSS"));
|
||
}
|
||
assert!(
|
||
ensure_editor_reference_image_source_is_stable(
|
||
"/generated-images/editor/reference.png",
|
||
"editor-test",
|
||
"sourceImageSrc",
|
||
"测试图片",
|
||
)
|
||
.is_ok()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_image_edit_can_complete_by_replacing_target_layer() {
|
||
let source = include_str!("editor_project.rs");
|
||
assert_function_contains(
|
||
source,
|
||
"pub(crate) async fn edit_editor_image_for_owner",
|
||
"pub async fn remove_editor_image_background",
|
||
&[
|
||
"payload.canvas_completion.is_some()",
|
||
"complete_editor_canvas_background_removal",
|
||
"payload.target_layer_id.as_deref()",
|
||
],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_icon_spritesheet_request_accepts_stable_extra_references() {
|
||
let request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({
|
||
"referenceImageSrc": "/generated-images/editor/spec.png",
|
||
"referenceImageSrcs": ["/generated-images/editor/style.png"],
|
||
"iconDescriptions": ["返回按钮"],
|
||
"screenColor": "#7FB3FF",
|
||
"segModel": "anime-seg",
|
||
"assetLabel": " 冒险界面图标 "
|
||
}))
|
||
.expect("icon spritesheet request should deserialize references");
|
||
|
||
assert_eq!(
|
||
request.reference_image_srcs,
|
||
Some(vec!["/generated-images/editor/style.png".to_string()])
|
||
);
|
||
assert_eq!(request.screen_color.as_deref(), Some("#7FB3FF"));
|
||
assert_eq!(request.seg_model.as_deref(), Some("anime-seg"));
|
||
assert_eq!(request.asset_label.as_deref(), Some(" 冒险界面图标 "));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_generated_asset_label_trims_custom_name_and_falls_back_for_blank_input() {
|
||
assert_eq!(
|
||
resolve_editor_generated_asset_label(
|
||
Some(" 冒险界面图标 ".to_string()),
|
||
"图标素材图集"
|
||
),
|
||
"冒险界面图标"
|
||
);
|
||
assert_eq!(
|
||
resolve_editor_generated_asset_label(Some(" ".to_string()), "图标素材图集"),
|
||
"图标素材图集"
|
||
);
|
||
assert_eq!(
|
||
resolve_editor_generated_asset_label(Some("图".repeat(81)), "图标素材图集")
|
||
.chars()
|
||
.count(),
|
||
EDITOR_ASSET_LABEL_MAX_CHARS
|
||
);
|
||
assert_eq!(
|
||
editor_generated_asset_variant_label("冒险界面图标", "原图"),
|
||
"冒险界面图标(原图)"
|
||
);
|
||
let long_variant = editor_generated_asset_variant_label("图".repeat(80).as_str(), "原图");
|
||
assert_eq!(long_variant.chars().count(), EDITOR_ASSET_LABEL_MAX_CHARS);
|
||
assert!(long_variant.ends_with("(原图)"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_icon_spritesheet_slice_request_requires_project_source_and_layout() {
|
||
let request: EditorIconSpritesheetSliceRequest = serde_json::from_value(json!({
|
||
"projectId": "project-1",
|
||
"sourceResourceId": "resource-atlas-1",
|
||
"assetFolderId": "project",
|
||
"canvasCompletion": {
|
||
"title": "拆分图集",
|
||
"placeholder": {
|
||
"x": 120.0,
|
||
"y": 80.0,
|
||
"width": 360.0,
|
||
"height": 360.0,
|
||
"originalWidth": 1024.0,
|
||
"originalHeight": 1024.0
|
||
}
|
||
}
|
||
}))
|
||
.expect("spritesheet slice request should deserialize");
|
||
|
||
assert_eq!(request.project_id, "project-1");
|
||
assert_eq!(request.source_resource_id, "resource-atlas-1");
|
||
assert_eq!(request.asset_folder_id.as_deref(), Some("project"));
|
||
assert_eq!(request.canvas_completion.title, "拆分图集");
|
||
assert_eq!(request.canvas_completion.placeholder.width, 360.0);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_manual_atlas_split_lookup_keeps_original_provenance_after_project_copy() {
|
||
let mut copied_resource = editor_project_resource_for_canvas_test(
|
||
"copied-resource-atlas-1",
|
||
"icon-spritesheet",
|
||
1024,
|
||
1024,
|
||
);
|
||
copied_resource.source_resource_id = Some("resource-atlas-1".to_string());
|
||
copied_resource.asset_object_id = Some("asset-object-atlas-1".to_string());
|
||
copied_resource.object_key = Some("generated/icon-atlas-1.png".to_string());
|
||
|
||
let lookup = editor_manual_atlas_split_group_source_lookup_input(
|
||
"user-1",
|
||
copied_resource.resource_id.as_str(),
|
||
copied_resource.source_resource_id.as_deref(),
|
||
copied_resource.asset_object_id.as_deref(),
|
||
copied_resource.object_key.as_deref(),
|
||
);
|
||
|
||
assert_eq!(lookup.owner_user_id, "user-1");
|
||
assert_eq!(
|
||
lookup.source_resource_ids,
|
||
vec!["copied-resource-atlas-1", "resource-atlas-1"]
|
||
);
|
||
assert_eq!(
|
||
lookup.asset_object_id.as_deref(),
|
||
Some("asset-object-atlas-1")
|
||
);
|
||
assert_eq!(
|
||
lookup.object_key.as_deref(),
|
||
Some("generated/icon-atlas-1.png")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_bgfilter_seg_model_parser_defaults_and_rejects_unknown() {
|
||
assert_eq!(
|
||
parse_editor_bgfilter_seg_model(None).expect("default should parse"),
|
||
EDITOR_BGFILTER_DEFAULT_SEG_MODEL
|
||
);
|
||
assert_eq!(
|
||
parse_editor_bgfilter_seg_model(Some("Anime-Seg")).expect("anime seg should parse"),
|
||
EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG
|
||
);
|
||
assert!(parse_editor_bgfilter_seg_model(Some("u2net")).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn editor_bgfilter_worker_budget_covers_two_attempts_and_parent_reserves() {
|
||
let config = AppConfig::default();
|
||
// N=16、est=5s → attempt=160s、callBudget=321s,全部由公式运行时派生。
|
||
assert_eq!(config.bgfilter_provider_attempt_timeout_ms(), 160_000);
|
||
assert_eq!(config.bgfilter_call_budget_ms(), 321_000);
|
||
assert_eq!(
|
||
editor_bgfilter_flat_deadline_reserve(30_000),
|
||
Duration::from_secs(39)
|
||
);
|
||
|
||
// complex:父剩余 400s − 2s 传输窗 − 321s callBudget → 排队上限约 77s。
|
||
let complex_deadline = Instant::now() + Duration::from_secs(400);
|
||
let complex_wait = crate::bgfilter_worker::max_queue_wait_ms(
|
||
config.bgfilter_call_budget_ms(),
|
||
Some(complex_deadline),
|
||
EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW,
|
||
);
|
||
assert!((76_500..=77_000).contains(&complex_wait));
|
||
|
||
// flat:再扣 39s fallback 预留 → 约 40s,排队吃不掉 fallback 的执行窗口。
|
||
let flat_deadline = Instant::now() + Duration::from_secs(400);
|
||
let flat_wait = crate::bgfilter_worker::max_queue_wait_ms(
|
||
config.bgfilter_call_budget_ms(),
|
||
Some(flat_deadline),
|
||
editor_bgfilter_flat_deadline_reserve(30_000),
|
||
);
|
||
assert!((39_500..=40_000).contains(&flat_wait));
|
||
|
||
// 父剩余放不下一次完整调用(排队 + callBudget + 预留)时必须为 0:不发请求直接降级。
|
||
let tight_deadline = Instant::now() + Duration::from_secs(100);
|
||
assert_eq!(
|
||
crate::bgfilter_worker::max_queue_wait_ms(
|
||
config.bgfilter_call_budget_ms(),
|
||
Some(tight_deadline),
|
||
editor_bgfilter_flat_deadline_reserve(30_000),
|
||
),
|
||
0
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_flat_bgfilter_expired_parent_deadline_does_not_start_fallback() {
|
||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||
let audit = crate::external_api_audit::ExternalApiAuditContext {
|
||
user_id: Some("user-deadline".to_string()),
|
||
profile_id: None,
|
||
request_id: Some("request-deadline".to_string()),
|
||
external_call_deadline: Instant::now().checked_sub(Duration::from_millis(1)),
|
||
};
|
||
|
||
let error = match remove_editor_generated_screen_background_with_bgfilter(
|
||
&state,
|
||
"generated-character-drafts/editor/source.png",
|
||
parse_editor_screen_background_color(Some("#CFEFFF"))
|
||
.expect("screen color should parse"),
|
||
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
|
||
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
|
||
&audit,
|
||
)
|
||
.await
|
||
{
|
||
Ok(_) => panic!("expired parent deadline should stop before flat fallback"),
|
||
Err(error) => error,
|
||
};
|
||
|
||
assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT);
|
||
assert_eq!(
|
||
error
|
||
.details()
|
||
.and_then(|details| details.get("workerCode")),
|
||
Some(&json!("deadline_exceeded"))
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_flat_bgfilter_background_removal_uses_internal_worker_once() {
|
||
let response_png = encode_test_png(3, 2);
|
||
let (base_url, request_receiver, server) = spawn_bgfilter_worker_png_mock_with_delay(
|
||
response_png.clone(),
|
||
Duration::from_millis(100),
|
||
);
|
||
let state = AppState::new(AppConfig {
|
||
bgfilter_worker_base_url: base_url,
|
||
bgfilter_internal_token: Some("flat-internal-token".to_string()),
|
||
..AppConfig::default()
|
||
})
|
||
.expect("state should build");
|
||
let audit = crate::external_api_audit::ExternalApiAuditContext {
|
||
user_id: Some("user-flat".to_string()),
|
||
profile_id: Some("project-flat".to_string()),
|
||
request_id: Some("request-flat".to_string()),
|
||
external_call_deadline: None,
|
||
};
|
||
|
||
let removed = remove_editor_generated_screen_background_with_bgfilter(
|
||
&state,
|
||
"generated-character-drafts/editor/source.png",
|
||
parse_editor_screen_background_color(Some("#CFEFFF"))
|
||
.expect("screen color should parse"),
|
||
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
|
||
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
|
||
&audit,
|
||
)
|
||
.await
|
||
.expect("internal worker PNG response should decode");
|
||
let request = request_receiver
|
||
.recv_timeout(Duration::from_secs(1))
|
||
.expect("mock server should capture request");
|
||
server.join().expect("mock server should stop cleanly");
|
||
let request_text = String::from_utf8_lossy(request.as_slice());
|
||
let lowercase_request = request_text.to_ascii_lowercase();
|
||
let payload = parse_mock_http_json_body(request.as_slice());
|
||
|
||
assert!(
|
||
request_text.starts_with("POST /internal/bgfilter/v1/remove-background HTTP/1.1\r\n")
|
||
);
|
||
assert!(lowercase_request.contains("\r\nauthorization: bearer flat-internal-token\r\n"));
|
||
let internal_request_id = payload["requestId"]
|
||
.as_str()
|
||
.expect("internal request id should be a string");
|
||
assert!(uuid::Uuid::parse_str(internal_request_id).is_ok());
|
||
assert_ne!(internal_request_id, "request-flat");
|
||
assert_eq!(
|
||
payload["sourceObjectKey"],
|
||
json!("generated-character-drafts/editor/source.png")
|
||
);
|
||
assert_eq!(payload["backgroundMode"], json!("flat"));
|
||
assert_eq!(payload["screenColor"], json!("#CFEFFF"));
|
||
assert_eq!(payload["segModel"], json!("birefnet"));
|
||
assert_eq!(payload["crossCheck"], json!(true));
|
||
// 无绝对 deadline 时排队额度取 callBudget 等长的有界默认值;两者都由 N/est 公式派生。
|
||
assert_eq!(payload["maxQueueWaitMs"], json!(321_000));
|
||
assert_eq!(payload["callBudgetMs"], json!(321_000));
|
||
assert_eq!(payload["auditContext"]["userId"], json!("user-flat"));
|
||
assert_eq!(payload["auditContext"]["requestId"], json!("request-flat"));
|
||
|
||
assert_eq!(removed.image.bytes, response_png);
|
||
assert_eq!(removed.image.mime_type, "image/png");
|
||
assert_eq!(removed.image.extension, "png");
|
||
assert_eq!(removed.provider, "BgFilter");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn editor_complex_bgfilter_background_removal_uses_internal_worker_once() {
|
||
let response_png = encode_test_png(3, 2);
|
||
let (base_url, request_receiver, server) =
|
||
spawn_bgfilter_worker_png_mock(response_png.clone());
|
||
let state = AppState::new(AppConfig {
|
||
bgfilter_worker_base_url: base_url,
|
||
bgfilter_internal_token: Some("complex-internal-token".to_string()),
|
||
..AppConfig::default()
|
||
})
|
||
.expect("state should build");
|
||
let audit = crate::external_api_audit::ExternalApiAuditContext {
|
||
user_id: Some("user-complex".to_string()),
|
||
profile_id: None,
|
||
request_id: Some("request-complex".to_string()),
|
||
external_call_deadline: None,
|
||
};
|
||
|
||
let removed = request_editor_background_removal_image_with_bgfilter_worker(
|
||
&state,
|
||
"generated-character-drafts/editor/manual-source.png",
|
||
&audit,
|
||
)
|
||
.await
|
||
.expect("internal worker PNG response should decode");
|
||
let request = request_receiver
|
||
.recv_timeout(Duration::from_secs(1))
|
||
.expect("mock server should capture request");
|
||
server.join().expect("mock server should stop cleanly");
|
||
let request_text = String::from_utf8_lossy(request.as_slice());
|
||
let lowercase_request = request_text.to_ascii_lowercase();
|
||
let payload = parse_mock_http_json_body(request.as_slice());
|
||
|
||
assert!(
|
||
request_text.starts_with("POST /internal/bgfilter/v1/remove-background HTTP/1.1\r\n")
|
||
);
|
||
assert!(lowercase_request.contains("\r\nauthorization: bearer complex-internal-token\r\n"));
|
||
let internal_request_id = payload["requestId"]
|
||
.as_str()
|
||
.expect("internal request id should be a string");
|
||
assert!(uuid::Uuid::parse_str(internal_request_id).is_ok());
|
||
assert_ne!(internal_request_id, "request-complex");
|
||
assert_eq!(
|
||
payload["sourceObjectKey"],
|
||
json!("generated-character-drafts/editor/manual-source.png")
|
||
);
|
||
assert_eq!(payload["backgroundMode"], json!("complex"));
|
||
assert!(payload["screenColor"].is_null());
|
||
assert_eq!(payload["segModel"], json!("birefnet"));
|
||
assert_eq!(payload["crossCheck"], json!(false));
|
||
assert_eq!(payload["maxQueueWaitMs"], json!(321_000));
|
||
assert_eq!(payload["callBudgetMs"], json!(321_000));
|
||
assert_eq!(
|
||
payload["auditContext"]["requestId"],
|
||
json!("request-complex")
|
||
);
|
||
|
||
assert_eq!((removed.width, removed.height), (3, 2));
|
||
assert_eq!(removed.image.mime_type, "image/png");
|
||
assert_eq!(removed.image.extension, "png");
|
||
assert_eq!(removed.image.bytes, response_png);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_image_and_spritesheet_generation_are_billable() {
|
||
let source = include_str!("editor_project.rs");
|
||
assert_function_contains(
|
||
source,
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
"fn normalize_editor_image_generation_size",
|
||
&[
|
||
"execute_billable_asset_operation_with_cost",
|
||
"configured_price_mud_points",
|
||
],
|
||
);
|
||
assert_function_contains(
|
||
source,
|
||
"pub(crate) async fn edit_editor_image_for_owner",
|
||
"pub async fn generate_editor_icon_spritesheet",
|
||
&[
|
||
"execute_billable_asset_operation_with_cost",
|
||
"expected_price_mud_points",
|
||
],
|
||
);
|
||
assert_function_contains(
|
||
source,
|
||
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
|
||
"pub async fn extract_editor_ui_design_assets",
|
||
&[
|
||
"execute_billable_asset_operation_with_cost",
|
||
"expected_price_mud_points",
|
||
],
|
||
);
|
||
assert_function_contains(
|
||
source,
|
||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||
"pub(crate) fn editor_project_payload_from_record",
|
||
&[
|
||
"execute_billable_asset_operation_with_cost",
|
||
"expected_price_mud_points",
|
||
],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_canvas_screen_background_generation_uses_bgfilter_postprocess() {
|
||
let source = include_str!("editor_project.rs");
|
||
assert_function_contains(
|
||
source,
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
"fn normalize_editor_image_generation_size",
|
||
&[
|
||
"persist_editor_provider_source_image",
|
||
"caller.report_processing_phase(state).await?",
|
||
"remove_editor_generated_screen_background_with_bgfilter",
|
||
"EDITOR_BGFILTER_CROSS_CHECK_ENABLED",
|
||
"persist_editor_provider_source_resource",
|
||
"build_multi_asset_canvas_layer_items",
|
||
"if is_character_generation",
|
||
],
|
||
);
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
"fn normalize_editor_image_generation_size",
|
||
&[
|
||
"persist_editor_provider_source_image",
|
||
"persist_editor_provider_source_resource",
|
||
"remove_editor_generated_screen_background_with_bgfilter",
|
||
"EDITOR_BGFILTER_CROSS_CHECK_ENABLED",
|
||
],
|
||
);
|
||
assert_function_contains(
|
||
source,
|
||
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
|
||
"pub async fn extract_editor_ui_design_assets",
|
||
&[
|
||
"persist_editor_provider_source_image",
|
||
"caller.report_processing_phase(state).await?",
|
||
"remove_editor_generated_screen_background_with_bgfilter",
|
||
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
|
||
"persist_editor_provider_source_resource",
|
||
"slice_generated_icon_spritesheet_by_connected_components",
|
||
"persist_editor_spritesheet_slices",
|
||
],
|
||
);
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
|
||
"pub async fn extract_editor_ui_design_assets",
|
||
&[
|
||
"persist_editor_provider_source_image",
|
||
"persist_editor_provider_source_resource",
|
||
"caller.report_processing_phase(state).await?",
|
||
"remove_editor_generated_screen_background_with_bgfilter",
|
||
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
|
||
],
|
||
);
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"pub(crate) async fn remove_editor_image_background_for_owner",
|
||
"struct EditorBackgroundRemovalImage",
|
||
&[
|
||
"caller.report_processing_phase(state).await?",
|
||
"resolve_editor_reference_object_key_for_owner",
|
||
"validate_editor_background_removal_source",
|
||
"request_editor_background_removal_image_with_bgfilter_worker",
|
||
],
|
||
);
|
||
assert_function_contains(
|
||
source,
|
||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||
"pub(crate) fn editor_project_payload_from_record",
|
||
&[
|
||
"persist_editor_provider_source_image",
|
||
"remove_editor_generated_screen_background_with_bgfilter",
|
||
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
|
||
"persist_editor_provider_source_resource",
|
||
"slice_generated_icon_spritesheet_all_by_connected_components",
|
||
],
|
||
);
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||
"pub(crate) fn editor_project_payload_from_record",
|
||
&[
|
||
"persist_editor_provider_source_image",
|
||
"persist_editor_provider_source_resource",
|
||
"remove_editor_generated_screen_background_with_bgfilter",
|
||
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
|
||
],
|
||
);
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
|
||
"async fn fallback_editor_screen_background_removal",
|
||
&[
|
||
"crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry",
|
||
"crate::bgfilter_worker::BgfilterBackgroundMode::Flat",
|
||
"editor_bgfilter_flat_deadline_reserve",
|
||
"state.config.aliyun_matting_request_timeout_ms",
|
||
"error.allows_flat_fallback()",
|
||
"fallback_editor_screen_background_removal",
|
||
],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
|
||
"async fn fallback_editor_screen_background_removal",
|
||
&[
|
||
"for attempt in",
|
||
concat!("EDITOR_BGFILTER_", "RETRY_COUNT"),
|
||
concat!("editor_bgfilter_", "circuit"),
|
||
"reqwest::multipart",
|
||
concat!("editor_bgfilter_", "http_client"),
|
||
],
|
||
);
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"async fn fallback_editor_screen_background_removal",
|
||
"fn sign_editor_private_object_read_url",
|
||
&[
|
||
"segment_image_url_with_aliyun_matting",
|
||
"download_editor_persisted_image_object",
|
||
"remove_editor_generated_green_screen_background",
|
||
"drop(source_image)",
|
||
],
|
||
);
|
||
assert_function_contains(
|
||
source,
|
||
"async fn request_editor_background_removal_image_with_bgfilter_worker",
|
||
"pub(crate) struct EditorScreenBackgroundRemovalOutput",
|
||
&[
|
||
"crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry",
|
||
"crate::bgfilter_worker::BgfilterBackgroundMode::Complex",
|
||
"EDITOR_BGFILTER_DEFAULT_SEG_MODEL",
|
||
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
|
||
"EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW",
|
||
".map_err(crate::bgfilter_worker::BgfilterClientError::into_app_error)?",
|
||
],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"async fn request_editor_background_removal_image_with_bgfilter_worker",
|
||
"pub(crate) struct EditorScreenBackgroundRemovalOutput",
|
||
&[
|
||
"for attempt in",
|
||
"fallback_editor_screen_background_removal",
|
||
"reqwest::multipart",
|
||
concat!("editor_bgfilter_", "http_client"),
|
||
concat!("editor_bgfilter_", "base_url"),
|
||
concat!("editor_bgfilter_", "token"),
|
||
],
|
||
);
|
||
assert!(!source.contains(concat!("const EDITOR_BGFILTER_", "RETRY_COUNT")));
|
||
assert!(!source.contains(concat!("static EDITOR_BGFILTER_", "CIRCUIT")));
|
||
assert_function_not_contains(
|
||
source,
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
"fn normalize_editor_image_generation_size",
|
||
&["try_remove_editor_downloaded_image_background"],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
"fn normalize_editor_image_generation_size",
|
||
&["remove_editor_generated_green_screen_background"],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
|
||
"pub async fn extract_editor_ui_design_assets",
|
||
&["try_remove_editor_downloaded_image_background"],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
|
||
"pub async fn extract_editor_ui_design_assets",
|
||
&["remove_editor_generated_green_screen_background"],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||
"pub(crate) fn editor_project_payload_from_record",
|
||
&["try_remove_editor_downloaded_image_background"],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||
"pub(crate) fn editor_project_payload_from_record",
|
||
&["remove_editor_generated_green_screen_background"],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
"fn normalize_editor_image_generation_size",
|
||
&[
|
||
concat!("prepare_editor_", "character_image_for_response"),
|
||
"Err(_)",
|
||
],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
|
||
"pub async fn extract_editor_ui_design_assets",
|
||
&[
|
||
concat!("prepare_editor_", "icon_spritesheet_image_for_persist"),
|
||
"Err(_)",
|
||
],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||
"pub(crate) fn editor_project_payload_from_record",
|
||
&["Err(_) => image"],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_flat_bgfilter_parent_calls_internal_worker_once_before_fallback() {
|
||
let source = include_str!("editor_project.rs");
|
||
assert_function_contains(
|
||
source,
|
||
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
|
||
"async fn fallback_editor_screen_background_removal",
|
||
&[
|
||
"crate::bgfilter_worker::request_bgfilter_worker",
|
||
"fallback_editor_screen_background_removal",
|
||
],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
|
||
"async fn fallback_editor_screen_background_removal",
|
||
&[
|
||
"for attempt in",
|
||
"will_retry",
|
||
concat!("EDITOR_BGFILTER_", "RETRY_COUNT"),
|
||
],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_processing_phase_retries_transport_once_but_not_fencing() {
|
||
let source = include_str!("editor_project.rs");
|
||
assert!(source.contains("const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1;"));
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"pub(crate) async fn report_processing(&self, state: &AppState)",
|
||
"fn map_editor_generation_phase_update_error",
|
||
&[
|
||
"let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1",
|
||
"for attempt in 1..=max_attempts",
|
||
".update_external_generation_job_phase(input.clone())",
|
||
"should_retry_editor_generation_phase_update",
|
||
"if will_retry",
|
||
"continue",
|
||
"map_editor_generation_phase_update_error(error)",
|
||
],
|
||
);
|
||
assert_function_contains(
|
||
source,
|
||
"fn map_editor_generation_phase_update_error",
|
||
"#[derive(Debug, Serialize)]",
|
||
&["error.is_lease_fencing_rejected()", "StatusCode::CONFLICT"],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_processing_phase_retry_policy_stops_after_one_transport_retry() {
|
||
let max_attempts = EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT + 1;
|
||
let transport_errors = [
|
||
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Build(
|
||
"initial websocket connect failed".to_string(),
|
||
)),
|
||
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::ConnectDropped),
|
||
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Timeout(
|
||
spacetime_client::SpacetimeClientStage::ProcedureResult,
|
||
)),
|
||
];
|
||
for error in &transport_errors {
|
||
assert!(should_retry_editor_generation_phase_update(
|
||
error,
|
||
1,
|
||
max_attempts
|
||
));
|
||
assert!(!should_retry_editor_generation_phase_update(
|
||
error,
|
||
max_attempts,
|
||
max_attempts
|
||
));
|
||
}
|
||
|
||
let lease = ExternalGenerationJobPhaseUpdateError::LeaseFencingRejected(
|
||
"lease token 不匹配".to_string(),
|
||
);
|
||
assert!(!should_retry_editor_generation_phase_update(
|
||
&lease,
|
||
1,
|
||
max_attempts
|
||
));
|
||
|
||
let rejected = ExternalGenerationJobPhaseUpdateError::Rejected("phase 非法".to_string());
|
||
assert!(!should_retry_editor_generation_phase_update(
|
||
&rejected,
|
||
1,
|
||
max_attempts
|
||
));
|
||
|
||
for error in [
|
||
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Procedure(
|
||
"procedure rejected".to_string(),
|
||
)),
|
||
ExternalGenerationJobPhaseUpdateError::Rpc(SpacetimeClientError::Runtime(
|
||
"runtime invariant failed".to_string(),
|
||
)),
|
||
] {
|
||
assert!(!should_retry_editor_generation_phase_update(
|
||
&error,
|
||
1,
|
||
max_attempts
|
||
));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn editor_static_postprocess_failure_completes_canvas_with_source_only() {
|
||
let source = include_str!("editor_project.rs");
|
||
for (start, end) in [
|
||
(
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
"fn normalize_editor_image_generation_size",
|
||
),
|
||
(
|
||
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
|
||
"pub async fn extract_editor_ui_design_assets",
|
||
),
|
||
(
|
||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||
"pub(crate) fn editor_project_payload_from_record",
|
||
),
|
||
] {
|
||
assert_function_contains_in_order(
|
||
source,
|
||
start,
|
||
end,
|
||
&[
|
||
"persist_editor_provider_source_resource",
|
||
"caller.report_processing_phase(state).await?",
|
||
"remove_editor_generated_screen_background_with_bgfilter",
|
||
"Err(error)",
|
||
"complete_editor_canvas_generation",
|
||
"source_record.resource.as_ref()",
|
||
"warning: Some(editor_postprocess_fallback_warning",
|
||
],
|
||
);
|
||
|
||
let start_index = source
|
||
.find(start)
|
||
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
|
||
let function_tail = &source[start_index..];
|
||
let end_index = function_tail
|
||
.find(end)
|
||
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
|
||
let body = &function_tail[..end_index];
|
||
let fallback_start = body
|
||
.find("let removal = match removal {")
|
||
.unwrap_or_else(|| panic!("{start} should match only the postprocess result"));
|
||
let success_marker = if start.contains("generate_editor_image_for_owner") {
|
||
"image = restored_removal_image;"
|
||
} else {
|
||
"let removal_provider = removal.provider;"
|
||
};
|
||
let fallback_end = body[fallback_start..]
|
||
.find(success_marker)
|
||
.map(|offset| fallback_start + offset)
|
||
.unwrap_or_else(|| {
|
||
panic!("{start} should resume its processed-image success path")
|
||
});
|
||
let fallback = &body[fallback_start..fallback_end];
|
||
assert!(fallback.contains("return Ok(json_success_body"));
|
||
for forbidden in [
|
||
"persist_editor_generated_image(",
|
||
"persist_editor_generated_asset(",
|
||
"slice_generated_icon_spritesheet",
|
||
"persist_editor_spritesheet_slices",
|
||
] {
|
||
assert!(
|
||
!fallback.contains(forbidden),
|
||
"{start} fallback must not absorb later failure scope: {forbidden}"
|
||
);
|
||
}
|
||
if start.contains("spritesheet") || start.contains("ui_design") {
|
||
let success_tail = &body[fallback_end..];
|
||
assert!(success_tail.contains("persist_editor_generated_image("));
|
||
assert!(success_tail.contains("persist_editor_generated_asset("));
|
||
assert!(success_tail.contains("persist_editor_spritesheet_slices"));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn editor_character_postprocess_fallback_is_inline_and_does_not_swallow_other_failures() {
|
||
let source = include_str!("editor_project.rs");
|
||
let start = source
|
||
.find("pub(crate) async fn generate_editor_image_for_owner")
|
||
.expect("character generation function should exist");
|
||
let function_tail = &source[start..];
|
||
let end = function_tail
|
||
.find("fn normalize_editor_image_generation_size")
|
||
.expect("character generation function end marker should exist");
|
||
let body = &function_tail[..end];
|
||
|
||
let phase = body
|
||
.find("caller.report_processing_phase(state).await?")
|
||
.expect("phase report should remain fallible");
|
||
let removal = body
|
||
.find("let removal = remove_editor_generated_screen_background_with_bgfilter")
|
||
.expect("transparent background postprocess should exist");
|
||
let fallback_start = body
|
||
.find("let removal = match removal {")
|
||
.expect("only the postprocess result should be matched");
|
||
let fallback_end = body[fallback_start..]
|
||
.find("image = restored_removal_image;")
|
||
.map(|offset| fallback_start + offset)
|
||
.expect("processed-image success path should follow the fallback match");
|
||
let fallback = &body[fallback_start..fallback_end];
|
||
|
||
assert!(
|
||
phase < removal,
|
||
"phase errors must fail before fallback is considered"
|
||
);
|
||
for snippet in [
|
||
"Err(error)",
|
||
"complete_editor_canvas_generation",
|
||
"source_record.resource.as_ref()",
|
||
"return Ok(json_success_body",
|
||
"warning: Some(editor_postprocess_fallback_warning",
|
||
] {
|
||
assert!(
|
||
fallback.contains(snippet),
|
||
"character postprocess fallback should contain {snippet}"
|
||
);
|
||
}
|
||
for forbidden in [
|
||
"persist_editor_generated_image(",
|
||
"persist_editor_generated_asset(",
|
||
"slice_generated_icon_spritesheet",
|
||
"build_multi_asset_canvas_layer_items",
|
||
] {
|
||
assert!(
|
||
!fallback.contains(forbidden),
|
||
"character postprocess fallback must not absorb later failure scope: {forbidden}"
|
||
);
|
||
}
|
||
|
||
let success_tail = &body[fallback_end..];
|
||
assert!(success_tail.contains("persist_editor_generated_image("));
|
||
assert!(success_tail.contains("persist_editor_generated_asset("));
|
||
assert!(success_tail.contains(".await?;"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_manual_background_removal_calls_internal_worker_once_without_fallback() {
|
||
let source = include_str!("editor_project.rs");
|
||
assert_function_contains(
|
||
source,
|
||
"async fn request_editor_background_removal_image_with_bgfilter_worker",
|
||
"pub(crate) struct EditorScreenBackgroundRemovalOutput",
|
||
&[
|
||
"crate::bgfilter_worker::request_bgfilter_worker",
|
||
"BgfilterBackgroundMode::Complex",
|
||
],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"async fn request_editor_background_removal_image_with_bgfilter_worker",
|
||
"pub(crate) struct EditorScreenBackgroundRemovalOutput",
|
||
&[
|
||
"for attempt in",
|
||
"will_retry",
|
||
"fallback_editor_screen_background_removal",
|
||
],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_matting_releases_source_buffers_at_oss_boundaries() {
|
||
let source = include_str!("editor_project.rs");
|
||
for (start, end) in [
|
||
(
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
"fn normalize_editor_image_generation_size",
|
||
),
|
||
(
|
||
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
|
||
"pub async fn extract_editor_ui_design_assets",
|
||
),
|
||
(
|
||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||
"pub(crate) fn editor_project_payload_from_record",
|
||
),
|
||
] {
|
||
assert_function_contains_in_order(
|
||
source,
|
||
start,
|
||
end,
|
||
&[
|
||
"persist_editor_provider_source_image",
|
||
"remove_editor_generated_screen_background_with_bgfilter",
|
||
"apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original",
|
||
"persist_editor_generated_image",
|
||
],
|
||
);
|
||
}
|
||
assert_function_contains(
|
||
source,
|
||
"async fn persist_editor_provider_source_image",
|
||
"async fn persist_editor_provider_source_resource",
|
||
&["persist_editor_generated_image_owned"],
|
||
);
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original",
|
||
"fn restore_editor_generated_image_output_dimensions",
|
||
&[
|
||
"into_dimensions",
|
||
"return (postprocessed, None)",
|
||
"download_editor_persisted_image_object",
|
||
"apply_editor_postprocessed_alpha_to_provider_source",
|
||
],
|
||
);
|
||
for (start, end) in [
|
||
(
|
||
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
|
||
"pub async fn extract_editor_ui_design_assets",
|
||
),
|
||
(
|
||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||
"pub(crate) fn editor_project_payload_from_record",
|
||
),
|
||
] {
|
||
assert_function_contains(
|
||
source,
|
||
start,
|
||
end,
|
||
&["bytes: image.bytes.clone()", "slice_source"],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
start,
|
||
end,
|
||
&[
|
||
"download_editor_persisted_image_object",
|
||
"drop(slice_source)",
|
||
],
|
||
);
|
||
}
|
||
assert_function_not_contains(
|
||
source,
|
||
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
|
||
"async fn fallback_editor_screen_background_removal",
|
||
&["fallback_image"],
|
||
);
|
||
assert_function_contains(
|
||
source,
|
||
"async fn persist_editor_generated_image_owned",
|
||
"async fn persist_editor_generated_image_data",
|
||
&["bytes: image.bytes"],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"async fn persist_editor_generated_image_owned",
|
||
"async fn persist_editor_generated_image_data",
|
||
&["image.bytes.clone()"],
|
||
);
|
||
assert_function_contains(
|
||
source,
|
||
"async fn read_editor_reference_image_object",
|
||
"async fn download_editor_persisted_image_object",
|
||
&["response.chunk().await", "bytes.extend_from_slice"],
|
||
);
|
||
assert_function_not_contains(
|
||
source,
|
||
"async fn read_editor_reference_image_object",
|
||
"async fn download_editor_persisted_image_object",
|
||
&["response.bytes().await", "bytes.to_vec()"],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_paid_image_postprocess_keeps_provider_outputs_recoverable() {
|
||
let source = include_str!("editor_project.rs");
|
||
assert_function_contains(
|
||
source,
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
"fn normalize_editor_image_generation_size",
|
||
&["EDITOR_ASSET_DEFAULT_FOLDER_ID"],
|
||
);
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"pub(crate) async fn edit_editor_image_for_owner",
|
||
"pub async fn remove_editor_image_background",
|
||
&[
|
||
"let provider_image",
|
||
"restore_editor_image_edit_output_dimensions_or_original",
|
||
"persist_editor_generated_image",
|
||
],
|
||
);
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
"fn normalize_editor_image_generation_size",
|
||
&[
|
||
"let mut image",
|
||
"restore_editor_generated_image_output_dimensions_or_original",
|
||
"persist_editor_generated_image",
|
||
],
|
||
);
|
||
assert_function_occurrence_count(
|
||
source,
|
||
"pub(crate) async fn edit_editor_image_for_owner",
|
||
"pub async fn remove_editor_image_background",
|
||
"persist_editor_generated_image(",
|
||
1,
|
||
);
|
||
assert_function_occurrence_count(
|
||
source,
|
||
"pub(crate) async fn generate_editor_image_for_owner",
|
||
"fn normalize_editor_image_generation_size",
|
||
"persist_editor_generated_image(",
|
||
1,
|
||
);
|
||
assert_function_contains_in_order(
|
||
source,
|
||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||
"pub(crate) fn editor_project_payload_from_record",
|
||
&[
|
||
"persist_editor_provider_source_resource",
|
||
"remove_editor_generated_screen_background_with_bgfilter",
|
||
"EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND",
|
||
"persist_editor_generated_asset",
|
||
"slice_generated_icon_spritesheet_all_by_connected_components",
|
||
"persist_editor_spritesheet_slices",
|
||
],
|
||
);
|
||
assert_function_contains(
|
||
source,
|
||
"pub(crate) async fn extract_editor_ui_design_assets_for_owner",
|
||
"pub(crate) fn editor_project_payload_from_record",
|
||
&[
|
||
"EDITOR_ASSET_DEFAULT_FOLDER_ID",
|
||
"EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS",
|
||
"EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE",
|
||
],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn generated_asset_folder_id_normalizes_project_and_legacy_local_folder() {
|
||
assert_eq!(
|
||
normalize_generated_asset_folder_id(Some("project".to_string()), "user-1"),
|
||
Some("user-1:asset-folder:project".to_string())
|
||
);
|
||
assert_eq!(
|
||
normalize_generated_asset_folder_id(Some(" folder-temporary ".to_string()), "user-1"),
|
||
Some("user-1:asset-folder:project".to_string())
|
||
);
|
||
assert_eq!(
|
||
normalize_generated_asset_folder_id(Some("folder-real".to_string()), "user-1"),
|
||
Some("user-1:asset-folder:project".to_string())
|
||
);
|
||
assert_eq!(
|
||
normalize_generated_asset_folder_id(
|
||
Some("user-1:asset-folder:project".to_string()),
|
||
"user-1",
|
||
),
|
||
Some("user-1:asset-folder:project".to_string())
|
||
);
|
||
assert_eq!(normalize_generated_asset_folder_id(None, "user-1"), None);
|
||
}
|
||
|
||
fn assert_function_contains(source: &str, start: &str, end: &str, snippets: &[&str]) {
|
||
let start_index = source
|
||
.find(start)
|
||
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
|
||
let tail = &source[start_index..];
|
||
let end_index = tail
|
||
.find(end)
|
||
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
|
||
let body = &tail[..end_index];
|
||
for snippet in snippets {
|
||
assert!(
|
||
body.contains(snippet),
|
||
"function body starting with {start} should contain {snippet}"
|
||
);
|
||
}
|
||
}
|
||
|
||
fn assert_function_contains_in_order(source: &str, start: &str, end: &str, snippets: &[&str]) {
|
||
let start_index = source
|
||
.find(start)
|
||
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
|
||
let tail = &source[start_index..];
|
||
let end_index = tail
|
||
.find(end)
|
||
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
|
||
let body = &tail[..end_index];
|
||
let mut cursor = 0;
|
||
for snippet in snippets {
|
||
let next = body[cursor..].find(snippet).unwrap_or_else(|| {
|
||
panic!(
|
||
"function body starting with {start} should contain {snippet} after offset {cursor}"
|
||
)
|
||
});
|
||
cursor += next + snippet.len();
|
||
}
|
||
}
|
||
|
||
fn assert_function_not_contains(source: &str, start: &str, end: &str, snippets: &[&str]) {
|
||
let start_index = source
|
||
.find(start)
|
||
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
|
||
let tail = &source[start_index..];
|
||
let end_index = tail
|
||
.find(end)
|
||
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
|
||
let body = &tail[..end_index];
|
||
for snippet in snippets {
|
||
assert!(
|
||
!body.contains(snippet),
|
||
"function body starting with {start} should not contain {snippet}"
|
||
);
|
||
}
|
||
}
|
||
|
||
fn assert_function_occurrence_count(
|
||
source: &str,
|
||
start: &str,
|
||
end: &str,
|
||
snippet: &str,
|
||
expected_count: usize,
|
||
) {
|
||
let start_index = source
|
||
.find(start)
|
||
.unwrap_or_else(|| panic!("missing function start marker: {start}"));
|
||
let tail = &source[start_index..];
|
||
let end_index = tail
|
||
.find(end)
|
||
.unwrap_or_else(|| panic!("missing function end marker: {end}"));
|
||
let body = &tail[..end_index];
|
||
assert_eq!(
|
||
body.matches(snippet).count(),
|
||
expected_count,
|
||
"function body starting with {start} should contain {snippet} exactly {expected_count} times"
|
||
);
|
||
}
|
||
}
|