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:
2026-05-02 05:17:18 +08:00
parent b6a276cb8d
commit c8c59f7ede
38 changed files with 2852 additions and 212 deletions

View File

@@ -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()} />);

View File

@@ -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 */}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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 ? (

View File

@@ -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 个文件');
});
});

View File

@@ -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' ? (

View File

@@ -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([
{

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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>

View 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();
});
});

View 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>
);
}

View File

@@ -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());
});
});

View File

@@ -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>
);
}

View File

@@ -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 () => {

View File

@@ -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;

View File

@@ -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>