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:
2026-06-22 21:10:43 +08:00
parent 6e475af269
commit 2007f1f9ce
30 changed files with 911 additions and 181 deletions
@@ -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 每秒 48kling3.0 为 480p 每秒 15 / 720p 每秒 30 / 1080p 每秒 60kling3.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
}
}
}
+54 -1
View File
@@ -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
);
}
+116 -13
View File
@@ -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