Files
Genarrative/server-rs/crates/api-server/src/editor_project.rs
T
wuxiangwanzi 32fc4089cb 补充是否公开资源的逻辑,修改精选列表展示逻辑
新增画布项目资源公开展示开关和公开精选资源接口。

左侧素材右键菜单支持公开展示勾选与删除操作。

陶泥儿精选改为读取全站公开生成资源并过滤同源同媒体副本。

拖拽生成素材入画布时保留源资源关系并复用同源资源。

同步 SpacetimeDB 迁移、绑定、前端测试和相关文档。
2026-06-24 17:25:19 +08:00

5021 lines
181 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{borrow::Cow, collections::BTreeMap};
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input,
generate_asset_object_id,
};
use platform_image::{
DownloadedImage,
generated_asset_sheets::{
GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_alpha_with_options,
slice_generated_icon_spritesheet_all_by_connected_components,
slice_generated_icon_spritesheet_by_connected_components,
},
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use shared_contracts::assets::{
EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest,
EditorCanvasGenerationPlaceholderPayload,
};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord,
EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord,
EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput,
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput,
EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord,
EditorProjectResourceShowcaseUpdateRecordInput, SpacetimeClientError,
};
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation_with_cost,
auth::AuthenticatedAccessToken,
editor_generation_queue::{
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,
},
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,
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,
};
pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-";
pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
pub(crate) const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-";
pub(crate) const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024;
pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
const EDITOR_ASSET_METADATA_MAX_BYTES: usize = 64 * 1024;
const EDITOR_ASSET_DEFAULT_FOLDER_ID: &str = "project";
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT: &str =
"仅提取被红色框框选的素材并整理成spritesheet";
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_IMAGE_ASSET_KIND: &str = "editor_icon_image";
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_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,
}
#[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) 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) 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 EditorIconSpritesheetGenerationRequest {
pub(crate) reference_image_src: String,
pub(crate) icon_descriptions: Vec<String>,
pub(crate) model: Option<String>,
pub(crate) aspect_ratio: Option<String>,
pub(crate) image_size: Option<String>,
pub(crate) project_id: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorUiDesignAssetExtractionRequest {
pub(crate) source_image_src: String,
pub(crate) aspect_ratio: String,
pub(crate) image_size: String,
pub(crate) project_id: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) spritesheet_label: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
}
pub(crate) struct EditorGenerationCaller {
pub(crate) owner_user_id: String,
pub(crate) audit_subject_user_id: Option<String>,
pub(crate) audit_project_id: Option<String>,
}
impl EditorGenerationCaller {
fn from_authenticated(authenticated: &AuthenticatedAccessToken) -> Self {
let owner_user_id = authenticated.claims().user_id().to_string();
Self {
audit_subject_user_id: Some(owner_user_id.clone()),
owner_user_id,
audit_project_id: None,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResponse {
project: EditorProjectPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectLayoutSaveResponse {
project_id: String,
canvas_id: String,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectRecentResponse {
project: Option<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectListResponse {
projects: Vec<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectDeleteResponse {
deleted_project_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourceResponse {
resource: EditorProjectResourcePayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourceListResponse {
resources: Vec<EditorProjectResourcePayload>,
}
#[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)]
struct EditorGeneratedAssetRecord {
resource: Option<EditorProjectResourcePayload>,
asset: Option<EditorAssetPayload>,
}
struct EditorCanvasGenerationCompletionResult {
layers: Value,
changed: bool,
}
#[derive(Debug)]
struct PersistEditorGeneratedAssetInput {
project_id: Option<String>,
owner_user_id: String,
folder_id: Option<String>,
label: String,
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
prompt: String,
actual_prompt: Option<String>,
model: String,
provider: String,
task_id: String,
source_resource_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct EditorGeneratedImageStorageProfile {
asset_kind: &'static str,
path_kind: &'static str,
slot: &'static str,
}
pub(crate) struct EditorCanvasGeneratedLayerInput {
pub(crate) layer_id: String,
pub(crate) resource_id: String,
pub(crate) title: String,
pub(crate) src: String,
pub(crate) media_type: Option<String>,
pub(crate) thumbnail_src: Option<String>,
pub(crate) original_width: u32,
pub(crate) original_height: u32,
pub(crate) display_width: f64,
pub(crate) display_height: f64,
pub(crate) source_type: String,
pub(crate) prompt: Option<String>,
pub(crate) actual_prompt: Option<String>,
pub(crate) model: Option<String>,
pub(crate) provider: Option<String>,
pub(crate) task_id: Option<String>,
pub(crate) object_key: Option<String>,
pub(crate) asset_object_id: Option<String>,
pub(crate) duration_seconds: Option<u32>,
pub(crate) source_resource_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) image_sequence_frames: Option<Value>,
pub(crate) preview_video_path: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorImageGenerationResponse {
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
width: u32,
height: u32,
source_type: &'static str,
prompt: String,
actual_prompt: Option<String>,
model: String,
provider: &'static str,
task_id: String,
resource: Option<EditorProjectResourcePayload>,
asset: Option<EditorAssetPayload>,
project: Option<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct 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 EditorIconSpritesheetGenerationResponse {
spritesheet_image_src: String,
spritesheet_width: u32,
spritesheet_height: u32,
icon_image_srcs: Vec<EditorIconSpritesheetIconResponse>,
prompt: String,
actual_prompt: Option<String>,
model: String,
provider: &'static str,
task_id: String,
price_mud_points: u32,
spritesheet_resource: Option<EditorProjectResourcePayload>,
spritesheet_asset: Option<EditorAssetPayload>,
project: Option<EditorProjectPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectPayload {
project_id: String,
title: String,
canvas: EditorCanvasPayload,
viewport: EditorCanvasViewportPayload,
layers: Value,
resources: Vec<EditorProjectResourcePayload>,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorCanvasPayload {
canvas_id: String,
project_id: String,
title: String,
viewport: EditorCanvasViewportPayload,
layers: Value,
created_at: String,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourcePayload {
resource_id: String,
project_id: 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>,
generation_inputs: Option<Value>,
public_showcase_enabled: bool,
created_at: 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>,
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().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>,
) -> Result<Json<Value>, AppError> {
let resources = state
.spacetime_client()
.list_public_editor_project_resources(EditorProjectResourcePublicShowcaseListRecordInput {
limit: 36,
})
.await
.map_err(map_editor_project_error)?;
let resources = resources
.into_iter()
.map(editor_project_resource_payload_from_record)
.collect();
Ok(json_success_body(
Some(&request_context),
EditorProjectResourceListResponse { resources },
))
}
pub async fn create_editor_project(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectCreateRequest>,
) -> Result<Json<Value>, AppError> {
let now_micros = current_utc_micros();
let title = payload
.title
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string());
let project = state
.spacetime_client()
.create_editor_project(EditorProjectCreateRecordInput {
project_id: build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(),
title,
now_micros,
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn get_editor_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id,
owner_user_id: authenticated.claims().user_id().to_string(),
})
.await
.map_err(map_editor_project_error)?;
let project = repair_editor_project_record_inline_media(&state, project).await;
Ok(json_success_body(
Some(&request_context),
EditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn save_editor_project_layout(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectLayoutSaveRequest>,
) -> Result<Json<Value>, AppError> {
let layers_json = serialize_editor_layers(payload.layers)?;
let ack = state
.spacetime_client()
.save_editor_project_layout_ack(EditorProjectLayoutSaveRecordInput {
project_id,
owner_user_id: authenticated.claims().user_id().to_string(),
viewport: payload.viewport.into_record(),
layers_json,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectLayoutSaveResponse {
project_id: ack.project_id,
canvas_id: ack.canvas_id,
updated_at: ack.updated_at,
},
))
}
pub async fn rename_editor_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectRenameRequest>,
) -> Result<Json<Value>, AppError> {
let project = state
.spacetime_client()
.rename_editor_project(EditorProjectRenameRecordInput {
project_id,
owner_user_id: authenticated.claims().user_id().to_string(),
title: payload.title,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
pub async fn delete_editor_project(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let deleted_project_id = state
.spacetime_client()
.delete_editor_project(EditorProjectDeleteRecordInput {
project_id,
owner_user_id: authenticated.claims().user_id().to_string(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectDeleteResponse { deleted_project_id },
))
}
pub async fn create_editor_project_resource(
State(state): State<AppState>,
Path(project_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectResourceCreateRequest>,
) -> Result<Json<Value>, AppError> {
let generation_inputs_json =
serialize_editor_asset_metadata(payload.generation_inputs.clone())?;
let object_key = normalize_editor_object_key(payload.object_key);
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
let resource = state
.spacetime_client()
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
project_id,
owner_user_id: authenticated.claims().user_id().to_string(),
asset_object_id: normalize_optional_string(payload.asset_object_id),
image_src,
object_key,
width: payload.width,
height: payload.height,
source_type: payload.source_type,
prompt: normalize_optional_string(payload.prompt),
actual_prompt: normalize_optional_string(payload.actual_prompt),
model: normalize_optional_string(payload.model),
provider: normalize_optional_string(payload.provider),
task_id: normalize_optional_string(payload.task_id),
source_resource_id: normalize_optional_string(payload.source_resource_id),
asset_kind: normalize_optional_string(payload.asset_kind),
generation_inputs_json,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectResourceResponse {
resource: editor_project_resource_payload_from_record(resource),
},
))
}
pub async fn update_editor_project_resource_showcase(
State(state): State<AppState>,
Path(resource_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectResourceShowcaseUpdateRequest>,
) -> Result<Json<Value>, AppError> {
let resource = state
.spacetime_client()
.update_editor_project_resource_showcase(EditorProjectResourceShowcaseUpdateRecordInput {
resource_id,
owner_user_id: current_owner_user_id(&authenticated),
public_showcase_enabled: payload.public_showcase_enabled,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectResourceResponse {
resource: editor_project_resource_payload_from_record(resource),
},
))
}
pub async fn get_editor_asset_library(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = current_owner_user_id(&authenticated);
let library = state
.spacetime_client()
.get_editor_asset_library(owner_user_id.clone(), current_utc_micros())
.await
.map_err(map_editor_project_error)?;
let library =
repair_editor_asset_library_inline_media(&state, owner_user_id.as_str(), library).await;
Ok(json_success_body(
Some(&request_context),
EditorAssetLibraryResponse {
library: editor_asset_library_payload_from_record(library),
},
))
}
pub async fn create_editor_asset_folder(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorAssetFolderCreateRequest>,
) -> Result<Json<Value>, AppError> {
let folder = state
.spacetime_client()
.create_editor_asset_folder(EditorAssetFolderCreateRecordInput {
folder_id: build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX),
owner_user_id: current_owner_user_id(&authenticated),
label: payload.label,
sort_order: payload.sort_order.unwrap_or(100),
now_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorAssetFolderResponse {
folder: editor_asset_folder_payload_from_record(folder),
},
))
}
pub async fn update_editor_asset_folder(
State(state): State<AppState>,
Path(folder_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorAssetFolderUpdateRequest>,
) -> Result<Json<Value>, AppError> {
let folder = state
.spacetime_client()
.update_editor_asset_folder(EditorAssetFolderUpdateRecordInput {
folder_id,
owner_user_id: current_owner_user_id(&authenticated),
label: normalize_optional_string(payload.label),
collapsed: payload.collapsed,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorAssetFolderResponse {
folder: editor_asset_folder_payload_from_record(folder),
},
))
}
pub async fn delete_editor_asset_folder(
State(state): State<AppState>,
Path(folder_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let library = state
.spacetime_client()
.delete_editor_asset_folder(EditorAssetFolderDeleteRecordInput {
folder_id,
owner_user_id: current_owner_user_id(&authenticated),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorAssetFolderDeleteResponse {
library: editor_asset_library_payload_from_record(library),
},
))
}
pub async fn create_editor_asset(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorAssetCreateRequest>,
) -> Result<Json<Value>, AppError> {
let generation_inputs_json =
serialize_editor_asset_metadata(payload.generation_inputs.clone())?;
let object_key = normalize_editor_object_key(payload.object_key);
let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?;
let asset = state
.spacetime_client()
.create_editor_asset(EditorAssetCreateRecordInput {
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
owner_user_id: current_owner_user_id(&authenticated),
folder_id: payload.folder_id,
label: payload.label,
asset_object_id: normalize_optional_string(payload.asset_object_id),
image_src,
object_key,
width: payload.width,
height: payload.height,
source_type: payload.source_type,
prompt: normalize_optional_string(payload.prompt),
actual_prompt: normalize_optional_string(payload.actual_prompt),
model: normalize_optional_string(payload.model),
provider: normalize_optional_string(payload.provider),
task_id: normalize_optional_string(payload.task_id),
asset_kind: normalize_optional_string(payload.asset_kind),
generation_inputs_json,
source_resource_id: None,
now_micros: current_utc_micros(),
})
.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 generate_editor_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorImageGenerationRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
let normalized_kind = payload.kind.as_deref().map(str::trim);
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
let is_publication_material_generation =
matches!(normalized_kind, Some("publication-material"));
let generation_options = normalize_editor_generation_options(
if is_ui_design_generation || is_publication_material_generation {
Some(GPT_IMAGE_2_MODEL)
} else {
payload.model.as_deref()
},
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let price_mud_points = u64::from(
state
.editor_generation_pricing()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(
normalized_kind,
Some(generation_options.model),
Some(generation_options.image_size),
),
);
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-image-generation",
);
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
caller.owner_user_id.as_str(),
EDITOR_IMAGE_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成图片",
price_mud_points,
&payload,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
generate_editor_image_for_owner(&state, &request_context, caller, payload).await
}
pub(crate) async fn generate_editor_image_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorImageGenerationRequest,
) -> Result<Json<Value>, AppError> {
let role_setting = payload.prompt.trim().to_string();
if role_setting.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-generation",
"message": "生成提示词不能为空",
})),
);
}
let normalized_kind = payload.kind.as_deref().map(str::trim);
let is_character_generation = matches!(normalized_kind, Some("character"));
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 submitted_prompt = if is_character_generation {
build_editor_character_image_prompt(role_setting.as_str())
} else if is_ui_design_generation {
build_editor_ui_design_prompt(
role_setting.as_str(),
payload
.reference_image_srcs
.as_ref()
.is_some_and(|references| {
references.iter().any(|source| !source.trim().is_empty())
}),
)
} else {
role_setting.clone()
};
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<_>>();
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体");
let reference_images = if reference_sources.is_empty() {
Vec::new()
} else {
reference_sources
.iter()
.map(|source| parse_editor_reference_image(source.as_str()))
.collect::<Result<Vec<_>, _>>()?
};
let image_size = if is_ui_design_generation {
Cow::Owned(generation_options.size.clone())
} else {
image_size
};
let configured_price_mud_points = state
.editor_generation_pricing()
.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 {
if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
create_openai_nanobanana_generate_content(
&http_client,
&settings,
generation_options.model,
submitted_prompt.as_str(),
negative_prompt,
generation_options.aspect_ratio,
generation_options.provider_image_size,
reference_images.as_slice(),
failure_context,
)
.await
} else if reference_images.is_empty() {
create_openai_image_generation_with_model(
&http_client,
&settings,
generation_options.model,
submitted_prompt.as_str(),
negative_prompt,
image_size.as_ref(),
1,
&[],
failure_context,
)
.await
} else {
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
submitted_prompt.as_str(),
negative_prompt,
image_size.as_ref(),
1,
reference_images.as_slice(),
failure_context,
)
.await
}
};
let generated = 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 mut image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图片",
}))
})?;
if is_character_generation {
image = prepare_editor_character_image_for_response(image);
}
let (width, height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((1024, 1024));
let storage_profile = editor_generated_image_storage_profile(normalized_kind);
let persisted = persist_editor_generated_image(
state,
caller.owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
submitted_prompt.as_str(),
generated.actual_prompt.as_deref(),
storage_profile.asset_kind,
storage_profile.path_kind,
"image",
storage_profile.slot,
);
let persisted = persisted.await?;
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let generated_asset = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: payload.asset_folder_id,
label: normalize_optional_string(payload.asset_label)
.unwrap_or_else(|| role_setting.clone()),
image_src: image_src.clone(),
object_key: Some(persisted.object_key.clone()),
asset_object_id: Some(persisted.asset_object_id.clone()),
width,
height,
prompt: role_setting.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
provider: "VectorEngine".to_string(),
task_id: generated.task_id.clone(),
source_resource_id: payload.source_resource_id,
asset_kind: payload.asset_kind,
generation_inputs: payload.generation_inputs,
},
)
.await?;
let completed_project = 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?;
Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
width,
height,
source_type: "generated",
prompt: role_setting,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
resource: generated_asset.resource,
asset: generated_asset.asset,
project: completed_project,
},
))
}
fn editor_image_generation_billing_asset_kind(normalized_kind: Option<&str>) -> &'static str {
match normalized_kind {
Some("character") => EDITOR_CHARACTER_IMAGE_ASSET_KIND,
Some("spec") => EDITOR_SPEC_IMAGE_ASSET_KIND,
Some("quick-edit") => EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND,
Some("ui-design") => EDITOR_UI_DESIGN_IMAGE_ASSET_KIND,
Some("publication-material") => EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
_ => EDITOR_GENERATED_IMAGE_ASSET_KIND,
}
}
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",
},
}
}
fn resolve_editor_image_edit_price(state: &AppState) -> Result<u32, AppError> {
let expected_price_mud_points = state
.editor_generation_pricing()
.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(GPT_IMAGE_2_MODEL), Some("1K"));
Ok(expected_price_mud_points)
}
fn normalize_editor_image_generation_size(size: Option<&str>) -> Cow<'static, str> {
match size.map(str::trim).filter(|value| !value.is_empty()) {
Some("1024x1024") | Some("1024*1024") | Some("1:1") => Cow::Borrowed("1024x1024"),
Some("1536x1024") | Some("1536*1024") | Some("16:9") => Cow::Borrowed("1536x1024"),
Some("2048x1152") | Some("2048*1152") | Some("1920x1080") | Some("1920*1080")
| Some("2k-16:9") => Cow::Borrowed("2048x1152"),
Some("1024x1536") | Some("1024*1536") | Some("9:16") => Cow::Borrowed("1024x1536"),
Some(value) if is_editor_custom_image_size(value) => Cow::Owned(value.to_string()),
_ => Cow::Borrowed(EDITOR_IMAGE_GENERATION_SIZE),
}
}
fn resolve_editor_image_request_size(
normalized_kind: Option<&str>,
payload_size: Option<&str>,
has_dimension_options: bool,
generation_options: &EditorGenerationOptions,
) -> Cow<'static, str> {
let legacy_size = normalize_editor_image_generation_size(payload_size);
let has_explicit_payload_size = payload_size
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if matches!(normalized_kind, Some("publication-material")) && has_explicit_payload_size {
return legacy_size;
}
if has_dimension_options {
return Cow::Owned(generation_options.size.clone());
}
legacy_size
}
fn is_editor_custom_image_size(value: &str) -> bool {
let Some((width, height)) = value.split_once('x') else {
return false;
};
let Ok(width) = width.parse::<u32>() else {
return false;
};
let Ok(height) = height.parse::<u32>() else {
return false;
};
(64..=4096).contains(&width) && (64..=4096).contains(&height)
}
fn normalize_editor_generation_options(
model: Option<&str>,
aspect_ratio: Option<&str>,
image_size: Option<&str>,
) -> EditorGenerationOptions {
let normalized_model = match model.map(str::trim).filter(|value| !value.is_empty()) {
Some(GPT_IMAGE_2_MODEL) => GPT_IMAGE_2_MODEL,
Some(
EDITOR_IMAGE_MODEL_NANOBANANA2
| EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS
| EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS,
) => EDITOR_IMAGE_MODEL_NANOBANANA2,
// 中文注释:未显式传模型的旧普通生成、快速编辑和生成规范继续走 gpt-image-2;
// 角色 / 图标素材入口由前端显式传入 nanobanana2 默认值。
None => GPT_IMAGE_2_MODEL,
_ => EDITOR_IMAGE_MODEL_NANOBANANA2,
};
let aspect_ratio = normalize_editor_generation_aspect_ratio(aspect_ratio);
let image_size = normalize_editor_generation_image_size(normalized_model, image_size);
let size = editor_generation_size_for_model(normalized_model, aspect_ratio, image_size);
let provider_image_size =
editor_generation_provider_image_size_for_model(normalized_model, image_size);
EditorGenerationOptions {
model: normalized_model,
aspect_ratio,
image_size,
size,
provider_image_size,
}
}
fn normalize_editor_generation_aspect_ratio(aspect_ratio: Option<&str>) -> &'static str {
match aspect_ratio
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some("2:3") => "2:3",
Some("3:2") => "3:2",
Some("4:3") => "4:3",
Some("9:16") => "9:16",
Some("16:9") => "16:9",
_ => "1:1",
}
}
fn normalize_editor_generation_image_size(model: &str, image_size: Option<&str>) -> &'static str {
match (
model,
image_size.map(str::trim).filter(|value| !value.is_empty()),
) {
(EDITOR_IMAGE_MODEL_NANOBANANA2, Some("0.5K")) => "0.5K",
(_, Some("2K")) => "2K",
_ => "1K",
}
}
fn editor_generation_size_for_model(model: &str, aspect_ratio: &str, image_size: &str) -> String {
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
return match image_size {
// 中文注释:VectorEngine 的 nanobanana2 文档要求 0.5K 传入 512。
"0.5K" => "512",
"2K" => "2048",
_ => "1024",
}
.to_string();
}
match (image_size, aspect_ratio) {
("2K", "1:1") => "2048x2048",
// 中文注释:gpt-image-2 文档未列出 2K 竖版,竖版选择回落到文档明确支持的 1K 竖版。
("2K", "2:3") | ("2K", "9:16") => "1024x1536",
("2K", "4:3") => "2048x1536",
("2K", "16:9") | ("2K", "3:2") => "2048x1152",
("1K", "2:3") | ("1K", "9:16") => "1024x1536",
("1K", "4:3") => "1536x1024",
("1K", "3:2") | ("1K", "16:9") => "1536x1024",
_ => "1024x1024",
}
.to_string()
}
fn editor_generation_provider_image_size_for_model(
model: &str,
image_size: &'static str,
) -> &'static str {
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
return match image_size {
"0.5K" => "512",
"2K" => "2K",
_ => "1K",
};
}
image_size
}
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() {
let price_mud_points = u64::from(resolve_editor_image_edit_price(&state)?);
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> {
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 reference_image = parse_editor_reference_image(payload.source_image_src.as_str())?;
let expected_price_mud_points = resolve_editor_image_edit_price(state)?;
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 = 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(
&http_client,
&settings,
prompt.as_str(),
Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"),
EDITOR_IMAGE_GENERATION_SIZE,
1,
&[reference_image],
"图片画布修改图片",
),
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图片",
}))
})?;
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",
)
.await?;
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let generated_asset = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: payload.asset_folder_id,
label: normalize_optional_string(payload.asset_label)
.unwrap_or_else(|| "图片修改结果".to_string()),
image_src: image_src.clone(),
object_key: Some(persisted.object_key.clone()),
asset_object_id: Some(persisted.asset_object_id.clone()),
width,
height,
prompt: prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: GPT_IMAGE_2_MODEL.to_string(),
provider: "VectorEngine".to_string(),
task_id: generated.task_id.clone(),
source_resource_id: payload.source_resource_id,
asset_kind: payload.asset_kind,
generation_inputs: payload.generation_inputs,
},
)
.await?;
let completed_project = 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?;
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: GPT_IMAGE_2_MODEL.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
resource: generated_asset.resource,
asset: generated_asset.asset,
project: completed_project,
},
))
}
pub async fn generate_editor_icon_spritesheet(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorIconSpritesheetGenerationRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
let generation_options = normalize_editor_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let price_mud_points = u64::from(resolve_editor_icon_spritesheet_price(
&state,
Some(generation_options.model),
Some(generation_options.image_size),
)?);
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-icon-spritesheet-generation",
);
let queue_job = enqueue_editor_generation_job(
&state,
&request_context,
caller.owner_user_id.as_str(),
EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成图标素材",
price_mud_points,
&payload,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
generate_editor_icon_spritesheet_for_owner(&state, &request_context, caller, payload).await
}
pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorIconSpritesheetGenerationRequest,
) -> Result<Json<Value>, AppError> {
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
let reference_image = parse_editor_reference_image(payload.reference_image_src.as_str())
.map_err(|error| {
error.with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "referenceImageSrc",
"message": "图标素材规范必须是图片 Data URL。",
}))
})?;
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),
)?;
let size = generation_options.size.as_str();
let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions);
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 = 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 {
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_image],
"图片画布生成图标素材 spritesheet",
)
.await
} else {
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
generation_options.model,
prompt.as_str(),
None,
size,
1,
&[reference_image],
"图片画布生成图标素材 spritesheet",
)
.await
}
},
)
.await?;
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 (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((512, 512));
let source = DownloadedImage {
bytes: image.bytes.clone(),
mime_type: image.mime_type.clone(),
extension: image.extension.clone(),
};
let icon_slices = slice_generated_icon_spritesheet_by_connected_components(
&source,
icon_descriptions.as_slice(),
)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-icon-spritesheet",
"message": error.to_string(),
}))
})?;
let generation_inputs = payload.generation_inputs.clone();
let owner_user_id = caller.owner_user_id;
let spritesheet_persisted = persist_editor_generated_image(
state,
owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_ICON_SPRITESHEET_ASSET_KIND,
"icon-spritesheets",
"spritesheet",
"spritesheet",
)
.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: payload.asset_folder_id.clone(),
label: "图标素材图集".to_string(),
image_src: spritesheet_image_src.clone(),
object_key: Some(spritesheet_persisted.object_key.clone()),
asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()),
width: spritesheet_width,
height: spritesheet_height,
prompt: prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
provider: "VectorEngine".to_string(),
task_id: generated.task_id.clone(),
source_resource_id: None,
asset_kind: Some("icon-spritesheet".to_string()),
generation_inputs: generation_inputs.clone(),
},
)
.await?;
let mut icon_image_srcs = Vec::new();
for icon in icon_slices {
let icon_index = icon_image_srcs.len() + 1;
let icon_name = icon.name.clone();
let icon_width = icon.width;
let icon_height = icon.height;
let icon_image = DownloadedOpenAiImage {
bytes: icon.bytes,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let icon_file_stem = format!("icon-{icon_index:03}");
let icon_persisted = persist_editor_generated_image(
state,
owner_user_id.as_str(),
generated.task_id.as_str(),
&icon_image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_ICON_IMAGE_ASSET_KIND,
"icons",
icon_file_stem.as_str(),
"icon",
)
.await?;
let icon_image_src = editor_media_src_from_object_key(icon_persisted.object_key.as_str());
let icon_record = 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.clone(),
label: icon_name.clone(),
image_src: icon_image_src.clone(),
object_key: Some(icon_persisted.object_key.clone()),
asset_object_id: Some(icon_persisted.asset_object_id.clone()),
width: icon_width,
height: icon_height,
prompt: prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
provider: "VectorEngine".to_string(),
task_id: generated.task_id.clone(),
source_resource_id: None,
asset_kind: Some("icon".to_string()),
generation_inputs: generation_inputs.clone(),
},
)
.await?;
icon_image_srcs.push(EditorIconSpritesheetIconResponse {
name: icon_name,
image_src: icon_image_src,
width: icon_width,
height: icon_height,
resource: icon_record.resource,
asset: icon_record.asset,
});
}
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(),
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,
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: spritesheet_record.resource,
spritesheet_asset: spritesheet_record.asset,
project: completed_project,
},
))
}
pub async fn extract_editor_ui_design_assets(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorUiDesignAssetExtractionRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
let generation_options = normalize_editor_ui_design_asset_extraction_options(
payload.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.image_size),
)?);
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> {
let reference_image =
parse_editor_reference_image(payload.source_image_src.as_str()).map_err(|error| {
error.with_details(json!({
"provider": "editor-ui-design-asset-extraction",
"field": "sourceImageSrc",
"message": "UI设计图必须是图片 Data URL。",
}))
})?;
let generation_options = normalize_editor_ui_design_asset_extraction_options(
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.image_size),
)?;
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 = 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),
create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
GPT_IMAGE_2_MODEL,
EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT,
None,
generation_options.size.as_str(),
1,
&[reference_image],
"图片画布提取UI设计图素材 spritesheet",
),
)
.await?;
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 (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice())
.map(|image| (image.width(), image.height()))
.unwrap_or((512, 512));
let source = DownloadedImage {
bytes: image.bytes.clone(),
mime_type: image.mime_type.clone(),
extension: image.extension.clone(),
};
let icon_slices = slice_generated_icon_spritesheet_all_by_connected_components(&source)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-ui-design-asset-extraction",
"message": error.to_string(),
}))
})?;
let prompt = EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT.to_string();
let generation_inputs = payload.generation_inputs.clone();
let owner_user_id = caller.owner_user_id;
let spritesheet_persisted = persist_editor_generated_image(
state,
owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND,
"ui-design-spritesheets",
"spritesheet",
"spritesheet",
)
.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: payload.asset_folder_id.clone(),
label: normalize_optional_string(payload.spritesheet_label)
.unwrap_or_else(|| "UI设计图素材图集".to_string()),
image_src: spritesheet_image_src.clone(),
object_key: Some(spritesheet_persisted.object_key.clone()),
asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()),
width: spritesheet_width,
height: spritesheet_height,
prompt: prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: GPT_IMAGE_2_MODEL.to_string(),
provider: "VectorEngine".to_string(),
task_id: generated.task_id.clone(),
source_resource_id: None,
asset_kind: Some("icon-spritesheet".to_string()),
generation_inputs: generation_inputs.clone(),
},
)
.await?;
let mut icon_image_srcs = Vec::new();
for icon in icon_slices {
let icon_index = icon_image_srcs.len() + 1;
let icon_name = icon.name.clone();
let icon_width = icon.width;
let icon_height = icon.height;
let icon_image = DownloadedOpenAiImage {
bytes: icon.bytes,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let icon_file_stem = format!("asset-{icon_index:03}");
let icon_persisted = persist_editor_generated_image(
state,
owner_user_id.as_str(),
generated.task_id.as_str(),
&icon_image,
prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_UI_DESIGN_ASSET_IMAGE_KIND,
"ui-design-assets",
icon_file_stem.as_str(),
"asset",
)
.await?;
let icon_image_src = editor_media_src_from_object_key(icon_persisted.object_key.as_str());
let icon_record = 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.clone(),
label: icon_name.clone(),
image_src: icon_image_src.clone(),
object_key: Some(icon_persisted.object_key.clone()),
asset_object_id: Some(icon_persisted.asset_object_id.clone()),
width: icon_width,
height: icon_height,
prompt: prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: GPT_IMAGE_2_MODEL.to_string(),
provider: "VectorEngine".to_string(),
task_id: generated.task_id.clone(),
source_resource_id: None,
asset_kind: Some("icon".to_string()),
generation_inputs: generation_inputs.clone(),
},
)
.await?;
icon_image_srcs.push(EditorIconSpritesheetIconResponse {
name: icon_name,
image_src: icon_image_src,
width: icon_width,
height: icon_height,
resource: icon_record.resource,
asset: icon_record.asset,
});
}
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(),
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,
prompt,
actual_prompt: generated.actual_prompt,
model: GPT_IMAGE_2_MODEL.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: spritesheet_record.resource,
spritesheet_asset: spritesheet_record.asset,
project: completed_project,
},
))
}
async fn repair_editor_project_record_inline_media(
state: &AppState,
mut record: EditorProjectRecord,
) -> EditorProjectRecord {
let owner_user_id = record.owner_user_id.clone();
let mut repaired_resources = Vec::with_capacity(record.resources.len());
for resource in record.resources {
repaired_resources.push(
repair_editor_project_resource_inline_media(state, owner_user_id.as_str(), resource)
.await,
);
}
record.resources = repaired_resources;
record
}
async fn repair_editor_asset_library_inline_media(
state: &AppState,
owner_user_id: &str,
mut record: EditorAssetLibraryRecord,
) -> EditorAssetLibraryRecord {
let mut repaired_assets = Vec::with_capacity(record.assets.len());
for asset in record.assets {
repaired_assets.push(repair_editor_asset_inline_media(state, owner_user_id, asset).await);
}
record.assets = repaired_assets;
record
}
async fn repair_editor_project_resource_inline_media(
state: &AppState,
owner_user_id: &str,
resource: EditorProjectResourceRecord,
) -> EditorProjectResourceRecord {
if !should_repair_editor_inline_image(
resource.image_src.as_str(),
resource.object_key.as_deref(),
) {
return resource;
}
let repair = persist_editor_legacy_inline_image(
state,
owner_user_id,
resource.image_src.as_str(),
resource
.task_id
.as_deref()
.unwrap_or(resource.resource_id.as_str()),
resource
.prompt
.as_deref()
.or(resource.actual_prompt.as_deref())
.unwrap_or("历史画布内联图片修复"),
resource.actual_prompt.as_deref(),
resource.asset_kind.as_deref(),
"legacy-resources",
resource.resource_id.as_str(),
)
.await;
let persisted = match repair {
Ok(persisted) => persisted,
Err(error) => {
tracing::warn!(
resource_id = %resource.resource_id,
error = %error,
"repair legacy inline editor project resource media failed"
);
return resource;
}
};
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let repaired = state
.spacetime_client()
.repair_editor_project_resource_media(EditorProjectResourceMediaRepairRecordInput {
resource_id: resource.resource_id.clone(),
owner_user_id: owner_user_id.to_string(),
asset_object_id: persisted.asset_object_id.clone(),
image_src: image_src.clone(),
object_key: persisted.object_key.clone(),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error);
match repaired {
Ok(record) => record,
Err(error) => {
tracing::warn!(
resource_id = %resource.resource_id,
error = %error,
"persist legacy inline editor project resource repair failed"
);
EditorProjectResourceRecord {
image_src,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
..resource
}
}
}
}
async fn repair_editor_asset_inline_media(
state: &AppState,
owner_user_id: &str,
asset: EditorAssetRecord,
) -> EditorAssetRecord {
if !should_repair_editor_inline_image(asset.image_src.as_str(), asset.object_key.as_deref()) {
return asset;
}
let repair = persist_editor_legacy_inline_image(
state,
owner_user_id,
asset.image_src.as_str(),
asset.task_id.as_deref().unwrap_or(asset.asset_id.as_str()),
asset.prompt.as_deref().unwrap_or(asset.label.as_str()),
asset.actual_prompt.as_deref(),
asset.asset_kind.as_deref(),
"legacy-assets",
asset.asset_id.as_str(),
)
.await;
let persisted = match repair {
Ok(persisted) => persisted,
Err(error) => {
tracing::warn!(
asset_id = %asset.asset_id,
error = %error,
"repair legacy inline editor asset media failed"
);
return asset;
}
};
let image_src = editor_media_src_from_object_key(persisted.object_key.as_str());
let repaired = state
.spacetime_client()
.repair_editor_asset_media(EditorAssetMediaRepairRecordInput {
asset_id: asset.asset_id.clone(),
owner_user_id: owner_user_id.to_string(),
asset_object_id: persisted.asset_object_id.clone(),
image_src: image_src.clone(),
object_key: persisted.object_key.clone(),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error);
match repaired {
Ok(record) => record,
Err(error) => {
tracing::warn!(
asset_id = %asset.asset_id,
error = %error,
"persist legacy inline editor asset repair failed"
);
EditorAssetRecord {
image_src,
object_key: Some(persisted.object_key),
asset_object_id: Some(persisted.asset_object_id),
..asset
}
}
}
}
async fn persist_editor_legacy_inline_image(
state: &AppState,
owner_user_id: &str,
image_src: &str,
task_id: &str,
prompt: &str,
actual_prompt: Option<&str>,
asset_kind: Option<&str>,
path_kind: &str,
file_stem: &str,
) -> Result<PersistedEditorGeneratedImage, AppError> {
let decoded = decode_generated_image_asset_data_url(image_src).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
"message": format!("历史画布内联图片无法解析:{error:?}"),
}))
})?;
let image = DownloadedOpenAiImage {
bytes: decoded.bytes,
mime_type: decoded.format.mime_type,
extension: decoded.format.extension,
};
persist_editor_generated_image(
state,
owner_user_id,
task_id,
&image,
prompt,
actual_prompt,
asset_kind.unwrap_or(EDITOR_LEGACY_INLINE_IMAGE_ASSET_KIND),
path_kind,
file_stem,
"legacy-repair",
)
.await
}
fn should_repair_editor_inline_image(image_src: &str, object_key: Option<&str>) -> bool {
object_key
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
&& is_editor_inline_image_src(image_src)
}
fn is_editor_inline_image_src(value: &str) -> bool {
value
.trim_start()
.to_ascii_lowercase()
.starts_with("data:image/")
}
pub(crate) fn editor_project_payload_from_record(
record: EditorProjectRecord,
) -> EditorProjectPayload {
let resources: Vec<EditorProjectResourcePayload> = record
.resources
.into_iter()
.map(editor_project_resource_payload_from_record)
.collect();
let canvas = editor_canvas_payload_from_record(record.canvas, resources.as_slice());
let layers = sanitize_editor_payload_media(record.layers, resources.as_slice());
EditorProjectPayload {
project_id: record.project_id,
title: record.title,
canvas,
viewport: EditorCanvasViewportPayload {
x: record.viewport.x,
y: record.viewport.y,
scale: record.viewport.scale,
},
layers,
resources,
updated_at: record.updated_at,
}
}
fn editor_canvas_payload_from_record(
record: EditorCanvasRecord,
resources: &[EditorProjectResourcePayload],
) -> EditorCanvasPayload {
EditorCanvasPayload {
canvas_id: record.canvas_id,
project_id: record.project_id,
title: record.title,
viewport: EditorCanvasViewportPayload {
x: record.viewport.x,
y: record.viewport.y,
scale: record.viewport.scale,
},
layers: sanitize_editor_payload_media(record.layers, resources),
created_at: record.created_at,
updated_at: record.updated_at,
}
}
pub(crate) fn editor_project_resource_payload_from_record(
record: EditorProjectResourceRecord,
) -> EditorProjectResourcePayload {
let image_src =
normalize_editor_record_media_src(record.image_src, record.object_key.as_deref());
EditorProjectResourcePayload {
resource_id: record.resource_id,
project_id: record.project_id,
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,
generation_inputs: record
.generation_inputs
.map(sanitize_editor_payload_inline_media),
public_showcase_enabled: record.public_showcase_enabled,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
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,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
#[derive(Clone, Debug)]
struct EditorPayloadMediaReference {
image_src: String,
object_key: Option<String>,
asset_object_id: Option<String>,
}
fn sanitize_editor_payload_media(
mut value: Value,
resources: &[EditorProjectResourcePayload],
) -> Value {
let resource_media: BTreeMap<String, EditorPayloadMediaReference> = resources
.iter()
.filter(|resource| !resource.image_src.trim().is_empty())
.map(|resource| {
(
resource.resource_id.clone(),
EditorPayloadMediaReference {
image_src: resource.image_src.clone(),
object_key: resource.object_key.clone(),
asset_object_id: resource.asset_object_id.clone(),
},
)
})
.collect();
sanitize_editor_payload_media_value(&mut value, None, &resource_media);
value
}
fn sanitize_editor_payload_inline_media(mut value: Value) -> Value {
sanitize_editor_payload_media_value(&mut value, None, &BTreeMap::new());
value
}
fn sanitize_editor_payload_media_value(
value: &mut Value,
inherited_media: Option<&EditorPayloadMediaReference>,
resource_media: &BTreeMap<String, EditorPayloadMediaReference>,
) {
match value {
Value::Object(object) => {
let current_media = object
.get("resourceId")
.and_then(Value::as_str)
.and_then(|resource_id| resource_media.get(resource_id))
.cloned()
.or_else(|| inherited_media.cloned());
if let Some(media) = current_media.as_ref() {
for field in ["src", "imageSrc", "thumbnailSrc", "previewVideoPath"] {
rewrite_or_remove_inline_media_field(object, field, media);
}
fill_missing_media_identity_field(object, "objectKey", media.object_key.as_ref());
fill_missing_media_identity_field(
object,
"assetObjectId",
media.asset_object_id.as_ref(),
);
}
for child in object.values_mut() {
sanitize_editor_payload_media_value(child, current_media.as_ref(), resource_media);
}
object.retain(|_, child| !child.is_null());
}
Value::Array(items) => {
for item in items.iter_mut() {
sanitize_editor_payload_media_value(item, inherited_media, resource_media);
}
items.retain(|item| !item.is_null());
}
Value::String(item) if is_forbidden_editor_persisted_media_src(item) => {
*value = Value::Null;
}
_ => {}
}
}
fn rewrite_or_remove_inline_media_field(
object: &mut serde_json::Map<String, Value>,
field: &str,
media: &EditorPayloadMediaReference,
) {
let Some(Value::String(current)) = object.get(field) else {
return;
};
if !is_forbidden_editor_persisted_media_src(current) {
return;
}
if media.image_src.trim().is_empty() {
object.remove(field);
} else {
object.insert(field.to_string(), Value::String(media.image_src.clone()));
}
}
fn fill_missing_media_identity_field(
object: &mut serde_json::Map<String, Value>,
field: &str,
value: Option<&String>,
) {
let Some(value) = value
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let should_fill = object
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|current| !current.is_empty())
.is_none();
if should_fill {
object.insert(field.to_string(), Value::String(value.to_string()));
}
}
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,
now_micros,
})
.await
.map_err(map_editor_project_error)?;
Some(editor_asset_payload_from_record(record))
} else {
None
};
Ok(EditorGeneratedAssetRecord { resource, asset })
}
pub(crate) async fn complete_editor_canvas_generation(
state: &AppState,
owner_user_id: &str,
project_id: Option<&str>,
completion: Option<&EditorCanvasGenerationCompletionRequest>,
resource: Option<&EditorProjectResourcePayload>,
) -> Result<Option<EditorProjectPayload>, AppError> {
let (Some(project_id), Some(completion), Some(resource)) = (project_id, completion, resource)
else {
return Ok(None);
};
let project_id = project_id.trim();
if project_id.is_empty() {
return Ok(None);
}
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.to_string(),
owner_user_id: owner_user_id.to_string(),
})
.await
.map_err(map_editor_project_error)?;
let project = repair_editor_project_record_inline_media(state, project).await;
let viewport = project.viewport.clone();
let project_payload = editor_project_payload_from_record(project);
let Some(placeholder) =
resolve_canvas_completion_placeholder(project_payload.layers.clone(), completion)?
else {
return Ok(None);
};
let layer_id = generated_canvas_layer_id(resource.resource_id.as_str());
let item =
build_generated_canvas_layer_item(completion, &placeholder, resource, layer_id.as_str(), 0);
let completion_result = apply_editor_canvas_generation_items(
project_payload.layers,
completion,
placeholder,
vec![item],
Some(layer_id),
)?;
if !completion_result.changed {
return Ok(None);
}
let saved = state
.spacetime_client()
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
project_id: project_id.to_string(),
owner_user_id: owner_user_id.to_string(),
viewport,
layers_json: serialize_editor_layers(completion_result.layers)?,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(Some(editor_project_payload_from_record(saved)))
}
pub(crate) async fn complete_editor_canvas_generation_with_items(
state: &AppState,
owner_user_id: &str,
project_id: Option<&str>,
completion: Option<&EditorCanvasGenerationCompletionRequest>,
items: Vec<Value>,
primary_layer_id: Option<String>,
) -> Result<Option<EditorProjectPayload>, AppError> {
let (Some(project_id), Some(completion)) = (project_id, completion) else {
return Ok(None);
};
let project_id = project_id.trim();
if project_id.is_empty() || items.is_empty() {
return Ok(None);
}
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.to_string(),
owner_user_id: owner_user_id.to_string(),
})
.await
.map_err(map_editor_project_error)?;
let project = repair_editor_project_record_inline_media(state, project).await;
let viewport = project.viewport.clone();
let project_payload = editor_project_payload_from_record(project);
let Some(placeholder) =
resolve_canvas_completion_placeholder(project_payload.layers.clone(), completion)?
else {
return Ok(None);
};
let items =
translate_editor_canvas_generation_items(items, &completion.placeholder, &placeholder);
let completion_result = apply_editor_canvas_generation_items(
project_payload.layers,
completion,
placeholder,
items,
primary_layer_id,
)?;
if !completion_result.changed {
return Ok(None);
}
let saved = state
.spacetime_client()
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
project_id: project_id.to_string(),
owner_user_id: owner_user_id.to_string(),
viewport,
layers_json: serialize_editor_layers(completion_result.layers)?,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
Ok(Some(editor_project_payload_from_record(saved)))
}
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!(true));
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_icon_spritesheet_canvas_layer_items(
completion: &EditorCanvasGenerationCompletionRequest,
spritesheet_resource: Option<&EditorProjectResourcePayload>,
icons: &[EditorIconSpritesheetIconResponse],
) -> (Vec<Value>, Option<String>) {
let mut items = Vec::new();
let mut primary_layer_id = None;
let placeholder = completion.placeholder.clone();
let Some(spritesheet_resource) = spritesheet_resource else {
return (items, primary_layer_id);
};
let spritesheet_layer_id = generated_canvas_layer_id(spritesheet_resource.resource_id.as_str());
primary_layer_id = Some(spritesheet_layer_id.clone());
let spritesheet_item = build_generated_canvas_layer_item(
completion,
&placeholder,
spritesheet_resource,
spritesheet_layer_id.as_str(),
0,
);
let sheet_width = spritesheet_item
.get("width")
.and_then(Value::as_f64)
.unwrap_or(placeholder.width.max(1.0));
items.push(spritesheet_item);
let mut cursor_x = placeholder.x + sheet_width + 32.0;
let mut cursor_y = placeholder.y;
let mut row_height = 0.0_f64;
let max_row_width = 560.0_f64;
let start_x = cursor_x;
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("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, primary_layer_id)
}
fn next_canvas_layer_z_index(items: &[Value]) -> i64 {
items
.iter()
.filter_map(|item| item.get("zIndex").and_then(Value::as_i64))
.max()
.unwrap_or(0)
+ 1
}
fn generation_dialog_item_matches(item: &Value, dialog_id: &str) -> bool {
item.get("itemType").and_then(Value::as_str) == Some("generation-dialog")
&& item
.get("dialog")
.and_then(|dialog| dialog.get("id"))
.and_then(Value::as_str)
.is_some_and(|candidate| candidate == dialog_id)
}
fn generation_dialog_item_placeholder(
item: &Value,
dialog_id: &str,
) -> Option<EditorCanvasGenerationPlaceholderPayload> {
if !generation_dialog_item_matches(item, dialog_id) {
return None;
}
serde_json::from_value(
item.get("dialog")?
.get("placeholder")
.cloned()
.unwrap_or(Value::Null),
)
.ok()
}
fn normalize_generated_asset_folder_id(
folder_id: Option<String>,
owner_user_id: &str,
) -> Option<String> {
let normalized = normalize_optional_string(folder_id);
match normalized.as_deref() {
None => None,
Some("project") => Some(default_editor_asset_folder_id(owner_user_id)),
Some(value) if value.starts_with("folder-") => {
Some(default_editor_asset_folder_id(owner_user_id))
}
Some(_) => normalized,
}
}
fn default_editor_asset_folder_id(owner_user_id: &str) -> String {
format!("{owner_user_id}:asset-folder:{EDITOR_ASSET_DEFAULT_FOLDER_ID}")
}
fn normalize_editor_object_key(value: Option<String>) -> Option<String> {
normalize_optional_string(value)
.map(|item| item.trim_start_matches('/').to_string())
.filter(|item| !item.is_empty())
}
fn editor_media_src_from_object_key(object_key: &str) -> String {
format!("/{}", object_key.trim().trim_start_matches('/'))
}
fn normalize_editor_record_media_src(image_src: String, object_key: Option<&str>) -> String {
if let Some(media_src) = object_key
.map(str::trim)
.filter(|value| !value.is_empty())
.map(editor_media_src_from_object_key)
{
return media_src;
}
if is_forbidden_editor_persisted_media_src(image_src.as_str()) {
return String::new();
}
image_src
}
pub(crate) fn normalize_editor_persisted_media_src(
image_src: String,
object_key: Option<&str>,
) -> Result<String, AppError> {
if let Some(object_key) = object_key.map(str::trim).filter(|value| !value.is_empty()) {
return Ok(editor_media_src_from_object_key(object_key));
}
if is_forbidden_editor_persisted_media_src(image_src.as_str()) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
"message": "图片画布媒体必须先上传 OSS 并以 objectKey 引用,不能写入 Data URL。",
})),
);
}
Ok(image_src)
}
fn is_forbidden_editor_persisted_media_src(value: &str) -> bool {
let normalized = value.trim_start().to_ascii_lowercase();
let value = normalized.as_str();
value.starts_with("blob:")
|| value.starts_with("data:image/")
|| value.starts_with("data:video/")
|| value.starts_with("data:audio/")
}
fn value_contains_forbidden_editor_persisted_media(value: &Value) -> bool {
match value {
Value::String(item) => is_forbidden_editor_persisted_media_src(item),
Value::Array(items) => items
.iter()
.any(value_contains_forbidden_editor_persisted_media),
Value::Object(object) => object
.values()
.any(value_contains_forbidden_editor_persisted_media),
_ => false,
}
}
impl EditorCanvasViewportPayload {
pub(crate) fn into_record(self) -> EditorCanvasViewportRecord {
EditorCanvasViewportRecord {
x: self.x,
y: self.y,
scale: self.scale,
}
}
}
fn current_owner_user_id(authenticated: &AuthenticatedAccessToken) -> String {
authenticated.claims().user_id().to_string()
}
pub(crate) fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
if value_contains_forbidden_editor_persisted_media(&layers) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
"message": "图层布局不能包含内联媒体 Data URL。",
})),
);
}
let payload = serde_json::to_string(&layers).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
"message": format!("图层布局无法序列化:{error}"),
}))
})?;
if payload.len() > EDITOR_LAYOUT_MAX_BYTES {
return Err(
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({
"provider": "editor-project",
"message": "图层布局过大",
})),
);
}
Ok(payload)
}
pub(crate) fn serialize_editor_asset_metadata(
metadata: Option<Value>,
) -> Result<Option<String>, AppError> {
let Some(metadata) = metadata else {
return Ok(None);
};
if metadata.is_null() {
return Ok(None);
}
if value_contains_forbidden_editor_persisted_media(&metadata) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
"message": "资产元数据不能包含内联媒体 Data URL。",
})),
);
}
let payload = serde_json::to_string(&metadata).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-project",
"message": format!("资产元数据无法序列化:{error}"),
}))
})?;
if payload.len() > EDITOR_ASSET_METADATA_MAX_BYTES {
return Err(
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({
"provider": "editor-project",
"message": "资产元数据过大",
})),
);
}
Ok(Some(payload))
}
pub(crate) fn normalize_optional_string(value: Option<String>) -> Option<String> {
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}
pub(crate) fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|character| match character {
'a'..='z' | '0'..='9' | '-' | '_' => character,
'A'..='Z' => character.to_ascii_lowercase(),
_ => '-',
})
.collect::<String>()
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
if normalized.is_empty() {
fallback.to_string()
} else {
normalized
}
}
fn normalize_icon_descriptions(descriptions: Vec<String>) -> Result<Vec<String>, AppError> {
let normalized = descriptions
.into_iter()
.map(|description| description.trim().to_string())
.filter(|description| !description.is_empty())
.take(EDITOR_ICON_DESCRIPTION_LIMIT.saturating_add(1))
.collect::<Vec<_>>();
if normalized.is_empty() || normalized.len() > EDITOR_ICON_DESCRIPTION_LIMIT {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"message": "图标素材描述数量必须在 1 到 100 个之间。",
})),
);
}
Ok(normalized)
}
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()
.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(
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。",
));
}
};
Ok(normalize_editor_generation_options(
Some(GPT_IMAGE_2_MODEL),
Some("1:1"),
Some(normalized_image_size),
))
}
fn resolve_editor_ui_design_asset_extraction_price(
state: &AppState,
image_size: Option<&str>,
) -> Result<u32, AppError> {
let expected_price_mud_points = state
.editor_generation_pricing()
.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"), Some(GPT_IMAGE_2_MODEL), image_size);
Ok(expected_price_mud_points)
}
fn build_editor_icon_spritesheet_prompt(icon_descriptions: &[String]) -> String {
format!(
"参考图1的图标素材规范,单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}",
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) -> String {
vec![
"严格基于图1的角色美术视觉规范指导中的美术风格、角色头身比、角色朝向等特征生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
format!("角色设定:{}", role_setting.trim()),
]
.join("\n")
}
fn prepare_editor_character_image_for_response(
image: DownloadedOpenAiImage,
) -> DownloadedOpenAiImage {
let Ok(decoded) = image::load_from_memory(image.bytes.as_slice()) else {
return image;
};
let processed = apply_generated_asset_sheet_alpha_with_options(
decoded,
editor_general_cutout_alpha_options(),
);
let mut encoded = std::io::Cursor::new(Vec::new());
if processed
.write_to(&mut encoded, image::ImageFormat::Png)
.is_err()
{
return image;
}
DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
}
}
fn editor_general_cutout_alpha_options() -> GeneratedAssetSheetAlphaOptions {
GeneratedAssetSheetAlphaOptions {
remove_muted_green_screen_background: true,
detect_internal_holes: true,
internal_hole_min_pixels: 16,
..GeneratedAssetSheetAlphaOptions::green_screen()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct EditorGenerationOptions {
model: &'static str,
aspect_ratio: &'static str,
image_size: &'static str,
size: String,
provider_image_size: &'static str,
}
pub(crate) struct PersistedEditorGeneratedImage {
pub(crate) object_key: String,
pub(crate) asset_object_id: String,
}
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,
) -> Result<PersistedEditorGeneratedImage, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let prepared =
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
prefix: LegacyAssetPrefix::CharacterDrafts,
path_segments: vec![
"editor".to_string(),
sanitize_editor_storage_segment(path_kind, "generated-images"),
sanitize_editor_storage_segment(task_id, "task"),
],
file_stem: sanitize_editor_storage_segment(file_stem, "image"),
image: GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime(image.mime_type.as_str()),
bytes: image.bytes.clone(),
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(asset_kind.to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()),
entity_id: Some(task_id.to_string()),
slot: Some(slot.to_string()),
provider: Some("vector-engine".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,
})
}
pub(crate) fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, AppError> {
let Some((header, data)) = source.trim().split_once(',') else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"field": "sourceImageSrc",
"message": "修改图片参考图必须是图片 Data URL。",
})),
);
};
let Some(mime_type) = header
.strip_prefix("data:")
.and_then(|value| value.strip_suffix(";base64"))
.filter(|value| value.starts_with("image/"))
else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"field": "sourceImageSrc",
"message": "修改图片参考图必须是 base64 图片 Data URL。",
})),
);
};
let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"field": "sourceImageSrc",
"message": format!("修改图片参考图解码失败:{error}"),
}))
})?;
if bytes.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"field": "sourceImageSrc",
"message": "修改图片参考图为空。",
})),
);
}
let extension = match mime_type {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/webp" => "webp",
_ => "png",
};
Ok(OpenAiReferenceImage {
bytes,
mime_type: mime_type.to_string(),
file_name: format!("editor-reference.{extension}"),
})
}
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::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;
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_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),
""
);
}
#[test]
fn editor_payload_media_sanitizer_replaces_inline_layer_src_from_resource() {
let resources = vec![EditorProjectResourcePayload {
resource_id: "resource-1".to_string(),
project_id: "project-1".to_string(),
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()),
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 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(),
project_id: "project-1".to_string(),
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()),
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 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(),
project_id: "editor-project-1".to_string(),
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()),
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 completed =
apply_editor_canvas_generation_completion(layers, &completion, &generated_resource)
.expect("completion should apply to sanitized legacy layout");
assert!(completed.changed);
serialize_editor_layers(completed.layers).expect("sanitized completion should save");
}
#[test]
fn editor_persisted_media_src_rejects_inline_media_without_object_key() {
let rejected = normalize_editor_persisted_media_src(
"data:image/png;base64,too-large".to_string(),
None,
)
.expect_err("inline image media should be rejected");
assert!(rejected.body_text().contains("objectKey"));
assert!(
normalize_editor_persisted_media_src("data:video/mp4;base64,AAAA".to_string(), None)
.is_err()
);
assert!(
normalize_editor_persisted_media_src("data:audio/mp3;base64,AAAA".to_string(), None)
.is_err()
);
assert!(
normalize_editor_persisted_media_src("blob:http://localhost/preview".to_string(), None)
.is_err()
);
assert_eq!(
normalize_editor_persisted_media_src(
"data:image/png;base64,legacy".to_string(),
Some("generated-character-drafts/editor/spec.png")
)
.expect("objectKey-backed legacy rows should normalize"),
"/generated-character-drafts/editor/spec.png"
);
}
#[test]
fn editor_layout_serialization_rejects_nested_inline_media() {
let error = serialize_editor_layers(json!([
{
"layerId": "layer-animation",
"thumbnailSrc": "/generated-character-drafts/editor/frame-1.png",
"imageSequenceFrames": [
{
"frameIndex": 1,
"imageSrc": "data:image/png;base64,frame"
}
]
}
]))
.expect_err("layout should reject nested inline image data");
assert!(error.body_text().contains("图层布局"));
assert!(
serialize_editor_layers(json!([
{
"layerId": "layer-animation",
"thumbnailSrc": "/generated-character-drafts/editor/frame-1.png",
"imageSequenceFrames": [
{
"frameIndex": 1,
"imageSrc": "/generated-character-drafts/editor/frame-1.png"
}
],
"previewVideoPath": "/generated-character-drafts/editor/preview.mp4"
}
]))
.is_ok()
);
}
#[test]
fn editor_asset_metadata_serialization_rejects_nested_inline_media() {
let error = serialize_editor_asset_metadata(Some(json!({
"references": [
{
"title": "参考图",
"src": "data:image/png;base64,reference"
}
]
})))
.expect_err("metadata should reject nested inline image data");
assert!(error.body_text().contains("资产元数据"));
assert!(
serialize_editor_asset_metadata(Some(json!({
"references": [
{
"title": "参考图",
"src": "/generated-character-drafts/editor/reference.png"
}
]
})))
.is_ok()
);
}
#[test]
fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() {
assert_eq!(normalize_editor_image_generation_size(None), "1024x1024");
assert_eq!(
normalize_editor_image_generation_size(Some("1536x1024")),
"1536x1024"
);
assert_eq!(
normalize_editor_image_generation_size(Some("1024x1536")),
"1024x1536"
);
assert_eq!(
normalize_editor_image_generation_size(Some("2048x1152")),
"2048x1152"
);
assert_eq!(
normalize_editor_image_generation_size(Some("640x640")),
"640x640"
);
assert_eq!(
normalize_editor_image_generation_size(Some("bad-size")),
"1024x1024"
);
}
#[test]
fn editor_generation_dimensions_follow_model_options() {
let default_generation = normalize_editor_generation_options(None, Some("1:1"), Some("1K"));
assert_eq!(default_generation.model, GPT_IMAGE_2_MODEL);
assert_eq!(default_generation.size, "1024x1024");
let nanobanana = normalize_editor_generation_options(
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
Some("1:1"),
Some("0.5K"),
);
assert_eq!(nanobanana.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(nanobanana.size, "512");
assert_eq!(nanobanana.aspect_ratio, "1:1");
assert_eq!(nanobanana.image_size, "0.5K");
assert_eq!(nanobanana.provider_image_size, "512");
let nanobanana_alias =
normalize_editor_generation_options(Some("nanobanana2"), Some("1:1"), Some("0.5K"));
assert_eq!(nanobanana_alias.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(nanobanana_alias.size, "512");
assert_eq!(nanobanana_alias.provider_image_size, "512");
let legacy_nanobanana_alias =
normalize_editor_generation_options(Some("nano-banana"), Some("16:9"), Some("2K"));
assert_eq!(
legacy_nanobanana_alias.model,
EDITOR_IMAGE_MODEL_NANOBANANA2
);
assert_eq!(legacy_nanobanana_alias.size, "2048");
assert_eq!(legacy_nanobanana_alias.aspect_ratio, "16:9");
let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K"));
assert_eq!(gpt.model, GPT_IMAGE_2_MODEL);
assert_eq!(gpt.size, "1024x1536");
assert_eq!(gpt.aspect_ratio, "2:3");
assert_eq!(gpt.image_size, "1K");
assert_eq!(gpt.provider_image_size, "1K");
let nanobanana_cover =
normalize_editor_generation_options(Some("nanobanana2"), Some("4:3"), Some("1K"));
assert_eq!(nanobanana_cover.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(nanobanana_cover.size, "1024");
assert_eq!(nanobanana_cover.aspect_ratio, "4:3");
let gpt_cover =
normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K"));
assert_eq!(gpt_cover.model, GPT_IMAGE_2_MODEL);
assert_eq!(gpt_cover.size, "1536x1024");
assert_eq!(gpt_cover.aspect_ratio, "4:3");
let gpt_landscape_2k =
normalize_editor_generation_options(Some("gpt-image-2"), Some("16:9"), Some("2K"));
assert_eq!(gpt_landscape_2k.model, GPT_IMAGE_2_MODEL);
assert_eq!(gpt_landscape_2k.size, "2048x1152");
assert_eq!(gpt_landscape_2k.aspect_ratio, "16:9");
assert_eq!(gpt_landscape_2k.image_size, "2K");
let gpt_portrait_2k_fallback =
normalize_editor_generation_options(Some("gpt-image-2"), Some("9:16"), Some("2K"));
assert_eq!(gpt_portrait_2k_fallback.model, GPT_IMAGE_2_MODEL);
assert_eq!(gpt_portrait_2k_fallback.size, "1024x1536");
assert_eq!(gpt_portrait_2k_fallback.aspect_ratio, "9:16");
assert_eq!(gpt_portrait_2k_fallback.image_size, "2K");
let fallback = normalize_editor_generation_options(
Some("unknown-model"),
Some("bad-ratio"),
Some("bad-size"),
);
assert_eq!(fallback.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(fallback.size, "1024");
assert_eq!(fallback.aspect_ratio, "1:1");
assert_eq!(fallback.image_size, "1K");
assert_eq!(fallback.provider_image_size, "1K");
}
#[test]
fn publication_material_request_size_keeps_workflow_pixels() {
let options =
normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K"));
assert_eq!(
resolve_editor_image_request_size(
Some("publication-material"),
Some("720x540"),
true,
&options,
),
"720x540"
);
assert_eq!(
resolve_editor_image_request_size(Some("generate"), Some("720x540"), true, &options),
"1536x1024"
);
}
#[test]
fn publication_material_generation_is_locked_to_gpt_image_2() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"pub(crate) async fn generate_editor_image_for_owner",
"let configured_price_mud_points",
&[
"is_publication_material_generation",
"Some(GPT_IMAGE_2_MODEL)",
],
);
}
#[test]
fn editor_ui_design_prompt_uses_fixed_user_input_block_and_optional_icon_spec() {
let prompt = build_editor_ui_design_prompt("二消玩法,主界面和结算弹窗", true);
assert!(prompt.contains("生成玩法UI原型图"));
assert!(prompt.contains("【用户输入】二消玩法,主界面和结算弹窗"));
assert!(prompt.contains("参考图1为图标素材规范"));
let no_reference_prompt = build_editor_ui_design_prompt("只要战斗HUD", false);
assert!(!no_reference_prompt.contains("参考图1为图标素材规范"));
}
#[test]
fn editor_character_image_prompt_appends_user_role_setting() {
let prompt = build_editor_character_image_prompt("菜市场卖菜大妈");
assert!(prompt.contains("基于图1的角色美术视觉规范指导"));
assert!(prompt.contains("生成游戏角色形象图"));
assert!(prompt.contains("背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕"));
assert!(prompt.contains("禁止镜头透视"));
assert!(prompt.contains("角色设定:菜市场卖菜大妈"));
}
#[test]
fn editor_character_image_uses_general_cutout_for_green_screen_background() {
let width = 12;
let height = 12;
let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255]));
for y in 4..8 {
for x in 4..8 {
image.put_pixel(x, y, image::Rgba([188, 82, 45, 255]));
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image)
.write_to(&mut encoded, image::ImageFormat::Png)
.expect("test image should encode");
let processed = prepare_editor_character_image_for_response(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
});
let decoded = image::load_from_memory(processed.bytes.as_slice())
.expect("processed image should decode")
.to_rgba8();
assert_eq!(processed.mime_type, "image/png");
assert_eq!(processed.extension, "png");
assert_eq!(decoded.get_pixel(0, 0).0[3], 0);
assert_eq!(decoded.get_pixel(5, 5).0[3], 255);
}
#[test]
fn editor_character_image_general_cutout_removes_muted_green_background() {
let width = 20;
let height = 20;
let mut image =
image::RgbaImage::from_pixel(width, height, image::Rgba([59, 131, 67, 255]));
for x in 0..width {
image.put_pixel(x, 0, image::Rgba([59, 81, 67, 255]));
image.put_pixel(x, height - 1, image::Rgba([63, 84, 70, 255]));
}
for y in 0..height {
image.put_pixel(0, y, image::Rgba([59, 81, 67, 255]));
image.put_pixel(width - 1, y, image::Rgba([63, 84, 70, 255]));
}
for y in 7..13 {
for x in 7..13 {
image.put_pixel(x, y, image::Rgba([188, 82, 45, 255]));
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image)
.write_to(&mut encoded, image::ImageFormat::Png)
.expect("test image should encode");
let processed = prepare_editor_character_image_for_response(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
});
let decoded = image::load_from_memory(processed.bytes.as_slice())
.expect("processed image should decode")
.to_rgba8();
assert_eq!(decoded.get_pixel(0, 0).0[3], 0);
assert_eq!(decoded.get_pixel(4, 4).0[3], 0);
assert_eq!(decoded.get_pixel(10, 10).0[3], 255);
}
#[test]
fn editor_character_image_general_cutout_converts_non_png_to_transparent_png() {
let width = 12;
let height = 12;
let mut image =
image::RgbImage::from_pixel(width, height, image::Rgb([0_u8, 255_u8, 0_u8]));
for y in 4..8 {
for x in 4..8 {
image.put_pixel(x, y, image::Rgb([188, 82, 45]));
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(image)
.write_to(&mut encoded, image::ImageFormat::Jpeg)
.expect("test image should encode");
let processed = prepare_editor_character_image_for_response(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/jpeg".to_string(),
extension: "jpg".to_string(),
});
let decoded = image::load_from_memory(processed.bytes.as_slice())
.expect("processed image should decode")
.to_rgba8();
assert_eq!(processed.mime_type, "image/png");
assert_eq!(processed.extension, "png");
assert_eq!(decoded.get_pixel(0, 0).0[3], 0);
assert_eq!(decoded.get_pixel(5, 5).0[3], 255);
}
#[test]
fn editor_character_image_general_cutout_removes_internal_green_holes() {
let width = 16;
let height = 16;
let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255]));
for y in 3..13 {
for x in 3..13 {
image.put_pixel(x, y, image::Rgba([188, 82, 45, 255]));
}
}
for y in 6..10 {
for x in 6..10 {
image.put_pixel(x, y, image::Rgba([0, 255, 0, 255]));
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image)
.write_to(&mut encoded, image::ImageFormat::Png)
.expect("test image should encode");
let processed = prepare_editor_character_image_for_response(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
});
let decoded = image::load_from_memory(processed.bytes.as_slice())
.expect("processed image should decode")
.to_rgba8();
assert_eq!(decoded.get_pixel(0, 0).0[3], 0);
assert_eq!(decoded.get_pixel(7, 7).0[3], 0);
assert_eq!(decoded.get_pixel(4, 4).0[3], 255);
}
#[test]
fn editor_canvas_generation_completion_inserts_result_layer_and_marks_dialog_idle() {
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(),
project_id: "editor-project-1".to_string(),
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()),
generation_inputs: Some(json!({
"fields": [{"title": "角色设定", "value": "银发游侠"}],
"references": []
})),
public_showcase_enabled: true,
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!(true));
assert_eq!(
dialog["generatedLayerId"],
json!("layer-editor-resource-editor-resource-character")
);
assert!(dialog.get("errorMessage").is_none());
}
#[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(),
project_id: "editor-project-1".to_string(),
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()),
generation_inputs: None,
public_showcase_enabled: true,
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(),
project_id: "editor-project-1".to_string(),
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()),
generation_inputs: None,
public_showcase_enabled: true,
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_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(),
project_id: "editor-project-1".to_string(),
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()),
generation_inputs: None,
public_showcase_enabled: true,
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);
assert!(prompt.contains("参考图1的图标素材规范"));
assert!(prompt.contains("单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景方便扣除背景"));
assert!(prompt.contains("返回按钮、设置按钮、下一关按钮"));
}
#[test]
fn editor_ui_design_asset_extraction_prompt_is_fixed() {
assert_eq!(
EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT,
"仅提取被红色框框选的素材并整理成spritesheet"
);
}
#[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_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")
)
.unwrap(),
12
);
assert_eq!(
resolve_editor_icon_spritesheet_price(&state, Some("gpt-image-2"), Some("1K")).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![EditorIconSpritesheetIconResponse {
name: "返回按钮".to_string(),
image_src: "data:image/png;base64,back".to_string(),
width: 128,
height: 128,
resource: None,
asset: None,
}],
prompt: "图标 prompt".to_string(),
actual_prompt: Some("图标 prompt".to_string()),
model: "gemini-3.1-flash-image-preview".to_string(),
provider: "VectorEngine",
task_id: "task-icons".to_string(),
price_mud_points: crate::editor_generation_config::editor_image_generation_mud_points(
Some("icon"),
None,
Some("1K"),
),
spritesheet_resource: None,
spritesheet_asset: None,
project: None,
};
let payload = serde_json::to_value(response).expect("response should serialize");
assert_eq!(payload["priceMudPoints"], json!(12));
}
#[test]
fn editor_ui_design_asset_extraction_price_uses_selected_gpt_image_size() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let request: EditorUiDesignAssetExtractionRequest = serde_json::from_value(json!({
"sourceImageSrc": "data:image/png;base64,AAAA",
"aspectRatio": "1:1",
"imageSize": "2K"
}))
.expect("UI extraction request should deserialize dimensions");
let generation_options = normalize_editor_ui_design_asset_extraction_options(
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.image_size)
)
.unwrap(),
5
);
let ratio_error = match normalize_editor_ui_design_asset_extraction_options("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("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",
"aspectRatio": "1:1",
"imageSize": "1K"
}))
.expect("image generation request should deserialize without price");
assert_eq!(request.prompt, "生成图片");
}
#[test]
fn editor_image_edit_request_omits_price_mud_points() {
let request: EditorImageEditRequest = serde_json::from_value(json!({
"prompt": "修改图片",
"sourceImageSrc": "data:image/png;base64,AAAA"
}))
.expect("image edit request should deserialize without price");
assert_eq!(request.prompt, "修改图片");
}
#[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 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}"
);
}
}
}