695 lines
23 KiB
TypeScript
695 lines
23 KiB
TypeScript
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<void>;
|
|
};
|
|
|
|
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<void> {
|
|
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<void> {
|
|
return new Promise((resolve) => {
|
|
window.requestAnimationFrame(() => {
|
|
window.requestAnimationFrame(() => resolve());
|
|
});
|
|
});
|
|
}
|
|
|
|
async function seekVideo(video: HTMLVideoElement, targetSecond: number): Promise<void> {
|
|
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<FrameItem> {
|
|
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<VideoTrimPickerProps> = ({
|
|
open,
|
|
videoUrl,
|
|
title = '手动拆镜',
|
|
loading = false,
|
|
minDuration = MIN_TRIM_SECONDS,
|
|
maxDuration = MAX_TRIM_SECONDS,
|
|
onCancel,
|
|
onConfirm,
|
|
}) => {
|
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
const abortRef = useRef(false);
|
|
const rangeRef = useRef<[number, number]>([0, minDuration]);
|
|
const currentTimeRef = useRef(0);
|
|
const frameContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [duration, setDuration] = useState(0);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [range, setRangeState] = useState<[number, number]>([0, minDuration]);
|
|
const [frames, setFrames] = useState<FrameItem[]>([]);
|
|
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 (
|
|
<Modal
|
|
title={title}
|
|
open={open}
|
|
onCancel={onCancel}
|
|
width={Math.min(window.innerWidth - 40, 1400)}
|
|
destroyOnHidden
|
|
footer={[
|
|
<Button key="cancel" onClick={onCancel} disabled={loading} style={{ borderRadius: 8, border: '1px solid rgba(99, 102, 241, 0.2)', color: '#6366f1' }}>
|
|
取消
|
|
</Button>,
|
|
<Button key="confirm" type="primary" onClick={handleConfirm} loading={loading} disabled={loading || disabledByDuration || !integerDuration} style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}>
|
|
确认拆镜
|
|
</Button>,
|
|
]}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
|
<style>{`
|
|
.ant-slider-handle::after {
|
|
height: 80px !important;
|
|
width: 10px !important;
|
|
border-radius: 10px !important;
|
|
top: 95% !important;
|
|
transform: translateY(-50%) !important;
|
|
}
|
|
.ant-slider-handle {
|
|
height: 80px !important;
|
|
margin-top: -37px !important;
|
|
}
|
|
`}</style>
|
|
<div style={{ display: 'flex', justifyContent: 'center', background: '#fff', borderRadius: 16, overflow: 'hidden' }}>
|
|
<video
|
|
ref={videoRef}
|
|
src={videoUrl}
|
|
crossOrigin="anonymous"
|
|
preload="metadata"
|
|
playsInline
|
|
onLoadedMetadata={(event) => {
|
|
const realDuration = Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0;
|
|
if (!realDuration) return;
|
|
const nextIntegerDuration = Math.max(0, Math.floor(realDuration));
|
|
setDuration((prev) => (Math.floor(prev || 0) === nextIntegerDuration ? prev : realDuration));
|
|
setRange((prev) => {
|
|
if (prev[1] > minDuration || prev[0] !== 0) return prev;
|
|
return buildInitialRange(nextIntegerDuration, minDuration, maxDuration);
|
|
});
|
|
}}
|
|
onTimeUpdate={handleTimeUpdate}
|
|
onPause={() => setPlaying(false)}
|
|
style={{ maxHeight: 320, objectFit: 'contain', background: '#111' }}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
borderRadius: 20,
|
|
background: '#f0f5ff',
|
|
padding: '20px 24px',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16, marginBottom: 18 }}>
|
|
<Button
|
|
type="text"
|
|
icon={playing ? <PauseCircleFilled /> : <PlayCircleFilled />}
|
|
onClick={handlePlaySelected}
|
|
disabled={!integerDuration || disabledByDuration}
|
|
style={{ fontSize: 20, color: '#1e293b', padding: 0 }}
|
|
/>
|
|
<span style={{ fontSize: 18, color: '#1e293b', fontFamily: 'monospace' }}>
|
|
{formatTime(currentTime)} / {formatTime(integerDuration)}
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
ref={frameContainerRef}
|
|
style={{
|
|
overflowX: 'auto',
|
|
overflowY: 'hidden',
|
|
scrollbarWidth: 'none',
|
|
msOverflowStyle: 'none',
|
|
padding: '0 6px',
|
|
}}
|
|
>
|
|
<style>{`
|
|
div::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
`}</style>
|
|
<div
|
|
style={{
|
|
margin: 'auto',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
width: frames.length > 0 ? `${frames.length * 80 + Math.max(0, frames.length - 1) * 1}px` : '100%',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
height: 86,
|
|
borderRadius: 12,
|
|
background: '#fff',
|
|
gap: 1,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{frameLoading && frames.length === 0 ? (
|
|
<div style={{ flex: '1 1 auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<Spin size="small" />
|
|
<Text style={{ marginLeft: 8, color: '#64748b' }}>正在抽取每秒帧...</Text>
|
|
</div>
|
|
) : frames.length > 0 ? (
|
|
frames.map((frame) => {
|
|
const isSelected = frame.second >= range[0] && frame.second < range[1];
|
|
return (
|
|
<button
|
|
key={frame.second}
|
|
type="button"
|
|
onClick={() => handleFrameClick(frame.second)}
|
|
title={`${frame.second}s${frame.fallback ? `,实际取帧 ${frame.captureSecond?.toFixed(1)}s` : ''}`}
|
|
style={{
|
|
minWidth: 80,
|
|
flex: '0 0 80px',
|
|
height: 86,
|
|
borderTop: isSelected ? '5px solid #6366f1' : 'none',
|
|
borderBottom: isSelected ? '5px solid #6366f1' : 'none',
|
|
padding: 0,
|
|
background: 'transparent',
|
|
overflow: 'hidden',
|
|
cursor: disabledByDuration ? 'not-allowed' : 'pointer',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{frame.status === 'success' && frame.image ? (
|
|
<img src={frame.image} alt={`${frame.second}s`} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
|
) : frame.status === 'loading' ? (
|
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<Spin size="small" />
|
|
</div>
|
|
) : (
|
|
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#64748b', fontSize: 12, background: '#fff' }}>
|
|
<span>{frame.second}s</span>
|
|
<span style={{ fontSize: 11 }}>无有效帧</span>
|
|
</div>
|
|
)}
|
|
<span
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
bottom: 3,
|
|
color: '#ffffffff',
|
|
fontSize: 16,
|
|
textShadow: '0 1px 3px rgba(0,0,0,.7)',
|
|
}}
|
|
>
|
|
{frame.second}s
|
|
</span>
|
|
</button>
|
|
);
|
|
})
|
|
) : (
|
|
<div style={{ flex: '1 1 auto', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748b' }}>
|
|
暂无帧预览,请确认视频是否加载完成
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: -10,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
}}
|
|
>
|
|
<Slider
|
|
range
|
|
min={0}
|
|
max={integerDuration || maxDuration}
|
|
step={1}
|
|
value={range}
|
|
onChange={handleRangeChange}
|
|
onChangeComplete={handleRangeChangeComplete}
|
|
tooltip={{ formatter: (value) => `${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%',
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ marginTop: 16, textAlign: 'center', color: '#64748b', fontSize: 14 }}>
|
|
已选取 {selectedDuration}s
|
|
</div>
|
|
|
|
{localError && (
|
|
<div style={{ marginTop: 12, padding: '10px 14px', borderRadius: 8, background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.15)', color: '#ef4444', fontSize: 13, textAlign: 'center' }}>
|
|
{localError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default VideoTrimPicker;
|