From d8417bdab474a7da2812e8215a407eb2a2d80a3b Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 3 Feb 2026 10:01:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E6=8A=BD=E5=B8=A7?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=A2=9E=E5=8A=A0=E7=89=87=E6=AE=B5=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(dashboard)/tools/video-frames/page.tsx | 286 ++++++++++++++++-- src/app/layout.tsx | 41 ++- src/components/ui/slider.tsx | 37 ++- src/lib/frame-extractor.ts | 70 +++-- src/locales/en.json | 64 ++-- src/locales/zh.json | 66 ++-- 6 files changed, 416 insertions(+), 148 deletions(-) diff --git a/src/app/(dashboard)/tools/video-frames/page.tsx b/src/app/(dashboard)/tools/video-frames/page.tsx index d551a4a..c937d9d 100644 --- a/src/app/(dashboard)/tools/video-frames/page.tsx +++ b/src/app/(dashboard)/tools/video-frames/page.tsx @@ -26,6 +26,7 @@ import { Eye, } from 'lucide-react'; import { FramePreviewPlayer } from '@/components/frame-preview-player'; +import { Slider } from '@/components/ui/slider'; import { ExtractedFrame, ExtractionSettings, @@ -37,8 +38,6 @@ import { formatFileSize, formatDuration, SUPPORTED_VIDEO_FORMATS, - MAX_DURATION, - MAX_FILE_SIZE, MIN_FRAME_RATE, MAX_FRAME_RATE, } from '@/lib/frame-extractor'; @@ -52,6 +51,13 @@ export default function VideoToFramesPage() { const [metadata, setMetadata] = useState(null); const videoRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); + const [isScrubbing, setIsScrubbing] = useState(false); + const wasPlayingRef = useRef(false); + + // 片段选择与预览 + const [clipRange, setClipRange] = useState<[number, number]>([0, 0]); + const [videoCurrentTime, setVideoCurrentTime] = useState(0); + const lastClipRangeRef = useRef<[number, number]>([0, 0]); // 设置相关状态 const [frameRate, setFrameRate] = useState(15); @@ -75,8 +81,13 @@ export default function VideoToFramesPage() { // 派生状态 const estimatedFrames = useMemo(() => { if (!metadata) return 0; - return calculateEstimatedFrames(metadata.duration, frameRate); - }, [metadata, frameRate]); + return calculateEstimatedFrames(metadata.duration, frameRate, clipRange[0], clipRange[1]); + }, [metadata, frameRate, clipRange]); + + const clipDuration = useMemo(() => { + if (!metadata) return 0; + return Math.max(0, clipRange[1] - clipRange[0]); + }, [metadata, clipRange]); const selectedCount = useMemo(() => { return frames.filter((f) => f.selected).length; @@ -90,6 +101,138 @@ export default function VideoToFramesPage() { }; }, [videoUrl]); + useEffect(() => { + if (!metadata) return; + const range: [number, number] = [0, metadata.duration]; + setClipRange(range); + lastClipRangeRef.current = range; + setVideoCurrentTime(0); + }, [metadata]); + + useEffect(() => { + if (!videoRef.current) return; + const [start, end] = clipRange; + if (videoCurrentTime < start || videoCurrentTime > end) { + videoRef.current.currentTime = start; + setVideoCurrentTime(start); + } + }, [clipRange, videoCurrentTime]); + + const clampTime = useCallback( + (value: number) => { + const duration = metadata?.duration ?? 0; + return Math.min(Math.max(value, 0), duration); + }, + [metadata] + ); + + const handleClipRangeChange = useCallback( + (value: number[]) => { + if (!metadata) return; + const start = clampTime(Math.min(value[0], value[1])); + const end = clampTime(Math.max(value[0], value[1])); + const prev = lastClipRangeRef.current; + let activeTime = videoCurrentTime; + + if (start !== prev[0]) { + activeTime = start; + } else if (end !== prev[1]) { + activeTime = end; + } + + const nextRange: [number, number] = [start, end]; + lastClipRangeRef.current = nextRange; + setClipRange(nextRange); + setVideoCurrentTime(activeTime); + if (videoRef.current) { + videoRef.current.currentTime = activeTime; + } + }, + [clampTime, metadata, videoCurrentTime] + ); + + const handleClipStartChange = useCallback( + (value: number) => { + if (!metadata) return; + const start = clampTime(value); + const end = Math.max(start, clipRange[1]); + const nextRange: [number, number] = [start, end]; + lastClipRangeRef.current = nextRange; + setClipRange(nextRange); + setVideoCurrentTime(start); + if (videoRef.current) { + videoRef.current.currentTime = start; + } + }, + [clipRange, clampTime, metadata] + ); + + const handleClipEndChange = useCallback( + (value: number) => { + if (!metadata) return; + const end = clampTime(value); + const start = Math.min(end, clipRange[0]); + const nextRange: [number, number] = [start, end]; + lastClipRangeRef.current = nextRange; + setClipRange(nextRange); + setVideoCurrentTime(end); + if (videoRef.current) { + videoRef.current.currentTime = end; + } + }, + [clipRange, clampTime, metadata] + ); + + const handleVideoTimeUpdate = useCallback(() => { + const video = videoRef.current; + if (!video || isScrubbing) return; + const [start, end] = clipRange; + + if (isPlaying && video.currentTime >= end) { + video.currentTime = start; + video.play().catch(() => undefined); + } + + setVideoCurrentTime(video.currentTime); + }, [clipRange, isPlaying, isScrubbing]); + + const handleProgressChange = useCallback( + (value: number[]) => { + const video = videoRef.current; + if (!video) return; + + if (!isScrubbing) { + wasPlayingRef.current = !video.paused; + video.pause(); + setIsPlaying(false); + setIsScrubbing(true); + } + + const time = value[0]; + video.currentTime = time; + setVideoCurrentTime(time); + }, + [isScrubbing] + ); + + const handleProgressCommit = useCallback( + (value: number[]) => { + const video = videoRef.current; + if (!video) return; + const time = value[0]; + video.currentTime = time; + setVideoCurrentTime(time); + + if (wasPlayingRef.current) { + video.play().catch(() => undefined); + setIsPlaying(true); + } + + setIsScrubbing(false); + }, + [] + ); + // 处理视频上传 const handleVideoUpload = useCallback( async (file: File) => { @@ -158,7 +301,13 @@ export default function VideoToFramesPage() { // 开始提取帧 const handleExtract = async () => { - if (!videoFile) return; + if (!videoFile || !metadata) return; + + if (clipDuration <= 0) { + setError('请选择有效的视频片段后再提取'); + setStatus('error'); + return; + } setStatus('extracting'); setExtractProgress(0); @@ -168,6 +317,8 @@ export default function VideoToFramesPage() { frameRate, format, quality: jpegQuality, + startTime: clipRange[0], + endTime: clipRange[1], }; try { @@ -253,6 +404,9 @@ export default function VideoToFramesPage() { setError(''); setIsPlaying(false); setShowPreview(false); + setClipRange([0, 0]); + lastClipRangeRef.current = [0, 0]; + setVideoCurrentTime(0); }; return ( @@ -325,7 +479,7 @@ export default function VideoToFramesPage() {

点击或拖拽视频到这里

- 支持 MP4, WebM, MOV 格式(最大 {MAX_FILE_SIZE / 1024 / 1024}MB,{MAX_DURATION} 秒以内) + 支持 MP4, WebM, MOV 格式(不限大小,建议选择片段以提升处理速度)

)} @@ -349,24 +503,52 @@ export default function VideoToFramesPage() { -
-