完善快速编辑参考图与生成占位

- 快速编辑支持最多8张额外参考图,并将原图自动作为最后一张参考图提交

- 快速编辑尺寸和模型沿用原图配置,按禁用态参数胶囊展示

- 快速编辑提交前同步改写原图引用为实际图序号

- 快速编辑生成改为创建独立画布生成占位,并支持键盘删除生成中对象

- 对齐生成类面板参考图图标、规范类固定参数和相关文档测试
This commit is contained in:
2026-06-18 22:39:03 +08:00
parent b04b6cee2b
commit ea29887d9c
65 changed files with 2819 additions and 531 deletions
@@ -2306,9 +2306,9 @@
## 2026-06-16 图片画布图标素材面板采用 Lovart 式参考卡与横向增宽布局
- 背景:图标素材生成面板里,规范入口与素材描述项过于平铺,且子面板内部采用滑动列表,和 Lovart 风格画布的参考卡 / 物料卡不一致。
- 决策:`生成图标素材` 面板不使用内部纵向滚动列表;每新增一个素材描述项就让面板整体增宽,保持描述项横向卡片一眼可扫。图标素材规范入口改为 Lovart 式参考卡:缩略图、名称、绑定状态和轻量动作分区分开呈现,独立菜单只负责来源切换,不再承载说明文案。
- 决策:`生成图标素材` 面板不使用内部纵向滚动列表;每新增一个素材描述项就让面板整体增宽,保持描述项横向卡片一眼可扫。图标规范入口改为 Lovart 式参考卡:缩略图、名称、绑定状态和轻量动作分区分开呈现,独立菜单只负责来源切换,不再承载说明文案。
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx``src/index.css`、图标素材生成专项设计文档。
- 验证方式:新增或增删素材描述项时,面板宽度应随项数变化;图标素材规范入口应呈现参考卡视觉而非纯文本按钮;移动端下仍应固定在底部锚定,不出现内部滚动条。
- 验证方式:新增或增删素材描述项时,面板宽度应随项数变化;图标规范入口应呈现参考卡视觉而非纯文本按钮;移动端下仍应固定在底部锚定,不出现内部滚动条。
- 关联文档:`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-06-16 图片画布图标素材与角色生成支持双图片模型
@@ -2322,7 +2322,7 @@
## 2026-06-16 图片画布生成面板与浮层层级收口
- 背景:图片画布底部工具栏和角色参考图行都存在局部滚动 / 裁切容器,生成规范菜单和角色规范来源菜单如果仍内嵌在触发按钮附近,会被边界遮挡;同时生成类面板打开后隐藏底部工具栏会破坏连续创作节奏。
- 决策:`生成规范``角色形象规范来源``图标素材规范来源` 菜单统一通过页面级 fixed portal 渲染到 `document.body`,触发按钮只提供定位锚点;点击 `生成工具``生成角色形象``生成图标素材` 后底部 AI 工具栏保持可见。点击画布空白区域只关闭当前生成面板并清除图片选中样式,不删除新建的占位图。角色面板中的 `角色形象规范``上传常规参考图` 入口统一改为 Lovart 式参考图卡片。
- 决策:`生成规范``角色规范来源``图标规范来源` 菜单统一通过页面级 fixed portal 渲染到 `document.body`,触发按钮只提供定位锚点;点击 `生成工具``生成角色形象``生成图标素材` 后底部 AI 工具栏保持可见。点击画布空白区域只关闭当前生成面板并清除图片选中样式,不删除新建的占位图。角色面板中的 `角色规范``上传常规参考图` 入口统一改为 Lovart 式参考图卡片。
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx``src/index.css``src/components/image-editor/ImageCanvasEditorView.test.tsx`、图片画布前端技术方案和角色形象生成设计文档。
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -2330,7 +2330,7 @@
## 2026-06-16 图片画布图片信息页不展示生图 Prompt
- 背景:图片画布中每张生成图片的信息页原来展示 `Prompt` 和复制 Prompt,但该字段可能是后端组装后的生图提示词,不适合作为用户可见的图片输入信息。
- 决策:图片信息页删除生图 Prompt 展示和复制入口,改为展示生成时的用户面板输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色形象规范、常规参考图、图标素材规范和修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,不得回退展示内部 Prompt。
- 决策:图片信息页删除生图 Prompt 展示和复制入口,改为展示生成时的用户面板输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色规范、常规参考图、图标规范和修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,不得回退展示内部 Prompt。
- 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、图片画布 layout snapshot、图片画布技术方案。
- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx` 应覆盖图片信息页无 `Prompt`、无 `复制Prompt`,并展示普通生成、角色生成、图标素材和修改结果的输入快照。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -2365,3 +2365,10 @@
- 决策:画布待生成占位按生成器模式渲染专属空白样式、icon 与右上角标签:视频、角色、音效、背景音乐不再统一使用图片 icon;角标继续按 viewport 反向缩放。编辑器音效请求契约改为 `type: "one-shot" | "loop"``tempo: number | null`BPM 范围 `1-300`,默认 `null`;前端底部只保留一个无标题音效参数按钮,组合显示如 `单次·120BPM`
- 影响范围:`src/components/image-editor/ImageCanvasWorldView.tsx``ImageCanvasGenerationComposerView.tsx``ImageCanvasEditorTypes.ts``ImageCanvasGenerationSubmissionModel.ts``src/services/image-editor/editorProjectClient.ts``server-rs/crates/shared-contracts/src/assets.rs``server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs``server-rs/crates/platform-audio/src/request.rs`
- 验证方式:`npx vitest run src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasGenerationModel.test.ts src/services/image-editor/editorProjectClient.test.ts --reporter verbose``cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml``cargo test -p platform-audio --manifest-path server-rs/Cargo.toml``cargo test -p api-server editor_sound_effect --manifest-path server-rs/Cargo.toml`
## 2026-06-18 图片画布角色动画改为角色动作生成占位
- 背景:旧角色动画入口点击后打开窄侧边面板,和 Lovart 式新建图片 / 视频占位不一致;点击生成好的角色图时也容易被误解为会自动进入重绘或生成面板。
- 决策:点击已生成角色图只选中图层并显示浮动工具栏,不自动弹出重绘、快速编辑或角色动画面板。点击工具栏或右键菜单的 `生成动画` 后,创建 `mode="character-animation"` 的画布 generation dialog,占位走统一避让落点与视口居中;占位使用角色动作 icon、橙色动作配色和右上角 `动作` 标签。角色动画参数面板复用原内容,但作为统一 generation composer 跟随占位底部,宽度对齐图片生成面板。提交成功后以首帧创建 `assetKind="character-animation"` 的角色动作图片图层,右上角标签显示 `动作`
- 影响范围:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts``useImageCanvasGenerationSurface.tsx``ImageCanvasCharacterAnimationPanelView.tsx``ImageCanvasWorldView.tsx``ImageCanvasGenerationLayerModel.ts``useImageCanvasGenerationSubmissionWorkflow.ts``src/index.css`
- 验证方式:`npx vitest run src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx --reporter=dot``npx vitest run src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "character animation" --reporter=dot`
+12 -4
View File
@@ -15,6 +15,14 @@
- 关联:相关文件、文档、提交或 Issue
```
## Suno 任务完成不代表已经拿到 wav 下载地址
- 现象:画板生成背景音乐时,前端报 `音频生成尚未返回可下载地址(requestId:...)`;画板生成音效时,前端可能报 `获取 Suno 音效 wav 失败(requestId:...)`。上游任务可能已经完成,但 wav 下载地址还没就绪。
- 原因:VectorEngine Suno `/suno/fetch/{task_id}` 可能先在 `data` 中返回歌曲 / 音效 clip id,而不是直接返回 `.wav` / `.mp3` URL;需要再调用 `/suno/act/wav/{clipId}` 获取 `wav_file_url`。如果只兼容 `data` 是字符串,会漏掉 `data` 对象 / 数组里的 `id``clip_id``audioId``songId`
- 处理:`platform-audio` 查询 Suno 结果时先提取直接音频 URL;没有 URL 时,从 `data` 字符串、对象或数组提取 clip id,逐个调用 `/suno/act/wav/{clipId}`。已拿到 clip id 但 wav 地址仍未就绪,或 wav 子请求暂时返回上游错误时,都保持 `processing` 让上层继续轮询,不能直接判定为缺少可下载地址或 wav 获取失败。
- 验证:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml``cargo test -p api-server vector_engine_audio_generation --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/platform-audio/src/client.rs``server-rs/crates/platform-audio/src/response.rs``docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`
## 图片编辑器底部生成按钮不要复用单一画布生成状态
- 现象:图片画布里先新建一个“生成规范”占位,再点击“生成角色形象”或其它底部生成入口,前一个规范占位和面板状态被销毁。
@@ -57,7 +65,7 @@
## 图片编辑器生成类菜单要挂到页面级 portal
- 现象:底部 `生成规范` 菜单、角色面板里的 `角色形象规范` 来源菜单点击后像没有弹出来,实际被按钮所在的局部滚动容器挡住了。
- 现象:底部 `生成规范` 菜单、角色面板里的 `角色规范` 来源菜单点击后像没有弹出来,实际被按钮所在的局部滚动容器挡住了。
- 原因:菜单仍然渲染在底部工具栏或参考图横向滚动行内部,父容器带 `overflow`,弹层无法越出边界;即便挂到 portal,如果菜单根节点的 `pointerdown` 继续冒泡到画布视口,也会先触发画布失焦并卸载面板,导致菜单项 `click` 前消失。
- 处理:这类轻量菜单统一用页面级 fixed portal 挂到 `document.body`,位置根据触发按钮的 `getBoundingClientRect()` 计算;`PlatformFloatingMenu` 根节点必须阻止 `pointerdown` 冒泡,避免画布清空当前生成面板;底部 AI 工具栏在生成面板打开时仍保持可见,不要整栏隐藏。
- 验证:测试断言菜单不包含在底部工具栏 / 参考图行里,并且生成面板打开时底部 `AI画布工具栏` 仍存在;规范参考图来源菜单应能通过 portal 点击“从画布中选择 / 上传图片”并写回规范参考图。
@@ -65,10 +73,10 @@
## 图片编辑器规范图片面板不要脱离统一生成 shell
- 现象:生成 UI 设计图或新建图标素材规范时,面板参考图、输入区和底部生成按钮相对生成图片 / 生成角色 / 生成视频错位;图标素材规范甚至可能缺少首行参考图入口。
- 现象:生成 UI 设计图或新建图标规范时,面板参考图、输入区和底部生成按钮相对生成图片 / 生成角色 / 生成视频错位;图标规范甚至可能缺少首行参考图入口。
- 原因:规范、UI 设计图等面板虽然都属于生成类入口,但 JSX 和 CSS 曾各自维护 `spec-footer`、局部 field wrapper 或缺省参考区,导致后续改造只覆盖普通图片 / 角色 / 视频,规范图片类面板结构漂移。
- 处理:生成规范下的角色形象规范、UI 素材规范、图标素材规范、自定义规范,以及生成 UI 设计图,都必须复用 `image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image` 外层 shell;首行统一 `image-canvas-editor__generation-ref`,底部统一 `image-canvas-editor__generation-composer-footer` + `image-canvas-editor__generation-submit`。多字段内容只在中央字段区保持紧凑,不单独发明 footer 或省略参考区。
- 验证:`npm test -- src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx -t "生成UI设计图|生成规范|visible titles|图标素材规范|character spec"`
- 处理:生成规范下的角色规范、图标规范、自定义规范,以及生成 UI 设计图,都必须复用 `image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image` 外层 shell;首行统一 `image-canvas-editor__generation-ref`,底部统一 `image-canvas-editor__generation-composer-footer` + `image-canvas-editor__generation-submit`。多字段内容只在中央字段区保持紧凑,不单独发明 footer 或省略参考区。
- 验证:`npm test -- src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx -t "生成UI设计图|生成规范|visible titles|图标规范|character spec"`
- 关联:`src/components/image-editor/ImageCanvasGenerationComposerView.tsx``src/index.css``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`
## 图片编辑器生成占位图在生成中也要使用最新拖拽位置
@@ -18,8 +18,8 @@
- [ ] **Step 1: Write the failing tests**
- 修改已有图片信息测试,断言弹窗不出现 `Prompt``复制Prompt`
- 新增普通生成图片测试:生成后打开信息页,应显示 `生成输入``生成提示词` 和用户输入值。
- 新增角色生成图片测试:绑定角色规范参考图后生成,信息页应显示 `角色设定``角色形象规范` 与参考图名称。
- 新增图标素材生成测试:绑定图标素材规范后生成,信息页应显示 `素材描述`、具体描述和 `图标素材规范` 参考图。
- 新增角色生成图片测试:绑定角色规范参考图后生成,信息页应显示 `角色设定``角色规范` 与参考图名称。
- 新增图标素材生成测试:绑定图标规范后生成,信息页应显示 `素材描述`、具体描述和 `图标规范` 参考图。
- [ ] **Step 2: Run test to verify it fails**
- Run: `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx`
@@ -27,7 +27,7 @@ it('keeps existing generation placeholders when another bottom generation object
fireEvent.click(
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '角色形象规范' }));
fireEvent.click(screen.getByRole('menuitem', { name: '角色规范' }));
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy();
@@ -16,11 +16,12 @@
- 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。
- 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。
- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。
- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色形象规范 / 常规参考图 / 图标素材规范 / 编辑参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。
- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。
- 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。
- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范、快速编辑和重绘保留既有 `gpt-image-2` 路径;其中生成规范类图片固定 `16:9``2K``gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象``生成图标素材` 支持 `nanobanana2``gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2``/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize``gpt-image-2``/v1/images/generations``/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后作为 `quick-edit` 参考图走同一图片生成 BFF,并在原图右侧生成一张新图。`生成视频``/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;首期固定文字转视频、`16:9`、标准模式、静音,生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 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` 路径;快速编辑按原图模型提交;其中生成规范类图片固定 `16:9``2K``gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象``生成图标素材` 支持 `nanobanana2``gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2``/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize``gpt-image-2``/v1/images/generations``/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后作为 `quick-edit` 参考图走同一图片生成 BFF,并在原图右侧生成一张新图。`生成视频``/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;首期固定文字转视频、`16:9`、标准模式、静音,生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和重绘在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。
- 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图自动作为最后一张参考图展示和提交。尺寸、模型沿用原图配置,只以禁用态参数胶囊展示,不可点击修改。提交前将提示词里对原图的 `原图``当前图片``当前图``图1` 引用改写为最后一张原图编号,例如额外 1 张参考图时写成 `图2`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;该生成中占位可通过键盘 `Delete` / `Backspace` 删除。
- 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。
- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标。当前覆盖入口包括 `生成图片``生成规范``生成角色形象``生成图标素材``生成视频``生成UI设计图`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变。
- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标。当前覆盖入口包括 `生成图片``生成规范``生成角色形象``生成图标素材``生成视频``生成UI设计图``生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变。
## 交互规则
@@ -65,7 +66,7 @@
- `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。
- `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`;接口返回 data URL、尺寸、prompt、model、provider 和 taskId。
- `POST /api/editor/icon-spritesheets/generations`:按图标素材规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model``aspectRatio``imageSize``priceMudPoints``priceMudPoints` 必须来自编辑器生成计费配置的 `icon` 档位(首版 12 泥点),后端用 `editor_generation_config` 校验后才调用上游;`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/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model``aspectRatio``imageSize``priceMudPoints``priceMudPoints` 必须来自编辑器生成计费配置的 `icon` 档位(首版 12 泥点),后端用 `editor_generation_config` 校验后才调用上游;`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-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`,两者不得混用。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`
@@ -89,13 +90,15 @@
- 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
- 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。
- 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色形象规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色形象规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。
- 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9 · 2K``gpt-image-2`,视觉对齐可编辑面板参数控件,提交到 `/api/editor/images/generations` 时也固定携带这些参数。
- 快速编辑面板底部必须以禁用态参数按钮显示原图尺寸和原图模型,视觉对齐可编辑面板参数控件;参考图顺序为额外参考图在前、原图在最后。
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt,不再展示独立 Size 字段。
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、角色图专属生成动画、分割线、重绘、下载按钮。裁扩支持输入四边像素,负数裁剪、正数扩大,扩区域保持透明;去除背景使用前端轻量去背并回写为新的工程资源快照。
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照。
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*``/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`
- 快速编辑的额外参考图同样必须在前端读取成图片 Data URL 后提交;后端 `referenceImageSrcs` 上限为 9 张,承载 8 张额外参考图加 1 张原图。
- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。
- 上传按钮和拖拽上传都支持多文件;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入默认文件夹并在投放点创建画布图层。
- 素材面板支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
@@ -12,8 +12,11 @@
- `生成图标素材`
- `生成UI设计图`
- `生成视频`
- `生成角色动作`
- `快速编辑`
`角色动画生成面板` 同步纳入本次生成类面板交互统一:参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。
`角色动画生成面板` 同步纳入本次生成类面板交互统一:点击角色图只聚焦图层,不自动弹出底部重绘或角色动画面板;点击 `生成动画` 后像新建图片一样创建 `角色动作` 画布占位,面板跟随占位底部,参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。
`快速编辑` 由选中图片后的浮动工具栏显式打开,面板结构、参考图首行、提示词输入区和底部参数 / 生成区对齐其它生成类面板。
## 统一布局
@@ -21,10 +24,10 @@
2. 参考图使用统一方形参考图图标组件,不再使用横向长卡:
- 普通参考图:灰蓝色图片图标。
- 规范参考图:紫色规范 logo。
- 图标素材规范:绿色图标图标
- UI 图标规范:琥珀色图标图标
- 图标规范:绿色图标。
- UI 图标规范:琥珀色图标。
- 视频参考图:深色视频图标。
3. 角色形象规范参考图组件只展示一个方形参考图图标;图标内上方是规范 logo,下方固定短标 `角色规范`,不再展示绑定状态、来源说明或长标题。已有绑定图片的原始标题只作为可访问名称、悬浮 title 和图片信息回看使用。
3. 角色规范参考图组件只展示一个方形参考图图标;图标内上方是规范 logo,下方固定短标 `角色规范`,不再展示绑定状态、来源说明或长标题。已有绑定图片的原始标题只作为可访问名称、悬浮 title 和图片信息回看使用。
4. 参考图图标尽量少文字;必要文字写在图标块内或短标签内,不写规则说明。
5. 已有参考图在鼠标悬停或键盘聚焦到对应参考图槽位时,右上角显示一个 `×` 删除按钮;鼠标不在槽位上时不显示。点击 `×` 只移除该参考图绑定,不触发来源菜单、不删除画布图片。
6. 普通参考图支持连续追加:已有图缩略图后始终保留一个 `+` 入口。点击入口只弹出“从画布中选择 / 上传图片”来源选项,不再直接打开系统文件选择器;生成图片、生成视频、角色常规参考图等同类参考图入口都遵循同一交互。
@@ -34,7 +37,8 @@
- 生成 UI`你希望这个 UI 长什么样?`
- 生成视频:`你希望生成什么视频?`
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
9. 生成规范下的角色形象规范、UI素材规范、图标素材规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
10. 快速编辑最多允许额外绑定 8 张参考图;原图始终自动作为最后一张参考图展示,不占用额外参考图额度。
## 参数交互
@@ -60,6 +64,7 @@
- 底边栏二级选项面板必须锚定到对应入口按钮本身,不使用屏幕居中或固定底部偏移;移动端窄屏下也应保持跟随入口位置。
- 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。
- 生成规范类图片固定使用 `16:9 · 2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。
- 快速编辑的尺寸和模型沿用原图生成配置,只在面板底部以禁用态胶囊按钮展示,不允许在快速编辑面板内修改。
- 不再在底部常驻展开全部可选项。
## 泥点显示
@@ -72,21 +77,23 @@
## 画布占位落点
- 任何会创建“即将生成”画布占位的 Lovart 面板入口,都必须先生成对应 draft,再通过统一 placement 模型改写 placeholder,不能直接使用 draft 初始中心点。
- 统一入口范围:生成图片、生成规范、生成角色形象、生成图标素材、生成视频、生成 UI 设计图。
- 统一入口范围:生成图片、生成规范、生成角色形象、生成图标素材、生成视频、生成 UI 设计图、生成角色动作
- 避让对象:所有 `hidden !== true` 的画布图层,以及当前 active / inactive generation dialogs 中已有的 placeholder。
- 避让间距:每个避让矩形向外扩 32px,保证新占位和已有图层 / 占位之间保留可见空隙。
- 距离目标:以当前视口世界中心为目标点,在候选落点中选择距离最近且不重叠的位置;中心被占用时按相邻边、四角和外圈候选继续寻找。
- 居中行为:调用 `openCanvasGenerationDialog(...)` 时写入避让后的 placeholder;随后调用 `centerViewportOnPlacement(...)`,让屏幕中心移动到新占位中心,并保持原 viewport scale。
- 入口状态:新入口打开后设置对应 activeTool,清空画布选中、右键菜单、快速编辑、角色动画和其它生成来源选择状态;只切换当前面板,不删除已有生成占位。
- 生成中的占位图允许通过键盘 `Delete` / `Backspace` 删除;不额外增加画布上的可见删除按钮。用户删除后,后续异步成功或失败回写不得重新创建该生成对象。
- 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。
- 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,角色动作使用角色动作图标和动作角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。
- 快速编辑点击生成后不在原图上播放生成中遮罩,而是立即创建独立 `Quick Edit Generator` 画布生成占位并播放生成中动画;生成成功后结果落在该占位框位置,失败时占位标记失败并恢复快速编辑面板。
## 画布悬浮信息
- 待生成占位图只有聚焦或生成中才显示高亮边框、左上角生成器名称和右上角原始尺寸;失焦后保留灰色占位块本体,不显示这些辅助信息。
- 占位图的生成器名称 / 原始尺寸、图片图层右上角素材类型标签、查看信息按钮和悬浮尺寸标签都按 viewport 反向缩放,画布缩小时保持屏幕可读尺寸。
- 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。
- 视频 / 角色 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。
- 视频 / 角色 / 角色动作 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。
## 画布保存
@@ -139,7 +146,7 @@
## 验收
- 所有生成类面板首行都是参考图区域。
- 参考图槽位统一是方形图标;角色形象规范槽位显示规范 logo 和 `角色规范` 四字,悬停已有参考图时才显示右上角删除 `×`
- 参考图槽位统一是方形图标;角色规范槽位显示规范 logo 和 `角色规范` 四字,悬停已有参考图时才显示右上角删除 `×`
- 比例 / 尺寸 / 模型不再平铺全部选项;比例与尺寸合并为左下角当前值按钮,模型与生成按钮位于右下角。
- 单文本输入面板不显示字段标题,placeholder 是问题式文案。
- 多文本输入面板字段标题和边界仍清晰;图标素材面板应只有一个 `素材描述` 文本输入框,不出现列表式 `素材描述 N` 卡片或添加描述按钮。
@@ -149,8 +156,13 @@
- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
- 生成规范类图片底部展示禁用态参数按钮 `16:9 · 2K``gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。
- 快速编辑底部展示禁用态原图尺寸和原图模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;额外参考图最多 8 张,原图显示为最后一个 `图x`
- 快速编辑提交前必须把提示词里对原图的 `原图``当前图片``当前图``图1` 引用改写成最后一张原图编号,例如额外 1 张参考图时改为 `图2`
- 快速编辑提交给后端的参考图顺序必须是额外参考图在前、原图在最后。
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
- 生成视频 / 角色形象 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon
- 快速编辑生成中的占位图同样只支持键盘 `Delete` / `Backspace` 删除,不新增 UI 删除按钮;删除后异步结果不得再落回画布
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
- 生成游戏音效面板底部不显示 `type` / `tempo` 字段标题,只有一个音效参数按钮;type 只支持 `one-shot` / `loop`BPM 为 `1-300` 的数字或 `null`,选择后按钮显示 `单次·120BPM` 这类组合值。
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`
- 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。
@@ -12,14 +12,14 @@
- 占位图下方打开独立生成面板,标题为 `生成UI设计图`
- 面板复用普通 `生成图片` / `生成角色形象` / `生成视频` 的生成类 shell:首行参考图区域、中央单文本输入、底部参数与生成按钮区。
- 面板第一个模块为 `图标素材规范`,并放在首行参考图区域。
- 点击图标素材规范卡片后,在卡片旁弹出来源菜单:
- 面板第一个模块为 `图标规范`,并放在首行参考图区域。
- 点击图标规范卡片后,在卡片旁弹出来源菜单:
- `从画布中选择`
- `新建图标素材规范`
- `新建图标规范`
- `上传图片`
- `从画布中选择` 只接受 `assetKind: "icon-spec"` 的图层;普通图片、其他类别图层和不携带标签的图片不绑定。
- `新建图标素材规范` 复用现有图标素材规范生成表单。
- `上传图片` 仅绑定到当前 UI 设计面板的图标素材规范参考,不自动添加为画布图层。
- `新建图标规范` 复用现有图标规范生成表单。
- `上传图片` 仅绑定到当前 UI 设计面板的图标规范参考,不自动添加为画布图层。
## 生成参数
@@ -36,10 +36,10 @@
【用户输入】<用户输入>
```
如果用户设置了图标素材规范参考图,则追加:
如果用户设置了图标规范参考图,则追加:
```text
参考图1为图标素材规范,请在UI图标、按钮符号、描边、材质、圆角、阴影和状态层级上严格遵循参考图1的素材规范。
参考图1为图标规范,请在UI图标、按钮符号、描边、材质、圆角、阴影和状态层级上严格遵循参考图1的素材规范。
```
生成请求固定使用 `gpt-image-2`。有参考图时走图片编辑请求;无参考图时走图片生成请求。
@@ -47,7 +47,7 @@
## 验收点
- 点击 `生成UI设计图` 后出现 UI 设计占位图和独立生成面板。
- 面板第一模块为图标素材规范,来源菜单包含三个动作。
- 从画布选择时只能绑定图标素材规范图片。
- 面板第一模块为图标规范,来源菜单包含三个动作。
- 从画布选择时只能绑定图标规范图片。
- 请求参数包含 `kind: "ui-design"``model: "gpt-image-2"`、比例、大小与可选参考图。
- 生成图层信息面板展示 `用户输入``图标素材规范`
- 生成图层信息面板展示 `用户输入``图标规范`
@@ -12,14 +12,14 @@
- 点击后立即在画布中心创建图标素材占位图,不复用普通“单张空白图片”图标;占位图表现为一叠空白素材图标卡片。
- 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。
- 生成完成后删除占位态,把拆分出的每个独立图标素材作为画布图片图层铺开,图标之间不重叠,并保留少量间距。
- 图标素材图层写入 `assetKind: "icon"`;图标素材规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
- 图标素材图层写入 `assetKind: "icon"`;图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
## 面板结构
1. 第一模块为 `图标素材规范`
- 点击后弹出菜单:`从画布中选择``新建图标素材规范``上传图片`
- `从画布中选择` 进入画布点选状态,只允许选择 `assetKind: "icon-spec"` 的图标素材规范图片;其它图片点击无效。
- `新建图标素材规范` 复用生成规范表单,规格类型为 `图标素材规范`,生成成功后图层标记为 `icon-spec`
1. 第一模块为 `图标规范`
- 点击后弹出菜单:`从画布中选择``新建图标规范``上传图片`
- `从画布中选择` 进入画布点选状态,只允许选择 `assetKind: "icon-spec"` 的图标规范图片;其它图片点击无效。
- `新建图标规范` 复用生成规范表单,规格类型为 `图标规范`,生成成功后图层标记为 `icon-spec`
- `上传图片` 使用现有本地图片上传入口,上传图只绑定到本次面板,不自动放入画布。
2. 第二模块为素材描述文本框。
- UI 复用角色形象生成面板同款单个文本输入框,让用户直接叙述多个素材。
@@ -29,27 +29,27 @@
## 面板外观
- 图标素材面板不再使用列表式素材描述框,也不再按描述项横向扩宽;素材描述区改为与角色形象生成面板一致的单个文本输入框。
- 图标素材规范入口采用 Lovart 式参考卡:左侧预览缩略图,中间显示当前绑定名称,右侧显示绑定状态和三个轻量动作入口,不再只是两行文字平铺。
- 规范卡的 `从画布中选择 / 新建图标素材规范 / 上传图片` 继续保留独立菜单,但菜单只负责来源切换,不承载说明文案。
- 图标规范入口采用 Lovart 式参考卡:左侧预览缩略图,中间显示当前绑定名称,右侧显示绑定状态和三个轻量动作入口,不再只是两行文字平铺。
- 规范卡的 `从画布中选择 / 新建图标规范 / 上传图片` 继续保留独立菜单,但菜单只负责来源切换,不承载说明文案。
## 生成契约
- 前端提交到 `POST /api/editor/icon-spritesheets/generations`
- 请求字段:
- `referenceImageSrc`:图标素材规范 Data URL。
- `referenceImageSrc`:图标规范 Data URL。
- `iconDescriptions`:过滤空文本后的图标描述数组,`1..100`
- `model`:支持 `gemini-3.1-flash-image-preview`UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`
- `aspectRatio`:按 `x:y` 展示,选项跟随模型。
- `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。
- `priceMudPoints`:固定取编辑器生成计费配置中的 `icon` 档位,首版为 `12`;前端只提交该配置值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死其它价格。
- 模型与尺寸选项:
- `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标素材规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig``0.5K` 按 VectorEngine 文档传 `"512"`
- `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标素材规范图作为 multipart `image`,按 `size` 映射:`1K 1:1 -> 1024x1024``1K 2:3/9:16 -> 1024x1536``1K 3:2/16:9 -> 1536x1024``2K 1:1 -> 2048x2048``2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`
- `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig``0.5K` 按 VectorEngine 文档传 `"512"`
- `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标规范图作为 multipart `image`,按 `size` 映射:`1K 1:1 -> 1024x1024``1K 2:3/9:16 -> 1024x1536``1K 3:2/16:9 -> 1536x1024``2K 1:1 -> 2048x2048``2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`
- 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。
- Prompt 固定为:
```text
参考图1的图标素材规范,纯绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet
参考图1的图标规范,纯绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet
<素材描述按中文顿号拼接>
```
@@ -72,7 +72,7 @@
## 验收
- 点击 `生成图标素材` 后出现一叠空白图标占位和图标素材面板。
- `图标素材规范 -> 从画布中选择` 只能选择图标素材规范图,点击普通图片或角色规范图不会绑定。
- `图标规范 -> 从画布中选择` 只能选择图标规范图,点击普通图片或角色规范图不会绑定。
- 默认 6 个素材描述会进入 prompt;用户在单个文本框中继续输入时最多解析 100 个素材描述。
- 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。
- 图标素材生成请求必须带 `model``aspectRatio``imageSize``nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize``gpt-image-2` 请求必须包含文档映射后的 `size`
@@ -4,7 +4,7 @@
## 背景
图片画布编辑器已有普通图片生成与“生成规范”能力。本次新增“生成角色形象”入口,用于在同一画布内生成标注为“角色”的单张角色形象图片,并支持绑定角色形象规范与常规参考图。
图片画布编辑器已有普通图片生成与“生成规范”能力。本次新增“生成角色形象”入口,用于在同一画布内生成标注为“角色”的单张角色形象图片,并支持绑定角色规范与常规参考图。
## 入口与画布表现
@@ -17,12 +17,12 @@
角色生成面板只包含创作必需输入;每个输入框、参考图入口和选项按钮都必须展示对应中文字段标题,不只依赖 placeholder、按钮文案或 aria-label
1. 第一项参考图入口为 `角色形象规范`
1. 第一项参考图入口为 `角色规范`
- 入口必须采用 Lovart 式参考图卡片:左侧小预览 / 图标,中间短标题,右侧仅保留必要状态,不把说明性规则文案铺在 UI 上。
- 点击后弹出菜单:`从画布中选择``新建角色形象规范``上传图片`
- 点击后弹出菜单:`从画布中选择``新建角色规范``上传图片`
- 来源菜单通过页面级 fixed portal 渲染,层级高于角色面板与参考图横向滚动区,不能被 `.image-canvas-editor__character-reference-row` 裁切。
- `从画布中选择` 进入画布点选状态,点击已有图片后把该图绑定为角色形象规范;按 `Esc` 退出点选状态。
- `新建角色形象规范` 复用当前 `生成规范 -> 角色形象规范` 流程。
- `从画布中选择` 进入画布点选状态,点击已有图片后把该图绑定为角色规范;按 `Esc` 退出点选状态。
- `新建角色规范` 复用当前 `生成规范 -> 角色规范` 流程。
- `上传图片` 使用现有本地图片上传入口。
2. 规范入口后方是常规参考图入口。
- `上传常规参考图` 同样使用 Lovart 式参考图卡片,不只显示一段文字按钮。
@@ -46,8 +46,8 @@
- 前端提交角色生成时,使用 `POST /api/editor/images/generations`
- `kind``character`,用于后端日志 / 审计语义识别。
- 角色形象规范与常规参考图作为 `referenceImageSrcs` 传入,顺序固定为:
1. 角色形象规范图。
- 角色规范与常规参考图作为 `referenceImageSrcs` 传入,顺序固定为:
1. 角色规范图。
2. 常规参考图列表。
- 请求同时提交 `model``aspectRatio``imageSize`
- `model` 支持 `gemini-3.1-flash-image-preview`UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`
@@ -68,7 +68,7 @@
## 生成规范参考图
- `生成规范 -> 角色形象规范``UI素材规范``图标素材规范``自定义规范` 的设定面板都支持上传 1 张参考图,并统一放在面板首行参考图区域。
- `生成规范 -> 角色规范``图标规范``图标规范``自定义规范` 的设定面板都支持上传 1 张参考图,并统一放在面板首行参考图区域。
- 参考图入口只展示字段标题、缩略图或上传图标、文件名,不把参考规则说明铺在 UI 上。
- 提交生成规范时,若存在参考图,前端必须把参考图作为 `referenceImageSrcs[0]` 提交到 `/api/editor/images/generations`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图 1 的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。
- 生成结果的信息快照必须记录该参考图,标题为 `参考图`,便于后续在图片信息面板回看生成输入。
@@ -89,8 +89,8 @@
## 可访问性与状态
- 点选状态下画布显示状态提示 `请选择画布中的图片作为角色形象规范,按 Esc 退出`
- 已绑定的角色形象规范入口显示所选图片标题。
- 点选状态下画布显示状态提示 `请选择画布中的图片作为角色规范,按 Esc 退出`
- 已绑定的角色规范入口显示所选图片标题。
- 生成中禁用参考图入口、文本框和按钮。
- 点击画布空白区域让当前占位图或图片失焦时,关闭角色生成面板并移除图片选中样式;占位图本身保持可重新打开。删除图层逻辑沿用现有图层删除能力。
@@ -98,8 +98,8 @@
- 点击 `生成角色形象` 后出现角色占位图、角色标签和角色生成面板。
- 角色生成面板打开时底部 AI 工具栏仍可见;点击画布空白区域后面板关闭,当前图片不再显示选中边框。
- `角色形象规范``上传常规参考图` 入口是带预览视觉块的参考图卡片,不是无样式文字。
- `从画布中选择` 后点击已有画布图片可绑定为角色形象规范,`Esc` 可退出点选状态。
- `角色规范``上传常规参考图` 入口是带预览视觉块的参考图卡片,不是无样式文字。
- `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。
- 上传常规参考图后缩略图右下角显示序号。
- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model``aspectRatio``imageSize`
- 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。
@@ -109,7 +109,7 @@
- 前端画板已接入 `生成角色形象` 底部入口、角色占位图、角色面板、画布点选规范图、上传规范图、上传常规参考图和序号角标。
- 画布生成类入口已统一接入新建占位图落点避让:优先使用当前屏幕中心对应画板位置,重叠时自动选择最近的不重叠位置,并将视口中心移动到新占位图。
- 角色生成提交统一走 `/api/editor/images/generations`,按 `角色形象规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`
- 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`
- 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent``imageConfig.aspectRatio/imageSize``gpt-image-2` 使用文档列出的 `size` 字符串。
- 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后自动执行绿幕去背、写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
- `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。
@@ -97,6 +97,7 @@ POST /api/editor/audios/background-music/generations
- 音效 body 使用 Suno 音效契约:`mv``task: "sound"``metadata_params`,其中 `metadata_params.tempo``1-300` 的 BPM 或 `null``metadata_params.key` 固定 `null`
- 编辑器音效提交和轮询都使用 Suno 路径,提交 `/suno/submit/music`,查询 `/suno/fetch/{taskId}`;旧 Vidu `/ent/v2/text2audio` 仅保留给历史视觉小说音效链路。
- Suno 提交成功后的任务 ID 兼容从 `data` 字符串、`data.task_id``data.taskId``data.id``task_id``taskId``id` 中读取,避免音效接口只返回 `data` 时误报“上游未返回任务 ID”。
- Suno 查询结果可能不直接返回音频 URL,而是在 `data` 中返回歌曲 / 音效 clip id;后端必须兼容 `data` 字符串、对象或数组中的 `id` / `clip_id` / `audioId` / `songId`,再调用 `/suno/act/wav/{clipId}` 提取 `wav_file_url` 等可下载地址。若已拿到 clip id 但 wav 地址仍未就绪,或 wav 子请求临时返回上游错误,继续按 `processing` 轮询,不要直接报“尚未返回可下载地址”或“获取 Suno 音效 wav 失败”。
- VectorEngine 音频响应的 `code` 需要兼容 `"success"``"ok"``"0"``"200"` 以及数字 `0` / `200`;HTTP 非 2xx 时后端错误信息应透出安全的上游状态和短响应摘要,避免前端只显示笼统提交失败。
-`api-server` 增加编辑器音频 BFF
- `/api/editor/audios/sound-effects/generations`
@@ -801,13 +801,18 @@ pub async fn generate_editor_image(
Some("ui-design") => "图片画布生成UI设计图",
_ => "图片画布生成图片",
};
let reference_limit = if matches!(normalized_kind, Some("quick-edit")) {
9
} else {
5
};
let reference_sources = payload
.reference_image_srcs
.unwrap_or_default()
.into_iter()
.map(|source| source.trim().to_string())
.filter(|source| !source.is_empty())
.take(5)
.take(reference_limit)
.collect::<Vec<_>>();
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
+37 -8
View File
@@ -202,15 +202,16 @@ pub async fn resolve_audio_task_download_urls(
break;
}
}
if audio_urls.is_empty()
&& let Some(error) = latest_wav_error
{
return Err(error);
}
// 中文注释:Suno 任务完成后可能先返回 clip id/suno/act/wav/{clip_id}
// wav_file_url 仍为 null。此时不能把任务判成失败,让上层轮询继续等待。
if should_wait_for_suno_wav_audio(&status, has_clip_ids, &audio_urls) {
return Ok(("processing".to_string(), audio_urls));
// 可能返回 404/业务错误或 wav_file_url 仍为 null。此时不能把任务判成失败,
// 让上层轮询继续等待;真正失败仍交给 fetch 任务状态判断。
if let Some(status) = resolve_suno_status_after_wav_probe(
&status,
has_clip_ids,
&audio_urls,
latest_wav_error,
) {
return Ok((status, audio_urls));
}
}
Ok((status, audio_urls))
@@ -220,6 +221,18 @@ fn should_wait_for_suno_wav_audio(status: &str, has_clip_ids: bool, audio_urls:
audio_urls.is_empty() && has_clip_ids && !is_failed_task_status(status)
}
fn resolve_suno_status_after_wav_probe(
status: &str,
has_clip_ids: bool,
audio_urls: &[String],
_latest_wav_error: Option<AudioError>,
) -> Option<String> {
if should_wait_for_suno_wav_audio(status, has_clip_ids, audio_urls) {
return Some("processing".to_string());
}
None
}
async fn get_vector_engine_json(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
@@ -498,4 +511,20 @@ mod tests {
&["https://cdn.example.test/audio.wav".to_string()]
));
}
#[test]
fn suno_wav_probe_error_keeps_completed_task_pending() {
let status = resolve_suno_status_after_wav_probe(
"completed",
true,
&Vec::new(),
Some(AudioError::upstream(
"获取 Suno 音效 wav 失败",
502,
r#"{"message":"wav not ready"}"#,
)),
);
assert_eq!(status.as_deref(), Some("processing"));
}
}
@@ -152,7 +152,7 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
expect(screen.getByLabelText('选择角色规范').textContent).toBe('true');
fireEvent.click(screen.getByRole('button', { name: '角色规范A' }));
fireEvent.click(screen.getByRole('menuitem', { name: '新建角色形象规范' }));
fireEvent.click(screen.getByRole('menuitem', { name: '新建角色规范' }));
expect(openSpecDialog).toHaveBeenCalledWith('character');
@@ -170,8 +170,8 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
it('removes bound character spec and regular references from their delete buttons', () => {
render(<CharacterGenerationHarness />);
fireEvent.click(screen.getByRole('button', { name: '删除角色形象规范' }));
expect(screen.getByRole('button', { name: '角色形象规范' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '删除角色规范' }));
expect(screen.getByRole('button', { name: '角色规范' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '角色规范A' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '删除参考图A' }));
@@ -186,12 +186,12 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '角色规范A' }));
expect(screen.getByRole('menu', { name: '角色形象规范来源' })).toBeTruthy();
expect(screen.getByRole('menu', { name: '角色规范来源' })).toBeTruthy();
fireEvent.click(screen.getByRole('textbox', { name: '角色设定' }));
expect(
screen.queryByRole('menu', { name: '角色形象规范来源' }),
screen.queryByRole('menu', { name: '角色规范来源' }),
).toBeNull();
});
});
@@ -128,8 +128,8 @@ export function ImageCanvasCharacterGenerationComposerView({
icon={<ClipboardList className="h-4 w-4" aria-hidden="true" />}
imageSrc={dialog.characterSpecReference?.src}
label="角色规范"
ariaLabel={dialog.characterSpecReference?.label ?? '角色形象规范'}
title={dialog.characterSpecReference?.label ?? '角色形象规范'}
ariaLabel={dialog.characterSpecReference?.label ?? '角色规范'}
title={dialog.characterSpecReference?.label ?? '角色规范'}
disabled={dialog.status === 'generating'}
isAdd={!dialog.characterSpecReference}
onClick={() => setIsCharacterSpecMenuOpen((open) => !open)}
@@ -146,13 +146,13 @@ export function ImageCanvasCharacterGenerationComposerView({
)
: undefined
}
removeLabel="删除角色形象规范"
removeLabel="删除角色规范"
/>
{isCharacterSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
label="角色形象规范来源"
label="角色规范来源"
placement="top-start"
style={buildPortalMenuStyle(
characterSpecButtonRef.current,
@@ -175,7 +175,7 @@ export function ImageCanvasCharacterGenerationComposerView({
onOpenSpecDialog('character');
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
describe('ImageCanvasCropExpandModel', () => {
it('resizes crop-expand frame freely from canvas handles', () => {
expect(
resizeCropExpandFrame({
frame: { x: 100, y: 80, width: 200, height: 120 },
handle: 'right',
deltaX: 40,
deltaY: 0,
ratio: 'free',
}),
).toEqual({ x: 100, y: 80, width: 240, height: 120 });
});
it('keeps a fixed ratio while dragging a crop-expand handle', () => {
const frame = resizeCropExpandFrame({
frame: { x: 100, y: 80, width: 200, height: 120 },
handle: 'bottom-right',
deltaX: 80,
deltaY: 10,
ratio: '1:1',
});
expect(frame.width).toBe(frame.height);
expect(frame).toEqual({ x: 100, y: 80, width: 280, height: 280 });
});
});
@@ -0,0 +1,184 @@
import type {
CropExpandPanelState,
CropExpandResizeHandle,
CropExpandRatioValue,
} from './ImageCanvasEditorTypes';
export const CROP_EXPAND_MIN_FRAME_SIZE = 16;
type CropExpandFrame = CropExpandPanelState['frame'];
type ResizeCropExpandFrameOptions = {
frame: CropExpandFrame;
handle: CropExpandResizeHandle;
deltaX: number;
deltaY: number;
ratio: CropExpandRatioValue;
minSize?: number;
};
export function resolveCropExpandRatioNumber(
ratio: CropExpandRatioValue,
): number | null {
if (ratio === 'free') {
return null;
}
const [widthValue, heightValue] = ratio.split(':').map(Number);
if (!widthValue || !heightValue) {
return null;
}
return widthValue / heightValue;
}
function normalizeFreeFrame(frame: CropExpandFrame, minSize: number) {
const right = frame.x + frame.width;
const bottom = frame.y + frame.height;
return {
x: Math.min(frame.x, right - minSize),
y: Math.min(frame.y, bottom - minSize),
width: Math.max(minSize, frame.width),
height: Math.max(minSize, frame.height),
};
}
function buildFreeResizeFrame({
frame,
handle,
deltaX,
deltaY,
minSize,
}: Omit<ResizeCropExpandFrameOptions, 'ratio'> & {
minSize: number;
}): CropExpandFrame {
const nextFrame = { ...frame };
const right = frame.x + frame.width;
const bottom = frame.y + frame.height;
if (handle.includes('left')) {
nextFrame.x = Math.min(frame.x + deltaX, right - minSize);
nextFrame.width = right - nextFrame.x;
}
if (handle.includes('right')) {
nextFrame.width = Math.max(minSize, frame.width + deltaX);
}
if (handle.includes('top')) {
nextFrame.y = Math.min(frame.y + deltaY, bottom - minSize);
nextFrame.height = bottom - nextFrame.y;
}
if (handle.includes('bottom')) {
nextFrame.height = Math.max(minSize, frame.height + deltaY);
}
return normalizeFreeFrame(nextFrame, minSize);
}
function resolveFixedDimensions({
candidate,
ratio,
minSize,
}: {
candidate: CropExpandFrame;
ratio: number;
minSize: number;
}) {
const widthFromHeight = Math.max(minSize, candidate.height * ratio);
const heightFromWidth = Math.max(minSize, candidate.width / ratio);
const optionFromWidth = {
width: Math.max(minSize, candidate.width),
height: heightFromWidth,
};
const optionFromHeight = {
width: widthFromHeight,
height: Math.max(minSize, candidate.height),
};
const widthDistance =
Math.abs(optionFromWidth.width - candidate.width) +
Math.abs(optionFromWidth.height - candidate.height);
const heightDistance =
Math.abs(optionFromHeight.width - candidate.width) +
Math.abs(optionFromHeight.height - candidate.height);
return widthDistance <= heightDistance ? optionFromWidth : optionFromHeight;
}
export function resizeCropExpandFrame({
frame,
handle,
deltaX,
deltaY,
ratio,
minSize = CROP_EXPAND_MIN_FRAME_SIZE,
}: ResizeCropExpandFrameOptions): CropExpandFrame {
const freeFrame = buildFreeResizeFrame({
frame,
handle,
deltaX,
deltaY,
minSize,
});
const ratioNumber = resolveCropExpandRatioNumber(ratio);
if (!ratioNumber) {
return freeFrame;
}
const sourceRight = frame.x + frame.width;
const sourceBottom = frame.y + frame.height;
const sourceCenterX = frame.x + frame.width / 2;
const sourceCenterY = frame.y + frame.height / 2;
const dimensions = resolveFixedDimensions({
candidate: freeFrame,
ratio: ratioNumber,
minSize,
});
switch (handle) {
case 'left':
return {
x: sourceRight - dimensions.width,
y: sourceCenterY - dimensions.height / 2,
...dimensions,
};
case 'right':
return {
x: frame.x,
y: sourceCenterY - dimensions.height / 2,
...dimensions,
};
case 'top':
return {
x: sourceCenterX - dimensions.width / 2,
y: sourceBottom - dimensions.height,
...dimensions,
};
case 'bottom':
return {
x: sourceCenterX - dimensions.width / 2,
y: frame.y,
...dimensions,
};
case 'top-left':
return {
x: sourceRight - dimensions.width,
y: sourceBottom - dimensions.height,
...dimensions,
};
case 'top-right':
return {
x: frame.x,
y: sourceBottom - dimensions.height,
...dimensions,
};
case 'bottom-left':
return {
x: sourceRight - dimensions.width,
y: frame.y,
...dimensions,
};
case 'bottom-right':
default:
return {
x: frame.x,
y: frame.y,
...dimensions,
};
}
}
@@ -1,24 +1,29 @@
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
import { X } from 'lucide-react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformTextField } from '../common/PlatformTextField';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasLayer, CropExpandPanelState } from './ImageCanvasEditorTypes';
import type {
CropExpandPanelState,
CropExpandRatioValue,
} from './ImageCanvasEditorTypes';
type ImageCanvasCropExpandPanelViewProps = {
panel: CropExpandPanelState;
sourceLayer: CanvasLayer;
style: CSSProperties;
setCropExpandPanel: Dispatch<SetStateAction<CropExpandPanelState | null>>;
onSubmit: () => void;
};
const CROP_EXPAND_FIELDS = [
{ key: 'left', label: '左' },
{ key: 'top', label: '上' },
{ key: 'right', label: '右' },
{ key: 'bottom', label: '下' },
const CROP_EXPAND_RATIO_OPTIONS: Array<{
value: CropExpandRatioValue;
label: string;
shape?: 'square' | 'landscape' | 'portrait';
}> = [
{ value: 'free', label: '自由比例' },
{ value: '1:1', label: '1:1', shape: 'square' },
{ value: '4:3', label: '4:3', shape: 'landscape' },
{ value: '3:4', label: '3:4', shape: 'portrait' },
{ value: '16:9', label: '16:9', shape: 'landscape' },
{ value: '9:16', label: '9:16', shape: 'portrait' },
] as const;
function resetFailedCropExpandPanel(panel: CropExpandPanelState) {
@@ -31,7 +36,6 @@ function resetFailedCropExpandPanel(panel: CropExpandPanelState) {
export function ImageCanvasCropExpandPanelView({
panel,
sourceLayer,
style,
setCropExpandPanel,
onSubmit,
@@ -50,43 +54,41 @@ export function ImageCanvasCropExpandPanelView({
}
}}
>
<div className="image-canvas-editor__quick-edit-head">
<div className="image-canvas-editor__quick-edit-reference">
<img src={sourceLayer.src} alt={`${sourceLayer.title}参考图`} />
<span>{sourceLayer.title}</span>
</div>
<EditorIconButton
label="关闭裁扩图片"
title="关闭"
icon={X}
onClick={() => setCropExpandPanel(null)}
/>
<div className="image-canvas-editor__crop-expand-head">
<strong></strong>
</div>
<div className="image-canvas-editor__crop-expand-fields">
{CROP_EXPAND_FIELDS.map(({ key, label }) => (
<label key={key} className="image-canvas-editor__crop-expand-field">
<span>{label}</span>
<PlatformTextField
aria-label={`裁扩${label}`}
value={String(panel.insets[key])}
inputMode="numeric"
size="xs"
density="compact"
onChange={(event) =>
setCropExpandPanel((currentPanel) =>
currentPanel
? {
...resetFailedCropExpandPanel(currentPanel),
insets: {
...currentPanel.insets,
[key]: event.target.value,
},
}
: currentPanel,
)
}
<div className="image-canvas-editor__crop-expand-ratios">
{CROP_EXPAND_RATIO_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={`image-canvas-editor__crop-expand-ratio ${
panel.ratio === option.value
? 'image-canvas-editor__crop-expand-ratio--selected'
: ''
}`}
aria-pressed={panel.ratio === option.value}
onClick={() =>
setCropExpandPanel((currentPanel) =>
currentPanel
? {
...resetFailedCropExpandPanel(currentPanel),
ratio: option.value,
}
: currentPanel,
)
}
>
<span
className={`image-canvas-editor__crop-expand-ratio-shape ${
option.shape
? `image-canvas-editor__crop-expand-ratio-shape--${option.shape}`
: ''
}`}
aria-hidden="true"
/>
</label>
<span>{option.label}</span>
</button>
))}
</div>
{panel.status === 'failed' ? (
@@ -94,15 +96,25 @@ export function ImageCanvasCropExpandPanelView({
{panel.errorMessage}
</p>
) : null}
<PlatformActionButton
type="submit"
tone="secondary"
size="sm"
className="image-canvas-editor__quick-edit-submit"
disabled={panel.status === 'processing'}
>
{panel.status === 'processing' ? '处理中' : '应用'}
</PlatformActionButton>
<div className="image-canvas-editor__crop-expand-actions">
<PlatformActionButton
type="button"
tone="secondary"
size="sm"
disabled={panel.status === 'processing'}
onClick={() => setCropExpandPanel(null)}
>
</PlatformActionButton>
<PlatformActionButton
type="submit"
tone="primary"
size="sm"
disabled={panel.status === 'processing'}
>
{panel.status === 'processing' ? '处理中' : '完成'}
</PlatformActionButton>
</div>
</form>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import type {
EditorCharacterAnimationGenerationResult,
EditorAssetLibrarySnapshot,
EditorProjectLayerSnapshot,
} from '../../services/image-editor/editorProjectClient';
@@ -331,11 +332,9 @@ export function hydrateCanvasGenerationDialog(
snapshot.characterAnimationDurationSeconds === 6
? snapshot.characterAnimationDurationSeconds
: undefined,
characterAnimationResult:
snapshot.characterAnimationResult &&
typeof snapshot.characterAnimationResult === 'object'
? snapshot.characterAnimationResult
: undefined,
characterAnimationResult: hydrateCharacterAnimationResult(
snapshot.characterAnimationResult,
),
soundType:
snapshot.soundType === 'loop' || snapshot.soundType === 'one-shot'
? snapshot.soundType
@@ -498,6 +497,29 @@ export function stringOrUndefined(value: unknown) {
return typeof value === 'string' && value.trim() ? value : undefined;
}
function hydrateCharacterAnimationResult(
value: unknown,
): EditorCharacterAnimationGenerationResult | undefined {
if (!value || typeof value !== 'object') {
return undefined;
}
const result = value as Partial<EditorCharacterAnimationGenerationResult>;
if (
typeof result.taskId !== 'string' ||
result.model !== 'seedance2.0-fast' ||
typeof result.prompt !== 'string' ||
typeof result.previewVideoPath !== 'string' ||
!Array.isArray(result.frames) ||
typeof result.frameCount !== 'number' ||
typeof result.durationSeconds !== 'number' ||
typeof result.fps !== 'number' ||
typeof result.priceMudPoints !== 'number'
) {
return undefined;
}
return result as EditorCharacterAnimationGenerationResult;
}
export function booleanFromSnapshot(value: unknown) {
return value === true;
}
@@ -641,6 +663,7 @@ function isCanvasGenerationDialogMode(
value === 'character' ||
value === 'icon' ||
value === 'ui-design' ||
value === 'quick-edit' ||
value === 'character-animation' ||
value === 'video' ||
value === 'audio-sound-effect' ||
@@ -127,7 +127,8 @@ function createStageProps(): ImageCanvasStageViewProps {
canvasMarquee: null,
canvasGenerationDialogs: [],
generateDialog: null,
quickEditPanel: null,
cropExpandPanel: null,
cropExpandSourceLayer: null,
generationComposerStyle: null,
selectedToolbarStyle: null,
uploadDropTarget: null,
@@ -159,6 +160,7 @@ function createStageProps(): ImageCanvasStageViewProps {
onOpenLayerMetadata: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
onOpenQuickEditPanel: vi.fn(),
onOpenRedrawPanel: vi.fn(),
onOpenCropExpandPanel: vi.fn(),
@@ -39,6 +39,7 @@ export type EditorAsset = {
taskId?: string;
objectKey?: string;
assetObjectId?: string;
durationSeconds?: number;
uploadStatus?: 'uploading' | 'failed';
uploadProgress?: number;
uploadMessage?: string;
@@ -86,6 +87,7 @@ export type CanvasLayer = {
groupId?: string | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
hidden?: boolean;
locked?: boolean;
flipX?: boolean;
@@ -151,6 +153,7 @@ export type GenerateDialogState = {
| 'character'
| 'icon'
| 'ui-design'
| 'quick-edit'
| 'character-animation'
| 'video'
| 'audio-sound-effect'
@@ -249,22 +252,42 @@ export type QuickEditPanelState = {
prompt: string;
size: string;
model: string;
quickEditReferences?: CharacterReferenceImage[];
status: 'idle' | 'generating' | 'failed';
errorMessage?: string;
};
export type CropExpandPanelState = {
sourceLayerId: string;
insets: {
left: string;
top: string;
right: string;
bottom: string;
frame: {
x: number;
y: number;
width: number;
height: number;
};
ratio: CropExpandRatioValue;
status: 'idle' | 'processing' | 'failed';
errorMessage?: string;
};
export type CropExpandRatioValue =
| 'free'
| '1:1'
| '4:3'
| '3:4'
| '16:9'
| '9:16';
export type CropExpandResizeHandle =
| 'left'
| 'top'
| 'right'
| 'bottom'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
export type CharacterAnimationPanelState = {
sourceLayerId: string;
promptText: string;
@@ -280,6 +303,7 @@ export type CharacterAnimationPanelState = {
export type UploadTarget =
| 'asset'
| 'generation-reference'
| 'quick-edit-reference'
| 'video-reference-image'
| 'video-reference-video'
| 'video-reference-audio'
@@ -464,6 +464,12 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(cropButton);
expect(screen.getByRole('dialog', { name: '裁扩图片' })).toBeTruthy();
expect(
screen.getByRole('button', { name: '自由比例' }).getAttribute(
'aria-pressed',
),
).toBe('true');
expect(screen.queryByLabelText('裁扩左')).toBeNull();
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
});
@@ -1,4 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
createEditorAsset,
@@ -15,6 +23,7 @@ import type {
CanvasTool,
CanvasViewport,
ImageContextMenuState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept } from './ImageCanvasFileModel';
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
@@ -61,6 +70,9 @@ export function ImageCanvasEditorView() {
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const selectedLayerIdRef = useRef<string | null>(null);
const selectedLayerIdsRef = useRef<string[]>([]);
const setQuickEditPanelRef = useRef<
Dispatch<SetStateAction<QuickEditPanelState | null>>
>(() => {});
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
() => {},
);
@@ -486,6 +498,7 @@ export function ImageCanvasEditorView() {
setAssets,
setLayers,
setGenerateDialog,
setQuickEditPanel: (updater) => setQuickEditPanelRef.current(updater),
appendCanvasLayersWithResources,
selectSingleLayer,
});
@@ -524,7 +537,6 @@ export function ImageCanvasEditorView() {
persistUpdatedLayerResource,
});
const {
quickEditPanel,
setQuickEditPanel,
setIsSpecMenuOpen,
setIsGenerationReferenceMenuOpen,
@@ -532,6 +544,8 @@ export function ImageCanvasEditorView() {
setIsCharacterReferenceMenuOpen,
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
@@ -549,6 +563,7 @@ export function ImageCanvasEditorView() {
removeSelectedLayerBackground,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
@@ -558,6 +573,7 @@ export function ImageCanvasEditorView() {
generationComposerNode,
switchGenerationTool,
} = generationSurface;
setQuickEditPanelRef.current = setQuickEditPanel;
const {
selectedLayer,
selectedToolbarStyle,
@@ -643,12 +659,14 @@ export function ImageCanvasEditorView() {
setGenerateDialog,
isPickingCharacterSpecFromCanvas,
isPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
clearCanvasFocus,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
@@ -685,6 +703,7 @@ export function ImageCanvasEditorView() {
setIsSpecMenuOpen,
setIsGenerationReferenceMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsPickingCharacterSpecFromCanvas,
@@ -859,7 +878,8 @@ export function ImageCanvasEditorView() {
canvasMarquee,
canvasGenerationDialogs,
generateDialog,
quickEditPanel,
cropExpandPanel: generationSurface.cropExpandPanel,
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
generationComposerStyle,
selectedToolbarStyle,
uploadDropTarget,
@@ -897,6 +917,8 @@ export function ImageCanvasEditorView() {
},
onGenerationFramePointerDown: handleGenerationFramePointerDown,
onActivateGenerationDialog: activateCanvasGenerationDialog,
onCropExpandHandlePointerDown:
generationSurface.startCropExpandFrameResize,
onOpenQuickEditPanel: openQuickEditPanel,
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
@@ -39,6 +39,7 @@ function createComposerProps(
isPickingCharacterSpecFromCanvas: false,
isPickingCharacterReferenceFromCanvas: false,
isPickingGenerationReferenceFromCanvas: false,
isPickingQuickEditReferenceFromCanvas: false,
isPickingIconSpecFromCanvas: false,
isPickingUiDesignSpecFromCanvas: false,
generateDialog,
@@ -50,8 +51,6 @@ function createComposerProps(
cropExpandPanel: null,
cropExpandSourceLayer: null,
cropExpandPanelStyle: null,
quickEditSizeOptions: ['1024x1024'],
quickEditModelOptions: [{ label: 'gpt-image-2', value: 'gpt-image-2' }],
characterAnimationPanel: null,
characterAnimationSourceLayer: null,
characterAnimationPanelStyle: null,
@@ -78,6 +77,7 @@ function createComposerProps(
setIsCharacterSpecMenuOpen: mockStateSetter<boolean>(),
setIsCharacterReferenceMenuOpen: mockStateSetter<boolean>(),
setIsGenerationReferenceMenuOpen: mockStateSetter<boolean>(),
setIsPickingQuickEditReferenceFromCanvas: mockStateSetter<boolean>(),
setIsIconSpecMenuOpen: mockStateSetter<boolean>(),
setIsUiDesignSpecMenuOpen: mockStateSetter<boolean>(),
setIsPickingCharacterSpecFromCanvas: mockStateSetter<boolean>(),
@@ -145,7 +145,7 @@ describe('ImageCanvasGenerationComposerView', () => {
'image-canvas-editor__generation-composer--image',
);
expect(
within(panel).getByRole('button', { name: 'UI设计图标素材规范' }),
within(panel).getByRole('button', { name: 'UI设计图标规范' }),
).toBeTruthy();
expect(
within(panel).getByRole('textbox', { name: 'UI设计要求' }).className,
@@ -159,7 +159,7 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(setGenerateDialog).toHaveBeenCalled();
const menu = screen.getByRole('menu', { name: '参考图来源' });
expect(
within(menu).getByRole('menuitem', { name: '新建图标素材规范' }),
within(menu).getByRole('menuitem', { name: '新建图标规范' }),
).toBeTruthy();
});
@@ -201,7 +201,7 @@ describe('ImageCanvasGenerationComposerView', () => {
).toContain('image-canvas-editor__generation-submit');
});
it('让图标素材规范生成面板也保留首行参考区', () => {
it('让图标规范生成面板也保留首行参考区', () => {
renderComposer({
mode: 'spec',
prompt: '',
@@ -60,6 +60,7 @@ type ImageCanvasGenerationComposerViewProps = {
isIconSpecMenuOpen: boolean;
isUiDesignSpecMenuOpen: boolean;
isPickingGenerationReferenceFromCanvas: boolean;
isPickingQuickEditReferenceFromCanvas: boolean;
isPickingCharacterSpecFromCanvas: boolean;
isPickingCharacterReferenceFromCanvas: boolean;
isPickingIconSpecFromCanvas: boolean;
@@ -73,8 +74,6 @@ type ImageCanvasGenerationComposerViewProps = {
cropExpandPanel: CropExpandPanelState | null;
cropExpandSourceLayer: CanvasLayer | null;
cropExpandPanelStyle: CSSProperties | null;
quickEditSizeOptions: string[];
quickEditModelOptions: Array<{ label: string; value: string }>;
characterAnimationPanel: CharacterAnimationPanelState | null;
characterAnimationSourceLayer: CanvasLayer | null;
characterAnimationPanelStyle: CSSProperties | null;
@@ -91,6 +90,7 @@ type ImageCanvasGenerationComposerViewProps = {
setIsIconSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsUiDesignSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingQuickEditReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
@@ -852,6 +852,7 @@ export function ImageCanvasGenerationComposerView({
isIconSpecMenuOpen,
isUiDesignSpecMenuOpen,
isPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
@@ -865,8 +866,6 @@ export function ImageCanvasGenerationComposerView({
cropExpandPanel,
cropExpandSourceLayer,
cropExpandPanelStyle,
quickEditSizeOptions,
quickEditModelOptions,
characterAnimationPanel,
characterAnimationSourceLayer,
characterAnimationPanelStyle,
@@ -881,6 +880,7 @@ export function ImageCanvasGenerationComposerView({
setIsIconSpecMenuOpen,
setIsUiDesignSpecMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsPickingIconSpecFromCanvas,
@@ -1080,7 +1080,7 @@ export function ImageCanvasGenerationComposerView({
{isPickingCharacterSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
Esc 退
</div>
) : null}
{isPickingCharacterReferenceFromCanvas ? (
@@ -1093,14 +1093,19 @@ export function ImageCanvasGenerationComposerView({
Esc 退
</div>
) : null}
{isPickingQuickEditReferenceFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
</div>
) : null}
{isPickingIconSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
Esc 退
</div>
) : null}
{isPickingUiDesignSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
Esc 退
</div>
) : null}
@@ -1113,8 +1118,15 @@ export function ImageCanvasGenerationComposerView({
panel={quickEditPanel}
sourceLayer={quickEditSourceLayer}
style={quickEditPanelStyle}
sizeOptions={quickEditSizeOptions}
modelOptions={quickEditModelOptions}
referenceButtonRef={generationReferenceButtonRef}
isReferenceMenuOpen={isGenerationReferenceMenuOpen}
setIsReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
setIsPickingQuickEditReferenceFromCanvas={
setIsPickingQuickEditReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
setQuickEditPanel={setQuickEditPanel}
onSubmit={onSubmitQuickEdit}
/>
@@ -1125,7 +1137,6 @@ export function ImageCanvasGenerationComposerView({
cropExpandPanelStyle ? (
<ImageCanvasCropExpandPanelView
panel={cropExpandPanel}
sourceLayer={cropExpandSourceLayer}
style={cropExpandPanelStyle}
setCropExpandPanel={setCropExpandPanel}
onSubmit={onSubmitCropExpand}

Some files were not shown because too many files have changed in this diff Show More