diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 486b802c3..5074673be 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -101,7 +101,7 @@ - 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。参考图请求顺序为额外参考图在前、原图作为隐式最后一张提交,信息面板输入快照只展示用户额外选择的参考图。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,显示短提示语、框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮;至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。 - 快速编辑的额外参考图同样必须在前端读取成图片 Data URL 后提交;后端 `referenceImageSrcs` 上限为 9 张,承载 8 张额外参考图加 1 张原图。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index e6b600bbc..ff3c9cd61 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -41,7 +41,7 @@ 8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。 9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。 10. 快速编辑最多允许额外绑定 8 张参考图;原图始终自动作为最后一张隐式参考图提交,不占用额外参考图额度,也不在参考图条里固定展示 `图x`。 -11. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态,再点同一工具取消启用。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。 +11. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。 ## 参数交互 @@ -174,7 +174,7 @@ - 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。 - 生成规范类图片底部展示禁用态参数按钮 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。 - 快速编辑底部展示当前选择的比例 / 尺寸和模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮,并允许展开修改;额外参考图最多 8 张,原图作为隐式最后参考图参与提交但不在参考图条里固定显示 `图x`。 -- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并显示选中态,再点同一工具取消;完成框选后画布红框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。 +- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。 - 快速编辑提交前必须把提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写成最后一张原图编号,例如额外 1 张参考图时改为 `图2`。 - 快速编辑提交给后端的参考图顺序必须是额外参考图在前、原图在最后;若用户已完成红色圈选,最后一张隐式原图必须替换为绘制了红框和序号的标注图。 - 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。 @@ -182,7 +182,7 @@ - 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。 - 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。 - 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。 -- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,点击同一工具可取消启用态;面板显示短提示语、框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet。框选数量不超过阈值时按 `1:1·1K` 和 gpt-image-2 1K 泥点提交,超过阈值时改用 `1:1·2K` 和 2K 泥点;后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。 +- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet` 生成 spritesheet。框选数量不超过阈值时按 `1:1·1K` 和 gpt-image-2 1K 泥点提交,超过阈值时改用 `1:1·2K` 和 2K 泥点;后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。 - 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。 - 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。 - 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。 diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 9d3b2d3f5..91ce99ff7 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -181,7 +181,6 @@ function createStageProps(): ImageCanvasStageViewProps { onUiAssetExtractionPointerStart: vi.fn(), onUiAssetExtractionPointerMove: vi.fn(), onUiAssetExtractionPointerEnd: vi.fn(), - onCancelUiAssetExtraction: vi.fn(), onSubmitUiAssetExtraction: vi.fn(), onQuickEditSelectionToolChange: vi.fn(), onQuickEditSelectionPointerStart: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 70ea0f28b..a6d84f995 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1,5 +1,6 @@ import { type Dispatch, + type PointerEvent as ReactPointerEvent, type SetStateAction, useCallback, useEffect, @@ -948,6 +949,17 @@ export function ImageCanvasEditorView() { onCloseImageContextMenu: () => setImageContextMenu(null), }); resetCanvasInteractionStateRef.current = clearActiveInteraction; + const handleCanvasPointerDownWithUiExtractionDismiss = useCallback( + (event: ReactPointerEvent) => { + if (uiAssetExtractionState) { + event.preventDefault(); + cancelUiAssetExtraction(); + return; + } + handleCanvasPointerDown(event); + }, + [cancelUiAssetExtraction, handleCanvasPointerDown, uiAssetExtractionState], + ); const deleteLayerByIdFromShortcut = useCallback( (layerId: string | null) => deleteLayerByIdRef.current(layerId), [], @@ -1357,7 +1369,7 @@ export function ImageCanvasEditorView() { activeSidebarPanel, isMinimapOpen, minimapModel, - onCanvasPointerDown: handleCanvasPointerDown, + onCanvasPointerDown: handleCanvasPointerDownWithUiExtractionDismiss, onCanvasPointerMove: handlePointerMove, onCanvasPointerUp: finishDrag, onCanvasDragOver: handleCanvasDragOver, @@ -1391,7 +1403,6 @@ export function ImageCanvasEditorView() { onUiAssetExtractionPointerStart: startUiAssetExtractionPointer, onUiAssetExtractionPointerMove: moveUiAssetExtractionPointer, onUiAssetExtractionPointerEnd: endUiAssetExtractionPointer, - onCancelUiAssetExtraction: cancelUiAssetExtraction, onSubmitUiAssetExtraction: () => void submitUiAssetExtraction(), onQuickEditSelectionToolChange: changeQuickEditSelectionTool, onQuickEditSelectionPointerStart: startQuickEditSelectionPointer, diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 3a00f6ca0..73d88f1aa 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -121,7 +121,6 @@ export type ImageCanvasStageViewProps = { onUiAssetExtractionPointerStart: (point: { x: number; y: number }) => void; onUiAssetExtractionPointerMove: (point: { x: number; y: number }) => void; onUiAssetExtractionPointerEnd: () => void; - onCancelUiAssetExtraction: () => void; onSubmitUiAssetExtraction: () => void; onQuickEditSelectionToolChange: (tool: UiAssetExtractionTool | null) => void; onQuickEditSelectionPointerStart: (point: { x: number; y: number }) => void; @@ -232,7 +231,6 @@ export function ImageCanvasStageView({ onUiAssetExtractionPointerStart, onUiAssetExtractionPointerMove, onUiAssetExtractionPointerEnd, - onCancelUiAssetExtraction, onSubmitUiAssetExtraction, onQuickEditSelectionToolChange, onQuickEditSelectionPointerStart, @@ -343,7 +341,6 @@ export function ImageCanvasStageView({ onPointerStart={onUiAssetExtractionPointerStart} onPointerMove={onUiAssetExtractionPointerMove} onPointerEnd={onUiAssetExtractionPointerEnd} - onCancel={onCancelUiAssetExtraction} onSubmit={onSubmitUiAssetExtraction} /> diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx index 362a9aeb8..8fdf99399 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.test.tsx @@ -109,7 +109,6 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { onPointerMove={vi.fn()} onPointerEnd={vi.fn()} onSubmit={vi.fn()} - onCancel={vi.fn()} />, ); @@ -120,7 +119,9 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { expect(panel.className).toContain( 'image-canvas-editor__ui-extraction-panel', ); - expect(within(panel).getByText('仅提取被红色框框选的素材并整理成spritesheet')).toBeTruthy(); + expect( + within(panel).getByText('使用框选工具框选你希望从画面中提取的素材'), + ).toBeTruthy(); expect(within(panel).getAllByLabelText(/框选区域预览/u)).toHaveLength(7); expect(within(panel).getByLabelText('计划规格').textContent).toBe('1:1·2K'); expect(within(panel).getByLabelText('固定模型').textContent).toContain('gpt-image-2'); @@ -206,7 +207,13 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => { '.image-canvas-editor__ui-extraction-mark-label', ), ).map((node) => node.textContent); + const labelCircles = Array.from( + container.querySelectorAll( + '.image-canvas-editor__ui-extraction-mark-label circle', + ), + ).map((node) => node.getAttribute('r')); expect(labels).toEqual(['1', '2']); + expect(labelCircles).toEqual(['12', '12']); }); }); diff --git a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx index 8a86db699..91694c6e3 100644 --- a/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx +++ b/src/components/image-editor/ImageCanvasUiAssetExtractionOverlayView.tsx @@ -1,4 +1,4 @@ -import { Circle, Paintbrush, Square, X } from 'lucide-react'; +import { Circle, Paintbrush, Square } from 'lucide-react'; import type { ComponentType, SVGProps } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react'; @@ -29,7 +29,6 @@ type ImageCanvasUiAssetExtractionOverlayViewProps = { onPointerStart: (point: { x: number; y: number }) => void; onPointerMove: (point: { x: number; y: number }) => void; onPointerEnd: () => void; - onCancel?: () => void; onSubmit?: () => void; }; @@ -81,6 +80,31 @@ function resolveMarkPreviewBounds( mark: UiAssetExtractionMark, sourceLayer: CanvasLayer, ) { + const originalWidth = Math.max(1, sourceLayer.originalWidth); + const originalHeight = Math.max(1, sourceLayer.originalHeight); + const normalizeBounds = (bounds: { + x: number; + y: number; + width: number; + height: number; + }) => { + const left = Math.min(Math.max(bounds.x, 0), originalWidth); + const top = Math.min(Math.max(bounds.y, 0), originalHeight); + const right = Math.min( + Math.max(bounds.x + bounds.width, 0), + originalWidth, + ); + const bottom = Math.min( + Math.max(bounds.y + bounds.height, 0), + originalHeight, + ); + return { + x: left, + y: top, + width: Math.max(1, right - left), + height: Math.max(1, bottom - top), + }; + }; if (mark.tool === 'brush') { const xs = mark.points.map((point) => point.x); const ys = mark.points.map((point) => point.y); @@ -88,19 +112,19 @@ function resolveMarkPreviewBounds( const top = Math.min(...ys); const right = Math.max(...xs); const bottom = Math.max(...ys); - return { + return normalizeBounds({ x: Number.isFinite(left) ? left : 0, y: Number.isFinite(top) ? top : 0, width: Math.max(1, Number.isFinite(right - left) ? right - left : 1), height: Math.max(1, Number.isFinite(bottom - top) ? bottom - top : 1), - }; + }); } - return { + return normalizeBounds({ x: Math.min(mark.x, mark.x + mark.width), y: Math.min(mark.y, mark.y + mark.height), width: Math.max(1, Math.abs(mark.width)), height: Math.max(1, Math.abs(mark.height)), - }; + }); } function renderMarkPreview( @@ -116,16 +140,17 @@ function renderMarkPreview( key={mark.id} aria-label={`框选区域预览 ${index + 1}`} className="image-canvas-editor__ui-extraction-preview" - style={{ - backgroundImage: `url("${sourceLayer.src}")`, - backgroundPosition: `${(bounds.x / originalWidth) * 100}% ${ - (bounds.y / originalHeight) * 100 - }%`, - backgroundSize: `${(originalWidth / bounds.width) * 100}% ${ - (originalHeight / bounds.height) * 100 - }%`, - }} > + {`${sourceLayer.title}框选预览 {index + 1} ); @@ -172,7 +197,7 @@ function renderMark( className="image-canvas-editor__ui-extraction-mark-label" transform={`translate(${labelPoint.x} ${labelPoint.y})`} > - + {index + 1} @@ -191,7 +216,6 @@ export function ImageCanvasUiAssetExtractionOverlayView({ onPointerStart, onPointerMove, onPointerEnd, - onCancel, onSubmit, }: ImageCanvasUiAssetExtractionOverlayViewProps) { if (!sourceLayer || !state) { @@ -308,7 +332,7 @@ export function ImageCanvasUiAssetExtractionOverlayView({ onPointerDown={(event) => event.stopPropagation()} >
- 仅提取被红色框框选的素材并整理成spritesheet + 使用框选工具框选你希望从画面中提取的素材
{IMAGE_MODEL_GPT_IMAGE_2} - } - onClick={onCancel} - />