diff --git a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx index c19356e13..d487cab38 100644 --- a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx @@ -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}, }, }; diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index bf7087e02..414f34fbc 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -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", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7724126d4..1a6cf7d07 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`,用户会继续看到“生成中”卡片。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2da702408..c0803f64f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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,而不是动作视频。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 1e1441550..e7c0fdfca 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -19,7 +19,7 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑默认从原图模型和分辨率初始化,但提交使用面板当前选择的 `model/aspectRatio/imageSize`;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材必须按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作快速编辑固定使用 `seedance2.0-fast` 动作 / 视频模型。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图 / 原素材自动作为最后一张隐式参考提交,但不在参考图条里固定展示 `图x`。尺寸和模型默认从原素材配置 / 分辨率推断,底部参数胶囊可点击修改;图片快速编辑左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。提交前将提示词里对原素材的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写为最后一张源素材编号,例如额外 1 张参考图时写成 `图2`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 +- 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图 / 原素材自动作为最后一张隐式参考提交,但不在参考图条里固定展示 `图x`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。尺寸和模型默认从原素材配置 / 分辨率推断,底部参数胶囊可点击修改;图片快速编辑左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。提交前将提示词里对原素材的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写为最后一张源素材编号,例如额外 1 张参考图时写成 `图2`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变。 @@ -68,7 +68,7 @@ - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。 -- `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 +- `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 - `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 - `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 @@ -96,10 +96,10 @@ - 画布多选语义必须同时覆盖普通图层和仍显示占位框的生成器对象:Shift 点选或框选可把生成器加入当前选择;拖动任一已选图层或生成器时,所有已选普通图层和生成器占位框同步移动;删除 / Backspace / Delete 作用于完整选择集合,移除所有已选图层和生成器对象。生成器对象在选择集合中使用稳定 `generation-dialog:` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。 - 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。角色形象生成面板每次成功绑定角色规范后,在当前编辑器生命周期内缓存为上一张角色规范;再次新建角色形象时自动带入该缓存。图标素材和 UI 设计图面板每次成功绑定图标规范后,同样缓存为上一张图标规范;再次新建需要图标规范的素材时自动带入该缓存。生成规范菜单里的图标规范对象自身只把首行参考图作为可选参考,不要求必须先绑定图标规范。 - 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板参数控件,提交到 `/api/editor/images/generations` 时也固定携带这些参数。 -- 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;参考图请求顺序为额外参考图在前、原图作为隐式最后一张提交,信息面板输入快照只展示用户额外选择的参考图。 +- 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。参考图请求顺序为额外参考图在前、原图作为隐式最后一张提交,信息面板输入快照只展示用户额外选择的参考图。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材必须先进入红框素材框选状态,支持矩形、椭圆和画笔自由框选;至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,显示短提示语、框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮;至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。 - 快速编辑的额外参考图同样必须在前端读取成图片 Data URL 后提交;后端 `referenceImageSrcs` 上限为 9 张,承载 8 张额外参考图加 1 张原图。 diff --git a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md index 03771b423..9b1645c15 100644 --- a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md +++ b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md @@ -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 } } } ``` diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 56cbb40c2..e6b600bbc 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -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`。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 9d43ebb96..ad2ae3fa2 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -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` 校验错误,不继续调用上游生成。 diff --git a/server-rs/crates/api-server/config/editor-generation-pricing.default.json b/server-rs/crates/api-server/config/editor-generation-pricing.default.json index 3db8f7c06..3e08db484 100644 --- a/server-rs/crates/api-server/config/editor-generation-pricing.default.json +++ b/server-rs/crates/api-server/config/editor-generation-pricing.default.json @@ -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 } } } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 7b6132704..c34abb18a 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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"], diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 8ca6d62e9..ab41ec044 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -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 ); } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index a78f9d277..6d681134b 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, @@ -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) -> 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 { + 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 { + 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!( diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index fd3cba2e4..6e22b4f70 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -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") diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index a2b56f4a4..9dd0d5fec 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -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(); 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(); @@ -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('button', { + within(extractionPanel).getByRole('button', { name: '提取', }).disabled, ).toBe(true); @@ -2817,12 +2821,15 @@ describe('ImageCanvasEditorView generation integration', () => { }); expect( - within(extractionToolbar).getByRole('button', { + within(screen.getByRole('dialog', { name: 'UI素材提取' })).getByRole('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(); + + 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', diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index c5fe87e27..bdf903e14 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -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', diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 270939bed..49eae3190 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -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]: { diff --git a/src/components/image-editor/ImageCanvasOverlayModel.test.ts b/src/components/image-editor/ImageCanvasOverlayModel.test.ts index d3a5955c1..097dfbabb 100644 --- a/src/components/image-editor/ImageCanvasOverlayModel.test.ts +++ b/src/components/image-editor/ImageCanvasOverlayModel.test.ts @@ -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, diff --git a/src/components/image-editor/ImageCanvasOverlayModel.ts b/src/components/image-editor/ImageCanvasOverlayModel.ts index 92c58c541..be6bb684d 100644 --- a/src/components/image-editor/ImageCanvasOverlayModel.ts +++ b/src/components/image-editor/ImageCanvasOverlayModel.ts @@ -20,6 +20,12 @@ type CanvasSize = { type OverlayAnchor = Pick; +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, + ), ), }; } diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx index 7639dcb72..8172d6f71 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx @@ -163,7 +163,7 @@ describe('ImageCanvasQuickEditPanelView', () => { ); expect(screen.getByRole('button', { name: '生成' }).textContent).toBe( - '生成20泥点', + '生成3泥点', ); }); diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index eb276b870..3a00f6ca0 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -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; diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionModel.ts b/src/components/image-editor/ImageCanvasUiAssetExtractionModel.ts index 2877d20ea..0662ae4d4 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionModel.ts +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionModel.ts @@ -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, diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx index e6217093b..362a9aeb8 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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', () => { diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx index 884a4324e..8a86db699 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx @@ -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 ( +
+ {index + 1} +
+ ); +} + 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 ( <>
event.stopPropagation()} > - {isQuickEdit ? null : ( - <> - - -
{ event.preventDefault(); @@ -270,6 +296,67 @@ export function ImageCanvasUiAssetExtractionOverlayView({ renderMark(mark, index, showIndexLabels && index < state.marks.length), )} + {!isQuickEdit ? ( +
event.stopPropagation()} + > +
+ 仅提取被红色框框选的素材并整理成spritesheet +
+
+ {state.marks.length + ? state.marks.map((mark, index) => + renderMarkPreview(mark, index, sourceLayer), + ) + : ( + + 框选后显示预览 + + )} +
+
+ + {`${extractionPlan.aspectRatio}·${extractionPlan.imageSize}`} + + + {IMAGE_MODEL_GPT_IMAGE_2} + + } + onClick={onCancel} + /> + + {state.status === 'extracting' + ? '提取中' + : `提取 · ${extractionPrice}泥点`} + +
+
+ ) : null} {state.errorMessage ? (
{ 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({ diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 99d905492..e8c9afa18 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -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, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index db5acb35b..a97869b00 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -202,7 +202,7 @@ function GenerationWorkflowHarness({ {workflow.uiAssetExtractionState - ? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}` + ? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool ?? 'none'}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}` : '-'} @@ -210,7 +210,7 @@ function GenerationWorkflowHarness({ {workflow.quickEditSelectionState - ? `${workflow.quickEditSelectionState.sourceLayerId}:${workflow.quickEditSelectionState.tool}:${workflow.quickEditSelectionState.marks.length}` + ? `${workflow.quickEditSelectionState.sourceLayerId}:${workflow.quickEditSelectionState.tool ?? 'none'}:${workflow.quickEditSelectionState.marks.length}` : '-'} @@ -506,6 +506,12 @@ function GenerationWorkflowHarness({ > 填写快速编辑 +