Update editor generation pricing defaults
Unify and lower default mud-point prices across editor generation features and update related API/schema, docs and tests. Changes set gpt-image-2 to 1K=3 / 2K=5, audio1.0 to 5, and chirp-v5 to 12, and update server default JSON, frontend models/workflows, OpenAPI schema (require aspectRatio/imageSize/priceMudPoints for UI extraction), multiple UX docs (quick-edit viewport, UI extraction panel, pricing guidance) and accompanying tests to match the new defaults and validation rules.
This commit is contained in:
@@ -28,7 +28,7 @@ const pricing: EditorGenerationPricingConfigPayload = {
|
||||
},
|
||||
'gpt-image-2': {
|
||||
unit: 'perGeneration',
|
||||
prices: {'1K': 20, '2K': 40},
|
||||
prices: {'1K': 3, '2K': 5},
|
||||
},
|
||||
'seedance2.0-fast': {
|
||||
unit: 'perSecond',
|
||||
@@ -38,8 +38,8 @@ const pricing: EditorGenerationPricingConfigPayload = {
|
||||
unit: 'perSecond',
|
||||
prices: {'480p': 12, '720p': 24, '1080p': 48},
|
||||
},
|
||||
'audio1.0': {unit: 'perGeneration', price: 10},
|
||||
'chirp-v5': {unit: 'perGeneration', price: 5},
|
||||
'audio1.0': {unit: 'perGeneration', price: 5},
|
||||
'chirp-v5': {unit: 'perGeneration', price: 12},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -2838,13 +2838,33 @@
|
||||
"EditorUiDesignAssetExtractionRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sourceImageSrc"
|
||||
"sourceImageSrc",
|
||||
"aspectRatio",
|
||||
"imageSize",
|
||||
"priceMudPoints"
|
||||
],
|
||||
"properties": {
|
||||
"sourceImageSrc": {
|
||||
"type": "string",
|
||||
"description": "UI 设计图 Data URL。"
|
||||
},
|
||||
"aspectRatio": {
|
||||
"type": "string",
|
||||
"const": "1:1",
|
||||
"description": "UI 素材提取固定使用 1:1。"
|
||||
},
|
||||
"imageSize": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"1K",
|
||||
"2K"
|
||||
],
|
||||
"description": "框选数量少时使用 1K,超过阈值时使用 2K。"
|
||||
},
|
||||
"priceMudPoints": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"projectId": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-22 编辑器生成模型默认定价调整
|
||||
|
||||
- 背景:图片画布生成按钮、后端 `priceMudPoints` 校验和后台定价页需要统一使用新的模型默认泥点。
|
||||
- 决策:`audio1.0` 默认按次 `5` 泥点,`chirp-v5` 默认按次 `12` 泥点,`gpt-image-2` 默认 `1K=3`、`2K=5` 泥点;运行态仍允许后台 override 覆盖,前端兜底必须与后端默认 JSON 保持一致。
|
||||
- 影响范围:`editor-generation-pricing.default.json`、`ImageCanvasGenerationModel.ts`、后台定价页 fixture、后端价格校验和编辑器定价文档。
|
||||
- 验证方式:运行 `editor_generation_config`、公开定价路由、图标素材价格校验、图片画布定价模型和后台定价页相关测试。
|
||||
- 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## 2026-06-21 图片画布生成完成态由后端写入画布布局
|
||||
|
||||
- 背景:图片画布角色形象等长耗时生成在服务端完成后,如果浏览器已刷新或原 HTTP 回调丢失,前端无法再把生成结果图层和 `generation-dialog` 完成态写回 `editor_canvas.layers_json`,用户会继续看到“生成中”卡片。
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## 本地 dev 启动日志先看成功锚点,不要把非阻断 warning 当失败
|
||||
|
||||
- 现象:`npm run dev` 启动 SpacetimeDB 时可能先打印 `static max level is off`、`Skipping tokio metrics`,或 SpacetimeDB CLI 提示存在新版本 / 当前版本较旧,看起来像启动异常。
|
||||
- 原因:这些是 tracing、metrics 或 CLI 更新提示,不代表本地 dev 栈失败;同一段日志后续仍可能已经完成 `SpacetimeDB listening on 127.0.0.1:3101`、模块 publish、`api-server` `/healthz` 200、主站 Vite `3000` 和后台 Vite `3102` ready。
|
||||
- 处理:排查本地 dev 栈时先确认成功锚点:`[dev:spacetime] actual`、`Updated database`、`api-server 已完成 tracing 初始化并开始监听`、`/healthz` 200、两个 Vite `ready`。只有缺少这些锚点或进程退出时,再继续查 CLI 权限、端口占用、publish 或 API 编译问题。
|
||||
- 验证:`http://127.0.0.1:3101/v1/ping` 可访问、`http://127.0.0.1:8082/healthz` 返回 200、`http://127.0.0.1:3000/` 和 `http://127.0.0.1:3102/admin/` 可打开。
|
||||
- 关联:`scripts/dev.mjs`、`.app/dev-stack.json`、`docs/project-memory/shared-memory/development-workflow.md`。
|
||||
|
||||
## 图片画布角色动作结果不能把首帧当主媒体
|
||||
|
||||
- 现象:画板里 `生成角色动作` 返回后显示成一张不可播放图片,点击下载或素材 ZIP 导出时拿到的也是 PNG,而不是动作视频。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -28,13 +28,14 @@
|
||||
},
|
||||
"gpt-image-2": {
|
||||
"unit": "perGeneration",
|
||||
"prices": { "1K": 20, "2K": 40 }
|
||||
"prices": { "1K": 3, "2K": 5 }
|
||||
},
|
||||
"seedance2.0-fast": {
|
||||
"unit": "perSecond",
|
||||
"prices": { "480p": 10, "720p": 20, "1080p": 40 }
|
||||
},
|
||||
"audio1.0": { "unit": "perGeneration", "price": 10 }
|
||||
"audio1.0": { "unit": "perGeneration", "price": 5 },
|
||||
"chirp-v5": { "unit": "perGeneration", "price": 12 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
|
||||
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
|
||||
10. 快速编辑最多允许额外绑定 8 张参考图;原图始终自动作为最后一张隐式参考图提交,不占用额外参考图额度,也不在参考图条里固定展示 `图x`。
|
||||
11. 快速编辑打开后,画布视口应调整到面板位于原图下方且不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。
|
||||
11. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态,再点同一工具取消启用。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。
|
||||
|
||||
## 参数交互
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
- 泥点配置默认值独立收口到 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,JSON 结构为 `models[model] = { unit, price | prices }`;后台“模型定价”页面通过 `POST /admin/api/editor-generation-pricing` 保存完整 override 到 `.app/editor-generation-pricing.override.json`(可由 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 覆盖路径),主站通过 `GET /api/editor/generation-pricing` 动态读取当前配置。后台必须展示定价单位:`perGeneration` 显示“按次”,`perSecond` 显示“按秒”。
|
||||
- 生成图标素材面板提交 `POST /api/editor/icon-spritesheets/generations` 时必须携带 `priceMudPoints`,取同一份 `icon` 计费配置;后端用 `editor_generation_config` 校验,不允许绕过配置继续生成。
|
||||
- 生成视频、角色动画、音效和背景音乐提交时携带的 `priceMudPoints` 也必须由同一模型配置函数计算,后端按归一后的模型、清晰度、时长或音频模型重新计算并校验。
|
||||
- `提取素材` 是 UI 设计图工具栏动作,不是面板生成按钮;它固定使用 `gpt-image-2` 和图标素材拆分链路,后端响应里的 `priceMudPoints` 仍必须来自 `editor_generation_config`,不能写死。
|
||||
- `提取素材` 点击后进入 UI 素材提取态:右侧框选工具对齐底部工具栏按钮风格,素材下方显示与生成新素材一致宽度的提取面板。提取面板显示短提示语、框选截图预览、固定模型 `gpt-image-2`、计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮;前后端提交 `aspectRatio / imageSize / priceMudPoints`,后端按 `editor_generation_config` 校验,不能写死。
|
||||
- 画板 UI 统一显示 `nanobanana2`,它代表上游真实模型 `gemini-3.1-flash-image-preview`;历史输入或旧布局中的 `nano-banana` 必须先归一为 `nanobanana2` 对应的真实模型 ID 后再提交和计费。定价表仍以真实模型 ID `gemini-3.1-flash-image-preview` 和 `gpt-image-2` 为准,别名不能作为新增正式模型绕过定价表。
|
||||
|
||||
## 画布占位落点
|
||||
@@ -118,11 +118,11 @@
|
||||
## 第一版计费配置
|
||||
|
||||
```text
|
||||
图片模型按尺寸单次计价:nanobanana2 / gemini-3.1-flash-image-preview 为 0.5K 8 泥点、1K 12 泥点、2K 24 泥点;gpt-image-2 为 1K 20 泥点、2K 40 泥点
|
||||
图片模型按尺寸单次计价:nanobanana2 / gemini-3.1-flash-image-preview 为 0.5K 8 泥点、1K 12 泥点、2K 24 泥点;gpt-image-2 为 1K 3 泥点、2K 5 泥点
|
||||
生成规范不再单独定价:固定读取 gpt-image-2 的 2K 定价
|
||||
视频 / 角色动作按同一视频模型分辨率每秒计价:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20 / 1080p 每秒 40(生成视频入口仍禁止 fast 选 1080p);seedance2.0 为 480p 每秒 12 / 720p 每秒 24 / 1080p 每秒 48;kling3.0 为 480p 每秒 15 / 720p 每秒 30 / 1080p 每秒 60;kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40 / 1080p 每秒 80
|
||||
生成音效:audio1.0 按次 10 泥点
|
||||
生成背景音乐:chirp-v5 按次 5 泥点
|
||||
生成音效:audio1.0 按次 5 泥点
|
||||
生成背景音乐:chirp-v5 按次 12 泥点
|
||||
```
|
||||
|
||||
当前必须显式覆盖的正式模型定价配置(默认值来自 `editor-generation-pricing.default.json`,运行时可由后台 override 修改):
|
||||
@@ -174,7 +174,7 @@
|
||||
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
|
||||
- 生成规范类图片底部展示禁用态参数按钮 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。
|
||||
- 快速编辑底部展示当前选择的比例 / 尺寸和模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮,并允许展开修改;额外参考图最多 8 张,原图作为隐式最后参考图参与提交但不在参考图条里固定显示 `图x`。
|
||||
- 快速编辑打开后面板位于原图下方且不遮挡原图,原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮;完成框选后画布红框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。
|
||||
- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并显示选中态,再点同一工具取消;完成框选后画布红框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。
|
||||
- 快速编辑提交前必须把提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写成最后一张原图编号,例如额外 1 张参考图时改为 `图2`。
|
||||
- 快速编辑提交给后端的参考图顺序必须是额外参考图在前、原图在最后;若用户已完成红色圈选,最后一张隐式原图必须替换为绘制了红框和序号的标注图。
|
||||
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
|
||||
@@ -182,7 +182,7 @@
|
||||
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
|
||||
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
|
||||
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
|
||||
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后进入红框素材框选编辑态,默认矩形框选并支持椭圆和画笔自由框选。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet,后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
|
||||
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,点击同一工具可取消启用态;面板显示短提示语、框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet。框选数量不超过阈值时按 `1:1·1K` 和 gpt-image-2 1K 泥点提交,超过阈值时改用 `1:1·2K` 和 2K 泥点;后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
|
||||
- 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。
|
||||
- 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。
|
||||
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
- `model`:支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。
|
||||
- `aspectRatio`:按 `x:y` 展示,选项跟随模型。
|
||||
- `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。
|
||||
- `priceMudPoints`:按当前模型从编辑器生成计费配置计算;`nanobanana2` 为 `12`,`gpt-image-2` 为 `20`。前端只提交配置函数计算值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死价格。
|
||||
- `priceMudPoints`:按当前模型和尺寸从编辑器生成计费配置计算;`nanobanana2 1K` 为 `12`,`gpt-image-2 1K` 为 `3`、`gpt-image-2 2K` 为 `5`。前端只提交配置函数计算值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死价格。
|
||||
- 模型与尺寸选项:
|
||||
- `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig`;`0.5K` 按 VectorEngine 文档传 `"512"`。
|
||||
- `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标规范图作为 multipart `image`,按 `size` 映射:`1K 1:1 -> 1024x1024`、`1K 2:3/9:16 -> 1024x1536`、`1K 3:2/16:9 -> 1536x1024`、`2K 1:1 -> 2048x2048`、`2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`。
|
||||
@@ -77,4 +77,4 @@
|
||||
- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。
|
||||
- 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。
|
||||
- 生成成功后画布出现 spritesheet 图集和按描述命名的多个透明图标素材图层,图层之间不重叠。
|
||||
- 生成图标素材提交体包含按模型计算的 `priceMudPoints`;`nanobanana2` 应为 `12`,`gpt-image-2` 应为 `20`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
|
||||
- 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
"gpt-image-2": {
|
||||
"unit": "perGeneration",
|
||||
"prices": {
|
||||
"1K": 20,
|
||||
"2K": 40
|
||||
"1K": 3,
|
||||
"2K": 5
|
||||
}
|
||||
},
|
||||
"seedance2.0-fast": {
|
||||
@@ -65,11 +65,11 @@
|
||||
},
|
||||
"audio1.0": {
|
||||
"unit": "perGeneration",
|
||||
"price": 10
|
||||
"price": 5
|
||||
},
|
||||
"chirp-v5": {
|
||||
"unit": "perGeneration",
|
||||
"price": 5
|
||||
"price": 12
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1526,6 +1526,9 @@ mod tests {
|
||||
let source_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024));
|
||||
let request_body = serde_json::json!({
|
||||
"sourceImageSrc": source_image_src,
|
||||
"aspectRatio": "1:1",
|
||||
"imageSize": "1K",
|
||||
"priceMudPoints": 3,
|
||||
})
|
||||
.to_string();
|
||||
assert!(request_body.len() > 2 * 1024 * 1024);
|
||||
@@ -1560,6 +1563,44 @@ mod tests {
|
||||
assert!(!body_text.contains("length limit exceeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_ui_design_asset_extraction_rejects_wrong_price_before_image_config() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138189", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_ui_extract_price");
|
||||
let app = build_router(state);
|
||||
let request_body = serde_json::json!({
|
||||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||||
"aspectRatio": "1:1",
|
||||
"imageSize": "2K",
|
||||
"priceMudPoints": 3,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/ui-designs/assets/extractions")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(request_body))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
assert!(body_text.contains("priceMudPoints"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_image_generation_accepts_reference_body_above_default_limit() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
@@ -4394,9 +4435,21 @@ mod tests {
|
||||
payload["models"]["gemini-3.1-flash-image-preview"]["prices"]["1K"],
|
||||
Value::Number(12.into())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["models"]["gpt-image-2"]["prices"]["1K"],
|
||||
Value::Number(3.into())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["models"]["gpt-image-2"]["prices"]["2K"],
|
||||
Value::Number(40.into())
|
||||
Value::Number(5.into())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["models"]["audio1.0"]["price"],
|
||||
Value::Number(5.into())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["models"]["chirp-v5"]["price"],
|
||||
Value::Number(12.into())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["models"]["seedance2.0"]["unit"],
|
||||
|
||||
@@ -596,17 +596,25 @@ mod tests {
|
||||
),
|
||||
12
|
||||
);
|
||||
assert_eq!(
|
||||
editor_image_generation_mud_points(
|
||||
Some("ui-design"),
|
||||
Some("gpt-image-2"),
|
||||
Some("1K")
|
||||
),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
editor_image_generation_mud_points(
|
||||
Some("ui-design"),
|
||||
Some("gpt-image-2"),
|
||||
Some("2K")
|
||||
),
|
||||
40
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
editor_image_generation_mud_points(Some("spec"), Some("gpt-image-2"), Some("2K")),
|
||||
40
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
@@ -630,11 +638,11 @@ mod tests {
|
||||
fn editor_audio_generation_price_uses_configured_model_rates() {
|
||||
assert_eq!(
|
||||
editor_sound_effect_model_generation_mud_points(Some("audio1.0")),
|
||||
10
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
editor_background_music_model_generation_mud_points(Some("chirp-v5")),
|
||||
5
|
||||
12
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,9 @@ pub struct EditorIconSpritesheetGenerationRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorUiDesignAssetExtractionRequest {
|
||||
pub(crate) source_image_src: String,
|
||||
pub(crate) aspect_ratio: String,
|
||||
pub(crate) image_size: String,
|
||||
pub(crate) price_mud_points: u32,
|
||||
pub(crate) project_id: Option<String>,
|
||||
pub(crate) generation_inputs: Option<Value>,
|
||||
pub(crate) asset_folder_id: Option<String>,
|
||||
@@ -1630,6 +1633,15 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
"message": "UI设计图必须是图片 Data URL。",
|
||||
}))
|
||||
})?;
|
||||
let generation_options = normalize_editor_ui_design_asset_extraction_options(
|
||||
payload.aspect_ratio.as_str(),
|
||||
payload.image_size.as_str(),
|
||||
)?;
|
||||
let expected_price_mud_points = validate_editor_ui_design_asset_extraction_price(
|
||||
state,
|
||||
payload.price_mud_points,
|
||||
Some(generation_options.image_size),
|
||||
)?;
|
||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||
request_context,
|
||||
caller.audit_subject_user_id.clone(),
|
||||
@@ -1645,7 +1657,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
GPT_IMAGE_2_MODEL,
|
||||
EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT,
|
||||
None,
|
||||
EDITOR_IMAGE_GENERATION_SIZE,
|
||||
generation_options.size.as_str(),
|
||||
1,
|
||||
&[reference_image],
|
||||
"图片画布提取UI设计图素材 spritesheet",
|
||||
@@ -1746,15 +1758,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
|
||||
model: GPT_IMAGE_2_MODEL.to_string(),
|
||||
provider: "VectorEngine",
|
||||
task_id: generated.task_id,
|
||||
price_mud_points: state
|
||||
.editor_generation_pricing()
|
||||
.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"), Some(GPT_IMAGE_2_MODEL), Some("1K")),
|
||||
price_mud_points: expected_price_mud_points,
|
||||
spritesheet_resource: spritesheet_record.resource,
|
||||
spritesheet_asset: spritesheet_record.asset,
|
||||
},
|
||||
@@ -2307,6 +2311,60 @@ fn validate_editor_icon_spritesheet_price(
|
||||
Ok(expected_price_mud_points)
|
||||
}
|
||||
|
||||
fn editor_ui_design_asset_extraction_bad_request(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-ui-design-asset-extraction",
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn normalize_editor_ui_design_asset_extraction_options(
|
||||
aspect_ratio: &str,
|
||||
image_size: &str,
|
||||
) -> Result<EditorGenerationOptions, AppError> {
|
||||
if aspect_ratio.trim() != "1:1" {
|
||||
return Err(editor_ui_design_asset_extraction_bad_request(
|
||||
"aspectRatio 必须固定为 1:1。",
|
||||
));
|
||||
}
|
||||
let normalized_image_size = match image_size.trim().to_ascii_uppercase().as_str() {
|
||||
"1K" => "1K",
|
||||
"2K" => "2K",
|
||||
_ => {
|
||||
return Err(editor_ui_design_asset_extraction_bad_request(
|
||||
"imageSize 仅支持 1K 或 2K。",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(normalize_editor_generation_options(
|
||||
Some(GPT_IMAGE_2_MODEL),
|
||||
Some("1:1"),
|
||||
Some(normalized_image_size),
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_editor_ui_design_asset_extraction_price(
|
||||
state: &AppState,
|
||||
price_mud_points: u32,
|
||||
image_size: Option<&str>,
|
||||
) -> Result<u32, AppError> {
|
||||
let expected_price_mud_points = state
|
||||
.editor_generation_pricing()
|
||||
.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"), Some(GPT_IMAGE_2_MODEL), image_size);
|
||||
if price_mud_points != expected_price_mud_points {
|
||||
return Err(editor_ui_design_asset_extraction_bad_request(format!(
|
||||
"priceMudPoints 与 UI 素材提取计费配置不一致,应为 {expected_price_mud_points}。"
|
||||
)));
|
||||
}
|
||||
Ok(expected_price_mud_points)
|
||||
}
|
||||
|
||||
fn build_editor_icon_spritesheet_prompt(icon_descriptions: &[String]) -> String {
|
||||
format!(
|
||||
"参考图1的图标素材规范,单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}",
|
||||
@@ -3248,10 +3306,10 @@ mod tests {
|
||||
12
|
||||
);
|
||||
assert_eq!(
|
||||
validate_editor_icon_spritesheet_price(&state, 20, Some("gpt-image-2"), Some("1K")).unwrap(),
|
||||
20
|
||||
validate_editor_icon_spritesheet_price(&state, 3, Some("gpt-image-2"), Some("1K")).unwrap(),
|
||||
3
|
||||
);
|
||||
let error = validate_editor_icon_spritesheet_price(&state, 5, Some("gpt-image-2"), Some("1K"))
|
||||
let error = validate_editor_icon_spritesheet_price(&state, 20, Some("gpt-image-2"), Some("1K"))
|
||||
.expect_err("wrong icon spritesheet price should fail");
|
||||
assert!(error.body_text().contains("priceMudPoints"));
|
||||
let response = EditorIconSpritesheetGenerationResponse {
|
||||
@@ -3284,6 +3342,51 @@ mod tests {
|
||||
assert_eq!(payload["priceMudPoints"], json!(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_ui_design_asset_extraction_price_uses_selected_gpt_image_size() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let request: EditorUiDesignAssetExtractionRequest = serde_json::from_value(json!({
|
||||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||||
"aspectRatio": "1:1",
|
||||
"imageSize": "2K",
|
||||
"priceMudPoints": 5
|
||||
}))
|
||||
.expect("UI extraction request should deserialize dimensions and price");
|
||||
let generation_options = normalize_editor_ui_design_asset_extraction_options(
|
||||
request.aspect_ratio.as_str(),
|
||||
request.image_size.as_str(),
|
||||
)
|
||||
.expect("UI extraction dimensions should pass");
|
||||
|
||||
assert_eq!(generation_options.model, GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(generation_options.size, "2048x2048");
|
||||
assert_eq!(
|
||||
validate_editor_ui_design_asset_extraction_price(
|
||||
&state,
|
||||
request.price_mud_points,
|
||||
Some(generation_options.image_size)
|
||||
)
|
||||
.unwrap(),
|
||||
5
|
||||
);
|
||||
let ratio_error = match normalize_editor_ui_design_asset_extraction_options("16:9", "2K")
|
||||
{
|
||||
Ok(_) => panic!("UI extraction ratio should stay square"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(ratio_error.body_text().contains("aspectRatio"));
|
||||
let size_error = match normalize_editor_ui_design_asset_extraction_options("1:1", "0.5K")
|
||||
{
|
||||
Ok(_) => panic!("UI extraction size should be 1K or 2K"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(size_error.body_text().contains("imageSize"));
|
||||
let error =
|
||||
validate_editor_ui_design_asset_extraction_price(&state, 3, Some("2K"))
|
||||
.expect_err("wrong UI extraction price should fail");
|
||||
assert!(error.body_text().contains("priceMudPoints"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_asset_folder_id_normalizes_project_and_legacy_local_folder() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -848,6 +848,18 @@ mod tests {
|
||||
.get("/api/external/v1/editor/ui-designs/assets/extractions")
|
||||
.is_some()
|
||||
);
|
||||
let ui_extraction_schema =
|
||||
&parsed["components"]["schemas"]["EditorUiDesignAssetExtractionRequest"];
|
||||
assert_eq!(ui_extraction_schema["properties"]["aspectRatio"]["const"], "1:1");
|
||||
assert_eq!(
|
||||
ui_extraction_schema["properties"]["imageSize"]["enum"],
|
||||
json!(["1K", "2K"])
|
||||
);
|
||||
assert!(
|
||||
ui_extraction_schema["properties"]
|
||||
.get("priceMudPoints")
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
parsed["paths"]
|
||||
.get("/api/external/v1/editor/videos/generations")
|
||||
|
||||
@@ -1237,7 +1237,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(
|
||||
within(specDialog).getByRole('button', { name: '提交生成规范' })
|
||||
.textContent,
|
||||
).toContain('生成40泥点');
|
||||
).toContain('生成5泥点');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('玩法设定'), {
|
||||
target: { value: '平台跳跃玩法' },
|
||||
@@ -1472,7 +1472,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'icon-gpt-model-1',
|
||||
priceMudPoints: 20,
|
||||
priceMudPoints: 3,
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
await screen.findByAltText('画布图片:拼图素材');
|
||||
@@ -1538,7 +1538,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
priceMudPoints: 20,
|
||||
priceMudPoints: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -2756,7 +2756,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'ui-extract-task-1',
|
||||
priceMudPoints: 20,
|
||||
priceMudPoints: 3,
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -2779,8 +2779,12 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const extractionToolbar = await screen.findByRole('toolbar', {
|
||||
name: 'UI素材框选工具',
|
||||
});
|
||||
expect(extractionToolbar).toBeTruthy();
|
||||
const extractionPanel = await screen.findByRole('dialog', {
|
||||
name: 'UI素材提取',
|
||||
});
|
||||
expect(
|
||||
within(extractionToolbar).getByRole<HTMLButtonElement>('button', {
|
||||
within(extractionPanel).getByRole<HTMLButtonElement>('button', {
|
||||
name: '提取',
|
||||
}).disabled,
|
||||
).toBe(true);
|
||||
@@ -2817,12 +2821,15 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
within(extractionToolbar).getByRole<HTMLButtonElement>('button', {
|
||||
within(screen.getByRole('dialog', { name: 'UI素材提取' })).getByRole<HTMLButtonElement>('button', {
|
||||
name: '提取',
|
||||
}).disabled,
|
||||
).toBe(false);
|
||||
fireEvent.click(
|
||||
within(extractionToolbar).getByRole('button', { name: '提取' }),
|
||||
within(screen.getByRole('dialog', { name: 'UI素材提取' })).getByRole(
|
||||
'button',
|
||||
{ name: '提取' },
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -2841,6 +2848,9 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
priceMudPoints: 3,
|
||||
projectId: 'editor-project-ui-extract',
|
||||
assetFolderId: 'project',
|
||||
spritesheetLabel: '战斗UI设计图 素材图集',
|
||||
@@ -2902,6 +2912,69 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('closes UI design asset extraction when clicking outside the source and panel', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-ui-extract-dismiss',
|
||||
title: 'UI素材提取外部关闭画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-ui-design-dismiss',
|
||||
resourceId: 'resource-ui-design-dismiss',
|
||||
title: '背包UI设计图',
|
||||
src: 'data:image/png;base64,dWktZGVzaWdu',
|
||||
x: 120,
|
||||
y: 140,
|
||||
width: 560,
|
||||
height: 315,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 1152,
|
||||
zIndex: 2,
|
||||
sourceType: 'generated',
|
||||
prompt: '背包UI',
|
||||
actualPrompt: '背包UI',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'ui-design-dismiss-task-1',
|
||||
assetKind: 'ui-design',
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-18T00:00:00.000Z',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const uiDesignImage = await screen.findByAltText(
|
||||
'画布图片:背包UI设计图',
|
||||
);
|
||||
fireEvent.pointerDown(uiDesignImage.closest('button')!, {
|
||||
button: 0,
|
||||
pointerId: 256,
|
||||
clientX: 180,
|
||||
clientY: 180,
|
||||
});
|
||||
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
||||
pointerId: 256,
|
||||
clientX: 180,
|
||||
clientY: 180,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '提取素材' }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: 'UI素材提取' }),
|
||||
).toBeTruthy();
|
||||
fireEvent.pointerDown(screen.getByTestId('image-canvas-world'), {
|
||||
button: 0,
|
||||
pointerId: 257,
|
||||
clientX: 24,
|
||||
clientY: 24,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: 'UI素材提取' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens quick edit from the floating toolbar, hides the implicit source reference, and generates from a new frame', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-quick-edit',
|
||||
|
||||
@@ -74,11 +74,15 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K']).toBe(40);
|
||||
expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']).toBe(3);
|
||||
expect(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K']).toBe(5);
|
||||
expect(EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]).toBeTruthy();
|
||||
expect(
|
||||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO],
|
||||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
|
||||
).toBe(5);
|
||||
expect(
|
||||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO],
|
||||
).toBe(12);
|
||||
// 中文注释:Veo 已从前端入口移除,但客户端类型和后端兼容层仍保留,定价表也必须覆盖,避免旧布局回放或测试请求价格漂移。
|
||||
expect(EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1]).toBeTruthy();
|
||||
expect(
|
||||
@@ -90,8 +94,8 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
|
||||
);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(20);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(40);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(3);
|
||||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(5);
|
||||
expect(
|
||||
calculateEditorImageGenerationPrice({
|
||||
kind: 'character',
|
||||
|
||||
@@ -75,8 +75,8 @@ export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = {
|
||||
'2K': 24,
|
||||
},
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: {
|
||||
'1K': 20,
|
||||
'2K': 40,
|
||||
'1K': 3,
|
||||
'2K': 5,
|
||||
},
|
||||
} as const;
|
||||
export const SPEC_GENERATION_COST =
|
||||
@@ -338,10 +338,10 @@ export const EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG = {
|
||||
[VIDEO_MODEL_VEO_3_1_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
|
||||
} as const;
|
||||
export const EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG = {
|
||||
[SOUND_EFFECT_MODEL_VIDU]: 10,
|
||||
[SOUND_EFFECT_MODEL_VIDU]: 5,
|
||||
} as const;
|
||||
export const EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG = {
|
||||
[BACKGROUND_MUSIC_MODEL_SUNO]: 5,
|
||||
[BACKGROUND_MUSIC_MODEL_SUNO]: 12,
|
||||
} as const;
|
||||
export const EDITOR_MODEL_MUD_POINT_CONFIG = {
|
||||
[IMAGE_MODEL_NANOBANANA2]: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
resolveGenerationAnchor,
|
||||
resolveGenerationComposerStyle,
|
||||
resolveIconComposerStyle,
|
||||
resolveQuickEditFocusViewport,
|
||||
resolveQuickEditPanelStyle,
|
||||
resolveSelectedToolbarStyle,
|
||||
} from './ImageCanvasOverlayModel';
|
||||
@@ -129,6 +130,21 @@ describe('ImageCanvasOverlayModel', () => {
|
||||
).toEqual({ left: 768, top: 88 });
|
||||
});
|
||||
|
||||
it('fits tall quick edit sources above the reserved bottom composer area', () => {
|
||||
const sourceLayer = createLayer({ x: 100, y: 50, width: 360, height: 1600 });
|
||||
const viewport = resolveQuickEditFocusViewport({
|
||||
sourceLayer,
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
});
|
||||
const sourceTop = viewport.y + sourceLayer.y * viewport.scale;
|
||||
const sourceBottom = sourceTop + sourceLayer.height * viewport.scale;
|
||||
|
||||
expect(viewport.scale).toBeCloseTo(0.1925, 4);
|
||||
expect(sourceTop).toBeCloseTo(56, 2);
|
||||
expect(sourceBottom).toBeLessThanOrEqual(364);
|
||||
expect(sourceBottom + 12 + 260).toBeLessThanOrEqual(640);
|
||||
});
|
||||
|
||||
it('positions quick edit and character animation panels with viewport bounds', () => {
|
||||
const sourceLayer = createLayer({ x: 740, y: 460, width: 240, height: 180 });
|
||||
const quickEditPanel: QuickEditPanelState = {
|
||||
@@ -155,7 +171,7 @@ describe('ImageCanvasOverlayModel', () => {
|
||||
viewport: { x: 20, y: 10, scale: 1 },
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
}),
|
||||
).toEqual({ left: 880, top: 280 });
|
||||
).toEqual({ left: 880, top: 376 });
|
||||
expect(
|
||||
resolveCharacterAnimationPanelStyle({
|
||||
panel: characterPanel,
|
||||
|
||||
@@ -20,6 +20,12 @@ type CanvasSize = {
|
||||
|
||||
type OverlayAnchor = Pick<CanvasLayer, 'x' | 'y' | 'width' | 'height'>;
|
||||
|
||||
const QUICK_EDIT_FOCUS_HORIZONTAL_MARGIN = 56;
|
||||
const QUICK_EDIT_FOCUS_SOURCE_TOP = 56;
|
||||
const QUICK_EDIT_PANEL_GAP = 12;
|
||||
const QUICK_EDIT_PANEL_ESTIMATED_HEIGHT = 260;
|
||||
const QUICK_EDIT_PANEL_BOTTOM_MARGIN = 4;
|
||||
|
||||
export type CanvasOverlayStyle = {
|
||||
left: number;
|
||||
top: number;
|
||||
@@ -102,6 +108,42 @@ export function resolveSelectedToolbarStyle({
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveQuickEditFocusViewport({
|
||||
sourceLayer,
|
||||
canvasSize,
|
||||
}: {
|
||||
sourceLayer: CanvasLayer;
|
||||
canvasSize: CanvasSize;
|
||||
}): CanvasViewport {
|
||||
const safeSourceWidth = Math.max(1, sourceLayer.width);
|
||||
const safeSourceHeight = Math.max(1, sourceLayer.height);
|
||||
const availableWidth = Math.max(
|
||||
1,
|
||||
canvasSize.width - QUICK_EDIT_FOCUS_HORIZONTAL_MARGIN * 2,
|
||||
);
|
||||
const availableHeight = Math.max(
|
||||
1,
|
||||
canvasSize.height -
|
||||
QUICK_EDIT_FOCUS_SOURCE_TOP -
|
||||
QUICK_EDIT_PANEL_GAP -
|
||||
QUICK_EDIT_PANEL_ESTIMATED_HEIGHT -
|
||||
QUICK_EDIT_PANEL_BOTTOM_MARGIN,
|
||||
);
|
||||
const scale = Math.min(
|
||||
1,
|
||||
availableWidth / safeSourceWidth,
|
||||
availableHeight / safeSourceHeight,
|
||||
);
|
||||
|
||||
return {
|
||||
scale,
|
||||
x:
|
||||
canvasSize.width / 2 -
|
||||
(sourceLayer.x + sourceLayer.width / 2) * scale,
|
||||
y: QUICK_EDIT_FOCUS_SOURCE_TOP - sourceLayer.y * scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveQuickEditPanelStyle({
|
||||
panel,
|
||||
sourceLayer,
|
||||
@@ -126,9 +168,14 @@ export function resolveQuickEditPanelStyle({
|
||||
top: clamp(
|
||||
viewport.y +
|
||||
(sourceLayer.y + sourceLayer.height) * viewport.scale +
|
||||
12,
|
||||
12,
|
||||
Math.max(12, canvasSize.height - 360),
|
||||
QUICK_EDIT_PANEL_GAP,
|
||||
QUICK_EDIT_PANEL_BOTTOM_MARGIN,
|
||||
Math.max(
|
||||
QUICK_EDIT_PANEL_BOTTOM_MARGIN,
|
||||
canvasSize.height -
|
||||
QUICK_EDIT_PANEL_ESTIMATED_HEIGHT -
|
||||
QUICK_EDIT_PANEL_BOTTOM_MARGIN,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
|
||||
'生成20泥点',
|
||||
'生成3泥点',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -117,13 +117,13 @@ export type ImageCanvasStageViewProps = {
|
||||
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
|
||||
onRemoveBackground: (layer: CanvasLayer) => void;
|
||||
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
|
||||
onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool) => void;
|
||||
onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void;
|
||||
onUiAssetExtractionPointerStart: (point: { x: number; y: number }) => void;
|
||||
onUiAssetExtractionPointerMove: (point: { x: number; y: number }) => void;
|
||||
onUiAssetExtractionPointerEnd: () => void;
|
||||
onCancelUiAssetExtraction: () => void;
|
||||
onSubmitUiAssetExtraction: () => void;
|
||||
onQuickEditSelectionToolChange: (tool: UiAssetExtractionTool) => void;
|
||||
onQuickEditSelectionToolChange: (tool: UiAssetExtractionTool | null) => void;
|
||||
onQuickEditSelectionPointerStart: (point: { x: number; y: number }) => void;
|
||||
onQuickEditSelectionPointerMove: (point: { x: number; y: number }) => void;
|
||||
onQuickEditSelectionPointerEnd: () => void;
|
||||
|
||||
@@ -26,25 +26,48 @@ export type UiAssetExtractionMark =
|
||||
|
||||
export type UiAssetExtractionState = {
|
||||
sourceLayerId: string;
|
||||
tool: UiAssetExtractionTool;
|
||||
tool: UiAssetExtractionTool | null;
|
||||
marks: UiAssetExtractionMark[];
|
||||
draftMark: UiAssetExtractionMark | null;
|
||||
status: 'idle' | 'extracting' | 'failed';
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export const UI_ASSET_EXTRACTION_ASPECT_RATIO = '1:1';
|
||||
export const UI_ASSET_EXTRACTION_SMALL_IMAGE_SIZE = '1K';
|
||||
export const UI_ASSET_EXTRACTION_LARGE_IMAGE_SIZE = '2K';
|
||||
export const UI_ASSET_EXTRACTION_LARGE_SIZE_MARK_THRESHOLD = 6;
|
||||
|
||||
export type UiAssetExtractionImageSize =
|
||||
| typeof UI_ASSET_EXTRACTION_SMALL_IMAGE_SIZE
|
||||
| typeof UI_ASSET_EXTRACTION_LARGE_IMAGE_SIZE;
|
||||
|
||||
export function createUiAssetExtractionState(
|
||||
sourceLayerId: string,
|
||||
options: { initialTool?: UiAssetExtractionTool | null } = {},
|
||||
): UiAssetExtractionState {
|
||||
return {
|
||||
sourceLayerId,
|
||||
tool: 'rect',
|
||||
tool: options.initialTool === undefined ? 'rect' : options.initialTool,
|
||||
marks: [],
|
||||
draftMark: null,
|
||||
status: 'idle',
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveUiAssetExtractionGenerationPlan(markCount: number): {
|
||||
aspectRatio: typeof UI_ASSET_EXTRACTION_ASPECT_RATIO;
|
||||
imageSize: UiAssetExtractionImageSize;
|
||||
} {
|
||||
return {
|
||||
aspectRatio: UI_ASSET_EXTRACTION_ASPECT_RATIO,
|
||||
imageSize:
|
||||
markCount > UI_ASSET_EXTRACTION_LARGE_SIZE_MARK_THRESHOLD
|
||||
? UI_ASSET_EXTRACTION_LARGE_IMAGE_SIZE
|
||||
: UI_ASSET_EXTRACTION_SMALL_IMAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
export function createUiAssetExtractionDraftMark({
|
||||
tool,
|
||||
id,
|
||||
|
||||
@@ -73,11 +73,98 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '椭圆框选' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '矩形框选' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '矩形框选' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '画笔自由框选' }),
|
||||
);
|
||||
|
||||
expect(handleToolChange).toHaveBeenCalledWith('brush');
|
||||
expect(handleToolChange).toHaveBeenNthCalledWith(1, null);
|
||||
expect(handleToolChange).toHaveBeenNthCalledWith(2, 'brush');
|
||||
});
|
||||
|
||||
it('renders UI extraction tools on the right and a bottom composer with previews and gpt-image-2 pricing', () => {
|
||||
const marks = Array.from({ length: 7 }, (_, index) => ({
|
||||
id: `mark-${index + 1}`,
|
||||
tool: 'rect' as const,
|
||||
x: index * 8,
|
||||
y: index * 6,
|
||||
width: 48,
|
||||
height: 36,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ImageCanvasUiAssetExtractionOverlayView
|
||||
sourceLayer={createLayer({ src: 'data:image/png;base64,ui' })}
|
||||
viewport={{ x: 10, y: 20, scale: 1 }}
|
||||
state={createState({ marks })}
|
||||
onToolChange={vi.fn()}
|
||||
onPointerStart={vi.fn()}
|
||||
onPointerMove={vi.fn()}
|
||||
onPointerEnd={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('toolbar', { name: 'UI素材框选工具' }).className,
|
||||
).toContain('image-canvas-editor__ui-extraction-toolbar--right');
|
||||
const panel = screen.getByRole('dialog', { name: 'UI素材提取' });
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__ui-extraction-panel',
|
||||
);
|
||||
expect(within(panel).getByText('仅提取被红色框框选的素材并整理成spritesheet')).toBeTruthy();
|
||||
expect(within(panel).getAllByLabelText(/框选区域预览/u)).toHaveLength(7);
|
||||
expect(within(panel).getByLabelText('计划规格').textContent).toBe('1:1·2K');
|
||||
expect(within(panel).getByLabelText('固定模型').textContent).toContain('gpt-image-2');
|
||||
expect(
|
||||
within(panel).queryByRole('button', { name: '取消框选' }),
|
||||
).toBeNull();
|
||||
expect(within(panel).getByRole('button', { name: '提取' }).textContent).toContain('5泥点');
|
||||
});
|
||||
|
||||
it('crops mark previews from the same source coordinates as the red selection', () => {
|
||||
render(
|
||||
<ImageCanvasUiAssetExtractionOverlayView
|
||||
sourceLayer={createLayer({ src: 'data:image/png;base64,ui' })}
|
||||
viewport={{ x: 10, y: 20, scale: 1 }}
|
||||
state={createState({
|
||||
marks: [
|
||||
{
|
||||
id: 'mark-1',
|
||||
tool: 'rect',
|
||||
x: 160,
|
||||
y: 120,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
],
|
||||
})}
|
||||
onToolChange={vi.fn()}
|
||||
onPointerStart={vi.fn()}
|
||||
onPointerMove={vi.fn()}
|
||||
onPointerEnd={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const preview = screen.getByLabelText('框选区域预览 1');
|
||||
const previewImage = within(preview).getByRole('img', {
|
||||
name: '源图框选预览 1',
|
||||
}) as HTMLImageElement;
|
||||
|
||||
expect(previewImage.getAttribute('src')).toBe('data:image/png;base64,ui');
|
||||
expect(previewImage.style.width).toBe('800%');
|
||||
expect(previewImage.style.height).toBe('800%');
|
||||
expect(previewImage.style.left).toBe('-200%');
|
||||
expect(previewImage.style.top).toBe('-200%');
|
||||
});
|
||||
|
||||
it('shows numbered labels for quick edit red selections', () => {
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { Circle, MousePointer2, Paintbrush, Square, X } from 'lucide-react';
|
||||
import { Circle, Paintbrush, Square, X } from 'lucide-react';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
calculateEditorIconSpritesheetPrice,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type {
|
||||
UiAssetExtractionMark,
|
||||
UiAssetExtractionState,
|
||||
UiAssetExtractionTool,
|
||||
} from './ImageCanvasUiAssetExtractionModel';
|
||||
import { buildUiAssetExtractionBrushPath } from './ImageCanvasUiAssetExtractionModel';
|
||||
import {
|
||||
buildUiAssetExtractionBrushPath,
|
||||
resolveUiAssetExtractionGenerationPlan,
|
||||
} from './ImageCanvasUiAssetExtractionModel';
|
||||
|
||||
type ImageCanvasUiAssetExtractionOverlayViewProps = {
|
||||
variant?: 'ui-extraction' | 'quick-edit';
|
||||
@@ -18,7 +25,7 @@ type ImageCanvasUiAssetExtractionOverlayViewProps = {
|
||||
sourceLayer: CanvasLayer | null;
|
||||
viewport: CanvasViewport;
|
||||
state: UiAssetExtractionState | null;
|
||||
onToolChange: (tool: UiAssetExtractionTool) => void;
|
||||
onToolChange: (tool: UiAssetExtractionTool | null) => void;
|
||||
onPointerStart: (point: { x: number; y: number }) => void;
|
||||
onPointerMove: (point: { x: number; y: number }) => void;
|
||||
onPointerEnd: () => void;
|
||||
@@ -70,6 +77,60 @@ function resolveMarkLabelPoint(mark: UiAssetExtractionMark) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMarkPreviewBounds(
|
||||
mark: UiAssetExtractionMark,
|
||||
sourceLayer: CanvasLayer,
|
||||
) {
|
||||
if (mark.tool === 'brush') {
|
||||
const xs = mark.points.map((point) => point.x);
|
||||
const ys = mark.points.map((point) => point.y);
|
||||
const left = Math.min(...xs);
|
||||
const top = Math.min(...ys);
|
||||
const right = Math.max(...xs);
|
||||
const bottom = Math.max(...ys);
|
||||
return {
|
||||
x: Number.isFinite(left) ? left : 0,
|
||||
y: Number.isFinite(top) ? top : 0,
|
||||
width: Math.max(1, Number.isFinite(right - left) ? right - left : 1),
|
||||
height: Math.max(1, Number.isFinite(bottom - top) ? bottom - top : 1),
|
||||
};
|
||||
}
|
||||
return {
|
||||
x: Math.min(mark.x, mark.x + mark.width),
|
||||
y: Math.min(mark.y, mark.y + mark.height),
|
||||
width: Math.max(1, Math.abs(mark.width)),
|
||||
height: Math.max(1, Math.abs(mark.height)),
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkPreview(
|
||||
mark: UiAssetExtractionMark,
|
||||
index: number,
|
||||
sourceLayer: CanvasLayer,
|
||||
) {
|
||||
const bounds = resolveMarkPreviewBounds(mark, sourceLayer);
|
||||
const originalWidth = Math.max(1, sourceLayer.originalWidth);
|
||||
const originalHeight = Math.max(1, sourceLayer.originalHeight);
|
||||
return (
|
||||
<div
|
||||
key={mark.id}
|
||||
aria-label={`框选区域预览 ${index + 1}`}
|
||||
className="image-canvas-editor__ui-extraction-preview"
|
||||
style={{
|
||||
backgroundImage: `url("${sourceLayer.src}")`,
|
||||
backgroundPosition: `${(bounds.x / originalWidth) * 100}% ${
|
||||
(bounds.y / originalHeight) * 100
|
||||
}%`,
|
||||
backgroundSize: `${(originalWidth / bounds.width) * 100}% ${
|
||||
(originalHeight / bounds.height) * 100
|
||||
}%`,
|
||||
}}
|
||||
>
|
||||
<span>{index + 1}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMark(
|
||||
mark: UiAssetExtractionMark,
|
||||
index: number,
|
||||
@@ -139,6 +200,13 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
const isQuickEdit = variant === 'quick-edit';
|
||||
const marks = [...state.marks, ...(state.draftMark ? [state.draftMark] : [])];
|
||||
const canSubmit = state.marks.length > 0 && state.status !== 'extracting';
|
||||
const extractionPlan = resolveUiAssetExtractionGenerationPlan(
|
||||
state.marks.length,
|
||||
);
|
||||
const extractionPrice = calculateEditorIconSpritesheetPrice(
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
extractionPlan.imageSize,
|
||||
);
|
||||
const screenFrame = {
|
||||
left: viewport.x + sourceLayer.x * viewport.scale,
|
||||
top: viewport.y + sourceLayer.y * viewport.scale,
|
||||
@@ -149,77 +217,34 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`image-canvas-editor__ui-extraction-toolbar ${
|
||||
isQuickEdit
|
||||
? 'image-canvas-editor__ui-extraction-toolbar--right'
|
||||
: ''
|
||||
}`}
|
||||
className="image-canvas-editor__ui-extraction-toolbar image-canvas-editor__ui-extraction-toolbar--right"
|
||||
role="toolbar"
|
||||
aria-label={isQuickEdit ? '快速编辑框选工具' : 'UI素材框选工具'}
|
||||
style={{
|
||||
left: isQuickEdit
|
||||
? screenFrame.left + screenFrame.width + 12
|
||||
: screenFrame.left + screenFrame.width / 2,
|
||||
top: isQuickEdit
|
||||
? screenFrame.top + screenFrame.height / 2
|
||||
: Math.max(10, screenFrame.top - 12),
|
||||
left: screenFrame.left + screenFrame.width + 12,
|
||||
top: screenFrame.top + screenFrame.height / 2,
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{isQuickEdit ? null : (
|
||||
<>
|
||||
<span className="image-canvas-editor__ui-extraction-toolbar-title">
|
||||
<MousePointer2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
框选素材
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{TOOL_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isActive = state.tool === option.value;
|
||||
return (
|
||||
<PlatformIconButton
|
||||
key={option.value}
|
||||
variant="darkMini"
|
||||
aria-pressed={isActive}
|
||||
className={
|
||||
state.tool === option.value
|
||||
isActive
|
||||
? 'image-canvas-editor__ui-extraction-tool--active'
|
||||
: undefined
|
||||
}
|
||||
label={option.label}
|
||||
title={option.label}
|
||||
icon={<Icon className="h-4 w-4" />}
|
||||
onClick={() => onToolChange(option.value)}
|
||||
onClick={() => onToolChange(isActive ? null : option.value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isQuickEdit ? null : (
|
||||
<>
|
||||
<span className="image-canvas-editor__ui-extraction-count">
|
||||
{state.marks.length}
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
disabled={!canSubmit}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{state.status === 'extracting' ? '提取中' : '提取'}
|
||||
</PlatformActionButton>
|
||||
<PlatformIconButton
|
||||
variant="darkMini"
|
||||
label="取消框选"
|
||||
title="取消框选"
|
||||
icon={<X className="h-4 w-4" />}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className="image-canvas-editor__ui-extraction-overlay"
|
||||
@@ -236,6 +261,7 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
width: screenFrame.width,
|
||||
height: screenFrame.height,
|
||||
zIndex: sourceLayer.zIndex + 40,
|
||||
pointerEvents: state.tool ? 'auto' : 'none',
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -270,6 +296,67 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
renderMark(mark, index, showIndexLabels && index < state.marks.length),
|
||||
)}
|
||||
</svg>
|
||||
{!isQuickEdit ? (
|
||||
<div
|
||||
className="image-canvas-editor__ui-extraction-panel image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image"
|
||||
role="dialog"
|
||||
aria-label="UI素材提取"
|
||||
style={{
|
||||
left: screenFrame.left + screenFrame.width / 2,
|
||||
top: screenFrame.top + screenFrame.height + 12,
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__ui-extraction-panel-head">
|
||||
仅提取被红色框框选的素材并整理成spritesheet
|
||||
</div>
|
||||
<div
|
||||
className="image-canvas-editor__ui-extraction-preview-list"
|
||||
aria-label="预览列表"
|
||||
>
|
||||
{state.marks.length
|
||||
? state.marks.map((mark, index) =>
|
||||
renderMarkPreview(mark, index, sourceLayer),
|
||||
)
|
||||
: (
|
||||
<span className="image-canvas-editor__ui-extraction-preview-empty">
|
||||
框选后显示预览
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="image-canvas-editor__generation-composer-footer image-canvas-editor__ui-extraction-panel-footer">
|
||||
<span
|
||||
className="image-canvas-editor__readonly-generation-option"
|
||||
aria-label="计划规格"
|
||||
>
|
||||
{`${extractionPlan.aspectRatio}·${extractionPlan.imageSize}`}
|
||||
</span>
|
||||
<span
|
||||
className="image-canvas-editor__readonly-generation-option image-canvas-editor__ui-extraction-model"
|
||||
aria-label="固定模型"
|
||||
>
|
||||
{IMAGE_MODEL_GPT_IMAGE_2}
|
||||
</span>
|
||||
<PlatformIconButton
|
||||
label="取消框选"
|
||||
title="取消框选"
|
||||
icon={<X className="h-4 w-4" />}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
aria-label="提取"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={!canSubmit}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{state.status === 'extracting'
|
||||
? '提取中'
|
||||
: `提取 · ${extractionPrice}泥点`}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{state.errorMessage ? (
|
||||
<div
|
||||
className="image-canvas-editor__ui-extraction-error"
|
||||
|
||||
@@ -305,16 +305,14 @@ function SubmissionWorkflowHarness({
|
||||
onClick={() => {
|
||||
if (sourceLayer) {
|
||||
void workflow.extractUiDesignAssets(sourceLayer, {
|
||||
marks: [
|
||||
{
|
||||
id: 'mark-1',
|
||||
tool: 'rect',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
],
|
||||
marks: Array.from({ length: 7 }, (_, index) => ({
|
||||
id: `mark-${index + 1}`,
|
||||
tool: 'rect' as const,
|
||||
x: 10 + index * 8,
|
||||
y: 20 + index * 6,
|
||||
width: 80,
|
||||
height: 60,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -1191,7 +1189,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
referenceImageSrc: 'data:image/png;base64,spec',
|
||||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||||
model: 'gpt-image-2',
|
||||
priceMudPoints: 20,
|
||||
priceMudPoints: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1271,7 +1269,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
||||
});
|
||||
|
||||
it('renders selected UI marks into the extraction reference image', async () => {
|
||||
it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => {
|
||||
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
||||
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
||||
spritesheetWidth: 512,
|
||||
@@ -1300,24 +1298,32 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '提取已框选UI素材' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith({
|
||||
source: 'data:image/png;base64,ui-design.png',
|
||||
marks: [
|
||||
{
|
||||
id: 'mark-1',
|
||||
tool: 'rect',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: 'data:image/png;base64,ui-design.png',
|
||||
marks: expect.arrayContaining([
|
||||
{
|
||||
id: 'mark-1',
|
||||
tool: 'rect',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
renderUiDesignAssetExtractionMarkedImageMock.mock.calls[0]?.[0].marks,
|
||||
).toHaveLength(7);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '2K',
|
||||
priceMudPoints: 5,
|
||||
generationInputs: expect.objectContaining({
|
||||
fields: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -51,11 +51,13 @@ import {
|
||||
buildUiDesignAssetExtractionGenerationInputs,
|
||||
buildVideoGenerationInputs,
|
||||
calculateCharacterAnimationPrice,
|
||||
calculateEditorIconSpritesheetPrice,
|
||||
calculateEditorVideoPrice,
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
ICON_FRAME_DISPLAY_SIZE,
|
||||
ICON_FRAME_ORIGINAL_SIZE,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
isCanvasGenerationDialog,
|
||||
@@ -74,6 +76,7 @@ import type {
|
||||
UiAssetExtractionMark,
|
||||
UiAssetExtractionState,
|
||||
} from './ImageCanvasUiAssetExtractionModel';
|
||||
import { resolveUiAssetExtractionGenerationPlan } from './ImageCanvasUiAssetExtractionModel';
|
||||
import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetExtractionRasterModel';
|
||||
|
||||
type CanvasSize = { width: number; height: number };
|
||||
@@ -575,12 +578,22 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
}
|
||||
const generationInputs =
|
||||
buildUiDesignAssetExtractionGenerationInputs(sourceLayer);
|
||||
const extractionPlan = resolveUiAssetExtractionGenerationPlan(
|
||||
options.marks?.length ?? 0,
|
||||
);
|
||||
const priceMudPoints = calculateEditorIconSpritesheetPrice(
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
extractionPlan.imageSize,
|
||||
);
|
||||
const generated = await extractEditorUiDesignAssets({
|
||||
sourceImageSrc,
|
||||
projectId,
|
||||
generationInputs,
|
||||
assetFolderId,
|
||||
spritesheetLabel: `${sourceLayer.title} 素材图集`,
|
||||
aspectRatio: extractionPlan.aspectRatio,
|
||||
imageSize: extractionPlan.imageSize,
|
||||
priceMudPoints,
|
||||
});
|
||||
const extractionFrame = {
|
||||
x: sourceLayer.x + sourceLayer.width + 32,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user