1f904d28e9
## 目标 保留现有客户端对话与 Codex app-server 链路,把客户端自身受控业务能力通过 MCP 暴露给 Codex。 ## 范围 - 客户端会话、项目文件、资源、画布、生成、预览等稳定能力 - 审核 Skill 的索引与按需指导资源 - 复用现有账号、项目路径、权限、计费、幂等、锁和恢复边界 ## 明确不做 - 不替换客户端对话入口或 Codex app-server - 不让客户端替 Codex 判断高层意图、完成状态或规划 - 不暴露任意 Tauri command、shell、凭据、内部 URL、数据库和管理能力 当前 PR 先建立独立分支与审查边界,后续提交实现与定向验证。 Reviewed-on: #274
3028 lines
117 KiB
Rust
3028 lines
117 KiB
Rust
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 和内存 admission;async 超时
|
||
// 只放弃等待,不能让尚未退出的编码绕过任一门限。
|
||
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")
|
||
);
|
||
}
|
||
}
|