修复画布快速编辑回写与原图元数据
快速编辑回到原图面板流程,避免新建生成器占位 提交快速编辑时传入目标图层并在后端成功后回写画布 快速编辑生成输入记录原图引用,图片信息可展示原图来源 同步前后端测试与图片画布文档记忆
This commit is contained in:
@@ -66,8 +66,8 @@
|
||||
|
||||
- 现象:画板生成、快速编辑、图标素材或 UI 素材提取如果允许直接提交 generated objectKey,用户只要知道其他账号的私有 objectKey,就可能让 api-server 签名读取并送给外部生成供应商。
|
||||
- 原因:Data URL 参考图可以直接解析,但 objectKey 是服务端私有对象引用;只校验 generated 前缀、mime 和大小不能证明它属于当前账号。
|
||||
- 处理:所有编辑器参考图入口统一走 `parse_editor_reference_image(state, owner_user_id, source)`;objectKey 分支必须先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key,再读取 OSS。快速编辑和图标素材额外参考图也必须真实传到 provider,不只写 metadata。
|
||||
- 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入快速编辑 / 图标生成请求。
|
||||
- 处理:所有编辑器参考图入口统一走 `parse_editor_reference_image(state, owner_user_id, source)`;objectKey 分支必须先在当前账号的项目资源、素材库资产或 `asset_object` 中匹配 owner / bucket / key,再读取 OSS。图标素材等额外参考图必须真实传到 provider,不只写 metadata;图片快速编辑当前不开放额外参考图,若后续重开入口也必须沿用同一归属校验。
|
||||
- 验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_reference`,并用前端 workflow 测试覆盖 `referenceImageSrcs` 进入图标生成请求;若快速编辑重开额外参考图,再补对应请求覆盖。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`。
|
||||
|
||||
## 编辑器生成按钮显示泥点后仍要查真实钱包预扣
|
||||
@@ -311,14 +311,30 @@
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasGenerationSurface.test.tsx`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasOverlayModel.ts`、`src/components/image-editor/useImageCanvasGenerationSurface.tsx`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
|
||||
|
||||
## 图片画布图片改造也必须创建独立生成器占位
|
||||
## 图片画布重绘创建独立占位,快速编辑不要新建生成器
|
||||
|
||||
- 现象:点击图片图层的“改造”后,输入框直接挂在原图上,提交时既不像其它生成入口一样有独立占位,也容易让用户误以为会覆盖源图。
|
||||
- 原因:图片改造复用了旧 `QuickEditPanelState` / redraw 面板路径,只把源图选中并在原图附近打开快速编辑框,没有进入统一的 `CanvasGenerationDialogState` 占位链路。
|
||||
- 处理:图片和用户快照图层的“改造”统一创建 `mode="quick-edit"` 的 generation dialog,占位仍走 `ImageCanvasGenerationPlacementModel`;源图作为隐式最后一张参考图提交,并把“当前图”提示词归一到对应参考图编号。音频改造继续走音频生成器路径,非图片 fallback 才保留旧面板。参考图 Data URL 提交前可压缩,但浏览器图片解码卡住时必须超时透传原图,不能阻塞生成请求。
|
||||
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorImageReference.test.ts -- --runInBand`,以及 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "hides quick edit and redraw panels|opens generated image info|shows the quick edit generator" -- --runInBand`。
|
||||
- 现象:用户点击图片素材的“快速编辑”后,画布上额外出现 `Quick Edit Generator` 占位,像是新建了一个生成器;但用户预期是在原图下方框选区域、填写一个提示词和模型,然后直接修改当前图。
|
||||
- 原因:快速编辑入口和提交链路误用了 `createQuickEditGenerationDialogDraft(...)` / `CanvasGenerationDialogState`,把“覆盖源图”的快速编辑伪装成会产出新图层的生成器占位。
|
||||
- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,把当前图片或带编号标注的图片作为 `sourceImageSrc`,成功后覆盖源图,失败时保留快速编辑面板。图片重绘、去背景、视频快速编辑等会产出新图层或异步占位的入口仍可走 generation dialog / placement 链路。
|
||||
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx -- --runInBand`,以及按需运行 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "快速编辑|quick edit" -- --runInBand`。
|
||||
- 关联:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorImageReference.ts`。
|
||||
|
||||
## 图片画布快速编辑完成必须按目标图层回写
|
||||
|
||||
- 现象:图片快速编辑任务成功后,刷新页面素材库能看到新图,但画布上的源图没有替换。
|
||||
- 原因:`/api/editor/images/edits` 只保存生成图、项目资源和素材;没有 `canvasCompletion` 时不会写 `editor_canvas.layers_json`。`sourceResourceId` 只能表示溯源,同一资源可出现在多个图层,不能用它来决定替换哪一层。
|
||||
- 处理:图片快速编辑请求必须传 `targetLayerId`;后端在没有 `canvasCompletion` 的快速编辑完成分支里,用目标 layer id 和生成资源写回项目 layout。
|
||||
- 验证:`npm run test -- src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand`;后端验证至少覆盖 `editor_image_edit_request_omits_price_mud_points` 和 `editor_image_edit_can_complete_by_replacing_target_layer`。
|
||||
- 关联:`src/services/image-editor/editorProjectClient.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`server-rs/crates/api-server/src/editor_project.rs`。
|
||||
|
||||
## 图片画布快速编辑元数据必须记录原图引用
|
||||
|
||||
- 现象:快速编辑生成的新图可以替换画布,但打开图片信息时“生成输入”里看不到被修改的原图。
|
||||
- 原因:信息面板直接渲染 `generationInputs.references`;快速编辑虽然把原图作为 `sourceImageSrc` 传给 provider,但如果 `buildQuickEditGenerationInputs(...)` 不把源图写成引用,后端资源和画布层都没有可展示的原图引用。
|
||||
- 处理:快速编辑的 `generationInputs.references` 必须始终包含 `原图`,再追加用户额外参考图;关闭额外参考图入口时也不能删除这条源图引用。
|
||||
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx -- --runInBand`。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts`、`src/components/image-editor/ImageCanvasMetadataModalView.tsx`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`。
|
||||
|
||||
## 图片画布生成完成应用项目快照后也要刷新素材库
|
||||
|
||||
- 现象:部分素材生成成功后画布上已经出现结果,但左侧素材库没有立刻出现新素材,刷新页面后才显示。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -202,7 +202,7 @@ npm run check:server-rs-ddd
|
||||
2. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;实际扣费真相以后端运行时模型定价配置为准,前端按钮泥点只作为展示。
|
||||
3. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
|
||||
4. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
|
||||
5. 编辑器图片生成、图片修改、图标 spritesheet 和 UI 设计图提取素材的参考图可以提交 Data URL 或已登记的 generated objectKey;objectKey 必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,后端通过归属校验后才签名读取 OSS。快速编辑、图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,快速编辑 / 图标素材上限 8 张额外参考图。
|
||||
5. 编辑器图片生成、图片修改、图标 spritesheet 和 UI 设计图提取素材的参考图可以提交 Data URL 或已登记的 generated objectKey;objectKey 必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,后端通过归属校验后才签名读取 OSS。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图,只提交原图或红框序号标注图作为 `sourceImageSrc`。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。
|
||||
|
||||
## 外部服务与资产
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- `快速编辑`
|
||||
|
||||
`角色动画生成面板` 同步纳入本次生成类面板交互统一:点击角色图只聚焦图层,不自动弹出底部重绘或角色动画面板;点击 `生成动画` 后像新建图片一样创建 `角色动作` 画布占位,面板跟随占位底部,参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。
|
||||
`快速编辑` 由选中图片后的浮动工具栏显式打开,面板结构、参考图首行、提示词输入区和底部参数 / 生成区对齐其它生成类面板。
|
||||
`快速编辑` 由选中图片后的浮动工具栏显式打开,图片类素材统一进入框选区域 + 单提示词 + 模型选择的修改面板,不再恢复原来源生成器,也不展示参考图或尺寸控件。
|
||||
|
||||
## 统一布局
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
- 生成视频:`你希望生成什么视频?`
|
||||
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
|
||||
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
|
||||
10. 快速编辑最多允许额外绑定 8 张参考图;原图始终作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不占用额外参考图额度,也不在参考图条里固定展示 `图x`。
|
||||
10. 图片快速编辑不展示额外参考图入口;原图或绘制了红框和序号的标注图始终作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。
|
||||
11. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。
|
||||
|
||||
## 参数交互
|
||||
@@ -68,7 +68,7 @@
|
||||
- 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。
|
||||
- 生成规范类图片固定使用 `16:9·2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。
|
||||
- 宣发素材的 `游戏首图`、`详情五图`、`运营海报` 固定使用 `gpt-image-2`。面板底部只显示禁用态 `gpt-image-2` 模型胶囊和生成按钮,不出现 `nanobanana2` 选项;后端收到 `publication-material` 旧请求时也必须强制归一为 `gpt-image-2`。
|
||||
- 快速编辑默认从原图分辨率和模型初始化比例、尺寸和模型,但底部胶囊必须可点击修改;左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。
|
||||
- 图片快速编辑只保留一个提示词输入框和模型选择;提示词 placeholder 为 `写下每个编号要怎么改`,提交按钮显示 `修改`,不展示比例 / 尺寸或参考图控件。
|
||||
- 不再在底部常驻展开全部可选项。
|
||||
|
||||
## 泥点显示
|
||||
@@ -98,7 +98,7 @@
|
||||
- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 计算像素尺寸;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。
|
||||
- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。
|
||||
- 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。
|
||||
- 快速编辑点击生成后不在原图上播放生成中遮罩,而是立即创建独立 `Quick Edit Generator` 画布生成占位并播放生成中动画;该占位必须复用新建图片的 placement 避让逻辑,和已有素材 / 生成占位至少保留 32px 画布间距,不允许固定放到原图右侧后压住其它素材;生成成功后结果落在该占位框位置,失败时占位标记失败并恢复快速编辑面板。
|
||||
- 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。
|
||||
- 任何会打开画布内 composer / 面板的入口,必须在面板渲染后通过统一 overlay 可见性校正检查真实 DOM 矩形;如果面板超出画布视口,或底部工具栏 / 左下 dock 会遮住面板,就只平移当前 viewport 让面板完整进入安全区域。新增生成类入口不要在按钮 handler 里手写单独的避让偏移。
|
||||
|
||||
## 画布悬浮信息
|
||||
@@ -175,12 +175,12 @@
|
||||
- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。
|
||||
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
|
||||
- 生成规范类图片底部展示禁用态参数按钮 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。
|
||||
- 快速编辑底部展示当前选择的比例 / 尺寸和模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮,并允许展开修改;额外参考图最多 8 张,原图作为 `sourceImageSrc` 直接编辑,不在参考图条里固定显示 `图x`。
|
||||
- 图片快速编辑底部只展示模型选择和 `修改` 按钮;原图或红框序号标注图作为 `sourceImageSrc` 直接编辑,不展示参考图条或比例 / 尺寸控件。
|
||||
- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。
|
||||
- 快速编辑提交前保留提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用,不再改写成 `图N`。
|
||||
- 快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`;额外参考图进入输入快照,并作为图片编辑请求的 `referenceImageSrcs` 一起提交。
|
||||
- 快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。
|
||||
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
|
||||
- 快速编辑生成中的占位图同样只支持键盘 `Delete` / `Backspace` 删除,不新增 UI 删除按钮;删除后异步结果不得再落回画布。
|
||||
- 快速编辑不创建生成中占位图;提交后当前面板显示修改中,异步结果只允许回填到源图。
|
||||
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
|
||||
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
|
||||
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
|
||||
|
||||
@@ -228,6 +228,7 @@ pub struct EditorImageEditRequest {
|
||||
pub(crate) asset_folder_id: Option<String>,
|
||||
pub(crate) asset_label: Option<String>,
|
||||
pub(crate) source_resource_id: Option<String>,
|
||||
pub(crate) target_layer_id: Option<String>,
|
||||
pub(crate) canvas_completion: Option<EditorCanvasGenerationCompletionRequest>,
|
||||
}
|
||||
|
||||
@@ -1828,14 +1829,25 @@ pub(crate) async fn edit_editor_image_for_owner(
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let completed_project = complete_editor_canvas_generation(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
payload.project_id.as_deref(),
|
||||
payload.canvas_completion.as_ref(),
|
||||
generated_asset.resource.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
let completed_project = if payload.canvas_completion.is_some() {
|
||||
complete_editor_canvas_generation(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
payload.project_id.as_deref(),
|
||||
payload.canvas_completion.as_ref(),
|
||||
generated_asset.resource.as_ref(),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
complete_editor_canvas_background_removal(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
payload.project_id.as_deref(),
|
||||
payload.target_layer_id.as_deref(),
|
||||
generated_asset.resource.as_ref(),
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -6386,7 +6398,8 @@ mod tests {
|
||||
"sourceImageSrc": "data:image/png;base64,AAAA",
|
||||
"size": "2048x1152",
|
||||
"model": "gpt-image-2",
|
||||
"referenceImageSrcs": ["data:image/png;base64,BBBB"]
|
||||
"referenceImageSrcs": ["data:image/png;base64,BBBB"],
|
||||
"targetLayerId": "layer-source"
|
||||
}))
|
||||
.expect("image edit request should deserialize without price");
|
||||
|
||||
@@ -6397,6 +6410,22 @@ mod tests {
|
||||
request.reference_image_srcs,
|
||||
Some(vec!["data:image/png;base64,BBBB".to_string()])
|
||||
);
|
||||
assert_eq!(request.target_layer_id.as_deref(), Some("layer-source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_image_edit_can_complete_by_replacing_target_layer() {
|
||||
let source = include_str!("editor_project.rs");
|
||||
assert_function_contains(
|
||||
source,
|
||||
"pub(crate) async fn edit_editor_image_for_owner",
|
||||
"pub async fn remove_editor_image_background",
|
||||
&[
|
||||
"payload.canvas_completion.is_some()",
|
||||
"complete_editor_canvas_background_removal",
|
||||
"payload.target_layer_id.as_deref()",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,6 @@ use std::{
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
||||
#[cfg(not(test))]
|
||||
use module_auth::{
|
||||
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
|
||||
RefreshSessionService, WechatAuthService, WechatAuthStateService,
|
||||
|
||||
@@ -183,6 +183,48 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
expect(submitButton.textContent).toBe('生成5泥点');
|
||||
});
|
||||
|
||||
it('keeps quick edit to one prompt box and model selection', () => {
|
||||
render(
|
||||
<BasicGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
mode: 'quick-edit',
|
||||
sourceLayerId: 'layer-source',
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'ref-a',
|
||||
label: '旧参考图',
|
||||
src: 'data:image/png;base64,cmVmQQ==',
|
||||
},
|
||||
],
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
|
||||
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: '快速编辑提示词' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).queryByRole('button', { name: '添加参考图' }),
|
||||
).toBeNull();
|
||||
expect(within(panel).queryByText('旧参考图')).toBeNull();
|
||||
expect(
|
||||
within(panel).queryByRole('button', {
|
||||
name: /快速编辑图片尺寸/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(panel).getByRole('button', {
|
||||
name: '快速编辑图片模型 gpt-image-2',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clicks the parent generation panel to collapse an opened option panel', () => {
|
||||
render(<BasicGenerationHarness />);
|
||||
|
||||
|
||||
@@ -145,13 +145,23 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
promptLabel ?? (isQuickEdit ? '快速编辑提示词' : '生成提示词');
|
||||
const resolvedPromptPlaceholder =
|
||||
promptPlaceholder ??
|
||||
(isQuickEdit ? '想怎么编辑这张图?' : '今天想生成什么画面?');
|
||||
(isQuickEdit ? '写下每个编号要怎么改' : '今天想生成什么画面?');
|
||||
const resolvedOptionLabelPrefix =
|
||||
optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
|
||||
const resolvedReferenceButtonLabel = referenceButtonLabel;
|
||||
const resolvedReferenceButtonAriaLabel =
|
||||
referenceButtonAriaLabel ?? `添加${resolvedReferenceButtonLabel}`;
|
||||
const hasReferenceMenu = includeReferences && onRequestUpload;
|
||||
const shouldIncludeReferences = isQuickEdit ? false : includeReferences;
|
||||
const shouldIncludeDimensions = isQuickEdit ? false : includeDimensions;
|
||||
const resolvedSubmitLabel =
|
||||
isQuickEdit && submitLabel === '生成' ? '修改' : submitLabel;
|
||||
const resolvedSubmitAriaLabel =
|
||||
isQuickEdit && submitAriaLabel === '生成' ? '修改' : submitAriaLabel;
|
||||
const resolvedSubmittingStatusLabel =
|
||||
isQuickEdit && submittingStatusLabel === '生成中'
|
||||
? '修改中'
|
||||
: submittingStatusLabel;
|
||||
const hasReferenceMenu = shouldIncludeReferences && onRequestUpload;
|
||||
const finalFormClassName =
|
||||
formClassName ??
|
||||
'image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image';
|
||||
@@ -179,7 +189,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{includeReferences ? (
|
||||
{shouldIncludeReferences ? (
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
{references.map((reference, index) => {
|
||||
const label =
|
||||
@@ -259,7 +269,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions={includeDimensions}
|
||||
includeDimensions={shouldIncludeDimensions}
|
||||
includeModel={includeModel}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
|
||||
@@ -269,8 +279,8 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
model: dialog.imageModel,
|
||||
imageSize: dialog.imageSize,
|
||||
})}
|
||||
submitLabel={submitLabel}
|
||||
submitAriaLabel={submitAriaLabel}
|
||||
submitLabel={resolvedSubmitLabel}
|
||||
submitAriaLabel={resolvedSubmitAriaLabel}
|
||||
submitButtonClassName={submitButtonClassName}
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
@@ -284,7 +294,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
className="image-canvas-editor__generate-status"
|
||||
role="status"
|
||||
>
|
||||
{submittingStatusLabel}
|
||||
{resolvedSubmittingStatusLabel}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{dialog.status === 'failed' ? (
|
||||
|
||||
@@ -566,14 +566,12 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '生成角色形象' }),
|
||||
await screen.findByRole('dialog', { name: '快速编辑图片' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByLabelText('角色设定') as HTMLTextAreaElement).value,
|
||||
).toBe('黑衣剑士');
|
||||
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the matching generator from image context quick edit for tagged uploaded images', async () => {
|
||||
it('opens quick edit from image context for tagged uploaded images', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-context-tagged-layer',
|
||||
title: '右键标签素材画布',
|
||||
@@ -617,13 +615,10 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '生成角色形象' }),
|
||||
await screen.findByRole('dialog', { name: '快速编辑图片' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByLabelText('角色设定') as HTMLTextAreaElement).value,
|
||||
).toBe('红披风骑士');
|
||||
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
|
||||
});
|
||||
|
||||
it('restores publication material composer fields and references from saved generator snapshots', async () => {
|
||||
|
||||
@@ -1161,6 +1161,7 @@ export function ImageCanvasEditorView() {
|
||||
setIsPickingUiDesignSpecFromCanvas,
|
||||
openCharacterAnimationPanel,
|
||||
openRedrawPanel,
|
||||
openQuickEditPanel,
|
||||
openCropExpandPanel,
|
||||
removeSelectedLayerBackground,
|
||||
extractUiDesignAssets,
|
||||
@@ -1903,7 +1904,7 @@ export function ImageCanvasEditorView() {
|
||||
onFocusExternalTask: focusExternalGenerationTask,
|
||||
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
|
||||
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
|
||||
onOpenQuickEditPanel: openLayerGenerationDialog,
|
||||
onOpenQuickEditPanel: openQuickEditPanel,
|
||||
onOpenRedrawPanel: openRedrawPanel,
|
||||
onOpenCropExpandPanel: openCropExpandPanel,
|
||||
onRemoveBackground: removeSelectedLayerBackground,
|
||||
|
||||
@@ -117,7 +117,7 @@ function renderComposer(
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationComposerView', () => {
|
||||
it('让快速编辑复用普通图片生成面板结构', () => {
|
||||
it('让快速编辑只保留提示词和模型选择', () => {
|
||||
renderComposer({
|
||||
mode: 'quick-edit',
|
||||
prompt: '',
|
||||
@@ -142,6 +142,20 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
);
|
||||
expect(within(panel).getByRole('textbox', { name: '快速编辑提示词' }))
|
||||
.toBeTruthy();
|
||||
expect(
|
||||
within(panel).queryByRole('button', { name: '添加参考图' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(panel).queryByRole('button', {
|
||||
name: /快速编辑图片尺寸/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(panel).getByRole('button', {
|
||||
name: '快速编辑图片模型 gpt-image-2',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy();
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -1198,11 +1198,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{quickEditPanel &&
|
||||
(quickEditPanel.mode === 'redraw' ||
|
||||
quickEditPanel.status !== 'generating') &&
|
||||
quickEditSourceLayer &&
|
||||
quickEditPanelStyle ? (
|
||||
{quickEditPanel && quickEditSourceLayer && quickEditPanelStyle ? (
|
||||
<ImageCanvasQuickEditPanelView
|
||||
panel={quickEditPanel}
|
||||
sourceLayer={quickEditSourceLayer}
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
updateSpecFormDialogValue,
|
||||
} from './ImageCanvasGenerationDialogModel';
|
||||
import {
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
@@ -649,21 +649,21 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes legacy nanobanana model aliases in quick edit drafts', () => {
|
||||
it('locks quick edit drafts to gpt-image-2 despite legacy source models', () => {
|
||||
expect(
|
||||
createQuickEditPanelDraft(
|
||||
createLayer({
|
||||
model: 'nano-banana',
|
||||
}),
|
||||
).model,
|
||||
).toBe(IMAGE_MODEL_NANOBANANA2);
|
||||
).toBe(IMAGE_MODEL_GPT_IMAGE_2);
|
||||
expect(
|
||||
createQuickEditPanelDraft(
|
||||
createLayer({
|
||||
model: 'nanobanana2',
|
||||
}),
|
||||
).model,
|
||||
).toBe(IMAGE_MODEL_NANOBANANA2);
|
||||
).toBe(IMAGE_MODEL_GPT_IMAGE_2);
|
||||
});
|
||||
|
||||
it('creates character animation generation dialog drafts for character layers', () => {
|
||||
|
||||
@@ -1187,9 +1187,7 @@ export function createQuickEditPanelDraft(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const model = normalizeEditorImageModel(
|
||||
options.imageModel ?? sourceLayer.model,
|
||||
);
|
||||
const model = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
return {
|
||||
mode: 'quick-edit',
|
||||
sourceLayerId: sourceLayer.id,
|
||||
@@ -1211,7 +1209,6 @@ export function createQuickEditGenerationDialogDraft({
|
||||
prompt,
|
||||
status = 'idle',
|
||||
references = [],
|
||||
model,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
frame,
|
||||
@@ -1234,7 +1231,7 @@ export function createQuickEditGenerationDialogDraft({
|
||||
composerOpen: false,
|
||||
sourceLayerId: sourceLayer.id,
|
||||
generationReferences: references.slice(0, 8),
|
||||
imageModel: normalizeEditorImageModel(model ?? sourceLayer.model),
|
||||
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
placeholder: {
|
||||
@@ -1259,6 +1256,7 @@ export function createRedrawPanelDraft(
|
||||
return {
|
||||
...createQuickEditPanelDraft(sourceLayer, options),
|
||||
mode: 'redraw',
|
||||
model: normalizeEditorImageModel(options.imageModel ?? sourceLayer.model),
|
||||
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getEditorImageModelDisplayName,
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
normalizeEditorImageModel,
|
||||
QUICK_EDIT_MODEL_OPTIONS,
|
||||
resizeGenerationPlaceholderToImageSelection,
|
||||
resolveEditorImageSizeLabel,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
@@ -135,6 +136,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
||||
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const isQuickEdit = dialog.mode === 'quick-edit';
|
||||
const normalizedLockedModel = lockedModel
|
||||
? normalizeEditorImageModel(lockedModel)
|
||||
: null;
|
||||
@@ -143,7 +145,17 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
? { ...dialog, imageModel: normalizedLockedModel }
|
||||
: dialog,
|
||||
);
|
||||
const selectedModelLabel = getEditorImageModelDisplayName(selection.model);
|
||||
const modelOptions = isQuickEdit
|
||||
? [...QUICK_EDIT_MODEL_OPTIONS]
|
||||
: [...EDITOR_IMAGE_MODEL_OPTIONS];
|
||||
const selectedModel = modelOptions.some(
|
||||
(option) => option.value === selection.model,
|
||||
)
|
||||
? selection.model
|
||||
: (modelOptions[0]?.value ?? selection.model);
|
||||
const selectedModelLabel =
|
||||
modelOptions.find((option) => option.value === selectedModel)?.label ??
|
||||
getEditorImageModelDisplayName(selectedModel);
|
||||
const selectedSizeLabel = resolveEditorImageSizeLabel({
|
||||
aspectRatio: selection.aspectRatio,
|
||||
imageSize: selection.imageSize,
|
||||
@@ -340,8 +352,8 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
|
||||
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => {
|
||||
const selected = selection.model === option.value;
|
||||
{modelOptions.map((option) => {
|
||||
const selected = selectedModel === option.value;
|
||||
return (
|
||||
<OptionChoice
|
||||
key={option.value}
|
||||
|
||||
@@ -348,6 +348,12 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
).toEqual({
|
||||
fields: [{ title: '快速编辑提示词', value: '让图2下雨' }],
|
||||
references: [
|
||||
{
|
||||
title: '原图',
|
||||
label: '原图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
},
|
||||
{
|
||||
title: '参考图 1',
|
||||
label: '天空',
|
||||
@@ -373,11 +379,9 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }),
|
||||
).toBe('自定义');
|
||||
expect(buildQuickEditModelOptions('nano-banana')).toEqual([
|
||||
{ label: 'nanobanana2', value: DEFAULT_IMAGE_MODEL },
|
||||
{ label: 'gpt-image-2', value: 'gpt-image-2' },
|
||||
]);
|
||||
expect(buildQuickEditModelOptions('nanobanana2')).toEqual([
|
||||
{ label: 'nanobanana2', value: DEFAULT_IMAGE_MODEL },
|
||||
{ label: 'gpt-image-2', value: 'gpt-image-2' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -105,11 +105,13 @@ export const QUICK_EDIT_SIZE_PRESETS = [
|
||||
'2048x1152',
|
||||
'1024x1536',
|
||||
] as const;
|
||||
export const QUICK_EDIT_MODEL_OPTIONS = [
|
||||
export const EDITOR_IMAGE_MODEL_OPTIONS = [
|
||||
{ label: 'nanobanana2', value: IMAGE_MODEL_NANOBANANA2 },
|
||||
{ label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 },
|
||||
] as const;
|
||||
export const EDITOR_IMAGE_MODEL_OPTIONS = QUICK_EDIT_MODEL_OPTIONS;
|
||||
export const QUICK_EDIT_MODEL_OPTIONS = [
|
||||
{ label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 },
|
||||
] as const;
|
||||
export const EDITOR_IMAGE_DIMENSION_OPTIONS = {
|
||||
[IMAGE_MODEL_NANOBANANA2]: {
|
||||
aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'],
|
||||
@@ -548,14 +550,9 @@ export function getEditorImageModelDisplayName(
|
||||
}
|
||||
|
||||
export function buildQuickEditModelOptions(currentModel: string) {
|
||||
const normalizedCurrentModel = normalizeEditorImageModel(currentModel);
|
||||
void currentModel;
|
||||
const options = [...QUICK_EDIT_MODEL_OPTIONS];
|
||||
return options.some((option) => option.value === normalizedCurrentModel)
|
||||
? options
|
||||
: [
|
||||
{ label: normalizedCurrentModel, value: normalizedCurrentModel },
|
||||
...options,
|
||||
];
|
||||
return options;
|
||||
}
|
||||
|
||||
export function buildCharacterSpecPrompt(values: SpecFormValues) {
|
||||
@@ -1233,7 +1230,7 @@ export function buildEditGenerationInputs(
|
||||
export function buildQuickEditGenerationInputs(
|
||||
title: '快速编辑提示词' | '重绘提示词',
|
||||
prompt: string,
|
||||
_sourceLayer: CanvasLayer,
|
||||
sourceLayer: CanvasLayer,
|
||||
references: CharacterReferenceImage[] | undefined,
|
||||
): CanvasGenerationInputs {
|
||||
const limitedReferences = (references ?? []).slice(
|
||||
@@ -1243,6 +1240,7 @@ export function buildQuickEditGenerationInputs(
|
||||
return {
|
||||
fields: createGenerationInputField(title, prompt),
|
||||
references: [
|
||||
...createLayerGenerationInputReference('原图', sourceLayer),
|
||||
...limitedReferences.flatMap((reference, index) =>
|
||||
createGenerationInputReference(`参考图 ${index + 1}`, reference),
|
||||
),
|
||||
|
||||
@@ -158,7 +158,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '快速编辑提示词', value: '把当前图改成雨天' }],
|
||||
references: [],
|
||||
references: [
|
||||
{
|
||||
title: '原图',
|
||||
label: '源图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
|
||||
@@ -256,10 +256,9 @@ export function buildImageGenerationSubmissionPlan({
|
||||
if (!sourceLayer) {
|
||||
throw new Error('未找到要改造的图片');
|
||||
}
|
||||
const quickEditReferences = dialog.generationReferences ?? [];
|
||||
const basePrompt = dialog.prompt.trim() || '快速编辑图片';
|
||||
const normalizedQuickEditPrompt = basePrompt;
|
||||
const imageModel = normalizeEditorImageModel(dialog.imageModel);
|
||||
const imageModel = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
const aspectRatio =
|
||||
dialog.aspectRatio ??
|
||||
inferEditorImageAspectRatio(
|
||||
@@ -292,7 +291,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
'快速编辑提示词',
|
||||
normalizedQuickEditPrompt,
|
||||
sourceLayer,
|
||||
quickEditReferences,
|
||||
[],
|
||||
),
|
||||
},
|
||||
rememberImageModel: imageModel,
|
||||
|
||||
@@ -81,7 +81,7 @@ function QuickEditPanelHarness({
|
||||
}
|
||||
|
||||
describe('ImageCanvasQuickEditPanelView', () => {
|
||||
it('updates prompt and clears failed state while keeping options editable', () => {
|
||||
it('updates prompt and clears failed state while keeping model editable', () => {
|
||||
const rememberImageModel = vi.fn();
|
||||
render(
|
||||
<QuickEditPanelHarness
|
||||
@@ -103,24 +103,22 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
target: { value: '新提示' },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText('写下每个编号要怎么改'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前提示词').textContent).toBe('新提示');
|
||||
expect(screen.getByLabelText('当前尺寸').textContent).toBe('1024x1024');
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
||||
'gemini-3.1-flash-image-preview',
|
||||
);
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '快速编辑尺寸 1:1·1K' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '比例 16:9' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '尺寸 2K' }));
|
||||
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
|
||||
expect(screen.getByLabelText('当前图片规格').textContent).toBe('2K');
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '快速编辑尺寸 1:1·1K' }),
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }),
|
||||
screen.getByRole('button', { name: '快速编辑模型 gpt-image-2' }),
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'nanobanana2' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
||||
expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2');
|
||||
@@ -143,7 +141,7 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改' }));
|
||||
|
||||
expect(submitQuickEdit).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
@@ -172,7 +170,7 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders only extra references and keeps the implicit source image out of the strip', () => {
|
||||
it('hides quick edit references from the simplified panel', () => {
|
||||
render(
|
||||
<QuickEditPanelHarness
|
||||
initialPanel={{
|
||||
@@ -191,18 +189,15 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('图1')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('图1')).toBeNull();
|
||||
expect(screen.queryByLabelText('原图 图2')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除图1' }));
|
||||
|
||||
expect(screen.getByLabelText('参考图数量').textContent).toBe('0');
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
expect(screen.queryByLabelText('原图 图1')).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '添加快速编辑参考图' }),
|
||||
).toBeNull();
|
||||
expect(screen.getByLabelText('参考图数量').textContent).toBe('1');
|
||||
});
|
||||
|
||||
it('opens reference source menu and dispatches canvas picking or upload actions', () => {
|
||||
it('does not expose quick edit reference pick actions', () => {
|
||||
const requestUpload = vi.fn();
|
||||
render(
|
||||
<QuickEditPanelHarness
|
||||
@@ -220,17 +215,14 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加快速编辑参考图' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
|
||||
expect(screen.getByLabelText('画布选择参考图').textContent).toBe('picking');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加快速编辑参考图' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
|
||||
expect(requestUpload).toHaveBeenCalledWith('quick-edit-reference');
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '添加快速编辑参考图' }),
|
||||
).toBeNull();
|
||||
expect(requestUpload).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('画布选择参考图').textContent).toBe('idle');
|
||||
});
|
||||
|
||||
it('disables adding quick edit references after eight extra images', () => {
|
||||
it('keeps quick edit reference controls hidden even with many references', () => {
|
||||
render(
|
||||
<QuickEditPanelHarness
|
||||
initialPanel={{
|
||||
@@ -251,12 +243,10 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '添加快速编辑参考图',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
screen.queryByRole('button', {
|
||||
name: '添加快速编辑参考图',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(screen.queryByLabelText('原图 图9')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
|
||||
import { QUICK_EDIT_REFERENCE_LIMIT } from './ImageCanvasGenerationModel';
|
||||
import {
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
QUICK_EDIT_REFERENCE_LIMIT,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type {
|
||||
CanvasLayer,
|
||||
GenerateDialogState,
|
||||
@@ -48,13 +51,14 @@ function resetFailedPanelStatus<
|
||||
function createQuickEditDialog(
|
||||
panel: QuickEditPanelState,
|
||||
): GenerateDialogState {
|
||||
const isQuickEdit = (panel.mode ?? 'quick-edit') === 'quick-edit';
|
||||
return {
|
||||
mode: panel.mode === 'redraw' ? 'generate' : 'quick-edit',
|
||||
mode: isQuickEdit ? 'quick-edit' : 'generate',
|
||||
prompt: panel.prompt,
|
||||
status: panel.status,
|
||||
sourceLayerId: panel.sourceLayerId,
|
||||
generationReferences: panel.quickEditReferences,
|
||||
imageModel: panel.model,
|
||||
imageModel: isQuickEdit ? IMAGE_MODEL_GPT_IMAGE_2 : panel.model,
|
||||
aspectRatio: panel.aspectRatio,
|
||||
imageSize: panel.imageSize,
|
||||
};
|
||||
@@ -66,6 +70,7 @@ function syncPanelFromDialogUpdate(
|
||||
) {
|
||||
const normalizedPanel = resetFailedPanelStatus(panel);
|
||||
const normalizedDialog = resetFailedPanelStatus(dialog);
|
||||
const isQuickEdit = (normalizedPanel.mode ?? 'quick-edit') === 'quick-edit';
|
||||
return {
|
||||
...normalizedPanel,
|
||||
size: panel.size,
|
||||
@@ -73,7 +78,9 @@ function syncPanelFromDialogUpdate(
|
||||
aspectRatio:
|
||||
normalizedDialog.aspectRatio ?? normalizedPanel.aspectRatio ?? '1:1',
|
||||
imageSize: normalizedDialog.imageSize ?? normalizedPanel.imageSize ?? '1K',
|
||||
model: normalizedDialog.imageModel ?? normalizedPanel.model,
|
||||
model: isQuickEdit
|
||||
? IMAGE_MODEL_GPT_IMAGE_2
|
||||
: (normalizedDialog.imageModel ?? normalizedPanel.model),
|
||||
quickEditReferences: normalizedDialog.generationReferences,
|
||||
status: normalizedDialog.status,
|
||||
errorMessage: normalizedDialog.errorMessage,
|
||||
@@ -137,12 +144,12 @@ export function ImageCanvasQuickEditPanelView({
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
onSubmit={onSubmit}
|
||||
dialogLabel={isRedraw ? '重绘图片' : '快速编辑图片'}
|
||||
includeDimensions={true}
|
||||
includeDimensions={isRedraw}
|
||||
includeModel={true}
|
||||
includeReferences={!isRedraw}
|
||||
includeReferences={false}
|
||||
promptLabel={isRedraw ? '重绘提示词' : '快速编辑提示词'}
|
||||
promptPlaceholder={
|
||||
isRedraw ? '把画面重绘成什么样?' : '想怎么编辑这张图?'
|
||||
isRedraw ? '把画面重绘成什么样?' : '写下每个编号要怎么改'
|
||||
}
|
||||
optionLabelPrefix={isRedraw ? '重绘' : '快速编辑'}
|
||||
dimensionRatioAriaLabelPrefix="比例"
|
||||
@@ -153,7 +160,7 @@ export function ImageCanvasQuickEditPanelView({
|
||||
referenceMenuLabel={isRedraw ? '' : '快速编辑参考图来源'}
|
||||
referenceSlotTone="quick-edit"
|
||||
referenceSlotDisabled={!canAddReferences}
|
||||
showReferenceAddButton={!isRedraw}
|
||||
showReferenceAddButton={false}
|
||||
referenceLabelFormatter={(_, index) => `图${index + 1}`}
|
||||
referenceAriaLabelFormatter={(_, index) => `图${index + 1}`}
|
||||
referenceRemoveLabelFormatter={(_, index) => `删除图${index + 1}`}
|
||||
@@ -162,9 +169,9 @@ export function ImageCanvasQuickEditPanelView({
|
||||
promptClassName="image-canvas-editor__generation-prompt image-canvas-editor__quick-edit-prompt"
|
||||
referenceSlotClassName="image-canvas-editor__quick-edit-reference"
|
||||
submitButtonClassName="image-canvas-editor__generation-submit image-canvas-editor__quick-edit-submit"
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
submittingStatusLabel="生成中"
|
||||
submitLabel={isRedraw ? '生成' : '修改'}
|
||||
submitAriaLabel={isRedraw ? '生成' : '修改'}
|
||||
submittingStatusLabel={isRedraw ? '生成中' : '修改中'}
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
/>
|
||||
|
||||
@@ -495,20 +495,23 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||||
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'quick-edit:generating:closed:-:placeholder:-',
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-source:generating:快速修图:-',
|
||||
);
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
await waitFor(() => {
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '快速修图',
|
||||
sourceImageSrc: 'data:image/png;base64,source.png',
|
||||
size: '1024x768',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
model: 'gpt-image-2',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
'canvasCompletion',
|
||||
);
|
||||
expect(generateEditorImageMock).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
@@ -522,6 +525,49 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(screen.getByTestId('fit-count').textContent).toBe('0');
|
||||
});
|
||||
|
||||
it('clears quick edit panel after a queued edit reloads the project snapshot', async () => {
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
const refreshTaskList = vi.fn();
|
||||
const backendProject = {
|
||||
projectId: 'editor-project-1',
|
||||
title: '队列修图项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
...createLayer(),
|
||||
src: 'data:image/png;base64,queued-generated',
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-23T08:00:00.000Z',
|
||||
};
|
||||
loadEditorProjectMock.mockResolvedValueOnce(backendProject);
|
||||
editEditorImageMock.mockResolvedValueOnce({
|
||||
...createGenerated({ prompt: '快速修图' }),
|
||||
queueState: createQueueState(),
|
||||
});
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
projectId="editor-project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
onQueuedGenerationTask={refreshTaskList}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-1');
|
||||
});
|
||||
expect(refreshTaskList).toHaveBeenCalledTimes(1);
|
||||
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject);
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||||
expect(screen.getByTestId('tool').textContent).toBe('select');
|
||||
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
|
||||
});
|
||||
|
||||
it('submits quick-edit canvas generators with the source image reference', async () => {
|
||||
editEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: '把当前图改成雨天' }),
|
||||
@@ -561,6 +607,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
size: '1024x768',
|
||||
model: 'gpt-image-2',
|
||||
sourceResourceId: 'resource-source',
|
||||
targetLayerId: 'layer-source',
|
||||
assetLabel: '源图 快速编辑',
|
||||
}),
|
||||
);
|
||||
@@ -633,7 +680,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('submits quick edits with the numbered red selection image as the final source reference', async () => {
|
||||
it('submits quick edits with the numbered red selection image as the source image', async () => {
|
||||
editEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
|
||||
@@ -704,13 +751,99 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-source',
|
||||
referenceImageSrcs: ['data:image/png;base64,style.png'],
|
||||
targetLayerId: 'layer-source',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
'referenceImageSrcs',
|
||||
);
|
||||
expect(generateEditorImageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits canvas quick edits with numbered selection marks', async () => {
|
||||
editEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialDialog={{
|
||||
id: 'generation-dialog-quick-edit-marked',
|
||||
mode: 'quick-edit',
|
||||
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
sourceLayerId: 'layer-source',
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '4:3',
|
||||
imageSize: '1K',
|
||||
placeholder: {
|
||||
x: 472,
|
||||
y: 140,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 768,
|
||||
},
|
||||
}}
|
||||
initialQuickEditSelectionState={{
|
||||
sourceLayerId: 'layer-source',
|
||||
tool: 'rect',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
marks: [
|
||||
{
|
||||
id: 'quick-edit-selection-mark-1',
|
||||
tool: 'rect',
|
||||
x: 24,
|
||||
y: 36,
|
||||
width: 160,
|
||||
height: 120,
|
||||
},
|
||||
],
|
||||
references: [],
|
||||
draftMark: null,
|
||||
status: 'idle',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
|
||||
'data:image/png;base64,marked-source',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
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(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceImageSrc: 'data:image/png;base64,marked-source',
|
||||
model: 'gpt-image-2',
|
||||
targetLayerId: 'layer-source',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps failed quick edit panels visible with the normalized prompt', async () => {
|
||||
const refreshWalletBalance = vi.fn();
|
||||
editEditorImageMock.mockRejectedValueOnce(new Error('修图失败'));
|
||||
@@ -864,7 +997,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
resolution: '480p',
|
||||
mode: 'std',
|
||||
sound: 'off',
|
||||
referenceImageSrcs: ['data:image/png;base64,style.png'],
|
||||
referenceVideoSrcs: [
|
||||
'generated-character-drafts/editor/videos/source.mp4',
|
||||
],
|
||||
@@ -873,6 +1005,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(generateEditorVideoMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
'referenceImageSrcs',
|
||||
);
|
||||
expect(generateEditorImageMock).not.toHaveBeenCalled();
|
||||
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
@@ -888,24 +1023,13 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('submits character action quick edits through seedance fast character animation generation', async () => {
|
||||
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
||||
taskId: 'action-quick-edit-task',
|
||||
model: 'seedance2.0-fast',
|
||||
prompt: '让图1跳得更高',
|
||||
previewVideoPath: '/generated-character-drafts/editor/action-preview.mp4',
|
||||
frames: [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: 'data:image/png;base64,action-frame-edited',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
frameCount: 32,
|
||||
durationSeconds: 4,
|
||||
fps: 8,
|
||||
});
|
||||
it('submits character action quick edits through image edits', async () => {
|
||||
editEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
prompt: '让当前图跳得更高',
|
||||
imageSrc: 'data:image/png;base64,action-frame-edited',
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialLayers={[
|
||||
@@ -945,24 +1069,22 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceLayerId: 'layer-source',
|
||||
sourceImageSrc: 'generated-character-drafts/editor/actions/frame.png',
|
||||
promptText: '让当前图跳得更高',
|
||||
resolution: '480p',
|
||||
ratio: 'same',
|
||||
frameCount: 32,
|
||||
durationSeconds: 4,
|
||||
model: 'seedance2.0-fast',
|
||||
prompt: '让当前图跳得更高',
|
||||
sourceImageSrc: 'data:image/png;base64,frame.png',
|
||||
model: 'gpt-image-2',
|
||||
assetLabel: '角色动作 快速编辑',
|
||||
targetLayerId: 'layer-source',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
|
||||
expect(generateEditorImageMock).not.toHaveBeenCalled();
|
||||
expect(generateEditorVideoMock).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-character-animation-1:角色动作 快速编辑:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited:data:image/png;base64,action-frame-edited:4',
|
||||
'layer-source:角色动作:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
ICON_FRAME_DISPLAY_SIZE,
|
||||
ICON_FRAME_ORIGINAL_SIZE,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
isCanvasGenerationDialog,
|
||||
@@ -281,10 +282,6 @@ function isVideoQuickEditSource(layer: CanvasLayer) {
|
||||
return layer.mediaType === 'video' || layer.assetKind === 'video';
|
||||
}
|
||||
|
||||
function isCharacterAnimationQuickEditSource(layer: CanvasLayer) {
|
||||
return layer.assetKind === 'character-animation';
|
||||
}
|
||||
|
||||
export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
layers,
|
||||
canvasSize,
|
||||
@@ -953,7 +950,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
|
||||
const panelMode = quickEditPanel.mode ?? 'quick-edit';
|
||||
const quickEditReferences =
|
||||
panelMode === 'redraw' ? [] : (quickEditPanel.quickEditReferences ?? []);
|
||||
panelMode === 'redraw' || panelMode === 'quick-edit'
|
||||
? []
|
||||
: (quickEditPanel.quickEditReferences ?? []);
|
||||
const quickEditAspectRatio =
|
||||
quickEditPanel.aspectRatio ??
|
||||
inferEditorImageAspectRatio(
|
||||
@@ -977,7 +976,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
(panelMode === 'redraw' ? '重绘图片' : '快速编辑图片');
|
||||
const normalizedPrompt = panelMode === 'redraw' ? basePrompt : basePrompt;
|
||||
let quickEditDialogId: string | undefined;
|
||||
if (panelMode === 'quick-edit') {
|
||||
const shouldUseCanvasPlaceholder =
|
||||
panelMode === 'quick-edit' && isVideoQuickEditSource(quickEditSourceLayer);
|
||||
if (shouldUseCanvasPlaceholder) {
|
||||
const quickEditDraft = createQuickEditGenerationDialogDraft({
|
||||
sourceLayer: quickEditSourceLayer,
|
||||
prompt: normalizedPrompt,
|
||||
@@ -1002,7 +1003,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
: quickEditDraft.placeholder;
|
||||
quickEditDialogId = openCanvasGenerationDialog({
|
||||
...quickEditDraft,
|
||||
// 中文注释:快速编辑生成结果复用新建图片的避让落点,避免覆盖已有素材。
|
||||
// 中文注释:视频快速编辑仍落到独立视频结果占位,图片快速编辑直接替换源图。
|
||||
placeholder: quickEditPlacement,
|
||||
});
|
||||
setQuickEditPanel(null);
|
||||
@@ -1049,72 +1050,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
originalWidth: quickEditOutputSize.width,
|
||||
originalHeight: quickEditOutputSize.height,
|
||||
});
|
||||
if (
|
||||
panelMode === 'quick-edit' &&
|
||||
isCharacterAnimationQuickEditSource(quickEditSourceLayer)
|
||||
) {
|
||||
const animationResolution = '480p' as const;
|
||||
const animationDurationSeconds = 4 as const;
|
||||
const animationFrameCount = 32 as const;
|
||||
const generationInputs = buildCharacterAnimationGenerationInputs(
|
||||
normalizedPrompt,
|
||||
quickEditSourceLayer,
|
||||
);
|
||||
const generated = await runEditorGenerationWithWalletRefresh(
|
||||
generateEditorCharacterAnimation({
|
||||
sourceLayerId: quickEditSourceLayer.id,
|
||||
sourceImageSrc:
|
||||
resolveCharacterAnimationSourceImageSrc(quickEditSourceLayer),
|
||||
sourceWidth: quickEditSourceLayer.originalWidth,
|
||||
sourceHeight: quickEditSourceLayer.originalHeight,
|
||||
promptText: normalizedPrompt,
|
||||
resolution: animationResolution,
|
||||
ratio: 'same',
|
||||
frameCount: animationFrameCount,
|
||||
durationSeconds: animationDurationSeconds,
|
||||
model: CHARACTER_ANIMATION_MODEL,
|
||||
projectId,
|
||||
generationInputs,
|
||||
sourceResourceId: quickEditSourceLayer.resourceId,
|
||||
...(projectId && quickEditCanvasCompletionPlaceholder
|
||||
? {
|
||||
canvasCompletion: {
|
||||
dialogId: quickEditDialogId,
|
||||
title: `${quickEditSourceLayer.title} 快速编辑`,
|
||||
placeholder: quickEditCanvasCompletionPlaceholder,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
onWalletBalanceMayHaveChanged,
|
||||
);
|
||||
if (
|
||||
await applyQueuedEditorGenerationProject(
|
||||
generated,
|
||||
projectId,
|
||||
applyProjectSnapshot,
|
||||
onQueuedGenerationTask,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (generated.project && applyProjectSnapshot) {
|
||||
applyProjectSnapshot(generated.project);
|
||||
return;
|
||||
}
|
||||
addCharacterAnimationResultLayer(
|
||||
generated,
|
||||
`${quickEditSourceLayer.title} 快速编辑`,
|
||||
generationInputs,
|
||||
quickEditFrame,
|
||||
quickEditDialogId,
|
||||
{
|
||||
sourceLayer: quickEditSourceLayer,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
panelMode === 'quick-edit' &&
|
||||
isVideoQuickEditSource(quickEditSourceLayer)
|
||||
@@ -1229,7 +1164,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
prompt: normalizedPrompt,
|
||||
sourceImageSrc: sourceReferenceImageSrc,
|
||||
size: quickEditSize,
|
||||
model: normalizeEditorImageModel(quickEditPanel.model),
|
||||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
...(extraReferenceImageSrcs.length
|
||||
? { referenceImageSrcs: extraReferenceImageSrcs }
|
||||
: {}),
|
||||
@@ -1239,6 +1174,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
assetFolderId,
|
||||
assetLabel: `${quickEditSourceLayer.title} 快速编辑`,
|
||||
sourceResourceId: quickEditSourceLayer.resourceId,
|
||||
targetLayerId: quickEditSourceLayer.id,
|
||||
}),
|
||||
onWalletBalanceMayHaveChanged,
|
||||
)
|
||||
@@ -1281,10 +1217,20 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
onWalletBalanceMayHaveChanged,
|
||||
)
|
||||
) {
|
||||
if (panelMode === 'quick-edit') {
|
||||
setQuickEditPanel(null);
|
||||
setActiveTool('select');
|
||||
setActiveSidebarPanel('layers');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (generated.project && applyProjectSnapshot) {
|
||||
applyProjectSnapshot(generated.project);
|
||||
if (panelMode === 'quick-edit') {
|
||||
setQuickEditPanel(null);
|
||||
setActiveTool('select');
|
||||
setActiveSidebarPanel('layers');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (panelMode === 'quick-edit') {
|
||||
@@ -1448,10 +1394,22 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
generationInputs: submissionPlan.generationInputs,
|
||||
});
|
||||
} else if (submissionPlan.kind === 'quick-edit') {
|
||||
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
|
||||
let referenceImageSrc = await resolveEditorImageReferenceDataUrl(
|
||||
submissionPlan.sourceLayer.objectKey?.trim() ||
|
||||
submissionPlan.sourceLayer.src,
|
||||
);
|
||||
const quickEditSelectionMarks =
|
||||
quickEditSelectionState?.sourceLayerId ===
|
||||
submissionPlan.sourceLayer.id
|
||||
? quickEditSelectionState.marks
|
||||
: [];
|
||||
if (quickEditSelectionMarks.length > 0) {
|
||||
referenceImageSrc = await renderUiDesignAssetExtractionMarkedImage({
|
||||
source: referenceImageSrc,
|
||||
marks: quickEditSelectionMarks,
|
||||
showIndexLabels: true,
|
||||
});
|
||||
}
|
||||
const generated = await runEditorGenerationWithWalletRefresh(
|
||||
editEditorImage({
|
||||
prompt: submissionPlan.normalizedPrompt,
|
||||
@@ -1463,6 +1421,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
assetFolderId,
|
||||
assetLabel: submissionPlan.result.title,
|
||||
sourceResourceId: submissionPlan.sourceLayer.resourceId,
|
||||
targetLayerId: submissionPlan.sourceLayer.id,
|
||||
}),
|
||||
onWalletBalanceMayHaveChanged,
|
||||
);
|
||||
@@ -1701,6 +1660,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
getGeneratingDialogPlaceholder,
|
||||
layerCounterRef,
|
||||
layers,
|
||||
quickEditSelectionState,
|
||||
rememberImageModel,
|
||||
projectId,
|
||||
assetFolderId,
|
||||
|
||||
@@ -231,6 +231,11 @@ function GenerationWorkflowHarness({
|
||||
? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}`
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="quick-edit-image-options">
|
||||
{workflow.quickEditPanel
|
||||
? `${workflow.quickEditPanel.mode ?? 'quick-edit'}:${workflow.quickEditPanel.model}:${workflow.quickEditPanel.aspectRatio ?? '-'}:${workflow.quickEditPanel.imageSize ?? '-'}`
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="crop-expand">
|
||||
{workflow.cropExpandPanel
|
||||
? `${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}`
|
||||
@@ -833,18 +838,22 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the source generator for generated character quick edit', () => {
|
||||
it('opens quick edit for generated character images', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '快速编辑角色图' }));
|
||||
|
||||
expect(screen.getByTestId('tool').textContent).toBe('character');
|
||||
expect(screen.getByTestId('selected').textContent).toBe('-');
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'character:idle:open:-:placeholder',
|
||||
expect(screen.getByTestId('tool').textContent).toBe('generate');
|
||||
expect(screen.getByTestId('selected').textContent).toBe(
|
||||
'layer-character-generated',
|
||||
);
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-character-generated:idle:-',
|
||||
);
|
||||
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
|
||||
'layer-character-generated:none:0',
|
||||
);
|
||||
expect(screen.getByTestId('dialog-prompt').textContent).toBe('-');
|
||||
expect(screen.getByTestId('quick-edit-selection').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('opens the source generator for generated publication redraw', () => {
|
||||
@@ -864,7 +873,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the source generator for plain generated images with a source dialog', () => {
|
||||
it('opens quick edit for plain generated images without inheriting hidden source dimensions', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(
|
||||
@@ -873,23 +882,27 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '快速编辑来源普通图' }));
|
||||
|
||||
expect(screen.getByTestId('tool').textContent).toBe('generate');
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:idle:open:-:placeholder',
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-source:idle:-',
|
||||
);
|
||||
expect(screen.getByTestId('dialog-image-options').textContent).toBe(
|
||||
'generate:gpt-image-2:2:3:2K',
|
||||
expect(screen.getByTestId('quick-edit-image-options').textContent).toBe(
|
||||
'quick-edit:gpt-image-2:4:3:1K',
|
||||
);
|
||||
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
|
||||
'layer-source:none:0',
|
||||
);
|
||||
expect(screen.getByTestId('quick-edit-selection').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('keeps plain images without a source generator on quick edit generator', () => {
|
||||
it('keeps plain images without a source generator on the quick edit panel', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||||
|
||||
expect(screen.getByTestId('tool').textContent).toBe('generate');
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'quick-edit:idle:open:-:placeholder',
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-source:idle:-',
|
||||
);
|
||||
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
|
||||
'layer-source:none:0',
|
||||
@@ -994,12 +1007,12 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'quick-edit:idle:open:-:placeholder',
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-source:idle:-',
|
||||
);
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||||
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
|
||||
'generation-dialog-1:generate:closed|generation-dialog-2:quick-edit:open',
|
||||
'generation-dialog-1:generate:closed',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1019,7 +1032,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps source image model and dimensions when opening quick edit', () => {
|
||||
it('keeps source image model and infers dimensions from the current image when opening quick edit', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
@@ -1037,11 +1050,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:idle:open:-:placeholder',
|
||||
);
|
||||
expect(screen.getByTestId('dialog-image-options').textContent).toBe(
|
||||
'generate:gpt-image-2:2:3:2K',
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||||
expect(screen.getByTestId('quick-edit-image-options').textContent).toBe(
|
||||
'quick-edit:gpt-image-2:3:2:2K',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1483,7 +1494,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||||
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-source:idle:-',
|
||||
);
|
||||
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
|
||||
'layer-source:none:0',
|
||||
);
|
||||
@@ -1526,7 +1539,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
|
||||
'layer-source:rect:1',
|
||||
);
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-source:idle:对1号红色圈选框里的内容做以下修改:',
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '选择快速编辑椭圆框选' }),
|
||||
@@ -1536,7 +1551,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(screen.getByTestId('quick-edit-selection').textContent).toBe(
|
||||
'layer-source:ellipse:2',
|
||||
);
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-source:idle:对1号红色圈选框里的内容做以下修改:\n对2号红色圈选框里的内容做以下修改:',
|
||||
);
|
||||
});
|
||||
|
||||
it('submits quick edits and updates the source layer directly', async () => {
|
||||
@@ -1546,8 +1563,8 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑提示词' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
@@ -1558,7 +1575,14 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
sourceResourceId: 'resource-source',
|
||||
generationInputs: {
|
||||
fields: [{ title: '快速编辑提示词', value: '快速修图' }],
|
||||
references: [],
|
||||
references: [
|
||||
{
|
||||
title: '原图',
|
||||
label: '源图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -1755,7 +1779,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||||
'layer-source:idle:-',
|
||||
);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开修改状态' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' }));
|
||||
|
||||
@@ -1024,8 +1024,6 @@ export function useImageCanvasGenerationWorkflow({
|
||||
);
|
||||
return createQuickEditPanelDraft(sourceLayer, {
|
||||
imageModel: sourceDialog?.imageModel,
|
||||
aspectRatio: sourceDialog?.aspectRatio,
|
||||
imageSize: sourceDialog?.imageSize,
|
||||
});
|
||||
},
|
||||
[canvasGenerationDialogs],
|
||||
@@ -1038,48 +1036,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setUiAssetExtractionState(null);
|
||||
setCropExpandPanel(null);
|
||||
setCharacterAnimationPanel(null);
|
||||
const sourceDialog = findSourceGenerationDialog(
|
||||
canvasGenerationDialogs,
|
||||
sourceLayer,
|
||||
);
|
||||
const sameSourceDraft = createSameSourceGenerationDialogDraft({
|
||||
sourceLayer,
|
||||
canvasSize,
|
||||
viewport,
|
||||
sourceDialog,
|
||||
mode: 'quick-edit',
|
||||
sourceAnimationLayer: findSourceAnimationLayer(layers, sourceLayer),
|
||||
});
|
||||
if (sameSourceDraft) {
|
||||
openPlacedCanvasGenerationDialog(sameSourceDraft);
|
||||
setQuickEditPanel(null);
|
||||
setQuickEditSelectionState(null);
|
||||
selectSingleLayer(null);
|
||||
setActiveTool(getCanvasToolForGenerationMode(sameSourceDraft.mode));
|
||||
return;
|
||||
}
|
||||
const quickEditDraft = createQuickEditModeDraft(sourceLayer);
|
||||
const quickEditSize = resolveEditorImageGenerationPixelSize({
|
||||
model: quickEditDraft.model,
|
||||
aspectRatio: quickEditDraft.aspectRatio,
|
||||
imageSize: quickEditDraft.imageSize,
|
||||
});
|
||||
openPlacedCanvasGenerationDialog({
|
||||
...createQuickEditGenerationDialogDraft({
|
||||
sourceLayer,
|
||||
prompt: quickEditDraft.prompt,
|
||||
references: quickEditDraft.quickEditReferences ?? [],
|
||||
model: quickEditDraft.model,
|
||||
aspectRatio: quickEditDraft.aspectRatio,
|
||||
imageSize: quickEditDraft.imageSize,
|
||||
frame: {
|
||||
width: quickEditSize.width,
|
||||
height: quickEditSize.height,
|
||||
},
|
||||
}),
|
||||
composerOpen: true,
|
||||
});
|
||||
setQuickEditPanel(null);
|
||||
archiveActiveCanvasGenerationDialog();
|
||||
setGenerateDialog(null);
|
||||
setQuickEditPanel(quickEditDraft);
|
||||
selectSingleLayer(sourceLayer.id);
|
||||
const nextSelectionState = createUiAssetExtractionState(sourceLayer.id, {
|
||||
initialTool: null,
|
||||
model: DEFAULT_IMAGE_MODEL,
|
||||
@@ -1087,22 +1048,20 @@ export function useImageCanvasGenerationWorkflow({
|
||||
quickEditSelectionStateRef.current = nextSelectionState;
|
||||
setQuickEditSelectionState(nextSelectionState);
|
||||
quickEditSelectionMarkCounterRef.current = 0;
|
||||
selectSingleLayer(sourceLayer.id);
|
||||
setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize }));
|
||||
setActiveTool('generate');
|
||||
},
|
||||
[
|
||||
archiveActiveCanvasGenerationDialog,
|
||||
canvasSize,
|
||||
createQuickEditModeDraft,
|
||||
canvasGenerationDialogs,
|
||||
layers,
|
||||
openPlacedCanvasGenerationDialog,
|
||||
selectSingleLayer,
|
||||
setActiveTool,
|
||||
setGenerateDialog,
|
||||
setImageContextMenu,
|
||||
setMetadataLayer,
|
||||
setQuickEditPanel,
|
||||
setViewport,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2012,6 +1971,23 @@ export function useImageCanvasGenerationWorkflow({
|
||||
draftMark: null,
|
||||
marks: [...currentSelectionState.marks, normalizedMark],
|
||||
}));
|
||||
setQuickEditPanel((currentPanel) =>
|
||||
currentPanel?.sourceLayerId === currentSelectionState.sourceLayerId
|
||||
? {
|
||||
...currentPanel,
|
||||
status:
|
||||
currentPanel.status === 'failed' ? 'idle' : currentPanel.status,
|
||||
errorMessage:
|
||||
currentPanel.status === 'failed'
|
||||
? undefined
|
||||
: currentPanel.errorMessage,
|
||||
prompt: appendQuickEditSelectionPrompt(
|
||||
currentPanel.prompt,
|
||||
nextMarkNumber,
|
||||
),
|
||||
}
|
||||
: currentPanel,
|
||||
);
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'quick-edit' &&
|
||||
currentDialog.sourceLayerId === currentSelectionState.sourceLayerId
|
||||
@@ -2031,7 +2007,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
}
|
||||
: currentDialog,
|
||||
);
|
||||
}, [setGenerateDialog, updateQuickEditSelectionState]);
|
||||
}, [setGenerateDialog, setQuickEditPanel, updateQuickEditSelectionState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!quickEditSelectionState) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user