优化工作区传播和清空交互
- 手工多边形、矩形和圆在未选语义分类时默认归入 maskid:0 的待分类类别。 - 后端自动传播按来源 annotation/mask/seed key 区分同类多实例,避免多个同类型 mask 传播时互相清理。 - 左侧工具栏在橡皮擦下方新增彩色 AI 自动传播入口,传播权重和范围控件只在进入传播后显示。 - 移除顶栏重复的清空片段遮罩入口,并取消当前清空/DEL 弹窗中的按帧范围清空路径。 - Canvas 右下角显示当前帧:XX/XXX,并调整布尔操作浮层位置避免重叠。 - 更新前端和后端回归测试,覆盖待分类默认、工具栏自动传播和同类多实例传播。 - 同步 AGENTS 与 doc 文档,说明新的工具栏、清空和传播行为。
This commit is contained in:
@@ -1253,8 +1253,11 @@ describe('CanvasArea', () => {
|
||||
expect(useStore.getState().masks).toHaveLength(1);
|
||||
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||
frameId: 'frame-1',
|
||||
label: '手工圆形',
|
||||
color: '#06b6d4',
|
||||
label: '待分类',
|
||||
color: '#000000',
|
||||
classId: 'reserved-unclassified',
|
||||
className: '待分类',
|
||||
classMaskId: 0,
|
||||
saveStatus: 'draft',
|
||||
bbox: [120, 80, 140, 120],
|
||||
metadata: expect.objectContaining({
|
||||
|
||||
@@ -5,6 +5,7 @@ import useImage from 'use-image';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { predictMask } from '../lib/api';
|
||||
import type { Frame, Mask } from '../store/useStore';
|
||||
import { RESERVED_UNCLASSIFIED_CLASS } from '../lib/maskIds';
|
||||
|
||||
type BooleanOperationTool = 'area_merge' | 'area_remove';
|
||||
|
||||
@@ -19,6 +20,8 @@ export interface BooleanFrameRangeRequest {
|
||||
interface CanvasAreaProps {
|
||||
activeTool: string;
|
||||
frame: Frame | null;
|
||||
currentFrameNumber?: number;
|
||||
totalFrames?: number;
|
||||
onRequestDeleteMasks?: (maskIds: string[]) => void;
|
||||
onRequestBooleanFrameRange?: (request: BooleanFrameRangeRequest) => void;
|
||||
onDeleteMaskAnnotations?: (annotationIds: string[]) => Promise<void> | void;
|
||||
@@ -428,7 +431,15 @@ function geometriesOverlap(first: MultiPolygon, second: MultiPolygon): boolean {
|
||||
return polygonClipping.intersection(first, second).length > 0;
|
||||
}
|
||||
|
||||
export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestBooleanFrameRange, onDeleteMaskAnnotations }: CanvasAreaProps) {
|
||||
export function CanvasArea({
|
||||
activeTool,
|
||||
frame,
|
||||
currentFrameNumber,
|
||||
totalFrames,
|
||||
onRequestDeleteMasks,
|
||||
onRequestBooleanFrameRange,
|
||||
onDeleteMaskAnnotations,
|
||||
}: CanvasAreaProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -781,21 +792,20 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
|
||||
if (!frame?.id || polygon.length < 3) return;
|
||||
const area = polygonArea(polygon);
|
||||
if (area <= 1) return;
|
||||
const color = activeClass?.color || '#06b6d4';
|
||||
const label = activeClass?.name || `手工${shape}`;
|
||||
const templateClass = activeClass || RESERVED_UNCLASSIFIED_CLASS;
|
||||
const mask: Mask = {
|
||||
id: `manual-${frame.id}-${shape}-${Date.now()}`,
|
||||
frameId: frame.id,
|
||||
templateId: activeTemplateId || undefined,
|
||||
classId: activeClass?.id,
|
||||
className: activeClass?.name,
|
||||
classZIndex: activeClass?.zIndex,
|
||||
classMaskId: activeClass?.maskId,
|
||||
classId: templateClass.id,
|
||||
className: templateClass.name,
|
||||
classZIndex: templateClass.zIndex,
|
||||
classMaskId: templateClass.maskId,
|
||||
saveStatus: 'draft',
|
||||
saved: false,
|
||||
pathData: polygonPath(polygon),
|
||||
label,
|
||||
color,
|
||||
label: templateClass.name,
|
||||
color: templateClass.color,
|
||||
segmentation: polygonSegmentation(polygon),
|
||||
bbox: polygonBbox(polygon),
|
||||
area,
|
||||
@@ -1768,17 +1778,23 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestB
|
||||
</Stage>
|
||||
|
||||
<div className="absolute bottom-4 left-4 flex gap-4 text-[10px] font-mono text-gray-500 pointer-events-none">
|
||||
<span>光标: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)}</span>
|
||||
<span>当前图层: {currentLayerLabel}</span>
|
||||
<span>缩放比: {(scale * 100).toFixed(0)}%</span>
|
||||
<span>遮罩数: {frameMasks.length}</span>
|
||||
<span>已保存: {savedMaskCount}</span>
|
||||
<span>未保存: {draftMaskCount}</span>
|
||||
<span>待更新: {dirtyMaskCount}</span>
|
||||
<span>光标: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)}</span>
|
||||
<span>当前图层: {currentLayerLabel}</span>
|
||||
<span>缩放比: {(scale * 100).toFixed(0)}%</span>
|
||||
<span>遮罩数: {frameMasks.length}</span>
|
||||
<span>已保存: {savedMaskCount}</span>
|
||||
<span>未保存: {draftMaskCount}</span>
|
||||
<span>待更新: {dirtyMaskCount}</span>
|
||||
</div>
|
||||
|
||||
{currentFrameNumber !== undefined && totalFrames !== undefined && (
|
||||
<div className="absolute bottom-4 right-4 rounded-md border border-white/10 bg-black/60 px-2.5 py-1.5 text-xs font-mono text-gray-200 shadow-lg">
|
||||
当前帧:{currentFrameNumber}/{totalFrames}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{frameMasks.length > 0 && isBooleanTool && (
|
||||
<div className="absolute bottom-4 right-4 flex gap-2">
|
||||
<div className="absolute bottom-14 right-4 flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs bg-white/5 text-gray-300 border border-white/10 px-2.5 py-1.5 rounded">
|
||||
已选 {booleanSelectedMasks.length}
|
||||
|
||||
@@ -81,6 +81,29 @@ describe('ToolsPalette', () => {
|
||||
expect(onClearMasks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('places colored auto propagation below the eraser tool', () => {
|
||||
const setActiveTool = vi.fn();
|
||||
const onAutoPropagate = vi.fn();
|
||||
render(
|
||||
<ToolsPalette
|
||||
activeTool="move"
|
||||
setActiveTool={setActiveTool}
|
||||
onAutoPropagate={onAutoPropagate}
|
||||
canAutoPropagate
|
||||
/>,
|
||||
);
|
||||
|
||||
const eraserButton = screen.getByTitle('橡皮擦 (X)');
|
||||
const autoButton = screen.getByRole('button', { name: '自动传播' });
|
||||
fireEvent.click(autoButton);
|
||||
|
||||
expect(autoButton).toHaveClass('bg-cyan-500/10');
|
||||
expect(autoButton.querySelector('[data-testid="ai-segmentation-icon"]')).toBeInTheDocument();
|
||||
expect(eraserButton.compareDocumentPosition(autoButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(setActiveTool).toHaveBeenCalledWith('auto_propagate');
|
||||
expect(onAutoPropagate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('separates drawing, editing, and external action tool groups', () => {
|
||||
const { container } = render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} canImportGtMask />);
|
||||
|
||||
@@ -89,6 +112,7 @@ describe('ToolsPalette', () => {
|
||||
const circleButton = screen.getByTitle('创建圆 (O)');
|
||||
const brushButton = screen.getByTitle('画笔 (B)');
|
||||
const eraserButton = screen.getByTitle('橡皮擦 (X)');
|
||||
const autoButton = screen.getByRole('button', { name: '自动传播' });
|
||||
const mergeButton = screen.getByTitle('区域合并 (+)');
|
||||
const removeButton = screen.getByTitle('重叠区域去除 (-)');
|
||||
const deleteButton = screen.getByTitle('删除选中遮罩 (Del)');
|
||||
@@ -99,7 +123,8 @@ describe('ToolsPalette', () => {
|
||||
expect(externalActionSeparator).toBe(separators[2]);
|
||||
expect(circleButton.compareDocumentPosition(separators[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(separators[0].compareDocumentPosition(brushButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(eraserButton.compareDocumentPosition(separators[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(eraserButton.compareDocumentPosition(autoButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(autoButton.compareDocumentPosition(separators[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(separators[1].compareDocumentPosition(mergeButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(removeButton.compareDocumentPosition(deleteButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(deleteButton.compareDocumentPosition(clearButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
@@ -8,9 +8,12 @@ interface ToolsPaletteProps {
|
||||
activeTool: string;
|
||||
setActiveTool: (tool: string) => void;
|
||||
onTriggerAI?: () => void;
|
||||
onAutoPropagate?: () => void;
|
||||
onImportGtMask?: () => void;
|
||||
onDeleteMasks?: () => void;
|
||||
onClearMasks?: () => void;
|
||||
canAutoPropagate?: boolean;
|
||||
isPropagating?: boolean;
|
||||
canImportGtMask?: boolean;
|
||||
isImportingGtMask?: boolean;
|
||||
}
|
||||
@@ -19,9 +22,12 @@ export function ToolsPalette({
|
||||
activeTool,
|
||||
setActiveTool,
|
||||
onTriggerAI,
|
||||
onAutoPropagate,
|
||||
onImportGtMask,
|
||||
onDeleteMasks,
|
||||
onClearMasks,
|
||||
canAutoPropagate = false,
|
||||
isPropagating = false,
|
||||
canImportGtMask = false,
|
||||
isImportingGtMask = false,
|
||||
}: ToolsPaletteProps) {
|
||||
@@ -96,7 +102,31 @@ export function ToolsPalette({
|
||||
<div className="mt-1 text-[10px] leading-none text-gray-400">{sizeControl.value}</div>
|
||||
</div>
|
||||
)}
|
||||
{(tool.id === 'create_circle' || tool.id === 'eraser') && (
|
||||
{tool.id === 'eraser' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTool('auto_propagate');
|
||||
onAutoPropagate?.();
|
||||
}}
|
||||
disabled={!canAutoPropagate || isPropagating}
|
||||
aria-label="自动传播"
|
||||
title={isPropagating ? '传播中...' : '自动传播'}
|
||||
className={cn(
|
||||
"w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 border",
|
||||
activeTool === 'auto_propagate'
|
||||
? "border-cyan-300/50 bg-cyan-500/20 text-white shadow-lg shadow-cyan-900/30"
|
||||
: "border-cyan-500/25 bg-cyan-500/10 text-cyan-200 hover:bg-cyan-500/20 hover:text-white",
|
||||
(!canAutoPropagate || isPropagating) && "opacity-35 cursor-not-allowed hover:bg-cyan-500/10 hover:text-cyan-200",
|
||||
)}
|
||||
>
|
||||
<AiSegmentationIcon size={17} strokeWidth={2.2} />
|
||||
</button>
|
||||
<div className="my-1 h-px w-9 bg-white/15" />
|
||||
</>
|
||||
)}
|
||||
{tool.id === 'create_circle' && (
|
||||
<div className="my-1 h-px w-9 bg-white/15" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -157,7 +157,7 @@ describe('VideoWorkspace', () => {
|
||||
fireEvent.keyDown(window, { key: 'Process', code: 'KeyY', ctrlKey: true });
|
||||
expect(useStore.getState().masks).toEqual([mask]);
|
||||
|
||||
fireEvent.keyDown(screen.getByLabelText('传播起始帧'), { key: 'z', ctrlKey: true });
|
||||
fireEvent.keyDown(screen.getByLabelText('遮罩透明度'), { key: 'z', ctrlKey: true });
|
||||
expect(useStore.getState().masks).toEqual([mask]);
|
||||
});
|
||||
|
||||
@@ -810,228 +810,50 @@ describe('VideoWorkspace', () => {
|
||||
expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears masks across the selected frame range', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||||
{ id: 12, project_id: 1, frame_index: 2, image_url: '/frame-2.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
apiMock.deleteAnnotation.mockResolvedValue(undefined);
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{ id: 'annotation-99', annotationId: '99', frameId: '10', pathData: 'M 0 0 Z', label: 'Saved 1', color: '#06b6d4', saved: true, saveStatus: 'saved' },
|
||||
{ id: 'draft-1', frameId: '11', pathData: 'M 1 1 Z', label: 'Draft', color: '#ff0000' },
|
||||
{ id: 'annotation-100', annotationId: '100', frameId: '12', pathData: 'M 2 2 Z', label: 'Saved 2', color: '#00ff00', saved: true, saveStatus: 'saved' },
|
||||
],
|
||||
selectedMaskIds: ['draft-1', 'annotation-100'],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
|
||||
expect(screen.getByText('请选择清空模式,并在播放进度条或视频处理进度条上点击/拖拽选择清空起止帧,再点击“确认清空”')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '清空全部' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: '保留人工/AI' })).toBeInTheDocument();
|
||||
|
||||
const processingBar = screen.getByLabelText('视频处理进度条');
|
||||
vi.spyOn(processingBar, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
right: 100,
|
||||
top: 0,
|
||||
bottom: 10,
|
||||
width: 100,
|
||||
height: 10,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
fireEvent.pointerDown(processingBar, { clientX: 0, pointerId: 1 });
|
||||
fireEvent.pointerMove(processingBar, { clientX: 50, pointerId: 1 });
|
||||
fireEvent.pointerUp(processingBar, { clientX: 50, pointerId: 1 });
|
||||
expect(screen.getByLabelText('传播起始帧')).toHaveValue(1);
|
||||
expect(screen.getByLabelText('传播结束帧')).toHaveValue(2);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
|
||||
expect(screen.getByText('清除人工/AI 标注帧')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('100');
|
||||
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-100']);
|
||||
expect(useStore.getState().selectedMaskIds).not.toContain('draft-1');
|
||||
expect(screen.getByText('已清空第 1-2 帧的 2 个遮罩,其中后端标注 1 个')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears a range after undo restores a mask whose backend annotation was already deleted', async () => {
|
||||
it('keeps range clearing out of the top bar and current clear confirmation', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
apiMock.getProjectAnnotations.mockResolvedValue([]);
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
|
||||
const restoredMask = {
|
||||
id: 'annotation-99',
|
||||
annotationId: '99',
|
||||
frameId: '10',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: 'Restored',
|
||||
color: '#06b6d4',
|
||||
saved: true,
|
||||
saveStatus: 'saved' as const,
|
||||
};
|
||||
act(() => {
|
||||
useStore.setState({ masks: [restoredMask], selectedMaskIds: ['annotation-99'] });
|
||||
useStore.getState().setMasks([]);
|
||||
useStore.getState().undoMasks();
|
||||
});
|
||||
expect(useStore.getState().masks).toEqual([restoredMask]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
|
||||
|
||||
await waitFor(() => expect(useStore.getState().masks).toEqual([]));
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('99');
|
||||
expect(useStore.getState().masks).toEqual([]);
|
||||
expect(screen.getByText('已清空第 1-2 帧的 1 个遮罩,其中后端标注 1 个')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('continues clearing a range when one of several annotation deletes returns 404', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
apiMock.deleteAnnotation
|
||||
.mockRejectedValueOnce({ status: 404 })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{ id: 'annotation-10149', annotationId: '10149', frameId: '10', pathData: 'M 0 0 Z', label: 'Missing', color: '#06b6d4', saved: true, saveStatus: 'saved' },
|
||||
{ id: 'annotation-10150', annotationId: '10150', frameId: '11', pathData: 'M 1 1 Z', label: 'Saved', color: '#22c55e', saved: true, saveStatus: 'saved' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10149'));
|
||||
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10150');
|
||||
expect(useStore.getState().masks).toEqual([]);
|
||||
expect(screen.getByText('已清空第 1-2 帧的 2 个遮罩,其中后端标注 2 个')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can clear only propagated masks while preserving manual or AI annotated frames', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
apiMock.deleteAnnotation.mockResolvedValue(undefined);
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{ id: 'manual-1', annotationId: '98', frameId: '10', pathData: 'M 0 0 Z', label: 'Manual', color: '#ef4444', saved: true, saveStatus: 'saved' },
|
||||
{
|
||||
id: 'seed-1',
|
||||
annotationId: '98',
|
||||
frameId: '10',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: 'Seed',
|
||||
color: '#ef4444',
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source_annotation_id: 98, source_mask_id: 'annotation-98' },
|
||||
},
|
||||
{
|
||||
id: 'propagated-1',
|
||||
annotationId: '99',
|
||||
frameId: '11',
|
||||
pathData: 'M 1 1 Z',
|
||||
label: 'Tracked',
|
||||
color: '#3b82f6',
|
||||
label: 'Seed',
|
||||
color: '#ef4444',
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source_annotation_id: 7, source_mask_id: 'annotation-7' },
|
||||
metadata: { source_annotation_id: 98, source_mask_id: 'annotation-98' },
|
||||
},
|
||||
],
|
||||
selectedMaskIds: ['manual-1', 'propagated-1'],
|
||||
selectedMaskIds: ['seed-1'],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '保留人工/AI' }));
|
||||
expect(screen.getByRole('button', { name: '保留人工/AI' })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
|
||||
|
||||
expect(screen.queryByText('清除人工/AI 标注帧')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('98');
|
||||
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['manual-1']);
|
||||
expect(useStore.getState().selectedMaskIds).toEqual(['manual-1']);
|
||||
expect(screen.getByText('已清空第 1-2 帧的 1 个自动传播遮罩,其中后端标注 1 个,人工/AI 标注帧已保留')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels range clearing when manual or AI annotated frames are not confirmed', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{ id: 'annotation-99', annotationId: '99', frameId: '10', pathData: 'M 0 0 Z', label: 'Manual', color: '#06b6d4', saved: true, saveStatus: 'saved' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
|
||||
expect(screen.getByText('清除人工/AI 标注帧')).toBeInTheDocument();
|
||||
const modal = screen.getByText('清除人工/AI 标注帧').closest('.fixed') as HTMLElement;
|
||||
fireEvent.click(within(modal).getByRole('button', { name: '取消' }));
|
||||
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
|
||||
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-99']);
|
||||
expect(screen.getByText('已取消清空片段遮罩')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not ask for manual-frame confirmation when clearing propagated-only frames', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
apiMock.deleteAnnotation.mockResolvedValue(undefined);
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'annotation-99',
|
||||
annotationId: '99',
|
||||
frameId: '10',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: 'Propagated',
|
||||
color: '#06b6d4',
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2_propagation', propagated_from_frame_id: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
|
||||
|
||||
expect(screen.queryByText('清除人工/AI 标注帧')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
|
||||
expect(screen.queryByRole('button', { name: '清空片段遮罩' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTitle('清空遮罩'));
|
||||
expect(screen.getByText('选择清空范围')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '按帧范围选择' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '只清当前帧' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '清空所有传播帧' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('auto-saves pending masks before exporting segmentation results', async () => {
|
||||
@@ -1649,12 +1471,13 @@ describe('VideoWorkspace', () => {
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText('传播权重')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||||
const propagationWeightSelect = screen.getByLabelText('传播权重');
|
||||
expect(propagationWeightSelect).toHaveClass('bg-[#050809]');
|
||||
expect(within(propagationWeightSelect).getByRole('option', { name: 'tiny' })).toHaveClass('text-cyan-100');
|
||||
fireEvent.change(propagationWeightSelect, { target: { value: 'sam2.1_hiera_small' } });
|
||||
expect(propagationWeightSelect).toHaveValue('sam2.1_hiera_small');
|
||||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -1785,7 +1608,7 @@ describe('VideoWorkspace', () => {
|
||||
})));
|
||||
});
|
||||
|
||||
it('removes propagation history bars when clearing the same frame range', async () => {
|
||||
it('keeps propagation history visible because top range clearing is removed', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||||
@@ -1869,12 +1692,9 @@ describe('VideoWorkspace', () => {
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('propagation-history-segment')).not.toBeInTheDocument());
|
||||
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('101');
|
||||
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('102');
|
||||
expect(screen.queryByRole('button', { name: '清空片段遮罩' })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('propagation-history-segment')).toBeInTheDocument();
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('auto-propagates all reference-frame masks in both directions inside the selected range', async () => {
|
||||
@@ -1946,9 +1766,10 @@ describe('VideoWorkspace', () => {
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||||
fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '1' } });
|
||||
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledTimes(1));
|
||||
const queuedPayload = apiMock.queuePropagationTask.mock.calls[0][0];
|
||||
|
||||
@@ -42,16 +42,7 @@ type PropagationHistorySegment = {
|
||||
colorIndex: number;
|
||||
label: string;
|
||||
};
|
||||
type RangeSelectionMode = 'propagation' | 'clear' | 'export' | 'boolean' | null;
|
||||
type ClearRangeMode = 'all' | 'propagated_only';
|
||||
type ClearRangeConfirmState = {
|
||||
frameIdsToClear: string[];
|
||||
annotationIds: string[];
|
||||
maskCount: number;
|
||||
rangeStartIndex: number;
|
||||
rangeEndIndex: number;
|
||||
mode: ClearRangeMode;
|
||||
};
|
||||
type RangeSelectionMode = 'propagation' | 'export' | 'boolean' | null;
|
||||
type CurrentClearConfirmState = {
|
||||
currentFrameNumber: number;
|
||||
scopeLabel: string;
|
||||
@@ -197,38 +188,6 @@ const normalizeMaskAgainstTemplates = (mask: Mask, templates: Template[]): Mask
|
||||
};
|
||||
};
|
||||
|
||||
const trimPropagationHistoryByClearedRange = (
|
||||
segments: PropagationHistorySegment[],
|
||||
clearStartFrame: number,
|
||||
clearEndFrame: number,
|
||||
): PropagationHistorySegment[] => {
|
||||
const start = Math.min(clearStartFrame, clearEndFrame);
|
||||
const end = Math.max(clearStartFrame, clearEndFrame);
|
||||
return segments.flatMap((segment) => {
|
||||
const segmentStart = Math.min(segment.startFrame, segment.endFrame);
|
||||
const segmentEnd = Math.max(segment.startFrame, segment.endFrame);
|
||||
if (segmentEnd < start || segmentStart > end) return [segment];
|
||||
const next: PropagationHistorySegment[] = [];
|
||||
if (segmentStart < start) {
|
||||
next.push({
|
||||
...segment,
|
||||
id: `${segment.id}-before-${start}`,
|
||||
startFrame: segmentStart,
|
||||
endFrame: start - 1,
|
||||
});
|
||||
}
|
||||
if (segmentEnd > end) {
|
||||
next.push({
|
||||
...segment,
|
||||
id: `${segment.id}-after-${end}`,
|
||||
startFrame: end + 1,
|
||||
endFrame: segmentEnd,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const prunePropagationHistoryByActiveFrames = (
|
||||
segments: PropagationHistorySegment[],
|
||||
activeFrameNumbers: Set<number>,
|
||||
@@ -517,8 +476,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const [propagationEndFrame, setPropagationEndFrame] = useState(1);
|
||||
const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false);
|
||||
const [rangeSelectionMode, setRangeSelectionMode] = useState<RangeSelectionMode>(null);
|
||||
const [clearRangeMode, setClearRangeMode] = useState<ClearRangeMode>('all');
|
||||
const [pendingClearRangeConfirm, setPendingClearRangeConfirm] = useState<ClearRangeConfirmState | null>(null);
|
||||
const [pendingCurrentClearConfirm, setPendingCurrentClearConfirm] = useState<CurrentClearConfirmState | null>(null);
|
||||
const [pendingBooleanRangeRequest, setPendingBooleanRangeRequest] = useState<BooleanFrameRangeRequest | null>(null);
|
||||
const [pendingBooleanRangeConfirm, setPendingBooleanRangeConfirm] = useState<BooleanRangeConfirmState | null>(null);
|
||||
@@ -947,90 +904,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
});
|
||||
}, [currentFrame, currentFrameNumber, executeClearCurrentMasks]);
|
||||
|
||||
const executeClearFrameRange = useCallback(async (request: ClearRangeConfirmState) => {
|
||||
const frameIdsToClear = new Set(request.frameIdsToClear);
|
||||
setIsSaving(true);
|
||||
setStatusMessage(request.annotationIds.length > 0
|
||||
? `正在删除第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的已保存标注...`
|
||||
: `正在清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的本地遮罩...`);
|
||||
try {
|
||||
await deleteAnnotationsIfExist(request.annotationIds, currentProject?.id);
|
||||
const latestMasks = useStore.getState().masks;
|
||||
const clearedMaskIds = new Set(
|
||||
latestMasks
|
||||
.filter((mask) => frameIdsToClear.has(String(mask.frameId)))
|
||||
.filter((mask) => request.mode === 'all' || isPropagatedMask(mask))
|
||||
.map((mask) => mask.id),
|
||||
);
|
||||
setMasks(latestMasks.filter((mask) => !clearedMaskIds.has(mask.id)));
|
||||
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id)));
|
||||
setPropagationHistory((previous) => trimPropagationHistoryByClearedRange(previous, request.rangeStartIndex + 1, request.rangeEndIndex + 1));
|
||||
setStatusMessage(request.mode === 'propagated_only'
|
||||
? `已清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的 ${request.maskCount} 个自动传播遮罩,其中后端标注 ${request.annotationIds.length} 个,人工/AI 标注帧已保留`
|
||||
: `已清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的 ${request.maskCount} 个遮罩,其中后端标注 ${request.annotationIds.length} 个`);
|
||||
setIsPropagationRangeSelecting(false);
|
||||
setRangeSelectionMode(null);
|
||||
setHasExplicitPropagationRange(false);
|
||||
setPendingClearRangeConfirm(null);
|
||||
} catch (err) {
|
||||
console.error('Delete range annotations failed:', err);
|
||||
setStatusMessage('批量清空失败,请检查后端服务');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [currentProject?.id, setMasks, setSelectedMaskIds]);
|
||||
|
||||
const handleClearFrameRangeMasks = useCallback(async () => {
|
||||
if (rangeSelectionMode !== 'clear') {
|
||||
setIsPropagationRangeSelecting(true);
|
||||
setRangeSelectionMode('clear');
|
||||
setClearRangeMode('all');
|
||||
setStatusMessage('请选择清空模式,并在播放进度条或视频处理进度条上点击/拖拽选择清空起止帧,再点击“确认清空”');
|
||||
return;
|
||||
}
|
||||
if (frames.length === 0) return;
|
||||
const clampRangeFrameNumber = (value: number) => {
|
||||
if (totalFrames <= 0) return 1;
|
||||
return Math.min(Math.max(value, 1), totalFrames);
|
||||
};
|
||||
const startFrameNumber = clampRangeFrameNumber(propagationStartFrame);
|
||||
const endFrameNumber = clampRangeFrameNumber(propagationEndFrame);
|
||||
const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1;
|
||||
const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1;
|
||||
const frameIdsToClear = new Set(
|
||||
frames.slice(rangeStartIndex, rangeEndIndex + 1).map((frame) => String(frame.id)),
|
||||
);
|
||||
const masksInRange = masks.filter((mask) => frameIdsToClear.has(String(mask.frameId)));
|
||||
const rangeMasks = clearRangeMode === 'propagated_only'
|
||||
? masksInRange.filter(isPropagatedMask)
|
||||
: masksInRange;
|
||||
if (rangeMasks.length === 0) {
|
||||
setStatusMessage(clearRangeMode === 'propagated_only'
|
||||
? `第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧没有可清空的自动传播遮罩`
|
||||
: `第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧没有可清空的遮罩`);
|
||||
return;
|
||||
}
|
||||
const hasManualOrAiAnnotatedFrames = clearRangeMode === 'all' && rangeMasks.some((mask) => !isPropagatedMask(mask));
|
||||
const annotationIds = Array.from(new Set(
|
||||
rangeMasks
|
||||
.map((mask) => mask.annotationId)
|
||||
.filter((annotationId): annotationId is string => Boolean(annotationId)),
|
||||
));
|
||||
const request = {
|
||||
frameIdsToClear: Array.from(frameIdsToClear),
|
||||
annotationIds,
|
||||
maskCount: rangeMasks.length,
|
||||
rangeStartIndex,
|
||||
rangeEndIndex,
|
||||
mode: clearRangeMode,
|
||||
};
|
||||
if (hasManualOrAiAnnotatedFrames) {
|
||||
setPendingClearRangeConfirm(request);
|
||||
return;
|
||||
}
|
||||
await executeClearFrameRange(request);
|
||||
}, [clearRangeMode, executeClearFrameRange, frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, totalFrames]);
|
||||
|
||||
const handleBooleanFrameRangeRequest = useCallback((request: BooleanFrameRangeRequest) => {
|
||||
const candidateFrameNumbers = request.candidateFrameIds
|
||||
.map((frameId) => frameNumberById.get(String(frameId)))
|
||||
@@ -1415,11 +1288,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setPropagationStartFrame(nextStart);
|
||||
setPropagationEndFrame(nextEnd);
|
||||
setHasExplicitPropagationRange(true);
|
||||
const actionLabel = rangeSelectionMode === 'clear'
|
||||
? '清空范围'
|
||||
: rangeSelectionMode === 'boolean'
|
||||
? '布尔操作范围'
|
||||
: '自动传播范围';
|
||||
const actionLabel = rangeSelectionMode === 'boolean' ? '布尔操作范围' : '自动传播范围';
|
||||
setStatusMessage(`已选择${actionLabel}:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)} 帧`);
|
||||
}, [clampFrameNumber, rangeSelectionMode]);
|
||||
|
||||
@@ -1601,6 +1470,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setIsPropagating(false);
|
||||
setPropagationProgress(null);
|
||||
setPropagationTaskId(null);
|
||||
setIsPropagationRangeSelecting(false);
|
||||
setRangeSelectionMode(null);
|
||||
setHasExplicitPropagationRange(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1638,10 +1510,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setStatusMessage('已取消导出范围选择');
|
||||
return;
|
||||
}
|
||||
if (previousMode === 'clear') {
|
||||
setStatusMessage('已取消清空片段范围选择');
|
||||
return;
|
||||
}
|
||||
if (previousMode === 'boolean') {
|
||||
setStatusMessage('已取消布尔操作范围选择');
|
||||
return;
|
||||
@@ -1673,6 +1541,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const propagationPercent = propagationProgress
|
||||
? Math.round((propagationProgress.completedSteps / Math.max(propagationProgress.totalSteps, 1)) * 100)
|
||||
: 0;
|
||||
const showPropagationControls = rangeSelectionMode === 'propagation' || isPropagating || Boolean(propagationTaskId);
|
||||
const showFrameRangeControls = showPropagationControls || rangeSelectionMode === 'boolean';
|
||||
const selectedRangeStartFrame = Math.min(propagationStartFrame, propagationEndFrame);
|
||||
const selectedRangeEndFrame = Math.max(propagationStartFrame, propagationEndFrame);
|
||||
const propagationBackwardFrameCount = Math.max(0, currentFrameNumber - selectedRangeStartFrame);
|
||||
@@ -1735,26 +1605,28 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
重做
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
|
||||
<span className="text-[10px] text-gray-500 whitespace-nowrap">传播权重</span>
|
||||
<select
|
||||
aria-label="传播权重"
|
||||
value={propagationWeight}
|
||||
onChange={(event) => {
|
||||
setHasCustomPropagationWeight(true);
|
||||
setPropagationWeight(event.target.value as AiModelId);
|
||||
}}
|
||||
disabled={isPropagating || isSaving || isExporting || isImportingGt}
|
||||
className="h-6 w-24 rounded border border-cyan-500/20 bg-[#050809] px-1 text-[10px] text-cyan-100 outline-none focus:border-cyan-400/70 disabled:opacity-40"
|
||||
>
|
||||
{SAM2_MODEL_OPTIONS.map((option) => (
|
||||
<option key={option.id} value={option.id} className="bg-[#050809] text-cyan-100">
|
||||
{option.shortLabel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{rangeSelectionMode === 'propagation' && (
|
||||
{showPropagationControls && (
|
||||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
|
||||
<span className="text-[10px] text-gray-500 whitespace-nowrap">传播权重</span>
|
||||
<select
|
||||
aria-label="传播权重"
|
||||
value={propagationWeight}
|
||||
onChange={(event) => {
|
||||
setHasCustomPropagationWeight(true);
|
||||
setPropagationWeight(event.target.value as AiModelId);
|
||||
}}
|
||||
disabled={isPropagating || isSaving || isExporting || isImportingGt}
|
||||
className="h-6 w-24 rounded border border-cyan-500/20 bg-[#050809] px-1 text-[10px] text-cyan-100 outline-none focus:border-cyan-400/70 disabled:opacity-40"
|
||||
>
|
||||
{SAM2_MODEL_OPTIONS.map((option) => (
|
||||
<option key={option.id} value={option.id} className="bg-[#050809] text-cyan-100">
|
||||
{option.shortLabel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{showPropagationControls && (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md border border-cyan-500/20 bg-cyan-500/[0.06] px-2 py-1 text-[10px] text-cyan-100"
|
||||
title="向前表示更早帧,向后表示更晚帧"
|
||||
@@ -1863,32 +1735,34 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
|
||||
<span className="text-[10px] text-gray-500 whitespace-nowrap">参考帧 {currentFrameNumber || 0}</span>
|
||||
<span className="text-[10px] text-gray-600">帧</span>
|
||||
<input
|
||||
aria-label="传播起始帧"
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(totalFrames, 1)}
|
||||
value={propagationStartFrame}
|
||||
onChange={(event) => handlePropagationStartInput(Number(event.target.value))}
|
||||
disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0}
|
||||
className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
|
||||
/>
|
||||
<span className="text-[10px] text-gray-600">-</span>
|
||||
<input
|
||||
aria-label="传播结束帧"
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(totalFrames, 1)}
|
||||
value={propagationEndFrame}
|
||||
onChange={(event) => handlePropagationEndInput(Number(event.target.value))}
|
||||
disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0}
|
||||
className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
|
||||
/>
|
||||
</div>
|
||||
{rangeSelectionMode === 'boolean' && pendingBooleanRangeRequest ? (
|
||||
{showFrameRangeControls && (
|
||||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-2 py-1">
|
||||
<span className="text-[10px] text-gray-500 whitespace-nowrap">参考帧 {currentFrameNumber || 0}</span>
|
||||
<span className="text-[10px] text-gray-600">帧</span>
|
||||
<input
|
||||
aria-label="传播起始帧"
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(totalFrames, 1)}
|
||||
value={propagationStartFrame}
|
||||
onChange={(event) => handlePropagationStartInput(Number(event.target.value))}
|
||||
disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0}
|
||||
className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
|
||||
/>
|
||||
<span className="text-[10px] text-gray-600">-</span>
|
||||
<input
|
||||
aria-label="传播结束帧"
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(totalFrames, 1)}
|
||||
value={propagationEndFrame}
|
||||
onChange={(event) => handlePropagationEndInput(Number(event.target.value))}
|
||||
disabled={isPropagating || isSaving || isExporting || isImportingGt || totalFrames === 0}
|
||||
className="h-6 w-14 rounded bg-black/20 border border-white/10 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{rangeSelectionMode === 'boolean' && pendingBooleanRangeRequest && (
|
||||
<button
|
||||
onClick={handleConfirmBooleanFrameRangeOperation}
|
||||
disabled={frames.length === 0 || isSaving || isExporting || isImportingGt || isPropagating}
|
||||
@@ -1897,53 +1771,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
>
|
||||
确认{booleanOperationLabel(pendingBooleanRangeRequest.operation)}
|
||||
</button>
|
||||
) : (
|
||||
)}
|
||||
{showPropagationControls && (
|
||||
<button
|
||||
onClick={handleClearFrameRangeMasks}
|
||||
onClick={handleAutoPropagate}
|
||||
disabled={frames.length === 0 || isSaving || isExporting || isImportingGt || isPropagating}
|
||||
title={clearRangeMode === 'propagated_only' ? '按当前起止帧清空自动传播遮罩,保留人工/AI 标注帧' : '按当前起止帧清空这一段视频内的全部遮罩'}
|
||||
className="px-3 py-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/25 rounded-md text-xs transition-colors text-red-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="px-4 py-1.5 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 rounded-md text-xs transition-colors text-cyan-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{rangeSelectionMode === 'clear' ? '确认清空' : '清空片段遮罩'}
|
||||
{isPropagating ? '传播中...' : '开始传播'}
|
||||
</button>
|
||||
)}
|
||||
{rangeSelectionMode === 'clear' && (
|
||||
<div className="flex items-center gap-1 rounded-md border border-red-500/20 bg-red-500/[0.04] px-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClearRangeMode('all')}
|
||||
aria-pressed={clearRangeMode === 'all'}
|
||||
className={cn(
|
||||
'h-6 rounded px-2 text-[10px] transition-colors',
|
||||
clearRangeMode === 'all'
|
||||
? 'bg-red-500/25 text-red-100'
|
||||
: 'text-gray-400 hover:bg-white/10 hover:text-gray-200',
|
||||
)}
|
||||
>
|
||||
清空全部
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClearRangeMode('propagated_only')}
|
||||
aria-pressed={clearRangeMode === 'propagated_only'}
|
||||
className={cn(
|
||||
'h-6 rounded px-2 text-[10px] transition-colors',
|
||||
clearRangeMode === 'propagated_only'
|
||||
? 'bg-blue-500/25 text-blue-100'
|
||||
: 'text-gray-400 hover:bg-white/10 hover:text-gray-200',
|
||||
)}
|
||||
>
|
||||
保留人工/AI
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleAutoPropagate}
|
||||
disabled={!currentProject?.id || !currentFrame?.id || isSaving || isExporting || isImportingGt || isPropagating}
|
||||
className="px-4 py-1.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-xs transition-colors text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isPropagating ? '传播中...' : rangeSelectionMode === 'propagation' ? '开始传播' : '自动传播'}
|
||||
</button>
|
||||
{isPropagationRangeSelecting && (
|
||||
<button
|
||||
onClick={handleCancelPropagationRangeSelection}
|
||||
@@ -2147,9 +1984,12 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
activeTool={activeTool}
|
||||
setActiveTool={setActiveTool}
|
||||
onTriggerAI={onNavigateToAI}
|
||||
onAutoPropagate={() => void handleAutoPropagate()}
|
||||
onImportGtMask={() => gtMaskInputRef.current?.click()}
|
||||
onDeleteMasks={handleDeleteSelectedMasks}
|
||||
onClearMasks={handleClearCurrentFrameMasks}
|
||||
canAutoPropagate={Boolean(currentProject?.id && currentFrame?.id) && !isSaving && !isExporting && !isImportingGt}
|
||||
isPropagating={isPropagating}
|
||||
canImportGtMask={Boolean(currentProject?.id && currentFrame?.id) && !isSaving && !isExporting && !isPropagating}
|
||||
isImportingGtMask={isImportingGt}
|
||||
/>
|
||||
@@ -2159,6 +1999,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
<CanvasArea
|
||||
activeTool={activeTool}
|
||||
frame={currentFrame}
|
||||
currentFrameNumber={currentFrameNumber || 0}
|
||||
totalFrames={totalFrames}
|
||||
onRequestDeleteMasks={(maskIds) => void handleDeleteSelectedMasks(maskIds)}
|
||||
onRequestBooleanFrameRange={handleBooleanFrameRangeRequest}
|
||||
onDeleteMaskAnnotations={handleDeleteMaskAnnotations}
|
||||
@@ -2189,17 +2031,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPendingCurrentClearConfirm(null);
|
||||
void handleClearFrameRangeMasks();
|
||||
}}
|
||||
className="rounded border border-amber-400/30 bg-amber-500/10 px-3 py-2 text-xs font-semibold text-amber-100 hover:bg-amber-500/20 disabled:opacity-60"
|
||||
disabled={isSaving}
|
||||
>
|
||||
按帧范围选择
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.currentMaskIds, pendingCurrentClearConfirm.scopeLabel)}
|
||||
@@ -2221,42 +2052,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingClearRangeConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
|
||||
<div className="w-full max-w-md rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">
|
||||
<h2 className="text-lg font-semibold text-white">清除人工/AI 标注帧</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-300">
|
||||
第 {pendingClearRangeConfirm.rangeStartIndex + 1}-{pendingClearRangeConfirm.rangeEndIndex + 1} 帧包含人工绘制或 AI 智能分割生成的 mask。
|
||||
继续后会清空该范围内共 {pendingClearRangeConfirm.maskCount} 个遮罩。
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-red-200/70">
|
||||
如只想删除自动传播内容,请取消后选择“保留人工/AI”。
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPendingClearRangeConfirm(null);
|
||||
setStatusMessage('已取消清空片段遮罩');
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void executeClearFrameRange(pendingClearRangeConfirm)}
|
||||
disabled={isSaving}
|
||||
className="rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-wait disabled:opacity-50"
|
||||
>
|
||||
确认清除人工/AI 标注
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingBooleanRangeConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
|
||||
<div className="w-full max-w-md rounded-lg border border-emerald-400/25 bg-[#151515] p-5 shadow-2xl">
|
||||
|
||||
Reference in New Issue
Block a user