e644d7c99f
生成接口接收项目与素材上下文并返回 resource/asset 快照 画布前端使用后端回包落层并避免重复创建资源和素材 补充生成元数据保存边界的测试与文档
2486 lines
88 KiB
Rust
2486 lines
88 KiB
Rust
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::{
|
||
GeneratedAssetSheetConnectedIcon,
|
||
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,
|
||
};
|
||
|
||
const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-";
|
||
const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
|
||
const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-";
|
||
const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
|
||
const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024;
|
||
const EDITOR_ASSET_METADATA_MAX_BYTES: usize = 64 * 1024;
|
||
const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
|
||
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 {
|
||
viewport: EditorCanvasViewportPayload,
|
||
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 {
|
||
prompt: String,
|
||
size: Option<String>,
|
||
kind: Option<String>,
|
||
model: Option<String>,
|
||
aspect_ratio: Option<String>,
|
||
image_size: Option<String>,
|
||
reference_image_srcs: Option<Vec<String>>,
|
||
project_id: Option<String>,
|
||
asset_kind: Option<String>,
|
||
generation_inputs: Option<Value>,
|
||
asset_folder_id: Option<String>,
|
||
asset_label: Option<String>,
|
||
source_resource_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorImageEditRequest {
|
||
prompt: String,
|
||
source_image_src: String,
|
||
project_id: Option<String>,
|
||
asset_kind: Option<String>,
|
||
generation_inputs: Option<Value>,
|
||
asset_folder_id: Option<String>,
|
||
asset_label: Option<String>,
|
||
source_resource_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorIconSpritesheetGenerationRequest {
|
||
reference_image_src: String,
|
||
icon_descriptions: Vec<String>,
|
||
model: Option<String>,
|
||
aspect_ratio: Option<String>,
|
||
image_size: Option<String>,
|
||
price_mud_points: u32,
|
||
project_id: Option<String>,
|
||
generation_inputs: Option<Value>,
|
||
asset_folder_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorUiDesignAssetExtractionRequest {
|
||
source_image_src: String,
|
||
project_id: Option<String>,
|
||
generation_inputs: Option<Value>,
|
||
asset_folder_id: Option<String>,
|
||
spritesheet_label: Option<String>,
|
||
}
|
||
|
||
#[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>,
|
||
}
|
||
|
||
#[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>,
|
||
}
|
||
|
||
#[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 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> {
|
||
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 legacy_size = normalize_editor_image_generation_size(payload.size.as_deref());
|
||
let image_size = if has_dimension_options {
|
||
Cow::Owned(generation_options.size.clone())
|
||
} else {
|
||
legacy_size
|
||
};
|
||
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 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,
|
||
Some(authenticated.claims().user_id().to_string()),
|
||
None,
|
||
);
|
||
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 =
|
||
crate::editor_generation_config::editor_image_generation_mud_points(
|
||
normalized_kind,
|
||
Some(generation_options.model),
|
||
);
|
||
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,
|
||
authenticated.claims().user_id(),
|
||
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_character_image(
|
||
&state,
|
||
authenticated.claims().user_id(),
|
||
generated.task_id.as_str(),
|
||
&image,
|
||
submitted_prompt.as_str(),
|
||
generated.actual_prompt.as_deref(),
|
||
)
|
||
.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,
|
||
owner_user_id: current_owner_user_id(&authenticated),
|
||
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?;
|
||
|
||
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,
|
||
},
|
||
))
|
||
}
|
||
|
||
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 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("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", "16:9") | ("2K", "3:2") => "2048x1152",
|
||
("1K", "2:3") | ("1K", "9:16") => "1024x1536",
|
||
("1K", "3:2") | ("1K", "16:9") => "1536x1024",
|
||
_ => "1024x1024",
|
||
}
|
||
.to_string()
|
||
}
|
||
|
||
fn editor_generation_provider_image_size_for_model(
|
||
model: &str,
|
||
image_size: &'static str,
|
||
) -> &'static str {
|
||
if model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
|
||
return match image_size {
|
||
"0.5K" => "512",
|
||
"2K" => "2K",
|
||
_ => "1K",
|
||
};
|
||
}
|
||
|
||
image_size
|
||
}
|
||
|
||
pub async fn edit_editor_image(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorImageEditRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let 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,
|
||
Some(authenticated.claims().user_id().to_string()),
|
||
None,
|
||
);
|
||
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: current_owner_user_id(&authenticated),
|
||
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,
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn generate_editor_icon_spritesheet(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<EditorIconSpritesheetGenerationRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let 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(
|
||
payload.price_mud_points,
|
||
Some(generation_options.model),
|
||
)?;
|
||
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,
|
||
Some(authenticated.claims().user_id().to_string()),
|
||
None,
|
||
);
|
||
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 = current_owner_user_id(&authenticated);
|
||
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> {
|
||
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,
|
||
Some(authenticated.claims().user_id().to_string()),
|
||
None,
|
||
);
|
||
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 = current_owner_user_id(&authenticated);
|
||
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: crate::editor_generation_config::editor_image_generation_mud_points(
|
||
Some("icon"),
|
||
Some(GPT_IMAGE_2_MODEL),
|
||
),
|
||
spritesheet_resource: spritesheet_record.resource,
|
||
spritesheet_asset: spritesheet_record.asset,
|
||
},
|
||
))
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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(),
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
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 })
|
||
}
|
||
|
||
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 {
|
||
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()
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||
value
|
||
.map(|item| item.trim().to_string())
|
||
.filter(|item| !item.is_empty())
|
||
}
|
||
|
||
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(
|
||
price_mud_points: u32,
|
||
model: Option<&str>,
|
||
) -> Result<u32, AppError> {
|
||
let expected_price_mud_points =
|
||
crate::editor_generation_config::editor_image_generation_mud_points(Some("icon"), model);
|
||
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的图标素材规范,纯绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张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的角色美术视觉规范指导中的美术风格、角色头身比、角色朝向等特征生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
|
||
format!("角色设定:{}", role_setting.trim()),
|
||
]
|
||
.join("\n")
|
||
}
|
||
|
||
fn prepare_editor_character_image_for_response(
|
||
image: DownloadedOpenAiImage,
|
||
) -> DownloadedOpenAiImage {
|
||
let source_bytes = if image.mime_type == "image/png" {
|
||
image.bytes
|
||
} else {
|
||
match image::load_from_memory(image.bytes.as_slice()) {
|
||
Ok(decoded) => {
|
||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||
if decoded
|
||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||
.is_err()
|
||
{
|
||
return image;
|
||
}
|
||
encoded.into_inner()
|
||
}
|
||
Err(_) => return image,
|
||
}
|
||
};
|
||
|
||
let processed =
|
||
crate::character_visual_assets::try_apply_background_alpha_to_png(source_bytes.as_slice());
|
||
let Some(bytes) = processed else {
|
||
return DownloadedOpenAiImage {
|
||
bytes: source_bytes,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
};
|
||
|
||
DownloadedOpenAiImage {
|
||
bytes,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
struct PersistedEditorGeneratedImage {
|
||
object_key: String,
|
||
asset_object_id: String,
|
||
}
|
||
|
||
async fn persist_editor_character_image(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
task_id: &str,
|
||
image: &DownloadedOpenAiImage,
|
||
prompt: &str,
|
||
actual_prompt: Option<&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(),
|
||
"character-images".to_string(),
|
||
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(EDITOR_CHARACTER_IMAGE_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(EDITOR_CHARACTER_IMAGE_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()),
|
||
EDITOR_CHARACTER_IMAGE_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,
|
||
})
|
||
}
|
||
|
||
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}"),
|
||
})
|
||
}
|
||
|
||
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(),
|
||
})),
|
||
}
|
||
}
|
||
|
||
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(),
|
||
}))
|
||
}
|
||
|
||
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::*;
|
||
|
||
#[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 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 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("禁止镜头透视"));
|
||
assert!(prompt.contains("角色设定:菜市场卖菜大妈"));
|
||
}
|
||
|
||
#[test]
|
||
fn editor_character_image_postprocess_removes_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_postprocess_converts_non_png_green_screen_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_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("纯绿幕背景方便扣除背景"));
|
||
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() {
|
||
assert_eq!(
|
||
validate_editor_icon_spritesheet_price(12, Some("gemini-3.1-flash-image-preview"))
|
||
.unwrap(),
|
||
12
|
||
);
|
||
assert_eq!(
|
||
validate_editor_icon_spritesheet_price(12, Some("gpt-image-2")).unwrap(),
|
||
12
|
||
);
|
||
let error = validate_editor_icon_spritesheet_price(5, Some("gpt-image-2"))
|
||
.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,
|
||
),
|
||
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);
|
||
}
|
||
}
|