Files
Genarrative/server-rs/crates/api-server/src/editor_project_icon.rs
T
kdletters 1f904d28e9
Project CI / Frontend tests (push) Successful in 4m2s
Project CI / Repository checks (push) Successful in 2m16s
Project CI / Backend tests (push) Successful in 10m14s
Project CI / Native shell tests (push) Failing after 19m54s
AGC 客户端 MCP 能力暴露 (#274)
## 目标
保留现有客户端对话与 Codex app-server 链路,把客户端自身受控业务能力通过 MCP 暴露给 Codex。

## 范围
- 客户端会话、项目文件、资源、画布、生成、预览等稳定能力
- 审核 Skill 的索引与按需指导资源
- 复用现有账号、项目路径、权限、计费、幂等、锁和恢复边界

## 明确不做
- 不替换客户端对话入口或 Codex app-server
- 不让客户端替 Codex 判断高层意图、完成状态或规划
- 不暴露任意 Tauri command、shell、凭据、内部 URL、数据库和管理能力

当前 PR 先建立独立分支与审查边界,后续提交实现与定向验证。

Reviewed-on: #274
2026-09-08 22:01:29 +08:00

3028 lines
117 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::{collections::BTreeMap, fmt, io::Cursor, str::FromStr, sync::Arc, time::Instant};
use crate::editor_project::*;
use axum::{
Json,
extract::{Extension, State, rejection::JsonRejection},
http::{HeaderMap, StatusCode},
};
use module_assets::{
AssetObjectAccessPolicy, build_asset_object_upsert_input, generate_asset_object_id,
};
use platform_image::{
DownloadedImage,
generated_asset_sheets::{
GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetConnectedIconPlan,
GeneratedAssetSheetError, prepare_generated_icon_spritesheet_all_by_connected_components,
prepare_generated_icon_spritesheet_grid_2x2,
},
};
use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use serde_json::{Value, json};
use sha2::{Digest as _, Sha256};
use shared_contracts::assets::EditorCanvasGenerationCompletionPayload;
use shared_contracts::editor_generation::editor_generation_stable_asset_object_id;
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::editor_project::{
EditorSpritesheetSliceBatchPersistRecordInput, EditorSpritesheetSlicePersistItemRecordInput,
};
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetGroupSourceLookupRecordInput,
EditorProjectGetRecordInput, EditorProjectResourceCreateRecordInput,
EditorProjectResourceRecord, ExternalGenerationJobRecord,
};
use std::time::Duration;
use tokio::time::sleep;
use crate::{
api_response::json_success_body,
asset_billing::{
execute_billable_asset_operation_with_cost, with_editor_generation_durable_billing_boundary,
},
auth::AuthenticatedAccessToken,
editor_generation_queue::{
EDITOR_ICON_SPEC_GENERATION_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND,
EditorGenerationQueuedResponse, editor_generation_queue_state,
editor_generation_source_entity_id, enqueue_editor_generation_job_for_caller,
},
editor_screen_background_decision::{
EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind,
resolve_editor_screen_background_color,
},
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
normalize_generated_image_asset_mime,
},
http_error::AppError,
openai_image_generation::{build_openai_image_http_client, require_openai_image_settings},
platform_errors::{is_retryable_llm_error, map_llm_error, map_oss_error},
prompt::icon_spec::{PROMPT_MAX_LEN, build_extra_param_prompt, get_gen_icon_spec_prompt},
request_context::RequestContext,
state::AppState,
};
const ICON_SPEC_MODEL: &str = "gpt-image-2";
const ICON_SPEC_ASPECT_RATIO: &str = "16:9";
const ICON_SPEC_IMAGE_SIZE: &str = "2K";
const ICON_SPEC_SIZE: &str = "2048x1152";
const ICON_SPEC_LLM_MAX_ATTEMPTS: u32 = 3;
const ICON_SPEC_EXTRA_PARAM_MAX_LEN: usize = 200;
pub(crate) const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
pub(crate) const EDITOR_ICON_DESCRIPTION_MAX_CHARS: usize = 200;
pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS: usize = 2_000;
pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES: usize = 6 * 1024;
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096;
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048;
const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 64;
pub(crate) const EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY: usize = 2;
pub(crate) const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2;
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2;
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 =
EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4;
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration =
Duration::from_secs(30);
const EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT: usize = 8;
const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet";
const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice";
pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str =
"insufficient-connected-components";
pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str =
"slice-persistence-failed";
pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT: &str =
"raw-component-limit-exceeded";
pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT: &str =
"output-slice-limit-exceeded";
pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT: &str =
"merge-candidate-limit-exceeded";
pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT: &str =
"crop-pixel-limit-exceeded";
pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT: &str = "slice-processing-timeout";
static EDITOR_ICON_SPRITESHEET_CPU_LIMITER: std::sync::LazyLock<
std::sync::Arc<tokio::sync::Semaphore>,
> = std::sync::LazyLock::new(|| {
std::sync::Arc::new(tokio::sync::Semaphore::new(
EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY,
))
});
static EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER: std::sync::LazyLock<
std::sync::Arc<tokio::sync::Semaphore>,
> = std::sync::LazyLock::new(|| {
std::sync::Arc::new(tokio::sync::Semaphore::new(
EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY,
))
});
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ValidatedEditorIconSpritesheetPrompt {
descriptions: Vec<String>,
prompt: String,
}
impl ValidatedEditorIconSpritesheetPrompt {
pub(crate) fn descriptions(&self) -> &[String] {
self.descriptions.as_slice()
}
pub(crate) fn as_str(&self) -> &str {
self.prompt.as_str()
}
pub(crate) fn into_descriptions(self) -> Vec<String> {
self.descriptions
}
}
pub(crate) fn normalize_icon_descriptions(
descriptions: Vec<String>,
) -> Result<ValidatedEditorIconSpritesheetPrompt, AppError> {
if descriptions.len() > EDITOR_ICON_DESCRIPTION_LIMIT {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"maxItems": EDITOR_ICON_DESCRIPTION_LIMIT,
"message": "图标素材描述数量必须在 1 到 100 个之间。",
})),
);
}
let mut normalized = Vec::with_capacity(descriptions.len());
let mut total_chars = 0usize;
let mut total_utf8_bytes = 0usize;
for (item_index, description) in descriptions.into_iter().enumerate() {
let description = description.trim();
if description.is_empty() {
continue;
}
let actual_length = description.chars().count();
if actual_length > EDITOR_ICON_DESCRIPTION_MAX_CHARS {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"itemIndex": item_index,
"maxLength": EDITOR_ICON_DESCRIPTION_MAX_CHARS,
"actualLength": actual_length,
"message": format!(
"每条图标素材描述不能超过 {EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符。"
),
})),
);
}
let separator_length = usize::from(!normalized.is_empty());
let next_total_chars = total_chars
.saturating_add(separator_length)
.saturating_add(actual_length);
if next_total_chars > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"maxTotalLength": EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS,
"actualTotalLength": next_total_chars,
"message": format!(
"图标素材描述合计不能超过 {EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符。"
),
})),
);
}
let next_total_utf8_bytes = total_utf8_bytes
.saturating_add(separator_length)
.saturating_add(description.len());
if next_total_utf8_bytes > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"maxTotalUtf8Bytes": EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES,
"actualTotalUtf8Bytes": next_total_utf8_bytes,
"message": format!(
"图标素材描述合计不能超过 {EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节。"
),
})),
);
}
total_chars = next_total_chars;
total_utf8_bytes = next_total_utf8_bytes;
normalized.push(description.to_string());
}
if normalized.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"minItems": 1,
"message": "图标素材描述数量必须在 1 到 100 个之间。",
})),
);
}
let prompt = normalized.join("\n");
Ok(ValidatedEditorIconSpritesheetPrompt {
descriptions: normalized,
prompt,
})
}
pub(crate) async fn resolve_editor_icon_spritesheet_price(
state: &AppState,
model: Option<&str>,
image_size: Option<&str>,
) -> Result<u32, AppError> {
Ok(state
.editor_generation_pricing()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(Some("icon"), model, image_size))
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EditorIconSpritesheetGenerationRequest {
pub(crate) reference_id: String,
pub(crate) reference_image_srcs: Option<Vec<String>>,
pub(crate) icon_descriptions: Vec<String>,
/// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) slice_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) slice_layout: Option<EditorIconSpritesheetSliceLayout>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) style: Option<String>,
pub(crate) model: Option<String>,
pub(crate) screen_color: Option<String>,
pub(crate) seg_model: Option<String>,
pub(crate) aspect_ratio: Option<String>,
pub(crate) image_size: Option<String>,
pub(crate) project_id: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) asset_label: Option<String>,
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
}
/// Deprecated compatibility layout. New callers should use `sliceCount`。
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) enum EditorIconSpritesheetSliceLayout {
#[serde(rename = "grid-2x2")]
Grid2x2,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EditorIconSpritesheetSliceRequest {
pub(crate) project_id: String,
pub(crate) source_layer_id: String,
pub(crate) source_resource_id: String,
pub(crate) asset_folder_id: Option<String>,
pub(crate) canvas_completion: EditorCanvasGenerationCompletionPayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EditorIconSpritesheetIconResponse {
pub(crate) name: String,
pub(crate) image_src: String,
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) resource: Option<crate::editor_project::EditorProjectResourcePayload>,
pub(crate) asset: Option<crate::editor_project::EditorAssetPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EditorIconSpritesheetSliceWarningResponse {
pub(crate) code: &'static str,
pub(crate) reason: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EditorIconSpritesheetGenerationResponse {
pub(crate) spritesheet_image_src: String,
pub(crate) spritesheet_width: u32,
pub(crate) spritesheet_height: u32,
pub(crate) icon_image_srcs: Vec<EditorIconSpritesheetIconResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) slice_layout: Option<EditorIconSpritesheetSliceLayout>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) slice_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) slice_warning: Option<EditorIconSpritesheetSliceWarningResponse>,
pub(crate) prompt: String,
pub(crate) actual_prompt: Option<String>,
pub(crate) model: String,
pub(crate) task_id: String,
pub(crate) price_mud_points: u32,
pub(crate) spritesheet_resource: Option<crate::editor_project::EditorProjectResourcePayload>,
pub(crate) spritesheet_asset: Option<crate::editor_project::EditorAssetPayload>,
pub(crate) project: Option<crate::editor_project::EditorProjectPayload>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) warning: Option<crate::editor_project::EditorGenerationWarningResponse>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EditorIconSpritesheetSliceResponse {
pub(crate) icon_image_srcs: Vec<EditorIconSpritesheetIconResponse>,
pub(crate) project: crate::editor_project::EditorProjectPayload,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RequestParam {
pub(crate) play_setting: String,
pub(crate) art_style: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct ExtraParam {
pub(crate) genre: GameGenre,
pub(crate) theme: String,
pub(crate) use_case: String,
pub(crate) target_user: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GameGenre {
Strategy,
Shooter,
Rpg,
Casual,
Mmorpg,
Simulation,
Racing,
Moba,
Card,
Sports,
Music,
Sandbox,
ActionAdventure,
Survival,
Party,
Social,
Raising,
LocationBased,
Other,
}
impl GameGenre {
pub(crate) const ALL: [Self; 19] = [
Self::Strategy,
Self::Shooter,
Self::Rpg,
Self::Casual,
Self::Mmorpg,
Self::Simulation,
Self::Racing,
Self::Moba,
Self::Card,
Self::Sports,
Self::Music,
Self::Sandbox,
Self::ActionAdventure,
Self::Survival,
Self::Party,
Self::Social,
Self::Raising,
Self::LocationBased,
Self::Other,
];
pub(crate) const fn as_slug(self) -> &'static str {
match self {
Self::Strategy => "策略",
Self::Shooter => "射击",
Self::Rpg => "RPG",
Self::Casual => "休闲",
Self::Mmorpg => "MMORPG",
Self::Simulation => "模拟",
Self::Racing => "竞速",
Self::Moba => "MOBA",
Self::Card => "卡牌",
Self::Sports => "体育",
Self::Music => "音乐",
Self::Sandbox => "沙盒",
Self::ActionAdventure => "动作冒险",
Self::Survival => "生存",
Self::Party => "派对",
Self::Social => "社交",
Self::Raising => "养成",
Self::LocationBased => "位置游戏",
Self::Other => "其他",
}
}
}
impl fmt::Display for GameGenre {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_slug())
}
}
impl Serialize for GameGenre {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_slug())
}
}
impl<'de> Deserialize<'de> for GameGenre {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let slug = String::deserialize(deserializer)?;
slug.parse().map_err(|()| {
de::Error::custom(format_args!(
"无效游戏类型“{slug}”,必须使用 GameGenre::as_slug() 定义的中文值"
))
})
}
}
impl FromStr for GameGenre {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
let value = value.trim();
Self::ALL
.into_iter()
.find(|genre| genre.as_slug() == value)
.ok_or(())
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EditorIconSpecGenerationRequest {
#[serde(flatten)]
request_param: RequestParam,
#[serde(default)]
reference_id: Option<String>,
#[serde(default)]
project_id: Option<String>,
#[serde(default)]
generation_inputs: Option<Value>,
#[serde(default)]
asset_folder_id: Option<String>,
#[serde(default)]
asset_label: Option<String>,
#[serde(default)]
source_resource_id: Option<String>,
#[serde(default)]
canvas_completion: Option<EditorCanvasGenerationCompletionPayload>,
}
pub(crate) async fn generate_icon_spec(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorIconSpecGenerationRequest>,
) -> Result<Json<Value>, AppError> {
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
let job = enqueue_icon_spec_generation_for_owner(
&state,
&request_context,
&caller,
payload,
None,
)
.await?;
return Ok(crate::api_response::json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(job),
},
));
}
let caller = caller.with_inline_operation(
&request_context,
EDITOR_ICON_SPEC_GENERATION_JOB_KIND,
&payload,
)?;
with_editor_generation_durable_billing_boundary(generate_icon_spec_for_owner(
&state,
&request_context,
caller,
payload,
))
.await
}
pub(crate) async fn enqueue_icon_spec_generation_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: &EditorGenerationCaller,
mut payload: EditorIconSpecGenerationRequest,
external_idempotency_key: Option<&str>,
) -> Result<spacetime_client::ExternalGenerationJobRecord, AppError> {
validate_icon_spec_request_fields(&mut payload)?;
payload.generation_inputs =
sanitize_editor_queued_generation_inputs(payload.generation_inputs.take());
let target = preflight_editor_billable_generation_target(
state,
caller.owner_user_id.as_str(),
payload.project_id.clone(),
payload.asset_folder_id.clone(),
)
.await?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
validate_icon_spec_reference_for_owner(state, caller, &payload).await?;
let price_mud_points = resolve_icon_spec_price(state).await?;
enqueue_editor_generation_job_for_caller(
state,
request_context,
caller.owner_user_id.as_str(),
EDITOR_ICON_SPEC_GENERATION_JOB_KIND,
editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-icon-spec-generation",
),
"图片画布生成图标规范",
u64::from(price_mud_points),
&payload,
external_idempotency_key,
)
.await
}
pub(crate) async fn generate_icon_spec_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
mut payload: EditorIconSpecGenerationRequest,
) -> Result<Json<Value>, AppError> {
validate_icon_spec_request_fields(&mut payload)?;
validate_icon_spec_reference_for_owner(state, &caller, &payload).await?;
let request_param = payload.request_param.clone();
let has_reference_image = payload.reference_id.is_some();
let generation_inputs = payload.generation_inputs.clone();
let image_request = build_icon_spec_image_request(payload, generation_inputs);
generate_editor_image_for_owner_with_prompt_builder(
state,
request_context,
caller,
image_request,
move |generation_inputs| async move {
let extra_param =
run_icon_spec_extra_param_llm(state, build_extra_param_prompt(&request_param))
.await?;
Ok(EditorImagePromptBuildOutput {
prompt: get_gen_icon_spec_prompt(&request_param, &extra_param, has_reference_image),
generation_inputs: Some(build_icon_spec_generation_inputs(
&request_param,
extra_param.genre,
generation_inputs,
)),
})
},
)
.await
}
fn validate_icon_spec_request_fields(
payload: &mut EditorIconSpecGenerationRequest,
) -> Result<(), AppError> {
payload.request_param.play_setting = require_icon_spec_generation_text(
std::mem::take(&mut payload.request_param.play_setting),
"playSetting",
"玩法设定",
)?;
payload.request_param.art_style = require_icon_spec_generation_text(
std::mem::take(&mut payload.request_param.art_style),
"artStyle",
"美术风格",
)?;
Ok(())
}
async fn validate_icon_spec_reference_for_owner(
state: &AppState,
caller: &EditorGenerationCaller,
payload: &EditorIconSpecGenerationRequest,
) -> Result<(), AppError> {
if let Some(reference_id) = payload.reference_id.as_deref() {
validate_editor_reference_id_for_owner(state, caller.owner_user_id.as_str(), reference_id)
.await?;
}
Ok(())
}
async fn resolve_icon_spec_price(state: &AppState) -> Result<u32, AppError> {
Ok(state
.editor_generation_pricing()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing", "message": error.to_string(),
}))
})?
.image_generation_mud_points(
Some("spec"),
Some(ICON_SPEC_MODEL),
Some(ICON_SPEC_IMAGE_SIZE),
))
}
fn build_icon_spec_image_request(
payload: EditorIconSpecGenerationRequest,
generation_inputs: Option<Value>,
) -> EditorImageGenerationRequest {
EditorImageGenerationRequest {
// 中文注释:最终 prompt 由共享执行器的 prompt builder 在预扣后构建,
// 这里不再放置会被误校验或误持久化的占位文本。
prompt: String::new(),
size: Some(ICON_SPEC_SIZE.to_string()),
kind: Some("spec".to_string()),
style: None,
model: Some(ICON_SPEC_MODEL.to_string()),
screen_color: None,
seg_model: None,
aspect_ratio: Some(ICON_SPEC_ASPECT_RATIO.to_string()),
image_size: Some(ICON_SPEC_IMAGE_SIZE.to_string()),
reference_image_srcs: payload.reference_id.map(|reference_id| vec![reference_id]),
project_id: payload.project_id,
asset_kind: Some("icon-spec".to_string()),
generation_inputs,
asset_folder_id: payload.asset_folder_id,
asset_label: payload.asset_label,
source_resource_id: payload.source_resource_id,
canvas_completion: payload.canvas_completion,
}
}
async fn run_icon_spec_extra_param_llm(
state: &AppState,
prompt: String,
) -> Result<ExtraParam, AppError> {
let configured_llm_client = state.vector_engine_llm_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "editor-icon-spec-llm",
"message": "服务端尚未配置可用的 VectorEngine LLM API Key",
}))
})?;
let retry_backoff_ms = configured_llm_client.config().retry_backoff_ms();
let llm_client = configured_llm_client.clone().with_max_retries(0);
let request = LlmRunRequest::new(vec![LlmMessage::user(prompt)])
.with_model(EDITOR_AGENT_GPT5_MODEL)
.with_openai_chat();
for attempt in 1..=ICON_SPEC_LLM_MAX_ATTEMPTS {
match llm_client.run(request.clone()).await {
Ok(response) => match parse_extra_param_response(
response.text.as_str(),
response.finish_reason.as_deref(),
) {
Ok(extra_param) => return Ok(extra_param),
Err(_) if attempt < ICON_SPEC_LLM_MAX_ATTEMPTS => {}
Err(error) => {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
json!({
"provider": "editor-icon-spec-llm",
"operation": "complete-extra-param",
"message": error.message(),
}),
));
}
},
Err(error) if is_retryable_llm_error(&error) => {
if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS {
return Err(map_llm_error(error));
}
}
Err(error) => return Err(map_llm_error(error)),
}
sleep_before_icon_spec_llm_retry(retry_backoff_ms, attempt).await;
}
unreachable!("icon spec extra param retry loop always returns")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ExtraParamResponseError {
Empty,
Incomplete,
Invalid,
}
impl ExtraParamResponseError {
fn message(self) -> &'static str {
match self {
Self::Empty => "LLM 未返回可用文本",
Self::Incomplete => "LLM 返回内容未完整结束",
Self::Invalid => "LLM 返回内容不符合图标规范参数契约",
}
}
}
fn parse_extra_param_response(
text: &str,
finish_reason: Option<&str>,
) -> Result<ExtraParam, ExtraParamResponseError> {
if finish_reason.is_some_and(|reason| {
platform_llm::is_incomplete_finish_reason(platform_llm::LlmApiKind::OpenAiChat, reason)
}) {
return Err(ExtraParamResponseError::Incomplete);
}
let text = text.trim();
if text.is_empty() {
return Err(ExtraParamResponseError::Empty);
}
parse_extra_param(text).map_err(|_| ExtraParamResponseError::Invalid)
}
fn parse_extra_param(text: &str) -> Result<ExtraParam, ()> {
let mut extra_param = serde_json::from_str::<ExtraParam>(text).map_err(|_| ())?;
for value in [
&mut extra_param.theme,
&mut extra_param.use_case,
&mut extra_param.target_user,
] {
let normalized = value.trim();
if normalized.is_empty() || normalized.chars().count() > ICON_SPEC_EXTRA_PARAM_MAX_LEN {
return Err(());
}
if normalized.len() != value.len() {
*value = normalized.to_string();
}
}
Ok(extra_param)
}
async fn sleep_before_icon_spec_llm_retry(retry_backoff_ms: u64, attempt: u32) {
let delay_ms = retry_backoff_ms.saturating_mul(u64::from(attempt));
if delay_ms > 0 {
sleep(Duration::from_millis(delay_ms)).await;
}
}
fn require_icon_spec_generation_text(
value: String,
field: &'static str,
field_label: &'static str,
) -> Result<String, AppError> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spec",
"field": field,
"message": format!("{field_label}不能为空"),
})),
);
}
let actual_length = value.chars().count();
if actual_length > PROMPT_MAX_LEN {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spec",
"field": field,
"maxLength": PROMPT_MAX_LEN,
"actualLength": actual_length,
"message": format!("{field_label}不能超过 {PROMPT_MAX_LEN} 个字符"),
})),
);
}
Ok(value)
}
fn build_icon_spec_generation_inputs(
request: &RequestParam,
genre: GameGenre,
current: Option<Value>,
) -> Value {
let references = sanitize_editor_client_generation_inputs(current)
.as_ref()
.and_then(|value| value.get("references"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
json!({
"fields": [
{ "title": "玩法设定", "value": request.play_setting },
{ "title": "美术风格", "value": request.art_style },
{ "title": "游戏类型", "value": genre.as_slug() },
],
"references": references,
})
}
pub(crate) fn game_genre_from_generation_inputs(
value: Option<&Value>,
) -> Result<Option<GameGenre>, ()> {
let raw_genre = value
.and_then(|value| value.get("fields"))
.and_then(Value::as_array)
.and_then(|fields| {
fields
.iter()
// TODO use id field
.find(|field| field.get("title").and_then(Value::as_str) == Some("游戏类型"))
})
.and_then(|field| field.get("value"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let Some(raw_genre) = raw_genre else {
return Ok(None);
};
raw_genre.parse().map(Some)
}
pub(crate) struct PersistEditorSpritesheetSlicesInput {
pub(crate) owner_user_id: String,
pub(crate) project_id: Option<String>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) source_resource_id: Option<String>,
pub(crate) task_id: String,
pub(crate) group_task_id: Option<String>,
pub(crate) prompt: String,
pub(crate) actual_prompt: Option<String>,
pub(crate) model: String,
pub(crate) provider: String,
pub(crate) generation_inputs: Option<Value>,
pub(crate) path_kind: &'static str,
pub(crate) asset_kind: &'static str,
pub(crate) persistence_provider: &'static str,
}
struct EditorSpritesheetSliceUpload {
index: usize,
name: String,
width: u32,
height: u32,
image_src: String,
object_key: String,
asset_object: module_assets::AssetObjectUpsertInput,
}
struct EditorIconSpritesheetMemoryAdmission {
_permit: tokio::sync::OwnedSemaphorePermit,
}
#[derive(Clone)]
pub(crate) struct PreparedEditorIconSpritesheetPlan {
plan: GeneratedAssetSheetConnectedIconPlan,
processing_deadline: Instant,
// 中文注释:每个仍可能运行的 blocking 编码任务都持有同一 admission,超时取消
// async 等待时也不会提前释放整张 RGBA 的内存名额。
_memory_admission: Arc<EditorIconSpritesheetMemoryAdmission>,
}
pub(crate) async fn persist_editor_spritesheet_slices(
state: &AppState,
prepared: PreparedEditorIconSpritesheetPlan,
input: PersistEditorSpritesheetSlicesInput,
) -> Result<Vec<EditorIconSpritesheetIconResponse>, AppError> {
use futures_util::{StreamExt as _, TryStreamExt as _};
if prepared.plan.is_empty() {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集中未识别到可拆分的独立素材。",
})),
);
}
let expected_asset_count = u32::try_from(prepared.plan.len()).map_err(|_| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集拆分产物数量超过支持范围。",
}))
})?;
let input = Arc::new(input);
let http_client = build_editor_spritesheet_http_client(
EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT,
EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT,
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "aliyun-oss",
"message": format!("创建图集切片上传客户端失败:{error}"),
}))
})?;
// TODO(legacy-spritesheet-deadline): 历史实现的 processing_deadline 只覆盖准入、下载、
// 切片规划和编码,尚未覆盖后续 PUT、HEAD 与批量持久化;应结合可对账的副作用状态机
// 统一剩余预算,避免仅中断本地等待而让远端提交处于不可查询状态。
// TODO(legacy-spritesheet-cleanup): 历史并发上传链路在部分 PUT/HEAD 成功后失败时没有
// 记录成功对象并执行可重试补偿;需先定义 staged/committed/cleanup-pending 生命周期,
// 再处理批量持久化失败、补偿失败和任务重放,不能只做一次 best-effort delete。
// TODO(legacy-spritesheet-pipeline): 该并发结构继承自 master;同一个 stream slot 串联编码、
// PUT 与 HEAD,慢上传会阻塞尚未开始的编码并继续消耗 processing_deadline。应拆成带有界
// channel 的独立编码池和上传池,并分别定义 CPU/内存准入、处理期限与网络超时。
let mut uploads = futures_util::stream::iter(0..prepared.plan.len())
.map(|index| {
let prepared = prepared.clone();
let http_client = http_client.clone();
let input = Arc::clone(&input);
async move {
let slice = encode_editor_icon_spritesheet_slice(prepared, index).await?;
upload_editor_spritesheet_slice(state, &http_client, index, slice, &input).await
}
})
.buffer_unordered(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY)
.try_collect::<Vec<_>>()
.await?;
uploads.sort_by_key(|upload| upload.index);
// 中文注释:上传收齐后只保留轻量 HEAD 元数据;数据库慢调用不应继续占用
// 整张 RGBA 与图集专用内存 admission。
drop(prepared);
let generation_inputs_json = serialize_editor_asset_metadata(input.generation_inputs.clone())?;
let folder_id = normalize_generated_asset_folder_id(
input.asset_folder_id.clone(),
input.owner_user_id.as_str(),
);
let now_micros = current_utc_micros();
let items = uploads
.iter()
.map(|upload| {
let resource_id = input.project_id.as_ref().map(|_| {
stable_editor_spritesheet_slice_record_id(
EDITOR_RESOURCE_ID_PREFIX,
input.owner_user_id.as_str(),
input.task_id.as_str(),
upload.index,
)
});
let project_resource = input.project_id.as_ref().map(|project_id| {
EditorProjectResourceCreateRecordInput {
resource_id: resource_id.clone().expect("resource id must exist"),
project_id: project_id.clone(),
owner_user_id: input.owner_user_id.clone(),
asset_object_id: Some(upload.asset_object.asset_object_id.clone()),
image_src: upload.image_src.clone(),
object_key: Some(upload.object_key.clone()),
width: upload.width,
height: upload.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: Some("icon".to_string()),
generation_inputs_json: generation_inputs_json.clone(),
updated_at_micros: now_micros,
image_sequence_frames_json: None,
image_sequence_duration_ms: None,
}
});
let asset = folder_id
.as_ref()
.map(|folder_id| EditorAssetCreateRecordInput {
asset_id: stable_editor_spritesheet_slice_record_id(
EDITOR_ASSET_ID_PREFIX,
input.owner_user_id.as_str(),
input.task_id.as_str(),
upload.index,
),
owner_user_id: input.owner_user_id.clone(),
folder_id: folder_id.clone(),
label: upload.name.clone(),
asset_object_id: Some(upload.asset_object.asset_object_id.clone()),
image_src: upload.image_src.clone(),
object_key: Some(upload.object_key.clone()),
width: upload.width,
height: upload.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()),
asset_kind: Some("icon".to_string()),
generation_inputs_json: generation_inputs_json.clone(),
source_resource_id: resource_id.clone(),
generation_cost_mud_points: 0,
now_micros,
thumbnail_src: None,
group_task_id: normalize_optional_string(input.group_task_id.clone()),
group_task_expected_asset_count: input
.group_task_id
.as_ref()
.map(|_| expected_asset_count),
image_sequence_frames_json: None,
image_sequence_duration_ms: None,
});
EditorSpritesheetSlicePersistItemRecordInput {
asset_object: upload.asset_object.clone(),
project_resource,
asset,
}
})
.collect();
let persisted = state
.spacetime_client()
.persist_editor_spritesheet_slice_batch(EditorSpritesheetSliceBatchPersistRecordInput {
owner_user_id: input.owner_user_id.clone(),
task_id: input.task_id.clone(),
group_task_id: input.group_task_id.clone(),
expected_asset_count,
items,
completed_at_micros: now_micros,
})
.await
.map_err(map_editor_project_error)?;
if persisted.items.len() != uploads.len() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "图集切片批量持久化返回数量不一致。",
})),
);
}
Ok(uploads
.into_iter()
.zip(persisted.items)
.map(|(upload, item)| EditorIconSpritesheetIconResponse {
name: upload.name,
image_src: upload.image_src,
width: upload.width,
height: upload.height,
resource: item
.project_resource
.map(editor_project_resource_payload_from_record),
asset: item.asset.map(editor_asset_payload_from_record),
})
.collect())
}
pub(crate) struct PreparedEditorSpritesheetSlices {
pub(crate) responses: Vec<EditorIconSpritesheetIconResponse>,
pub(crate) items: Vec<PreparedEditorGenerationResultItem>,
}
pub(crate) async fn prepare_editor_spritesheet_slices_for_generation(
state: &AppState,
caller: &EditorGenerationCaller,
prepared: PreparedEditorIconSpritesheetPlan,
input: PersistEditorSpritesheetSlicesInput,
) -> Result<PreparedEditorSpritesheetSlices, AppError> {
use futures_util::{StreamExt as _, TryStreamExt as _};
if prepared.plan.is_empty() {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集中未识别到可拆分的独立素材。",
})),
);
}
let input = Arc::new(input);
let http_client = build_editor_spritesheet_http_client(
EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT,
EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT,
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "aliyun-oss",
"message": format!("创建图集切片上传客户端失败:{error}"),
}))
})?;
let mut uploads = futures_util::stream::iter(0..prepared.plan.len())
.map(|index| {
let prepared = prepared.clone();
let http_client = http_client.clone();
let input = Arc::clone(&input);
async move {
let slice = encode_editor_icon_spritesheet_slice(prepared, index).await?;
upload_editor_spritesheet_slice(state, &http_client, index, slice, &input).await
}
})
.buffer_unordered(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY)
.try_collect::<Vec<_>>()
.await?;
uploads.sort_by_key(|upload| upload.index);
drop(prepared);
let operation = caller.operation.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-operation",
"message": "准备图集切片时缺少稳定 operation 上下文。",
}))
})?;
let prepared_at_micros = current_utc_micros();
let mut responses = Vec::with_capacity(uploads.len());
let mut items = Vec::with_capacity(uploads.len());
for mut upload in uploads {
let slot = format!("slice-{:04}", upload.index);
upload.asset_object.asset_object_id = editor_generation_stable_asset_object_id(
caller.owner_user_id.as_str(),
operation.operation_kind.as_str(),
operation.operation_id.as_str(),
slot.as_str(),
);
let prepared_item = prepare_editor_generated_asset(
caller,
slot.as_str(),
Some(upload.asset_object),
PersistEditorGeneratedAssetInput {
project_id: input.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: input.asset_folder_id.clone(),
label: upload.name.clone(),
image_src: upload.image_src.clone(),
object_key: Some(upload.object_key),
asset_object_id: None,
width: upload.width,
height: upload.height,
prompt: input.prompt.clone(),
actual_prompt: input.actual_prompt.clone(),
model: input.model.clone(),
provider: input.provider.clone(),
task_id: input.task_id.clone(),
group_task_id: input.group_task_id.clone(),
group_task_expected_asset_count: None,
source_resource_id: input.source_resource_id.clone(),
asset_kind: Some("icon".to_string()),
generation_inputs: input.generation_inputs.clone(),
thumbnail_src: None,
generation_cost_mud_points: 0,
image_sequence_frames: None,
image_sequence_duration_ms: None,
},
prepared_at_micros,
)?;
responses.push(EditorIconSpritesheetIconResponse {
name: upload.name,
image_src: upload.image_src,
width: upload.width,
height: upload.height,
resource: prepared_item.record.resource,
asset: prepared_item.record.asset,
});
items.push(prepared_item.item);
}
Ok(PreparedEditorSpritesheetSlices { responses, items })
}
pub(crate) fn stable_editor_spritesheet_slice_record_id(
prefix: &str,
owner_user_id: &str,
task_id: &str,
index: usize,
) -> String {
let digest = Sha256::digest(
format!(
"editor-spritesheet-slice\n{}\n{}\n{index}",
owner_user_id.trim(),
task_id.trim()
)
.as_bytes(),
);
format!("{prefix}{}", hex::encode(&digest[..16]))
}
pub(crate) fn build_editor_spritesheet_http_client(
connect_timeout: Duration,
request_timeout: Duration,
) -> Result<reqwest::Client, reqwest::Error> {
reqwest::Client::builder()
.connect_timeout(connect_timeout)
.timeout(request_timeout)
.build()
}
async fn encode_editor_icon_spritesheet_slice(
prepared: PreparedEditorIconSpritesheetPlan,
index: usize,
) -> Result<GeneratedAssetSheetConnectedIcon, AppError> {
if Instant::now() >= prepared.processing_deadline {
return Err(editor_icon_spritesheet_processing_timeout_error());
}
let permit = match tokio::time::timeout_at(
tokio::time::Instant::from_std(prepared.processing_deadline),
Arc::clone(&*EDITOR_ICON_SPRITESHEET_CPU_LIMITER).acquire_owned(),
)
.await
{
Ok(Ok(permit)) => permit,
Ok(Err(error)) => {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT,
"message": format!("图集切片 CPU 并发门限不可用:{error}"),
})),
);
}
Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()),
};
let processing_deadline = prepared.processing_deadline;
let worker = tokio::task::spawn_blocking(move || {
// 中文注释:blocking 任务同时持有 CPU permit 和内存 admissionasync 超时
// 只放弃等待,不能让尚未退出的编码绕过任一门限。
let _permit = permit;
let _memory_admission = &prepared._memory_admission;
if Instant::now() >= processing_deadline {
return Err(editor_icon_spritesheet_processing_timeout_error());
}
prepared
.plan
.encode(index)
.map_err(map_editor_icon_spritesheet_platform_error)
});
match tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker).await
{
Ok(Ok(result)) => result,
Ok(Err(error)) => Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": format!("图集切片编码工作线程异常:{error}"),
}))),
Err(_) => Err(editor_icon_spritesheet_processing_timeout_error()),
}
}
async fn upload_editor_spritesheet_slice(
state: &AppState,
http_client: &reqwest::Client,
index: usize,
slice: GeneratedAssetSheetConnectedIcon,
input: &PersistEditorSpritesheetSlicesInput,
) -> Result<EditorSpritesheetSliceUpload, 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(input.path_kind, "generated-images"),
sanitize_editor_storage_segment(input.task_id.as_str(), "task"),
],
file_stem: sanitize_editor_storage_segment(
format!("asset-{:03}", index + 1).as_str(),
"image",
),
image: GeneratedImageAssetDataUrl {
format: normalize_generated_image_asset_mime("image/png"),
bytes: slice.bytes,
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(input.asset_kind.to_string()),
owner_user_id: Some(input.owner_user_id.clone()),
entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()),
entity_id: Some(input.task_id.clone()),
slot: Some("asset".to_string()),
provider: Some(input.persistence_provider.to_string()),
task_id: Some(input.task_id.clone()),
},
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 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,
},
)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let now_micros = current_utc_micros();
let 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(
input
.actual_prompt
.as_deref()
.unwrap_or(input.prompt.as_str())
.to_string(),
),
input.asset_kind.to_string(),
Some(input.task_id.clone()),
Some(input.owner_user_id.clone()),
None,
Some(input.task_id.clone()),
now_micros,
)
.map_err(map_editor_asset_field_error)?;
Ok(EditorSpritesheetSliceUpload {
index,
name: slice.name,
width: slice.width,
height: slice.height,
image_src: editor_media_src_from_object_key(head.object_key.as_str()),
object_key: head.object_key,
asset_object,
})
}
pub async fn generate_editor_icon_spritesheet(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
payload: Result<Json<EditorIconSpritesheetGenerationRequest>, JsonRejection>,
) -> Result<Json<Value>, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
let idempotency_key = optional_editor_idempotency_key(&headers)?;
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
if !state.config.external_generation_mode.is_inline() {
let queue_job = enqueue_editor_icon_spritesheet_generation_for_owner(
&state,
&request_context,
&caller,
payload,
idempotency_key,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorGenerationQueuedResponse {
queue_state: editor_generation_queue_state(queue_job),
},
));
}
let caller = caller.with_inline_operation(
&request_context,
EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND,
&payload,
)?;
with_editor_generation_durable_billing_boundary(generate_editor_icon_spritesheet_for_owner(
&state,
&request_context,
caller,
payload,
))
.await
}
pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: &EditorGenerationCaller,
mut payload: EditorIconSpritesheetGenerationRequest,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, AppError> {
payload.generation_inputs =
sanitize_editor_queued_generation_inputs(payload.generation_inputs.take());
payload.icon_descriptions =
normalize_icon_descriptions(payload.icon_descriptions)?.into_descriptions();
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-icon-spritesheet",
"referenceImageSrcs",
"图标素材参考图",
)?;
let generation_options = normalize_editor_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let extra_reference_limit = EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT
.min(editor_provider_reference_limit(generation_options.model).saturating_sub(1));
ensure_editor_reference_image_source_limit(
payload.reference_image_srcs.as_deref(),
extra_reference_limit,
"editor-icon-spritesheet",
"referenceImageSrcs",
"图标素材参考图",
)?;
let target = preflight_editor_billable_generation_target(
state,
caller.owner_user_id.as_str(),
payload.project_id.clone(),
payload
.asset_folder_id
.clone()
.or_else(|| Some("project".to_string())),
)
.await?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
preflight_editor_icon_spec_reference_metadata(
state,
caller.owner_user_id.as_str(),
payload.reference_id.as_str(),
)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "referenceId",
"message": "图标素材规范必须是当前用户已登记的 icon-spec 资源或素材。",
}))
})?;
let price_mud_points = u64::from(
resolve_editor_icon_spritesheet_price(
state,
Some(generation_options.model),
Some(generation_options.image_size),
)
.await?,
);
let source_entity_id = editor_generation_source_entity_id(
payload.project_id.as_deref(),
"editor-icon-spritesheet-generation",
);
enqueue_editor_generation_job_for_caller(
state,
request_context,
caller.owner_user_id.as_str(),
EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND,
source_entity_id,
"图片画布生成图标素材",
price_mud_points,
&payload,
external_idempotency_key,
)
.await
}
pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
mut payload: EditorIconSpritesheetGenerationRequest,
) -> Result<Json<Value>, AppError> {
payload.generation_inputs =
sanitize_editor_client_generation_inputs(payload.generation_inputs.take());
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-icon-spritesheet",
"referenceImageSrcs",
"图标素材参考图",
)?;
let generation_options = normalize_editor_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let extra_reference_limit = EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT
.min(editor_provider_reference_limit(generation_options.model).saturating_sub(1));
ensure_editor_reference_image_source_limit(
payload.reference_image_srcs.as_deref(),
extra_reference_limit,
"editor-icon-spritesheet",
"referenceImageSrcs",
"图标素材参考图",
)?;
let target = preflight_editor_billable_generation_target(
state,
caller.owner_user_id.as_str(),
payload.project_id.clone(),
payload
.asset_folder_id
.clone()
.or_else(|| Some("project".to_string())),
)
.await?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
payload.generation_inputs = rebuild_editor_generation_input_references(
state,
caller.owner_user_id.as_str(),
payload.generation_inputs.take(),
build_editor_generation_reference_sources(
Some(("图标规范", payload.reference_id.as_str())),
payload.reference_image_srcs.as_deref(),
"参考图",
),
)
.await?;
let spritesheet_prompt = normalize_icon_descriptions(payload.icon_descriptions)?;
let user_prompt = spritesheet_prompt.as_str().to_string();
let resolved_icon_spec = resolve_editor_icon_spec_reference(
state,
caller.owner_user_id.as_str(),
payload.reference_id.as_str(),
)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "referenceId",
"message": "图标素材规范必须是当前用户已登记的 icon-spec 资源或素材。",
}))
})?;
let icon_spec_genre = resolved_icon_spec.genre;
let (image_style, mut generation_warning) =
normalize_editor_image_generation_style(payload.style.as_deref(), true);
// 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时
// 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。
let requested_screen_color = payload.screen_color.clone();
let reference_count = 1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len);
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
let mut reference_images =
Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len));
reference_images.push(resolved_icon_spec.image);
for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref())
{
reference_images.push(
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "referenceImageSrcs",
"message": "图标素材参考图必须是 objectKey、资源或素材引用。",
}))
})?,
);
}
let expected_price_mud_points = resolve_editor_icon_spritesheet_price(
state,
Some(generation_options.model),
Some(generation_options.image_size),
)
.await?;
let size = generation_options.provider_size.as_str();
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
// TODO(legacy-icon-spritesheet-billing-boundary): 该计费边界继承自 master 的历史实现;
// Provider 成功后 operation 即提交,后续解码、OSS、资源与画布持久化失败时缺少可对账中间态。
// 调整前需先定义 provider_succeeded/persistence_pending 等状态、稳定幂等键和补偿语义,
// 不能只把后续副作用机械包入 billable future。
let (generated, prompt, screen_background_decision) =
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
"editor_icon_spritesheet",
request_context.request_id(),
u64::from(expected_price_mud_points),
async {
// 背景色决策:预扣泥点之后才发起(决策失败→future 返回 Err→失败退款)。
let screen_background_decision = resolve_editor_screen_background_color(
state.llm_client(),
state.vector_engine_llm_client(),
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::IconSpritesheet,
screen_color: requested_screen_color.clone(),
prompt: spritesheet_prompt.as_str().to_string(),
icon_descriptions: spritesheet_prompt.descriptions().to_vec(),
reference_count,
source_image_data_url: None,
},
Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
state,
context: crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
external_call_deadline: request_context.external_call_deadline(),
},
}),
)
.await?;
let screen_color = screen_background_decision.color;
let prompt = match payload.slice_layout {
Some(EditorIconSpritesheetSliceLayout::Grid2x2) => {
crate::prompt::icon_spec::build_grid_2x2_spritesheet_prompt(
&spritesheet_prompt,
screen_color,
icon_spec_genre,
)
}
None => crate::prompt::icon_spec::build_spritesheet_prompt(
&spritesheet_prompt,
screen_color,
icon_spec_genre,
),
};
let prompt = apply_editor_pixel_art_style_prompt(
prompt,
image_style,
EditorPixelArtPromptScope::IconSpritesheet,
);
let generated = request_editor_generated_images(
&http_client,
&settings,
EditorImageProviderRequest {
model: generation_options.model,
prompt: prompt.as_str(),
negative_prompt: None,
size,
aspect_ratio: generation_options.aspect_ratio,
image_size: generation_options.provider_image_size,
reference_images: reference_images.as_slice(),
failure_context: "图片画布生成图标素材 spritesheet",
},
)
.await?;
Ok::<_, AppError>((generated, prompt, screen_background_decision))
},
)
.await?;
let screen_color = screen_background_decision.color;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "VectorEngine 未返回图标 spritesheet",
}))
})?;
let (source_width, source_height) = decode_editor_provider_source_dimensions(
&image,
"editor-icon-spritesheet",
"图标 spritesheet 原图不是有效图片",
)?;
let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs(
payload.generation_inputs.clone(),
Some(&screen_background_decision),
);
let spritesheet_label =
resolve_editor_generated_asset_label(payload.asset_label.clone(), "图标素材图集");
let asset_folder_id = payload
.asset_folder_id
.clone()
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string()));
let source_persisted = prepare_editor_generated_image(
state,
&caller,
generated.task_id.as_str(),
&image,
user_prompt.as_str(),
generated.actual_prompt.as_deref(),
EDITOR_ICON_SPRITESHEET_ASSET_KIND,
"icon-spritesheets",
"spritesheet-provider-source",
EDITOR_PROVIDER_SOURCE_SLOT,
"vector-engine",
)
.await?;
let source_image_src = editor_media_src_from_object_key(source_persisted.object_key.as_str());
let source_object_key = source_persisted.object_key.clone();
let source_asset_object_id = source_persisted.asset_object_id().to_string();
let source_record = prepare_editor_generated_asset(
&caller,
EDITOR_PROVIDER_SOURCE_SLOT,
Some(source_persisted.asset_object),
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: caller.owner_user_id.clone(),
folder_id: asset_folder_id.clone(),
label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"),
image_src: source_image_src.clone(),
object_key: Some(source_object_key.clone()),
asset_object_id: Some(source_asset_object_id.clone()),
width: source_width,
height: source_height,
prompt: user_prompt.clone(),
actual_prompt: generated.actual_prompt.clone(),
model: generation_options.model.to_string(),
task_id: generated.task_id.clone(),
source_resource_id: None,
asset_kind: Some("icon-spritesheet".to_string()),
generation_inputs: generation_inputs.clone(),
generation_cost_mud_points: u64::from(expected_price_mud_points),
provider: "VectorEngine".to_string(),
group_task_id: None,
group_task_expected_asset_count: None,
thumbnail_src: None,
image_sequence_frames: None,
image_sequence_duration_ms: None,
},
current_utc_micros(),
)?;
caller.report_processing_phase(state).await?;
let matting_audit = crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
external_call_deadline: request_context.external_call_deadline(),
};
let removal = remove_editor_generated_screen_background_with_bgfilter(
state,
source_object_key.as_str(),
screen_color,
seg_model,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
&matting_audit,
)
.await;
let removal = match removal {
Ok(removal) => removal,
Err(error) => {
let failure_message = error.body_text();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "background_postprocess",
task_id = %generated.task_id,
error = %failure_message,
"图标图集原图已保存,但透明背景处理失败,使用原图完成画布"
);
let (source_record, completed_project, fallback_warning) =
persist_editor_source_only_fallback_atomically(
state,
&caller,
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
source_record,
generated.task_id.as_str(),
source_image_src.as_str(),
source_object_key.as_str(),
source_asset_object_id.as_str(),
EditorSourceOnlyFallbackResultContext {
width: source_width,
height: source_height,
prompt: prompt.as_str(),
actual_prompt: generated.actual_prompt.as_deref(),
model: generation_options.model,
price_mud_points: expected_price_mud_points,
},
generation_warning,
None,
)
.await?;
return Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetGenerationResponse {
spritesheet_image_src: source_image_src,
spritesheet_width: source_width,
spritesheet_height: source_height,
icon_image_srcs: Vec::new(),
slice_layout: payload.slice_layout,
slice_count: Some(0),
slice_warning: None,
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: source_record.resource,
spritesheet_asset: source_record.asset,
project: completed_project,
warning: fallback_warning,
},
));
}
};
let removal_provider = removal.provider;
let removal_model = removal.model.clone();
let (image, postprocess_dimension_error, pixel_art_error) = if image_style
== EditorImageGenerationStyle::PixelArt
{
apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original(
state,
source_object_key.as_str(),
source_width,
source_height,
removal.image,
request_context.external_call_deadline(),
)
.await
} else {
let (image, error) =
apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original(
state,
source_object_key.as_str(),
source_width,
source_height,
removal.image,
None,
)
.await;
(image, error, None)
};
if let Some(error) = postprocess_dimension_error {
tracing::warn!(
task_id = %generated.task_id,
provider = removal_provider,
source_width,
source_height,
error = ?error,
"图标透明图蒙版尺寸恢复失败,改用已保存的 provider 原图完成画布"
);
let (source_record, completed_project, fallback_warning) =
persist_editor_source_only_fallback_atomically(
state,
&caller,
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
source_record,
generated.task_id.as_str(),
source_image_src.as_str(),
source_object_key.as_str(),
source_asset_object_id.as_str(),
EditorSourceOnlyFallbackResultContext {
width: source_width,
height: source_height,
prompt: prompt.as_str(),
actual_prompt: generated.actual_prompt.as_deref(),
model: generation_options.model,
price_mud_points: expected_price_mud_points,
},
generation_warning,
None,
)
.await?;
return Ok(json_success_body(
Some(request_context),
EditorIconSpritesheetGenerationResponse {
spritesheet_image_src: source_image_src,
spritesheet_width: source_width,
spritesheet_height: source_height,
icon_image_srcs: Vec::new(),
slice_layout: payload.slice_layout,
slice_count: Some(0),
slice_warning: None,
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: source_record.resource,
spritesheet_asset: source_record.asset,
project: completed_project,
warning: fallback_warning,
},
));
}
if let Some(error) = pixel_art_error {
tracing::warn!(
task_id = %generated.task_id,
provider = removal_provider,
error = %error,
"图标图集像素规整失败,保留透明后处理图"
);
generation_warning = merge_editor_generation_warnings(
generation_warning,
Some(editor_pixel_art_fallback_warning()),
);
}
let matting_generation_inputs = apply_editor_matting_metadata_to_generation_inputs(
build_editor_derived_asset_generation_inputs("图标图集抠图", &source_record.record),
removal_provider,
removal_model.as_str(),
);
let (spritesheet_width, spritesheet_height) = decode_editor_provider_source_dimensions(
&image,
"editor-icon-spritesheet",
"图标 spritesheet 透明后处理图不是有效图片",
)?;
let owner_user_id = caller.owner_user_id.clone();
let spritesheet_prepared_image = prepare_editor_generated_image(
state,
&caller,
generated.task_id.as_str(),
&image,
user_prompt.as_str(),
None,
EDITOR_ICON_SPRITESHEET_ASSET_KIND,
"icon-spritesheets",
"spritesheet",
"spritesheet",
removal_provider,
)
.await?;
let spritesheet_image_src =
editor_media_src_from_object_key(spritesheet_prepared_image.object_key.as_str());
let prepared_at_micros = current_utc_micros();
let spritesheet_record = prepare_editor_generated_asset(
&caller,
"spritesheet",
Some(spritesheet_prepared_image.asset_object.clone()),
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
folder_id: asset_folder_id.clone(),
label: spritesheet_label,
image_src: spritesheet_image_src.clone(),
object_key: Some(spritesheet_prepared_image.object_key.clone()),
asset_object_id: Some(spritesheet_prepared_image.asset_object_id().to_string()),
width: spritesheet_width,
height: spritesheet_height,
prompt: user_prompt.clone(),
actual_prompt: None,
model: generation_options.model.to_string(),
provider: removal_provider.to_string(),
task_id: generated.task_id.clone(),
group_task_id: None,
group_task_expected_asset_count: None,
source_resource_id: source_record
.record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
asset_kind: Some("icon-spritesheet".to_string()),
generation_inputs: matting_generation_inputs,
thumbnail_src: None,
generation_cost_mud_points: 0,
image_sequence_frames: None,
image_sequence_duration_ms: None,
},
prepared_at_micros,
)?;
let slice_source = DownloadedImage {
bytes: image.bytes,
mime_type: image.mime_type,
extension: image.extension,
};
let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all(
slice_source,
request_context.external_call_deadline(),
payload.slice_layout,
payload.slice_count,
)
.await
{
Ok(icon_slices) => {
match prepare_editor_spritesheet_slices_for_generation(
state,
&caller,
icon_slices,
PersistEditorSpritesheetSlicesInput {
owner_user_id: owner_user_id.clone(),
project_id: payload.project_id.clone(),
asset_folder_id: asset_folder_id.clone(),
source_resource_id: spritesheet_record
.record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
task_id: generated.task_id.clone(),
group_task_id: None,
prompt: user_prompt.clone(),
actual_prompt: None,
model: generation_options.model.to_string(),
provider: "Genarrative".to_string(),
generation_inputs: build_editor_derived_asset_generation_inputs(
"图集拆分",
&spritesheet_record.record,
),
path_kind: "icon-spritesheet-assets",
asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND,
persistence_provider: "vector-engine",
},
)
.await
{
Ok(prepared_slices) => (prepared_slices.responses, prepared_slices.items, None),
Err(error) => {
let reason = error.body_text();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "persist_automatic_slices",
task_id = %generated.task_id,
reason = %reason,
"图标图集已持久化,但自动拆分素材持久化失败"
);
(
Vec::new(),
Vec::new(),
Some(editor_icon_spritesheet_warning_after_persist_error(&error)),
)
}
}
}
Err(error) => {
let reason = error.body_text();
tracing::warn!(
provider = "editor-icon-spritesheet",
operation = "detect_automatic_slices",
task_id = %generated.task_id,
reason = %reason,
"图标图集已持久化,但自动拆分未完成"
);
let warning = editor_icon_spritesheet_slice_warning_from_error(&error);
(Vec::new(), Vec::new(), Some(warning))
}
};
let (canvas_items, primary_layer_id) =
if let Some(completion) = payload.canvas_completion.as_ref() {
build_icon_spritesheet_canvas_layer_items(
completion,
spritesheet_record.record.resource.as_ref(),
source_record.record.resource.as_ref(),
icon_image_srcs.as_slice(),
)
} else {
(Vec::new(), None)
};
let canvas_layout_plan = EditorCanvasLayoutPlan::generation(
owner_user_id.as_str(),
payload.project_id.as_deref(),
payload.canvas_completion.as_ref(),
canvas_items,
primary_layer_id,
);
let job_result_payload_json = editor_generation_job_result_payload_json(
&caller,
json!({
"ok": true,
"spritesheetImageSrc": spritesheet_image_src.clone(),
"spritesheetWidth": spritesheet_width,
"spritesheetHeight": spritesheet_height,
"iconImageSrcs": &icon_image_srcs,
"sliceLayout": payload.slice_layout,
"sliceCount": payload.slice_count,
"sliceWarning": &slice_warning,
"warning": &generation_warning,
"prompt": user_prompt.clone(),
"actualPrompt": generated.actual_prompt.clone(),
"model": generation_options.model,
"taskId": generated.task_id.clone(),
"priceMudPoints": expected_price_mud_points,
"spritesheetResource": &spritesheet_record.record.resource,
"spritesheetAsset": &spritesheet_record.record.asset,
"resourceId": spritesheet_record
.record
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
"assetObjectId": spritesheet_prepared_image.asset_object_id(),
"objectKey": spritesheet_prepared_image.object_key.clone(),
}),
);
let completed_at_micros = current_utc_micros();
let mut items = vec![source_record.item, spritesheet_record.item];
items.extend(slice_items);
let persisted_result = persist_editor_generation_result_atomically(
state,
&caller,
items,
canvas_layout_plan,
job_result_payload_json,
completed_at_micros,
)
.await?;
let completed_project = persisted_result
.project
.map(editor_project_payload_from_record);
let mut persisted_items = persisted_result.items;
let spritesheet_item_index = persisted_items
.iter()
.position(|item| item.slot == "spritesheet")
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-generation-operation",
"message": "图标图集原子提交缺少 spritesheet 结果。",
}))
})?;
let spritesheet_item = persisted_items.remove(spritesheet_item_index);
let spritesheet_record = EditorGeneratedAssetRecord {
resource: spritesheet_item
.project_resource
.map(editor_project_resource_payload_from_record),
asset: spritesheet_item.asset.map(editor_asset_payload_from_record),
};
for (index, icon) in icon_image_srcs.iter_mut().enumerate() {
let slot = format!("slice-{index:04}");
let item_index = persisted_items
.iter()
.position(|item| item.slot == slot)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "editor-generation-operation",
"message": format!("图标图集原子提交缺少 {slot} 结果。"),
}))
})?;
let item = persisted_items.remove(item_index);
icon.resource = item
.project_resource
.map(editor_project_resource_payload_from_record);
icon.asset = item.asset.map(editor_asset_payload_from_record);
}
let slice_count = icon_image_srcs.len();
Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetGenerationResponse {
spritesheet_image_src,
spritesheet_width,
spritesheet_height,
icon_image_srcs,
slice_layout: payload.slice_layout,
slice_count: Some(slice_count),
slice_warning,
prompt,
actual_prompt: generated.actual_prompt,
model: generation_options.model.to_string(),
task_id: generated.task_id,
price_mud_points: expected_price_mud_points,
spritesheet_resource: spritesheet_record.resource,
spritesheet_asset: spritesheet_record.asset,
project: completed_project,
warning: generation_warning,
},
))
}
pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind<'a>(
layers: &'a Value,
source_layer_id: &str,
source_resource_id: &str,
resource_asset_kind: Option<&'a str>,
) -> Result<Option<&'a str>, AppError> {
let source_layer = layers
.as_array()
.and_then(|items| {
items
.iter()
.find(|item| item.get("layerId").and_then(Value::as_str) == Some(source_layer_id))
})
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "未找到要拆分的图集图层。",
"sourceLayerId": source_layer_id,
}))
})?;
let layer_resource_id = source_layer
.get("resourceId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集图层缺少有效的资源引用。",
"sourceLayerId": source_layer_id,
}))
})?;
if layer_resource_id != source_resource_id {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集图层与请求资源不一致。",
"sourceLayerId": source_layer_id,
"sourceResourceId": source_resource_id,
})),
);
}
match source_layer.get("assetKindOverride") {
// 图层 override 只控制画布展示;手工拆分授权必须以服务端资源类型为准。
Some(Value::String(_)) => Ok(resource_asset_kind),
Some(Value::Null) | None => Ok(resource_asset_kind),
Some(_) => Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集图层的素材类型覆盖无效。",
"sourceLayerId": source_layer_id,
})),
),
}
}
pub async fn split_editor_icon_spritesheet(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorIconSpritesheetSliceRequest>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = authenticated.claims().user_id().to_string();
let project_id = payload.project_id.trim().to_string();
let source_layer_id = payload.source_layer_id.trim().to_string();
let source_resource_id = payload.source_resource_id.trim().to_string();
if project_id.is_empty() || source_layer_id.is_empty() || source_resource_id.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "projectId、sourceLayerId 和 sourceResourceId 不能为空。",
})),
);
}
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.clone(),
owner_user_id: owner_user_id.clone(),
})
.await
.map_err(map_editor_project_error)?;
let source_resource = project
.resources
.iter()
.find(|resource| resource.resource_id == source_resource_id)
.cloned()
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "未找到要拆分的图集资源。",
}))
})?;
if project.owner_user_id != owner_user_id
|| source_resource.owner_user_id != owner_user_id
|| source_resource.project_id != project_id
{
return Err(
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集资源不属于当前账号或当前项目。",
})),
);
}
let effective_asset_kind = resolve_editor_icon_spritesheet_source_layer_asset_kind(
&project.layers,
source_layer_id.as_str(),
source_resource_id.as_str(),
source_resource.asset_kind.as_deref(),
)?;
if effective_asset_kind != Some("icon-spritesheet") {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "只有图集素材可以使用拆分功能。",
})),
);
}
let source_object_key = normalize_editor_record_object_key(
source_resource.object_key.as_deref(),
source_resource.image_src.as_str(),
)
.ok_or_else(|| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集资源缺少可读取的 objectKey。",
}))
})?;
let task_id = build_prefixed_uuid_id("editor-atlas-split-");
let group_task_id = resolve_editor_manual_atlas_split_group_task_id(
&state,
owner_user_id.as_str(),
&source_resource,
)
.await?
.unwrap_or_else(|| task_id.clone());
let processing_deadline = resolve_editor_icon_spritesheet_processing_deadline(
Instant::now(),
request_context.external_call_deadline(),
);
let memory_admission =
acquire_editor_icon_spritesheet_memory_admission(processing_deadline).await?;
let download_client = build_editor_spritesheet_http_client(
EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT,
EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT,
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "aliyun-oss",
"message": format!("创建图集来源下载客户端失败:{error}"),
}))
})?;
// 中文注释:source_resource 已由 owner-scoped 项目读取完成鉴权,直接使用其稳定
// objectKey,避免再按注册 ID 做两轮全账号项目与素材库扫描;memory admission
// 在下载前取得,使排队请求不能各自先堆积一份最大 32 MiB 的压缩源图。
let reference = match tokio::time::timeout_at(
tokio::time::Instant::from_std(processing_deadline),
read_editor_reference_image_object_with_client(
&state,
source_object_key.as_str(),
&download_client,
),
)
.await
{
Ok(result) => result?,
Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()),
};
let source = DownloadedImage {
bytes: reference.bytes,
mime_type: reference.mime_type,
extension: "png".to_string(),
};
let slices = slice_editor_icon_spritesheet_all_with_memory_admission(
source,
processing_deadline,
memory_admission,
None,
None,
)
.await?;
let prompt = source_resource
.prompt
.clone()
.unwrap_or_else(|| "拆分图集素材".to_string());
let actual_prompt = source_resource.actual_prompt.clone();
let model = source_resource
.model
.clone()
.unwrap_or_else(|| "connected-components".to_string());
let provider = source_resource
.provider
.clone()
.unwrap_or_else(|| "Genarrative".to_string());
let generation_inputs = source_resource.generation_inputs.clone();
let icon_image_srcs = persist_editor_spritesheet_slices(
&state,
slices,
PersistEditorSpritesheetSlicesInput {
owner_user_id: owner_user_id.clone(),
project_id: Some(project_id.clone()),
asset_folder_id: payload
.asset_folder_id
.or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())),
source_resource_id: Some(source_resource_id),
task_id,
group_task_id: Some(group_task_id),
prompt,
actual_prompt,
model,
provider,
generation_inputs,
path_kind: "icon-spritesheet-slices",
asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND,
persistence_provider: "genarrative",
},
)
.await?;
let canvas_items = build_icon_spritesheet_slice_canvas_layer_items(
&payload.canvas_completion,
icon_image_srcs.as_slice(),
);
let project = complete_editor_canvas_generation_with_items(
&state,
owner_user_id.as_str(),
Some(project_id.as_str()),
Some(&payload.canvas_completion),
canvas_items,
icon_image_srcs
.first()
.and_then(|icon| icon.resource.as_ref())
.map(|resource| generated_canvas_layer_id(resource.resource_id.as_str())),
)
.await?
.ok_or_else(|| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集拆分结果未能写入画布。",
}))
})?;
Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetSliceResponse {
icon_image_srcs,
project,
},
))
}
pub(crate) async fn slice_editor_icon_spritesheet_all(
source: DownloadedImage,
request_deadline: Option<Instant>,
slice_layout: Option<EditorIconSpritesheetSliceLayout>,
slice_count: Option<usize>,
) -> Result<PreparedEditorIconSpritesheetPlan, AppError> {
let processing_deadline =
resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline);
let memory_admission =
acquire_editor_icon_spritesheet_memory_admission(processing_deadline).await?;
slice_editor_icon_spritesheet_all_with_memory_admission(
source,
processing_deadline,
memory_admission,
slice_layout,
slice_count,
)
.await
}
async fn acquire_editor_icon_spritesheet_memory_admission(
processing_deadline: Instant,
) -> Result<Arc<EditorIconSpritesheetMemoryAdmission>, AppError> {
if Instant::now() >= processing_deadline {
return Err(editor_icon_spritesheet_processing_timeout_error());
}
let memory_permit = match tokio::time::timeout_at(
tokio::time::Instant::from_std(processing_deadline),
Arc::clone(&*EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER).acquire_owned(),
)
.await
{
Ok(Ok(permit)) => permit,
Ok(Err(error)) => {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT,
"message": format!("图集切片内存门限不可用:{error}"),
})),
);
}
Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()),
};
Ok(Arc::new(EditorIconSpritesheetMemoryAdmission {
_permit: memory_permit,
}))
}
async fn slice_editor_icon_spritesheet_all_with_memory_admission(
source: DownloadedImage,
processing_deadline: Instant,
memory_admission: Arc<EditorIconSpritesheetMemoryAdmission>,
slice_layout: Option<EditorIconSpritesheetSliceLayout>,
slice_count: Option<usize>,
) -> Result<PreparedEditorIconSpritesheetPlan, AppError> {
if Instant::now() >= processing_deadline {
return Err(editor_icon_spritesheet_processing_timeout_error());
}
let permit = match tokio::time::timeout_at(
tokio::time::Instant::from_std(processing_deadline),
Arc::clone(&*EDITOR_ICON_SPRITESHEET_CPU_LIMITER).acquire_owned(),
)
.await
{
Ok(Ok(permit)) => permit,
Ok(Err(error)) => {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT,
"message": format!("图集切片 CPU 并发门限不可用:{error}"),
})),
);
}
Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()),
};
let worker_memory_admission = Arc::clone(&memory_admission);
let worker = tokio::task::spawn_blocking(move || {
// 中文注释:CPU permit 和内存 admission 必须由 blocking 闭包持有;即使
// async 等待超时,尚未退出的解码或连通域处理仍不能绕过并发门限。
let _permit = permit;
let _memory_admission = worker_memory_admission;
if Instant::now() >= processing_deadline {
return Err(editor_icon_spritesheet_processing_timeout_error());
}
validate_editor_icon_spritesheet_source(&source)?;
match slice_layout {
Some(EditorIconSpritesheetSliceLayout::Grid2x2) => {
prepare_generated_icon_spritesheet_grid_2x2(&source)
}
None => prepare_generated_icon_spritesheet_all_by_connected_components(
&source,
slice_count
.unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES)
.min(EDITOR_ICON_SPRITESHEET_MAX_SLICES),
EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS,
),
}
.map_err(map_editor_icon_spritesheet_platform_error)
});
let plan =
match tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker)
.await
{
Ok(Ok(result)) => result?,
Ok(Err(error)) => {
return Err(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": format!("图集切片工作线程异常:{error}"),
})),
);
}
Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()),
};
if let Some(expected) = slice_count {
if expected == 0 || expected > EDITOR_ICON_SPRITESHEET_MAX_SLICES || plan.len() != expected
{
return Err(AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": format!("请求切片数量为 {expected},实际识别到 {} 个。请调整 sliceCount 或素材排布。", plan.len()),
})));
}
}
if plan.is_empty() {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
"message": "图集中未识别到可拆分的独立素材。",
})),
);
}
Ok(PreparedEditorIconSpritesheetPlan {
plan,
processing_deadline,
_memory_admission: memory_admission,
})
}
pub(crate) fn resolve_editor_icon_spritesheet_processing_deadline(
started_at: Instant,
request_deadline: Option<Instant>,
) -> Instant {
let local_deadline = started_at
.checked_add(EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION)
.unwrap_or(started_at);
request_deadline
.map(|request_deadline| request_deadline.min(local_deadline))
.unwrap_or(local_deadline)
}
pub(crate) fn editor_icon_spritesheet_processing_timeout_error() -> AppError {
AppError::from_status(StatusCode::GATEWAY_TIMEOUT).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"code": EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT,
"message": "图集切片处理超时。",
}))
}
pub(crate) fn map_editor_icon_spritesheet_platform_error(
error: GeneratedAssetSheetError,
) -> AppError {
let code = match &error {
GeneratedAssetSheetError::RawConnectedComponentLimitExceeded { .. } => {
Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT)
}
GeneratedAssetSheetError::OutputSliceLimitExceeded { .. } => {
Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT)
}
GeneratedAssetSheetError::MergeCandidateLimitExceeded { .. } => {
Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT)
}
GeneratedAssetSheetError::TotalCropPixelLimitExceeded { .. } => {
Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT)
}
_ => None,
};
let mut details = json!({
"provider": "editor-icon-spritesheet-slicing",
"message": error.to_string(),
});
if let Some(code) = code {
details["code"] = json!(code);
}
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(details)
}
pub(crate) fn editor_icon_spritesheet_slice_warning_from_error(
error: &AppError,
) -> EditorIconSpritesheetSliceWarningResponse {
let code = match error
.details()
.and_then(|details| details.get("code"))
.and_then(Value::as_str)
{
Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT) => {
EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT
}
Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT) => {
EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT
}
Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT) => {
EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT
}
Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT) => {
EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT
}
Some(EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT) => {
EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT
}
_ => EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
};
EditorIconSpritesheetSliceWarningResponse {
code,
reason: error.body_text(),
}
}
pub(crate) fn editor_icon_spritesheet_warning_after_persist_error(
error: &AppError,
) -> EditorIconSpritesheetSliceWarningResponse {
let is_processing_failure = error
.details()
.and_then(|details| details.get("code"))
.and_then(Value::as_str)
.is_some_and(|code| {
matches!(
code,
EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT
| EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT
| EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT
| EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT
| EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT
)
});
if is_processing_failure {
editor_icon_spritesheet_slice_warning_from_error(error)
} else {
EditorIconSpritesheetSliceWarningResponse {
code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE,
reason: error.body_text(),
}
}
}
fn validate_editor_icon_spritesheet_source(source: &DownloadedImage) -> Result<(), AppError> {
let reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice()))
.with_guessed_format()
.map_err(|error| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": format!("无法识别图集图片格式:{error}"),
}))
})?;
let (width, height) = reader.into_dimensions().map_err(|error| {
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": format!("无法读取图集图片尺寸:{error}"),
}))
})?;
validate_editor_icon_spritesheet_dimensions(width, height)
}
async fn resolve_editor_manual_atlas_split_group_task_id(
state: &AppState,
owner_user_id: &str,
source_resource: &EditorProjectResourceRecord,
) -> Result<Option<String>, AppError> {
let lookup = editor_manual_atlas_split_group_source_lookup_input(
owner_user_id,
source_resource.resource_id.as_str(),
source_resource.source_resource_id.as_deref(),
source_resource.asset_object_id.as_deref(),
source_resource.object_key.as_deref(),
);
let asset = state
.spacetime_client()
.find_editor_asset_group_source(lookup)
.await
.map_err(map_editor_project_error)?;
Ok(asset.and_then(|asset| normalize_optional_string(asset.task_id)))
}
pub(crate) fn editor_manual_atlas_split_group_source_lookup_input(
owner_user_id: &str,
resource_id: &str,
source_resource_id: Option<&str>,
asset_object_id: Option<&str>,
object_key: Option<&str>,
) -> EditorAssetGroupSourceLookupRecordInput {
let mut source_resource_ids = vec![resource_id.to_string()];
if let Some(source_resource_id) =
normalize_optional_string(source_resource_id.map(str::to_string))
{
source_resource_ids.push(source_resource_id);
}
EditorAssetGroupSourceLookupRecordInput {
owner_user_id: owner_user_id.to_string(),
source_resource_ids,
asset_object_id: asset_object_id.map(str::to_string),
object_key: object_key.map(str::to_string),
}
}
pub(crate) fn validate_editor_icon_spritesheet_dimensions(
width: u32,
height: u32,
) -> Result<(), AppError> {
let pixel_count = u64::from(width).saturating_mul(u64::from(height));
if width == 0
|| height == 0
|| width > EDITOR_ICON_SPRITESHEET_MAX_DIMENSION
|| height > EDITOR_ICON_SPRITESHEET_MAX_DIMENSION
|| pixel_count > EDITOR_ICON_SPRITESHEET_MAX_PIXELS
{
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "图集尺寸超过手动拆分限制。",
"width": width,
"height": height,
"maxDimension": EDITOR_ICON_SPRITESHEET_MAX_DIMENSION,
"maxPixels": EDITOR_ICON_SPRITESHEET_MAX_PIXELS,
})),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn icon_spritesheet_logged_in_handler_forwards_stable_idempotency_key() {
let source = include_str!("editor_project_icon.rs");
let start = source
.find("pub async fn generate_editor_icon_spritesheet(")
.expect("logged-in icon spritesheet handler should exist");
let branch = &source[start..];
let end = branch
.find("pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner(")
.expect("icon spritesheet enqueue helper should follow handler");
let handler = &branch[..end];
assert!(handler.contains("headers: HeaderMap"));
assert!(handler.contains("optional_editor_idempotency_key(&headers)?"));
assert!(handler.contains("idempotency_key,"));
}
#[test]
fn game_genre_serde_uses_chinese_slugs() {
for genre in GameGenre::ALL {
assert_eq!(
serde_json::to_value(genre).expect("game genre should serialize"),
json!(genre.as_slug())
);
assert_eq!(
serde_json::from_value::<GameGenre>(json!(genre.as_slug()))
.expect("Chinese game genre should deserialize"),
genre
);
}
assert!(serde_json::from_value::<GameGenre>(json!("strategy")).is_err());
assert!(serde_json::from_value::<GameGenre>(json!("action_adventure")).is_err());
}
#[test]
fn request_param_uses_play_setting_camel_case_contract() {
let request: RequestParam = serde_json::from_value(json!({
"playSetting": "回合制占点",
"artStyle": "低多边形"
}))
.expect("playSetting request should parse");
assert_eq!(request.play_setting, "回合制占点");
assert!(
serde_json::from_value::<RequestParam>(json!({
"gamePlay": "旧字段",
"artStyle": "低多边形"
}))
.is_err()
);
}
#[test]
fn generation_inputs_keep_only_expected_fields_and_references() {
let request = RequestParam {
play_setting: "回合制占点".to_string(),
art_style: "低多边形".to_string(),
};
let inputs = build_icon_spec_generation_inputs(
&request,
GameGenre::Strategy,
Some(json!({
"fields": [{ "title": "客户端字段", "value": "不得保留" }],
"references": [{
"title": "参考图",
"label": "参考图",
"refType": "asset",
"refId": "asset-1"
}],
"theme": "不得保留",
"useCase": "不得保留",
"targetUser": "不得保留"
})),
);
assert_eq!(
inputs["fields"],
json!([
{ "title": "玩法设定", "value": "回合制占点" },
{ "title": "美术风格", "value": "低多边形" },
{ "title": "游戏类型", "value": "策略" }
])
);
assert_eq!(inputs["references"].as_array().map(Vec::len), Some(1));
assert!(inputs.get("theme").is_none());
assert!(inputs.get("useCase").is_none());
assert!(inputs.get("targetUser").is_none());
}
#[test]
fn extra_param_requires_exact_non_empty_bounded_fields() {
let valid = parse_extra_param(
r#"{"genre":"策略","theme":" 现代军事 ","useCase":"PC、mobile","targetUser":"策略游戏玩家"}"#,
)
.expect("valid extra params should parse");
assert_eq!(valid.theme, "现代军事");
assert!(
parse_extra_param(
r#"{"genre":"策略","theme":"","useCase":"PC","targetUser":"策略游戏玩家"}"#
)
.is_err()
);
assert!(
parse_extra_param(
r#"{"genre":"策略","theme":"现代军事","useCase":"PC","targetUser":"策略游戏玩家","extra":"unexpected"}"#
)
.is_err()
);
let oversized = json!({
"genre": "策略",
"theme": "题".repeat(ICON_SPEC_EXTRA_PARAM_MAX_LEN + 1),
"useCase": "PC",
"targetUser": "策略游戏玩家",
});
assert!(parse_extra_param(oversized.to_string().as_str()).is_err());
}
#[test]
fn extra_param_rejects_explicitly_incomplete_llm_responses() {
let complete_json =
r#"{"genre":"策略","theme":"现代军事","useCase":"PC","targetUser":"策略游戏玩家"}"#;
for finish_reason in ["length", "content_filter"] {
assert_eq!(
parse_extra_param_response(complete_json, Some(finish_reason)),
Err(ExtraParamResponseError::Incomplete)
);
}
assert!(parse_extra_param_response(complete_json, Some("stop")).is_ok());
}
#[test]
fn icon_spec_generation_text_enforces_prompt_max_length() {
assert!(
require_icon_spec_generation_text(
"玩".repeat(PROMPT_MAX_LEN),
"playSetting",
"玩法设定"
)
.is_ok()
);
assert!(
require_icon_spec_generation_text(
"玩".repeat(PROMPT_MAX_LEN + 1),
"playSetting",
"玩法设定"
)
.is_err()
);
}
#[test]
fn icon_descriptions_reject_raw_array_over_openapi_max_items() {
let mut descriptions = vec![String::new(); EDITOR_ICON_DESCRIPTION_LIMIT];
descriptions.push("唯一非空项".to_string());
let error = normalize_icon_descriptions(descriptions)
.expect_err("raw array above maxItems must fail before blank entries are discarded");
assert_eq!(
error
.details()
.and_then(|details| details.get("maxItems"))
.and_then(Value::as_u64),
Some(EDITOR_ICON_DESCRIPTION_LIMIT as u64)
);
}
#[tokio::test]
async fn grid_2x2_slicing_returns_exactly_four_quadrants_with_detached_details() {
use image::{ImageBuffer, ImageFormat, Rgba};
let mut image: image::RgbaImage = ImageBuffer::from_pixel(128, 128, Rgba([0, 255, 0, 255]));
for index in 0..4u32 {
let row = index / 2;
let col = index % 2;
let color = [80 + (index as u8 * 30), 120, 220, 255];
let x0 = col * 64 + 10;
let y0 = row * 64 + 10;
for y in y0..y0 + 28 {
for x in x0..x0 + 28 {
image.put_pixel(x, y, Rgba(color));
}
}
image.put_pixel(x0 + 38, y0 + 4, Rgba(color));
}
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(image)
.write_to(&mut std::io::Cursor::new(&mut bytes), ImageFormat::Png)
.expect("fixture png should encode");
let source = DownloadedImage {
bytes,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let prepared = slice_editor_icon_spritesheet_all(
source,
None,
Some(EditorIconSpritesheetSliceLayout::Grid2x2),
None,
)
.await
.expect("declared 2x2 sheet should slice");
assert_eq!(prepared.plan.len(), 4);
}
#[test]
fn spritesheet_genre_requires_exact_game_type_title() {
assert_eq!(
game_genre_from_generation_inputs(Some(&json!({
"fields": [
{ "title": "玩法设定", "value": "rpg" },
{ "title": "游戏类型", "value": "动作冒险" }
]
}))),
Ok(Some(GameGenre::ActionAdventure))
);
assert_eq!(
game_genre_from_generation_inputs(Some(&json!({
"fields": [
{ "title": "玩法设定", "value": "rpg" },
{ "title": "游戏类型", "value": "action_adventure" }
]
}))),
Err(())
);
assert_eq!(
game_genre_from_generation_inputs(Some(&json!({
"fields": [{ "title": "玩法设定", "value": "rpg" }]
}))),
Ok(None)
);
}
#[test]
fn icon_spec_dispatch_builds_id_only_reference_into_shared_image_contract() {
let image_request = build_icon_spec_image_request(
EditorIconSpecGenerationRequest {
request_param: RequestParam {
play_setting: "回合制占点".to_string(),
art_style: "低多边形".to_string(),
},
reference_id: Some("resource-reference-1".to_string()),
project_id: Some("project-1".to_string()),
generation_inputs: None,
asset_folder_id: Some("project".to_string()),
asset_label: Some("图标规范 1".to_string()),
source_resource_id: Some("resource-source-1".to_string()),
canvas_completion: None,
},
Some(json!({ "fields": [], "references": [] })),
);
assert!(image_request.prompt.is_empty());
assert_eq!(
image_request.generation_inputs,
Some(json!({ "fields": [], "references": [] }))
);
assert_eq!(image_request.kind.as_deref(), Some("spec"));
assert_eq!(image_request.asset_kind.as_deref(), Some("icon-spec"));
assert_eq!(image_request.model.as_deref(), Some(ICON_SPEC_MODEL));
assert_eq!(image_request.size.as_deref(), Some(ICON_SPEC_SIZE));
assert_eq!(
image_request.aspect_ratio.as_deref(),
Some(ICON_SPEC_ASPECT_RATIO)
);
assert_eq!(
image_request.image_size.as_deref(),
Some(ICON_SPEC_IMAGE_SIZE)
);
assert_eq!(
image_request.reference_image_srcs,
Some(vec!["resource-reference-1".to_string()])
);
assert_eq!(image_request.project_id.as_deref(), Some("project-1"));
assert_eq!(image_request.asset_folder_id.as_deref(), Some("project"));
assert_eq!(image_request.asset_label.as_deref(), Some("图标规范 1"));
assert_eq!(
image_request.source_resource_id.as_deref(),
Some("resource-source-1")
);
}
}