diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 1ddc9543d..7ad9a3aa1 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3360,19 +3360,19 @@ "EditorIconSpritesheetGenerationRequest": { "type": "object", "required": [ - "referenceImageSrc", + "referenceId", "iconDescriptions" ], "properties": { - "referenceImageSrc": { + "referenceId": { "type": "string", - "description": "图标规范的稳定引用:当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。" + "description": "当前账号中已登记为 icon-spec 的项目资源 ID 或素材 ID。只接受 ID,不接受 objectKey、图片 URL、Data URL 或 Blob URL。" }, "referenceImageSrcs": { "type": "array", "items": { "type": "string", - "description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" + "description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceId 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" }, "maxItems": 8 }, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2fd6f5bad..a9021de99 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6637,3 +6637,11 @@ - 图标规范 HTTP handler 在调用文本 LLM 前,先通过可复用图片请求预检完成参考图稳定性、owner 授权、Provider 配置与运行时定价校验;随后补齐 `ExtraParam` 与最终 prompt,并继续走既有 `editor_image_generation` inline / queue 分流,不新增图标规范专用 worker job。最终执行仍重新校验请求,以处理排队期间发生的权限或资源变化。 - 图标图集主规范引用由通用 SpacetimeDB procedure `resolve_editor_reference_and_return` 在同一事务快照内解析和校验 owner。ID 使用资源 / 素材主键;objectKey 按规范化 `image_src="/"` 索引读取唯一行,并使用 `asset_object(bucket, object_key)` 复合索引校验对象 owner。procedure 不接收业务 / 存储类型 allowlist,复用既有 `EditorProjectResourceSnapshot` / `EditorAssetSnapshot` 返回完整单行,不新增图标专属 DTO,也不再拉取完整项目和素材库;`icon-spec` 业务类型与游戏类型由 API 业务代码校验和提取。缺失引用、跨 owner、asset object 不匹配、业务类型错误、元数据解析与数据库错误全部失败关闭;仅“合法记录没有 genre”允许返回 `None`。 - 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-08-06 图标规范与图标图集使用独立任务及 ID-only 主规范引用 + +- 本条取代上一条“不新增图标规范专用 worker job”和“图标图集主规范允许 objectKey”的结论。图标规范生成使用独立 `editor_icon_spec_generation` job kind;队列保存原始强类型业务参数,worker 在统一计费操作预扣成功后执行 ExtraParam LLM、构建最终 prompt,再进入共享图片生成和既有后处理 / 持久化链。余额不足不调用文本 LLM,执行失败沿用统一退款;普通 `editor_image_generation` 的 DTO、payload 与执行行为保持不变。 +- `editor_project_icon.rs` 独立承接图标规范、图标 spritesheet、自动切片和手工拆分。普通图片模块只暴露可复用的 prompt builder 执行入口与强类型 provider 请求分流;nanobanana、无参考图 generation、有参考图 edit 的选择集中在同一个 helper,图标图集复用该 helper,不复制 provider 分支。 +- 图标规范生成的可选参考图与图标图集的必选主规范统一使用 `referenceId`,只接受当前 owner 的项目资源 ID 或账号素材 ID,不接受 objectKey、URL 或临时 key。SpacetimeDB `resolve_editor_reference_and_return` 因而只接收 `reference_id` 并按两张表主键查询,删除 `image_src` 二级索引。图标图集的普通附加参考图 `referenceImageSrcs` 仍允许 owned objectKey、项目资源 ID 或素材 ID,并继续走既有 owner 校验与真实图片下载链。 +- External v1 图标图集请求与 OpenAPI 同步改为必填 `referenceId`。前端和 Editor Agent 优先传正式 resource ID,其次传 asset ID;本地临时 ID、objectKey 和图片地址不得回退成主规范引用,未登记时明确失败并要求先上传或登记。 +- 关联:`server-rs/crates/api-server/src/editor_project_icon.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`docs/openapi/genarrative-external-v1.openapi.json`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a3d987a56..f28028a54 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -338,7 +338,7 @@ - 现象:画板生成、快速编辑、图标素材或 UI 素材提取如果允许直接提交 generated objectKey,用户只要知道其他账号的私有 objectKey,就可能让 api-server 签名读取并送给外部生成供应商。 - 原因:Data URL/Blob URL 只允许停留在浏览器临时态,正式编辑器引用必须先上传并经统一 resolver 校验归属。 -- 处理:所有私有对象引用在读取字节或签发 URL 前统一走 `resolve_editor_reference_object_key_for_owner(state, owner_user_id, source)`,先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key。只有确实需要图片字节的入口(生成 / 重绘 / 图标 / UI 提取等交给 provider 的路径)再走 `parse_editor_reference_image`(内部仍先 resolve,再下载 OSS 字节);手动去背景等只签发短期 URL 的入口不要 `parse` 整图。手动去背景还要恢复源模型时,object key 解析、所有权校验和源模型回溯必须复用同一轮账号项目 / 素材快照,项目与素材快照各最多读取一次;不得先走通用 resolver 全量读取,再为模型回溯重复拉取完整画布和素材库。图标素材等额外参考图必须真实传到 provider,不只写 metadata;图片快速编辑当前不开放额外参考图,若后续重开入口也必须沿用同一归属校验。 +- 处理:普通图片、重绘、图标图集附加参考图和 UI 提取等允许 objectKey 的私有对象引用,在读取字节或签发 URL 前统一走 `resolve_editor_reference_object_key_for_owner(state, owner_user_id, source)`,先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key。只有确实需要图片字节的入口再走 `parse_editor_reference_image`(内部仍先 resolve,再下载 OSS 字节);手动去背景等只签发短期 URL 的入口不要 `parse` 整图。图标规范生成的可选参考图与图标图集的主规范引用是更窄的业务契约:只接受正式 `referenceId`(项目资源 ID / 素材 ID),不得回退 objectKey、URL 或临时 key;图标图集的额外 `referenceImageSrcs` 才继续沿用通用 owned objectKey 规则。手动去背景还要恢复源模型时,object key 解析、所有权校验和源模型回溯必须复用同一轮账号项目 / 素材快照,项目与素材快照各最多读取一次;不得先走通用 resolver 全量读取,再为模型回溯重复拉取完整画布和素材库。图标素材等额外参考图必须真实传到 provider,不只写 metadata;图片快速编辑当前不开放额外参考图,若后续重开入口也必须沿用同一归属校验。 - 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入图标生成请求;若快速编辑重开额外参考图,再补对应请求覆盖。 - 关联:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index b9aa91ca5..11ac15a00 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -630,7 +630,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - `generation_inputs_json` 包络契约:`fields` / `references` 是图片信息读取的用户可见生成输入快照;顶层允许保存后端内部结果扩展。现有 `screenColorHex` 保存实际背景色,角色、图标图集和 UI 图集抠图派生资产使用 `mattingProvider` / `mattingModel` 保存实际成功的处理后端与模型。BgFilter 保存本次 `seg_model`,阿里云通用抠图保存 `Aliyun Matting / segment-common-image`,本地键色保存 `Genarrative Local / screen-color-keying`。同源画布 BFF 的角色、图标和 UI 请求由前端自动提交 `screenColor=auto` 与默认 `segModel=birefnet`,其中 `segModel` 是不可由用户选择的请求控制字段,不进入 `generationInputs`;`background_mode` 和 `cross_check` 只属于 api-server 到 worker 的内部 RPC。External OpenAPI 不开放 `segModel`。上述内部结果字段不写入 `fields`,普通用户(包括素材 owner)与匿名公开读取均不得取得;普通用户响应还必须省略素材顶层 `provider` 和内部处理 `model`,但保留正常用户可见 `model` 与其他合法的顶层功能字段。后台管理和服务端审计可读取原始值。过滤只作用于普通用户 / 公开响应边界,不修改素材或精选快照,因此历史数据无需迁移。 - 普通用户生成结果契约:图片、图标图集、视频、音频和角色动画的完成响应与新建画布图层均不返回或写入生成 provider;项目资源、素材、精选和 Agent 紧凑结果使用同一读取边界。真实 provider 只保留在持久化、tracking / tracing 和后台管理原始审计中。该规则针对生成供应商元数据,不改变直传票据等必须由客户端执行的存储协议字段。 - 普通用户错误契约:手动去背景和角色动作透明化的原始服务端错误可能包含 BgFilter、分割模型或 provider 细节;Owner HTTP 响应与外部任务状态必须按 job kind 返回稳定业务文案,原始错误只保留在任务记录、tracing 与后台审计。 -- 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`、`by_editor_project_resource_image_src`。 +- 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。 ### `editor_asset_folder` @@ -644,7 +644,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、真实操作 `task_id`、可选后台归组 `group_task_id`、拆分批次预期数量 `group_task_expected_asset_count`、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id` 和 `generation_cost_mud_points`。`prompt` 固定表示规范化后的用户原始意图,供跨资源搜索和用户侧元数据使用;provider 实际返回的改写只写 `actual_prompt`,提交给 provider 的系统 / 工程化 prompt 不得写入 `prompt`。角色透明图、图标透明图和自动切片等派生产物继承源用户 prompt,并用 `source_resource_id`、`generation_inputs_json`、provider / asset kind 表达处理来源。归组字段追加在表尾并默认 `None`,只用于稳定派生任务的后台分组,不替代 `task_id`;批次是否初始完整以独立完成事实为准,不按当前剩余素材数反推。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成保留原始绿幕视频中间素材,同时把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧列表、FPS、时长和预览视频写入 `generation_inputs_json.characterAnimation`,不把每帧拆成独立素材。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 -- 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`、`by_editor_asset_image_src`。 +- 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。 ### `editor_asset_group_source_provenance` diff --git a/server-rs/crates/api-server/src/editor_agent/context.rs b/server-rs/crates/api-server/src/editor_agent/context.rs index 8b86d0cb9..4a0a84047 100644 --- a/server-rs/crates/api-server/src/editor_agent/context.rs +++ b/server-rs/crates/api-server/src/editor_agent/context.rs @@ -13,6 +13,7 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> let image_id = ImageId::from_data_key(&data_key); let metadata = ImageMetadata { data_key, + reference_id: Some(a.reference_id.clone()), image_src: a.image_src.clone(), object_key: a.object_key.clone(), thumbnail_src: a.thumbnail_src.clone(), @@ -29,6 +30,7 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> let image_id = ImageId::from_data_key(&data_key); let metadata = ImageMetadata { data_key, + reference_id: img.resource_id.clone(), image_src: img.image_src.clone(), object_key: img.object_key.clone(), thumbnail_src: img.thumbnail_src.clone(), diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index a14b96efd..c3ea39b72 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -53,9 +53,8 @@ use crate::editor_generation_queue::{ EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, }; -use crate::editor_project::{ - EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest, -}; +use crate::editor_project::{EditorImageEditRequest, EditorImageGenerationRequest}; +use crate::editor_project_icon::EditorIconSpritesheetGenerationRequest; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum EditorAgentToolError { @@ -792,14 +791,19 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?; - let reference_image_src = self + let reference_id = self .context - .image_data_key(&args.reference_image_id) - .ok_or_else(|| EditorAgentToolError::invalid_args("reference image not found"))? + .image_metadata(&args.reference_image_id) + .and_then(|metadata| metadata.reference_id.as_deref()) + .ok_or_else(|| { + EditorAgentToolError::invalid_args( + "reference image is not a registered resource or asset", + ) + })? .to_string(); let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?; let payload = EditorIconSpritesheetGenerationRequest { - reference_image_src, + reference_id, reference_image_srcs: Some(reference_image_srcs), icon_descriptions: args.icon_descriptions, style: None, @@ -1184,6 +1188,7 @@ mod tests { }, ImageMetadata { data_key: format!("generated/{image_id}.png"), + reference_id: Some(format!("resource-{image_id}")), image_src: format!("/api/assets/read/generated/{image_id}.png"), object_key: Some(format!("generated/{image_id}.png")), thumbnail_src: Some(format!("/api/assets/read/generated/{image_id}-thumb.png")), diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 22db58e8b..736d1f8a3 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -11,6 +11,7 @@ use spacetime_client::{ExternalGenerationJobEnqueueRecordInput, ExternalGenerati use crate::{http_error::AppError, request_context::RequestContext, state::AppState}; pub(crate) const EDITOR_IMAGE_GENERATION_JOB_KIND: &str = "editor_image_generation"; +pub(crate) const EDITOR_ICON_SPEC_GENERATION_JOB_KIND: &str = "editor_icon_spec_generation"; pub(crate) const EDITOR_IMAGE_EDIT_JOB_KIND: &str = "editor_image_edit"; pub(crate) const EDITOR_BACKGROUND_REMOVAL_JOB_KIND: &str = "editor_background_removal"; pub(crate) const EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND: &str = diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index e4cf6e788..252be1560 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1,6 +1,7 @@ use std::{ borrow::Cow, collections::{BTreeMap, HashSet}, + future::Future, io::Cursor, sync::{ Arc, LazyLock, @@ -18,13 +19,7 @@ use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectUpsertInput, 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, - }, -}; +use platform_image::DownloadedImage; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest, }; @@ -40,15 +35,14 @@ use shared_kernel::build_prefixed_uuid_id; use spacetime_client::editor_project::{ EditorPixelArtCanvasCompletionRecordInput, EditorPixelArtCanvasPlaceholderRecordInput, EditorPixelArtResultPersistRecordInput, EditorPixelArtResultPreflightRecordInput, - EditorSpritesheetSliceBatchPersistRecordInput, EditorSpritesheetSlicePersistItemRecordInput, }; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, - EditorAssetFolderUpdateRecordInput, EditorAssetGroupSourceLookupRecordInput, - EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, - EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, - EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, + EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, + EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, + EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, + EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput, @@ -65,16 +59,22 @@ use crate::{ asset_billing::execute_billable_asset_operation_with_cost, auth::AuthenticatedAccessToken, editor_generation_queue::{ - EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, - EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, - EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EditorGenerationQueuedResponse, - editor_generation_queue_state, editor_generation_source_entity_id, - enqueue_editor_generation_job, enqueue_editor_generation_job_for_caller, + EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND, + EDITOR_IMAGE_GENERATION_JOB_KIND, EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, + EditorGenerationQueuedResponse, editor_generation_queue_state, + editor_generation_source_entity_id, enqueue_editor_generation_job, + enqueue_editor_generation_job_for_caller, }, editor_green_screen::{ EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause, editor_ui_design_asset_extraction_prompt, remove_editor_generated_green_screen_background, }, + editor_project_icon::{ + EditorIconSpritesheetGenerationResponse, EditorIconSpritesheetIconResponse, + PersistEditorSpritesheetSlicesInput, editor_icon_spritesheet_slice_warning_from_error, + editor_icon_spritesheet_warning_after_persist_error, persist_editor_spritesheet_slices, + slice_editor_icon_spritesheet_all, + }, editor_screen_background_decision::{ EditorScreenBackgroundDecision, EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, resolve_editor_screen_background_color, @@ -89,8 +89,9 @@ use crate::{ }, http_error::AppError, openai_image_generation::{ - DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiReferenceImage, - build_openai_image_http_client, create_openai_image_edit_with_references_and_model, + DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiGeneratedImages, OpenAiImageSettings, + OpenAiReferenceImage, build_openai_image_http_client, + create_openai_image_edit_with_references_and_model, create_openai_image_generation_with_model, create_openai_nanobanana_generate_content, require_openai_image_settings, }, @@ -100,6 +101,11 @@ use crate::{ work_author::{ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID}, }; +#[cfg(test)] +use crate::editor_project_icon::*; +#[cfg(test)] +use platform_image::generated_asset_sheets::GeneratedAssetSheetError; + const EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE: usize = 36; pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "proj-"; @@ -110,29 +116,17 @@ const EDITOR_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024; pub(crate) const EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES: usize = 4 * 1024 * 1024; pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布"; const EDITOR_ASSET_METADATA_MAX_BYTES: usize = 64 * 1024; -const EDITOR_ASSET_DEFAULT_FOLDER_ID: &str = "project"; +pub(crate) const EDITOR_ASSET_DEFAULT_FOLDER_ID: &str = "project"; const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024"; -const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; +pub(crate) const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2"; const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana"; -const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100; -const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096; -const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048; -const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 64; -const EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY: usize = 2; -const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2; -const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2; -const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 = EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4; -const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); -const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); const EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT: usize = 5; const EDITOR_QUICK_EDIT_REFERENCE_LIMIT: usize = 9; const EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT: usize = 8; -const EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT: usize = 8; const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5; -fn editor_provider_reference_limit(model: &str) -> usize { +pub(crate) fn editor_provider_reference_limit(model: &str) -> usize { if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { 14 } else { @@ -140,22 +134,13 @@ fn editor_provider_reference_limit(model: &str) -> usize { } } const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; -const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; +pub(crate) const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image"; const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image"; const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image"; const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image"; const EDITOR_IMAGE_EDIT_ASSET_KIND: &str = "editor_image_edit"; -const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet"; -const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice"; -const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components"; -const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed"; -const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_RAW_LIMIT: &str = "raw-component-limit-exceeded"; -const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_OUTPUT_LIMIT: &str = "output-slice-limit-exceeded"; -const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_MERGE_LIMIT: &str = "merge-candidate-limit-exceeded"; -const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_CROP_PIXEL_LIMIT: &str = "crop-pixel-limit-exceeded"; -const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_TIMEOUT: &str = "slice-processing-timeout"; const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved"; const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback"; const EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE: &str = "unsupported-image-style"; @@ -205,18 +190,6 @@ static EDITOR_PIXEL_ART_SNAP_LIMITER: LazyLock> = La )) }); static EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH: AtomicUsize = AtomicUsize::new(0); -static EDITOR_ICON_SPRITESHEET_CPU_LIMITER: LazyLock> = - LazyLock::new(|| { - Arc::new(tokio::sync::Semaphore::new( - EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY, - )) - }); -static EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER: LazyLock> = - LazyLock::new(|| { - Arc::new(tokio::sync::Semaphore::new( - EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY, - )) - }); const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1; const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet"; const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset"; @@ -354,6 +327,69 @@ pub struct EditorImageGenerationRequest { pub(crate) canvas_completion: Option, } +pub(crate) struct EditorImagePromptBuildOutput { + pub(crate) prompt: String, + pub(crate) generation_inputs: Option, +} + +pub(crate) struct EditorImageProviderRequest<'a> { + pub(crate) model: &'a str, + pub(crate) prompt: &'a str, + pub(crate) negative_prompt: Option<&'a str>, + pub(crate) size: &'a str, + pub(crate) aspect_ratio: &'a str, + pub(crate) image_size: &'a str, + pub(crate) reference_images: &'a [OpenAiReferenceImage], + pub(crate) failure_context: &'a str, +} + +pub(crate) async fn request_editor_generated_images( + http_client: &reqwest::Client, + settings: &OpenAiImageSettings, + request: EditorImageProviderRequest<'_>, +) -> Result { + if request.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + create_openai_nanobanana_generate_content( + http_client, + settings, + request.model, + request.prompt, + request.negative_prompt, + request.aspect_ratio, + request.image_size, + request.reference_images, + request.failure_context, + ) + .await + } else if request.reference_images.is_empty() { + create_openai_image_generation_with_model( + http_client, + settings, + request.model, + request.prompt, + request.negative_prompt, + request.size, + 1, + &[], + request.failure_context, + ) + .await + } else { + create_openai_image_edit_with_references_and_model( + http_client, + settings, + request.model, + request.prompt, + request.negative_prompt, + request.size, + 1, + request.reference_images, + request.failure_context, + ) + .await + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorImageEditRequest { @@ -402,36 +438,6 @@ pub struct EditorPixelArtSnapRequest { pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest, } -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EditorIconSpritesheetGenerationRequest { - pub(crate) reference_image_src: String, - pub(crate) reference_image_srcs: Option>, - pub(crate) icon_descriptions: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) style: Option, - pub(crate) model: Option, - pub(crate) screen_color: Option, - pub(crate) seg_model: Option, - pub(crate) aspect_ratio: Option, - pub(crate) image_size: Option, - pub(crate) project_id: Option, - pub(crate) generation_inputs: Option, - pub(crate) asset_folder_id: Option, - pub(crate) asset_label: Option, - pub(crate) canvas_completion: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub 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, - pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest, -} - #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorUiDesignAssetExtractionRequest { @@ -467,7 +473,7 @@ impl EditorGenerationCaller { } } - async fn report_processing_phase(&self, state: &AppState) -> Result<(), AppError> { + pub(crate) async fn report_processing_phase(&self, state: &AppState) -> Result<(), AppError> { if let Some(reporter) = self.phase_reporter.as_ref() { reporter.report_processing(state).await?; } @@ -708,7 +714,7 @@ struct EditorGeneratedImageStorageProfile { } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum EditorImageGenerationStyle { +pub(crate) enum EditorImageGenerationStyle { #[default] None, PixelArt, @@ -755,7 +761,7 @@ fn editor_postprocess_fallback_warning(reason: &'static str) -> EditorGeneration } } -fn editor_pixel_art_fallback_warning() -> EditorGenerationWarningResponse { +pub(crate) fn editor_pixel_art_fallback_warning() -> EditorGenerationWarningResponse { editor_postprocess_fallback_warning("像素规整未完成,已保留原始生成结果。") } @@ -792,7 +798,7 @@ fn editor_unsupported_image_style_warning() -> EditorGenerationWarningResponse { } } -fn merge_editor_generation_warnings( +pub(crate) fn merge_editor_generation_warnings( current: Option, next: Option, ) -> Option { @@ -811,7 +817,7 @@ fn merge_editor_generation_warnings( } } -fn normalize_editor_image_generation_style( +pub(crate) fn normalize_editor_image_generation_style( value: Option<&str>, pixel_art_supported: bool, ) -> ( @@ -900,52 +906,6 @@ pub struct EditorPixelArtSnapResponse { project: Option, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EditorIconSpritesheetIconResponse { - name: String, - image_src: String, - width: u32, - height: u32, - resource: Option, - asset: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EditorIconSpritesheetSliceWarningResponse { - code: &'static str, - reason: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EditorIconSpritesheetGenerationResponse { - spritesheet_image_src: String, - spritesheet_width: u32, - spritesheet_height: u32, - icon_image_srcs: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - slice_warning: Option, - prompt: String, - actual_prompt: Option, - model: String, - task_id: String, - price_mud_points: u32, - spritesheet_resource: Option, - spritesheet_asset: Option, - project: Option, - #[serde(skip_serializing_if = "Option::is_none")] - warning: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EditorIconSpritesheetSliceResponse { - icon_image_srcs: Vec, - project: EditorProjectPayload, -} - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectPayload { @@ -1855,8 +1815,35 @@ pub(crate) async fn generate_editor_image_for_owner( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, - mut payload: EditorImageGenerationRequest, + payload: EditorImageGenerationRequest, ) -> Result, AppError> { + let prompt = payload.prompt.clone(); + generate_editor_image_for_owner_with_prompt_builder( + state, + request_context, + caller, + payload, + move |generation_inputs| async move { + Ok(EditorImagePromptBuildOutput { + prompt, + generation_inputs, + }) + }, + ) + .await +} + +pub(crate) async fn generate_editor_image_for_owner_with_prompt_builder( + state: &AppState, + request_context: &RequestContext, + caller: EditorGenerationCaller, + mut payload: EditorImageGenerationRequest, + prompt_builder: F, +) -> Result, AppError> +where + F: FnOnce(Option) -> Fut, + Fut: Future>, +{ payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_sources_are_stable( @@ -1865,8 +1852,8 @@ pub(crate) async fn generate_editor_image_for_owner( "referenceImageSrcs", "生成参考图", )?; - let role_setting = payload.prompt.trim().to_string(); - if role_setting.is_empty() { + let initial_prompt = payload.prompt.trim().to_string(); + if initial_prompt.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-image-generation", @@ -2013,7 +2000,18 @@ pub(crate) async fn generate_editor_image_for_owner( Some(generation_options.model), Some(generation_options.image_size), ); + let prompt_generation_inputs = payload.generation_inputs.take(); let generate_operation = async { + let prompt_build = prompt_builder(prompt_generation_inputs).await?; + let role_setting = prompt_build.prompt.trim().to_string(); + if role_setting.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-image-generation", + "message": "生成提示词构建结果不能为空", + })), + ); + } // 背景色决策:预扣泥点之后才发起,仅角色生图需要(决策失败→future 返回 Err→失败退款, // 用户不损失泥点,平台也只在用户确有余额时才付这次 gpt-5-mini 决策)。 let screen_background_decision = if is_character_generation { @@ -2064,63 +2062,49 @@ pub(crate) async fn generate_editor_image_for_owner( image_style, pixel_art_prompt_scope, ); - let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { - create_openai_nanobanana_generate_content( - &http_client, - &settings, - generation_options.model, - submitted_prompt.as_str(), + let generated = request_editor_generated_images( + &http_client, + &settings, + EditorImageProviderRequest { + model: generation_options.model, + prompt: submitted_prompt.as_str(), negative_prompt, - generation_options.aspect_ratio, - generation_options.provider_image_size, - reference_images.as_slice(), + size: provider_request_size.as_ref(), + aspect_ratio: generation_options.aspect_ratio, + image_size: generation_options.provider_image_size, + reference_images: reference_images.as_slice(), failure_context, - ) - .await? - } else if reference_images.is_empty() { - create_openai_image_generation_with_model( - &http_client, - &settings, - generation_options.model, - submitted_prompt.as_str(), - negative_prompt, - provider_request_size.as_ref(), - 1, - &[], - failure_context, - ) - .await? - } else { - create_openai_image_edit_with_references_and_model( - &http_client, - &settings, - generation_options.model, - submitted_prompt.as_str(), - negative_prompt, - provider_request_size.as_ref(), - 1, - reference_images.as_slice(), - failure_context, - ) - .await? - }; - Ok::<_, AppError>((generated, submitted_prompt, screen_background_decision)) - }; - let (generated, submitted_prompt, screen_background_decision) = - execute_billable_asset_operation_with_cost( - state, - caller.owner_user_id.as_str(), - editor_image_generation_billing_asset_kind(normalized_kind), - request_context.request_id(), - u64::from(configured_price_mud_points), - generate_operation, + }, ) .await?; + Ok::<_, AppError>(( + generated, + submitted_prompt, + screen_background_decision, + role_setting, + prompt_build.generation_inputs, + )) + }; + let ( + generated, + submitted_prompt, + screen_background_decision, + role_setting, + prompt_generation_inputs, + ) = execute_billable_asset_operation_with_cost( + state, + caller.owner_user_id.as_str(), + editor_image_generation_billing_asset_kind(normalized_kind), + request_context.request_id(), + u64::from(configured_price_mud_points), + generate_operation, + ) + .await?; let screen_color = screen_background_decision .as_ref() .map(|decision| decision.color); let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( - payload.generation_inputs.clone(), + prompt_generation_inputs, screen_background_decision.as_ref(), ); let asset_label = @@ -2522,7 +2506,7 @@ pub(crate) fn apply_editor_screen_background_decision_to_generation_inputs( Some(value) } -fn apply_editor_matting_metadata_to_generation_inputs( +pub(crate) fn apply_editor_matting_metadata_to_generation_inputs( generation_inputs: Option, provider: &str, model: &str, @@ -2539,7 +2523,7 @@ fn apply_editor_matting_metadata_to_generation_inputs( Some(value) } -fn build_editor_derived_asset_generation_inputs( +pub(crate) fn build_editor_derived_asset_generation_inputs( stage: &str, source: &EditorGeneratedAssetRecord, ) -> Option { @@ -2772,7 +2756,7 @@ fn is_editor_custom_image_size(value: &str) -> bool { (64..=4096).contains(&width) && (64..=4096).contains(&height) } -fn normalize_editor_generation_options( +pub(crate) fn normalize_editor_generation_options( model: Option<&str>, aspect_ratio: Option<&str>, image_size: Option<&str>, @@ -3270,7 +3254,7 @@ fn apply_editor_postprocessed_alpha_to_provider_source( ) } -fn decode_editor_provider_source_dimensions( +pub(crate) fn decode_editor_provider_source_dimensions( image: &DownloadedOpenAiImage, provider: &'static str, message: &'static str, @@ -3410,7 +3394,7 @@ fn editor_postprocessed_alpha_matches_delivery_dimensions( Ok(postprocessed_width == provider_width && postprocessed_height == provider_height) } -async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( +pub(crate) async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state: &AppState, provider_source_object_key: &str, provider_width: u32, @@ -3945,7 +3929,7 @@ fn degrade_editor_pixel_art_with_provider_source( } } -async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( +pub(crate) async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original( state: &AppState, provider_source_object_key: &str, delivery_width: u32, @@ -5959,7 +5943,9 @@ fn sign_editor_private_object_read_url( .map_err(|error| map_oss_error(error, "aliyun-oss")) } -fn parse_editor_bgfilter_seg_model(value: Option<&str>) -> Result<&'static str, AppError> { +pub(crate) fn parse_editor_bgfilter_seg_model( + value: Option<&str>, +) -> Result<&'static str, AppError> { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(EDITOR_BGFILTER_DEFAULT_SEG_MODEL); }; @@ -5975,1574 +5961,6 @@ fn parse_editor_bgfilter_seg_model(value: Option<&str>) -> Result<&'static str, } } -struct PersistEditorSpritesheetSlicesInput { - owner_user_id: String, - project_id: Option, - asset_folder_id: Option, - source_resource_id: Option, - task_id: String, - group_task_id: Option, - prompt: String, - actual_prompt: Option, - model: String, - provider: String, - generation_inputs: Option, - path_kind: &'static str, - asset_kind: &'static str, - 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)] -struct PreparedEditorIconSpritesheetPlan { - plan: GeneratedAssetSheetConnectedIconPlan, - processing_deadline: Instant, - // 中文注释:每个仍可能运行的 blocking 编码任务都持有同一 admission,超时取消 - // async 等待时也不会提前释放整张 RGBA 的内存名额。 - _memory_admission: Arc, -} - -async fn persist_editor_spritesheet_slices( - state: &AppState, - prepared: PreparedEditorIconSpritesheetPlan, - input: PersistEditorSpritesheetSlicesInput, -) -> Result, 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}"), - })) - })?; - 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::>() - .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, - } - }); - 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), - }); - 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()) -} - -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])) -} - -fn build_editor_spritesheet_http_client( - connect_timeout: Duration, - request_timeout: Duration, -) -> Result { - reqwest::Client::builder() - .connect_timeout(connect_timeout) - .timeout(request_timeout) - .build() -} - -async fn encode_editor_icon_spritesheet_slice( - prepared: PreparedEditorIconSpritesheetPlan, - index: usize, -) -> Result { - 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 { - 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, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, AppError> { - let Json(payload) = parse_editor_generation_json_payload(payload)?; - 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, - None, - ) - .await?; - return Ok(json_success_body( - Some(&request_context), - EditorGenerationQueuedResponse { - queue_state: editor_generation_queue_state(queue_job), - }, - )); - } - generate_editor_icon_spritesheet_for_owner(&state, &request_context, caller, payload).await -} - -pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( - state: &AppState, - request_context: &RequestContext, - caller: &EditorGenerationCaller, - mut payload: EditorIconSpritesheetGenerationRequest, - external_idempotency_key: Option<&str>, -) -> Result { - payload.generation_inputs = - sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); - ensure_editor_reference_image_source_is_stable( - payload.reference_image_src.as_str(), - "editor-icon-spritesheet", - "referenceImageSrc", - "图标素材规范", - )?; - 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 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, AppError> { - payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); - ensure_editor_reference_image_source_is_stable( - payload.reference_image_src.as_str(), - "editor-icon-spritesheet", - "referenceImageSrc", - "图标素材规范", - )?; - 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", - "图标素材参考图", - )?; - 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_image_src.as_str())), - payload.reference_image_srcs.as_deref(), - "参考图", - ), - ) - .await?; - let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; - let user_prompt = icon_descriptions.join("\n"); - let resolved_icon_spec = resolve_editor_icon_spec_reference( - state, - caller.owner_user_id.as_str(), - payload.reference_image_src.as_str(), - ) - .await - .map_err(|error| { - error.with_details(json!({ - "provider": "editor-icon-spritesheet", - "field": "referenceImageSrc", - "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)?; - 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: user_prompt.clone(), - icon_descriptions: icon_descriptions.clone(), - 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 = crate::prompt::icon_spec::build_spritesheet_prompt( - user_prompt.as_str(), - screen_color, - icon_spec_genre, - ); - let prompt = apply_editor_pixel_art_style_prompt( - prompt, - image_style, - EditorPixelArtPromptScope::IconSpritesheet, - ); - let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { - create_openai_nanobanana_generate_content( - &http_client, - &settings, - generation_options.model, - prompt.as_str(), - None, - generation_options.aspect_ratio, - generation_options.provider_image_size, - reference_images.as_slice(), - "图片画布生成图标素材 spritesheet", - ) - .await? - } else { - create_openai_image_edit_with_references_and_model( - &http_client, - &settings, - generation_options.model, - prompt.as_str(), - None, - size, - 1, - reference_images.as_slice(), - "图片画布生成图标素材 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 = persist_editor_provider_source_image( - state, - caller.owner_user_id.as_str(), - generated.task_id.as_str(), - image, - user_prompt.as_str(), - generated.actual_prompt.as_deref(), - EDITOR_ICON_SPRITESHEET_ASSET_KIND, - "icon-spritesheets", - "spritesheet", - ) - .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_record = persist_editor_provider_source_resource( - state, - source_persisted, - PersistEditorProviderSourceResourceInput { - 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(), "原图"), - 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), - }, - ) - .await?; - 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 (completed_project, fallback_warning) = complete_editor_source_only_fallback( - state, - caller.owner_user_id.as_str(), - payload.project_id.as_deref(), - payload.canvas_completion.as_ref(), - &source_record, - 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_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 (completed_project, fallback_warning) = complete_editor_source_only_fallback( - state, - caller.owner_user_id.as_str(), - payload.project_id.as_deref(), - payload.canvas_completion.as_ref(), - &source_record, - 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_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), - 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; - let spritesheet_persisted = persist_editor_generated_image( - state, - owner_user_id.as_str(), - 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_persisted.object_key.as_str()); - let spritesheet_record = persist_editor_generated_asset( - state, - PersistEditorGeneratedAssetInput { - project_id: payload.project_id.clone(), - owner_user_id: owner_user_id.clone(), - folder_id: asset_folder_id.clone(), - label: spritesheet_label, - image_src: spritesheet_image_src.clone(), - object_key: Some(spritesheet_persisted.object_key.clone()), - asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), - width: spritesheet_width, - height: spritesheet_height, - prompt: 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 - .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, - }, - ) - .await?; - let slice_source = DownloadedImage { - bytes: image.bytes, - mime_type: image.mime_type, - extension: image.extension, - }; - let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all( - slice_source, - request_context.external_call_deadline(), - ) - .await - { - Ok(icon_slices) => { - match persist_editor_spritesheet_slices( - state, - 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 - .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, - ), - path_kind: "icon-spritesheet-assets", - asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, - persistence_provider: "vector-engine", - }, - ) - .await - { - Ok(icon_image_srcs) => (icon_image_srcs, 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(), - 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(), 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.resource.as_ref(), - source_record.resource.as_ref(), - icon_image_srcs.as_slice(), - ) - } else { - (Vec::new(), None) - }; - let completed_project = complete_editor_canvas_generation_with_items( - state, - owner_user_id.as_str(), - payload.project_id.as_deref(), - payload.canvas_completion.as_ref(), - canvas_items, - primary_layer_id, - ) - .await?; - - Ok(json_success_body( - Some(&request_context), - EditorIconSpritesheetGenerationResponse { - spritesheet_image_src, - spritesheet_width, - spritesheet_height, - icon_image_srcs, - 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, - }, - )) -} - -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, 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") { - Some(Value::String(value)) => Ok(Some(value.trim())), - 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, - Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, -) -> Result, 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, - ) - .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, - }, - )) -} - -async fn slice_editor_icon_spritesheet_all( - source: DownloadedImage, - request_deadline: Option, -) -> Result { - 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, - ) - .await -} - -async fn acquire_editor_icon_spritesheet_memory_admission( - processing_deadline: Instant, -) -> Result, 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, -) -> Result { - 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)?; - prepare_generated_icon_spritesheet_all_by_connected_components( - &source, - 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 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, - }) -} - -fn resolve_editor_icon_spritesheet_processing_deadline( - started_at: Instant, - request_deadline: Option, -) -> 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) -} - -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": "图集切片处理超时。", - })) -} - -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) -} - -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(), - } -} - -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, 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))) -} - -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), - } -} - -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(()) -} - pub async fn extract_editor_ui_design_assets( State(state): State, Extension(request_context): Extension, @@ -8603,7 +7021,7 @@ fn editor_generation_reference_from_records( }) } -async fn rebuild_editor_generation_input_references( +pub(crate) async fn rebuild_editor_generation_input_references( state: &AppState, owner_user_id: &str, generation_inputs: Option, @@ -9122,7 +7540,7 @@ pub(crate) async fn complete_editor_canvas_generation( Ok(Some(editor_project_payload_from_record(saved))) } -async fn complete_editor_source_only_fallback( +pub(crate) async fn complete_editor_source_only_fallback( state: &AppState, owner_user_id: &str, project_id: Option<&str>, @@ -9670,7 +8088,7 @@ fn positive_f64(value: f64, fallback: f64) -> f64 { } } -fn generated_canvas_layer_id(resource_id: &str) -> String { +pub(crate) fn generated_canvas_layer_id(resource_id: &str) -> String { format!("layer-editor-resource-{resource_id}") } @@ -9747,7 +8165,7 @@ fn build_multi_asset_canvas_layer_items( (items, primary_layer_id) } -fn build_icon_spritesheet_canvas_layer_items( +pub(crate) fn build_icon_spritesheet_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, spritesheet_resource: Option<&EditorProjectResourcePayload>, source_resource: Option<&EditorProjectResourcePayload>, @@ -9775,7 +8193,7 @@ fn build_icon_spritesheet_canvas_layer_items( (items, primary_layer_id) } -fn build_icon_spritesheet_slice_canvas_layer_items( +pub(crate) fn build_icon_spritesheet_slice_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, icons: &[EditorIconSpritesheetIconResponse], ) -> Vec { @@ -9871,7 +8289,7 @@ fn generation_dialog_item_placeholder( .ok() } -fn normalize_generated_asset_folder_id( +pub(crate) fn normalize_generated_asset_folder_id( folder_id: Option, owner_user_id: &str, ) -> Option { @@ -9896,7 +8314,7 @@ fn normalize_editor_object_key(value: Option) -> Option { .filter(|item| !item.is_empty()) } -fn editor_media_src_from_object_key(object_key: &str) -> String { +pub(crate) fn editor_media_src_from_object_key(object_key: &str) -> String { format!("/{}", object_key.trim().trim_start_matches('/')) } @@ -10034,7 +8452,10 @@ pub(crate) fn normalize_optional_string(value: Option) -> Option .filter(|item| !item.is_empty()) } -fn resolve_editor_generated_asset_label(value: Option, fallback: &str) -> String { +pub(crate) fn resolve_editor_generated_asset_label( + value: Option, + fallback: &str, +) -> String { normalize_optional_string(value) .unwrap_or_else(|| fallback.to_string()) .chars() @@ -10042,7 +8463,7 @@ fn resolve_editor_generated_asset_label(value: Option, fallback: &str) - .collect() } -fn editor_generated_asset_variant_label(label: &str, variant: &str) -> String { +pub(crate) fn editor_generated_asset_variant_label(label: &str, variant: &str) -> String { let suffix = format!("({variant})"); let base_char_limit = EDITOR_ASSET_LABEL_MAX_CHARS.saturating_sub(suffix.chars().count()); let base = label @@ -10074,43 +8495,6 @@ pub(crate) fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> St } } -fn normalize_icon_descriptions(descriptions: Vec) -> Result, AppError> { - let normalized = descriptions - .into_iter() - .map(|description| description.trim().to_string()) - .filter(|description| !description.is_empty()) - .take(EDITOR_ICON_DESCRIPTION_LIMIT.saturating_add(1)) - .collect::>(); - if normalized.is_empty() || normalized.len() > EDITOR_ICON_DESCRIPTION_LIMIT { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-icon-spritesheet", - "field": "iconDescriptions", - "message": "图标素材描述数量必须在 1 到 100 个之间。", - })), - ); - } - Ok(normalized) -} - -async fn resolve_editor_icon_spritesheet_price( - state: &AppState, - model: Option<&str>, - image_size: Option<&str>, -) -> Result { - let expected_price_mud_points = 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); - Ok(expected_price_mud_points) -} - fn editor_ui_design_asset_extraction_bad_request(message: impl Into) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-ui-design-asset-extraction", @@ -10215,7 +8599,7 @@ const EDITOR_PIXEL_ART_ICON_SPRITESHEET_PROMPT: &str = "每个图标素材均为 /// 中文注释:按链路命名而不是按「整幅画面 / 只有主体」这类抽象作用域分类——三条链路的差异 /// 不只是作用域,措辞本身还各自绑定各自提示词里已有的约束。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum EditorPixelArtPromptScope { +pub(crate) enum EditorPixelArtPromptScope { Image, Character, IconSpritesheet, @@ -10223,7 +8607,7 @@ enum EditorPixelArtPromptScope { /// 中文注释:追加在末尾而不是前置——风格约束靠后更容易被遵守,也不会和「生成游戏角色立绘」 /// 这类任务声明抢第一位。非 PixelArt 必须原样返回:普通图片路径的提示词就是用户原文。 -fn apply_editor_pixel_art_style_prompt( +pub(crate) fn apply_editor_pixel_art_style_prompt( prompt: String, style: EditorImageGenerationStyle, scope: EditorPixelArtPromptScope, @@ -10240,14 +8624,14 @@ fn apply_editor_pixel_art_style_prompt( } #[derive(Debug, Clone, PartialEq, Eq)] -struct EditorGenerationOptions { - model: &'static str, - aspect_ratio: &'static str, - image_size: &'static str, +pub(crate) struct EditorGenerationOptions { + pub(crate) model: &'static str, + pub(crate) aspect_ratio: &'static str, + pub(crate) image_size: &'static str, // 中文注释:size 是模型无关的画布交付像素;provider_size 只用于满足 GPT 图片接口的合法像素矩阵。 - size: String, - provider_size: String, - provider_image_size: &'static str, + pub(crate) size: String, + pub(crate) provider_size: String, + pub(crate) provider_image_size: &'static str, } pub(crate) struct PersistedEditorGeneratedImage { @@ -10300,21 +8684,21 @@ fn prepare_editor_generated_image_object_data( }) } -struct PersistEditorProviderSourceResourceInput { - project_id: Option, - owner_user_id: String, - folder_id: Option, - label: String, - width: u32, - height: u32, - prompt: String, - actual_prompt: Option, - model: String, - task_id: String, - source_resource_id: Option, - asset_kind: Option, - generation_inputs: Option, - generation_cost_mud_points: u64, +pub(crate) struct PersistEditorProviderSourceResourceInput { + pub(crate) project_id: Option, + pub(crate) owner_user_id: String, + pub(crate) folder_id: Option, + pub(crate) label: String, + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) prompt: String, + pub(crate) actual_prompt: Option, + pub(crate) model: String, + pub(crate) task_id: String, + pub(crate) source_resource_id: Option, + pub(crate) asset_kind: Option, + pub(crate) generation_inputs: Option, + pub(crate) generation_cost_mud_points: u64, } pub(crate) async fn persist_editor_generated_image( @@ -10542,7 +8926,7 @@ async fn upload_editor_generated_image_object_prepared( }) } -async fn persist_editor_provider_source_image( +pub(crate) async fn persist_editor_provider_source_image( state: &AppState, owner_user_id: &str, task_id: &str, @@ -10569,7 +8953,7 @@ async fn persist_editor_provider_source_image( .await } -async fn persist_editor_provider_source_resource( +pub(crate) async fn persist_editor_provider_source_resource( state: &AppState, persisted: PersistedEditorGeneratedImage, input: PersistEditorProviderSourceResourceInput, @@ -10635,7 +9019,7 @@ fn ensure_editor_reference_image_source_is_stable( ) } -fn ensure_editor_reference_image_sources_are_stable( +pub(crate) fn ensure_editor_reference_image_sources_are_stable( sources: Option<&[String]>, provider: &str, field: &str, @@ -10657,23 +9041,23 @@ pub(crate) async fn parse_editor_reference_image( read_editor_reference_image_object(state, object_key.as_str()).await } -struct ResolvedEditorIconSpecReference { - image: OpenAiReferenceImage, - genre: Option, +pub(crate) struct ResolvedEditorIconSpecReference { + pub(crate) image: OpenAiReferenceImage, + pub(crate) genre: Option, } -async fn resolve_editor_icon_spec_reference( +async fn resolve_editor_reference_record_by_id_for_owner( state: &AppState, owner_user_id: &str, - reference_source: &str, -) -> Result { - let reference_source = reference_source.trim(); - if reference_source.is_empty() { + reference_id: &str, +) -> Result { + let reference_id = reference_id.trim(); + if reference_id.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "editor-icon-spritesheet", - "field": "referenceImageSrc", - "message": "图标素材规范引用不能为空。", + "provider": "editor-reference-image", + "field": "referenceId", + "message": "编辑器引用 ID 不能为空。", })), ); } @@ -10683,20 +9067,55 @@ async fn resolve_editor_icon_spec_reference( "reason": "OSS 未完成环境变量配置", })) })?; - let (reference_id, object_key) = match normalize_editor_reference_object_key(reference_source) { - Ok(object_key) => (None, Some(object_key)), - Err(_) => (Some(reference_source.to_string()), None), - }; - let resolved = state + state .spacetime_client() .resolve_editor_reference(EditorReferenceResolveRecordInput { owner_user_id: owner_user_id.to_string(), bucket: oss_client.config_bucket().to_string(), - reference_id, - object_key, + reference_id: reference_id.to_string(), }) .await - .map_err(map_editor_project_error)?; + .map_err(map_editor_project_error) +} + +pub(crate) async fn validate_editor_reference_id_for_owner( + state: &AppState, + owner_user_id: &str, + reference_id: &str, +) -> Result<(), AppError> { + let resolved = + resolve_editor_reference_record_by_id_for_owner(state, owner_user_id, reference_id).await?; + let object_key = match resolved { + EditorReferenceRecord::ProjectResource(resource) => resource.object_key, + EditorReferenceRecord::Asset(asset) => asset.object_key, + } + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": "已验证的编辑器引用缺少 object_key。", + })) + })?; + read_editor_reference_image_object(state, object_key.as_str()).await?; + Ok(()) +} + +pub(crate) async fn resolve_editor_icon_spec_reference( + state: &AppState, + owner_user_id: &str, + reference_id: &str, +) -> Result { + let reference_id = reference_id.trim(); + if reference_id.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-icon-spritesheet", + "field": "referenceId", + "message": "图标素材规范引用不能为空。", + })), + ); + } + let resolved = + resolve_editor_reference_record_by_id_for_owner(state, owner_user_id, reference_id).await?; let (object_key, generation_inputs, asset_kind) = match resolved { EditorReferenceRecord::ProjectResource(resource) => ( resource.object_key, @@ -10711,7 +9130,7 @@ async fn resolve_editor_icon_spec_reference( return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spritesheet", - "field": "referenceImageSrc", + "field": "referenceId", "message": "图标素材规范引用必须指向 icon-spec 资源或素材。", })), ); @@ -10722,15 +9141,14 @@ async fn resolve_editor_icon_spec_reference( "message": "已验证的编辑器引用缺少 object_key。", })) })?; - let genre = crate::editor_project_icon_specs::game_genre_from_generation_inputs( - generation_inputs.as_ref(), - ) - .map_err(|()| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "spacetimedb", - "message": "图标规范引用保存了无效的游戏类型。", - })) - })?; + let genre = + crate::editor_project_icon::game_genre_from_generation_inputs(generation_inputs.as_ref()) + .map_err(|()| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": "图标规范引用保存了无效的游戏类型。", + })) + })?; let image = read_editor_reference_image_object(state, object_key.as_str()).await?; Ok(ResolvedEditorIconSpecReference { image, genre }) } @@ -10961,7 +9379,7 @@ fn resolve_editor_background_removal_resource_model( None } -fn normalize_editor_reference_image_sources(sources: Option<&[String]>) -> Vec<&str> { +pub(crate) fn normalize_editor_reference_image_sources(sources: Option<&[String]>) -> Vec<&str> { sources .unwrap_or(&[]) .iter() @@ -10971,7 +9389,7 @@ fn normalize_editor_reference_image_sources(sources: Option<&[String]>) -> Vec<& .collect() } -fn ensure_editor_reference_image_source_limit( +pub(crate) fn ensure_editor_reference_image_source_limit( sources: Option<&[String]>, limit: usize, provider: &str, @@ -10997,7 +9415,7 @@ fn ensure_editor_reference_image_source_limit( ) } -fn build_editor_generation_reference_sources( +pub(crate) fn build_editor_generation_reference_sources( primary: Option<(&str, &str)>, extras: Option<&[String]>, extra_title: &str, @@ -11138,7 +9556,10 @@ fn editor_reference_asset_object_key(asset: &EditorAssetRecord, source: &str) -> .flatten() } -fn normalize_editor_record_object_key(object_key: Option<&str>, image_src: &str) -> Option { +pub(crate) fn normalize_editor_record_object_key( + object_key: Option<&str>, + image_src: &str, +) -> Option { object_key .map(str::trim) .map(|value| value.trim_start_matches('/')) @@ -11287,7 +9708,7 @@ async fn read_editor_reference_image_object( .await } -async fn read_editor_reference_image_object_with_client( +pub(crate) async fn read_editor_reference_image_object_with_client( state: &AppState, source: &str, http_client: &reqwest::Client, @@ -14040,7 +12461,10 @@ mod tests { #[test] fn explicit_pixel_art_snap_is_inline_strict_and_persists_only_after_processing() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains_in_order( source, "pub async fn snap_editor_image_to_pixel_art(", @@ -14195,7 +12619,10 @@ mod tests { #[test] fn editor_image_generation_returns_never_drop_accumulated_warnings() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); // 中文注释:generation_warning 承载未知 style 的 unsupported-image-style 等 // 累积告警,普通图片与角色生成的每一个返回点都必须经 merge 合并;任何 // 直接的 `warning: Some(` 都会把此前累积的告警整条覆盖掉。 @@ -14215,7 +12642,10 @@ mod tests { #[test] fn pixel_art_degrade_paths_guard_postprocessed_delivery_dimensions() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); // 中文注释:像素规整的每一条 best-effort 降级(预算耗尽、回读原图失败或超时、 // permit 获取失败、worker 内 deadline、join 异常、worker 超时)都不能把 BgFilter // 原始输出连同 None 尺寸错误直接交回调用方——那等于谎报「尺寸无误」。 @@ -14296,7 +12726,10 @@ mod tests { #[test] fn pixel_art_provider_input_prep_shares_deadline_and_cpu_permit() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains_in_order( source, "async fn apply_editor_postprocessed_alpha_and_pixel_art_from_persisted_provider_source_or_original", @@ -14731,7 +13164,10 @@ mod tests { #[test] fn publication_material_generation_is_locked_to_gpt_image_2() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains( source, @@ -14758,14 +13194,17 @@ mod tests { #[test] fn icon_spec_reference_resolution_uses_narrow_verified_lookup() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains( source, "async fn resolve_editor_icon_spec_reference", "pub(crate) async fn resolve_editor_reference_object_key_for_owner", &[ - ".resolve_editor_reference(", + "resolve_editor_reference_record_by_id_for_owner(", "read_editor_reference_image_object(", ], ); @@ -14783,7 +13222,10 @@ mod tests { #[test] fn image_generation_preflight_checks_references_provider_and_pricing() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains_in_order( source, @@ -14873,7 +13315,10 @@ mod tests { #[test] fn pixel_art_style_prompt_is_applied_before_provider_call() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); // 中文注释:注入必须发生在 provider 调用之前——一旦被挪到之后,提示词约束就完全 // 不起作用,而产物看起来仍然「成功」,只是退化成 snapper 的统一网格马赛克。 @@ -14886,17 +13331,17 @@ mod tests { "EditorPixelArtPromptScope::Character", "EditorPixelArtPromptScope::Image", "apply_editor_pixel_art_style_prompt(", - "create_openai_", + "request_editor_generated_images(", ], ); assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", - "pub async fn extract_editor_ui_design_assets", + "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &[ "apply_editor_pixel_art_style_prompt(", "EditorPixelArtPromptScope::IconSpritesheet", - "create_openai_", + "request_editor_generated_images(", ], ); } @@ -14922,7 +13367,10 @@ mod tests { #[test] fn editor_generated_asset_persistence_keeps_user_prompt_separate_from_system_prompt() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains( source, "pub(crate) async fn generate_editor_image_for_owner", @@ -14935,7 +13383,7 @@ mod tests { assert_function_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", - "pub async fn extract_editor_ui_design_assets", + "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &[ "let user_prompt = icon_descriptions.join(\"\\n\");", "image,\n user_prompt.as_str(),\n generated.actual_prompt.as_deref(),", @@ -15799,7 +14247,10 @@ mod tests { #[test] fn all_spritesheet_paths_share_bounded_blocking_slicer() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains_in_order( source, "async fn slice_editor_icon_spritesheet_all", @@ -16100,7 +14551,10 @@ mod tests { assert!(error.body_text().contains("objectKey")); } - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains_in_order( source, "async fn resolve_editor_reference_object_key(", @@ -16701,7 +15155,10 @@ mod tests { #[test] fn editor_image_edit_guards_before_queue_and_owner_execution() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains_in_order( source, "pub async fn edit_editor_image(", @@ -16724,7 +15181,10 @@ mod tests { #[test] fn editor_inline_media_sources_are_rejected_before_queueing() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); for (start, end) in [ ( "pub async fn generate_editor_image(", @@ -16786,7 +15246,10 @@ mod tests { #[test] fn editor_image_edit_can_complete_by_replacing_target_layer() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains( source, "pub(crate) async fn edit_editor_image_for_owner", @@ -16800,9 +15263,9 @@ mod tests { } #[test] - fn editor_icon_spritesheet_request_accepts_stable_extra_references() { + fn editor_icon_spritesheet_request_accepts_id_only_spec_and_stable_extra_references() { let request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ - "referenceImageSrc": "/generated-images/editor/spec.png", + "referenceId": "editor-resource-spec", "referenceImageSrcs": ["/generated-images/editor/style.png"], "iconDescriptions": ["返回按钮"], "screenColor": "#7FB3FF", @@ -16811,6 +15274,7 @@ mod tests { })) .expect("icon spritesheet request should deserialize references"); + assert_eq!(request.reference_id, "editor-resource-spec"); assert_eq!( request.reference_image_srcs, Some(vec!["/generated-images/editor/style.png".to_string()]) @@ -16821,7 +15285,7 @@ mod tests { assert_eq!(request.style, None); let pixel_request: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ - "referenceImageSrc": "/generated-images/editor/spec.png", + "referenceId": "editor-resource-spec", "iconDescriptions": ["返回按钮"], "style": "pixelArt" })) @@ -17215,7 +15679,10 @@ mod tests { #[test] fn editor_image_and_spritesheet_generation_are_billable() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains( source, "pub(crate) async fn generate_editor_image_for_owner", @@ -17237,7 +15704,7 @@ mod tests { assert_function_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", - "pub async fn extract_editor_ui_design_assets", + "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &[ "execute_billable_asset_operation_with_cost", "expected_price_mud_points", @@ -17256,7 +15723,10 @@ mod tests { #[test] fn editor_manual_background_removal_reuses_single_account_snapshot_pair() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_not_contains( source, "pub(crate) async fn remove_editor_image_background_for_owner", @@ -17281,7 +15751,10 @@ mod tests { #[test] fn editor_canvas_screen_background_generation_uses_bgfilter_postprocess() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains( source, "pub(crate) async fn generate_editor_image_for_owner", @@ -17320,7 +15793,7 @@ mod tests { assert_function_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", - "pub async fn extract_editor_ui_design_assets", + "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &[ "persist_editor_provider_source_image", "caller.report_processing_phase(state).await?", @@ -17338,13 +15811,13 @@ mod tests { assert_function_not_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", - "pub async fn extract_editor_ui_design_assets", + "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &["{ \"title\": \"处理模型\""], ); assert_function_contains_in_order( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", - "pub async fn extract_editor_ui_design_assets", + "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &[ "persist_editor_provider_source_image", "persist_editor_provider_source_resource", @@ -17479,13 +15952,13 @@ mod tests { assert_function_not_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", - "pub async fn extract_editor_ui_design_assets", + "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &["try_remove_editor_downloaded_image_background"], ); assert_function_not_contains( source, "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", - "pub async fn extract_editor_ui_design_assets", + "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", &["remove_editor_generated_green_screen_background"], ); assert_function_not_contains( @@ -17528,7 +16001,10 @@ mod tests { #[test] fn editor_flat_bgfilter_parent_calls_internal_worker_once_before_fallback() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains( source, "pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(", @@ -17552,7 +16028,10 @@ mod tests { #[test] fn editor_processing_phase_retries_transport_once_but_not_fencing() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert!(source.contains("const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1;")); assert_function_contains_in_order( source, @@ -17635,7 +16114,10 @@ mod tests { #[test] fn editor_static_postprocess_failure_completes_canvas_with_source_only() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains( source, "async fn complete_editor_source_only_fallback", @@ -17666,7 +16148,7 @@ mod tests { ), ( "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", - "pub async fn extract_editor_ui_design_assets", + "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", ), ( "pub(crate) async fn extract_editor_ui_design_assets_for_owner", @@ -17790,7 +16272,10 @@ mod tests { #[test] fn editor_character_postprocess_fallback_uses_shared_helper_without_swallowing_other_failures() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); let start = source .find("pub(crate) async fn generate_editor_image_for_owner") .expect("character generation function should exist"); @@ -17884,7 +16369,10 @@ mod tests { #[test] fn editor_manual_background_removal_calls_internal_worker_once_without_fallback() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains( source, "async fn request_editor_background_removal_image_with_bgfilter_worker", @@ -17908,7 +16396,10 @@ mod tests { #[test] fn editor_matting_releases_source_buffers_at_oss_boundaries() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); for (start, end) in [ ( "pub(crate) async fn generate_editor_image_for_owner", @@ -17916,7 +16407,7 @@ mod tests { ), ( "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", - "pub async fn extract_editor_ui_design_assets", + "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", ), ( "pub(crate) async fn extract_editor_ui_design_assets_for_owner", @@ -17962,7 +16453,7 @@ mod tests { for (start, end) in [ ( "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", - "pub async fn extract_editor_ui_design_assets", + "pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind", ), ( "pub(crate) async fn extract_editor_ui_design_assets_for_owner", @@ -18105,7 +16596,10 @@ mod tests { #[test] fn editor_paid_image_postprocess_keeps_provider_outputs_recoverable() { - let source = include_str!("editor_project.rs"); + let source = concat!( + include_str!("editor_project_icon.rs"), + include_str!("editor_project.rs") + ); assert_function_contains( source, "pub(crate) async fn generate_editor_image_for_owner", diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs new file mode 100644 index 000000000..83c51e804 --- /dev/null +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -0,0 +1,2445 @@ +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::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, + }, +}; +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_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, + 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; +const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100; +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, +> = 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, +> = std::sync::LazyLock::new(|| { + std::sync::Arc::new(tokio::sync::Semaphore::new( + EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY, + )) +}); + +pub(crate) fn normalize_icon_descriptions( + descriptions: Vec, +) -> Result, AppError> { + let normalized = descriptions + .into_iter() + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()) + .take(EDITOR_ICON_DESCRIPTION_LIMIT.saturating_add(1)) + .collect::>(); + if normalized.is_empty() || normalized.len() > EDITOR_ICON_DESCRIPTION_LIMIT { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-icon-spritesheet", + "field": "iconDescriptions", + "message": "图标素材描述数量必须在 1 到 100 个之间。", + })), + ); + } + Ok(normalized) +} + +pub(crate) async fn resolve_editor_icon_spritesheet_price( + state: &AppState, + model: Option<&str>, + image_size: Option<&str>, +) -> Result { + 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>, + pub(crate) icon_descriptions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) style: Option, + pub(crate) model: Option, + pub(crate) screen_color: Option, + pub(crate) seg_model: Option, + pub(crate) aspect_ratio: Option, + pub(crate) image_size: Option, + pub(crate) project_id: Option, + pub(crate) generation_inputs: Option, + pub(crate) asset_folder_id: Option, + pub(crate) asset_label: Option, + pub(crate) canvas_completion: Option, +} + +#[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, + 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, + pub(crate) asset: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) slice_warning: Option, + pub(crate) prompt: String, + pub(crate) actual_prompt: Option, + pub(crate) model: String, + pub(crate) task_id: String, + pub(crate) price_mud_points: u32, + pub(crate) spritesheet_resource: Option, + pub(crate) spritesheet_asset: Option, + pub(crate) project: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) warning: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditorIconSpritesheetSliceResponse { + pub(crate) icon_image_srcs: Vec, + 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(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_slug()) + } +} + +impl<'de> Deserialize<'de> for GameGenre { + fn deserialize(deserializer: D) -> Result + 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 { + 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, + #[serde(default)] + project_id: Option, + #[serde(default)] + generation_inputs: Option, + #[serde(default)] + asset_folder_id: Option, + #[serde(default)] + asset_label: Option, + #[serde(default)] + source_resource_id: Option, + #[serde(default)] + canvas_completion: Option, +} + +pub(crate) async fn generate_icon_spec( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, 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), + }, + )); + } + 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 { + validate_icon_spec_request(state, caller, &mut 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, AppError> { + validate_icon_spec_request(state, &caller, &mut 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, "图标规范生成中".to_string(), 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 +} + +async fn validate_icon_spec_request( + state: &AppState, + caller: &EditorGenerationCaller, + 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", + "美术风格", + )?; + 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 { + 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, + prompt: String, + generation_inputs: Option, +) -> EditorImageGenerationRequest { + EditorImageGenerationRequest { + prompt, + 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 { + 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) => { + let text = response.text.trim(); + if text.is_empty() { + if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( + json!({ + "provider": "editor-icon-spec-llm", + "operation": "complete-extra-param", + "message": "LLM 未返回可用文本", + }), + )); + } + } else { + match parse_extra_param(text) { + Ok(extra_param) => return Ok(extra_param), + Err(_) if attempt < ICON_SPEC_LLM_MAX_ATTEMPTS => {} + Err(_) => { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY) + .with_details(json!({ + "provider": "editor-icon-spec-llm", + "operation": "complete-extra-param", + "message": "LLM 返回内容不符合图标规范参数契约", + }))); + } + } + } + } + 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") +} + +fn parse_extra_param(text: &str) -> Result { + let mut extra_param = serde_json::from_str::(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 { + 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 { + 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, ()> { + 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, + pub(crate) asset_folder_id: Option, + pub(crate) source_resource_id: Option, + pub(crate) task_id: String, + pub(crate) group_task_id: Option, + pub(crate) prompt: String, + pub(crate) actual_prompt: Option, + pub(crate) model: String, + pub(crate) provider: String, + pub(crate) generation_inputs: Option, + 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, +} + +pub(crate) async fn persist_editor_spritesheet_slices( + state: &AppState, + prepared: PreparedEditorIconSpritesheetPlan, + input: PersistEditorSpritesheetSlicesInput, +) -> Result, 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}"), + })) + })?; + 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::>() + .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, + } + }); + 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), + }); + 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) 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::builder() + .connect_timeout(connect_timeout) + .timeout(request_timeout) + .build() +} + +async fn encode_editor_icon_spritesheet_slice( + prepared: PreparedEditorIconSpritesheetPlan, + index: usize, +) -> Result { + 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 { + 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, AppError> { + let Json(payload) = parse_editor_generation_json_payload(payload)?; + 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, + None, + ) + .await?; + return Ok(json_success_body( + Some(&request_context), + EditorGenerationQueuedResponse { + queue_state: editor_generation_queue_state(queue_job), + }, + )); + } + generate_editor_icon_spritesheet_for_owner(&state, &request_context, caller, payload).await +} + +pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( + state: &AppState, + request_context: &RequestContext, + caller: &EditorGenerationCaller, + mut payload: EditorIconSpritesheetGenerationRequest, + external_idempotency_key: Option<&str>, +) -> Result { + payload.generation_inputs = + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); + 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 资源或素材。", + })) + })?; + 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 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, 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", + "图标素材参考图", + )?; + 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 icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; + let user_prompt = icon_descriptions.join("\n"); + 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)?; + 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: user_prompt.clone(), + icon_descriptions: icon_descriptions.clone(), + 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 = crate::prompt::icon_spec::build_spritesheet_prompt( + user_prompt.as_str(), + 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 = persist_editor_provider_source_image( + state, + caller.owner_user_id.as_str(), + generated.task_id.as_str(), + image, + user_prompt.as_str(), + generated.actual_prompt.as_deref(), + EDITOR_ICON_SPRITESHEET_ASSET_KIND, + "icon-spritesheets", + "spritesheet", + ) + .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_record = persist_editor_provider_source_resource( + state, + source_persisted, + PersistEditorProviderSourceResourceInput { + 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(), "原图"), + 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), + }, + ) + .await?; + 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 (completed_project, fallback_warning) = complete_editor_source_only_fallback( + state, + caller.owner_user_id.as_str(), + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + &source_record, + 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_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 (completed_project, fallback_warning) = complete_editor_source_only_fallback( + state, + caller.owner_user_id.as_str(), + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + &source_record, + 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_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), + 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; + let spritesheet_persisted = persist_editor_generated_image( + state, + owner_user_id.as_str(), + 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_persisted.object_key.as_str()); + let spritesheet_record = persist_editor_generated_asset( + state, + PersistEditorGeneratedAssetInput { + project_id: payload.project_id.clone(), + owner_user_id: owner_user_id.clone(), + folder_id: asset_folder_id.clone(), + label: spritesheet_label, + image_src: spritesheet_image_src.clone(), + object_key: Some(spritesheet_persisted.object_key.clone()), + asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), + width: spritesheet_width, + height: spritesheet_height, + prompt: 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 + .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, + }, + ) + .await?; + let slice_source = DownloadedImage { + bytes: image.bytes, + mime_type: image.mime_type, + extension: image.extension, + }; + let (icon_image_srcs, slice_warning) = match slice_editor_icon_spritesheet_all( + slice_source, + request_context.external_call_deadline(), + ) + .await + { + Ok(icon_slices) => { + match persist_editor_spritesheet_slices( + state, + 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 + .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, + ), + path_kind: "icon-spritesheet-assets", + asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, + persistence_provider: "vector-engine", + }, + ) + .await + { + Ok(icon_image_srcs) => (icon_image_srcs, 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(), + 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(), 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.resource.as_ref(), + source_record.resource.as_ref(), + icon_image_srcs.as_slice(), + ) + } else { + (Vec::new(), None) + }; + let completed_project = complete_editor_canvas_generation_with_items( + state, + owner_user_id.as_str(), + payload.project_id.as_deref(), + payload.canvas_completion.as_ref(), + canvas_items, + primary_layer_id, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + EditorIconSpritesheetGenerationResponse { + spritesheet_image_src, + spritesheet_width, + spritesheet_height, + icon_image_srcs, + 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, 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") { + Some(Value::String(value)) => Ok(Some(value.trim())), + 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, 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, + ) + .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, +) -> Result { + 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, + ) + .await +} + +async fn acquire_editor_icon_spritesheet_memory_admission( + processing_deadline: Instant, +) -> Result, 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, +) -> Result { + 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)?; + prepare_generated_icon_spritesheet_all_by_connected_components( + &source, + 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 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 { + 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, 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 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::(json!(genre.as_slug())) + .expect("Chinese game genre should deserialize"), + genre + ); + } + + assert!(serde_json::from_value::(json!("strategy")).is_err()); + assert!(serde_json::from_value::(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::(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 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 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, + }, + "最终图标规范 prompt".to_string(), + Some(json!({ "fields": [], "references": [] })), + ); + + assert_eq!(image_request.prompt, "最终图标规范 prompt"); + 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") + ); + } +} diff --git a/server-rs/crates/api-server/src/editor_project_icon_specs.rs b/server-rs/crates/api-server/src/editor_project_icon_specs.rs deleted file mode 100644 index 66abc797c..000000000 --- a/server-rs/crates/api-server/src/editor_project_icon_specs.rs +++ /dev/null @@ -1,621 +0,0 @@ -use std::{fmt, str::FromStr}; - -use axum::{ - Json, - extract::{Extension, State}, - http::StatusCode, -}; -use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest}; -use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; -use serde_json::{Value, json}; -use shared_contracts::assets::EditorCanvasGenerationCompletionPayload; -use std::time::Duration; -use tokio::time::sleep; - -use crate::{ - auth::AuthenticatedAccessToken, - editor_project::{ - EditorGenerationCaller, EditorImageGenerationRequest, generate_editor_image, - sanitize_editor_client_generation_inputs, - validate_editor_image_generation_parameters_for_owner, - }, - http_error::AppError, - platform_errors::{is_retryable_llm_error, map_llm_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; - -#[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(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(self.as_slug()) - } -} - -impl<'de> Deserialize<'de> for GameGenre { - fn deserialize(deserializer: D) -> Result - 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 { - let value = value.trim(); - Self::ALL - .into_iter() - .find(|genre| genre.as_slug() == value) - .ok_or(()) - } -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct EditorIconSpecGenerationRequest { - #[serde(flatten)] - request_param: RequestParam, - #[serde(default)] - reference_image_srcs: Option>, - #[serde(default)] - project_id: Option, - #[serde(default)] - generation_inputs: Option, - #[serde(default)] - asset_folder_id: Option, - #[serde(default)] - asset_label: Option, - #[serde(default)] - source_resource_id: Option, - #[serde(default)] - canvas_completion: Option, -} - -pub(crate) async fn generate_icon_spec( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(mut payload): Json, -) -> Result, AppError> { - payload.request_param.play_setting = require_icon_spec_generation_text( - payload.request_param.play_setting, - "playSetting", - "玩法设定", - )?; - payload.request_param.art_style = - require_icon_spec_generation_text(payload.request_param.art_style, "artStyle", "美术风格")?; - - let preflight_request = build_icon_spec_image_request( - payload.clone(), - String::new(), - payload.generation_inputs.clone(), - ); - let caller = EditorGenerationCaller::from_authenticated(&authenticated); - validate_editor_image_generation_parameters_for_owner( - &state, - &request_context, - &caller, - &preflight_request, - ) - .await?; - - let extra_prompt = build_extra_param_prompt(&payload.request_param); - let extra_param = run_icon_spec_extra_param_llm(&state, extra_prompt).await?; - let has_reference_image = payload - .reference_image_srcs - .as_ref() - .map(|r| !r.is_empty()) - .unwrap_or(false); - let final_prompt = - get_gen_icon_spec_prompt(&payload.request_param, &extra_param, has_reference_image); - let generation_inputs = build_icon_spec_generation_inputs( - &payload.request_param, - extra_param.genre, - payload.generation_inputs.take(), - ); - let image_request = - build_icon_spec_image_request(payload, final_prompt, Some(generation_inputs)); - - generate_editor_image( - State(state), - Extension(request_context), - Extension(authenticated), - Ok(Json(image_request)), - ) - .await -} - -fn build_icon_spec_image_request( - payload: EditorIconSpecGenerationRequest, - prompt: String, - generation_inputs: Option, -) -> EditorImageGenerationRequest { - EditorImageGenerationRequest { - prompt, - 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_image_srcs, - 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 { - 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) => { - let text = response.text.trim(); - if text.is_empty() { - if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS { - return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( - json!({ - "provider": "editor-icon-spec-llm", - "operation": "complete-extra-param", - "message": "LLM 未返回可用文本", - }), - )); - } - } else { - match parse_extra_param(text) { - Ok(extra_param) => return Ok(extra_param), - Err(_) if attempt < ICON_SPEC_LLM_MAX_ATTEMPTS => {} - Err(_) => { - return Err(AppError::from_status(StatusCode::BAD_GATEWAY) - .with_details(json!({ - "provider": "editor-icon-spec-llm", - "operation": "complete-extra-param", - "message": "LLM 返回内容不符合图标规范参数契约", - }))); - } - } - } - } - 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") -} - -fn parse_extra_param(text: &str) -> Result { - let mut extra_param = serde_json::from_str::(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 { - 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 { - 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, ()> { - 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) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[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::(json!(genre.as_slug())) - .expect("Chinese game genre should deserialize"), - genre - ); - } - - assert!(serde_json::from_value::(json!("strategy")).is_err()); - assert!(serde_json::from_value::(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::(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 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 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 generation_dispatch_builds_the_existing_image_job_contract() { - let image_request = build_icon_spec_image_request( - EditorIconSpecGenerationRequest { - request_param: RequestParam { - play_setting: "回合制占点".to_string(), - art_style: "低多边形".to_string(), - }, - reference_image_srcs: Some(vec!["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, - }, - "最终图标规范 prompt".to_string(), - Some(json!({ "fields": [], "references": [] })), - ); - - assert_eq!(image_request.prompt, "最终图标规范 prompt"); - 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") - ); - } -} diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 4923b2b3f..3f09ff616 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -21,6 +21,9 @@ use spacetime_client::{ ExternalGenerationJobRecord, SpacetimeClientError, }; +use crate::editor_project_icon::{ + EditorIconSpritesheetGenerationRequest, enqueue_editor_icon_spritesheet_generation_for_owner, +}; use crate::{ api_response::json_success_body, character_animation_assets::{ @@ -31,14 +34,12 @@ use crate::{ EDITOR_ASSET_FOLDER_ID_PREFIX, EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE, EDITOR_PROJECT_ID_PREFIX, EDITOR_RESOURCE_ID_PREFIX, EditorAssetFolderPayload, EditorAssetLibraryPayload, EditorAssetPayload, EditorCanvasViewportPayload, - EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, - EditorImageGenerationRequest, EditorProjectPayload, EditorProjectResourcePayload, - EditorUiDesignAssetExtractionRequest, current_utc_micros, - editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record, - editor_asset_payload_from_record, editor_project_payload_from_record, - editor_project_resource_payload_from_record, - enqueue_editor_icon_spritesheet_generation_for_owner, enqueue_editor_image_edit_for_owner, - enqueue_editor_image_generation_for_owner, + EditorGenerationCaller, EditorImageEditRequest, EditorImageGenerationRequest, + EditorProjectPayload, EditorProjectResourcePayload, EditorUiDesignAssetExtractionRequest, + current_utc_micros, editor_asset_folder_payload_from_record, + editor_asset_library_payload_from_record, editor_asset_payload_from_record, + editor_project_payload_from_record, editor_project_resource_payload_from_record, + enqueue_editor_image_edit_for_owner, enqueue_editor_image_generation_for_owner, enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, parse_editor_generation_json_payload, sanitize_editor_untrusted_generation_inputs, @@ -1370,6 +1371,18 @@ mod tests { .get("/api/external/v1/editor/icon-spritesheets/generations") .is_some() ); + let icon_spritesheet_request = + &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]; + assert!( + icon_spritesheet_request["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("referenceId"))) + ); + assert!( + icon_spritesheet_request["properties"] + .get("referenceImageSrc") + .is_none() + ); let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"] ["properties"]["style"]; assert_eq!(icon_style_schema["anyOf"][0]["type"], "string"); diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 73097118e..15de47172 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -34,19 +34,21 @@ use crate::{ config::AppConfig, editor_generation_queue::{ EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_BACKGROUND_REMOVAL_JOB_KIND, - EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, + EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_ICON_SPEC_GENERATION_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, }, editor_project::{ EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EditorBackgroundRemovalRequest, - EditorGenerationCaller, EditorGenerationPhaseReporter, - EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, + EditorGenerationCaller, EditorGenerationPhaseReporter, EditorImageEditRequest, EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest, edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner, - generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, - remove_editor_image_background_for_owner, + generate_editor_image_for_owner, remove_editor_image_background_for_owner, + }, + editor_project_icon::{ + EditorIconSpecGenerationRequest, EditorIconSpritesheetGenerationRequest, + generate_editor_icon_spritesheet_for_owner, generate_icon_spec_for_owner, }, request_context::RequestContext, state::AppState, @@ -793,6 +795,42 @@ async fn process_external_generation_job_once( } } } + EDITOR_ICON_SPEC_GENERATION_JOB_KIND => { + let payload = match serde_json::from_str::( + job.request_payload_json.as_str(), + ) { + Ok(payload) => payload, + Err(error) => { + let message = format!("图片画布图标规范任务参数解析失败:{error}"); + fail_job(&state, &worker_id, &job, message.clone()).await?; + return Err(message); + } + }; + let request_context = worker_request_context(&job, provider_deadline); + match generate_icon_spec_for_owner( + &state, + &request_context, + editor_generation_worker_caller(&worker_id, &job)?, + payload, + ) + .await + { + Ok(response) => { + complete_editor_generation_job_with_response( + &state, + &worker_id, + &job, + &response.0, + ) + .await + } + Err(error) => { + let message = error.body_text(); + fail_job(&state, &worker_id, &job, message.clone()).await?; + Err(message) + } + } + } EDITOR_IMAGE_EDIT_JOB_KIND => { let payload = match serde_json::from_str::( job.request_payload_json.as_str(), @@ -1699,6 +1737,7 @@ fn external_generation_worker_job_timeout(config: &AppConfig, job_kind: &str) -> match job_kind { EDITOR_IMAGE_GENERATION_JOB_KIND | EDITOR_IMAGE_EDIT_JOB_KIND + | EDITOR_ICON_SPEC_GENERATION_JOB_KIND | EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND | EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND | EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND @@ -2403,6 +2442,10 @@ mod tests { external_generation_worker_job_timeout(&config, EDITOR_IMAGE_EDIT_JOB_KIND), Duration::from_secs(600) ); + assert_eq!( + external_generation_worker_job_timeout(&config, EDITOR_ICON_SPEC_GENERATION_JOB_KIND), + Duration::from_secs(600) + ); assert_eq!( external_generation_worker_job_timeout( &config, diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index c59287ceb..7d0914408 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -26,7 +26,7 @@ mod editor_generation_config; mod editor_generation_queue; mod editor_green_screen; mod editor_project; -mod editor_project_icon_specs; +mod editor_project_icon; mod editor_screen_background_decision; mod editor_screen_background_filter; mod error_middleware; diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 86b1a0eaa..73470da0f 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -18,15 +18,17 @@ use crate::{ EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES, create_editor_asset, create_editor_asset_folder, create_editor_project, create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder, delete_editor_project, edit_editor_image, - extract_editor_ui_design_assets, generate_editor_icon_spritesheet, generate_editor_image, - get_editor_asset_library, get_editor_generation_pricing, get_editor_project, - list_editor_projects, list_public_editor_project_resources, load_recent_editor_project, + extract_editor_ui_design_assets, generate_editor_image, get_editor_asset_library, + get_editor_generation_pricing, get_editor_project, list_editor_projects, + list_public_editor_project_resources, load_recent_editor_project, remove_editor_image_background, rename_editor_project, save_editor_project_layout, - snap_editor_image_to_pixel_art, split_editor_icon_spritesheet, - submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset, - update_editor_asset_folder, update_editor_project_resource_showcase, + snap_editor_image_to_pixel_art, submit_editor_asset_showcase, + toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder, + update_editor_project_resource_showcase, + }, + editor_project_icon::{ + generate_editor_icon_spritesheet, generate_icon_spec, split_editor_icon_spritesheet, }, - editor_project_icon_specs::generate_icon_spec, llm::icon_specs::{refine_art_style, refine_game_play}, state::AppState, }; diff --git a/server-rs/crates/api-server/src/prompt/icon_spec.rs b/server-rs/crates/api-server/src/prompt/icon_spec.rs index fe2fc6675..b1a506fc2 100644 --- a/server-rs/crates/api-server/src/prompt/icon_spec.rs +++ b/server-rs/crates/api-server/src/prompt/icon_spec.rs @@ -1,4 +1,4 @@ -use super::super::editor_project_icon_specs::{ExtraParam, GameGenre, RequestParam}; +use super::super::editor_project_icon::{ExtraParam, GameGenre, RequestParam}; use crate::editor_green_screen::EditorScreenBackgroundColor; pub const PROMPT_MAX_LEN: usize = 200; diff --git a/server-rs/crates/platform-editor-agent/src/agent/asset.rs b/server-rs/crates/platform-editor-agent/src/agent/asset.rs index f35c6c69f..a83aa4c17 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/asset.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/asset.rs @@ -39,6 +39,7 @@ impl Display for ImageId { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageMetadata { pub data_key: String, + pub reference_id: Option, pub image_src: String, pub object_key: Option, pub thumbnail_src: Option, diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs index 96229eee0..79d4f12e0 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs @@ -257,6 +257,7 @@ mod tests { reference_image_id.clone(), ImageMetadata { data_key: "asset://reference-image".to_string(), + reference_id: Some("resource-reference-image".to_string()), image_src: "asset://reference-image".to_string(), object_key: None, thumbnail_src: None, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs index 261882edf..e686c67fb 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs @@ -4,8 +4,7 @@ use super::*; pub struct EditorReferenceResolveRecordInput { pub owner_user_id: String, pub bucket: String, - pub reference_id: Option, - pub object_key: Option, + pub reference_id: String, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -22,7 +21,6 @@ impl From owner_user_id: input.owner_user_id, bucket: input.bucket, reference_id: input.reference_id, - object_key: input.object_key, } } } diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index ac7a2bb3c..228cc4891 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -4,8 +4,7 @@ use super::*; pub struct EditorReferenceResolveRecordInput { pub owner_user_id: String, pub bucket: String, - pub reference_id: Option, - pub object_key: Option, + pub reference_id: String, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -21,7 +20,6 @@ impl From for crate::module_bindings::EditorR owner_user_id: input.owner_user_id, bucket: input.bucket, reference_id: input.reference_id, - object_key: input.object_key, } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs index 54e8cb22d..34f4046be 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_type.rs @@ -114,7 +114,6 @@ impl __sdk::__query_builder::HasCols for EditorAsset { pub struct EditorAssetIxCols { pub asset_id: __sdk::__query_builder::IxCol, pub folder_id: __sdk::__query_builder::IxCol, - pub image_src: __sdk::__query_builder::IxCol, pub owner_user_id: __sdk::__query_builder::IxCol, } @@ -124,7 +123,6 @@ impl __sdk::__query_builder::HasIxCols for EditorAsset { EditorAssetIxCols { asset_id: __sdk::__query_builder::IxCol::new(table_name, "asset_id"), folder_id: __sdk::__query_builder::IxCol::new(table_name, "folder_id"), - image_src: __sdk::__query_builder::IxCol::new(table_name, "image_src"), owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs index 7bae75f13..4ef4a0a43 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_type.rs @@ -97,7 +97,6 @@ impl __sdk::__query_builder::HasCols for EditorProjectResource { /// /// Provides typed access to indexed columns for query building. pub struct EditorProjectResourceIxCols { - pub image_src: __sdk::__query_builder::IxCol, pub owner_user_id: __sdk::__query_builder::IxCol, pub project_id: __sdk::__query_builder::IxCol, pub resource_id: __sdk::__query_builder::IxCol, @@ -107,7 +106,6 @@ impl __sdk::__query_builder::HasIxCols for EditorProjectResource { type IxCols = EditorProjectResourceIxCols; fn ix_cols(table_name: &'static str) -> Self::IxCols { EditorProjectResourceIxCols { - image_src: __sdk::__query_builder::IxCol::new(table_name, "image_src"), owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), project_id: __sdk::__query_builder::IxCol::new(table_name, "project_id"), resource_id: __sdk::__query_builder::IxCol::new(table_name, "resource_id"), diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_reference_resolve_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_reference_resolve_input_type.rs index 310ca57db..67bfc6ccc 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_reference_resolve_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_reference_resolve_input_type.rs @@ -9,8 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub struct EditorReferenceResolveInput { pub owner_user_id: String, pub bucket: String, - pub reference_id: Option, - pub object_key: Option, + pub reference_id: String, } impl __sdk::InModule for EditorReferenceResolveInput { diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index ca89fa1ff..8cdf3c495 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -221,8 +221,7 @@ pub struct EditorCanvasLayoutMigration { #[spacetimedb::table( accessor = editor_project_resource, index(accessor = by_editor_project_resource_project_id, btree(columns = [project_id])), - index(accessor = by_editor_project_resource_owner_user_id, btree(columns = [owner_user_id])), - index(accessor = by_editor_project_resource_image_src, btree(columns = [image_src])) + index(accessor = by_editor_project_resource_owner_user_id, btree(columns = [owner_user_id])) )] #[derive(Clone)] pub struct EditorProjectResource { @@ -271,8 +270,7 @@ pub struct EditorAssetFolder { #[spacetimedb::table( accessor = editor_asset, index(accessor = by_editor_asset_owner_user_id, btree(columns = [owner_user_id])), - index(accessor = by_editor_asset_folder_id, btree(columns = [folder_id])), - index(accessor = by_editor_asset_image_src, btree(columns = [image_src])) + index(accessor = by_editor_asset_folder_id, btree(columns = [folder_id])) )] pub struct EditorAsset { #[primary_key] @@ -611,8 +609,7 @@ pub struct EditorProjectResourceMediaRepairInput { pub struct EditorReferenceResolveInput { pub owner_user_id: String, pub bucket: String, - pub reference_id: Option, - pub object_key: Option, + pub reference_id: String, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -6113,60 +6110,27 @@ fn resolve_editor_reference( if owner_user_id.is_empty() || bucket.is_empty() { return Err("编辑器引用的 owner_user_id 与 bucket 不能为空".to_string()); } - let reference_id = input - .reference_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - let object_key = input - .object_key - .as_deref() - .map(str::trim) - .map(|value| value.trim_start_matches('/')) - .filter(|value| !value.is_empty()); - if reference_id.is_some() == object_key.is_some() { - return Err("编辑器引用必须且只能提供 reference_id 或 object_key".to_string()); + let reference_id = input.reference_id.trim(); + if reference_id.is_empty() { + return Err("编辑器引用的 reference_id 不能为空".to_string()); } - - if let Some(reference_id) = reference_id { - if let Some(resource) = ctx - .db - .editor_project_resource() - .resource_id() - .find(&reference_id.to_string()) - { - return resolve_editor_reference_resource(ctx, bucket, owner_user_id, &resource); - } - if let Some(asset) = ctx - .db - .editor_asset() - .asset_id() - .find(&reference_id.to_string()) - { - return resolve_editor_reference_asset(ctx, bucket, owner_user_id, asset); - } - return Err("编辑器引用不存在".to_string()); - } - - let object_key = object_key.expect("reference shape checked above"); - let image_src = format!("/{object_key}"); - for resource in ctx + if let Some(resource) = ctx .db .editor_project_resource() - .by_editor_project_resource_image_src() - .filter(&image_src) + .resource_id() + .find(&reference_id.to_string()) { return resolve_editor_reference_resource(ctx, bucket, owner_user_id, &resource); } - for asset in ctx + if let Some(asset) = ctx .db .editor_asset() - .by_editor_asset_image_src() - .filter(&image_src) + .asset_id() + .find(&reference_id.to_string()) { return resolve_editor_reference_asset(ctx, bucket, owner_user_id, asset); } - Err("编辑器引用未关联资源或素材记录".to_string()) + Err("编辑器引用不存在".to_string()) } fn resolve_editor_reference_resource( diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 2deab2ee7..108506eeb 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -2701,7 +2701,7 @@ describe('ImageCanvasEditorView generation integration', () => { await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ - referenceImageSrc: 'resource-icon-spec', + referenceId: 'resource-icon-spec', iconDescriptions: ['返回按钮\n设置按钮'], model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', @@ -3171,7 +3171,7 @@ describe('ImageCanvasEditorView generation integration', () => { await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ - referenceImageSrc: 'resource-editor-icon-spec-renamed-1', + referenceId: 'resource-editor-icon-spec-renamed-1', iconDescriptions: ['背包按钮'], model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 40f83a78f..d71f4bed5 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -243,7 +243,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { input: { playSetting: '休闲消除', artStyle: '清爽卡通', - referenceImageSrcs: ['data:image/png;base64,ref'], + referenceId: 'resource-spec-ref', generationInputs: { fields: [ { title: '玩法设定', value: '休闲消除' }, @@ -262,6 +262,20 @@ describe('ImageCanvasGenerationSubmissionModel', () => { result: { assetKind: 'icon-spec', title: '图标规范 8', + generationInputs: { + fields: [ + { title: '玩法设定', value: '休闲消除' }, + { title: '美术风格', value: '清爽卡通' }, + ], + references: [ + { + title: '参考图', + label: '参考.png', + refType: 'project-resource', + refId: 'resource-spec-ref', + }, + ], + }, }, }); expect(plan.kind === 'icon-spec' ? plan.result.generationInputs : null).toEqual( @@ -733,7 +747,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { ok: true, iconDescriptions: ['返回按钮\n\n设置按钮'], input: { - referenceImageSrc: 'data:image/png;base64,spec', + referenceId: 'resource-icon-spec', referenceImageSrcs: ['data:image/png;base64,ref'], iconDescriptions: ['返回按钮\n\n设置按钮'], model: 'gpt-image-2', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index b9548a630..0844f1830 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -91,6 +91,25 @@ export function resolveImageReferenceSubmissionSource(reference: { return reference.objectKey?.trim() || reference.src; } +export function resolveRegisteredEditorReferenceId(reference: { + resourceId?: string | null; + sourceAssetId?: string | null; +}) { + const resourceId = reference.resourceId?.trim(); + if ( + resourceId && + !resourceId.startsWith('local-resource-') && + !resourceId.startsWith('generation-dialog:') + ) { + return resourceId; + } + const assetId = reference.sourceAssetId?.trim(); + if (assetId) { + return assetId; + } + throw new Error('参考图尚未登记为项目资源或素材,请重新选择或上传后再试'); +} + function buildSeedanceVideoReferenceInput( references: NonNullable, ) { @@ -318,9 +337,9 @@ export function buildImageGenerationSubmissionPlan({ artStyle: specValues.artStyle.trim(), ...(dialog.specReference?.src ? { - referenceImageSrcs: [ - resolveImageReferenceSubmissionSource(dialog.specReference), - ], + referenceId: resolveRegisteredEditorReferenceId( + dialog.specReference, + ), } : {}), generationInputs, @@ -664,9 +683,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan( ok: true, iconDescriptions, input: { - referenceImageSrc: resolveImageReferenceSubmissionSource( - dialog.iconSpecReference, - ), + referenceId: resolveRegisteredEditorReferenceId(dialog.iconSpecReference), ...(dialog.generationReferences?.length ? { referenceImageSrcs: dialog.generationReferences.map((reference) => diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index ffb5ddf63..22c2f9b56 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -2667,6 +2667,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { id: 'spec', label: '图标规范', src: 'data:image/png;base64,c3BlYw==', + resourceId: 'resource-icon-spec', }, iconDescriptions: ['旧返回', '旧设置'], placeholder: { @@ -2687,8 +2688,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ - referenceImageSrc: - 'generated-character-drafts/editor/generation-references/reference.png', + referenceId: 'resource-icon-spec', iconDescriptions: ['返回按钮\n设置按钮'], model: 'gpt-image-2', assetLabel: '复古操作图标', @@ -2754,6 +2754,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { id: 'spec', label: '图标规范', src: 'data:image/png;base64,c3BlYw==', + resourceId: 'resource-icon-spec', }, iconDescriptions: [], placeholder: { @@ -2779,7 +2780,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); }); - it('submits uploaded objectKey icon references before submitting spritesheets', async () => { + it('submits registered icon spec IDs and keeps objectKey extra references', async () => { generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, @@ -2806,6 +2807,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { src: 'https://signed.example.test/icon-spec.png?expires=1', objectKey: 'generated-character-drafts/editor/specs/icon-spec.png', assetObjectId: 'assetobj-icon-spec', + resourceId: 'resource-icon-spec', }, generationReferences: [ { @@ -2830,8 +2832,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ).not.toHaveBeenCalled(); expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ - referenceImageSrc: - 'generated-character-drafts/editor/specs/icon-spec.png', + referenceId: 'resource-icon-spec', referenceImageSrcs: [ 'generated-character-drafts/editor/refs/icon-style.png', ], diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 405863df0..b0816e6a7 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -81,6 +81,7 @@ import { buildIconSpritesheetGenerationSubmissionPlan, buildImageGenerationSubmissionPlan, resolveGenerationAssetLabel, + resolveRegisteredEditorReferenceId, } from './ImageCanvasGenerationSubmissionModel'; import type { UiAssetExtractionMark, @@ -1273,11 +1274,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ try { const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); - const referenceImageSrc = await resolveEditorGenerationMediaReference( - iconSpecReference, - 'image', - projectId, - ); + const referenceId = resolveRegisteredEditorReferenceId(iconSpecReference); const referenceImageSrcs = await Promise.all( (dialog.generationReferences ?? []).map((reference) => resolveEditorGenerationMediaReference( @@ -1290,7 +1287,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ const generated = await runEditorGenerationWithWalletRefresh( generateEditorIconSpritesheet({ ...submissionPlan.input, - referenceImageSrc, + referenceId, ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), projectId, generationInputs: submissionPlan.generationInputs, @@ -1943,11 +1940,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ submissionPlan.result.title, ); } else if (submissionPlan.kind === 'icon-spec') { - const iconSpecInput = await normalizeGenerationReferenceImages( - submissionPlan.input, - resolveImageGenerationDialogReferences(dialog), - projectId, - ); + const iconSpecInput = submissionPlan.input; const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); const generated = await runEditorGenerationWithWalletRefresh( diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 388646173..49e4623dc 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -900,7 +900,7 @@ describe('editorProjectClient', () => { ).rejects.toThrow('生成参考图最多允许 5 张'); await expect( generateEditorIconSpritesheet({ - referenceImageSrc: '/generated-images/editor/spec.png', + referenceId: 'editor-resource-spec', referenceImageSrcs: references.slice(0, 5), iconDescriptions: ['返回按钮'], model: 'gpt-image-2', @@ -935,7 +935,7 @@ describe('editorProjectClient', () => { ); await generateEditorIconSpritesheet({ - referenceImageSrc: '/generated-images/editor/icon-spec.png', + referenceId: 'editor-resource-icon-spec', referenceImageSrcs: references, iconDescriptions: ['返回按钮'], }); @@ -1118,7 +1118,7 @@ describe('editorProjectClient', () => { }); const result = await generateEditorIconSpritesheet({ - referenceImageSrc: '/generated-images/editor/spec.png', + referenceId: 'editor-resource-spec', referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], assetLabel: '冒险游戏图标', @@ -1135,7 +1135,7 @@ describe('editorProjectClient', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - referenceImageSrc: '/generated-images/editor/spec.png', + referenceId: 'editor-resource-spec', referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], model: 'gemini-3.1-flash-image-preview', @@ -1164,7 +1164,7 @@ describe('editorProjectClient', () => { }); await generateEditorIconSpritesheet({ - referenceImageSrc: '/generated-images/editor/spec.png', + referenceId: 'editor-resource-spec', iconDescriptions: ['返回按钮'], model: 'gpt-image-2', screenColor: '#E6D8FF', @@ -1186,7 +1186,7 @@ describe('editorProjectClient', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - referenceImageSrc: '/generated-images/editor/spec.png', + referenceId: 'editor-resource-spec', iconDescriptions: ['返回按钮'], model: 'gpt-image-2', screenColor: '#E6D8FF', @@ -1452,20 +1452,6 @@ describe('editorProjectClient', () => { expect(requestJsonMock).not.toHaveBeenCalled(); }); - it('rejects excess icon spec references before dispatch', async () => { - await expect( - generateEditorIconSpec({ - playSetting: '回合制占点', - artStyle: '低多边形', - referenceImageSrcs: Array.from( - { length: 6 }, - (_, index) => `resource-reference-${index + 1}`, - ), - }), - ).rejects.toThrow('图标规范参考图最多允许 5 张,当前选择 6 张'); - expect(requestJsonMock).not.toHaveBeenCalled(); - }); - it('submits icon spec business fields without a client prompt or image kind', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,spec', @@ -1476,7 +1462,7 @@ describe('editorProjectClient', () => { await generateEditorIconSpec({ playSetting: '回合制占点', artStyle: '低多边形', - referenceImageSrcs: ['resource-reference-1'], + referenceId: 'resource-reference-1', projectId: 'editor-project-1', generationInputs: { fields: [], @@ -1498,7 +1484,7 @@ describe('editorProjectClient', () => { expect(body).toEqual({ playSetting: '回合制占点', artStyle: '低多边形', - referenceImageSrcs: ['resource-reference-1'], + referenceId: 'resource-reference-1', projectId: 'editor-project-1', generationInputs: { fields: [], diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index e2969149a..e3280b659 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -258,7 +258,7 @@ export type EditorImageGenerationInput = { export type EditorIconSpecGenerationInput = { playSetting: string; artStyle: string; - referenceImageSrcs?: string[]; + referenceId?: string; projectId?: string | null; generationInputs?: EditorAssetGenerationInputs | null; assetFolderId?: string | null; @@ -281,7 +281,7 @@ export type EditorCanvasGenerationCompletionInput = { }; export type EditorIconSpritesheetGenerationInput = { - referenceImageSrc: string; + referenceId: string; referenceImageSrcs?: string[]; iconDescriptions: string[]; model?: string; @@ -1105,20 +1105,12 @@ export async function generateEditorIconSpec( input.artStyle, '美术风格', ); - assertStableEditorMediaReferences(input.referenceImageSrcs, '图标规范参考图'); - assertEditorReferenceLimit( - input.referenceImageSrcs, - EDITOR_IMAGE_REFERENCE_LIMIT, - '图标规范参考图', - ); return requestJson( EDITOR_ICON_SPEC_GENERATION_API, jsonRequest('POST', { playSetting, artStyle, - ...(input.referenceImageSrcs?.length - ? { referenceImageSrcs: input.referenceImageSrcs } - : {}), + ...(input.referenceId ? { referenceId: input.referenceId } : {}), ...(input.projectId ? { projectId: input.projectId } : {}), ...(input.generationInputs ? { generationInputs: input.generationInputs } @@ -1143,7 +1135,6 @@ export async function generateEditorIconSpritesheet( input: EditorIconSpritesheetGenerationInput, ) { const model = input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2; - assertStableEditorMediaReference(input.referenceImageSrc, '图标素材规范'); assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图'); assertEditorReferenceLimit( input.referenceImageSrcs, @@ -1156,7 +1147,7 @@ export async function generateEditorIconSpritesheet( return requestJson( EDITOR_ICON_SPRITESHEET_GENERATION_API, jsonRequest('POST', { - referenceImageSrc: input.referenceImageSrc, + referenceId: input.referenceId, ...(input.referenceImageSrcs?.length ? { referenceImageSrcs: input.referenceImageSrcs } : {}),