import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button, Modal, Slider, Spin, Typography } from 'antd'; import { PauseCircleFilled, PlayCircleFilled } from '@ant-design/icons'; const { Text } = Typography; const MIN_TRIM_SECONDS = 2; const MAX_TRIM_SECONDS = 15; const DEFAULT_TRIM_SECONDS = 7; const FRAME_WIDTH = 160; const FRAME_HEIGHT = 90; type VideoTrimPickerProps = { open: boolean; videoUrl: string; title?: string; loading?: boolean; minDuration?: number; maxDuration?: number; onCancel: () => void; onConfirm: (range: { startSecond: number; endSecond: number; durationSecond: number }) => void | Promise; }; type FrameItem = { second: number; captureSecond?: number; image: string; status: 'loading' | 'success' | 'failed'; fallback?: boolean; }; function pad2(value: number): string { return String(value).padStart(2, '0'); } function toIntegerSecond(value: number): number { if (!Number.isFinite(value)) return 0; return Math.max(0, Math.round(value)); } function formatTime(secondValue: number): string { const total = toIntegerSecond(secondValue); const minutes = Math.floor(total / 60); const seconds = total % 60; return `${pad2(minutes)}:${pad2(seconds)}`; } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } function sameRange(a: [number, number], b: [number, number]): boolean { return a[0] === b[0] && a[1] === b[1]; } function waitForEvent(target: EventTarget, eventName: string, timeout = 8000): Promise { return new Promise((resolve, reject) => { let timer: number | undefined; const cleanup = () => { if (timer) window.clearTimeout(timer); target.removeEventListener(eventName, onOk); target.removeEventListener('error', onError); }; const onOk = () => { cleanup(); resolve(); }; const onError = () => { cleanup(); reject(new Error(`视频${eventName}失败`)); }; target.addEventListener(eventName, onOk, { once: true }); target.addEventListener('error', onError, { once: true }); timer = window.setTimeout(() => { cleanup(); reject(new Error(`视频${eventName}超时`)); }, timeout); }); } function waitNextFrame(): Promise { return new Promise((resolve) => { window.requestAnimationFrame(() => { window.requestAnimationFrame(() => resolve()); }); }); } async function seekVideo(video: HTMLVideoElement, targetSecond: number): Promise { const safeTarget = Math.max(0, targetSecond); if (Math.abs(video.currentTime - safeTarget) > 0.01) { const seeked = waitForEvent(video, 'seeked'); video.currentTime = safeTarget; await seeked; } await waitNextFrame(); } function isMostlyBlackFrame(ctx: CanvasRenderingContext2D, width: number, height: number): boolean { let data: Uint8ClampedArray; try { data = ctx.getImageData(0, 0, width, height).data; } catch { return false; } let sampled = 0; let dark = 0; const pixelStride = 8; for (let y = 0; y < height; y += pixelStride) { for (let x = 0; x < width; x += pixelStride) { const index = (y * width + x) * 4; const r = data[index]; const g = data[index + 1]; const b = data[index + 2]; const a = data[index + 3]; if (a < 20) continue; const luma = 0.299 * r + 0.587 * g + 0.114 * b; sampled += 1; if (luma < 18) dark += 1; } } return sampled > 0 && dark / sampled >= 0.85; } async function captureOneFrame( video: HTMLVideoElement, ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, displaySecond: number, realDuration: number, ): Promise { const lastSafeSecond = Math.max(0, realDuration - 0.05); const candidates = Array.from( new Set( [displaySecond, displaySecond + 0.2, displaySecond + 0.5, displaySecond + 1, displaySecond + 2] .map((value) => Math.min(value, lastSafeSecond)) .filter((value) => value >= 0 && value <= lastSafeSecond), ), ); for (const captureSecond of candidates) { try { await seekVideo(video, captureSecond); ctx.clearRect(0, 0, FRAME_WIDTH, FRAME_HEIGHT); ctx.drawImage(video, 0, 0, FRAME_WIDTH, FRAME_HEIGHT); if (isMostlyBlackFrame(ctx, FRAME_WIDTH, FRAME_HEIGHT)) { continue; } return { second: displaySecond, captureSecond, image: canvas.toDataURL('image/jpeg', 0.76), status: 'success', fallback: Math.abs(captureSecond - displaySecond) > 0.01, }; } catch { // 当前候选时间失败时继续尝试后面的候选时间。 } } return { second: displaySecond, image: '', status: 'failed', }; } function normalizeRange( rawRange: [number, number], prevRange: [number, number], integerDuration: number, minDuration: number, maxDuration: number, ): [number, number] { if (!integerDuration || integerDuration <= 0) { return [0, 0]; } const maxSelectableDuration = Math.min(maxDuration, integerDuration); const minSelectableDuration = Math.min(minDuration, maxSelectableDuration); let [start, end] = rawRange; start = toIntegerSecond(clamp(start, 0, integerDuration)); end = toIntegerSecond(clamp(end, 0, integerDuration)); if (end < start) { [start, end] = [end, start]; } const movedStart = Math.abs(start - prevRange[0]) >= Math.abs(end - prevRange[1]); let selectedDuration = end - start; if (selectedDuration < minSelectableDuration) { if (movedStart) { start = toIntegerSecond(clamp(end - minSelectableDuration, 0, Math.max(0, integerDuration - minSelectableDuration))); end = start + minSelectableDuration; } else { end = toIntegerSecond(clamp(start + minSelectableDuration, minSelectableDuration, integerDuration)); start = end - minSelectableDuration; } } selectedDuration = end - start; if (selectedDuration > maxSelectableDuration) { if (movedStart) { start = toIntegerSecond(clamp(end - maxSelectableDuration, 0, Math.max(0, integerDuration - maxSelectableDuration))); } else { end = toIntegerSecond(clamp(start + maxSelectableDuration, maxSelectableDuration, integerDuration)); } } start = toIntegerSecond(clamp(start, 0, integerDuration)); end = toIntegerSecond(clamp(end, start, integerDuration)); return [start, end]; } function buildInitialRange(integerDuration: number, minDuration: number, maxDuration: number): [number, number] { if (!integerDuration || integerDuration <= 0) return [0, minDuration]; const initialEnd = Math.min(integerDuration, Math.max(minDuration, Math.min(DEFAULT_TRIM_SECONDS, maxDuration))); return [0, initialEnd]; } const VideoTrimPicker: React.FC = ({ open, videoUrl, title = '手动拆镜', loading = false, minDuration = MIN_TRIM_SECONDS, maxDuration = MAX_TRIM_SECONDS, onCancel, onConfirm, }) => { const videoRef = useRef(null); const abortRef = useRef(false); const rangeRef = useRef<[number, number]>([0, minDuration]); const currentTimeRef = useRef(0); const frameContainerRef = useRef(null); const [duration, setDuration] = useState(0); const [currentTime, setCurrentTime] = useState(0); const [range, setRangeState] = useState<[number, number]>([0, minDuration]); const [frames, setFrames] = useState([]); const [frameLoading, setFrameLoading] = useState(false); const [playing, setPlaying] = useState(false); const [localError, setLocalError] = useState(''); const integerDuration = useMemo(() => Math.max(0, Math.floor(duration || 0)), [duration]); const selectedDuration = useMemo(() => Math.max(0, range[1] - range[0]), [range]); const disabledByDuration = integerDuration > 0 && integerDuration < minDuration; const setRange = useCallback((next: [number, number] | ((prev: [number, number]) => [number, number])) => { setRangeState((prev) => { const resolved = typeof next === 'function' ? next(prev) : next; if (sameRange(prev, resolved)) return prev; rangeRef.current = resolved; return resolved; }); }, []); const setDisplayedTime = useCallback((second: number) => { const next = toIntegerSecond(second); if (currentTimeRef.current === next) return; currentTimeRef.current = next; setCurrentTime(next); }, []); const seekPreview = useCallback((second: number) => { const video = videoRef.current; if (!video || !integerDuration) return; const next = toIntegerSecond(clamp(second, 0, integerDuration)); try { video.currentTime = next; } catch { // ignore seek error } setDisplayedTime(next); }, [integerDuration, setDisplayedTime]); useEffect(() => { if (!open) { abortRef.current = true; setPlaying(false); setFrames([]); setFrameLoading(false); setDuration(0); setDisplayedTime(0); setLocalError(''); setRange([0, minDuration]); if (videoRef.current) { videoRef.current.pause(); } return; } abortRef.current = false; setPlaying(false); setFrameLoading(true); setFrames([]); setDuration(0); setDisplayedTime(0); setLocalError(''); setRange([0, minDuration]); const extractor = document.createElement('video'); extractor.crossOrigin = 'anonymous'; extractor.muted = true; extractor.playsInline = true; extractor.preload = 'auto'; extractor.src = videoUrl; const buildFrames = async () => { try { await waitForEvent(extractor, 'loadedmetadata'); await waitForEvent(extractor, 'loadeddata').catch(() => undefined); if (abortRef.current) return; const realDuration = Number.isFinite(extractor.duration) ? extractor.duration : 0; const nextIntegerDuration = Math.max(0, Math.floor(realDuration)); setDuration(realDuration); setRange(buildInitialRange(nextIntegerDuration, minDuration, maxDuration)); if (!realDuration || nextIntegerDuration <= 0) { setLocalError('视频时长异常,无法抽取帧'); return; } const canvas = document.createElement('canvas'); canvas.width = FRAME_WIDTH; canvas.height = FRAME_HEIGHT; const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) { setLocalError('当前浏览器不支持 Canvas 抽帧'); return; } const initialFrames: FrameItem[] = Array.from({ length: nextIntegerDuration }, (_, index) => ({ second: index, image: '', status: 'loading', })); setFrames(initialFrames); const collected = [...initialFrames]; for (let second = 0; second < nextIntegerDuration; second += 1) { if (abortRef.current) return; const frame = await captureOneFrame(extractor, ctx, canvas, second, realDuration); if (abortRef.current) return; collected[second] = frame; setFrames([...collected]); } } catch (error: any) { if (!abortRef.current) { setLocalError(error?.message || '视频帧抽取失败,请确认视频资源允许跨域访问'); } } finally { if (!abortRef.current) { setFrameLoading(false); } } }; buildFrames(); return () => { abortRef.current = true; extractor.pause(); extractor.removeAttribute('src'); extractor.load(); }; }, [open, videoUrl, minDuration, maxDuration, setDisplayedTime, setRange]); const handleRangeChange = useCallback((value: number[]) => { if (!integerDuration) return; setLocalError(''); const nextRange = [Number(value[0]), Number(value[1])] as [number, number]; const clamped = [ Math.max(0, Math.min(integerDuration, nextRange[0])), Math.max(nextRange[0], Math.min(integerDuration, nextRange[1])), ] as [number, number]; setRange(clamped); }, [integerDuration, setRange]); const handleRangeChangeComplete = useCallback(() => { seekPreview(rangeRef.current[0]); }, [seekPreview]); const handleFrameClick = useCallback((second: number) => { if (!integerDuration || disabledByDuration) return; const keepDuration = clamp(selectedDuration || Math.min(DEFAULT_TRIM_SECONDS, maxDuration), minDuration, Math.min(maxDuration, integerDuration)); let start = clamp(second, 0, Math.max(0, integerDuration - keepDuration)); start = toIntegerSecond(start); const next: [number, number] = [start, start + keepDuration]; setRange(next); seekPreview(next[0]); }, [disabledByDuration, integerDuration, maxDuration, minDuration, seekPreview, selectedDuration, setRange]); const handlePlaySelected = async () => { const video = videoRef.current; if (!video || !integerDuration) return; if (playing) { video.pause(); setPlaying(false); return; } if (video.currentTime < range[0] || video.currentTime >= range[1]) { video.currentTime = range[0]; setDisplayedTime(range[0]); } try { await video.play(); setPlaying(true); setLocalError(''); } catch { setLocalError('视频播放失败,请检查视频地址'); } }; const handleTimeUpdate = () => { const video = videoRef.current; if (!video) return; const current = toIntegerSecond(video.currentTime || 0); setDisplayedTime(current); if (playing && video.currentTime >= rangeRef.current[1]) { video.pause(); video.currentTime = rangeRef.current[1]; setDisplayedTime(rangeRef.current[1]); setPlaying(false); } }; const handleConfirm = async () => { if (!integerDuration || integerDuration <= 0) { setLocalError('请等待视频加载完成'); return; } if (disabledByDuration) { setLocalError(`视频总时长不足 ${minDuration} 秒,无法手动拆镜`); return; } if (selectedDuration < minDuration) { setLocalError(`拆镜片段不能低于 ${minDuration} 秒`); return; } if (selectedDuration > maxDuration) { setLocalError(`拆镜片段不能超过 ${maxDuration} 秒`); return; } setLocalError(''); await onConfirm({ startSecond: range[0], endSecond: range[1], durationSecond: selectedDuration, }); }; return ( 取消 , , ]} >
0 ? `${frames.length * 80 + Math.max(0, frames.length - 1) * 1}px` : '100%', }} >
{frameLoading && frames.length === 0 ? (
正在抽取每秒帧...
) : frames.length > 0 ? ( frames.map((frame) => { const isSelected = frame.second >= range[0] && frame.second < range[1]; return ( ); }) ) : (
暂无帧预览,请确认视频是否加载完成
)}
`${toIntegerSecond(Number(value || 0))}s` }} disabled={!integerDuration || disabledByDuration} styles={{ track: { background: '#6969dd63', height: 86, borderRadius: 12, margin: '0 ', }, rail: { height: 86, borderRadius: 12, }, handle: { width: 20, height: 86, marginTop: 0, borderRadius: '50%', }, }} />
已选取 {selectedDuration}s
{localError && (
{localError}
)}
); }; export default VideoTrimPicker;