UI extraction selection + local FFmpeg dev env

Require an explicit red-box selection before extracting UI-design assets: add rectangular/ellipse/brush selection UI, render red-outline marks into a marked Data URL on the client, and update the extraction prompt to "仅提取被红色框框选的素材并整理成spritesheet". Add frontend overlay/raster models and tests to support the selection flow and adjust numerous image-editor tests and views to wire up the selection lifecycle. On the dev ops side, enhance scripts/dev to detect a local %LOCALAPPDATA%/Genarrative/ffmpeg/bin installation on Windows, prepend it to PATH and inject CHARACTER_ANIMATION_FFMPEG_PATH/FFPROBE_PATH (with tests), and export the helper. Server config now prefers GENARRATIVE_*-prefixed FFmpeg env vars and includes a Rust test to validate reading the prefixed variables. Update docs to describe the new UI extraction behavior and local FFmpeg handling.
This commit is contained in:
2026-06-22 19:13:37 +08:00
parent 163b2fa077
commit 55469a2198
25 changed files with 1924 additions and 49 deletions
@@ -59,7 +59,7 @@
## 2026-06-18 图片画布 UI 设计图提取素材保留图集
- 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`固定调用 `/api/editor/ui-designs/assets/extractions`后端固定 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet`,返回结构复用图标 spritesheet 响应。图标生成与 UI 提取都必须把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和提示词 `提取被红色框框选的素材并整理成spritesheet`,返回结构复用图标 spritesheet 响应。图标生成与 UI 提取都必须把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。
- 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。
- 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`
- 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md``docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`
@@ -95,6 +95,14 @@
- 验证:`cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`,其中 `editor_character_animation_extracts_final_sample_from_short_video` 应覆盖本机 FFmpeg 8 的 0 帧回归。
- 关联:`server-rs/crates/api-server/src/character_animation_assets.rs``docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`
## Windows 本地角色动画抽帧找不到 ffmpeg 先查 dev 子进程环境
- 现象:画板角色动画抽帧报 `抽取动作视频帧失败:无法启动进程 ffmpegprogram not found(requestId:...)`,但新开的 PowerShell 里 `ffmpeg -version` 正常。
- 原因:长期运行的 `api-server` 可能是在安装 FFmpeg 或更新用户 Path 之前启动的,子进程不会自动继承后续写入的用户环境变量。
- 处理:Windows 本地默认把 FFmpeg 安装到 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin`,并确保用户 Path 包含该目录;`npm run dev` / `npm run dev:api-server` 会在启动 `api-server` 时自动注入该目录和 `CHARACTER_ANIMATION_FFMPEG_PATH` / `CHARACTER_ANIMATION_FFPROBE_PATH` 绝对路径。修复后需要重启 `api-server`,不能只刷新浏览器。
- 验证:`where ffmpeg``where ffprobe` 能找到本地安装;`npm run test -- scripts/dev.test.ts -t "FFmpeg"`;重启 `npm run dev:api-server` 后访问 `/healthz`
- 关联:`scripts/dev.mjs``server-rs/crates/api-server/src/config.rs``server-rs/crates/api-server/src/character_animation_assets.rs`
## 图片编辑器生成长请求完成态必须由后端写入画布
- 现象:画板角色形象等生成请求已经在服务端返回 `200`OSS 中也已有 `generated-character-drafts/.../image.png`,但用户刷新或页面重载后仍看到旧生成卡片停在“生成中”。
@@ -67,7 +67,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 响应。请求可携带 `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`
@@ -97,7 +97,7 @@
- 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;参考图请求顺序为额外参考图在前、原图作为隐式最后一张提交,信息面板输入快照只展示用户额外选择的参考图。
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材固定走 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材必须先进入红框素材框选状态,支持矩形、椭圆和画笔自由框选;至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `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 张原图。
@@ -51,6 +51,8 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模
Windows 本地 `npm run dev` / `npm run dev:api-server` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。不要把 wrapper 绕过值写成 `rustc`Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。
Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg`npm run dev` / `npm run dev:api-server` 会自动把该目录加入本次 `api-server` 子进程 `Path`,并注入 `CHARACTER_ANIMATION_FFMPEG_PATH` / `CHARACTER_ANIMATION_FFPROBE_PATH` 的绝对路径。这样即使外层终端或长期运行的 dev 进程是在安装 FFmpeg 之前启动,角色动画抽帧也不会继续因为 `ffmpeg: program not found` 失败;若手动配置了上述环境变量或 `GENARRATIVE_CHARACTER_ANIMATION_*` 前缀变量,显式配置优先。
开发态 `npm run dev``npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
本地 `npm run dev``npm run dev:api-server` 默认保留 inline 开发体验:未显式设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,外部生成 handler 会同步复用 worker executor,完成后返回 `completed`,便于快速确认 provider、OSS 和 SpacetimeDB 写回链路。inline 不创建 `external_generation_job`,也不能验证 worker lease、队列等待展示或动态扩缩容。
@@ -41,6 +41,7 @@
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
10. 快速编辑最多允许额外绑定 8 张参考图;原图始终自动作为最后一张隐式参考图提交,不占用额外参考图额度,也不在参考图条里固定展示 `图x`
11. 快速编辑打开后,画布视口应调整到面板位于原图下方且不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`
## 参数交互
@@ -173,14 +174,15 @@
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
- 生成规范类图片底部展示禁用态参数按钮 `16:9·2K``gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。
- 快速编辑底部展示当前选择的比例 / 尺寸和模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮,并允许展开修改;额外参考图最多 8 张,原图作为隐式最后参考图参与提交但不在参考图条里固定显示 `图x`
- 快速编辑打开后面板位于原图下方且不遮挡原图,原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮;完成框选后画布红框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`
- 快速编辑提交前必须把提示词里对原图的 `原图``当前图片``当前图``图1` 引用改写成最后一张原图编号,例如额外 1 张参考图时改为 `图2`
- 快速编辑提交给后端的参考图顺序必须是额外参考图在前、原图在最后。
- 快速编辑提交给后端的参考图顺序必须是额外参考图在前、原图在最后;若用户已完成红色圈选,最后一张隐式原图必须替换为绘制了红框和序号的标注图
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
- 快速编辑生成中的占位图同样只支持键盘 `Delete` / `Backspace` 删除,不新增 UI 删除按钮;删除后异步结果不得再落回画布。
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后固定用 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet` 生成 spritesheet复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后进入红框素材框选编辑态,默认矩形框选并支持椭圆和画笔自由框选。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用 `gpt-image-2` 和提示词 `提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
- 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。
- 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`
@@ -47,11 +47,14 @@
## 提取素材
- 点击浮动工具栏 `提取素材` 后,前端把当前 UI 设计图读取为图片 Data URL,提交到 `POST /api/editor/ui-designs/assets/extractions`
- 点击浮动工具栏 `提取素材` 后,进入 UI 设计图素材框选编辑状态,不立即提交提取接口
- 框选状态参考微信截图工具栏布局,默认矩形框选,并支持矩形框选、椭圆框选和画笔自由框选三种工具。
- 用户可在同一张 UI 设计图上多次框选;未至少框选一个区域时,工具栏 `提取` 按钮不可点击。
- 点击 `提取` 后,前端把所有框选的红色轮廓绘入原 UI 设计图,生成合成图片 Data URL,再提交到 `POST /api/editor/ui-designs/assets/extractions`
- 后端固定使用 `gpt-image-2` 图片编辑链路,并固定提示词:
```text
提取画面中的所有独立并整理成spritesheet
提取被红色框框选的素材并整理成spritesheet
```
- 后端收到 spritesheet 后复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1``素材 2`
@@ -64,4 +67,4 @@
- 从画布选择时只能绑定图标规范图片。
- 请求参数包含 `kind: "ui-design"``model: "gpt-image-2"`、比例、大小与可选参考图。
- 生成图层信息面板展示 `用户输入``图标规范`
- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后调用固定 `gpt-image-2` 提取接口,画布同时出现 spritesheet 图集和拆分后的独立素材。
- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后进入红框素材框选状态,至少框选一个区域后才能调用固定 `gpt-image-2` 提取接口,画布同时出现 spritesheet 图集和拆分后的独立素材。
+107 -12
View File
@@ -37,6 +37,92 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml');
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
function resolveLocalGenarrativeFfmpeg(env = process.env, platform = process.platform) {
if (platform !== 'win32') {
return null;
}
const localAppData =
String(env.LOCALAPPDATA ?? '').trim() ||
(String(env.USERPROFILE ?? '').trim()
? join(String(env.USERPROFILE).trim(), 'AppData', 'Local')
: '');
if (!localAppData) {
return null;
}
const binDir = join(localAppData, 'Genarrative', 'ffmpeg', 'bin');
const ffmpegPath = join(binDir, 'ffmpeg.exe');
const ffprobePath = join(binDir, 'ffprobe.exe');
if (!existsSync(ffmpegPath) || !existsSync(ffprobePath)) {
return null;
}
return {binDir, ffmpegPath, ffprobePath};
}
function resolvePathEnvKey(env, platform = process.platform) {
return (
Object.keys(env).find((key) => key.toLowerCase() === 'path') ||
(platform === 'win32' ? 'Path' : 'PATH')
);
}
function prependEnvPathEntry(env, entry, platform = process.platform) {
const pathKey = resolvePathEnvKey(env, platform);
const separator = platform === 'win32' ? ';' : ':';
const currentEntries = String(env[pathKey] ?? '')
.split(separator)
.map((value) => value.trim())
.filter(Boolean);
const normalize = (value) =>
platform === 'win32' ? value.toLowerCase() : value;
if (currentEntries.some((value) => normalize(value) === normalize(entry))) {
return;
}
env[pathKey] = [entry, ...currentEntries].join(separator);
}
function setDefaultEnvValue(env, key, value) {
if (!String(env[key] ?? '').trim()) {
env[key] = value;
}
}
function applyLocalFfmpegEnv(env, platform = process.platform) {
const localFfmpeg = resolveLocalGenarrativeFfmpeg(env, platform);
if (!localFfmpeg) {
return env;
}
// 本地 dev 进程常在安装 FFmpeg 前已经启动;这里用绝对路径兜底,避免只依赖旧 PATH。
prependEnvPathEntry(env, localFfmpeg.binDir, platform);
const ffmpegPath =
String(env.CHARACTER_ANIMATION_FFMPEG_PATH ?? '').trim() ||
String(env.GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH ?? '').trim() ||
localFfmpeg.ffmpegPath;
const ffprobePath =
String(env.CHARACTER_ANIMATION_FFPROBE_PATH ?? '').trim() ||
String(env.GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH ?? '').trim() ||
localFfmpeg.ffprobePath;
setDefaultEnvValue(env, 'CHARACTER_ANIMATION_FFMPEG_PATH', ffmpegPath);
setDefaultEnvValue(
env,
'GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH',
ffmpegPath,
);
setDefaultEnvValue(env, 'CHARACTER_ANIMATION_FFPROBE_PATH', ffprobePath);
setDefaultEnvValue(
env,
'GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH',
ffprobePath,
);
return env;
}
function resolveLocalDevRustcWrapperBypass() {
// Windows 下不能把 rustc 自身当成 Cargo wrapper;空值会覆盖仓库 .cargo/config.toml 中的 sccache。
return process.platform === 'win32' ? '' : '/usr/bin/env';
@@ -2149,23 +2235,32 @@ function isSpacetimePublishPermissionError(error) {
);
}
function buildApiServerProcessEnv({baseEnv, options, state}) {
return {
...baseEnv,
// 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true',
GENARRATIVE_API_HOST: options.apiHost,
GENARRATIVE_API_PORT: String(options.apiPort),
GENARRATIVE_API_LOG: options.apiLog,
GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer,
GENARRATIVE_SPACETIME_DATABASE: options.database,
GENARRATIVE_SPACETIME_TOKEN: baseEnv.GENARRATIVE_SPACETIME_TOKEN || '',
};
function buildApiServerProcessEnv({
baseEnv,
options,
state,
platform = process.platform,
}) {
return applyLocalFfmpegEnv(
{
...baseEnv,
// 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true',
GENARRATIVE_API_HOST: options.apiHost,
GENARRATIVE_API_PORT: String(options.apiPort),
GENARRATIVE_API_LOG: options.apiLog,
GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer,
GENARRATIVE_SPACETIME_DATABASE: options.database,
GENARRATIVE_SPACETIME_TOKEN: baseEnv.GENARRATIVE_SPACETIME_TOKEN || '',
},
platform,
);
}
export {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
applyLocalFfmpegEnv,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildLocalRustProcessEnv,
+55 -1
View File
@@ -1,4 +1,4 @@
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
import {mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
@@ -184,6 +184,60 @@ describe('dev scheduler api-server env', () => {
expect(env.GENARRATIVE_API_PORT).toBe('9091');
expect(env.GENARRATIVE_SPACETIME_SERVER_URL).toBe('http://127.0.0.1:3199');
});
test('Windows 本地 dev 自动注入已安装的 FFmpeg 路径', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-ffmpeg-'));
try {
const binDir = join(tempDir, 'Genarrative', 'ffmpeg', 'bin');
const ffmpegPath = join(binDir, 'ffmpeg.exe');
const ffprobePath = join(binDir, 'ffprobe.exe');
mkdirSync(binDir, {recursive: true});
writeFileSync(ffmpegPath, '', 'utf8');
writeFileSync(ffprobePath, '', 'utf8');
const {options} = parseArgs(['api-server'], {});
const env = buildApiServerProcessEnv({
baseEnv: {LOCALAPPDATA: tempDir, Path: 'C:\\Windows\\System32'},
options,
state: {spacetimeServer: 'http://127.0.0.1:3199'},
platform: 'win32',
});
expect(env.CHARACTER_ANIMATION_FFMPEG_PATH).toBe(ffmpegPath);
expect(env.CHARACTER_ANIMATION_FFPROBE_PATH).toBe(ffprobePath);
expect(env.GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH).toBe(ffmpegPath);
expect(env.Path?.split(';')[0]).toBe(binDir);
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
test('Windows 本地 dev 保留显式 FFmpeg 配置', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-ffmpeg-'));
try {
const binDir = join(tempDir, 'Genarrative', 'ffmpeg', 'bin');
mkdirSync(binDir, {recursive: true});
writeFileSync(join(binDir, 'ffmpeg.exe'), '', 'utf8');
writeFileSync(join(binDir, 'ffprobe.exe'), '', 'utf8');
const {options} = parseArgs(['api-server'], {});
const env = buildApiServerProcessEnv({
baseEnv: {
LOCALAPPDATA: tempDir,
CHARACTER_ANIMATION_FFMPEG_PATH: 'D:\\tools\\ffmpeg.exe',
CHARACTER_ANIMATION_FFPROBE_PATH: 'D:\\tools\\ffprobe.exe',
},
options,
state: {spacetimeServer: 'http://127.0.0.1:3199'},
platform: 'win32',
});
expect(env.CHARACTER_ANIMATION_FFMPEG_PATH).toBe('D:\\tools\\ffmpeg.exe');
expect(env.CHARACTER_ANIMATION_FFPROBE_PATH).toBe('D:\\tools\\ffprobe.exe');
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
});
describe('dev scheduler Rust build env', () => {
+48 -2
View File
@@ -1114,13 +1114,19 @@ impl AppConfig {
}
if let Some(character_animation_ffmpeg_path) =
read_first_non_empty_env(&["CHARACTER_ANIMATION_FFMPEG_PATH"])
read_first_non_empty_env(&[
"GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH",
"CHARACTER_ANIMATION_FFMPEG_PATH",
])
{
config.character_animation_ffmpeg_path = character_animation_ffmpeg_path;
}
if let Some(character_animation_ffprobe_path) =
read_first_non_empty_env(&["CHARACTER_ANIMATION_FFPROBE_PATH"])
read_first_non_empty_env(&[
"GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH",
"CHARACTER_ANIMATION_FFPROBE_PATH",
])
{
config.character_animation_ffprobe_path = character_animation_ffprobe_path;
}
@@ -2076,4 +2082,44 @@ mod tests {
std::env::remove_var("GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH");
}
}
#[test]
fn from_env_reads_prefixed_character_animation_ffmpeg_paths() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH");
std::env::remove_var("GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH");
std::env::remove_var("CHARACTER_ANIMATION_FFMPEG_PATH");
std::env::remove_var("CHARACTER_ANIMATION_FFPROBE_PATH");
std::env::set_var(
"GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH",
"C:\\Genarrative\\ffmpeg\\bin\\ffmpeg.exe",
);
std::env::set_var(
"GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH",
"C:\\Genarrative\\ffmpeg\\bin\\ffprobe.exe",
);
}
let config = AppConfig::from_env();
assert_eq!(
config.character_animation_ffmpeg_path,
"C:\\Genarrative\\ffmpeg\\bin\\ffmpeg.exe"
);
assert_eq!(
config.character_animation_ffprobe_path,
"C:\\Genarrative\\ffmpeg\\bin\\ffprobe.exe"
);
unsafe {
std::env::remove_var("GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH");
std::env::remove_var("GENARRATIVE_CHARACTER_ANIMATION_FFPROBE_PATH");
std::env::remove_var("CHARACTER_ANIMATION_FFMPEG_PATH");
std::env::remove_var("CHARACTER_ANIMATION_FFPROBE_PATH");
}
}
}
@@ -68,7 +68,7 @@ const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana";
const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT: &str = "提取画面中的所有独立并整理成spritesheet";
const EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT: &str = "提取被红色框框选的素材并整理成spritesheet";
const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image";
const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project";
const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character";
@@ -3175,7 +3175,7 @@ mod tests {
fn editor_ui_design_asset_extraction_prompt_is_fixed() {
assert_eq!(
EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT,
"提取画面中的所有独立并整理成spritesheet"
"提取被红色框框选的素材并整理成spritesheet"
);
}
@@ -9,7 +9,7 @@ import {
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiClientError,
@@ -23,6 +23,7 @@ import {
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
const createEditorAssetMock = vi.hoisted(() => vi.fn());
@@ -64,6 +65,11 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
};
});
vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
renderUiDesignAssetExtractionMarkedImage:
renderUiDesignAssetExtractionMarkedImageMock,
}));
describe('ImageCanvasEditorView generation integration', () => {
setupImageCanvasEditorViewTestLifecycle({
generateEditorImageMock,
@@ -85,6 +91,14 @@ describe('ImageCanvasEditorView generation integration', () => {
saveEditorProjectLayoutMock,
});
beforeEach(() => {
window.localStorage.clear();
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
'data:image/png;base64,marked-ui-design',
);
});
const openGenerationDimensionsPanel = (
panel: HTMLElement,
labelPrefix = '生成图片',
@@ -2737,8 +2751,8 @@ describe('ImageCanvasEditorView generation integration', () => {
height: 96,
},
],
prompt: '提取画面中的所有独立并整理成spritesheet',
actualPrompt: '提取画面中的所有独立并整理成spritesheet',
prompt: '提取被红色框框选的素材并整理成spritesheet',
actualPrompt: '提取被红色框框选的素材并整理成spritesheet',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'ui-extract-task-1',
@@ -2762,10 +2776,71 @@ describe('ImageCanvasEditorView generation integration', () => {
});
fireEvent.click(screen.getByRole('button', { name: '提取素材' }));
const extractionToolbar = await screen.findByRole('toolbar', {
name: 'UI素材框选工具',
});
expect(
within(extractionToolbar).getByRole<HTMLButtonElement>('button', {
name: '提取',
}).disabled,
).toBe(true);
expect(extractEditorUiDesignAssetsMock).not.toHaveBeenCalled();
const extractionCanvas = screen.getByRole('application', {
name: 'UI素材框选画布',
});
vi.spyOn(extractionCanvas, 'getBoundingClientRect').mockReturnValue({
x: 120,
y: 140,
left: 120,
top: 140,
right: 680,
bottom: 455,
width: 560,
height: 315,
toJSON: () => ({}),
} as DOMRect);
dispatchPointerEvent(extractionCanvas, 'pointerdown', {
pointerId: 201,
clientX: 140,
clientY: 160,
});
dispatchPointerEvent(extractionCanvas, 'pointermove', {
pointerId: 201,
clientX: 300,
clientY: 260,
});
dispatchPointerEvent(extractionCanvas, 'pointerup', {
pointerId: 201,
clientX: 300,
clientY: 260,
});
expect(
within(extractionToolbar).getByRole<HTMLButtonElement>('button', {
name: '提取',
}).disabled,
).toBe(false);
fireEvent.click(
within(extractionToolbar).getByRole('button', { name: '提取' }),
);
await waitFor(() => {
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
expect.objectContaining({
source: 'data:image/png;base64,dWktZGVzaWdu',
marks: expect.arrayContaining([
expect.objectContaining({
tool: 'rect',
}),
]),
}),
);
});
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'data:image/png;base64,dWktZGVzaWdu',
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
projectId: 'editor-project-ui-extract',
assetFolderId: 'project',
spritesheetLabel: '战斗UI设计图 素材图集',
@@ -2773,7 +2848,7 @@ describe('ImageCanvasEditorView generation integration', () => {
fields: expect.arrayContaining([
expect.objectContaining({
title: '提取提示词',
value: '提取画面中的所有独立并整理成spritesheet',
value: '提取被红色框框选的素材并整理成spritesheet',
}),
]),
references: expect.arrayContaining([
@@ -2801,7 +2876,7 @@ describe('ImageCanvasEditorView generation integration', () => {
fields: expect.arrayContaining([
expect.objectContaining({
title: '提取提示词',
value: '提取画面中的所有独立并整理成spritesheet',
value: '提取被红色框框选的素材并整理成spritesheet',
}),
]),
}),
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
@@ -130,6 +130,10 @@ function createStageProps(): ImageCanvasStageViewProps {
generateDialog: null,
cropExpandPanel: null,
cropExpandSourceLayer: null,
uiAssetExtractionState: null,
uiAssetExtractionSourceLayer: null,
quickEditSelectionState: null,
quickEditSelectionSourceLayer: null,
generationComposerStyle: null,
selectedToolbarStyle: null,
uploadDropTarget: null,
@@ -169,6 +173,16 @@ function createStageProps(): ImageCanvasStageViewProps {
onOpenCropExpandPanel: vi.fn(),
onRemoveBackground: vi.fn(),
onExtractUiDesignAssets: vi.fn(),
onUiAssetExtractionToolChange: vi.fn(),
onUiAssetExtractionPointerStart: vi.fn(),
onUiAssetExtractionPointerMove: vi.fn(),
onUiAssetExtractionPointerEnd: vi.fn(),
onCancelUiAssetExtraction: vi.fn(),
onSubmitUiAssetExtraction: vi.fn(),
onQuickEditSelectionToolChange: vi.fn(),
onQuickEditSelectionPointerStart: vi.fn(),
onQuickEditSelectionPointerMove: vi.fn(),
onQuickEditSelectionPointerEnd: vi.fn(),
onOpenCharacterAnimationPanel: vi.fn(),
onDownloadLayer: vi.fn(),
onPasteCanvasClipboard: vi.fn(),
@@ -264,4 +278,44 @@ describe('ImageCanvasEditorShellView', () => {
expect((preview as HTMLElement).style.left).toBe('0px');
expect((preview as HTMLElement).style.top).toBe('48px');
});
it('renders quick edit selection tools through the stage props', () => {
const layer = createLayer();
const handleQuickEditToolChange = vi.fn();
const stageProps = {
...createStageProps(),
layers: [layer],
quickEditSelectionSourceLayer: layer,
quickEditSelectionState: {
sourceLayerId: layer.id,
tool: 'rect' as const,
marks: [],
draftMark: null,
status: 'idle' as const,
},
onQuickEditSelectionToolChange: handleQuickEditToolChange,
};
render(
<ImageCanvasEditorShellView
editorRootRef={createRef<HTMLElement>()}
uploadInputRef={createRef<HTMLInputElement>()}
onUploadInputChange={vi.fn()}
assetDragPreview={null}
sidebarProps={createSidebarProps()}
topbarProps={createTopbarProps()}
stageProps={stageProps}
metadataProps={createMetadataProps()}
/>,
);
const toolbar = screen.getByRole('toolbar', {
name: '快速编辑框选工具',
});
fireEvent.click(
within(toolbar).getByRole('button', { name: '椭圆框选' }),
);
expect(handleQuickEditToolChange).toHaveBeenCalledWith('ellipse');
});
});
@@ -677,6 +677,20 @@ export function ImageCanvasEditorView() {
pickUiDesignSpecFromLayer,
hideGeneratedLayerPanelAfterBlur,
clearDeletedLayerGenerationState,
uiAssetExtractionState,
uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
changeQuickEditSelectionTool,
startUiAssetExtractionPointer,
startQuickEditSelectionPointer,
moveUiAssetExtractionPointer,
moveQuickEditSelectionPointer,
endUiAssetExtractionPointer,
endQuickEditSelectionPointer,
cancelUiAssetExtraction,
submitUiAssetExtraction,
generationComposerStyle,
generationComposerNode,
switchGenerationTool,
@@ -1069,6 +1083,10 @@ export function ImageCanvasEditorView() {
generateDialog,
cropExpandPanel: generationSurface.cropExpandPanel,
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
uiAssetExtractionState,
uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
generationComposerStyle,
selectedToolbarStyle,
uploadDropTarget,
@@ -1115,6 +1133,16 @@ export function ImageCanvasEditorView() {
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
onExtractUiDesignAssets: extractUiDesignAssets,
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
onUiAssetExtractionPointerStart: startUiAssetExtractionPointer,
onUiAssetExtractionPointerMove: moveUiAssetExtractionPointer,
onUiAssetExtractionPointerEnd: endUiAssetExtractionPointer,
onCancelUiAssetExtraction: cancelUiAssetExtraction,
onSubmitUiAssetExtraction: () => void submitUiAssetExtraction(),
onQuickEditSelectionToolChange: changeQuickEditSelectionTool,
onQuickEditSelectionPointerStart: startQuickEditSelectionPointer,
onQuickEditSelectionPointerMove: moveQuickEditSelectionPointer,
onQuickEditSelectionPointerEnd: endQuickEditSelectionPointer,
onOpenCharacterAnimationPanel: openCharacterAnimationPanel,
onDownloadLayer: exportLayerImage,
onPasteCanvasClipboard: pasteCanvasClipboard,
@@ -96,7 +96,7 @@ export const DEFAULT_ICON_DESCRIPTIONS = [
'冻结按钮',
];
export const UI_DESIGN_ASSET_EXTRACTION_PROMPT =
'提取画面中的所有独立并整理成spritesheet';
'提取被红色框框选的素材并整理成spritesheet';
export const QUICK_EDIT_SIZE_PRESETS = [
'1024x1024',
'1536x1024',
@@ -10,8 +10,8 @@ import type {
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
import type {
CanvasClipboard,
CanvasAssetKind,
CanvasClipboard,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
@@ -28,6 +28,11 @@ import type {
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView';
import { ImageCanvasSelectedLayerToolbarView } from './ImageCanvasSelectedLayerToolbarView';
import type {
UiAssetExtractionState,
UiAssetExtractionTool,
} from './ImageCanvasUiAssetExtractionModel';
import { ImageCanvasUiAssetExtractionOverlayView } from './ImageCanvasUiAssetExtractionOverlayView';
import { ImageCanvasWorldView } from './ImageCanvasWorldView';
export type ImageCanvasStageViewProps = {
@@ -50,6 +55,10 @@ export type ImageCanvasStageViewProps = {
generateDialog: GenerateDialogState | null;
cropExpandPanel: CropExpandPanelState | null;
cropExpandSourceLayer: CanvasLayer | null;
uiAssetExtractionState: UiAssetExtractionState | null;
uiAssetExtractionSourceLayer: CanvasLayer | null;
quickEditSelectionState: UiAssetExtractionState | null;
quickEditSelectionSourceLayer: CanvasLayer | null;
generationComposerStyle: CSSProperties | null;
selectedToolbarStyle: CSSProperties | null;
uploadDropTarget: 'canvas' | 'assets' | null;
@@ -108,6 +117,16 @@ export type ImageCanvasStageViewProps = {
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
onRemoveBackground: (layer: CanvasLayer) => void;
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool) => 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;
onQuickEditSelectionPointerStart: (point: { x: number; y: number }) => void;
onQuickEditSelectionPointerMove: (point: { x: number; y: number }) => void;
onQuickEditSelectionPointerEnd: () => void;
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
onDownloadLayer: (layer: CanvasLayer) => void;
onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void;
@@ -165,6 +184,10 @@ export function ImageCanvasStageView({
generateDialog,
cropExpandPanel,
cropExpandSourceLayer,
uiAssetExtractionState,
uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
generationComposerStyle,
selectedToolbarStyle,
uploadDropTarget,
@@ -205,6 +228,16 @@ export function ImageCanvasStageView({
onOpenCropExpandPanel,
onRemoveBackground,
onExtractUiDesignAssets,
onUiAssetExtractionToolChange,
onUiAssetExtractionPointerStart,
onUiAssetExtractionPointerMove,
onUiAssetExtractionPointerEnd,
onCancelUiAssetExtraction,
onSubmitUiAssetExtraction,
onQuickEditSelectionToolChange,
onQuickEditSelectionPointerStart,
onQuickEditSelectionPointerMove,
onQuickEditSelectionPointerEnd,
onOpenCharacterAnimationPanel,
onDownloadLayer,
onPasteCanvasClipboard,
@@ -287,7 +320,11 @@ export function ImageCanvasStageView({
/>
<ImageCanvasSelectedLayerToolbarView
selectedLayer={selectedLayer}
selectedLayer={
uiAssetExtractionState || quickEditSelectionState
? null
: selectedLayer
}
selectedToolbarStyle={selectedToolbarStyle}
onOpenQuickEditPanel={onOpenQuickEditPanel}
onOpenRedrawPanel={onOpenRedrawPanel}
@@ -298,6 +335,30 @@ export function ImageCanvasStageView({
onDownloadLayer={onDownloadLayer}
/>
<ImageCanvasUiAssetExtractionOverlayView
sourceLayer={uiAssetExtractionSourceLayer}
viewport={viewport}
state={uiAssetExtractionState}
onToolChange={onUiAssetExtractionToolChange}
onPointerStart={onUiAssetExtractionPointerStart}
onPointerMove={onUiAssetExtractionPointerMove}
onPointerEnd={onUiAssetExtractionPointerEnd}
onCancel={onCancelUiAssetExtraction}
onSubmit={onSubmitUiAssetExtraction}
/>
<ImageCanvasUiAssetExtractionOverlayView
variant="quick-edit"
showIndexLabels
sourceLayer={quickEditSelectionSourceLayer}
viewport={viewport}
state={quickEditSelectionState}
onToolChange={onQuickEditSelectionToolChange}
onPointerStart={onQuickEditSelectionPointerStart}
onPointerMove={onQuickEditSelectionPointerMove}
onPointerEnd={onQuickEditSelectionPointerEnd}
/>
<ImageCanvasContextMenusView
viewport={viewport}
contextMenu={contextMenu}
@@ -0,0 +1,131 @@
export type UiAssetExtractionTool = 'rect' | 'ellipse' | 'brush';
export type UiAssetExtractionPoint = {
x: number;
y: number;
};
export type UiAssetExtractionBoxMark = {
id: string;
tool: 'rect' | 'ellipse';
x: number;
y: number;
width: number;
height: number;
};
export type UiAssetExtractionBrushMark = {
id: string;
tool: 'brush';
points: UiAssetExtractionPoint[];
};
export type UiAssetExtractionMark =
| UiAssetExtractionBoxMark
| UiAssetExtractionBrushMark;
export type UiAssetExtractionState = {
sourceLayerId: string;
tool: UiAssetExtractionTool;
marks: UiAssetExtractionMark[];
draftMark: UiAssetExtractionMark | null;
status: 'idle' | 'extracting' | 'failed';
errorMessage?: string;
};
export function createUiAssetExtractionState(
sourceLayerId: string,
): UiAssetExtractionState {
return {
sourceLayerId,
tool: 'rect',
marks: [],
draftMark: null,
status: 'idle',
};
}
export function createUiAssetExtractionDraftMark({
tool,
id,
point,
}: {
tool: UiAssetExtractionTool;
id: string;
point: UiAssetExtractionPoint;
}): UiAssetExtractionMark {
if (tool === 'brush') {
return { id, tool, points: [point] };
}
return {
id,
tool,
x: point.x,
y: point.y,
width: 0,
height: 0,
};
}
export function updateUiAssetExtractionDraftMark(
draftMark: UiAssetExtractionMark,
point: UiAssetExtractionPoint,
): UiAssetExtractionMark {
if (draftMark.tool === 'brush') {
return {
...draftMark,
points: [...draftMark.points, point],
};
}
return {
...draftMark,
width: point.x - draftMark.x,
height: point.y - draftMark.y,
};
}
export function normalizeUiAssetExtractionMark(
mark: UiAssetExtractionMark,
): UiAssetExtractionMark | null {
if (mark.tool === 'brush') {
const points = mark.points.filter(
(point) => Number.isFinite(point.x) && Number.isFinite(point.y),
);
return points.length >= 2 ? { ...mark, points } : null;
}
if (
!Number.isFinite(mark.x) ||
!Number.isFinite(mark.y) ||
!Number.isFinite(mark.width) ||
!Number.isFinite(mark.height)
) {
return null;
}
const left = Math.min(mark.x, mark.x + mark.width);
const top = Math.min(mark.y, mark.y + mark.height);
const width = Math.abs(mark.width);
const height = Math.abs(mark.height);
if (width < 4 || height < 4) {
return null;
}
return {
...mark,
x: left,
y: top,
width,
height,
};
}
export function buildUiAssetExtractionBrushPath(
mark: UiAssetExtractionBrushMark,
) {
const [firstPoint, ...restPoints] = mark.points;
if (!firstPoint) {
return '';
}
return [
`M ${firstPoint.x} ${firstPoint.y}`,
...restPoints.map((point) => `L ${point.x} ${point.y}`),
].join(' ');
}
@@ -0,0 +1,125 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel';
import { ImageCanvasUiAssetExtractionOverlayView } from './ImageCanvasUiAssetExtractionOverlayView';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-source',
resourceId: 'resource-source',
title: '源图',
src: 'data:image/png;base64,source',
x: 100,
y: 120,
width: 320,
height: 240,
originalWidth: 640,
originalHeight: 480,
zIndex: 4,
sourceType: 'uploaded',
...overrides,
};
}
function createState(
overrides: Partial<UiAssetExtractionState> = {},
): UiAssetExtractionState {
return {
sourceLayerId: 'layer-source',
tool: 'rect',
marks: [],
draftMark: null,
status: 'idle',
...overrides,
};
}
describe('ImageCanvasUiAssetExtractionOverlayView', () => {
it('renders quick edit tools vertically on the right without extraction actions', () => {
const handleToolChange = vi.fn();
render(
<ImageCanvasUiAssetExtractionOverlayView
variant="quick-edit"
sourceLayer={createLayer()}
viewport={{ x: 10, y: 20, scale: 1 }}
state={createState()}
onToolChange={handleToolChange}
onPointerStart={vi.fn()}
onPointerMove={vi.fn()}
onPointerEnd={vi.fn()}
/>,
);
const toolbar = screen.getByRole('toolbar', {
name: '快速编辑框选工具',
});
expect(toolbar.className).toContain(
'image-canvas-editor__ui-extraction-toolbar--right',
);
expect(within(toolbar).queryByText('框选素材')).toBeNull();
expect(within(toolbar).queryByText('0')).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '提取' }),
).toBeNull();
expect(
within(toolbar).getByRole('button', { name: '矩形框选' }),
).toBeTruthy();
expect(
within(toolbar).getByRole('button', { name: '椭圆框选' }),
).toBeTruthy();
fireEvent.click(
within(toolbar).getByRole('button', { name: '画笔自由框选' }),
);
expect(handleToolChange).toHaveBeenCalledWith('brush');
});
it('shows numbered labels for quick edit red selections', () => {
const { container } = render(
<ImageCanvasUiAssetExtractionOverlayView
variant="quick-edit"
showIndexLabels
sourceLayer={createLayer()}
viewport={{ x: 0, y: 0, scale: 1 }}
state={createState({
marks: [
{
id: 'mark-1',
tool: 'rect',
x: 20,
y: 30,
width: 120,
height: 90,
},
{
id: 'mark-2',
tool: 'ellipse',
x: 220,
y: 120,
width: 80,
height: 60,
},
],
})}
onToolChange={vi.fn()}
onPointerStart={vi.fn()}
onPointerMove={vi.fn()}
onPointerEnd={vi.fn()}
/>,
);
const labels = Array.from(
container.querySelectorAll(
'.image-canvas-editor__ui-extraction-mark-label',
),
).map((node) => node.textContent);
expect(labels).toEqual(['1', '2']);
});
});
@@ -0,0 +1,287 @@
import { Circle, MousePointer2, 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 type {
UiAssetExtractionMark,
UiAssetExtractionState,
UiAssetExtractionTool,
} from './ImageCanvasUiAssetExtractionModel';
import { buildUiAssetExtractionBrushPath } from './ImageCanvasUiAssetExtractionModel';
type ImageCanvasUiAssetExtractionOverlayViewProps = {
variant?: 'ui-extraction' | 'quick-edit';
showIndexLabels?: boolean;
sourceLayer: CanvasLayer | null;
viewport: CanvasViewport;
state: UiAssetExtractionState | null;
onToolChange: (tool: UiAssetExtractionTool) => void;
onPointerStart: (point: { x: number; y: number }) => void;
onPointerMove: (point: { x: number; y: number }) => void;
onPointerEnd: () => void;
onCancel?: () => void;
onSubmit?: () => void;
};
const TOOL_OPTIONS: Array<{
value: UiAssetExtractionTool;
label: string;
icon: ComponentType<SVGProps<SVGSVGElement>>;
}> = [
{ value: 'rect', label: '矩形框选', icon: Square },
{ value: 'ellipse', label: '椭圆框选', icon: Circle },
{ value: 'brush', label: '画笔自由框选', icon: Paintbrush },
];
function resolvePointerImagePoint(
event: ReactPointerEvent<SVGSVGElement>,
layer: CanvasLayer,
) {
const rect = event.currentTarget.getBoundingClientRect();
const fallbackWidth = Math.max(1, layer.width);
const fallbackHeight = Math.max(1, layer.height);
const ratioX =
(event.clientX - rect.left) / (rect.width > 0 ? rect.width : fallbackWidth);
const ratioY =
(event.clientY - rect.top) /
(rect.height > 0 ? rect.height : fallbackHeight);
return {
x: Math.min(
Math.max(ratioX * Math.max(1, layer.originalWidth), 0),
Math.max(1, layer.originalWidth),
),
y: Math.min(
Math.max(ratioY * Math.max(1, layer.originalHeight), 0),
Math.max(1, layer.originalHeight),
),
};
}
function resolveMarkLabelPoint(mark: UiAssetExtractionMark) {
if (mark.tool === 'brush') {
return mark.points[0] ?? { x: 0, y: 0 };
}
return {
x: Math.min(mark.x, mark.x + mark.width),
y: Math.min(mark.y, mark.y + mark.height),
};
}
function renderMark(
mark: UiAssetExtractionMark,
index: number,
showIndexLabels: boolean,
) {
const markNode =
mark.tool === 'brush' ? (
<path
d={buildUiAssetExtractionBrushPath(mark)}
className="image-canvas-editor__ui-extraction-mark"
/>
) : mark.tool === 'ellipse' ? (
<ellipse
cx={mark.x + mark.width / 2}
cy={mark.y + mark.height / 2}
rx={Math.abs(mark.width) / 2}
ry={Math.abs(mark.height) / 2}
className="image-canvas-editor__ui-extraction-mark"
/>
) : (
<rect
x={Math.min(mark.x, mark.x + mark.width)}
y={Math.min(mark.y, mark.y + mark.height)}
width={Math.abs(mark.width)}
height={Math.abs(mark.height)}
className="image-canvas-editor__ui-extraction-mark"
/>
);
if (!showIndexLabels) {
return <g key={mark.id}>{markNode}</g>;
}
const labelPoint = resolveMarkLabelPoint(mark);
// 中文注释:序号直接落在红色圈选框左上角,生成模型可通过“1号红色圈选框”稳定对应用户修改说明。
return (
<g key={mark.id}>
{markNode}
<g
className="image-canvas-editor__ui-extraction-mark-label"
transform={`translate(${labelPoint.x} ${labelPoint.y})`}
>
<circle r="16" cx="0" cy="0" />
<text x="0" y="0">
{index + 1}
</text>
</g>
</g>
);
}
export function ImageCanvasUiAssetExtractionOverlayView({
variant = 'ui-extraction',
showIndexLabels = false,
sourceLayer,
viewport,
state,
onToolChange,
onPointerStart,
onPointerMove,
onPointerEnd,
onCancel,
onSubmit,
}: ImageCanvasUiAssetExtractionOverlayViewProps) {
if (!sourceLayer || !state) {
return null;
}
const isQuickEdit = variant === 'quick-edit';
const marks = [...state.marks, ...(state.draftMark ? [state.draftMark] : [])];
const canSubmit = state.marks.length > 0 && state.status !== 'extracting';
const screenFrame = {
left: viewport.x + sourceLayer.x * viewport.scale,
top: viewport.y + sourceLayer.y * viewport.scale,
width: sourceLayer.width * viewport.scale,
height: sourceLayer.height * viewport.scale,
};
return (
<>
<div
className={`image-canvas-editor__ui-extraction-toolbar ${
isQuickEdit
? '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),
}}
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;
return (
<PlatformIconButton
key={option.value}
variant="darkMini"
className={
state.tool === option.value
? '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)}
/>
);
})}
{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"
role="application"
aria-label="UI素材框选画布"
viewBox={`0 0 ${Math.max(1, sourceLayer.originalWidth)} ${Math.max(
1,
sourceLayer.originalHeight,
)}`}
preserveAspectRatio="none"
style={{
left: screenFrame.left,
top: screenFrame.top,
width: screenFrame.width,
height: screenFrame.height,
zIndex: sourceLayer.zIndex + 40,
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId);
onPointerStart(resolvePointerImagePoint(event, sourceLayer));
}}
onPointerMove={(event) => {
event.preventDefault();
event.stopPropagation();
onPointerMove(resolvePointerImagePoint(event, sourceLayer));
}}
onPointerUp={(event) => {
event.preventDefault();
event.stopPropagation();
onPointerEnd();
}}
onPointerCancel={(event) => {
event.preventDefault();
event.stopPropagation();
onPointerEnd();
}}
>
<rect
x="0"
y="0"
width={sourceLayer.originalWidth}
height={sourceLayer.originalHeight}
className="image-canvas-editor__ui-extraction-hit-area"
/>
{marks.map((mark, index) =>
renderMark(mark, index, showIndexLabels && index < state.marks.length),
)}
</svg>
{state.errorMessage ? (
<div
className="image-canvas-editor__ui-extraction-error"
role="alert"
style={{
left: screenFrame.left + screenFrame.width / 2,
top: screenFrame.top + screenFrame.height + 12,
}}
>
{state.errorMessage}
</div>
) : null}
</>
);
}
@@ -0,0 +1,141 @@
import type { UiAssetExtractionMark } from './ImageCanvasUiAssetExtractionModel';
function assertCanvasSupport() {
if (typeof document === 'undefined') {
throw new Error('当前环境不支持图片编辑');
}
}
function loadImageElement(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('图片读取失败'));
image.src = source;
});
}
function createRasterCanvas(width: number, height: number) {
assertCanvasSupport();
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('当前浏览器不支持图片编辑');
}
return { canvas, context };
}
function resolveImageSize(image: HTMLImageElement) {
return {
width: Math.max(1, image.naturalWidth || image.width || 1),
height: Math.max(1, image.naturalHeight || image.height || 1),
};
}
function prepareRedSelectionStroke(context: CanvasRenderingContext2D) {
context.strokeStyle = '#ff2b2b';
context.lineWidth = 6;
context.lineCap = 'round';
context.lineJoin = 'round';
}
function resolveMarkLabelPoint(mark: UiAssetExtractionMark) {
if (mark.tool === 'brush') {
return mark.points[0] ?? { x: 0, y: 0 };
}
return {
x: Math.min(mark.x, mark.x + mark.width),
y: Math.min(mark.y, mark.y + mark.height),
};
}
function drawRedSelectionIndexLabel(
context: CanvasRenderingContext2D,
mark: UiAssetExtractionMark,
index: number,
) {
const point = resolveMarkLabelPoint(mark);
const radius = 18;
const x = Math.max(radius + 2, Math.min(point.x, context.canvas.width - radius - 2));
const y = Math.max(radius + 2, Math.min(point.y, context.canvas.height - radius - 2));
context.save();
context.fillStyle = '#ff2b2b';
context.strokeStyle = '#ffffff';
context.lineWidth = 4;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
context.stroke();
context.fillStyle = '#ffffff';
context.font = '900 24px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(String(index + 1), x, y + 1);
context.restore();
}
export async function renderUiDesignAssetExtractionMarkedImage({
source,
marks,
showIndexLabels = false,
}: {
source: string;
marks: UiAssetExtractionMark[];
showIndexLabels?: boolean;
}) {
if (!marks.length) {
throw new Error('请至少框选一个素材');
}
const image = await loadImageElement(source);
const { width, height } = resolveImageSize(image);
const { canvas, context } = createRasterCanvas(width, height);
context.drawImage(image, 0, 0, width, height);
prepareRedSelectionStroke(context);
for (const mark of marks) {
context.beginPath();
if (mark.tool === 'rect') {
context.rect(
Math.min(mark.x, mark.x + mark.width),
Math.min(mark.y, mark.y + mark.height),
Math.abs(mark.width),
Math.abs(mark.height),
);
} else if (mark.tool === 'ellipse') {
const width = Math.abs(mark.width);
const height = Math.abs(mark.height);
context.ellipse(
Math.min(mark.x, mark.x + mark.width) + width / 2,
Math.min(mark.y, mark.y + mark.height) + height / 2,
width / 2,
height / 2,
0,
0,
Math.PI * 2,
);
} else if (mark.tool === 'brush') {
const [firstPoint, ...restPoints] = mark.points;
if (!firstPoint) {
continue;
}
context.moveTo(firstPoint.x, firstPoint.y);
restPoints.forEach((point) => {
context.lineTo(point.x, point.y);
});
context.stroke();
continue;
}
context.stroke();
}
if (showIndexLabels) {
marks.forEach((mark, index) =>
drawRedSelectionIndexLabel(context, mark, index),
);
}
return canvas.toDataURL('image/png');
}
@@ -20,6 +20,7 @@ import type {
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { createQuickEditPanelDraft } from './ImageCanvasGenerationDialogModel';
import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow';
@@ -29,6 +30,7 @@ const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
);
const editEditorImageMock = vi.hoisted(() => vi.fn());
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const generateEditorImageMock = vi.hoisted(() => vi.fn());
@@ -55,6 +57,11 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
};
});
vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
renderUiDesignAssetExtractionMarkedImage:
renderUiDesignAssetExtractionMarkedImageMock,
}));
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-source',
@@ -116,6 +123,7 @@ function createIconResult(name: string, imageSrc: string) {
function SubmissionWorkflowHarness({
initialDialog = null,
initialQuickEditPanel = null,
initialQuickEditSelectionState = null,
initialCharacterAnimationPanel = null,
initialLayers = [createLayer()],
projectId = undefined,
@@ -123,6 +131,7 @@ function SubmissionWorkflowHarness({
}: {
initialDialog?: GenerateDialogState | null;
initialQuickEditPanel?: QuickEditPanelState | null;
initialQuickEditSelectionState?: UiAssetExtractionState | null;
initialCharacterAnimationPanel?: CharacterAnimationPanelState | null;
initialLayers?: CanvasLayer[];
projectId?: string | null;
@@ -169,6 +178,7 @@ function SubmissionWorkflowHarness({
layerCounterRef,
quickEditPanel,
quickEditSourceLayer,
quickEditSelectionState: initialQuickEditSelectionState,
setQuickEditPanel,
characterAnimationPanel,
characterAnimationDialog: activeCanvasDialog,
@@ -290,6 +300,27 @@ function SubmissionWorkflowHarness({
>
UI素材
</button>
<button
type="button"
onClick={() => {
if (sourceLayer) {
void workflow.extractUiDesignAssets(sourceLayer, {
marks: [
{
id: 'mark-1',
tool: 'rect',
x: 10,
y: 20,
width: 80,
height: 60,
},
],
});
}
}}
>
UI素材
</button>
<button
type="button"
onClick={() => void workflow.submitCharacterAnimation()}
@@ -324,6 +355,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
resolveEditorImageReferenceDataUrlForGenerationMock.mockReset();
editEditorImageMock.mockReset();
extractEditorUiDesignAssetsMock.mockReset();
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
generateEditorCharacterAnimationMock.mockReset();
generateEditorIconSpritesheetMock.mockReset();
generateEditorImageMock.mockReset();
@@ -334,6 +366,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
resolveEditorImageReferenceDataUrlForGenerationMock.mockImplementation(
async (src: string) => src,
);
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
'data:image/png;base64,marked-ui-design',
);
});
it('submits quick edits, clears the panel, and fits the source plus result', async () => {
@@ -377,6 +412,81 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('fit-count').textContent).toBe('1');
});
it('submits quick edits with the numbered red selection image as the final source reference', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色' }),
);
render(
<SubmissionWorkflowHarness
initialQuickEditPanel={{
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
quickEditReferences: [
{
id: 'ref-style',
label: '风格参考',
src: 'https://assets.example.test/style.png',
},
],
}}
initialQuickEditSelectionState={{
sourceLayerId: 'layer-source',
tool: 'rect',
marks: [
{
id: 'quick-edit-selection-mark-1',
tool: 'rect',
x: 24,
y: 36,
width: 160,
height: 120,
},
],
draftMark: null,
status: 'idle',
}}
/>,
);
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
'data:image/png;base64,marked-source',
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
{
source: 'data:image/png;base64,source.png',
marks: [
{
id: 'quick-edit-selection-mark-1',
tool: 'rect',
x: 24,
y: 36,
width: 160,
height: 120,
},
],
showIndexLabels: true,
},
);
});
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrcs: [
'data:image/png;base64,style.png',
'data:image/png;base64,marked-source',
],
}),
);
});
});
it('keeps failed quick edit panels visible with the normalized prompt', async () => {
generateEditorImageMock.mockRejectedValueOnce(new Error('修图失败'));
render(
@@ -1112,8 +1222,8 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt: '提取画面中的所有独立并整理成spritesheet',
actualPrompt: '提取画面中的所有独立并整理成spritesheet',
prompt: '提取被红色框框选的素材并整理成spritesheet',
actualPrompt: '提取被红色框框选的素材并整理成spritesheet',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
@@ -1161,6 +1271,66 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('fit-count').textContent).toBe('1');
});
it('renders selected UI marks into the extraction reference image', async () => {
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt: '仅提取被红色框框选的素材并整理成spritesheet',
actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
priceMudPoints: 20,
iconImageSrcs: [
createIconResult('素材 1', 'data:image/png;base64,asset-1'),
],
});
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
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,
},
],
});
});
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '提取提示词',
value: '仅提取被红色框框选的素材并整理成spritesheet',
}),
]),
}),
}),
);
});
});
it('keeps character generation dialog data after the result is created', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ width: 768, height: 768 }),
@@ -70,6 +70,11 @@ import {
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
} from './ImageCanvasGenerationSubmissionModel';
import type {
UiAssetExtractionMark,
UiAssetExtractionState,
} from './ImageCanvasUiAssetExtractionModel';
import { renderUiDesignAssetExtractionMarkedImage } from './ImageCanvasUiAssetExtractionRasterModel';
type CanvasSize = { width: number; height: number };
@@ -77,6 +82,11 @@ type CanvasGenerationDialogUpdater = (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null;
type UiDesignAssetExtractionOptions = {
marks?: UiAssetExtractionMark[];
suppressAlert?: boolean;
};
type GenerationSubmissionWorkflowOptions = {
layers: CanvasLayer[];
canvasSize: CanvasSize;
@@ -84,6 +94,7 @@ type GenerationSubmissionWorkflowOptions = {
layerCounterRef: MutableRefObject<number>;
quickEditPanel: QuickEditPanelState | null;
quickEditSourceLayer: CanvasLayer | null;
quickEditSelectionState: UiAssetExtractionState | null;
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
characterAnimationPanel: CharacterAnimationPanelState | null;
characterAnimationDialog: CanvasGenerationDialogState | null;
@@ -150,6 +161,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
layerCounterRef,
quickEditPanel,
quickEditSourceLayer,
quickEditSelectionState,
setQuickEditPanel,
characterAnimationPanel,
characterAnimationDialog,
@@ -543,15 +555,24 @@ export function useImageCanvasGenerationSubmissionWorkflow({
);
const extractUiDesignAssets = useCallback(
async (sourceLayer: CanvasLayer) => {
async (
sourceLayer: CanvasLayer,
options: UiDesignAssetExtractionOptions = {},
) => {
if (sourceLayer.assetKind !== 'ui-design') {
return;
}
try {
const sourceImageSrc = await resolveEditorImageReferenceDataUrl(
let sourceImageSrc = await resolveEditorImageReferenceDataUrl(
sourceLayer.objectKey?.trim() || sourceLayer.src,
);
if (options.marks?.length) {
sourceImageSrc = await renderUiDesignAssetExtractionMarkedImage({
source: sourceImageSrc,
marks: options.marks,
});
}
const generationInputs =
buildUiDesignAssetExtractionGenerationInputs(sourceLayer);
const generated = await extractEditorUiDesignAssets({
@@ -583,6 +604,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
fitLayers([sourceLayer, ...nextLayers]);
}
} catch (error) {
if (options.suppressAlert) {
throw error;
}
window.alert(
error instanceof Error && error.message.trim()
? error.message
@@ -832,9 +856,22 @@ export function useImageCanvasGenerationSubmissionWorkflow({
);
return;
}
const sourceReferenceImageSrc = await resolveEditorImageReferenceDataUrl(
let sourceReferenceImageSrc = await resolveEditorImageReferenceDataUrl(
quickEditSourceLayer.objectKey?.trim() || quickEditSourceLayer.src,
);
const quickEditSelectionMarks =
panelMode === 'quick-edit' &&
quickEditSelectionState?.sourceLayerId === quickEditSourceLayer.id
? quickEditSelectionState.marks
: [];
if (quickEditSelectionMarks.length > 0) {
sourceReferenceImageSrc =
await renderUiDesignAssetExtractionMarkedImage({
source: sourceReferenceImageSrc,
marks: quickEditSelectionMarks,
showIndexLabels: true,
});
}
const generationInputs =
panelMode === 'redraw'
? buildEditGenerationInputs(
@@ -912,6 +949,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
assetFolderId,
quickEditPanel,
quickEditSourceLayer,
quickEditSelectionState,
selectSingleLayer,
setQuickEditPanel,
updateCanvasGenerationDialogById,
@@ -1083,7 +1121,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setGenerateDialog,
updateCanvasGenerationDialogById,
upsertGeneratedAsset,
viewport,
],
);
@@ -200,6 +200,19 @@ function GenerationWorkflowHarness({
? `${workflow.cropExpandPanel.sourceLayerId}:${workflow.cropExpandPanel.ratio}:${workflow.cropExpandPanel.frame.x}:${workflow.cropExpandPanel.frame.y}:${workflow.cropExpandPanel.frame.width}:${workflow.cropExpandPanel.frame.height}:${workflow.cropExpandPanel.status}`
: '-'}
</span>
<span data-testid="ui-extraction">
{workflow.uiAssetExtractionState
? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}`
: '-'}
</span>
<span data-testid="ui-extraction-source">
{workflow.uiAssetExtractionSourceLayer?.id ?? '-'}
</span>
<span data-testid="quick-edit-selection">
{workflow.quickEditSelectionState
? `${workflow.quickEditSelectionState.sourceLayerId}:${workflow.quickEditSelectionState.tool}:${workflow.quickEditSelectionState.marks.length}`
: '-'}
</span>
<span data-testid="character-animation">
{workflow.characterAnimationPanel
? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}`
@@ -493,6 +506,28 @@ function GenerationWorkflowHarness({
>
</button>
<button
type="button"
onClick={() => workflow.changeQuickEditSelectionTool('ellipse')}
>
</button>
<button
type="button"
onClick={() => workflow.changeQuickEditSelectionTool('brush')}
>
</button>
<button
type="button"
onClick={() => {
workflow.startQuickEditSelectionPointer({ x: 20, y: 30 });
workflow.moveQuickEditSelectionPointer({ x: 120, y: 150 });
workflow.endQuickEditSelectionPointer();
}}
>
</button>
<button type="button" onClick={() => void workflow.submitQuickEdit()}>
</button>
@@ -504,6 +539,32 @@ function GenerationWorkflowHarness({
>
</button>
<button
type="button"
onClick={() =>
workflow.extractUiDesignAssets(
createLayer({ assetKind: 'ui-design' }),
)
}
>
UI素材框选
</button>
<button
type="button"
onClick={() => workflow.changeUiAssetExtractionTool('ellipse')}
>
</button>
<button
type="button"
onClick={() => {
workflow.startUiAssetExtractionPointer({ x: 10, y: 20 });
workflow.moveUiAssetExtractionPointer({ x: 80, y: 100 });
workflow.endUiAssetExtractionPointer();
}}
>
UI素材
</button>
<button
type="button"
onClick={() => {
@@ -947,6 +1008,44 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
});
it('opens quick edit below the source layer and moves the viewport to keep the panel clear', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:idle:-',
);
expect(screen.getByTestId('selected').textContent).toBe('layer-source');
expect(screen.getByTestId('viewport').textContent).toBe('170:-60:1');
});
it('adds numbered quick edit red selections and appends matching prompt lines', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
fireEvent.click(screen.getByRole('button', { name: '快速编辑框选' }));
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
'layer-source:rect:1',
);
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:idle:对1号红色圈选框里的内容做以下修改:',
);
fireEvent.click(
screen.getByRole('button', { name: '选择快速编辑椭圆框选' }),
);
fireEvent.click(screen.getByRole('button', { name: '快速编辑框选' }));
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
'layer-source:ellipse:2',
);
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:idle:对1号红色圈选框里的内容做以下修改:\n对2号红色圈选框里的内容做以下修改:',
);
});
it('submits quick edits beside the source and fits source plus result', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图' }),
@@ -1037,6 +1136,28 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('fit-count').textContent).toBe('1');
});
it('opens UI design extraction as a mark selection state before submitting', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' }));
expect(screen.getByTestId('ui-extraction').textContent).toBe(
'layer-source:rect:0:idle',
);
expect(screen.getByTestId('ui-extraction-source').textContent).toBe(
'layer-source',
);
expect(screen.getByTestId('selected').textContent).toBe('layer-source');
expect(screen.getByTestId('tool').textContent).toBe('select');
fireEvent.click(screen.getByRole('button', { name: '选择椭圆框选' }));
fireEvent.click(screen.getByRole('button', { name: '框选UI素材' }));
expect(screen.getByTestId('ui-extraction').textContent).toBe(
'layer-source:ellipse:1:idle',
);
});
it('clears generation side panels and linked edit dialogs when a source layer is deleted', async () => {
render(<GenerationWorkflowHarness />);
@@ -1047,9 +1168,11 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
fireEvent.click(screen.getByRole('button', { name: '打开修改状态' }));
fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' }));
fireEvent.click(screen.getByRole('button', { name: '清理源图状态' }));
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('ui-extraction').textContent).toBe('-');
expect(screen.getByTestId('dialog').textContent).toBe('-');
});
File diff suppressed because it is too large Load Diff
+99
View File
@@ -7384,6 +7384,105 @@ button.image-canvas-editor__reference-chip:disabled {
transform: none;
}
.image-canvas-editor__ui-extraction-toolbar {
position: absolute;
z-index: 50;
display: inline-flex;
align-items: center;
gap: 0.32rem;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
background: rgba(15, 23, 42, 0.88);
padding: 0.32rem;
color: #ffffff;
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.3);
transform: translate(-50%, -100%);
}
.image-canvas-editor__ui-extraction-toolbar--right {
flex-direction: column;
transform: translate(0, -50%);
}
.image-canvas-editor__ui-extraction-toolbar-title {
display: inline-flex;
align-items: center;
gap: 0.28rem;
padding: 0 0.34rem;
font-size: 0.74rem;
font-weight: 850;
white-space: nowrap;
}
.image-canvas-editor__ui-extraction-tool--active {
border-color: rgba(255, 255, 255, 0.48);
background: rgba(239, 68, 68, 0.92);
}
.image-canvas-editor__ui-extraction-count {
display: inline-flex;
min-width: 1.45rem;
height: 1.45rem;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
font-size: 0.72rem;
font-weight: 900;
}
.image-canvas-editor__ui-extraction-overlay {
position: absolute;
z-index: 50;
cursor: crosshair;
touch-action: none;
}
.image-canvas-editor__ui-extraction-hit-area {
fill: transparent;
}
.image-canvas-editor__ui-extraction-mark {
fill: rgba(255, 43, 43, 0.08);
stroke: #ff2b2b;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 6;
vector-effect: non-scaling-stroke;
}
.image-canvas-editor__ui-extraction-mark-label circle {
fill: #ff2b2b;
stroke: #ffffff;
stroke-width: 3;
vector-effect: non-scaling-stroke;
}
.image-canvas-editor__ui-extraction-mark-label text {
fill: #ffffff;
font-size: 22px;
font-weight: 900;
dominant-baseline: central;
pointer-events: none;
text-anchor: middle;
}
.image-canvas-editor__ui-extraction-error {
position: absolute;
z-index: 51;
max-width: min(24rem, calc(100vw - 2rem));
border-radius: 0.7rem;
background: rgba(127, 29, 29, 0.92);
padding: 0.48rem 0.68rem;
color: #ffffff;
font-size: 0.74rem;
font-weight: 850;
line-height: 1.35;
box-shadow: 0 14px 30px rgba(127, 29, 29, 0.24);
transform: translateX(-50%);
}
.image-canvas-editor__crop-expand-head {
display: flex;
align-items: center;
@@ -830,8 +830,8 @@ describe('editorProjectClient', () => {
height: 128,
},
],
prompt: '提取画面中的所有独立并整理成spritesheet',
actualPrompt: '提取画面中的所有独立并整理成spritesheet',
prompt: '提取被红色框框选的素材并整理成spritesheet',
actualPrompt: '提取被红色框框选的素材并整理成spritesheet',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'ui-asset-extraction-1',