diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f4a522a0a..1846b7b87 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-17 图片画布生成占位统一避让落点 + +- 背景:图片画布的普通图片、规范、角色、图标、视频和 UI 设计图生成入口都会先在画布中新建“即将生成”的占位图;若各入口直接使用当前视口中心,容易压住已有图片或已有生成占位,Lovart 式连续创作体验不稳定。 +- 决策:所有会新建画布生成占位的入口统一经过 `ImageCanvasGenerationPlacementModel` 计算落点。模型以当前视口世界中心为目标,避让所有未隐藏画布图层和 active / inactive generation dialog placeholder,按 32px 画布世界坐标间距外扩阻挡矩形,选择距离当前屏幕中心对应画板位置最近且不重叠的位置。选定后立即调用 `centerViewportOnPlacement(...)`,保持当前缩放比例不变,只平移画布 viewport,让屏幕中心移动到新占位中心。 +- 影响范围:`/editor/canvas` 图片画布生成入口、`useImageCanvasGenerationWorkflow`、`ImageCanvasGenerationPlacementModel`、图片画布技术方案和 Lovart 生成类面板文档。 +- 验证方式:运行 `npm run test -- src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 + ## 2026-06-15 SpacetimeDB 本地 skills 只保留 CLI / Concepts / Rust - 背景:本仓库的 SpacetimeDB 接入已固定为 `server-rs + Axum + SpacetimeDB`,本地 skill 需要从上游 SpacetimeDB `skills/` 更新到 2.5 口径,同时避免继续维护当前项目不使用的 TypeScript server/client、C# 和 Unity 专用 skill。 @@ -2322,7 +2330,7 @@ ## 2026-06-17 图片画布底部生成视频接入 Lovart 面板 - 背景:编辑器画板底部工具栏需要新增 `生成视频`,并和现有 Lovart 式生成类面板、泥点展示、占位图和画布结果图层保持一致。 -- 决策:`生成视频` 点击后创建独立视频生成占位和极简面板,提交 `POST /api/editor/videos/generations`;首期支持 `seedance2.0`、`seedance2.0-fast`、`kling3.0`、`kling3.0-omni`、`veo3.1`、`veo3.1-fast`,固定文字转视频、`16:9`、标准模式和静音。后端复用 Ark / VectorEngine content generation task 轮询链路,下载视频后持久化到 OSS。生成结果在画布中写入 `mediaType="video"` 与 `assetKind="video"`,图片信息弹窗按视频显示为 `视频信息` / `视频类型`。生成类泥点价格统一走 `editor_generation_config`,视频和角色动画均为 480p 每秒 10 泥点、720p 每秒 20 泥点。 +- 决策:`生成视频` 点击后创建独立视频生成占位和极简面板,提交 `POST /api/editor/videos/generations`;首期前端仅开放 `seedance2.0`、`seedance2.0-fast`、`kling3.0`、`kling3.0-omni`,不展示 Veo 模型入口;固定文字转视频、`16:9`、标准模式和静音。后端复用 Ark / VectorEngine content generation task 轮询链路,下载视频后持久化到 OSS。生成结果在画布中写入 `mediaType="video"` 与 `assetKind="video"`,图片信息弹窗按视频显示为 `视频信息` / `视频类型`。生成类泥点价格统一走 `editor_generation_config`,视频和角色动画均为 480p 每秒 10 泥点、720p 每秒 20 泥点。 - 影响范围:图片画布生成工作流、前端 editorProjectClient、`shared-contracts`、`api-server` 视频生成 BFF、编辑器技术方案和生成类面板方案。 - 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "opens the bottom generate video panel"`、`npm run test -- src/components/image-editor/ImageCanvasMetadataModalView.test.tsx`、`npm run test -- src/services/image-editor/editorProjectClient.test.ts`、`cargo test -p shared-contracts editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 67c0ecec9..635346316 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -18,8 +18,9 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色形象规范 / 常规参考图 / 图标素材规范 / 修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行修改时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和快速编辑保留既有 `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`。纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits`。`生成视频` 走 `/api/editor/videos/generations`,支持 Seedance 2.0 / Seedance 2.0 Fast / Kling 3.0 / Kling 3.0 Omni / Veo 3.1 / Veo 3.1 Fast,首期固定文字转视频、`16:9`、标准模式、静音,生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成工具后先在画布中心创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和修改图片在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和快速编辑保留既有 `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`。纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits`。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 / Seedance 2.0 Fast / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口;首期固定文字转视频、`16:9`、标准模式、静音,生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成工具后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和修改图片在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 +- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频` 和 `生成UI设计图`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变。 ## 交互规则 @@ -65,7 +66,7 @@ - `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;接口返回 data URL、尺寸、prompt、model、provider 和 taskId。 - `POST /api/editor/icon-spritesheets/generations`:按图标素材规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`,后端把 UI 尺寸归一为文档支持的 `1024x1024`、`1024x1536`、`1536x1024`、`2048x2048`、`2048x1152` 等 `size` 字符串。 - `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据。 -- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音和泥点价格生成视频。请求模型支持 `seedance2.0`、`seedance2.0-fast`、`kling3.0`、`kling3.0-omni`、`veo3.1`、`veo3.1-fast`;后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 +- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音和泥点价格生成视频。前端可选模型为 `seedance2.0`、`seedance2.0-fast`、`kling3.0`、`kling3.0-omni`;后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index d617fa79b..0f61ad148 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -12,7 +12,8 @@ - `生成图标素材` - `生成UI设计图` - `生成视频` -- `角色动画生成面板` + +`角色动画生成面板` 同步纳入本次生成类面板交互统一:参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。 ## 统一布局 @@ -22,7 +23,7 @@ - 规范参考图:紫色剪贴板图标。 - 图标素材规范:绿色图标图标。 - UI 图标规范:琥珀色图标图标。 - - 视频参考图 / 角色视频源图:深色视频或角色图标。 + - 视频参考图:深色视频图标。 3. 参考图卡片尽量少文字;必要文字写在图标块内或短标签内,不写规则说明。 4. 普通参考图支持连续追加:已有图缩略图后始终保留一个 `+` 入口。点击入口只弹出“从画布中选择 / 上传图片”来源选项,不再直接打开系统文件选择器;生成图片、生成视频、角色常规参考图等同类参考图入口都遵循同一交互。 5. 单文本输入面板不显示文本框标题,用问题式 placeholder: @@ -30,7 +31,6 @@ - 生成角色:`你希望角色如何设计?` - 生成 UI:`你希望这个 UI 长什么样?` - 生成视频:`你希望生成什么视频?` - - 角色动画:`你希望角色做什么动作?` 6. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范、图标素材多描述。 7. 生成规范下的角色形象规范、UI素材规范、图标素材规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。 @@ -39,15 +39,15 @@ 画面比例、大小尺寸、模型、分辨率、时长等生成参数统一为 Lovart 式底部胶囊按钮: ```text -左下:[比例 · 尺寸 ˄] 右下:[模型 ˄] [生成 · 图标 12] +左下:[比例 · 尺寸 ˄] 右下:[模型 ˄] [生成12泥点] ``` - 面板内不显示 `画面比例`、`大小尺寸`、`模型` 等字段标题,只显示当前选择值。 -- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频 / 角色动画把比例、时长、清晰度合并成一个左下角选项框。 +- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频面板把比例、时长、清晰度合并成一个左下角选项框。 - 模型选项框和生成按钮位于右下角。 -- 点击后在父级生成面板内向上弹出子面板;父级面板隐藏或销毁时,子面板同步销毁。 -- 子面板展示时,点击父级面板中除当前子面板和选项触发器以外的任意区域即可收起,不额外显示右上角关闭按钮。 -- 弹出面板内可分组展示字段,点击某个选项只更新当前字段和选中样式,不关闭弹出面板,方便连续修改。 +- 点击后以页面级浮层向上弹出独立选项面板;父级面板隐藏或销毁时,选项面板同步销毁。 +- 点击选项只更新字段和选中样式,不收起选项面板;再次点击当前参数按钮可收起。 +- 弹出面板内可分组展示字段,点击某个选项只更新当前字段和选中样式,方便连续修改。 - 模型子面板每行固定一个模型,不用方框包裹模型名;模型名不换行,前置对应模型类型图标,选中项在模型名后用对号标记。 - 比例选项卡片内展示对应比例的线框。 - 父级面板、底部选项框、弹出子面板字号保持一致。 @@ -59,9 +59,25 @@ ## 泥点显示 - 本次消耗泥点必须显示在生成按钮内部。 -- `泥点` 文本不在 UI 中显示,改用图标 + 数值,例如 `生成 ✦ 12`。 +- 生成按钮内明确显示 `N泥点`,例如 `生成12泥点`、`生成40泥点`;不使用泥点图标替代文字。 - 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。 +## 画布占位落点 + +- 任何会创建“即将生成”画布占位的 Lovart 面板入口,都必须先生成对应 draft,再通过统一 placement 模型改写 placeholder,不能直接使用 draft 初始中心点。 +- 统一入口范围:生成图片、生成规范、生成角色形象、生成图标素材、生成视频、生成 UI 设计图。 +- 避让对象:所有 `hidden !== true` 的画布图层,以及当前 active / inactive generation dialogs 中已有的 placeholder。 +- 避让间距:每个避让矩形向外扩 32px,保证新占位和已有图层 / 占位之间保留可见空隙。 +- 距离目标:以当前视口世界中心为目标点,在候选落点中选择距离最近且不重叠的位置;中心被占用时按相邻边、四角和外圈候选继续寻找。 +- 居中行为:调用 `openCanvasGenerationDialog(...)` 时写入避让后的 placeholder;随后调用 `centerViewportOnPlacement(...)`,让屏幕中心移动到新占位中心,并保持原 viewport scale。 +- 入口状态:新入口打开后设置对应 activeTool,清空画布选中、右键菜单、快速编辑、角色动画和其它生成来源选择状态;只切换当前面板,不删除已有生成占位。 + +## 画布悬浮信息 + +- 待生成占位图只有聚焦或生成中才显示高亮边框、左上角生成器名称和右上角原始尺寸;失焦后保留灰色占位块本体,不显示这些辅助信息。 +- 占位图的生成器名称 / 原始尺寸、图片图层右上角素材类型标签、查看信息按钮和悬浮尺寸标签都按 viewport 反向缩放,画布缩小时保持屏幕可读尺寸。 +- 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。 + ## 第一版计费配置 ```text @@ -83,9 +99,7 @@ - `seedance2.0-fast` - `kling3.0` - `kling3.0-omni` -- 不展示 Veo 模型入口。 - - `veo3.1` - - `veo3.1-fast` +- 前端不展示 Veo 模型入口。 - 后端复用现有 Ark / VectorEngine content generation task 轮询链路,并把生成视频持久化到 OSS;缺少 `ARK_CHARACTER_VIDEO_BASE_URL` 或 `ARK_CHARACTER_VIDEO_API_KEY` 时 fail-closed 返回配置错误。 ## 验收 @@ -94,8 +108,7 @@ - 比例 / 尺寸 / 模型不再平铺全部选项;比例与尺寸合并为左下角当前值按钮,模型与生成按钮位于右下角。 - 单文本输入面板不显示字段标题,placeholder 是问题式文案。 - 多文本输入面板字段标题和边界仍清晰。 -- 生成按钮内能看到图标化泥点消耗数值。 -- 弹出选项面板点击选项后保持打开,可连续修改多个字段。 +- 生成按钮内能看到 `N泥点` 文案。 +- 弹出选项面板通过 `role=menu` 暴露,点击选项后保持打开,可连续修改多个字段。 - 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。 -- 后端存在独立编辑器生成计费配置文件,角色动画价格校验使用该配置。 - 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。 diff --git a/src/components/common/PlatformFloatingMenu.tsx b/src/components/common/PlatformFloatingMenu.tsx index 4cdcb2478..f91e9152a 100644 --- a/src/components/common/PlatformFloatingMenu.tsx +++ b/src/components/common/PlatformFloatingMenu.tsx @@ -1,6 +1,6 @@ -import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react'; +import type { ButtonHTMLAttributes, CSSProperties, HTMLAttributes, ReactNode } from 'react'; -type PlatformFloatingMenuProps = { +type PlatformFloatingMenuProps = HTMLAttributes & { children: ReactNode; className?: string; label?: string; @@ -26,9 +26,11 @@ export function PlatformFloatingMenu({ label, placement = 'top-end', style, + ...divProps }: PlatformFloatingMenuProps) { return (
, @@ -12,13 +12,13 @@ type PlatformInlineOptionButtonProps = Omit< * 平台内联选项按钮。 * 统一承接工具面板里“当前选项 + 下拉箭头”这类轻量 pill 动作。 */ -export function PlatformInlineOptionButton({ - children, - trailingIcon, - className, - type = 'button', - ...buttonProps -}: PlatformInlineOptionButtonProps) { +export const PlatformInlineOptionButton = forwardRef< + HTMLButtonElement, + PlatformInlineOptionButtonProps +>(function PlatformInlineOptionButton( + { children, trailingIcon, className, type = 'button', ...buttonProps }, + ref, +) { const actionClassName = [ 'platform-inline-option-button inline-flex items-center justify-center border-0 bg-transparent text-sm font-black text-slate-700 transition-colors hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-55', className, @@ -27,7 +27,7 @@ export function PlatformInlineOptionButton({ .join(' '); return ( - ); -} +}); diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx index 212a5029c..bc9009dff 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen } from '@testing-library/react'; -import { useState } from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { createRef, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import type { @@ -17,6 +17,10 @@ function createDialog( mode: 'generate', prompt: '初始提示', status: 'idle', + generationReferences: [], + imageModel: 'gemini-3.1-flash-image-preview', + aspectRatio: '16:9', + imageSize: '1K', ...patch, }; } @@ -35,6 +39,9 @@ function BasicGenerationHarness({ const [dialog, setDialog] = useState( initialDialog, ); + const [isReferenceMenuOpen, setIsReferenceMenuOpen] = useState(false); + const [isPickingReference, setIsPickingReference] = useState(false); + const generationReferenceButtonRef = createRef(); return dialog ? (
@@ -42,13 +49,24 @@ function BasicGenerationHarness({ dialog={dialog} style={{ left: 10, top: 20 }} setGenerateDialog={setDialog} + generationReferenceButtonRef={generationReferenceButtonRef} + isGenerationReferenceMenuOpen={isReferenceMenuOpen} + setIsGenerationReferenceMenuOpen={setIsReferenceMenuOpen} + setIsPickingGenerationReferenceFromCanvas={setIsPickingReference} + renderEditorPortal={(node) => node} + buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })} onRequestUpload={onRequestUpload} + onToggleReferenceMenu={() => setIsReferenceMenuOpen((open) => !open)} onSubmit={onSubmit} onClose={onClose} /> {dialog.prompt} + {dialog.aspectRatio} + {dialog.imageSize} + {dialog.imageModel} {dialog.status} {dialog.errorMessage ?? '-'} + {String(isPickingReference)}
) : null; } @@ -71,31 +89,89 @@ describe('ImageCanvasBasicGenerationComposerView', () => { fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '新的提示' }, }); - fireEvent.click(screen.getByRole('button', { name: '添加参考图' })); + const panel = screen.getByRole('dialog', { name: '生成图片' }); + fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' })); + fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' })); fireEvent.click(screen.getByRole('button', { name: '生成' })); expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示'); expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); expect(screen.getByLabelText('当前错误').textContent).toBe('-'); - expect(requestUpload).not.toHaveBeenCalled(); + expect(requestUpload).toHaveBeenCalledWith('generation-reference'); expect(submitGeneration).toHaveBeenCalledWith( expect.objectContaining({ prompt: '新的提示', status: 'idle' }), ); }); - it('runs placeholder actions and closes through its interface', () => { + it('keeps references on the first row and appends a new add-reference entry', () => { + render( + , + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + const strip = panel.querySelector('.image-canvas-editor__reference-strip'); + + expect(strip).toBeTruthy(); + expect(within(strip as HTMLElement).getByText('参考图A')).toBeTruthy(); + expect(within(strip as HTMLElement).getByText('参考图B')).toBeTruthy(); + expect( + within(strip as HTMLElement).getByRole('button', { name: '添加参考图' }), + ).toBeTruthy(); + }); + + it('shows only current image options and opens upward option panels', () => { + render(); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + fireEvent.click( + within(panel).getByRole('button', { + name: '生成图片尺寸 16:9 · 1K', + }), + ); + const dimensionPanel = screen.getByRole('menu', { + name: '生成图片尺寸选项', + }); + expect(within(dimensionPanel).getByText('比例')).toBeTruthy(); + fireEvent.click(within(dimensionPanel).getByRole('button', { name: '2K' })); + + expect(screen.getByLabelText('当前尺寸').textContent).toBe('2K'); + + fireEvent.click( + within(panel).getByRole('button', { + name: '生成图片模型 nanobanana2', + }), + ); + const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' }); + fireEvent.click( + within(modelPanel).getByRole('button', { name: /gpt-image-2/ }), + ); + + expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2'); + const submitButton = within(panel).getByRole('button', { name: '生成' }); + expect(submitButton.textContent).toBe('生成12泥点'); + }); + + it('closes through its interface', () => { const closeComposer = vi.fn(); - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); render(); - fireEvent.click(screen.getByRole('button', { name: '生成比例 1:1 2k 1张' })); - fireEvent.click(screen.getByRole('button', { name: '生成模型 GPT Image' })); fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' })); - expect(alertSpy).toHaveBeenCalledWith('生成参数功能建设中'); - expect(alertSpy).toHaveBeenCalledWith('模型选择功能建设中'); expect(closeComposer).toHaveBeenCalledTimes(1); - - alertSpy.mockRestore(); }); }); diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index 003e14201..5c3909c18 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ImageIcon, X } from 'lucide-react'; +import { ImageIcon, X } from 'lucide-react'; import { type CSSProperties, type Dispatch, @@ -7,17 +7,19 @@ import { type SetStateAction, } from 'react'; -import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; -import { PlatformIconButton } from '../common/PlatformIconButton'; -import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; +import { + EDITOR_GENERATION_MUD_POINT_CONFIG, +} from './ImageCanvasGenerationModel'; +import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; import type { + CharacterReferenceImage, GenerateDialogState, } from './ImageCanvasEditorTypes'; @@ -36,14 +38,10 @@ type ImageCanvasBasicGenerationComposerViewProps = { ) => CSSProperties; onRequestUpload: (target: 'generation-reference') => void; onToggleReferenceMenu?: () => void; + onRememberImageModel?: (model: string) => void; onSubmit: (dialog: GenerateDialogState) => void; onClose: () => void; }; - -function triggerPlaceholderAction(label: string) { - window.alert(`${label}功能建设中`); -} - function resetFailedDialogStatus(dialog: GenerateDialogState) { return { ...dialog, @@ -52,6 +50,28 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) { }; } +function ReferenceChip({ + reference, + index, +}: { + reference: CharacterReferenceImage; + index: number; +}) { + return ( + + + + + + {reference.label || `参考图${index + 1}`} + + + ); +} + export function ImageCanvasBasicGenerationComposerView({ dialog, style, @@ -64,9 +84,11 @@ export function ImageCanvasBasicGenerationComposerView({ buildPortalMenuStyle = () => ({}), onRequestUpload, onToggleReferenceMenu, + onRememberImageModel = () => {}, onSubmit, onClose, }: ImageCanvasBasicGenerationComposerViewProps) { + const references = dialog.generationReferences ?? []; return ( <>
- onToggleReferenceMenu?.()} - icon={} - > - 参考图 - +
+ {references.map((reference, index) => ( + + ))} + +
- triggerPlaceholderAction('生成参数')} - trailingIcon={} - > - 中 · 1:1(2k) · 1张 - - triggerPlaceholderAction('模型选择')} - trailingIcon={} - > - GPT Im... - - - {dialog.status === 'generating' ? '生成中' : '12'} - +
{dialog.status === 'generating' ? ( = {}, ): CharacterAnimationPanelState { @@ -64,6 +82,7 @@ function CharacterAnimationPanelHarness({
{ - it('updates prompt, resolution and ratio while clearing failed state', () => { + it('keeps source reference first and updates prompt, resolution and ratio from a menu while clearing failed state', () => { render( { fireEvent.change(screen.getByLabelText('动画描述'), { target: { value: `${'a'.repeat(4001)}` }, }); - fireEvent.change(screen.getByLabelText('分辨率'), { - target: { value: '720p' }, - }); - fireEvent.change(screen.getByLabelText('画面比例'), { - target: { value: '16:9' }, - }); + fireEvent.click( + screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }), + ); + const menu = screen.getByRole('menu', { name: '动画参数选项' }); + fireEvent.click(screen.getByRole('button', { name: '清晰度 720p' })); + fireEvent.click(screen.getByRole('button', { name: '比例 16:9' })); + expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy(); expect(screen.getByLabelText('当前动画描述').textContent).toHaveLength(4000); expect(screen.getByLabelText('当前分辨率').textContent).toBe('720p'); expect(screen.getByLabelText('当前比例').textContent).toBe('16:9'); + expect(menu).toBeTruthy(); expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); expect(screen.getByLabelText('当前错误').textContent).toBe('-'); }); @@ -124,9 +145,10 @@ describe('ImageCanvasCharacterAnimationPanelView', () => { ); fireEvent.click(screen.getByRole('button', { name: '行走' })); - fireEvent.change(screen.getByLabelText('时长'), { - target: { value: '48' }, - }); + fireEvent.click( + screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }), + ); + fireEvent.click(screen.getByRole('button', { name: '时长 48帧·6秒' })); expect(screen.getByLabelText('当前动画描述').textContent).toBe( '循环行走动作,步伐稳定。', @@ -145,7 +167,7 @@ describe('ImageCanvasCharacterAnimationPanelView', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: '生成' })); + fireEvent.click(screen.getByRole('button', { name: '生成18泥点' })); fireEvent.click( screen.getByRole('button', { name: '关闭角色动画生成面板' }), ); diff --git a/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.tsx b/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.tsx index e58677e25..896f6cdf3 100644 --- a/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.tsx @@ -1,19 +1,37 @@ -import { type CSSProperties, type Dispatch, type SetStateAction } from 'react'; -import { X } from 'lucide-react'; +import { + type CSSProperties, + type Dispatch, + type ReactNode, + type SetStateAction, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { ChevronDown, X } from 'lucide-react'; import { PlatformActionButton } from '../common/PlatformActionButton'; -import { PlatformSelectField, PlatformTextField } from '../common/PlatformTextField'; +import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu'; +import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import { CHARACTER_ANIMATION_ACTION_PROMPTS, CHARACTER_ANIMATION_DURATION_OPTIONS, CHARACTER_ANIMATION_RATIO_OPTIONS, } from './ImageCanvasGenerationModel'; -import type { CharacterAnimationPanelState } from './ImageCanvasEditorTypes'; +import type { + EditorCharacterAnimationRatio, + EditorCharacterAnimationResolution, +} from '../../services/image-editor/editorProjectClient'; +import type { + CanvasLayer, + CharacterAnimationPanelState, +} from './ImageCanvasEditorTypes'; type ImageCanvasCharacterAnimationPanelViewProps = { panel: CharacterAnimationPanelState; + sourceLayer: CanvasLayer; style: CSSProperties; price: number; setCharacterAnimationPanel: Dispatch< @@ -33,144 +51,198 @@ function resetFailedPanelStatus void; +}) { + return ( + + ); +} + export function ImageCanvasCharacterAnimationPanelView({ panel, + sourceLayer, style, price, setCharacterAnimationPanel, onUpdateDuration, onSubmit, }: ImageCanvasCharacterAnimationPanelViewProps) { + const [isParameterMenuOpen, setIsParameterMenuOpen] = useState(false); + const parameterButtonRef = useRef(null); + const isGenerating = panel.status === 'generating'; + const ratioLabel = getRatioLabel(panel.ratio); + + const updatePanel = (patch: Partial) => { + setCharacterAnimationPanel((currentPanel) => + currentPanel + ? { + ...resetFailedPanelStatus(currentPanel), + ...patch, + } + : currentPanel, + ); + }; + return ( - event.stopPropagation()} - onSubmit={(event) => { - event.preventDefault(); - if (panel.status !== 'generating') { - onSubmit(); - } - }} - > -
- 角色动画 + <> + event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + if (!isGenerating) { + onSubmit(); + } + }} + > +
+ + + + + + {sourceLayer.title} + + +
setCharacterAnimationPanel(null)} /> -
- - setCharacterAnimationPanel((currentPanel) => - currentPanel - ? { - ...resetFailedPanelStatus(currentPanel), - promptText: event.target.value.slice(0, 4000), - } - : currentPanel, - ) - } - /> -
- {CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => ( - + ))} +
+
+
+ } + onClick={() => setIsParameterMenuOpen((open) => !open)} + > + {ratioLabel} · {panel.durationSeconds}秒 · {panel.resolution} + +
+ - {preset.label} - - ))} -
-
- - setCharacterAnimationPanel((currentPanel) => - currentPanel - ? { - ...resetFailedPanelStatus(currentPanel), - resolution: event.target.value === '720p' ? '720p' : '480p', - } - : currentPanel, - ) - } - > - - - - - setCharacterAnimationPanel((currentPanel) => - currentPanel - ? { - ...resetFailedPanelStatus(currentPanel), - ratio: - CHARACTER_ANIMATION_RATIO_OPTIONS.find( - (item) => item.value === event.target.value, - )?.value ?? 'same', - } - : currentPanel, - ) - } - > - {CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => ( - - ))} - - onUpdateDuration(event.target.value)} - > - {CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => ( - - ))} - -
-
+ {isGenerating ? '生成中' : `生成${price}泥点`} + +
{panel.promptText.trim() ? panel.promptText.trim() : '动画描述'} - {price}泥点 -
- {panel.status === 'completed' && panel.result ? ( - - 已生成 {panel.result.frameCount} 帧 - - ) : null} - {panel.status === 'failed' ? ( - - {panel.errorMessage} - - ) : null} - - {panel.status === 'generating' ? '生成中' : '生成'} - - + {panel.status === 'completed' && panel.result ? ( + + 已生成 {panel.result.frameCount} 帧 + + ) : null} + {panel.status === 'failed' ? ( + + {panel.errorMessage} + + ) : null} + + {isParameterMenuOpen + ? renderPanelPortal( + event.stopPropagation()} + > +
+
+ + 比例 + +
+ {CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => ( + updatePanel({ ratio: option.value })} + > + + ))} +
+
+
+ + 时长 + +
+ {CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => ( + onUpdateDuration(String(option.frameCount))} + > + {option.label} + + ))} +
+
+
+ + 清晰度 + +
+ {(['480p', '720p'] as EditorCharacterAnimationResolution[]).map( + (resolution) => ( + updatePanel({ resolution })} + > + {resolution} + + ), + )} +
+
+
+
, + ) + : null} + ); } diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx index 28a73fa5c..e278bdb26 100644 --- a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx @@ -92,6 +92,23 @@ function CharacterGenerationHarness({ } describe('ImageCanvasCharacterGenerationComposerView', () => { + it('keeps the prompt as a borderless single text input with a question placeholder', () => { + render(); + + const panel = screen.getByRole('dialog', { name: '生成角色形象' }); + const prompt = screen.getByRole('textbox', { name: '角色设定' }); + + expect( + Array.from(panel.querySelectorAll('.image-canvas-editor__field-title')) + .map((node) => node.textContent) + .join(''), + ).not.toContain('角色设定'); + expect(prompt.getAttribute('placeholder')).toBe('你希望角色如何设计?'); + expect(prompt.className).toContain( + 'image-canvas-editor__generation-prompt--borderless', + ); + }); + it('updates character prompt, clears failed state and submits', () => { const submitCharacter = vi.fn(); render( diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx index d580b4285..04d122fe5 100644 --- a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx @@ -7,8 +7,6 @@ import { type SetStateAction, } from 'react'; -import { PlatformActionButton } from '../common/PlatformActionButton'; -import { PlatformFieldLabel } from '../common/PlatformFieldLabel'; import { PlatformFloatingMenu, PlatformFloatingMenuItem, @@ -16,10 +14,12 @@ import { import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import type { + CharacterReferenceImage, GenerateDialogState, SpecGenerationType, UploadTarget, } from './ImageCanvasEditorTypes'; +import { EDITOR_GENERATION_MUD_POINT_CONFIG } from './ImageCanvasGenerationModel'; import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; type ImageCanvasCharacterGenerationComposerViewProps = { @@ -53,6 +53,28 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) { }; } +function ReferenceChip({ + reference, + index, +}: { + reference: CharacterReferenceImage; + index: number; +}) { + return ( + + + + + + {reference.label || `参考图${index + 1}`} + + + ); +} + export function ImageCanvasCharacterGenerationComposerView({ dialog, style, @@ -86,39 +108,32 @@ export function ImageCanvasCharacterGenerationComposerView({ } }} > -
-
- + + - -
+ + {dialog.characterSpecReference ? ( + + ) : ( + + + {dialog.characterSpecReference?.label ?? '角色形象规范'} + + + {isCharacterSpecMenuOpen ? renderEditorPortal( , ) : null} -
- - 常规参考图 - -
- {(dialog.characterReferences ?? []).map((reference, index) => ( - - {reference.label} - - {index + 1} - - - ))} - - {isCharacterReferenceMenuOpen - ? renderEditorPortal( - - { - setIsPickingCharacterReferenceFromCanvas(true); - setIsCharacterReferenceMenuOpen(false); - }} - > - 从画布中选择 - - { - setIsCharacterReferenceMenuOpen(false); - onRequestUpload('character-reference'); - }} - > - 上传图片 - - , - ) - : null} -
-
-
-
+