Files
Genarrative/server-rs/crates/api-server/src/editor_project.rs
T

3311 lines
118 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, GeneratedAssetSheetConnectedIcon,
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_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord,
EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetRecord,
EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord,
EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError,
};
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation_with_cost,
auth::AuthenticatedAccessToken,
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
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_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material";
#[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 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(Debug, Deserialize)]
#[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)]
#[serde(rename_all = "camelCase")]
pub struct EditorCanvasGenerationCompletionRequest {
pub(crate) dialog_id: String,
pub(crate) title: String,
pub(crate) placeholder: EditorCanvasGenerationPlaceholderPayload,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorCanvasGenerationPlaceholderPayload {
pub(crate) x: f64,
pub(crate) y: f64,
pub(crate) width: f64,
pub(crate) height: f64,
pub(crate) original_width: f64,
pub(crate) original_height: f64,
}
#[derive(Debug, Deserialize)]
#[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>,
}
#[derive(Debug, Deserialize)]
#[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) price_mud_points: u32,
pub(crate) project_id: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorUiDesignAssetExtractionRequest {
pub(crate) source_image_src: 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) 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 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 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(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>,
}
#[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>,
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>,
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)?
.map(editor_project_payload_from_record);
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 projects = state
.spacetime_client()
.list_editor_projects(owner_user_id)
.await
.map_err(map_editor_project_error)?
.into_iter()
.map(editor_project_payload_from_record)
.collect();
Ok(json_success_body(
Some(&request_context),
EditorProjectListResponse { projects },
))
}
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)?;
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 project = state
.spacetime_client()
.save_editor_project_layout(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),
EditorProjectResponse {
project: editor_project_payload_from_record(project),
},
))
}
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 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: payload.image_src,
object_key: normalize_optional_string(payload.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 get_editor_asset_library(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, AppError> {
let library = state
.spacetime_client()
.get_editor_asset_library(current_owner_user_id(&authenticated), current_utc_micros())
.await
.map_err(map_editor_project_error)?;
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 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: payload.image_src,
object_key: normalize_optional_string(payload.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,
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> {
generate_editor_image_for_owner(
&state,
&request_context,
EditorGenerationCaller::from_authenticated(&authenticated),
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 generation_options = normalize_editor_generation_options(
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 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 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 generation_options = if is_ui_design_generation {
normalize_editor_generation_options(
Some(GPT_IMAGE_2_MODEL),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
)
} else {
generation_options
};
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 = if is_publication_material_generation {
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
request_context.request_id(),
u64::from(configured_price_mud_points),
generate_operation,
)
.await?
} else {
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 persisted = if is_character_generation {
Some(
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(),
EDITOR_CHARACTER_IMAGE_ASSET_KIND,
"character-images",
EDITOR_CHARACTER_IMAGE_SLOT,
)
.await?,
)
} else {
None
};
let image_src = format!(
"data:{};base64,{}",
image.mime_type,
BASE64_STANDARD.encode(image.bytes.as_slice())
);
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: persisted.as_ref().map(|asset| asset.object_key.clone()),
asset_object_id: persisted
.as_ref()
.map(|asset| asset.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: persisted.as_ref().map(|asset| asset.object_key.clone()),
asset_object_id: persisted
.as_ref()
.map(|asset| asset.asset_object_id.clone()),
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 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> {
edit_editor_image_for_owner(
&state,
&request_context,
EditorGenerationCaller::from_authenticated(&authenticated),
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 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 = 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 image_src = format!(
"data:{};base64,{}",
image.mime_type,
BASE64_STANDARD.encode(image.bytes.as_slice())
);
let generated_asset = persist_editor_generated_asset(
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id,
owner_user_id: caller.owner_user_id,
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: None,
asset_object_id: None,
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?;
Ok(json_success_body(
Some(&request_context),
EditorImageGenerationResponse {
image_src,
object_key: None,
asset_object_id: None,
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: None,
},
))
}
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> {
generate_editor_icon_spritesheet_for_owner(
&state,
&request_context,
EditorGenerationCaller::from_authenticated(&authenticated),
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 = validate_editor_icon_spritesheet_price(
state,
payload.price_mud_points,
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 = 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?
};
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 spritesheet_image_src =
data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice());
let generation_inputs = payload.generation_inputs.clone();
let owner_user_id = caller.owner_user_id;
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: None,
asset_object_id: None,
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_response = editor_icon_response_from_slice(icon);
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_response.name.clone(),
image_src: icon_response.image_src.clone(),
object_key: None,
asset_object_id: None,
width: icon_response.width,
height: icon_response.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 {
resource: icon_record.resource,
asset: icon_record.asset,
..icon_response
});
}
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,
},
))
}
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> {
extract_editor_ui_design_assets_for_owner(
&state,
&request_context,
EditorGenerationCaller::from_authenticated(&authenticated),
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 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 = create_openai_image_edit_with_references_and_model(
&http_client,
&settings,
GPT_IMAGE_2_MODEL,
EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT,
None,
EDITOR_IMAGE_GENERATION_SIZE,
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 spritesheet_image_src =
data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice());
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_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: None,
asset_object_id: None,
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_response = editor_icon_response_from_slice(icon);
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_response.name.clone(),
image_src: icon_response.image_src.clone(),
object_key: None,
asset_object_id: None,
width: icon_response.width,
height: icon_response.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 {
resource: icon_record.resource,
asset: icon_record.asset,
..icon_response
});
}
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: 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), Some("1K")),
spritesheet_resource: spritesheet_record.resource,
spritesheet_asset: spritesheet_record.asset,
},
))
}
pub(crate) fn editor_project_payload_from_record(
record: EditorProjectRecord,
) -> EditorProjectPayload {
let canvas = editor_canvas_payload_from_record(record.canvas);
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: record.layers,
resources: record
.resources
.into_iter()
.map(editor_project_resource_payload_from_record)
.collect(),
updated_at: record.updated_at,
}
}
fn editor_canvas_payload_from_record(record: EditorCanvasRecord) -> 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: record.layers,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
pub(crate) fn editor_project_resource_payload_from_record(
record: EditorProjectResourceRecord,
) -> EditorProjectResourcePayload {
EditorProjectResourcePayload {
resource_id: record.resource_id,
project_id: record.project_id,
image_src: record.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,
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 {
EditorAssetPayload {
asset_id: record.asset_id,
folder_id: record.folder_id,
label: record.label,
image_src: record.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,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
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 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: input.asset_object_id.clone(),
image_src: input.image_src.clone(),
object_key: input.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 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: input.asset_object_id,
image_src: input.image_src,
object_key: input.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,
now_micros,
})
.await
.map_err(map_editor_project_error)?;
Some(editor_asset_payload_from_record(record))
} else {
None
};
Ok(EditorGeneratedAssetRecord { resource, asset })
}
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() || completion.dialog_id.trim().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 completion_result =
apply_editor_canvas_generation_completion(project.layers, completion, resource)?;
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: project.viewport.clone(),
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 apply_editor_canvas_generation_completion(
layers: Value,
completion: &EditorCanvasGenerationCompletionRequest,
resource: &EditorProjectResourcePayload,
) -> Result<EditorCanvasGenerationCompletionResult, AppError> {
let mut items = match layers {
Value::Array(items) => items,
_ => Vec::new(),
};
let Some(placeholder) = items
.iter()
.find(|item| generation_dialog_item_matches(item, completion.dialog_id.as_str()))
.map(|item| {
generation_dialog_item_placeholder(item, completion.dialog_id.as_str())
.unwrap_or_else(|| completion.placeholder.clone())
})
else {
return Ok(EditorCanvasGenerationCompletionResult {
layers: Value::Array(items),
changed: false,
});
};
let generated_layer_id = generated_canvas_layer_id(resource.resource_id.as_str());
let completed_layer_id = if let Some(existing_layer_id) = items.iter().find_map(|item| {
let resource_id = item.get("resourceId").and_then(Value::as_str)?;
if resource_id == resource.resource_id {
item.get("layerId")
.and_then(Value::as_str)
.map(str::to_string)
} else {
None
}
}) {
existing_layer_id
} else {
let z_index = next_canvas_layer_z_index(&items);
items.push(build_generated_canvas_layer_item(
completion,
&placeholder,
resource,
generated_layer_id.as_str(),
z_index,
));
generated_layer_id
};
for item in &mut items {
if generation_dialog_item_matches(item, completion.dialog_id.as_str()) {
if 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));
dialog.insert("generatedLayerId".to_string(), json!(completed_layer_id));
dialog.remove("errorMessage");
}
}
}
Ok(EditorCanvasGenerationCompletionResult {
layers: Value::Array(items),
changed: true,
})
}
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(),
"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,
})
}
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 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}")
}
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> {
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);
}
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 editor_icon_spritesheet_bad_request(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"message": message.into(),
}))
}
fn validate_editor_icon_spritesheet_price(
state: &AppState,
price_mud_points: u32,
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);
if price_mud_points != expected_price_mud_points {
return Err(editor_icon_spritesheet_bad_request(format!(
"priceMudPoints 与素材生成计费配置不一致,应为 {expected_price_mud_points}。"
)));
}
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()
}
}
pub(crate) fn data_url_from_image_bytes(mime_type: &str, bytes: &[u8]) -> String {
format!(
"data:{};base64,{}",
mime_type,
BASE64_STANDARD.encode(bytes)
)
}
fn editor_icon_response_from_slice(
icon: GeneratedAssetSheetConnectedIcon,
) -> EditorIconSpritesheetIconResponse {
EditorIconSpritesheetIconResponse {
name: icon.name,
image_src: data_url_from_image_bytes("image/png", icon.bytes.as_slice()),
width: icon.width,
height: icon.height,
resource: None,
asset: None,
}
}
#[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,
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: "image".to_string(),
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;
#[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 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: "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": []
})),
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.get("src"),
None,
"layout must not embed heavy image payloads"
);
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: "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,
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: "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,
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_completion_skips_when_dialog_was_removed() {
let layers = json!([
{
"layerId": "layer-existing",
"resourceId": "resource-existing",
"zIndex": 4
}
]);
let completion = EditorCanvasGenerationCompletionRequest {
dialog_id: "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,
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_request_price_uses_generation_config() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert_eq!(
validate_editor_icon_spritesheet_price(
&state,
12,
Some("gemini-3.1-flash-image-preview"),
Some("1K")
)
.unwrap(),
12
);
assert_eq!(
validate_editor_icon_spritesheet_price(&state, 20, Some("gpt-image-2"), Some("1K")).unwrap(),
20
);
let error = validate_editor_icon_spritesheet_price(&state, 5, Some("gpt-image-2"), Some("1K"))
.expect_err("wrong icon spritesheet price should fail");
assert!(error.body_text().contains("priceMudPoints"));
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,
};
let payload = serde_json::to_value(response).expect("response should serialize");
assert_eq!(payload["priceMudPoints"], json!(12));
}
#[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);
}
}