功能增加: - 在工作区增加按范围传播和传播全部可达入口,支持选中区域或当前帧全部 mask 作为 seed,并按前后帧范围调用 SAM2 传播后刷新已保存标注。 - 在 AI 智能分割中接入框选提示,支持 box prompt 以及 box + 正/反向点的 interactive prompt 细化流程。 - 在 AI 智能分割中增加提示点删除、最近锚点删除、清空锚点、选中 AI 候选删除和 Delete/Backspace 快捷删除。 - 在项目库删除项目后同步清理当前项目、帧、mask 与选区状态,避免删除后工作区残留旧数据。 - 将时间进度条上的已编辑帧提示改为覆盖在进度条上的琥珀色竖线,并保留已编辑帧计数。 - 将 AI 参数文案调整为局部专注模式(自动裁剪无锚区域)和严格除杂模式(自动清理干涉点),仅改善可读性,不改变内部字段。 Bugfix: - 修复 AI 框选工具无实际 prompt 输出的问题。 - 修复多次执行 AI 高精度语义分割时旧候选 mask 叠加显示的问题,改为替换本页 AI 候选。 - 修复删除 AI 候选后选区仍引用已删除 mask 的状态残留。 - 修复进度条当前帧提示与已编辑帧提示颜色/语义混淆的问题,当前帧继续由播放进度和缩略图高亮表达。 测试与文档: - 补充 AI 分割框选、候选替换、提示点删除和快捷删除相关测试。 - 补充工作区传播范围、传播全部可达、编辑区域删除和项目删除状态清理测试。 - 更新 README、AGENTS 和 doc 下需求冻结、设计冻结、接口契约、前端审计、实施计划、测试计划,记录当前真实功能和测试覆盖。
220 lines
9.7 KiB
TypeScript
220 lines
9.7 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { Play, Pause } from 'lucide-react';
|
|
import { cn } from '../lib/utils';
|
|
import { useStore } from '../store/useStore';
|
|
|
|
export function FrameTimeline() {
|
|
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 totalFrames = frames.length;
|
|
const currentFrame = totalFrames > 0 ? currentFrameIndex + 1 : 0;
|
|
const playbackFps = useMemo(() => {
|
|
const fps = currentProject?.parse_fps || currentProject?.original_fps || 12;
|
|
return Math.min(Math.max(fps, 1), 30);
|
|
}, [currentProject?.original_fps, currentProject?.parse_fps]);
|
|
const timeBaseFps = useMemo(() => {
|
|
const fps = currentProject?.parse_fps || currentProject?.original_fps || 12;
|
|
return Math.max(fps, 1);
|
|
}, [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 editedFrameMarkers = useMemo(() => {
|
|
const frameIds = new Set(frames.map((frame) => frame.id));
|
|
const editedIds = new Set(
|
|
masks
|
|
.filter((mask) => frameIds.has(mask.frameId))
|
|
.map((mask) => mask.frameId),
|
|
);
|
|
return frames
|
|
.map((frame, index) => ({ frame, index }))
|
|
.filter(({ frame }) => editedIds.has(frame.id));
|
|
}, [frames, masks]);
|
|
|
|
const formatTime = (seconds: number) => {
|
|
const safeSeconds = Math.max(0, seconds);
|
|
const minutes = Math.floor(safeSeconds / 60);
|
|
const wholeSeconds = Math.floor(safeSeconds % 60);
|
|
const centiseconds = Math.floor((safeSeconds % 1) * 100);
|
|
return `${minutes.toString().padStart(2, '0')}:${wholeSeconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!isPlaying || totalFrames <= 1) return;
|
|
|
|
const timer = window.setTimeout(() => {
|
|
if (currentFrameIndex >= totalFrames - 1) {
|
|
setIsPlaying(false);
|
|
return;
|
|
}
|
|
|
|
setCurrentFrame(currentFrameIndex + 1);
|
|
}, 1000 / playbackFps);
|
|
|
|
return () => window.clearTimeout(timer);
|
|
}, [currentFrameIndex, isPlaying, playbackFps, setCurrentFrame, totalFrames]);
|
|
|
|
useEffect(() => {
|
|
if (totalFrames === 0) {
|
|
setIsPlaying(false);
|
|
}
|
|
}, [totalFrames]);
|
|
|
|
useEffect(() => {
|
|
const isEditableTarget = (target: EventTarget | null) => {
|
|
if (!(target instanceof HTMLElement)) return false;
|
|
const tagName = target.tagName.toLowerCase();
|
|
return target.isContentEditable || ['input', 'textarea', 'select'].includes(tagName);
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (isEditableTarget(event.target) || totalFrames <= 1) return;
|
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
|
|
|
event.preventDefault();
|
|
setIsPlaying(false);
|
|
const direction = event.key === 'ArrowRight' ? 1 : -1;
|
|
const nextIndex = Math.min(Math.max(currentFrameIndex + direction, 0), totalFrames - 1);
|
|
if (nextIndex !== currentFrameIndex) {
|
|
setCurrentFrame(nextIndex);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [currentFrameIndex, setCurrentFrame, totalFrames]);
|
|
|
|
// show frames around current frame
|
|
const frameWindow = 20;
|
|
const displayIndices = totalFrames > 0
|
|
? Array.from({ length: 41 }, (_, i) => currentFrameIndex - frameWindow + i)
|
|
: [];
|
|
|
|
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="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
|
|
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"
|
|
disabled={totalFrames === 0}
|
|
/>
|
|
<div className="h-1 bg-white/10 w-full relative group-hover:h-2 transition-all">
|
|
<div
|
|
className="h-full bg-cyan-500 absolute left-0"
|
|
style={{ width: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
|
|
/>
|
|
{editedFrameMarkers.map(({ frame, index }) => {
|
|
const left = totalFrames > 0 ? ((index + 1) / totalFrames) * 100 : 0;
|
|
return (
|
|
<button
|
|
key={frame.id}
|
|
type="button"
|
|
aria-label={`跳转到已编辑帧 ${index + 1}`}
|
|
title={`已编辑帧 ${index + 1}`}
|
|
onClick={() => setCurrentFrame(index)}
|
|
className={cn(
|
|
"absolute left-0 top-1/2 z-30 w-3 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-sm transition-all",
|
|
"before:absolute before:left-1/2 before:top-1/2 before:w-px before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:content-['']",
|
|
"before:h-5 before:bg-amber-300 before:shadow-[0_0_8px_rgba(251,191,36,0.5)] hover:before:h-7 hover:before:bg-amber-100"
|
|
)}
|
|
style={{ left: `${left}%` }}
|
|
/>
|
|
);
|
|
})}
|
|
<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}%` }}
|
|
>
|
|
{formatTime(currentSeconds)}
|
|
</div>
|
|
</div>
|
|
<div className="absolute bottom-0 right-3 text-[9px] font-mono text-gray-500 pointer-events-none">
|
|
已编辑 {editedFrameMarkers.length} 帧
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 flex items-center px-4 gap-6">
|
|
<div className="flex flex-col items-center gap-2 px-4 border-r border-white/10 shrink-0">
|
|
<button
|
|
className="p-2 rounded-full bg-white/5 text-white hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
disabled={totalFrames <= 1}
|
|
onClick={() => {
|
|
if (currentFrameIndex >= totalFrames - 1) {
|
|
setCurrentFrame(0);
|
|
}
|
|
setIsPlaying(!isPlaying);
|
|
}}
|
|
>
|
|
{isPlaying ? <Pause size={20} fill="currentColor" /> : <Play size={20} fill="currentColor" />}
|
|
</button>
|
|
<span className="text-[10px] font-mono text-gray-500">{isPlaying ? '暂停 (SPACE)' : '播放序列 (F5)'}</span>
|
|
</div>
|
|
|
|
<div
|
|
className="flex-1 flex gap-px flex-nowrap items-center overflow-hidden justify-center"
|
|
style={{ WebkitMaskImage: 'linear-gradient(to right, transparent, black 10%, black 90%, transparent)' }}
|
|
>
|
|
{totalFrames === 0 ? (
|
|
<div className="text-xs text-gray-600 font-mono">暂无帧数据</div>
|
|
) : (
|
|
displayIndices.map((idx) => {
|
|
if (idx < 0 || idx >= totalFrames) {
|
|
return <div key={`empty-${idx}`} className="w-16 h-12 opacity-0 shrink-0" />
|
|
}
|
|
const frame = frames[idx];
|
|
const isCurrent = idx === currentFrameIndex;
|
|
return (
|
|
<div
|
|
key={frame.id}
|
|
onClick={() => setCurrentFrame(idx)}
|
|
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"
|
|
)}
|
|
>
|
|
{frame.url ? (
|
|
<img
|
|
src={frame.url}
|
|
alt={`frame-${idx}`}
|
|
className="absolute inset-0 w-full h-full object-cover"
|
|
loading="lazy"
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 bg-gradient-to-br from-gray-800 to-gray-900 opacity-20" />
|
|
)}
|
|
<span className={cn("text-[9px] font-mono text-white text-center z-10 drop-shadow", isCurrent ? "text-[10px] font-bold" : "")}>
|
|
{(idx + 1).toString().padStart(4, '0')}
|
|
</span>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
<div className="w-48 text-right shrink-0">
|
|
<div className="text-2xl font-mono text-white">{currentFrame}<span className="text-xs text-gray-500"> / {totalFrames}</span></div>
|
|
<div className="text-xs font-mono text-cyan-300 mt-1">
|
|
{formatTime(currentSeconds)} <span className="text-gray-600">/</span> {formatTime(totalSeconds)}
|
|
</div>
|
|
<div className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">底层时序视频图层截帧导航轴</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|