收紧图标图集提示词输入契约
新增独立的 spritesheet prompt 单项字符、聚合字符和 UTF-8 字节上限。 以已验证类型约束 prompt builder,并覆盖 inline、队列与 worker 执行边界。 同步画布校验、Editor Agent 工具合同、External v1 OpenAPI、文档和边界测试。
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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="/<objectKey>"` 索引定位单条资源或素材,并通过 `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` 展示,选项跟随模型。
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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(",
|
||||
|
||||
@@ -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<String>,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
impl ValidatedEditorIconSpritesheetPrompt {
|
||||
pub(crate) fn descriptions(&self) -> &[String] {
|
||||
self.descriptions.as_slice()
|
||||
}
|
||||
|
||||
pub(crate) fn as_str(&self) -> &str {
|
||||
self.prompt.as_str()
|
||||
}
|
||||
|
||||
pub(crate) fn into_descriptions(self) -> Vec<String> {
|
||||
self.descriptions
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_icon_descriptions(
|
||||
descriptions: Vec<String>,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
let normalized = descriptions
|
||||
.into_iter()
|
||||
.map(|description| description.trim().to_string())
|
||||
.filter(|description| !description.is_empty())
|
||||
.take(EDITOR_ICON_DESCRIPTION_LIMIT.saturating_add(1))
|
||||
.collect::<Vec<_>>();
|
||||
if normalized.is_empty() || normalized.len() > EDITOR_ICON_DESCRIPTION_LIMIT {
|
||||
) -> Result<ValidatedEditorIconSpritesheetPrompt, AppError> {
|
||||
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<ExternalGenerationJobRecord, AppError> {
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<GameGenre>,
|
||||
) -> 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!("<playSetting>{escaped}</playSetting>").as_str()));
|
||||
assert!(extra_prompt.contains(format!("<artStyle>{escaped}</artStyle>").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),
|
||||
);
|
||||
|
||||
+119
-2
@@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
<IconComposerHarness
|
||||
initialDialog={createIconDialog({ prompt: '' })}
|
||||
onUpdateIconDescriptionText={updateIconDescriptionText}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{dialog.status === 'failed' ? (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user