From 55ff47c6c43c57a4ec55ae3f16fdbbcf3cac65d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 16:44:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=9B=BE=E6=A0=87=E5=9B=BE?= =?UTF-8?q?=E9=9B=86=E6=8F=90=E7=A4=BA=E8=AF=8D=E8=BE=93=E5=85=A5=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增独立的 spritesheet prompt 单项字符、聚合字符和 UTF-8 字节上限。 以已验证类型约束 prompt builder,并覆盖 inline、队列与 worker 执行边界。 同步画布校验、Editor Agent 工具合同、External v1 OpenAPI、文档和边界测试。 --- .../genarrative-external-v1.openapi.json | 7 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 2 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 4 +- .../crates/api-server/src/editor_project.rs | 23 +++- .../api-server/src/editor_project_icon.rs | 125 +++++++++++++++--- .../api-server/src/external_editor_api.rs | 18 +++ .../crates/api-server/src/prompt/icon_spec.rs | 13 +- .../agent/tools/generate_icon_spritesheet.rs | 121 ++++++++++++++++- ...ageCanvasGenerationSubmissionModel.test.ts | 20 +++ .../ImageCanvasGenerationSubmissionModel.ts | 7 + ...CanvasIconSpritesheetComposerView.test.tsx | 21 +++ ...ImageCanvasIconSpritesheetComposerView.tsx | 11 +- .../image-editor/editorProjectClient.test.ts | 39 ++++++ .../image-editor/editorProjectClient.ts | 43 +++++- 15 files changed, 424 insertions(+), 32 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 7ad9a3aa1..6d1ad60df 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3378,11 +3378,14 @@ }, "iconDescriptions": { "type": "array", - "description": "图标生成需求文本数组,供 prompt 组装使用;数组长度不控制自动切片数量。画布前端把完整用户提示词作为唯一数组元素提交;其它调用方可继续提交 1 到 100 条非空文本。", + "description": "图标生成需求文本数组,供 prompt 组装使用;数组长度不控制自动切片数量。画布前端把完整用户提示词作为唯一数组元素提交;其它调用方可继续提交 1 到 100 条非空文本。服务端去空并以换行拼接后,单条最多 200 个 Unicode 字符,合计最多 2000 个 Unicode 字符且不超过 6144 个 UTF-8 字节;超限同步返回 400,不会入队或计费。", "minItems": 1, "maxItems": 100, + "x-genarrative-maxTotalCharacters": 2000, + "x-genarrative-maxTotalUtf8Bytes": 6144, "items": { - "type": "string" + "type": "string", + "maxLength": 200 } }, "screenColor": { diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 11ac15a00..94a2cb31c 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -243,7 +243,7 @@ npm run check:server-rs-ddd 6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。 7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。 8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。 -9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标规范分析、润色、规范图生图和图标 spritesheet 提示词中的请求文本必须统一按 XML 文本节点转义 `& < > " '`,不得把只经过 trim 或长度检查的原始值直接插入标签或后续生图提示词。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 +9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标规范分析、润色、规范图生图和图标 spritesheet 提示词中的请求文本必须统一按 XML 文本节点转义 `& < > " '`,不得把只经过 trim 或长度检查的原始值直接插入标签或后续生图提示词。图标 spritesheet 的 `iconDescriptions` 还必须在请求边界执行独立合同:去空后 `1..100` 条、单条最多 `200` 个 Unicode 字符、换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节;只有 `ValidatedEditorIconSpritesheetPrompt` 能进入 prompt builder,External v1 超限同步返回 `400`。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。 11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。 12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值,检测到 owner 项目中已存在同一稳定 task/resource 的历史结果时则复用该服务端既存 metadata 完成精确 compare-and-return。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 2e97546a0..82bfef397 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -38,7 +38,7 @@ - 生成角色:`你希望角色如何设计?` - 生成 UI:`你希望这个 UI 长什么样?` - 生成视频:`你希望生成什么视频?` -8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。 +8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框;该完整文本按 Unicode 字符限制为 `200`,输入时按 code point 截断,不能用 UTF-16 `maxLength` 误截 emoji。 9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。 10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填;每项独立支持一键优化、处理中锁定自身、成功后单次撤销,手工编辑后立即清除该字段已经失效的撤销快照,失败只保留当前文本与仍然有效的旧撤销快照。LLM 返回空文本、超长文本或 Markdown / 结构化内容时,后续有界重试必须携带上次无效输出和对应修正要求,不能原样重放同一请求。优化请求必须绑定发起时的生成对象 ID 和请求代次;活动对象身份只在 React effect 提交后更新,并在 cleanup 中失效,丢弃的并发 render 不得改变请求归属;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。控件继续使用平台默认样式,不新增图标规范专属 CSS。 11. 图标规范最终生成改走 `POST /api/editor/icon-specs/generations`。前端只提交业务字段和统一参考图 / 项目完成包络,不拼最终 prompt,不提交 `kind / assetKind / ExtraParam`;后端固定图片参数。HTTP handler 先调用可复用的图片请求预检,完成参考图稳定性、owner 授权、Provider 配置和运行时定价校验;全部通过后才调用文本 LLM 补齐 `ExtraParam` 和最终 prompt,再把完整图片请求交给既有 `editor_image_generation` inline / queue 分流。不得为图标规范新增独立外部任务类型;最终 worker 仍按执行时事实重新校验,避免排队期间状态变化产生 TOCTOU。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 7254461d8..81db42fe7 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -40,11 +40,11 @@ ## 生成契约 - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 -- inline 与持久队列入口共用同一份 `iconDescriptions` 归一化:去除空白项后必须保留 `1..100` 条;队列入口必须在定价和任务持久化前同步拒绝可预测的数量错误,不能把无效任务留给 worker 延迟失败。 +- inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:去除空白项后必须保留 `1..100` 条,单条最多 `200` 个 Unicode 字符,以换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节。请求边界校验成功后生成 `ValidatedEditorIconSpritesheetPrompt`,后续 prompt builder 不接受裸字符串。队列入口必须在引用解析、定价和任务持久化前同步拒绝可预测错误,不能把无效任务留给 worker 延迟失败。 - worker 解析主 `referenceImageSrc` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:资源 ID / 素材 ID 走主键,对象键按规范化 `image_src="/"` 索引定位单条资源或素材,并通过 `asset_object(bucket, object_key)` 复合索引校验对象 owner。procedure 复用既有 `EditorProjectResourceSnapshot` 或 `EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"` 与 `genre` 都由图标图集业务代码从返回行校验和提取。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`。 - 请求字段: - `referenceImageSrc`:图标规范的稳定引用(当前账号的 `objectKey`、项目资源 ID 或素材 ID);本地临时图必须先上传 OSS,禁止 Data URL / Blob URL。 - - `iconDescriptions`:兼容现有接口的图标需求数组,`1..100`;当前画布前端固定把完整文本作为唯一数组元素提交。数组长度只表达请求文本,不作为自动拆分数量。 + - `iconDescriptions`:兼容现有接口的图标需求数组,`1..100`;当前画布前端固定把完整文本作为唯一数组元素提交。数组长度只表达请求文本,不作为自动拆分数量;单项、聚合字符和 UTF-8 字节上限按上一条 prompt 合同执行。 - `model`:支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。 - `aspectRatio`:按 `x:y` 展示,选项跟随模型。 - `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 3c07e2a2b..a64b2eeec 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -14535,20 +14535,39 @@ mod tests { "设置按钮".to_string(), ]) .expect("valid descriptions should pass"); - assert_eq!(descriptions, vec!["返回按钮", "设置按钮"]); + assert_eq!(descriptions.descriptions(), ["返回按钮", "设置按钮"]); + assert_eq!(descriptions.as_str(), "返回按钮\n设置按钮"); let too_many = (0..101) .map(|index| format!("图标{index}")) .collect::>(); assert!(normalize_icon_descriptions(too_many).is_err()); + let too_long = vec!["图".repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)]; + let error = + normalize_icon_descriptions(too_long).expect_err("overlong description should fail"); + assert!(error.body_text().contains("每条图标素材描述")); + + let aggregate_too_long = (0..EDITOR_ICON_DESCRIPTION_LIMIT) + .map(|_| "图".repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS)) + .collect::>(); + let error = normalize_icon_descriptions(aggregate_too_long) + .expect_err("overlong aggregate should fail"); + assert!(error.body_text().contains("合计")); + + let utf8_too_large = vec!["😀".repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS); 8]; + let error = normalize_icon_descriptions(utf8_too_large) + .expect_err("oversized UTF-8 aggregate should fail"); + assert!(error.body_text().contains("UTF-8")); + let source = include_str!("editor_project_icon.rs"); assert_function_contains_in_order( source, "pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner", "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", &[ - "payload.icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;", + "payload.icon_descriptions =", + "normalize_icon_descriptions(payload.icon_descriptions)?.into_descriptions();", "resolve_editor_icon_spec_reference(", "resolve_editor_icon_spritesheet_price(", "enqueue_editor_generation_job_for_caller(", diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 11cc3e2ee..bdf6f94f0 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -66,7 +66,10 @@ 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_DESCRIPTION_LIMIT: usize = 100; +pub(crate) const EDITOR_ICON_DESCRIPTION_MAX_CHARS: usize = 200; +pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS: usize = 2_000; +pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES: usize = 6 * 1024; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048; const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 64; @@ -111,25 +114,114 @@ static EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER: std::sync::LazyLock< )) }); +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ValidatedEditorIconSpritesheetPrompt { + descriptions: Vec, + prompt: String, +} + +impl ValidatedEditorIconSpritesheetPrompt { + pub(crate) fn descriptions(&self) -> &[String] { + self.descriptions.as_slice() + } + + pub(crate) fn as_str(&self) -> &str { + self.prompt.as_str() + } + + pub(crate) fn into_descriptions(self) -> Vec { + self.descriptions + } +} + 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 { +) -> Result { + let mut normalized = Vec::with_capacity(descriptions.len().min(EDITOR_ICON_DESCRIPTION_LIMIT)); + let mut total_chars = 0usize; + let mut total_utf8_bytes = 0usize; + for (item_index, description) in descriptions.into_iter().enumerate() { + let description = description.trim(); + if description.is_empty() { + continue; + } + if normalized.len() >= EDITOR_ICON_DESCRIPTION_LIMIT { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-icon-spritesheet", + "field": "iconDescriptions", + "maxItems": EDITOR_ICON_DESCRIPTION_LIMIT, + "message": "图标素材描述数量必须在 1 到 100 个之间。", + })), + ); + } + let actual_length = description.chars().count(); + if actual_length > EDITOR_ICON_DESCRIPTION_MAX_CHARS { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-icon-spritesheet", + "field": "iconDescriptions", + "itemIndex": item_index, + "maxLength": EDITOR_ICON_DESCRIPTION_MAX_CHARS, + "actualLength": actual_length, + "message": format!( + "每条图标素材描述不能超过 {EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符。" + ), + })), + ); + } + let separator_length = usize::from(!normalized.is_empty()); + let next_total_chars = total_chars + .saturating_add(separator_length) + .saturating_add(actual_length); + if next_total_chars > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-icon-spritesheet", + "field": "iconDescriptions", + "maxTotalLength": EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS, + "actualTotalLength": next_total_chars, + "message": format!( + "图标素材描述合计不能超过 {EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符。" + ), + })), + ); + } + let next_total_utf8_bytes = total_utf8_bytes + .saturating_add(separator_length) + .saturating_add(description.len()); + if next_total_utf8_bytes > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-icon-spritesheet", + "field": "iconDescriptions", + "maxTotalUtf8Bytes": EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES, + "actualTotalUtf8Bytes": next_total_utf8_bytes, + "message": format!( + "图标素材描述合计不能超过 {EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节。" + ), + })), + ); + } + total_chars = next_total_chars; + total_utf8_bytes = next_total_utf8_bytes; + normalized.push(description.to_string()); + } + if normalized.is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-icon-spritesheet", "field": "iconDescriptions", + "minItems": 1, "message": "图标素材描述数量必须在 1 到 100 个之间。", })), ); } - Ok(normalized) + let prompt = normalized.join("\n"); + Ok(ValidatedEditorIconSpritesheetPrompt { + descriptions: normalized, + prompt, + }) } pub(crate) async fn resolve_editor_icon_spritesheet_price( @@ -1099,7 +1191,8 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( ) -> Result { payload.generation_inputs = sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); - payload.icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; + payload.icon_descriptions = + normalize_icon_descriptions(payload.icon_descriptions)?.into_descriptions(); resolve_editor_icon_spec_reference( state, caller.owner_user_id.as_str(), @@ -1198,8 +1291,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( ), ) .await?; - let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; - let user_prompt = icon_descriptions.join("\n"); + let spritesheet_prompt = normalize_icon_descriptions(payload.icon_descriptions)?; + let user_prompt = spritesheet_prompt.as_str().to_string(); let resolved_icon_spec = resolve_editor_icon_spec_reference( state, caller.owner_user_id.as_str(), @@ -1270,8 +1363,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( EditorScreenBackgroundDecisionInput { kind: EditorScreenBackgroundDecisionKind::IconSpritesheet, screen_color: requested_screen_color.clone(), - prompt: user_prompt.clone(), - icon_descriptions: icon_descriptions.clone(), + prompt: spritesheet_prompt.as_str().to_string(), + icon_descriptions: spritesheet_prompt.descriptions().to_vec(), reference_count, source_image_data_url: None, }, @@ -1291,7 +1384,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .await?; let screen_color = screen_background_decision.color; let prompt = crate::prompt::icon_spec::build_spritesheet_prompt( - user_prompt.as_str(), + &spritesheet_prompt, screen_color, icon_spec_genre, ); 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 3f09ff616..22f1d9b56 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -1383,6 +1383,24 @@ mod tests { .get("referenceImageSrc") .is_none() ); + let icon_descriptions = &icon_spritesheet_request["properties"]["iconDescriptions"]; + assert_eq!( + icon_descriptions["items"]["maxLength"], + crate::editor_project_icon::EDITOR_ICON_DESCRIPTION_MAX_CHARS + ); + assert_eq!( + icon_descriptions["x-genarrative-maxTotalCharacters"], + crate::editor_project_icon::EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS + ); + assert_eq!( + icon_descriptions["x-genarrative-maxTotalUtf8Bytes"], + crate::editor_project_icon::EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES + ); + assert!( + icon_descriptions["description"] + .as_str() + .is_some_and(|description| description.contains("同步返回 400")) + ); 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/prompt/icon_spec.rs b/server-rs/crates/api-server/src/prompt/icon_spec.rs index b1a506fc2..f947b7c2b 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,6 @@ -use super::super::editor_project_icon::{ExtraParam, GameGenre, RequestParam}; +use super::super::editor_project_icon::{ + ExtraParam, GameGenre, RequestParam, ValidatedEditorIconSpritesheetPrompt, +}; use crate::editor_green_screen::EditorScreenBackgroundColor; pub const PROMPT_MAX_LEN: usize = 200; @@ -176,11 +178,11 @@ pub(crate) fn get_gen_icon_spec_prompt( } pub(crate) fn build_spritesheet_prompt( - user_prompt: &str, + user_prompt: &ValidatedEditorIconSpritesheetPrompt, screen_color: EditorScreenBackgroundColor, genre: Option, ) -> String { - let normalized_prompt = escape_xml_prompt_data(user_prompt.trim()); + let normalized_prompt = escape_xml_prompt_data(user_prompt.as_str()); let background_color = format!( "{label} {hex}", label = screen_color.label, @@ -331,8 +333,11 @@ mod tests { assert!(extra_prompt.contains(format!("{escaped}").as_str())); assert!(extra_prompt.contains(format!("{escaped}").as_str())); + let spritesheet_input = + crate::editor_project_icon::normalize_icon_descriptions(vec![malicious.to_string()]) + .expect("bounded spritesheet prompt should pass"); let spritesheet_prompt = build_spritesheet_prompt( - malicious, + &spritesheet_input, crate::editor_green_screen::default_editor_screen_background_color(), Some(GameGenre::Strategy), ); 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 79d4f12e0..55f451d15 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 @@ -28,6 +28,9 @@ pub enum GenerateIconSpritesheetError { ReferenceNotProvided, DescriptionsNotProvided, TooManyDescriptions(usize), + DescriptionTooLong { index: usize, actual_chars: usize }, + DescriptionsTooLong(usize), + DescriptionsTooManyBytes(usize), AssetNotFound(ImageId), } @@ -51,6 +54,24 @@ impl Display for GenerateIconSpritesheetError { "icon description count must be between 1 and {}, got {count}", GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS ), + Self::DescriptionTooLong { + index, + actual_chars, + } => write!( + f, + "icon description at index {index} must not exceed {} Unicode characters, got {actual_chars}", + GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTION_CHARS + ), + Self::DescriptionsTooLong(actual_chars) => write!( + f, + "icon descriptions must not exceed {} Unicode characters in total, got {actual_chars}", + GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS_TOTAL_CHARS + ), + Self::DescriptionsTooManyBytes(actual_bytes) => write!( + f, + "icon descriptions must not exceed {} UTF-8 bytes in total, got {actual_bytes}", + GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS_TOTAL_UTF8_BYTES + ), Self::AssetNotFound(image_id) => write!(f, "asset {image_id} not found in context"), } } @@ -131,7 +152,18 @@ impl Tool for GenerateIconSpritesheetTool { "reference_image_id": { "type": "string", "description": "必填的图标规范或风格参考图 ID。" }, "model": image_model_parameter_schema(), "reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "image_id(s) for desc" }, - "icon_descriptions": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS, "description": "要生成的多个图标描述,数量必须在 1 到 100 个之间。" }, + "icon_descriptions": { + "type": "array", + "items": { + "type": "string", + "maxLength": GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTION_CHARS + }, + "minItems": 1, + "maxItems": GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS, + "x-genarrative-maxTotalCharacters": GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS_TOTAL_CHARS, + "x-genarrative-maxTotalUtf8Bytes": GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS_TOTAL_UTF8_BYTES, + "description": "要生成的多个图标描述。去空后必须为 1 到 100 条;单条最多 200 个 Unicode 字符,拼接换行后合计最多 2000 个 Unicode 字符且不超过 6144 个 UTF-8 字节。" + }, "aspect_ratio": image_aspect_ratio_parameter_schema(), "image_size": image_size_parameter_schema() }, @@ -167,7 +199,10 @@ impl Tool for GenerateIconSpritesheetTool { | GenerateIconSpritesheetError::InvalidImageSize { .. } | GenerateIconSpritesheetError::ReferenceNotProvided | GenerateIconSpritesheetError::DescriptionsNotProvided - | GenerateIconSpritesheetError::TooManyDescriptions(_) => { + | GenerateIconSpritesheetError::TooManyDescriptions(_) + | GenerateIconSpritesheetError::DescriptionTooLong { .. } + | GenerateIconSpritesheetError::DescriptionsTooLong(_) + | GenerateIconSpritesheetError::DescriptionsTooManyBytes(_) => { ToolFailure::invalid_args(error.to_string()) } } @@ -176,6 +211,9 @@ impl Tool for GenerateIconSpritesheetTool { impl GenerateIconSpritesheetTool { pub const MAX_ICON_DESCRIPTIONS: usize = 100; + pub const MAX_ICON_DESCRIPTION_CHARS: usize = 200; + pub const MAX_ICON_DESCRIPTIONS_TOTAL_CHARS: usize = 2_000; + pub const MAX_ICON_DESCRIPTIONS_TOTAL_UTF8_BYTES: usize = 6 * 1024; pub fn validate_args( &self, @@ -207,6 +245,40 @@ impl GenerateIconSpritesheetTool { description_count, )); } + let mut normalized_count = 0usize; + let mut total_chars = 0usize; + let mut total_utf8_bytes = 0usize; + for (index, description) in args.icon_descriptions.iter().enumerate() { + let description = description.trim(); + if description.is_empty() { + continue; + } + let actual_chars = description.chars().count(); + if actual_chars > Self::MAX_ICON_DESCRIPTION_CHARS { + return Err(GenerateIconSpritesheetError::DescriptionTooLong { + index, + actual_chars, + }); + } + let separator_length = usize::from(normalized_count > 0); + total_chars = total_chars + .saturating_add(separator_length) + .saturating_add(actual_chars); + if total_chars > Self::MAX_ICON_DESCRIPTIONS_TOTAL_CHARS { + return Err(GenerateIconSpritesheetError::DescriptionsTooLong( + total_chars, + )); + } + total_utf8_bytes = total_utf8_bytes + .saturating_add(separator_length) + .saturating_add(description.len()); + if total_utf8_bytes > Self::MAX_ICON_DESCRIPTIONS_TOTAL_UTF8_BYTES { + return Err(GenerateIconSpritesheetError::DescriptionsTooManyBytes( + total_utf8_bytes, + )); + } + normalized_count += 1; + } for image_id in &args.reference_image_ids { if !self.context.contains_image(image_id) { return Err(GenerateIconSpritesheetError::AssetNotFound( @@ -309,5 +381,50 @@ mod tests { schema["properties"]["icon_descriptions"]["maxItems"], json!(100) ); + assert_eq!( + schema["properties"]["icon_descriptions"]["items"]["maxLength"], + json!(GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTION_CHARS) + ); + assert_eq!( + schema["properties"]["icon_descriptions"]["x-genarrative-maxTotalCharacters"], + json!(GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS_TOTAL_CHARS) + ); + assert_eq!( + schema["properties"]["icon_descriptions"]["x-genarrative-maxTotalUtf8Bytes"], + json!(GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS_TOTAL_UTF8_BYTES) + ); + } + + #[test] + fn validates_icon_description_item_and_aggregate_limits() { + let (tool, args) = tool_and_args(vec![ + "图".repeat(GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTION_CHARS + 1), + ]); + assert!(matches!( + tool.validate_args(&args), + Err(GenerateIconSpritesheetError::DescriptionTooLong { index: 0, .. }) + )); + + let (tool, args) = tool_and_args(vec![ + "图".repeat( + GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTION_CHARS + ); + 11 + ]); + assert!(matches!( + tool.validate_args(&args), + Err(GenerateIconSpritesheetError::DescriptionsTooLong(_)) + )); + + let (tool, args) = tool_and_args(vec![ + "😀".repeat( + GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTION_CHARS + ); + 8 + ]); + assert!(matches!( + tool.validate_args(&args), + Err(GenerateIconSpritesheetError::DescriptionsTooManyBytes(_)) + )); } } diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index d71f4bed5..dcdc5b472 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { IMAGE_MODEL_GPT_IMAGE_2, @@ -716,6 +717,25 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); + it('returns an icon spritesheet error when the complete prompt is too long', () => { + const plan = buildIconSpritesheetGenerationSubmissionPlan({ + mode: 'icon', + prompt: '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1), + status: 'idle', + iconSpecReference: { + id: 'icon-spec', + label: '图标规范', + src: 'data:image/png;base64,spec', + resourceId: 'resource-icon-spec', + }, + }); + + expect(plan).toEqual({ + ok: false, + errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`, + }); + }); + it('builds icon spritesheet plans with the complete prompt and references', () => { const plan = buildIconSpritesheetGenerationSubmissionPlan({ mode: 'icon', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 0844f1830..5af28dcdb 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -8,6 +8,7 @@ import type { EditorSoundEffectGenerationInput, EditorVideoGenerationInput, } from '../../services/image-editor/editorProjectClient'; +import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient'; import type { CanvasGenerationInputs, CanvasLayer, @@ -675,6 +676,12 @@ export function buildIconSpritesheetGenerationSubmissionPlan( errorMessage: '请填写素材描述', }; } + if (Array.from(normalizedPrompt).length > EDITOR_ICON_DESCRIPTION_MAX_CHARS) { + return { + ok: false, + errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`, + }; + } const rememberImageModel = normalizeEditorImageModel(dialog.imageModel); const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR; diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx index 10b6eb788..5fbfc58be 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import { createRef, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient'; import type { GenerateDialogState, UploadTarget, @@ -206,6 +207,26 @@ describe('ImageCanvasIconSpritesheetComposerView', () => { expect(submitIconGeneration).toHaveBeenCalledWith(dialog); }); + it('limits description input by Unicode characters', () => { + const updateIconDescriptionText = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByLabelText('素材描述'), { + target: { + value: '😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1), + }, + }); + + expect(updateIconDescriptionText).toHaveBeenCalledWith( + '😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), + ); + }); + it('keeps image option changes inside the icon dialog', () => { const rememberImageModel = vi.fn(); render( diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx index 2e55408fe..07037a9c1 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx @@ -7,6 +7,7 @@ import { type SetStateAction, } from 'react'; +import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient'; import { AutoGrowTextArea } from '../common/AutoGrowTextArea'; import { PlatformFloatingMenu, @@ -23,6 +24,10 @@ import { calculateEditorIconSpritesheetPrice } from './ImageCanvasGenerationMode import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss'; +function limitIconDescriptionText(value: string) { + return Array.from(value).slice(0, EDITOR_ICON_DESCRIPTION_MAX_CHARS).join(''); +} + type ImageCanvasIconSpritesheetComposerViewProps = { dialog: GenerateDialogState; style: CSSProperties; @@ -216,7 +221,11 @@ export function ImageCanvasIconSpritesheetComposerView({ disabled={dialog.status === 'generating'} placeholder="你需要哪些图标?" className="image-canvas-editor__generation-prompt" - onChange={(event) => onUpdateIconDescriptionText(event.target.value)} + onChange={(event) => + onUpdateIconDescriptionText( + limitIconDescriptionText(event.target.value), + ) + } /> {dialog.status === 'failed' ? ( diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 49e4623dc..db7512098 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -9,6 +9,9 @@ import { deleteEditorAssetFolder, deleteEditorProject, editEditorImage, + EDITOR_ICON_DESCRIPTION_MAX_CHARS, + EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS, + EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES, EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH, extractEditorUiDesignAssets, generateEditorBackgroundMusic, @@ -1150,6 +1153,42 @@ describe('editorProjectClient', () => { ); }); + it('rejects icon descriptions outside the item and aggregate contracts before requesting', async () => { + await expect( + generateEditorIconSpritesheet({ + referenceId: 'editor-resource-spec', + iconDescriptions: ['图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)], + }), + ).rejects.toThrow(`不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`); + + await expect( + generateEditorIconSpritesheet({ + referenceId: 'editor-resource-spec', + iconDescriptions: [ + '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), + ...Array.from({ length: 10 }, () => + '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), + ), + ], + }), + ).rejects.toThrow( + `合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`, + ); + + await expect( + generateEditorIconSpritesheet({ + referenceId: 'editor-resource-spec', + iconDescriptions: Array.from({ length: 8 }, () => + '😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), + ), + }), + ).rejects.toThrow( + `合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`, + ); + + expect(requestJsonMock).not.toHaveBeenCalled(); + }); + it('passes image model options to icon spritesheet generation', async () => { requestJsonMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index e3280b659..7b1ea5d8f 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -14,6 +14,10 @@ const EDITOR_ICON_SPEC_REFINE_GAME_PLAY_API = const EDITOR_ICON_SPEC_REFINE_ART_STYLE_API = '/api/editor/llm/icon-specs/refine-art-style'; export const EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH = 200; +export const EDITOR_ICON_DESCRIPTION_LIMIT = 100; +export const EDITOR_ICON_DESCRIPTION_MAX_CHARS = 200; +export const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS = 2_000; +export const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES = 6 * 1024; const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits'; const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals'; const EDITOR_PIXEL_ART_SNAP_API = '/api/editor/images/pixel-art-snaps'; @@ -87,6 +91,40 @@ function assertEditorReferenceLimit( } } +function normalizeEditorIconDescriptions(values: readonly string[]) { + const normalized = values.map((value) => value.trim()).filter(Boolean); + if ( + normalized.length < 1 || + normalized.length > EDITOR_ICON_DESCRIPTION_LIMIT + ) { + throw new Error( + `图标素材描述数量必须在 1 到 ${EDITOR_ICON_DESCRIPTION_LIMIT} 个之间`, + ); + } + normalized.forEach((description, index) => { + const actualLength = Array.from(description).length; + if (actualLength > EDITOR_ICON_DESCRIPTION_MAX_CHARS) { + throw new Error( + `第 ${index + 1} 条图标素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`, + ); + } + }); + const prompt = normalized.join('\n'); + const totalLength = Array.from(prompt).length; + if (totalLength > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS) { + throw new Error( + `图标素材描述合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`, + ); + } + const totalUtf8Bytes = new TextEncoder().encode(prompt).byteLength; + if (totalUtf8Bytes > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES) { + throw new Error( + `图标素材描述合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`, + ); + } + return normalized; +} + function assertStableEditorVideoReferences(input: EditorVideoGenerationInput) { const references = [ ...(input.referenceImageSrcs ?? []), @@ -1134,6 +1172,9 @@ export async function generateEditorIconSpec( export async function generateEditorIconSpritesheet( input: EditorIconSpritesheetGenerationInput, ) { + const iconDescriptions = normalizeEditorIconDescriptions( + input.iconDescriptions, + ); const model = input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2; assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图'); assertEditorReferenceLimit( @@ -1151,7 +1192,7 @@ export async function generateEditorIconSpritesheet( ...(input.referenceImageSrcs?.length ? { referenceImageSrcs: input.referenceImageSrcs } : {}), - iconDescriptions: input.iconDescriptions, + iconDescriptions, model, ...(input.screenColor ? { screenColor: input.screenColor } : {}), ...(input.segModel ? { segModel: input.segModel } : {}),