支持布尔操作按帧范围执行
- 区域合并和重叠区域去除新增“按帧范围选择”,复用底部时间轴范围选择并在执行前二次确认 - 布尔操作范围只处理所选帧内存在对应传播链的区域,范围外传播 mask 保持不变 - 自动传播范围选择时在顶栏显示传播权重,以及相对参考帧的向前/向后传播帧数 - Canvas 将传播链布尔操作委托给工作区统一处理范围选择,同时保留当前帧/所有传播帧快捷操作 - 增加 CanvasArea、VideoWorkspace 回归测试,覆盖布尔操作范围选择、范围执行和自动传播方向摘要 - 更新 AGENTS 与前端审计、需求冻结、设计冻结、测试计划文档
This commit is contained in:
@@ -993,6 +993,68 @@ describe('CanvasArea', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('can hand propagated boolean operations to the workspace frame range selector', () => {
|
||||
const onRequestBooleanFrameRange = vi.fn();
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'annotation-1',
|
||||
annotationId: '1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
|
||||
},
|
||||
{
|
||||
id: 'annotation-2',
|
||||
annotationId: '2',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
|
||||
},
|
||||
{
|
||||
id: 'annotation-10',
|
||||
annotationId: '10',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]],
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, propagation_seed_key: 'annotation:1' },
|
||||
},
|
||||
{
|
||||
id: 'annotation-20',
|
||||
annotationId: '20',
|
||||
frameId: 'frame-2',
|
||||
pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, propagation_seed_key: 'annotation:2' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CanvasArea activeTool="area_merge" frame={frame} onRequestBooleanFrameRange={onRequestBooleanFrameRange} />);
|
||||
const paths = screen.getAllByTestId('konva-path');
|
||||
fireEvent.click(paths[0]);
|
||||
fireEvent.click(paths[1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));
|
||||
|
||||
expect(onRequestBooleanFrameRange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
operation: 'area_merge',
|
||||
currentFrameId: 'frame-1',
|
||||
candidateFrameIds: expect.arrayContaining(['frame-1', 'frame-2']),
|
||||
selectedMaskIds: ['annotation-1', 'annotation-2'],
|
||||
execute: expect.any(Function),
|
||||
}));
|
||||
expect(useStore.getState().masks.map((mask) => mask.id).sort()).toEqual(['annotation-1', 'annotation-10', 'annotation-2', 'annotation-20']);
|
||||
});
|
||||
|
||||
it('removes overlap from the primary selected mask with polygon difference', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
|
||||
@@ -6,10 +6,21 @@ import { useStore } from '../store/useStore';
|
||||
import { predictMask } from '../lib/api';
|
||||
import type { Frame, Mask } from '../store/useStore';
|
||||
|
||||
type BooleanOperationTool = 'area_merge' | 'area_remove';
|
||||
|
||||
export interface BooleanFrameRangeRequest {
|
||||
operation: BooleanOperationTool;
|
||||
currentFrameId: string;
|
||||
candidateFrameIds: string[];
|
||||
selectedMaskIds: string[];
|
||||
execute: (targetFrameIds: Set<string>) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface CanvasAreaProps {
|
||||
activeTool: string;
|
||||
frame: Frame | null;
|
||||
onRequestDeleteMasks?: (maskIds: string[]) => void;
|
||||
onRequestBooleanFrameRange?: (request: BooleanFrameRangeRequest) => void;
|
||||
onDeleteMaskAnnotations?: (annotationIds: string[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -417,7 +428,7 @@ function geometriesOverlap(first: MultiPolygon, second: MultiPolygon): boolean {
|
||||
return polygonClipping.intersection(first, second).length > 0;
|
||||
}
|
||||
|
||||
export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onDeleteMaskAnnotations }: CanvasAreaProps) {
|
||||
export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onRequestBooleanFrameRange, onDeleteMaskAnnotations }: CanvasAreaProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -1801,6 +1812,24 @@ export function CanvasArea({ activeTool, frame, onRequestDeleteMasks, onDeleteMa
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{onRequestBooleanFrameRange && (effectiveTool === 'area_merge' || effectiveTool === 'area_remove') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onRequestBooleanFrameRange({
|
||||
operation: effectiveTool,
|
||||
currentFrameId: String(frame.id),
|
||||
candidateFrameIds: pendingBooleanFrameIds,
|
||||
selectedMaskIds: booleanSelectedMasks.map((mask) => mask.id),
|
||||
execute: runBooleanOperation,
|
||||
});
|
||||
setPendingBooleanFrameIds(null);
|
||||
}}
|
||||
className="rounded border border-amber-400/30 bg-amber-500/10 px-3 py-1.5 text-xs font-semibold text-amber-100 hover:bg-amber-500/20"
|
||||
>
|
||||
按帧范围选择
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runBooleanOperation(new Set([String(frame.id)]))}
|
||||
|
||||
@@ -1187,9 +1187,134 @@ describe('VideoWorkspace', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||||
|
||||
expect(screen.getByText('请在播放进度条或视频处理进度条上点击/拖拽选择传播起止帧,再点击“开始传播”')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('SAM 2.1 Tiny').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('向前 0 帧')).toBeInTheDocument();
|
||||
expect(screen.getByText('向后 2 帧')).toBeInTheDocument();
|
||||
expect(apiMock.queuePropagationTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the timeline frame range selector for propagated boolean operations', 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 },
|
||||
]);
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(3));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
{
|
||||
id: 'annotation-1',
|
||||
annotationId: '1',
|
||||
frameId: '10',
|
||||
pathData: 'M 10 10 L 60 10 L 60 60 L 10 60 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 60, 10, 60, 60, 10, 60]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'annotation-2',
|
||||
annotationId: '2',
|
||||
frameId: '10',
|
||||
pathData: 'M 50 50 L 100 50 L 100 100 L 50 100 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[50, 50, 100, 50, 100, 100, 50, 100]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
},
|
||||
{
|
||||
id: 'annotation-10',
|
||||
annotationId: '10',
|
||||
frameId: '11',
|
||||
pathData: 'M 12 12 L 62 12 L 62 62 L 12 62 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[12, 12, 62, 12, 62, 62, 12, 62]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
|
||||
},
|
||||
{
|
||||
id: 'annotation-20',
|
||||
annotationId: '20',
|
||||
frameId: '11',
|
||||
pathData: 'M 52 52 L 102 52 L 102 102 L 52 102 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[52, 52, 102, 52, 102, 102, 52, 102]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
|
||||
},
|
||||
{
|
||||
id: 'annotation-11',
|
||||
annotationId: '11',
|
||||
frameId: '12',
|
||||
pathData: 'M 14 14 L 64 14 L 64 64 L 14 64 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[14, 14, 64, 14, 64, 64, 14, 64]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
|
||||
},
|
||||
{
|
||||
id: 'annotation-21',
|
||||
annotationId: '21',
|
||||
frameId: '12',
|
||||
pathData: 'M 54 54 L 104 54 L 104 104 L 54 104 Z',
|
||||
label: 'A',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[54, 54, 104, 54, 104, 104, 54, 104]],
|
||||
saved: true,
|
||||
saveStatus: 'saved',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation', source_annotation_id: 2, source_mask_id: 'annotation-2', propagation_seed_key: 'annotation:2' },
|
||||
},
|
||||
],
|
||||
activeTool: 'area_merge',
|
||||
});
|
||||
});
|
||||
apiMock.getProjectAnnotations.mockResolvedValue([
|
||||
{ id: 1, frame_id: 10 },
|
||||
{ id: 2, frame_id: 10 },
|
||||
{ id: 10, frame_id: 11 },
|
||||
{ id: 20, frame_id: 11 },
|
||||
{ id: 11, frame_id: 12 },
|
||||
{ id: 21, frame_id: 12 },
|
||||
]);
|
||||
|
||||
const paths = await screen.findAllByTestId('konva-path');
|
||||
fireEvent.click(paths[0]);
|
||||
fireEvent.click(paths[1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '按帧范围选择' }));
|
||||
|
||||
expect(screen.getByText('请选择区域合并帧范围,再点击“确认区域合并”')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('propagation-range-overlay')).toHaveLength(2);
|
||||
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认区域合并' }));
|
||||
expect(screen.getByText(/对 2 帧存在对应传播链的区域执行区域合并/)).toBeInTheDocument();
|
||||
const confirmButtons = screen.getAllByRole('button', { name: '确认区域合并' });
|
||||
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
const ids = useStore.getState().masks.map((mask) => mask.id).sort();
|
||||
expect(ids).toEqual(['annotation-1', 'annotation-10', 'annotation-11', 'annotation-21']);
|
||||
});
|
||||
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('2');
|
||||
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('20');
|
||||
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('21');
|
||||
expect(useStore.getState().masks.find((mask) => mask.id === 'annotation-10')).toEqual(expect.objectContaining({
|
||||
saveStatus: 'dirty',
|
||||
metadata: expect.objectContaining({ source: 'sam2.1_hiera_tiny_propagation' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('exports only the current frame when current image scope is selected', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type SegmentationExportOutput,
|
||||
updateAnnotation,
|
||||
} from '../lib/api';
|
||||
import { CanvasArea } from './CanvasArea';
|
||||
import { CanvasArea, type BooleanFrameRangeRequest } from './CanvasArea';
|
||||
import { ToolsPalette } from './ToolsPalette';
|
||||
import { OntologyInspector } from './OntologyInspector';
|
||||
import { FrameTimeline } from './FrameTimeline';
|
||||
@@ -43,7 +43,7 @@ type PropagationHistorySegment = {
|
||||
colorIndex: number;
|
||||
label: string;
|
||||
};
|
||||
type RangeSelectionMode = 'propagation' | 'clear' | 'export' | null;
|
||||
type RangeSelectionMode = 'propagation' | 'clear' | 'export' | 'boolean' | null;
|
||||
type ClearRangeMode = 'all' | 'propagated_only';
|
||||
type ClearRangeConfirmState = {
|
||||
frameIdsToClear: string[];
|
||||
@@ -61,6 +61,12 @@ type CurrentClearConfirmState = {
|
||||
currentMaskCount: number;
|
||||
propagatedMaskCount: number;
|
||||
};
|
||||
type BooleanRangeConfirmState = {
|
||||
request: BooleanFrameRangeRequest;
|
||||
targetFrameIds: string[];
|
||||
rangeStartIndex: number;
|
||||
rangeEndIndex: number;
|
||||
};
|
||||
type GtUnknownPolicy = 'discard' | 'undefined';
|
||||
type ExportScope = 'all' | 'range' | 'current';
|
||||
type ExportPreviewPolygon = {
|
||||
@@ -344,6 +350,10 @@ const expandedPropagationDeletionMaskIds = (selectedIds: string[], allMasks: Mas
|
||||
);
|
||||
};
|
||||
|
||||
const booleanOperationLabel = (operation: BooleanFrameRangeRequest['operation']) => (
|
||||
operation === 'area_merge' ? '区域合并' : '重叠区域去除'
|
||||
);
|
||||
|
||||
const persistentMaskMetadata = (metadata?: Record<string, unknown>) => {
|
||||
if (!metadata) return {};
|
||||
const {
|
||||
@@ -511,6 +521,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
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);
|
||||
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
|
||||
const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null);
|
||||
const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null);
|
||||
@@ -660,6 +672,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
normalizeClassMaskIds(activeTemplateForGt?.classes || [])
|
||||
), [activeTemplateForGt?.classes]);
|
||||
const frameById = useMemo(() => new Map(frames.map((frame) => [frame.id, frame])), [frames]);
|
||||
const frameNumberById = useMemo(() => new Map(frames.map((frame, index) => [String(frame.id), index + 1])), [frames]);
|
||||
const projectFrameIds = useMemo(() => new Set(frames.map((frame) => frame.id)), [frames]);
|
||||
const currentFrameNumber = currentFrameIndex + 1;
|
||||
const pendingAnnotationChangeCount = useMemo(() => (
|
||||
@@ -687,7 +700,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
|
||||
useEffect(() => {
|
||||
if (propagationHistory.length === 0 || frames.length === 0) return;
|
||||
const frameNumberById = new Map(frames.map((frame, index) => [String(frame.id), index + 1]));
|
||||
const activePropagatedFrameNumbers = new Set<number>();
|
||||
masks.forEach((mask) => {
|
||||
if (!isPropagatedMask(mask)) return;
|
||||
@@ -702,7 +714,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
if (!propagationHistoryEqual(propagationHistory, nextHistory)) {
|
||||
setPropagationHistory(nextHistory);
|
||||
}
|
||||
}, [frames, masks, propagationHistory, totalFrames]);
|
||||
}, [frameNumberById, frames.length, masks, propagationHistory, totalFrames]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!statusMessage || isWorkspaceBusy || totalFrames === 0) return undefined;
|
||||
@@ -724,6 +736,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setExportEndFrame(Math.min(totalFrames, currentFrameNumber + 29));
|
||||
setIsPropagationRangeSelecting(false);
|
||||
setRangeSelectionMode(null);
|
||||
setPendingBooleanRangeRequest(null);
|
||||
setPendingBooleanRangeConfirm(null);
|
||||
setHasExplicitPropagationRange(false);
|
||||
}, [currentFrameNumber, totalFrames]);
|
||||
|
||||
@@ -1018,6 +1032,76 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
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)))
|
||||
.filter((frameNumber): frameNumber is number => Boolean(frameNumber));
|
||||
if (candidateFrameNumbers.length === 0) {
|
||||
setStatusMessage(`${booleanOperationLabel(request.operation)}没有可按范围处理的传播帧`);
|
||||
return;
|
||||
}
|
||||
const startFrame = Math.min(...candidateFrameNumbers);
|
||||
const endFrame = Math.max(...candidateFrameNumbers);
|
||||
setPendingBooleanRangeRequest(request);
|
||||
setPendingBooleanRangeConfirm(null);
|
||||
setPropagationStartFrame(startFrame);
|
||||
setPropagationEndFrame(endFrame);
|
||||
setHasExplicitPropagationRange(true);
|
||||
setIsPropagationRangeSelecting(true);
|
||||
setRangeSelectionMode('boolean');
|
||||
setStatusMessage(`请选择${booleanOperationLabel(request.operation)}帧范围,再点击“确认${booleanOperationLabel(request.operation)}”`);
|
||||
}, [frameNumberById]);
|
||||
|
||||
const handleConfirmBooleanFrameRangeOperation = useCallback(() => {
|
||||
if (!pendingBooleanRangeRequest || 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 rangeFrameIds = new Set(
|
||||
frames.slice(rangeStartIndex, rangeEndIndex + 1).map((frame) => String(frame.id)),
|
||||
);
|
||||
const targetFrameIds = pendingBooleanRangeRequest.candidateFrameIds
|
||||
.filter((frameId) => rangeFrameIds.has(String(frameId)))
|
||||
.sort((a, b) => (frameNumberById.get(String(a)) || 0) - (frameNumberById.get(String(b)) || 0));
|
||||
|
||||
if (targetFrameIds.length === 0) {
|
||||
setStatusMessage(`第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧没有可执行${booleanOperationLabel(pendingBooleanRangeRequest.operation)}的对应传播区域`);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingBooleanRangeConfirm({
|
||||
request: pendingBooleanRangeRequest,
|
||||
targetFrameIds,
|
||||
rangeStartIndex,
|
||||
rangeEndIndex,
|
||||
});
|
||||
}, [frameNumberById, frames, pendingBooleanRangeRequest, propagationEndFrame, propagationStartFrame, totalFrames]);
|
||||
|
||||
const executeBooleanFrameRangeOperation = useCallback(async (confirmState: BooleanRangeConfirmState) => {
|
||||
const label = booleanOperationLabel(confirmState.request.operation);
|
||||
setIsSaving(true);
|
||||
setStatusMessage(`正在对第 ${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧执行${label}...`);
|
||||
try {
|
||||
await confirmState.request.execute(new Set(confirmState.targetFrameIds));
|
||||
setPendingBooleanRangeConfirm(null);
|
||||
setPendingBooleanRangeRequest(null);
|
||||
setIsPropagationRangeSelecting(false);
|
||||
setRangeSelectionMode(null);
|
||||
setHasExplicitPropagationRange(false);
|
||||
setStatusMessage(`已对第 ${confirmState.rangeStartIndex + 1}-${confirmState.rangeEndIndex + 1} 帧内 ${confirmState.targetFrameIds.length} 帧执行${label}`);
|
||||
} catch (err) {
|
||||
console.error('Boolean frame range operation failed:', err);
|
||||
setStatusMessage(`${label}失败,请检查后端服务`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => {
|
||||
if (annotationIds.length === 0) return;
|
||||
try {
|
||||
@@ -1332,7 +1416,11 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setPropagationStartFrame(nextStart);
|
||||
setPropagationEndFrame(nextEnd);
|
||||
setHasExplicitPropagationRange(true);
|
||||
const actionLabel = rangeSelectionMode === 'clear' ? '清空范围' : '自动传播范围';
|
||||
const actionLabel = rangeSelectionMode === 'clear'
|
||||
? '清空范围'
|
||||
: rangeSelectionMode === 'boolean'
|
||||
? '布尔操作范围'
|
||||
: '自动传播范围';
|
||||
setStatusMessage(`已选择${actionLabel}:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)} 帧`);
|
||||
}, [clampFrameNumber, rangeSelectionMode]);
|
||||
|
||||
@@ -1542,6 +1630,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const previousMode = rangeSelectionMode;
|
||||
setIsPropagationRangeSelecting(false);
|
||||
setRangeSelectionMode(null);
|
||||
setPendingBooleanRangeRequest(null);
|
||||
setPendingBooleanRangeConfirm(null);
|
||||
setHasExplicitPropagationRange(false);
|
||||
setPropagationStartFrame(currentFrameNumber || 1);
|
||||
setPropagationEndFrame(Math.min(Math.max(totalFrames, 1), (currentFrameNumber || 1) + 29));
|
||||
@@ -1549,7 +1639,15 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
setStatusMessage('已取消导出范围选择');
|
||||
return;
|
||||
}
|
||||
setStatusMessage(previousMode === 'clear' ? '已取消清空片段范围选择' : '已取消自动传播范围选择');
|
||||
if (previousMode === 'clear') {
|
||||
setStatusMessage('已取消清空片段范围选择');
|
||||
return;
|
||||
}
|
||||
if (previousMode === 'boolean') {
|
||||
setStatusMessage('已取消布尔操作范围选择');
|
||||
return;
|
||||
}
|
||||
setStatusMessage('已取消自动传播范围选择');
|
||||
};
|
||||
|
||||
const visibleTimelineRange = rangeSelectionMode === 'export'
|
||||
@@ -1576,6 +1674,10 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const propagationPercent = propagationProgress
|
||||
? Math.round((propagationProgress.completedSteps / Math.max(propagationProgress.totalSteps, 1)) * 100)
|
||||
: 0;
|
||||
const selectedRangeStartFrame = Math.min(propagationStartFrame, propagationEndFrame);
|
||||
const selectedRangeEndFrame = Math.max(propagationStartFrame, propagationEndFrame);
|
||||
const propagationBackwardFrameCount = Math.max(0, currentFrameNumber - selectedRangeStartFrame);
|
||||
const propagationForwardFrameCount = Math.max(0, selectedRangeEndFrame - currentFrameNumber);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-[#0a0a0a]">
|
||||
@@ -1654,6 +1756,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{rangeSelectionMode === 'propagation' && (
|
||||
<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="向前表示更早帧,向后表示更晚帧"
|
||||
>
|
||||
<span className="font-semibold">{propagationWeightLabel}</span>
|
||||
<span className="text-gray-500">|</span>
|
||||
<span>向前 {propagationBackwardFrameCount} 帧</span>
|
||||
<span>向后 {propagationForwardFrameCount} 帧</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={gtMaskInputRef}
|
||||
type="file"
|
||||
@@ -1777,14 +1890,25 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
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>
|
||||
<button
|
||||
onClick={handleClearFrameRangeMasks}
|
||||
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"
|
||||
>
|
||||
{rangeSelectionMode === 'clear' ? '确认清空' : '清空片段遮罩'}
|
||||
</button>
|
||||
{rangeSelectionMode === 'boolean' && pendingBooleanRangeRequest ? (
|
||||
<button
|
||||
onClick={handleConfirmBooleanFrameRangeOperation}
|
||||
disabled={frames.length === 0 || isSaving || isExporting || isImportingGt || isPropagating}
|
||||
title={`按当前起止帧执行${booleanOperationLabel(pendingBooleanRangeRequest.operation)}`}
|
||||
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/25 rounded-md text-xs transition-colors text-emerald-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
确认{booleanOperationLabel(pendingBooleanRangeRequest.operation)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleClearFrameRangeMasks}
|
||||
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"
|
||||
>
|
||||
{rangeSelectionMode === 'clear' ? '确认清空' : '清空片段遮罩'}
|
||||
</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
|
||||
@@ -2038,6 +2162,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
activeTool={activeTool}
|
||||
frame={currentFrame}
|
||||
onRequestDeleteMasks={(maskIds) => void handleDeleteSelectedMasks(maskIds)}
|
||||
onRequestBooleanFrameRange={handleBooleanFrameRangeRequest}
|
||||
onDeleteMaskAnnotations={handleDeleteMaskAnnotations}
|
||||
/>
|
||||
</div>
|
||||
@@ -2134,6 +2259,39 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
</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">
|
||||
<h2 className="text-lg font-semibold text-white">确认{booleanOperationLabel(pendingBooleanRangeConfirm.request.operation)}</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-300">
|
||||
将在第 {pendingBooleanRangeConfirm.rangeStartIndex + 1}-{pendingBooleanRangeConfirm.rangeEndIndex + 1} 帧范围内,
|
||||
对 {pendingBooleanRangeConfirm.targetFrameIds.length} 帧存在对应传播链的区域执行{booleanOperationLabel(pendingBooleanRangeConfirm.request.operation)}。
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-emerald-100/70">
|
||||
本操作会保留传播帧原有来源属性,只把几何结果标记为待保存。
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingBooleanRangeConfirm(null)}
|
||||
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 executeBooleanFrameRangeOperation(pendingBooleanRangeConfirm)}
|
||||
disabled={isSaving}
|
||||
className="rounded bg-emerald-500 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-400 disabled:cursor-wait disabled:opacity-50"
|
||||
>
|
||||
确认{booleanOperationLabel(pendingBooleanRangeConfirm.request.operation)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Timeline */}
|
||||
<FrameTimeline
|
||||
propagationRange={visibleTimelineRange}
|
||||
|
||||
Reference in New Issue
Block a user