feat: 完善分割工作区传播与交互闭环
功能增加:新增后端传播任务执行器,支持异步自动传播、传播进度、结果统计、取消/重试状态同步。 功能增加:传播请求支持指定 SAM2.1 tiny/small/base+/large 权重,并记录 seed mask、source annotation 和传播范围。 功能增加:传播逻辑增加 seed 签名,未变化的 mask 二次传播会跳过,已变化的 mask 会先清理旧自动传播结果再重新生成,避免重复重叠。 功能增加:工作区增加传播范围二次选择、传播进度提示、人工/AI 标注帧红色标识、自动传播帧蓝色标识和当前帧双层边框。 功能增加:新增临时提示组件,让工具操作提示自动消失且不阻塞后续操作。 功能增加:补充项目删除、模板删除、任务失败详情、任务取消/重试等前后端联动状态。 功能增加:新增安装部署文档,补充当前需求冻结、设计冻结、接口契约、测试计划和 AGENTS/README 项目说明。 Bugfix:修复自动传播接口 404、传播后看不到任务进度、传播结果重复堆叠和已编辑帧提示不清晰的问题。 Bugfix:修复 AI 分割框选/点选交互、单候选 mask、删除选点、工作区保存与候选 mask 推送相关问题。 Bugfix:修复 Canvas 多边形顶点拖动告警、工具栏提示缺失、项目库 FPS 展示和若干 UI 文案/可用性问题。 测试:补充 AI 分割、Canvas、Dashboard、FrameTimeline、ProjectLibrary、TemplateRegistry、ToolsPalette、VideoWorkspace、API 和后端任务/AI/dashboard 测试。 验证:npm run lint;npm run test:run;python -m pytest backend/tests -q。
This commit is contained in:
@@ -169,6 +169,26 @@ describe('AISegmentation', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('handles stage drag end for move-tool canvas panning', () => {
|
||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('konva-stage')).toHaveAttribute('data-has-drag-end', 'true');
|
||||
});
|
||||
|
||||
it('centers the active frame with a large default fit inside the AI canvas', async () => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 1000 });
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 700 });
|
||||
|
||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const stage = screen.getByTestId('konva-stage');
|
||||
expect(Number(stage.getAttribute('data-scale-x'))).toBeCloseTo(1.34375, 4);
|
||||
expect(Number(stage.getAttribute('data-x'))).toBeCloseTo(70, 0);
|
||||
expect(Number(stage.getAttribute('data-y'))).toBeCloseTo(108, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('combines the AI page box prompt with later positive and negative refinement points', async () => {
|
||||
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
|
||||
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Undo, Redo, Loader2, XCircle, Trash2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Stage, Layer, Image as KonvaImage, Circle, Path, Group, Rect } from 'react-konva';
|
||||
@@ -14,8 +14,10 @@ interface AISegmentationProps {
|
||||
type PromptPoint = { x: number; y: number; type: 'pos' | 'neg' };
|
||||
type PromptBox = { x1: number; y1: number; x2: number; y2: number };
|
||||
type ToolHint = { title: string; body: string };
|
||||
const DEFAULT_IMAGE_FIT_RATIO = 0.86;
|
||||
|
||||
export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||||
const storeActiveTool = useStore((state) => state.activeTool);
|
||||
const setActiveTool = useStore((state) => state.setActiveTool);
|
||||
const masks = useStore((state) => state.masks);
|
||||
@@ -42,8 +44,10 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
const [aiMaskIds, setAiMaskIds] = useState<string[]>([]);
|
||||
|
||||
// Canvas state
|
||||
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
|
||||
const [scale, setScale] = useState(1);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const lastAutoFitKeyRef = useRef('');
|
||||
const [points, setPoints] = useState<PromptPoint[]>([]);
|
||||
const [promptBox, setPromptBox] = useState<PromptBox | null>(null);
|
||||
const [boxStart, setBoxStart] = useState<{ x: number; y: number } | null>(null);
|
||||
@@ -59,6 +63,42 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
const modelCanInfer = selectedModelStatus?.available ?? true;
|
||||
|
||||
const effectiveTool = storeActiveTool;
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (!canvasContainerRef.current) return;
|
||||
setStageSize({
|
||||
width: canvasContainerRef.current.clientWidth,
|
||||
height: canvasContainerRef.current.clientHeight,
|
||||
});
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFrame?.id || stageSize.width <= 0 || stageSize.height <= 0) return;
|
||||
const imageWidth = currentFrame.width || image?.naturalWidth || image?.width || 0;
|
||||
const imageHeight = currentFrame.height || image?.naturalHeight || image?.height || 0;
|
||||
if (imageWidth <= 0 || imageHeight <= 0) return;
|
||||
|
||||
const fitKey = `${currentFrame.id}:${stageSize.width}x${stageSize.height}:${imageWidth}x${imageHeight}`;
|
||||
if (lastAutoFitKeyRef.current === fitKey) return;
|
||||
lastAutoFitKeyRef.current = fitKey;
|
||||
|
||||
const nextScale = Math.max(
|
||||
0.05,
|
||||
Math.min(stageSize.width / imageWidth, stageSize.height / imageHeight) * DEFAULT_IMAGE_FIT_RATIO,
|
||||
);
|
||||
setScale(nextScale);
|
||||
setPosition({
|
||||
x: (stageSize.width - imageWidth * nextScale) / 2,
|
||||
y: (stageSize.height - imageHeight * nextScale) / 2,
|
||||
});
|
||||
}, [currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, stageSize.height, stageSize.width]);
|
||||
|
||||
const toolHint = React.useMemo<ToolHint | null>(() => {
|
||||
if (!currentFrame) return null;
|
||||
if (effectiveTool === 'point_pos') {
|
||||
@@ -146,6 +186,14 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleStageDragEnd = (e: any) => {
|
||||
const stage = e.target;
|
||||
setPosition({
|
||||
x: stage.x(),
|
||||
y: stage.y(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: any) => {
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
@@ -557,7 +605,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
</header>
|
||||
|
||||
<div className="flex-1 relative p-8">
|
||||
<div className="w-full h-full relative border border-white/5 rounded shadow-2xl bg-[#1e1e1e] overflow-hidden cursor-crosshair">
|
||||
<div ref={canvasContainerRef} className="w-full h-full relative border border-white/5 rounded shadow-2xl bg-[#1e1e1e] overflow-hidden cursor-crosshair">
|
||||
{!currentFrame && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-[#151515] text-xs text-gray-500">
|
||||
请先在项目库选择项目并生成帧
|
||||
@@ -570,8 +618,8 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
</div>
|
||||
)}
|
||||
<Stage
|
||||
width={window.innerWidth - 320 - 64}
|
||||
height={window.innerHeight - 64 - 64}
|
||||
width={stageSize.width}
|
||||
height={stageSize.height}
|
||||
onWheel={handleWheel}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleStageMouseDown}
|
||||
@@ -582,6 +630,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
x={position.x}
|
||||
y={position.y}
|
||||
draggable={effectiveTool === 'move'}
|
||||
onDragEnd={handleStageDragEnd}
|
||||
>
|
||||
<Layer>
|
||||
{/* Background Image */}
|
||||
|
||||
@@ -292,6 +292,26 @@ describe('CanvasArea', () => {
|
||||
expect(screen.getByText('遮罩数: 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles stage drag end when the move tool pans the canvas', () => {
|
||||
render(<CanvasArea activeTool="move" frame={frame} />);
|
||||
|
||||
expect(screen.getByTestId('konva-stage')).toHaveAttribute('data-has-drag-end', 'true');
|
||||
});
|
||||
|
||||
it('centers the frame image with a large default fit that keeps margins', async () => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 1000 });
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 700 });
|
||||
|
||||
render(<CanvasArea activeTool="move" frame={frame} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const stage = screen.getByTestId('konva-stage');
|
||||
expect(Number(stage.getAttribute('data-scale-x'))).toBeCloseTo(1.34375, 4);
|
||||
expect(Number(stage.getAttribute('data-x'))).toBeCloseTo(70, 0);
|
||||
expect(Number(stage.getAttribute('data-y'))).toBeCloseTo(108, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes the selected mask ids for the ontology panel', async () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
@@ -663,7 +683,10 @@ describe('CanvasArea', () => {
|
||||
expect(selectedPaths[0]).toHaveAttribute('data-stroke', '#facc15');
|
||||
expect(selectedPaths[0]).toHaveAttribute('data-dash', '');
|
||||
expect(selectedPaths[1]).toHaveAttribute('data-stroke', '#fb7185');
|
||||
expect(selectedPaths[1]).toHaveAttribute('data-dash', '6,4');
|
||||
const scale = Number(screen.getByTestId('konva-stage').getAttribute('data-scale-x')) || 1;
|
||||
const dash = selectedPaths[1].getAttribute('data-dash')?.split(',').map(Number);
|
||||
expect(dash?.[0]).toBeCloseTo(6 / scale, 4);
|
||||
expect(dash?.[1]).toBeCloseTo(4 / scale, 4);
|
||||
fireEvent.click(screen.getByRole('button', { name: '从主区域去除' }));
|
||||
|
||||
expect(useStore.getState().masks).toHaveLength(2);
|
||||
|
||||
@@ -24,6 +24,7 @@ const EDIT_POLYGON_TOOL = 'edit_polygon';
|
||||
const POINT_TOOL = 'create_point';
|
||||
const BOOLEAN_TOOLS = new Set(['area_merge', 'area_remove']);
|
||||
const POLYGON_CLOSE_RADIUS = 8;
|
||||
const DEFAULT_IMAGE_FIT_RATIO = 0.86;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
@@ -245,6 +246,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const previousFrameIdRef = useRef<string | undefined>(frame?.id);
|
||||
const [isInferencing, setIsInferencing] = useState(false);
|
||||
const [inferenceMessage, setInferenceMessage] = useState('');
|
||||
const lastAutoFitKeyRef = useRef('');
|
||||
|
||||
const masks = useStore((state) => state.masks);
|
||||
const addMask = useStore((state) => state.addMask);
|
||||
@@ -256,8 +258,6 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
const aiModel = useStore((state) => state.aiModel);
|
||||
const activeTemplateId = useStore((state) => state.activeTemplateId);
|
||||
const activeClass = useStore((state) => state.activeClass);
|
||||
const undoMasks = useStore((state) => state.undoMasks);
|
||||
const redoMasks = useStore((state) => state.redoMasks);
|
||||
|
||||
const effectiveTool = activeTool || storeActiveTool;
|
||||
|
||||
@@ -374,6 +374,27 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!frame?.id || stageSize.width <= 0 || stageSize.height <= 0) return;
|
||||
const imageWidth = frame.width || image?.naturalWidth || image?.width || 0;
|
||||
const imageHeight = frame.height || image?.naturalHeight || image?.height || 0;
|
||||
if (imageWidth <= 0 || imageHeight <= 0) return;
|
||||
|
||||
const fitKey = `${frame.id}:${stageSize.width}x${stageSize.height}:${imageWidth}x${imageHeight}`;
|
||||
if (lastAutoFitKeyRef.current === fitKey) return;
|
||||
lastAutoFitKeyRef.current = fitKey;
|
||||
|
||||
const nextScale = Math.max(
|
||||
0.05,
|
||||
Math.min(stageSize.width / imageWidth, stageSize.height / imageHeight) * DEFAULT_IMAGE_FIT_RATIO,
|
||||
);
|
||||
setScale(nextScale);
|
||||
setPosition({
|
||||
x: (stageSize.width - imageWidth * nextScale) / 2,
|
||||
y: (stageSize.height - imageHeight * nextScale) / 2,
|
||||
});
|
||||
}, [frame?.height, frame?.id, frame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, stageSize.height, stageSize.width]);
|
||||
|
||||
useEffect(() => {
|
||||
setManualStart(null);
|
||||
setManualCurrent(null);
|
||||
@@ -458,6 +479,14 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
});
|
||||
};
|
||||
|
||||
const handleStageDragEnd = (e: any) => {
|
||||
const stage = e.target;
|
||||
setPosition({
|
||||
x: stage.x(),
|
||||
y: stage.y(),
|
||||
});
|
||||
};
|
||||
|
||||
const stagePoint = (e: any): CanvasPoint | null => {
|
||||
const stage = e.target.getStage();
|
||||
const relPos = stage?.getRelativePointerPosition();
|
||||
@@ -839,18 +868,6 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if ((event.metaKey || event.ctrlKey) && key === 'z') {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) redoMasks();
|
||||
else undoMasks();
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && key === 'y') {
|
||||
event.preventDefault();
|
||||
redoMasks();
|
||||
return;
|
||||
}
|
||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedMask && selectedVertexIndex !== null) {
|
||||
const currentPoints = segmentationToPoints(selectedMask.segmentation, selectedPolygonIndex);
|
||||
if (currentPoints.length > 3) {
|
||||
@@ -880,7 +897,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, polygonPoints, redoMasks, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, undoMasks, updatePolygonMask]);
|
||||
}, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, updatePolygonMask]);
|
||||
|
||||
const boxRect = React.useMemo(() => {
|
||||
if (!boxStart || !boxCurrent) return null;
|
||||
@@ -1080,6 +1097,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
|
||||
x={position.x}
|
||||
y={position.y}
|
||||
draggable={effectiveTool === 'move'}
|
||||
onDragEnd={handleStageDragEnd}
|
||||
onClick={handleStageClick}
|
||||
>
|
||||
<Layer>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function Dashboard() {
|
||||
status: task.message || task.status,
|
||||
raw_status: task.status,
|
||||
error: task.error,
|
||||
frame_count: Number(task.result?.frames_extracted || 0),
|
||||
frame_count: Number(task.result?.frames_extracted || task.result?.processed_frame_count || 0),
|
||||
updated_at: task.updated_at,
|
||||
});
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('FrameTimeline', () => {
|
||||
expect(screen.getAllByText('00:00.20').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('marks propagated frames as light-blue progress bar segments', () => {
|
||||
it('renders a processing progress bar with red annotation markers and blue propagation segments', () => {
|
||||
useStore.setState({
|
||||
currentFrameIndex: 1,
|
||||
frames: [
|
||||
@@ -76,13 +76,162 @@ describe('FrameTimeline', () => {
|
||||
|
||||
render(<FrameTimeline />);
|
||||
|
||||
expect(screen.getByText('自动传播 1 帧')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('视频处理进度条')).toBeInTheDocument();
|
||||
expect(screen.getByText('人工/AI 1 帧 · 自动传播 1 帧')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('current-frame-line')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('propagated-frame-segment')).toHaveLength(1);
|
||||
expect(screen.getByTestId('propagated-frame-segment').className).toContain('bg-sky-200');
|
||||
expect(screen.getByTestId('propagated-frame-segment').className).toContain('bg-blue-500');
|
||||
expect(screen.getAllByTestId('annotated-frame-marker')).toHaveLength(1);
|
||||
expect(screen.getByTestId('annotated-frame-marker').className).toContain('bg-red-500');
|
||||
expect(screen.queryByLabelText('跳转到已编辑帧 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('jumps from the processing progress bar and frame status markers', () => {
|
||||
useStore.setState({
|
||||
frames: [
|
||||
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||
{ id: 'f4', projectId: 'p1', index: 3, url: '/4.jpg', width: 640, height: 360 },
|
||||
{ id: 'f5', projectId: 'p1', index: 4, url: '/5.jpg', width: 640, height: 360 },
|
||||
],
|
||||
masks: [
|
||||
{ id: 'm1', frameId: 'f2', pathData: 'M 0 0 Z', label: 'Draft', color: '#ef4444' },
|
||||
{
|
||||
id: 'm2',
|
||||
frameId: 'f4',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: 'Tracked',
|
||||
color: '#3b82f6',
|
||||
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<FrameTimeline />);
|
||||
|
||||
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: 50, pointerId: 1 });
|
||||
expect(useStore.getState().currentFrameIndex).toBe(2);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '跳转到人工/AI 标注帧 2' }));
|
||||
expect(useStore.getState().currentFrameIndex).toBe(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '跳转到自动传播帧 4' }));
|
||||
expect(useStore.getState().currentFrameIndex).toBe(3);
|
||||
});
|
||||
|
||||
it('hides the propagation range overlay until range selection is active', () => {
|
||||
useStore.setState({
|
||||
frames: [
|
||||
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||
],
|
||||
});
|
||||
|
||||
render(<FrameTimeline propagationRange={{ startFrame: 1, endFrame: 3 }} />);
|
||||
|
||||
expect(screen.queryByTestId('propagation-range-overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses red thumbnail borders for manual or AI frames and blue for propagated frames', () => {
|
||||
useStore.setState({
|
||||
frames: [
|
||||
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||
],
|
||||
masks: [
|
||||
{ id: 'm1', frameId: 'f2', pathData: 'M 0 0 Z', label: 'Draft', color: '#ef4444' },
|
||||
{
|
||||
id: 'm2',
|
||||
frameId: 'f3',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: 'Tracked',
|
||||
color: '#3b82f6',
|
||||
metadata: { propagated_from_frame_id: 'f1' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<FrameTimeline />);
|
||||
|
||||
expect(screen.getByAltText('frame-0').closest('div')?.className).toContain('border-cyan-500');
|
||||
expect(screen.getByAltText('frame-1').closest('div')?.className).toContain('border-red-500');
|
||||
expect(screen.getByAltText('frame-2').closest('div')?.className).toContain('border-blue-500');
|
||||
});
|
||||
|
||||
it('keeps the current frame blue border while showing an inner red ring for annotated frames', () => {
|
||||
useStore.setState({
|
||||
currentFrameIndex: 1,
|
||||
frames: [
|
||||
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||
],
|
||||
masks: [
|
||||
{ id: 'm1', frameId: 'f2', pathData: 'M 0 0 Z', label: 'Draft', color: '#ef4444' },
|
||||
],
|
||||
});
|
||||
|
||||
render(<FrameTimeline />);
|
||||
|
||||
const currentAnnotatedTile = screen.getByAltText('frame-1').closest('div');
|
||||
expect(currentAnnotatedTile?.className).toContain('border-cyan-500');
|
||||
expect(currentAnnotatedTile?.className).toContain('inset_0_0_0_2px_rgba(239,68,68,0.95)');
|
||||
});
|
||||
|
||||
it('selects a propagation range from the playback and processing progress bars', () => {
|
||||
const onPropagationRangeChange = vi.fn();
|
||||
useStore.setState({
|
||||
frames: [
|
||||
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
|
||||
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
|
||||
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
|
||||
{ id: 'f4', projectId: 'p1', index: 3, url: '/4.jpg', width: 640, height: 360 },
|
||||
{ id: 'f5', projectId: 'p1', index: 4, url: '/5.jpg', width: 640, height: 360 },
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<FrameTimeline
|
||||
propagationRange={{ startFrame: 2, endFrame: 4 }}
|
||||
propagationRangeSelectionActive
|
||||
onPropagationRangeChange={onPropagationRangeChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const playbackBar = screen.getByTestId('playback-progress-bar');
|
||||
vi.spyOn(playbackBar, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
right: 100,
|
||||
top: 0,
|
||||
bottom: 10,
|
||||
width: 100,
|
||||
height: 10,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
fireEvent.pointerDown(playbackBar, { clientX: 25, pointerId: 1 });
|
||||
fireEvent.pointerMove(playbackBar, { clientX: 75, pointerId: 1 });
|
||||
fireEvent.pointerUp(playbackBar, { clientX: 75, pointerId: 1 });
|
||||
|
||||
expect(onPropagationRangeChange).toHaveBeenLastCalledWith(2, 4);
|
||||
expect(screen.getAllByTestId('propagation-range-overlay')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('changes frames with left and right arrow keys without leaving bounds', () => {
|
||||
useStore.setState({
|
||||
currentFrameIndex: 1,
|
||||
|
||||
@@ -3,13 +3,29 @@ import { Play, Pause } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useStore } from '../store/useStore';
|
||||
|
||||
export function FrameTimeline() {
|
||||
interface FrameTimelineProps {
|
||||
propagationRange?: {
|
||||
startFrame: number;
|
||||
endFrame: number;
|
||||
};
|
||||
propagationRangeSelectionActive?: boolean;
|
||||
propagationRangeDisabled?: boolean;
|
||||
onPropagationRangeChange?: (startFrame: number, endFrame: number) => void;
|
||||
}
|
||||
|
||||
export function FrameTimeline({
|
||||
propagationRange,
|
||||
propagationRangeSelectionActive = false,
|
||||
propagationRangeDisabled = false,
|
||||
onPropagationRangeChange,
|
||||
}: FrameTimelineProps = {}) {
|
||||
const frames = useStore((state) => state.frames);
|
||||
const currentProject = useStore((state) => state.currentProject);
|
||||
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
|
||||
const masks = useStore((state) => state.masks);
|
||||
const setCurrentFrame = useStore((state) => state.setCurrentFrame);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [rangeDragAnchorFrame, setRangeDragAnchorFrame] = useState<number | null>(null);
|
||||
|
||||
const totalFrames = frames.length;
|
||||
const currentFrame = totalFrames > 0 ? currentFrameIndex + 1 : 0;
|
||||
@@ -23,21 +39,42 @@ export function FrameTimeline() {
|
||||
}, [currentProject?.original_fps, currentProject?.parse_fps]);
|
||||
const currentSeconds = totalFrames > 0 ? currentFrameIndex / timeBaseFps : 0;
|
||||
const totalSeconds = totalFrames > 0 ? Math.max(totalFrames - 1, 0) / timeBaseFps : 0;
|
||||
const isPropagatedMask = (mask: (typeof masks)[number]) => {
|
||||
const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source : '';
|
||||
return source.includes('_propagation') || mask.metadata?.propagated_from_frame_id !== undefined;
|
||||
};
|
||||
const propagatedFrameMarkers = useMemo(() => {
|
||||
const frameIds = new Set(frames.map((frame) => frame.id));
|
||||
const propagatedIds = new Set(
|
||||
masks
|
||||
.filter((mask) => frameIds.has(mask.frameId))
|
||||
.filter((mask) => {
|
||||
const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source : '';
|
||||
return source.includes('_propagation') || mask.metadata?.propagated_from_frame_id !== undefined;
|
||||
})
|
||||
.filter(isPropagatedMask)
|
||||
.map((mask) => mask.frameId),
|
||||
);
|
||||
return frames
|
||||
.map((frame, index) => ({ frame, index }))
|
||||
.filter(({ frame }) => propagatedIds.has(frame.id));
|
||||
}, [frames, masks]);
|
||||
const propagatedFrameIds = useMemo(
|
||||
() => new Set(propagatedFrameMarkers.map(({ frame }) => frame.id)),
|
||||
[propagatedFrameMarkers],
|
||||
);
|
||||
const annotatedFrameMarkers = useMemo(() => {
|
||||
const frameIds = new Set(frames.map((frame) => frame.id));
|
||||
const annotatedIds = new Set(
|
||||
masks
|
||||
.filter((mask) => frameIds.has(mask.frameId))
|
||||
.filter((mask) => !isPropagatedMask(mask))
|
||||
.map((mask) => mask.frameId),
|
||||
);
|
||||
return frames
|
||||
.map((frame, index) => ({ frame, index }))
|
||||
.filter(({ frame }) => annotatedIds.has(frame.id));
|
||||
}, [frames, masks]);
|
||||
const annotatedFrameIds = useMemo(
|
||||
() => new Set(annotatedFrameMarkers.map(({ frame }) => frame.id)),
|
||||
[annotatedFrameMarkers],
|
||||
);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const safeSeconds = Math.max(0, seconds);
|
||||
@@ -47,6 +84,80 @@ export function FrameTimeline() {
|
||||
return `${minutes.toString().padStart(2, '0')}:${wholeSeconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const clampFrame = (frame: number) => Math.min(Math.max(frame, 1), Math.max(totalFrames, 1));
|
||||
const normalizeRange = (startFrame: number, endFrame: number) => ({
|
||||
startFrame: Math.min(clampFrame(startFrame), clampFrame(endFrame)),
|
||||
endFrame: Math.max(clampFrame(startFrame), clampFrame(endFrame)),
|
||||
});
|
||||
const selectedRange = propagationRange
|
||||
? normalizeRange(propagationRange.startFrame, propagationRange.endFrame)
|
||||
: null;
|
||||
const visibleSelectedRange = propagationRangeSelectionActive ? selectedRange : null;
|
||||
const rangeLeft = visibleSelectedRange && totalFrames > 0 ? ((visibleSelectedRange.startFrame - 1) / totalFrames) * 100 : 0;
|
||||
const rangeWidth = visibleSelectedRange && totalFrames > 0
|
||||
? ((visibleSelectedRange.endFrame - visibleSelectedRange.startFrame + 1) / totalFrames) * 100
|
||||
: 0;
|
||||
|
||||
const frameFromPointerEvent = (event: React.PointerEvent<HTMLElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const ratio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0;
|
||||
return clampFrame(Math.round(Math.min(Math.max(ratio, 0), 1) * Math.max(totalFrames - 1, 0)) + 1);
|
||||
};
|
||||
|
||||
const jumpToFrame = (frame: number) => {
|
||||
if (totalFrames === 0) return;
|
||||
setIsPlaying(false);
|
||||
setCurrentFrame(clampFrame(frame) - 1);
|
||||
};
|
||||
|
||||
const updatePropagationRangeFromPointer = (
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
anchorFrame = rangeDragAnchorFrame,
|
||||
) => {
|
||||
if (!propagationRangeSelectionActive || propagationRangeDisabled || totalFrames === 0 || !onPropagationRangeChange) return;
|
||||
const frame = frameFromPointerEvent(event);
|
||||
const startFrame = anchorFrame ?? frame;
|
||||
const nextRange = normalizeRange(startFrame, frame);
|
||||
onPropagationRangeChange(nextRange.startFrame, nextRange.endFrame);
|
||||
};
|
||||
|
||||
const handleRangePointerDown = (event: React.PointerEvent<HTMLElement>) => {
|
||||
if (!propagationRangeSelectionActive || propagationRangeDisabled || totalFrames === 0 || !onPropagationRangeChange) return;
|
||||
event.preventDefault();
|
||||
setIsPlaying(false);
|
||||
const frame = frameFromPointerEvent(event);
|
||||
setRangeDragAnchorFrame(frame);
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
onPropagationRangeChange(frame, frame);
|
||||
};
|
||||
|
||||
const handleProcessingBarPointerDown = (event: React.PointerEvent<HTMLElement>) => {
|
||||
if (propagationRangeSelectionActive) {
|
||||
handleRangePointerDown(event);
|
||||
return;
|
||||
}
|
||||
if (totalFrames === 0) return;
|
||||
event.preventDefault();
|
||||
jumpToFrame(frameFromPointerEvent(event));
|
||||
};
|
||||
|
||||
const handleFrameMarkerClick = (event: React.MouseEvent<HTMLElement>, frame: number) => {
|
||||
event.stopPropagation();
|
||||
if (propagationRangeSelectionActive) return;
|
||||
jumpToFrame(frame);
|
||||
};
|
||||
|
||||
const handleRangePointerMove = (event: React.PointerEvent<HTMLElement>) => {
|
||||
if (rangeDragAnchorFrame === null) return;
|
||||
updatePropagationRangeFromPointer(event, rangeDragAnchorFrame);
|
||||
};
|
||||
|
||||
const handleRangePointerUp = (event: React.PointerEvent<HTMLElement>) => {
|
||||
if (rangeDragAnchorFrame === null) return;
|
||||
updatePropagationRangeFromPointer(event, rangeDragAnchorFrame);
|
||||
setRangeDragAnchorFrame(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || totalFrames <= 1) return;
|
||||
|
||||
@@ -99,41 +210,48 @@ export function FrameTimeline() {
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="h-32 bg-[#111] border-t border-white/5 flex flex-col shrink-0 z-20">
|
||||
<div className="h-7 bg-[#0d0d0d] flex items-center group relative">
|
||||
<div className="h-36 bg-[#111] border-t border-white/5 flex flex-col shrink-0 z-20">
|
||||
<div className="h-12 bg-[#0d0d0d] flex flex-col justify-center group relative">
|
||||
<div className="absolute left-3 -top-5 text-[10px] font-mono text-gray-500 pointer-events-none">
|
||||
{formatTime(currentSeconds)}
|
||||
</div>
|
||||
<div className="absolute right-3 -top-5 text-[10px] font-mono text-gray-500 pointer-events-none">
|
||||
{formatTime(totalSeconds)}
|
||||
</div>
|
||||
<input
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max={Math.max(totalFrames, 1)}
|
||||
value={currentFrame}
|
||||
onChange={(e) => setCurrentFrame(parseInt(e.target.value) - 1)}
|
||||
className="w-full absolute inset-0 opacity-0 cursor-ew-resize z-20"
|
||||
className={cn(
|
||||
"w-full absolute left-0 right-0 top-0 h-7 opacity-0 cursor-ew-resize z-20",
|
||||
propagationRangeSelectionActive && "pointer-events-none",
|
||||
)}
|
||||
disabled={totalFrames === 0}
|
||||
/>
|
||||
<div className="h-1 bg-white/10 w-full relative group-hover:h-2 transition-all">
|
||||
<div
|
||||
data-testid="playback-progress-bar"
|
||||
className={cn(
|
||||
"h-1 bg-white/10 w-full relative group-hover:h-2 transition-all",
|
||||
propagationRangeSelectionActive && !propagationRangeDisabled && "cursor-crosshair",
|
||||
)}
|
||||
onPointerDown={handleRangePointerDown}
|
||||
onPointerMove={handleRangePointerMove}
|
||||
onPointerUp={handleRangePointerUp}
|
||||
onPointerCancel={() => setRangeDragAnchorFrame(null)}
|
||||
>
|
||||
{visibleSelectedRange && (
|
||||
<div
|
||||
data-testid="propagation-range-overlay"
|
||||
className="absolute inset-y-0 z-10 rounded-sm border border-amber-300/80 bg-amber-300/30 shadow-[0_0_12px_rgba(251,191,36,0.45)]"
|
||||
style={{ left: `${rangeLeft}%`, width: `${rangeWidth}%` }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="h-full bg-cyan-500 absolute left-0"
|
||||
style={{ width: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
|
||||
/>
|
||||
{propagatedFrameMarkers.map(({ frame, index }) => {
|
||||
const left = totalFrames > 0 ? (index / totalFrames) * 100 : 0;
|
||||
const width = totalFrames > 0 ? 100 / totalFrames : 0;
|
||||
return (
|
||||
<div
|
||||
key={frame.id}
|
||||
data-testid="propagated-frame-segment"
|
||||
title={`自动传播帧 ${index + 1}`}
|
||||
className="absolute inset-y-0 z-10 bg-sky-200/80 shadow-[0_0_10px_rgba(186,230,253,0.55)]"
|
||||
style={{ left: `${left}%`, width: `${width}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className="absolute -top-7 -translate-x-1/2 rounded bg-black/80 border border-white/10 px-2 py-0.5 text-[10px] font-mono text-cyan-300 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
style={{ left: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
|
||||
@@ -141,8 +259,66 @@ export function FrameTimeline() {
|
||||
{formatTime(currentSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 h-2.5 w-full relative bg-zinc-700/80 border-y border-white/10 shadow-inner",
|
||||
propagationRangeSelectionActive && !propagationRangeDisabled && "cursor-crosshair",
|
||||
)}
|
||||
aria-label="视频处理进度条"
|
||||
onPointerDown={handleProcessingBarPointerDown}
|
||||
onPointerMove={handleRangePointerMove}
|
||||
onPointerUp={handleRangePointerUp}
|
||||
onPointerCancel={() => setRangeDragAnchorFrame(null)}
|
||||
>
|
||||
{visibleSelectedRange && (
|
||||
<div
|
||||
data-testid="propagation-range-overlay"
|
||||
className="absolute inset-y-0 z-30 rounded-sm border border-amber-300/80 bg-amber-300/30 shadow-[0_0_12px_rgba(251,191,36,0.45)]"
|
||||
style={{ left: `${rangeLeft}%`, width: `${rangeWidth}%` }}
|
||||
/>
|
||||
)}
|
||||
{propagatedFrameMarkers.map(({ frame, index }) => {
|
||||
const left = totalFrames > 0 ? (index / totalFrames) * 100 : 0;
|
||||
const width = totalFrames > 0 ? 100 / totalFrames : 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={frame.id}
|
||||
data-testid="propagated-frame-segment"
|
||||
title={`自动传播帧 ${index + 1}`}
|
||||
aria-label={`跳转到自动传播帧 ${index + 1}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => handleFrameMarkerClick(event, index + 1)}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-10 border-0 bg-blue-500/85 p-0 shadow-[0_0_8px_rgba(59,130,246,0.65)]",
|
||||
propagationRangeSelectionActive ? "pointer-events-none" : "cursor-pointer hover:bg-blue-400",
|
||||
)}
|
||||
style={{ left: `${left}%`, width: `${width}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{annotatedFrameMarkers.map(({ frame, index }) => {
|
||||
const left = totalFrames > 1 ? (index / Math.max(totalFrames - 1, 1)) * 100 : 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={frame.id}
|
||||
data-testid="annotated-frame-marker"
|
||||
title={`人工/AI 标注帧 ${index + 1}`}
|
||||
aria-label={`跳转到人工/AI 标注帧 ${index + 1}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => handleFrameMarkerClick(event, index + 1)}
|
||||
className={cn(
|
||||
"absolute -top-1 z-20 h-4 w-0.5 -translate-x-1/2 rounded-full border-0 bg-red-500 p-0 shadow-[0_0_9px_rgba(239,68,68,0.8)]",
|
||||
propagationRangeSelectionActive ? "pointer-events-none" : "cursor-pointer hover:bg-red-400",
|
||||
)}
|
||||
style={{ left: `${left}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-3 text-[9px] font-mono text-gray-500 pointer-events-none">
|
||||
自动传播 {propagatedFrameMarkers.length} 帧
|
||||
人工/AI {annotatedFrameMarkers.length} 帧 · 自动传播 {propagatedFrameMarkers.length} 帧
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -176,13 +352,33 @@ export function FrameTimeline() {
|
||||
}
|
||||
const frame = frames[idx];
|
||||
const isCurrent = idx === currentFrameIndex;
|
||||
const isPropagatedFrame = propagatedFrameIds.has(frame.id);
|
||||
const isAnnotatedFrame = annotatedFrameIds.has(frame.id);
|
||||
return (
|
||||
<div
|
||||
key={frame.id}
|
||||
onClick={() => setCurrentFrame(idx)}
|
||||
title={
|
||||
isPropagatedFrame
|
||||
? `自动传播帧 ${idx + 1}`
|
||||
: isAnnotatedFrame
|
||||
? `人工/AI 标注帧 ${idx + 1}`
|
||||
: `视频帧 ${idx + 1}`
|
||||
}
|
||||
className={cn(
|
||||
"relative shrink-0 rounded-sm transition-all cursor-pointer flex items-center justify-center overflow-hidden group mx-0.5",
|
||||
isCurrent ? "w-28 h-16 border-2 border-cyan-500 bg-gray-700 shadow-[0_0_15px_rgba(6,182,212,0.3)] z-10" : "w-16 h-12 border border-white/5 bg-gray-800/50 opacity-40 hover:opacity-100"
|
||||
isCurrent
|
||||
? cn(
|
||||
"w-28 h-16 border-2 border-cyan-500 bg-gray-700 z-10",
|
||||
isAnnotatedFrame
|
||||
? "shadow-[inset_0_0_0_2px_rgba(239,68,68,0.95),0_0_15px_rgba(6,182,212,0.3)]"
|
||||
: "shadow-[0_0_15px_rgba(6,182,212,0.3)]",
|
||||
)
|
||||
: isPropagatedFrame
|
||||
? "w-16 h-12 border border-blue-500 bg-blue-950/30 opacity-80 shadow-[0_0_10px_rgba(59,130,246,0.22)] hover:opacity-100"
|
||||
: isAnnotatedFrame
|
||||
? "w-16 h-12 border border-red-500 bg-red-950/30 opacity-85 shadow-[0_0_10px_rgba(239,68,68,0.22)] hover:opacity-100"
|
||||
: "w-16 h-12 border border-white/5 bg-gray-800/50 opacity-40 hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
{frame.url ? (
|
||||
|
||||
@@ -42,6 +42,27 @@ describe('ProjectLibrary', () => {
|
||||
expect(onProjectSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the generated frame sequence FPS on project cards instead of source FPS', async () => {
|
||||
apiMock.getProjects.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'p-fps',
|
||||
name: 'Frame Rate Demo',
|
||||
status: 'ready',
|
||||
frames: 120,
|
||||
fps: '12FPS',
|
||||
parse_fps: 12,
|
||||
original_fps: 29.97,
|
||||
video_path: 'uploads/demo.mp4',
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('12FPS')).toBeInTheDocument();
|
||||
expect(screen.getByText('原 30.0fps')).toBeInTheDocument();
|
||||
expect(screen.queryByText('30FPS')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a new project from the modal', async () => {
|
||||
apiMock.createProject.mockResolvedValueOnce({ id: 'p2', name: 'New Project', status: 'pending' });
|
||||
|
||||
@@ -74,6 +95,7 @@ describe('ProjectLibrary', () => {
|
||||
})));
|
||||
expect(apiMock.uploadMedia).toHaveBeenCalledWith(file, 'p3');
|
||||
expect(apiMock.parseMedia).not.toHaveBeenCalled();
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('视频导入成功');
|
||||
});
|
||||
|
||||
it('generates frames from an imported video with the selected FPS', async () => {
|
||||
@@ -89,6 +111,8 @@ describe('ProjectLibrary', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始生成帧' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.parseMedia).toHaveBeenCalledWith('p4', { parseFps: 12 }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('生成帧任务已入队 #22');
|
||||
expect(await screen.findByText('12FPS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes a project from the project card without entering the workspace', async () => {
|
||||
@@ -129,5 +153,6 @@ describe('ProjectLibrary', () => {
|
||||
|
||||
await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalledWith([dcm]));
|
||||
expect(apiMock.parseMedia).toHaveBeenCalledWith('77');
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('DICOM 上传成功: 1 个文件');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '../lib/utils';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { getProjects, createProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject } from '../lib/api';
|
||||
import type { Project } from '../store/useStore';
|
||||
import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice';
|
||||
|
||||
interface ProjectLibraryProps {
|
||||
onProjectSelect: () => void;
|
||||
@@ -31,9 +32,24 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
const [frameParseFps, setFrameParseFps] = useState(30);
|
||||
const [isGeneratingFrames, setIsGeneratingFrames] = useState(false);
|
||||
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<NoticeState | null>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
const dicomInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const showNotice = (message: string, tone: NoticeTone = 'info') => {
|
||||
setNotice({ id: Date.now(), message, tone });
|
||||
};
|
||||
|
||||
const frameSequenceLabel = (project: Project) => {
|
||||
if (project.source_type === 'dicom') return 'DICOM';
|
||||
if (project.video_path && (project.frames ?? 0) === 0 && project.status !== 'parsing') return '待生成帧';
|
||||
if (project.parse_fps && project.parse_fps > 0) {
|
||||
const rounded = Math.round(project.parse_fps * 10) / 10;
|
||||
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}FPS`;
|
||||
}
|
||||
return project.fps || '30FPS';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
getProjects()
|
||||
@@ -81,7 +97,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete project failed:', err);
|
||||
alert('删除项目失败,请检查后端服务');
|
||||
showNotice('删除项目失败,请检查后端服务', 'error');
|
||||
} finally {
|
||||
setDeletingProjectId(null);
|
||||
}
|
||||
@@ -102,12 +118,12 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
description: `导入于 ${new Date().toLocaleString()}`,
|
||||
});
|
||||
const result = await uploadMedia(pendingFile, String(newProject.id));
|
||||
alert(`视频导入成功: ${pendingFile.name}\n已保存至: ${result.url}\n需要生成帧时,请在项目卡片点击“生成帧”。`);
|
||||
showNotice(`视频导入成功: ${pendingFile.name}\n已保存至: ${result.url}\n需要生成帧时,请在项目卡片点击“生成帧”。`, 'success');
|
||||
const data = await getProjects();
|
||||
setProjects(data);
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
alert('上传失败,请检查后端服务');
|
||||
showNotice('上传失败,请检查后端服务', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setPendingFile(null);
|
||||
@@ -127,14 +143,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
setIsGeneratingFrames(true);
|
||||
try {
|
||||
const task = await parseMedia(frameProject.id, { parseFps: frameParseFps });
|
||||
alert(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`);
|
||||
showNotice(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`, 'success');
|
||||
const data = await getProjects();
|
||||
setProjects(data);
|
||||
setShowFrameConfig(false);
|
||||
setFrameProject(null);
|
||||
} catch (err) {
|
||||
console.error('Frame generation failed:', err);
|
||||
alert('生成帧失败,请检查后端服务或项目源文件');
|
||||
showNotice('生成帧失败,请检查后端服务或项目源文件', 'error');
|
||||
} finally {
|
||||
setIsGeneratingFrames(false);
|
||||
}
|
||||
@@ -144,19 +160,19 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
if (!files || files.length === 0) return;
|
||||
const dcmFiles = Array.from(files).filter((f) => f.name.toLowerCase().endsWith('.dcm'));
|
||||
if (dcmFiles.length === 0) {
|
||||
alert('未选择有效的 .dcm 文件');
|
||||
showNotice('未选择有效的 .dcm 文件', 'error');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await uploadDicomBatch(dcmFiles);
|
||||
await parseMedia(String(result.project_id));
|
||||
alert(`DICOM 上传成功: ${result.uploaded_count} 个文件`);
|
||||
showNotice(`DICOM 上传成功: ${result.uploaded_count} 个文件`, 'success');
|
||||
const data = await getProjects();
|
||||
setProjects(data);
|
||||
} catch (err) {
|
||||
console.error('DICOM upload failed:', err);
|
||||
alert('DICOM 上传失败,请检查后端服务');
|
||||
showNotice('DICOM 上传失败,请检查后端服务', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (dicomInputRef.current) dicomInputRef.current.value = '';
|
||||
@@ -175,6 +191,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
|
||||
<TransientNotice notice={notice} onDismiss={() => setNotice(null)} />
|
||||
<div className="flex justify-between items-end mb-8 border-b border-white/5 pb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-medium tracking-tight text-white mb-2">视频与连续帧项目库</h1>
|
||||
@@ -263,7 +280,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
)}
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] font-mono px-2 py-1 rounded border border-white/10 uppercase tracking-widest">
|
||||
{proj.source_type === 'dicom' ? 'DICOM' : (proj.video_path && (proj.frames ?? 0) === 0 ? '待生成帧' : (proj.fps || '30FPS'))}
|
||||
{frameSequenceLabel(proj)}
|
||||
</span>
|
||||
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] px-2 py-1 rounded border border-white/10 flex items-center gap-1 uppercase tracking-widest">
|
||||
{proj.status === 'ready' ? (
|
||||
|
||||
@@ -83,6 +83,32 @@ describe('TemplateRegistry', () => {
|
||||
expect(screen.getByText('分类A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows JSON import errors as transient notices instead of blocking alerts', async () => {
|
||||
apiMock.getTemplates.mockResolvedValueOnce([]);
|
||||
|
||||
render(<TemplateRegistry />);
|
||||
fireEvent.click(screen.getByText('新建方案'));
|
||||
fireEvent.click(screen.getByText('批量导入'));
|
||||
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
|
||||
target: { value: '{broken-json' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '导入' }));
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('JSON 解析失败');
|
||||
});
|
||||
|
||||
it('shows template save errors as transient notices', async () => {
|
||||
apiMock.getTemplates.mockResolvedValueOnce([]);
|
||||
apiMock.createTemplate.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
render(<TemplateRegistry />);
|
||||
fireEvent.click(screen.getByText('新建方案'));
|
||||
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'Bad Template' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('保存失败,请查看控制台');
|
||||
});
|
||||
|
||||
it('edits an existing template through the backend and store', async () => {
|
||||
apiMock.getTemplates.mockResolvedValueOnce([
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '../lib/utils';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { getTemplates, createTemplate, updateTemplate, deleteTemplate } from '../lib/api';
|
||||
import type { Template, TemplateClass } from '../store/useStore';
|
||||
import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice';
|
||||
|
||||
// HSL to Hex color generator
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
@@ -59,6 +60,11 @@ export function TemplateRegistry() {
|
||||
const [editClasses, setEditClasses] = useState<TemplateClass[]>([]);
|
||||
const [editingClassId, setEditingClassId] = useState<string | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
const [notice, setNotice] = useState<NoticeState | null>(null);
|
||||
|
||||
const showNotice = (message: string, tone: NoticeTone = 'info') => {
|
||||
setNotice({ id: Date.now(), message, tone });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
@@ -106,7 +112,7 @@ export function TemplateRegistry() {
|
||||
setShowModal(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to save template:', err);
|
||||
alert('保存失败,请查看控制台');
|
||||
showNotice('保存失败,请查看控制台', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -122,6 +128,7 @@ export function TemplateRegistry() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete template:', err);
|
||||
showNotice('删除失败,请检查后端服务', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,7 +175,7 @@ export function TemplateRegistry() {
|
||||
colors = data.colors;
|
||||
names = data.names;
|
||||
} else {
|
||||
alert('格式错误:请提供 [[colors...], [names...]] 或 {colors, names}');
|
||||
showNotice('格式错误:请提供 [[colors...], [names...]] 或 {colors, names}', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -188,7 +195,7 @@ export function TemplateRegistry() {
|
||||
setShowImport(false);
|
||||
setImportText('');
|
||||
} catch (e) {
|
||||
alert('JSON 解析失败');
|
||||
showNotice('JSON 解析失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -212,6 +219,7 @@ export function TemplateRegistry() {
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
|
||||
<TransientNotice notice={notice} onDismiss={() => setNotice(null)} />
|
||||
<div className="mb-8 border-b border-white/5 pb-6">
|
||||
<h1 className="text-3xl font-medium tracking-tight text-white mb-2">分割模板与分类优先级管理库</h1>
|
||||
<p className="text-gray-400 text-sm">定义业务语义本体树架构、约束覆盖遮罩优先级(Z-Index裁决权重),以及真实标签数据的向下兼容转换映射(Dict Translation)原则。</p>
|
||||
|
||||
@@ -42,4 +42,13 @@ describe('ToolsPalette', () => {
|
||||
expect(setActiveTool).toHaveBeenCalledWith('sam_trigger');
|
||||
expect(onTriggerAI).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses compact vertically scrollable layout for smaller workspaces', () => {
|
||||
const { container } = render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} />);
|
||||
const palette = container.firstElementChild;
|
||||
|
||||
expect(palette).toHaveClass('overflow-y-auto');
|
||||
expect(screen.getByTitle('创建多边形 (P)')).toHaveClass('h-9');
|
||||
expect(screen.getByTitle('打开 AI 智能分割')).toHaveClass('h-9');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,8 +40,8 @@ export function ToolsPalette({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-12 bg-[#0d0d0d] border-r border-white/5 flex flex-col items-center py-4 shrink-0 z-10">
|
||||
<div className="flex flex-col gap-4 w-full px-2">
|
||||
<div className="h-full w-12 bg-[#0d0d0d] border-r border-white/5 flex flex-col items-center py-2 shrink-0 z-10 overflow-y-auto overflow-x-hidden overscroll-contain">
|
||||
<div className="flex flex-col gap-1.5 w-full px-1.5">
|
||||
{tools.map(tool => {
|
||||
const Icon = tool.icon;
|
||||
const isActive = activeTool === tool.id;
|
||||
@@ -51,7 +51,7 @@ export function ToolsPalette({
|
||||
onClick={() => setActiveTool(tool.id)}
|
||||
title={tool.label}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-lg flex items-center justify-center transition-all p-2",
|
||||
"w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5",
|
||||
isActive
|
||||
? (tool.id.includes('remove') ? "bg-red-500/10 text-red-500"
|
||||
: tool.id.includes('merge') ? "bg-green-500/10 text-green-500"
|
||||
@@ -59,12 +59,12 @@ export function ToolsPalette({
|
||||
: "text-gray-500 hover:bg-white/5 hover:text-white"
|
||||
)}
|
||||
>
|
||||
<Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
|
||||
<Icon size={16} strokeWidth={isActive ? 2.5 : 2} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="w-full h-px bg-white/10 my-1" />
|
||||
<div className="w-full h-px bg-white/10 my-0.5" />
|
||||
|
||||
{aiTools.map(tool => {
|
||||
const Icon = tool.icon;
|
||||
@@ -75,13 +75,13 @@ export function ToolsPalette({
|
||||
onClick={() => setActiveTool(tool.id)}
|
||||
title={tool.label}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-lg flex items-center justify-center transition-all p-2 border",
|
||||
"w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 border",
|
||||
isActive
|
||||
? `${tool.bg} ${tool.color} ${tool.border} shadow-[0_0_10px_rgba(255,255,255,0.05)]`
|
||||
: "text-gray-500 hover:bg-white/5 hover:text-white border-transparent"
|
||||
)}
|
||||
>
|
||||
<Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
|
||||
<Icon size={16} strokeWidth={isActive ? 2.5 : 2} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -93,32 +93,32 @@ export function ToolsPalette({
|
||||
}}
|
||||
title="打开 AI 智能分割"
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-lg flex items-center justify-center transition-all",
|
||||
"w-9 h-9 rounded-md flex items-center justify-center transition-all",
|
||||
activeTool === 'sam_trigger'
|
||||
? "bg-cyan-600 text-white shadow-lg shadow-cyan-900/20"
|
||||
: "text-gray-500 hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Wand2 size={18} strokeWidth={2} />
|
||||
<Wand2 size={16} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
<div className="w-full h-px bg-white/10 my-1" />
|
||||
<div className="w-full h-px bg-white/10 my-0.5" />
|
||||
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
className="w-10 h-10 rounded text-gray-500 hover:bg-white/5 hover:text-white flex items-center justify-center transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-gray-500 disabled:cursor-not-allowed"
|
||||
className="w-9 h-9 rounded text-gray-500 hover:bg-white/5 hover:text-white flex items-center justify-center transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-gray-500 disabled:cursor-not-allowed"
|
||||
title="撤销操作 (Ctrl+Z)"
|
||||
>
|
||||
<Undo size={18} />
|
||||
<Undo size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
className="w-10 h-10 rounded text-gray-500 hover:bg-white/5 hover:text-white flex items-center justify-center transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-gray-500 disabled:cursor-not-allowed"
|
||||
className="w-9 h-9 rounded text-gray-500 hover:bg-white/5 hover:text-white flex items-center justify-center transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-gray-500 disabled:cursor-not-allowed"
|
||||
title="重做操作 (Ctrl+Shift+Z)"
|
||||
>
|
||||
<Redo size={18} />
|
||||
<Redo size={16} />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
28
src/components/TransientNotice.test.tsx
Normal file
28
src/components/TransientNotice.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TransientNotice } from './TransientNotice';
|
||||
|
||||
describe('TransientNotice', () => {
|
||||
it('renders a non-blocking notice and dismisses it after the timeout', () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(
|
||||
<TransientNotice
|
||||
notice={{ id: 1, message: '操作已完成', tone: 'success' }}
|
||||
onDismiss={onDismiss}
|
||||
durationMs={1000}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('操作已完成');
|
||||
expect(screen.getByRole('status').parentElement).toHaveClass('pointer-events-none');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
47
src/components/TransientNotice.tsx
Normal file
47
src/components/TransientNotice.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export type NoticeTone = 'info' | 'success' | 'error';
|
||||
|
||||
export interface NoticeState {
|
||||
id: number;
|
||||
message: string;
|
||||
tone?: NoticeTone;
|
||||
}
|
||||
|
||||
interface TransientNoticeProps {
|
||||
notice: NoticeState | null;
|
||||
onDismiss: () => void;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
const toneClasses: Record<NoticeTone, string> = {
|
||||
info: 'border-cyan-400/30 bg-cyan-950/85 text-cyan-100 shadow-cyan-950/30',
|
||||
success: 'border-emerald-400/30 bg-emerald-950/85 text-emerald-100 shadow-emerald-950/30',
|
||||
error: 'border-red-400/30 bg-red-950/85 text-red-100 shadow-red-950/30',
|
||||
};
|
||||
|
||||
export function TransientNotice({ notice, onDismiss, durationMs = 3600 }: TransientNoticeProps) {
|
||||
useEffect(() => {
|
||||
if (!notice) return undefined;
|
||||
const timer = window.setTimeout(onDismiss, durationMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [durationMs, notice, onDismiss]);
|
||||
|
||||
if (!notice) return null;
|
||||
|
||||
const tone = notice.tone || 'info';
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-6 top-6 z-[80] max-w-sm" aria-live="polite">
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
'rounded-md border px-4 py-3 text-xs font-medium leading-relaxed shadow-2xl backdrop-blur whitespace-pre-line',
|
||||
toneClasses[tone],
|
||||
)}
|
||||
>
|
||||
{notice.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetStore } from '../test/storeTestUtils';
|
||||
import { useStore } from '../store/useStore';
|
||||
@@ -8,7 +8,9 @@ const apiMock = vi.hoisted(() => ({
|
||||
getProjectFrames: vi.fn(),
|
||||
parseMedia: vi.fn(),
|
||||
propagateMasks: vi.fn(),
|
||||
queuePropagationTask: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
cancelTask: vi.fn(),
|
||||
getTemplates: vi.fn(),
|
||||
getProjectAnnotations: vi.fn(),
|
||||
saveAnnotation: vi.fn(),
|
||||
@@ -26,7 +28,9 @@ vi.mock('../lib/api', () => ({
|
||||
getProjectFrames: apiMock.getProjectFrames,
|
||||
parseMedia: apiMock.parseMedia,
|
||||
propagateMasks: apiMock.propagateMasks,
|
||||
queuePropagationTask: apiMock.queuePropagationTask,
|
||||
getTask: apiMock.getTask,
|
||||
cancelTask: apiMock.cancelTask,
|
||||
getTemplates: apiMock.getTemplates,
|
||||
getProjectAnnotations: apiMock.getProjectAnnotations,
|
||||
saveAnnotation: apiMock.saveAnnotation,
|
||||
@@ -48,7 +52,15 @@ describe('VideoWorkspace', () => {
|
||||
apiMock.getTemplates.mockResolvedValue([]);
|
||||
apiMock.getProjectAnnotations.mockResolvedValue([]);
|
||||
apiMock.annotationToMask.mockReturnValue(null);
|
||||
apiMock.getTask.mockResolvedValue({ id: 1, status: 'success', progress: 100, message: '解析完成' });
|
||||
apiMock.queuePropagationTask.mockResolvedValue({ id: 31, status: 'queued', progress: 0, message: '自动传播任务已入队' });
|
||||
apiMock.getTask.mockResolvedValue({
|
||||
id: 31,
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
message: '自动传播完成',
|
||||
result: { processed_frame_count: 3, created_annotation_count: 2, completed_steps: 1 },
|
||||
});
|
||||
apiMock.cancelTask.mockResolvedValue({ id: 31, status: 'cancelled', progress: 100, message: '任务已取消' });
|
||||
apiMock.propagateMasks.mockResolvedValue({
|
||||
model: 'sam2.1_hiera_tiny',
|
||||
direction: 'forward',
|
||||
@@ -81,6 +93,60 @@ describe('VideoWorkspace', () => {
|
||||
expect(apiMock.getProjectAnnotations).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('exposes workspace undo/redo buttons and keyboard shortcuts without hijacking inputs', async () => {
|
||||
const mask = {
|
||||
id: 'mask-undo',
|
||||
frameId: '10',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: 'Draft',
|
||||
color: '#06b6d4',
|
||||
};
|
||||
useStore.setState({
|
||||
currentProject: null,
|
||||
masks: [mask],
|
||||
maskHistory: [[]],
|
||||
maskFuture: [],
|
||||
});
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销操作' }));
|
||||
|
||||
expect(useStore.getState().masks).toEqual([]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重做操作' }));
|
||||
expect(useStore.getState().masks).toEqual([mask]);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'z', ctrlKey: true });
|
||||
expect(useStore.getState().masks).toEqual([]);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'z', ctrlKey: true, shiftKey: true });
|
||||
expect(useStore.getState().masks).toEqual([mask]);
|
||||
|
||||
fireEvent.keyDown(screen.getByLabelText('传播起始帧'), { key: 'z', ctrlKey: true });
|
||||
expect(useStore.getState().masks).toEqual([mask]);
|
||||
});
|
||||
|
||||
it('auto-dismisses short workspace operation messages without blocking later actions', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(1));
|
||||
|
||||
vi.useFakeTimers();
|
||||
fireEvent.click(screen.getByRole('button', { name: '结构化归档保存' }));
|
||||
expect(screen.getByText('没有待保存标注')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3600);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('没有待保存标注')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '结构化归档保存' })).not.toBeDisabled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not auto-generate frames when a media project has no frames yet', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([]);
|
||||
|
||||
@@ -417,26 +483,190 @@ describe('VideoWorkspace', () => {
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||||
expect(apiMock.queuePropagationTask).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.propagateMasks).toHaveBeenCalledWith({
|
||||
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith({
|
||||
project_id: 1,
|
||||
frame_id: 10,
|
||||
model: 'sam2.1_hiera_tiny',
|
||||
direction: 'forward',
|
||||
max_frames: 2,
|
||||
include_source: false,
|
||||
save_annotations: true,
|
||||
seed: {
|
||||
steps: [{
|
||||
direction: 'forward',
|
||||
max_frames: 2,
|
||||
seed: {
|
||||
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
|
||||
bbox: [0.1, 0.1, 0.2, 0.2],
|
||||
points: undefined,
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
class_metadata: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
|
||||
template_id: 2,
|
||||
source_mask_id: 'mask-1',
|
||||
source_annotation_id: undefined,
|
||||
},
|
||||
}],
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask,处理 3 帧次,删除旧区域 0 个,保存 2 个区域')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('uses the separately selected propagation weight when queueing propagation', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
apiMock.buildAnnotationPayload.mockReturnValueOnce({
|
||||
project_id: 1,
|
||||
frame_id: 10,
|
||||
mask_data: {
|
||||
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
|
||||
bbox: [0.1, 0.1, 0.2, 0.2],
|
||||
points: undefined,
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
class_metadata: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20 },
|
||||
template_id: 2,
|
||||
},
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask,处理 3 帧次,保存 2 个区域')).toBeInTheDocument());
|
||||
bbox: [0.1, 0.1, 0.2, 0.2],
|
||||
});
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
aiModel: 'sam2.1_hiera_tiny',
|
||||
masks: [{
|
||||
id: 'mask-propagation-model',
|
||||
frameId: '10',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
segmentation: [[64, 36, 192, 36, 192, 108]],
|
||||
bbox: [64, 36, 128, 72],
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
const propagationWeightSelect = screen.getByLabelText('传播权重');
|
||||
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({
|
||||
model: 'sam2.1_hiera_small',
|
||||
})));
|
||||
await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask,处理 3 帧次,删除旧区域 0 个,保存 2 个区域')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows propagation task progress and reports empty results', async () => {
|
||||
apiMock.getProjectFrames.mockResolvedValueOnce([
|
||||
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
|
||||
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
apiMock.buildAnnotationPayload.mockReturnValueOnce({
|
||||
project_id: 1,
|
||||
frame_id: 10,
|
||||
mask_data: {
|
||||
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
},
|
||||
bbox: [0.1, 0.1, 0.2, 0.2],
|
||||
});
|
||||
apiMock.queuePropagationTask.mockResolvedValueOnce({ id: 44, status: 'queued', progress: 0, message: '自动传播任务已入队' });
|
||||
apiMock.getTask.mockResolvedValueOnce({
|
||||
id: 44,
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
message: '自动传播完成,但没有生成新的 mask',
|
||||
result: { processed_frame_count: 2, created_annotation_count: 0, completed_steps: 1 },
|
||||
});
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
masks: [{
|
||||
id: 'mask-progress',
|
||||
frameId: 10 as unknown as string,
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
segmentation: [[64, 36, 192, 36, 192, 108]],
|
||||
bbox: [64, 36, 128, 72],
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
|
||||
|
||||
const progressPanel = await screen.findByLabelText('自动传播进度');
|
||||
expect(progressPanel).toBeInTheDocument();
|
||||
expect(within(progressPanel).getByText('0%')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText(/没有生成新的 mask/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets users select the propagation range on the timeline before queueing', 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 },
|
||||
{ id: 13, project_id: 1, frame_index: 3, image_url: '/frame-3.jpg', width: 640, height: 360 },
|
||||
{ id: 14, project_id: 1, frame_index: 4, image_url: '/frame-4.jpg', width: 640, height: 360 },
|
||||
]);
|
||||
apiMock.buildAnnotationPayload.mockReturnValueOnce({
|
||||
project_id: 1,
|
||||
frame_id: 10,
|
||||
mask_data: {
|
||||
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
},
|
||||
bbox: [0.1, 0.1, 0.2, 0.2],
|
||||
});
|
||||
|
||||
render(<VideoWorkspace />);
|
||||
await waitFor(() => expect(useStore.getState().frames).toHaveLength(5));
|
||||
act(() => {
|
||||
useStore.setState({
|
||||
masks: [{
|
||||
id: 'mask-timeline-range',
|
||||
frameId: '10',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '胆囊',
|
||||
color: '#ff0000',
|
||||
segmentation: [[64, 36, 192, 36, 192, 108]],
|
||||
bbox: [64, 36, 128, 72],
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||||
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: 25, pointerId: 1 });
|
||||
fireEvent.pointerMove(processingBar, { clientX: 100, pointerId: 1 });
|
||||
fireEvent.pointerUp(processingBar, { clientX: 100, pointerId: 1 });
|
||||
|
||||
expect(screen.getByLabelText('传播起始帧')).toHaveValue(2);
|
||||
expect(screen.getByLabelText('传播结束帧')).toHaveValue(5);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
frame_id: 10,
|
||||
steps: [expect.objectContaining({ direction: 'forward', max_frames: 5 })],
|
||||
})));
|
||||
});
|
||||
|
||||
it('auto-propagates all reference-frame masks in both directions inside the selected range', async () => {
|
||||
@@ -445,13 +675,16 @@ describe('VideoWorkspace', () => {
|
||||
{ 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.propagateMasks.mockResolvedValue({
|
||||
model: 'sam2.1_hiera_tiny',
|
||||
direction: 'forward',
|
||||
source_frame_id: 11,
|
||||
processed_frame_count: 2,
|
||||
created_annotation_count: 1,
|
||||
annotations: [],
|
||||
apiMock.getTask.mockResolvedValue({
|
||||
id: 31,
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
message: '自动传播完成',
|
||||
result: {
|
||||
processed_frame_count: 8,
|
||||
created_annotation_count: 4,
|
||||
completed_steps: 4,
|
||||
},
|
||||
});
|
||||
apiMock.buildAnnotationPayload
|
||||
.mockReturnValueOnce({
|
||||
@@ -505,27 +738,14 @@ describe('VideoWorkspace', () => {
|
||||
fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
|
||||
|
||||
await waitFor(() => expect(apiMock.propagateMasks).toHaveBeenCalledTimes(4));
|
||||
expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
direction: 'backward',
|
||||
max_frames: 2,
|
||||
seed: expect.objectContaining({ label: '胆囊' }),
|
||||
}));
|
||||
expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
direction: 'forward',
|
||||
max_frames: 2,
|
||||
seed: expect.objectContaining({ label: '胆囊' }),
|
||||
}));
|
||||
expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
||||
direction: 'backward',
|
||||
max_frames: 2,
|
||||
seed: expect.objectContaining({ label: '肝脏' }),
|
||||
}));
|
||||
expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(4, expect.objectContaining({
|
||||
direction: 'forward',
|
||||
max_frames: 2,
|
||||
seed: expect.objectContaining({ label: '肝脏' }),
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByText('已自动传播 2 个参考 mask,处理 8 帧次,保存 4 个区域')).toBeInTheDocument());
|
||||
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledTimes(1));
|
||||
const queuedPayload = apiMock.queuePropagationTask.mock.calls[0][0];
|
||||
expect(queuedPayload.steps).toEqual([
|
||||
expect.objectContaining({ direction: 'backward', max_frames: 2, seed: expect.objectContaining({ label: '胆囊' }) }),
|
||||
expect.objectContaining({ direction: 'forward', max_frames: 2, seed: expect.objectContaining({ label: '胆囊' }) }),
|
||||
expect.objectContaining({ direction: 'backward', max_frames: 2, seed: expect.objectContaining({ label: '肝脏' }) }),
|
||||
expect.objectContaining({ direction: 'forward', max_frames: 2, seed: expect.objectContaining({ label: '肝脏' }) }),
|
||||
]);
|
||||
await waitFor(() => expect(screen.getByText('已自动传播 2 个参考 mask,处理 8 帧次,删除旧区域 0 个,保存 4 个区域')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Redo, Undo } from 'lucide-react';
|
||||
import { useStore } from '../store/useStore';
|
||||
import {
|
||||
annotationToMask,
|
||||
buildAnnotationPayload,
|
||||
cancelTask,
|
||||
deleteAnnotation,
|
||||
exportCoco,
|
||||
exportMasks,
|
||||
getProjectAnnotations,
|
||||
getProjectFrames,
|
||||
getTask,
|
||||
getTemplates,
|
||||
importGtMask,
|
||||
propagateMasks,
|
||||
queuePropagationTask,
|
||||
saveAnnotation,
|
||||
updateAnnotation,
|
||||
} from '../lib/api';
|
||||
@@ -19,9 +22,20 @@ import { ToolsPalette } from './ToolsPalette';
|
||||
import { OntologyInspector } from './OntologyInspector';
|
||||
import { FrameTimeline } from './FrameTimeline';
|
||||
import { ModelStatusBadge } from './ModelStatusBadge';
|
||||
import type { Frame, Mask } from '../store/useStore';
|
||||
import { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, type Mask } from '../store/useStore';
|
||||
|
||||
type PropagationDirection = 'forward' | 'backward';
|
||||
type PropagationProgress = {
|
||||
currentStep: number;
|
||||
completedSteps: number;
|
||||
totalSteps: number;
|
||||
processedCount: number;
|
||||
createdCount: number;
|
||||
label: string;
|
||||
} | null;
|
||||
|
||||
const PROPAGATION_POLL_INTERVAL_MS = 250;
|
||||
const STATUS_MESSAGE_TTL_MS = 3600;
|
||||
|
||||
export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) {
|
||||
const gtMaskInputRef = React.useRef<HTMLInputElement>(null);
|
||||
@@ -53,6 +67,47 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [propagationStartFrame, setPropagationStartFrame] = useState(1);
|
||||
const [propagationEndFrame, setPropagationEndFrame] = useState(1);
|
||||
const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false);
|
||||
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
|
||||
const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null);
|
||||
const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null);
|
||||
const [propagationWeight, setPropagationWeight] = useState<AiModelId>(aiModel || DEFAULT_AI_MODEL_ID);
|
||||
const [hasCustomPropagationWeight, setHasCustomPropagationWeight] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCustomPropagationWeight) {
|
||||
setPropagationWeight(aiModel || DEFAULT_AI_MODEL_ID);
|
||||
}
|
||||
}, [aiModel, hasCustomPropagationWeight]);
|
||||
|
||||
const propagationWeightLabel = useMemo(
|
||||
() => SAM2_MODEL_OPTIONS.find((option) => option.id === propagationWeight)?.label || propagationWeight,
|
||||
[propagationWeight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWorkspaceShortcuts = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const tagName = target?.tagName?.toLowerCase();
|
||||
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select' || target?.isContentEditable) return;
|
||||
if (!event.metaKey && !event.ctrlKey) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'z') {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) redoMasks();
|
||||
else undoMasks();
|
||||
return;
|
||||
}
|
||||
if (key === 'y') {
|
||||
event.preventDefault();
|
||||
redoMasks();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleWorkspaceShortcuts);
|
||||
return () => window.removeEventListener('keydown', handleWorkspaceShortcuts);
|
||||
}, [redoMasks, undoMasks]);
|
||||
|
||||
const hydrateSavedAnnotations = useCallback(async (
|
||||
projectId: string,
|
||||
@@ -143,6 +198,13 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
const frameById = useMemo(() => new Map(frames.map((frame) => [frame.id, frame])), [frames]);
|
||||
const projectFrameIds = useMemo(() => new Set(frames.map((frame) => frame.id)), [frames]);
|
||||
const currentFrameNumber = currentFrameIndex + 1;
|
||||
const isWorkspaceBusy = isSaving || isExporting || isImportingGt || isPropagating || Boolean(propagationProgress);
|
||||
|
||||
useEffect(() => {
|
||||
if (!statusMessage || isWorkspaceBusy || totalFrames === 0) return undefined;
|
||||
const timer = window.setTimeout(() => setStatusMessage(''), STATUS_MESSAGE_TTL_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isWorkspaceBusy, statusMessage, totalFrames]);
|
||||
|
||||
useEffect(() => {
|
||||
if (totalFrames === 0) {
|
||||
@@ -152,6 +214,8 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
}
|
||||
setPropagationStartFrame(currentFrameNumber);
|
||||
setPropagationEndFrame(Math.min(totalFrames, currentFrameNumber + 29));
|
||||
setIsPropagationRangeSelecting(false);
|
||||
setHasExplicitPropagationRange(false);
|
||||
}, [currentFrameNumber, totalFrames]);
|
||||
|
||||
const savePendingAnnotations = useCallback(async ({ silent = false } = {}) => {
|
||||
@@ -341,6 +405,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
if (!seedPayload?.mask_data?.polygons?.length && !seedPayload?.bbox) {
|
||||
return null;
|
||||
}
|
||||
const sourceAnnotationId = seedMask.annotationId && /^\d+$/.test(seedMask.annotationId)
|
||||
? Number(seedMask.annotationId)
|
||||
: undefined;
|
||||
return {
|
||||
polygons: seedPayload.mask_data?.polygons,
|
||||
bbox: seedPayload.bbox,
|
||||
@@ -349,12 +416,33 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
color: seedPayload.mask_data?.color,
|
||||
class_metadata: seedPayload.mask_data?.class,
|
||||
template_id: seedPayload.template_id,
|
||||
source_mask_id: seedMask.id,
|
||||
source_annotation_id: sourceAnnotationId,
|
||||
};
|
||||
}, [activeTemplateId, currentFrame, currentProject?.id]);
|
||||
|
||||
const handleAutoPropagate = async () => {
|
||||
const handlePropagationRangeChange = useCallback((startFrame: number, endFrame: number) => {
|
||||
const nextStart = clampFrameNumber(startFrame);
|
||||
const nextEnd = clampFrameNumber(endFrame);
|
||||
setPropagationStartFrame(nextStart);
|
||||
setPropagationEndFrame(nextEnd);
|
||||
setHasExplicitPropagationRange(true);
|
||||
setStatusMessage(`已选择自动传播范围:第 ${Math.min(nextStart, nextEnd)}-${Math.max(nextStart, nextEnd)} 帧`);
|
||||
}, [clampFrameNumber]);
|
||||
|
||||
const handlePropagationStartInput = (value: number) => {
|
||||
setPropagationStartFrame(clampFrameNumber(value || 1));
|
||||
setHasExplicitPropagationRange(true);
|
||||
};
|
||||
|
||||
const handlePropagationEndInput = (value: number) => {
|
||||
setPropagationEndFrame(clampFrameNumber(value || 1));
|
||||
setHasExplicitPropagationRange(true);
|
||||
};
|
||||
|
||||
const runAutoPropagate = async () => {
|
||||
if (!currentProject?.id || !currentFrame?.id) return;
|
||||
const seedMasks = masks.filter((mask) => mask.frameId === currentFrame.id);
|
||||
const seedMasks = masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
|
||||
if (seedMasks.length === 0) {
|
||||
setStatusMessage('请先在当前参考帧创建或保存至少一个 mask');
|
||||
return;
|
||||
@@ -390,37 +478,121 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPropagationRangeSelecting(false);
|
||||
setIsPropagating(true);
|
||||
setStatusMessage(`${aiModel.toUpperCase()} 正在以第 ${currentFrameNumber} 帧为参考,自动传播 ${seeds.length} 个 mask 到第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧...`);
|
||||
const totalSteps = seeds.length * propagationDirections.length;
|
||||
setPropagationProgress({
|
||||
currentStep: 0,
|
||||
completedSteps: 0,
|
||||
totalSteps,
|
||||
processedCount: 0,
|
||||
createdCount: 0,
|
||||
label: '准备传播',
|
||||
});
|
||||
setStatusMessage(`${propagationWeightLabel} 权重正在以第 ${currentFrameNumber} 帧为参考,自动传播 ${seeds.length} 个 mask 到第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧...`);
|
||||
try {
|
||||
let createdCount = 0;
|
||||
let processedCount = 0;
|
||||
for (const { seed } of seeds) {
|
||||
for (const { direction, maxFrames } of propagationDirections) {
|
||||
const result = await propagateMasks({
|
||||
project_id: Number(currentProject.id),
|
||||
frame_id: Number(currentFrame.id),
|
||||
model: aiModel,
|
||||
direction,
|
||||
max_frames: maxFrames,
|
||||
include_source: false,
|
||||
save_annotations: true,
|
||||
seed,
|
||||
});
|
||||
createdCount += result.created_annotation_count;
|
||||
processedCount += result.processed_frame_count;
|
||||
const steps = seeds.flatMap(({ seed }) => (
|
||||
propagationDirections.map(({ direction, maxFrames }) => ({
|
||||
seed,
|
||||
direction,
|
||||
max_frames: maxFrames,
|
||||
}))
|
||||
));
|
||||
const task = await queuePropagationTask({
|
||||
project_id: Number(currentProject.id),
|
||||
frame_id: Number(currentFrame.id),
|
||||
model: propagationWeight,
|
||||
steps,
|
||||
include_source: false,
|
||||
save_annotations: true,
|
||||
});
|
||||
setPropagationTaskId(task.id);
|
||||
setStatusMessage(`自动传播任务已入队 #${task.id},可在 Dashboard 查看进度`);
|
||||
|
||||
let currentTask = task;
|
||||
while (!['success', 'failed', 'cancelled'].includes(currentTask.status)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, PROPAGATION_POLL_INTERVAL_MS));
|
||||
currentTask = await getTask(task.id);
|
||||
const result = currentTask.result || {};
|
||||
const completedSteps = Number(result.completed_steps || 0);
|
||||
const processedCount = Number(result.processed_frame_count || 0);
|
||||
const createdCount = Number(result.created_annotation_count || 0);
|
||||
setPropagationProgress({
|
||||
currentStep: Math.min(completedSteps + 1, totalSteps),
|
||||
completedSteps,
|
||||
totalSteps,
|
||||
processedCount,
|
||||
createdCount,
|
||||
label: currentTask.message || `自动传播任务 #${task.id}`,
|
||||
});
|
||||
setStatusMessage(currentTask.message || `自动传播任务 #${task.id} 运行中...`);
|
||||
if (createdCount > 0) {
|
||||
await hydrateSavedAnnotations(currentProject.id, frames);
|
||||
}
|
||||
}
|
||||
|
||||
const result = currentTask.result || {};
|
||||
const createdCount = Number(result.created_annotation_count || 0);
|
||||
const processedCount = Number(result.processed_frame_count || 0);
|
||||
const skippedCount = Number(result.skipped_seed_count || 0);
|
||||
const deletedCount = Number(result.deleted_annotation_count || 0);
|
||||
await hydrateSavedAnnotations(currentProject.id, frames);
|
||||
setStatusMessage(`已自动传播 ${seeds.length} 个参考 mask,处理 ${processedCount} 帧次,保存 ${createdCount} 个区域`);
|
||||
if (currentTask.status === 'failed') {
|
||||
setStatusMessage(currentTask.error ? `传播失败:${currentTask.error}` : '传播失败,请检查权重状态或后端日志');
|
||||
return;
|
||||
}
|
||||
if (currentTask.status === 'cancelled') {
|
||||
setStatusMessage('自动传播任务已取消');
|
||||
return;
|
||||
}
|
||||
setStatusMessage(createdCount > 0
|
||||
? `已自动传播 ${seeds.length} 个参考 mask,处理 ${processedCount} 帧次,删除旧区域 ${deletedCount} 个,保存 ${createdCount} 个区域`
|
||||
: skippedCount > 0
|
||||
? `自动传播已完成:${skippedCount} 个未改变 mask 已跳过,没有生成重复区域`
|
||||
: `自动传播已完成,但没有生成新的 mask;请检查参考 mask、传播范围或 ${propagationWeightLabel} 权重状态`);
|
||||
} catch (err) {
|
||||
console.error('Propagation failed:', err);
|
||||
setStatusMessage('传播失败,请检查模型状态或后端日志');
|
||||
const detail = (err as any)?.response?.data?.detail;
|
||||
setStatusMessage(detail ? `传播失败:${detail}` : '传播失败,请检查权重状态或后端日志');
|
||||
} finally {
|
||||
setIsPropagating(false);
|
||||
setPropagationProgress(null);
|
||||
setPropagationTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoPropagate = async () => {
|
||||
if (!hasExplicitPropagationRange && !isPropagationRangeSelecting) {
|
||||
setIsPropagationRangeSelecting(true);
|
||||
setStatusMessage('请在播放进度条或视频处理进度条上点击/拖拽选择传播起止帧,再点击“开始传播”');
|
||||
return;
|
||||
}
|
||||
await runAutoPropagate();
|
||||
};
|
||||
|
||||
const handleCancelPropagationRangeSelection = () => {
|
||||
setIsPropagationRangeSelecting(false);
|
||||
setHasExplicitPropagationRange(false);
|
||||
setPropagationStartFrame(currentFrameNumber || 1);
|
||||
setPropagationEndFrame(Math.min(Math.max(totalFrames, 1), (currentFrameNumber || 1) + 29));
|
||||
setStatusMessage('已取消自动传播范围选择');
|
||||
};
|
||||
|
||||
const handleCancelPropagation = async () => {
|
||||
if (!propagationTaskId) return;
|
||||
try {
|
||||
await cancelTask(propagationTaskId);
|
||||
setStatusMessage(`正在取消自动传播任务 #${propagationTaskId}...`);
|
||||
} catch (err) {
|
||||
console.error('Cancel propagation failed:', err);
|
||||
setStatusMessage('取消自动传播失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const propagationPercent = propagationProgress
|
||||
? Math.round((propagationProgress.completedSteps / Math.max(propagationProgress.totalSteps, 1)) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-[#0a0a0a]">
|
||||
{/* Top Header / Status bar */}
|
||||
@@ -436,7 +608,68 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
{statusMessage}
|
||||
</span>
|
||||
)}
|
||||
{propagationProgress && (
|
||||
<div
|
||||
className="w-56 rounded-md border border-blue-500/20 bg-blue-500/5 px-2 py-1"
|
||||
aria-label="自动传播进度"
|
||||
title={`已处理 ${propagationProgress.processedCount} 帧次,已保存 ${propagationProgress.createdCount} 个区域`}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-[10px] font-mono text-blue-200">
|
||||
<span className="truncate">{propagationProgress.label}</span>
|
||||
<span>{propagationPercent}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-zinc-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-400 transition-all"
|
||||
style={{ width: `${propagationPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 rounded-md border border-white/10 bg-white/[0.03] px-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={undoMasks}
|
||||
disabled={maskHistory.length === 0}
|
||||
aria-label="撤销操作"
|
||||
title="撤销操作 (Ctrl+Z)"
|
||||
className="h-7 px-2 rounded text-gray-400 hover:bg-white/5 hover:text-white inline-flex items-center gap-1.5 text-xs transition-colors disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Undo size={14} />
|
||||
撤销
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={redoMasks}
|
||||
disabled={maskFuture.length === 0}
|
||||
aria-label="重做操作"
|
||||
title="重做操作 (Ctrl+Shift+Z / Ctrl+Y)"
|
||||
className="h-7 px-2 rounded text-gray-400 hover:bg-white/5 hover:text-white inline-flex items-center gap-1.5 text-xs transition-colors disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Redo size={14} />
|
||||
重做
|
||||
</button>
|
||||
</div>
|
||||
<ModelStatusBadge />
|
||||
<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-white/10 bg-black/20 px-1 text-[10px] text-gray-300 outline-none focus:border-cyan-500/50 disabled:opacity-40"
|
||||
>
|
||||
{SAM2_MODEL_OPTIONS.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.shortLabel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
ref={gtMaskInputRef}
|
||||
type="file"
|
||||
@@ -460,7 +693,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
min={1}
|
||||
max={Math.max(totalFrames, 1)}
|
||||
value={propagationStartFrame}
|
||||
onChange={(event) => setPropagationStartFrame(clampFrameNumber(Number(event.target.value) || 1))}
|
||||
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"
|
||||
/>
|
||||
@@ -471,7 +704,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
min={1}
|
||||
max={Math.max(totalFrames, 1)}
|
||||
value={propagationEndFrame}
|
||||
onChange={(event) => setPropagationEndFrame(clampFrameNumber(Number(event.target.value) || 1))}
|
||||
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"
|
||||
/>
|
||||
@@ -481,8 +714,25 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
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 ? '传播中...' : '自动传播'}
|
||||
{isPropagating ? '传播中...' : isPropagationRangeSelecting ? '开始传播' : '自动传播'}
|
||||
</button>
|
||||
{isPropagationRangeSelecting && (
|
||||
<button
|
||||
onClick={handleCancelPropagationRangeSelection}
|
||||
disabled={isPropagating}
|
||||
className="px-3 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/25 rounded-md text-xs transition-colors text-amber-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
取消选区
|
||||
</button>
|
||||
)}
|
||||
{propagationTaskId && (
|
||||
<button
|
||||
onClick={handleCancelPropagation}
|
||||
className="px-3 py-1.5 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 rounded-md text-xs transition-colors text-red-200"
|
||||
>
|
||||
取消传播
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleExportMasks}
|
||||
disabled={!currentProject?.id || isExporting || isSaving || isPropagating}
|
||||
@@ -534,7 +784,15 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
</div>
|
||||
|
||||
{/* Bottom Timeline */}
|
||||
<FrameTimeline />
|
||||
<FrameTimeline
|
||||
propagationRange={{
|
||||
startFrame: propagationStartFrame,
|
||||
endFrame: propagationEndFrame,
|
||||
}}
|
||||
propagationRangeSelectionActive={isPropagationRangeSelecting}
|
||||
propagationRangeDisabled={isPropagating || isSaving || isExporting || isImportingGt}
|
||||
onPropagationRangeChange={handlePropagationRangeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,10 +53,12 @@ describe('api client contracts', () => {
|
||||
name: 'Demo',
|
||||
status: 'ready',
|
||||
frames: 12,
|
||||
fps: '30FPS',
|
||||
fps: '10FPS',
|
||||
thumbnail_url: 'thumb',
|
||||
video_path: 'uploads/demo.mp4',
|
||||
source_type: 'video',
|
||||
original_fps: 29.97,
|
||||
parse_fps: 10,
|
||||
createdAt: 'created',
|
||||
updatedAt: 'updated',
|
||||
}),
|
||||
@@ -184,7 +186,7 @@ describe('api client contracts', () => {
|
||||
});
|
||||
|
||||
it('lists, saves, updates, and deletes annotations with the backend annotation contract', async () => {
|
||||
const { deleteAnnotation, getProjectAnnotations, propagateMasks, saveAnnotation, updateAnnotation } = await import('./api');
|
||||
const { deleteAnnotation, getProjectAnnotations, propagateMasks, queuePropagationTask, saveAnnotation, updateAnnotation } = await import('./api');
|
||||
const saved = {
|
||||
id: 1,
|
||||
project_id: 9,
|
||||
@@ -267,6 +269,48 @@ describe('api client contracts', () => {
|
||||
}, {
|
||||
timeout: 600000,
|
||||
});
|
||||
|
||||
axiosMock.client.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 33,
|
||||
task_type: 'propagate_masks',
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
message: '自动传播任务已入队',
|
||||
},
|
||||
});
|
||||
await expect(queuePropagationTask({
|
||||
project_id: 9,
|
||||
frame_id: 5,
|
||||
model: 'sam2.1_hiera_tiny',
|
||||
steps: [{
|
||||
seed: {
|
||||
polygons: [[[0, 0], [1, 0], [1, 1]]],
|
||||
label: 'mask',
|
||||
color: '#06b6d4',
|
||||
},
|
||||
direction: 'forward',
|
||||
max_frames: 30,
|
||||
}],
|
||||
include_source: false,
|
||||
save_annotations: true,
|
||||
})).resolves.toEqual(expect.objectContaining({ id: 33, task_type: 'propagate_masks' }));
|
||||
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/ai/propagate/task', {
|
||||
project_id: 9,
|
||||
frame_id: 5,
|
||||
model: 'sam2.1_hiera_tiny',
|
||||
steps: [{
|
||||
seed: {
|
||||
polygons: [[[0, 0], [1, 0], [1, 1]]],
|
||||
label: 'mask',
|
||||
color: '#06b6d4',
|
||||
},
|
||||
direction: 'forward',
|
||||
max_frames: 30,
|
||||
}],
|
||||
include_source: false,
|
||||
save_annotations: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('imports GT masks through multipart form data', async () => {
|
||||
|
||||
@@ -49,6 +49,12 @@ function normalizeProjectStatus(status?: string): Project['status'] {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function formatProjectFps(value?: number | null): string {
|
||||
if (!value || value <= 0) return '30FPS';
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}FPS`;
|
||||
}
|
||||
|
||||
function mapProject(p: any): Project {
|
||||
return {
|
||||
id: String(p.id),
|
||||
@@ -56,7 +62,7 @@ function mapProject(p: any): Project {
|
||||
description: p.description,
|
||||
status: normalizeProjectStatus(p.status),
|
||||
frames: p.frame_count ?? 0,
|
||||
fps: p.original_fps ? `${Math.round(p.original_fps)}FPS` : '30FPS',
|
||||
fps: formatProjectFps(p.parse_fps ?? p.original_fps),
|
||||
thumbnail_url: p.thumbnail_url,
|
||||
video_path: p.video_path,
|
||||
source_type: p.source_type,
|
||||
@@ -346,6 +352,8 @@ export interface PropagateMasksPayload {
|
||||
category?: string;
|
||||
};
|
||||
template_id?: number;
|
||||
source_mask_id?: string;
|
||||
source_annotation_id?: number;
|
||||
};
|
||||
direction?: 'forward' | 'backward' | 'both';
|
||||
max_frames?: number;
|
||||
@@ -353,6 +361,19 @@ export interface PropagateMasksPayload {
|
||||
save_annotations?: boolean;
|
||||
}
|
||||
|
||||
export interface PropagateTaskPayload {
|
||||
project_id: number;
|
||||
frame_id: number;
|
||||
model?: AiModelId;
|
||||
steps: Array<{
|
||||
seed: PropagateMasksPayload['seed'];
|
||||
direction: 'forward' | 'backward';
|
||||
max_frames: number;
|
||||
}>;
|
||||
include_source?: boolean;
|
||||
save_annotations?: boolean;
|
||||
}
|
||||
|
||||
export interface PropagateMasksResult {
|
||||
model: AiModelId;
|
||||
direction: string;
|
||||
@@ -652,6 +673,11 @@ export async function propagateMasks(payload: PropagateMasksPayload): Promise<Pr
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function queuePropagationTask(payload: PropagateTaskPayload): Promise<ProcessingTask> {
|
||||
const response = await apiClient.post('/api/ai/propagate/task', payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function saveAnnotation(payload: SaveAnnotationPayload): Promise<SavedAnnotation> {
|
||||
const response = await apiClient.post('/api/ai/annotate', payload);
|
||||
return response.data;
|
||||
|
||||
@@ -32,7 +32,7 @@ function makeStageEvent(x = 120, y = 80) {
|
||||
}
|
||||
|
||||
vi.mock('react-konva', () => ({
|
||||
Stage: ({ children, onClick, onMouseDown, onMouseUp, onMouseMove, onWheel }: any) => {
|
||||
Stage: ({ children, onClick, onMouseDown, onMouseUp, onMouseMove, onWheel, onDragEnd, scaleX, scaleY, x, y, width, height }: any) => {
|
||||
const coords = (event: React.MouseEvent<HTMLDivElement>, fallbackX: number, fallbackY: number) => ({
|
||||
x: event.clientX || fallbackX,
|
||||
y: event.clientY || fallbackY,
|
||||
@@ -40,6 +40,13 @@ vi.mock('react-konva', () => ({
|
||||
return (
|
||||
<div
|
||||
data-testid="konva-stage"
|
||||
data-has-drag-end={Boolean(onDragEnd)}
|
||||
data-scale-x={scaleX}
|
||||
data-scale-y={scaleY}
|
||||
data-x={x}
|
||||
data-y={y}
|
||||
data-width={width}
|
||||
data-height={height}
|
||||
onClick={(event) => {
|
||||
const point = coords(event, 120, 80);
|
||||
onClick?.(makeStageEvent(point.x, point.y));
|
||||
@@ -57,6 +64,12 @@ vi.mock('react-konva', () => ({
|
||||
onMouseMove?.(makeStageEvent(point.x, point.y));
|
||||
}}
|
||||
onWheel={() => onWheel?.(makeStageEvent())}
|
||||
onDragEnd={(event) => onDragEnd?.({
|
||||
target: {
|
||||
x: () => event.clientX || 0,
|
||||
y: () => event.clientY || 0,
|
||||
},
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user