13 changed files with 843 additions and 149 deletions
@ -0,0 +1,478 @@ |
|||
"use client"; |
|||
|
|||
import { Popover } from "antd"; |
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
|||
import { preventVideoDoubleClick } from "@/hooks/usePreventVideoFullscreen"; |
|||
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl"; |
|||
import { |
|||
captureFrameFromUrlAtTime, |
|||
captureFrameFromVideoElement, |
|||
type CapturedVideoFrame, |
|||
} from "@/utils/videoFrameCapture"; |
|||
|
|||
interface CustomVideoPlayerProps { |
|||
src: string; |
|||
poster?: string | null; |
|||
selected?: boolean; |
|||
isRunning?: boolean; |
|||
dimensions?: { width: number; height: number } | null; |
|||
className?: string; |
|||
videoClassName?: string; |
|||
loop?: boolean; |
|||
muted?: boolean; |
|||
captureTitle?: string; |
|||
onLoadedMetadata?: (metadata: { |
|||
duration: number | null; |
|||
dimensions: { width: number; height: number } | null; |
|||
}) => void; |
|||
onCaptureFrame?: (frame: CapturedVideoFrame & { time: number }) => Promise<void> | void; |
|||
onError?: () => void; |
|||
} |
|||
|
|||
type CaptureFrameMode = "first" | "last" | "current"; |
|||
|
|||
function formatTime(seconds: number) { |
|||
if (!Number.isFinite(seconds) || seconds <= 0) return "0:00"; |
|||
const minutes = Math.floor(seconds / 60); |
|||
const remaining = Math.floor(seconds % 60).toString().padStart(2, "0"); |
|||
return `${minutes}:${remaining}`; |
|||
} |
|||
|
|||
export function CustomVideoPlayer({ |
|||
src, |
|||
poster, |
|||
selected = false, |
|||
isRunning = false, |
|||
dimensions: _dimensions, |
|||
className = "", |
|||
videoClassName = "", |
|||
loop = true, |
|||
muted = true, |
|||
captureTitle = "截帧", |
|||
onLoadedMetadata, |
|||
onCaptureFrame, |
|||
onError, |
|||
}: CustomVideoPlayerProps) { |
|||
const videoRef = useRef<HTMLVideoElement | null>(null); |
|||
const playbackUrl = useVideoBlobUrl(src); |
|||
const [duration, setDuration] = useState(0); |
|||
const [currentTime, setCurrentTime] = useState(0); |
|||
const [isHovered, setIsHovered] = useState(false); |
|||
const [isPlaying, setIsPlaying] = useState(false); |
|||
const [isManuallyPaused, setIsManuallyPaused] = useState(false); |
|||
const [isScrubbing, setIsScrubbing] = useState(false); |
|||
const [isCaptureLoading, setIsCaptureLoading] = useState(false); |
|||
const [hasLoadedFrame, setHasLoadedFrame] = useState(false); |
|||
const [volume, setVolume] = useState(muted ? 0 : 1); |
|||
const [isMuted, setIsMuted] = useState(muted); |
|||
|
|||
const shouldShowPoster = Boolean( |
|||
poster && (isRunning || (!isHovered && !selected && !isManuallyPaused && !isScrubbing)) |
|||
); |
|||
const progress = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0; |
|||
|
|||
const pauseVideo = useCallback(() => { |
|||
const video = videoRef.current; |
|||
if (!video) return; |
|||
video.pause(); |
|||
setIsPlaying(false); |
|||
}, []); |
|||
|
|||
const playVideo = useCallback(() => { |
|||
const video = videoRef.current; |
|||
if (!video || isRunning) return; |
|||
video.play() |
|||
.then(() => { |
|||
setHasLoadedFrame(true); |
|||
setIsPlaying(true); |
|||
}) |
|||
.catch(() => { |
|||
setIsPlaying(false); |
|||
}); |
|||
}, [isRunning]); |
|||
|
|||
useEffect(() => { |
|||
setCurrentTime(0); |
|||
setDuration(0); |
|||
setIsPlaying(false); |
|||
setIsManuallyPaused(false); |
|||
setIsScrubbing(false); |
|||
setHasLoadedFrame(false); |
|||
}, [src]); |
|||
|
|||
useEffect(() => { |
|||
const video = videoRef.current; |
|||
if (!video) return; |
|||
video.volume = Math.min(1, Math.max(0, volume)); |
|||
video.muted = isMuted || volume === 0; |
|||
}, [isMuted, volume]); |
|||
|
|||
useEffect(() => { |
|||
const video = videoRef.current; |
|||
if (!video) return; |
|||
|
|||
if (isRunning) { |
|||
video.pause(); |
|||
try { |
|||
video.currentTime = 0; |
|||
} catch { |
|||
// Metadata may not be ready yet.
|
|||
} |
|||
setCurrentTime(0); |
|||
setIsPlaying(false); |
|||
setHasLoadedFrame(false); |
|||
return; |
|||
} |
|||
|
|||
if (isScrubbing || isManuallyPaused) { |
|||
video.pause(); |
|||
setIsPlaying(false); |
|||
return; |
|||
} |
|||
|
|||
if (isHovered || selected) { |
|||
playVideo(); |
|||
return; |
|||
} |
|||
|
|||
video.pause(); |
|||
try { |
|||
video.currentTime = 0; |
|||
} catch { |
|||
// Metadata may not be ready yet.
|
|||
} |
|||
setCurrentTime(0); |
|||
setIsPlaying(false); |
|||
setHasLoadedFrame(false); |
|||
}, [isHovered, isManuallyPaused, isRunning, isScrubbing, playVideo, selected]); |
|||
|
|||
const seekToRatio = useCallback((clientX: number, target: HTMLElement) => { |
|||
const video = videoRef.current; |
|||
if (!video || duration <= 0) return; |
|||
const rect = target.getBoundingClientRect(); |
|||
const ratio = rect.width > 0 ? Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) : 0; |
|||
const nextTime = ratio * duration; |
|||
video.currentTime = nextTime; |
|||
setCurrentTime(nextTime); |
|||
setHasLoadedFrame(true); |
|||
}, [duration]); |
|||
|
|||
const handleProgressPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
event.currentTarget.setPointerCapture(event.pointerId); |
|||
setIsScrubbing(true); |
|||
setIsManuallyPaused(true); |
|||
pauseVideo(); |
|||
seekToRatio(event.clientX, event.currentTarget); |
|||
}, [pauseVideo, seekToRatio]); |
|||
|
|||
const handleProgressPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => { |
|||
if (!isScrubbing) return; |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
seekToRatio(event.clientX, event.currentTarget); |
|||
}, [isScrubbing, seekToRatio]); |
|||
|
|||
const handleProgressPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => { |
|||
if (!isScrubbing) return; |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
seekToRatio(event.clientX, event.currentTarget); |
|||
setIsScrubbing(false); |
|||
setIsManuallyPaused(true); |
|||
pauseVideo(); |
|||
}, [isScrubbing, pauseVideo, seekToRatio]); |
|||
|
|||
const handleTogglePlayback = useCallback((event: React.MouseEvent) => { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
if (isRunning) return; |
|||
if (isPlaying) { |
|||
setIsManuallyPaused(true); |
|||
pauseVideo(); |
|||
return; |
|||
} |
|||
setIsManuallyPaused(false); |
|||
playVideo(); |
|||
}, [isPlaying, isRunning, pauseVideo, playVideo]); |
|||
|
|||
const handleVolumeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
const nextVolume = Number(event.currentTarget.value) / 100; |
|||
setVolume(nextVolume); |
|||
setIsMuted(nextVolume === 0); |
|||
}, []); |
|||
|
|||
const handleCaptureFrame = useCallback(async (mode: CaptureFrameMode, event: React.MouseEvent) => { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
const video = videoRef.current; |
|||
if (!video || !onCaptureFrame || isCaptureLoading) return; |
|||
|
|||
setIsCaptureLoading(true); |
|||
try { |
|||
const videoDuration = Number.isFinite(video.duration) ? video.duration : duration; |
|||
const time = mode === "first" |
|||
? 0.001 |
|||
: mode === "last" |
|||
? Math.max(0, videoDuration - 0.1) |
|||
: video.currentTime; |
|||
let frame: CapturedVideoFrame; |
|||
if (mode === "current") { |
|||
try { |
|||
frame = await captureFrameFromVideoElement(video); |
|||
} catch (error) { |
|||
frame = await captureFrameFromUrlAtTime(src, time); |
|||
} |
|||
} else { |
|||
frame = await captureFrameFromUrlAtTime(src, time); |
|||
} |
|||
await onCaptureFrame({ ...frame, time }); |
|||
} finally { |
|||
setIsCaptureLoading(false); |
|||
} |
|||
}, [duration, isCaptureLoading, onCaptureFrame, src]); |
|||
|
|||
const playPauseLabel = useMemo(() => isPlaying ? "暂停" : "播放", [isPlaying]); |
|||
const volumeLabel = useMemo(() => { |
|||
if (isMuted || volume === 0) return "音量 0%"; |
|||
return `音量 ${Math.round(volume * 100)}%`; |
|||
}, [isMuted, volume]); |
|||
const volumePopoverContent = useMemo(() => ( |
|||
<div |
|||
className="flex flex-col items-center gap-1.5 px-1.5 py-2 text-white" |
|||
onClick={(event) => { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
}} |
|||
> |
|||
<span className="text-[10px] tabular-nums text-white/80">{Math.round(volume * 100)}</span> |
|||
<div className="relative flex h-24 w-5 items-center justify-center"> |
|||
<div |
|||
className="absolute bottom-2 left-1/2 w-1 -translate-x-1/2 rounded-full bg-white" |
|||
style={{ height: `${Math.max(4, Math.round(volume * 80))}px` }} |
|||
/> |
|||
<input |
|||
aria-label="音量" |
|||
type="range" |
|||
min={0} |
|||
max={100} |
|||
value={Math.round(volume * 100)} |
|||
onChange={handleVolumeChange} |
|||
className="relative h-1 w-20 -rotate-90 cursor-pointer opacity-0" |
|||
/> |
|||
</div> |
|||
</div> |
|||
), [handleVolumeChange, volume]); |
|||
const capturePopoverContent = useMemo(() => ( |
|||
<div className="overflow-hidden py-1"> |
|||
{[ |
|||
["first", "截取首帧"], |
|||
["last", "截取尾帧"], |
|||
["current", "截取当前帧"], |
|||
].map(([mode, label]) => ( |
|||
<button |
|||
key={mode} |
|||
type="button" |
|||
onClick={(event) => handleCaptureFrame(mode as CaptureFrameMode, event)} |
|||
disabled={isCaptureLoading || isRunning || !hasLoadedFrame} |
|||
className="block w-full whitespace-nowrap px-3 py-2 text-left text-xs text-white/90 transition-colors hover:bg-white/12 disabled:cursor-not-allowed disabled:opacity-45" |
|||
> |
|||
{label} |
|||
</button> |
|||
))} |
|||
</div> |
|||
), [handleCaptureFrame, hasLoadedFrame, isCaptureLoading, isRunning]); |
|||
|
|||
return ( |
|||
<div |
|||
className={`relative h-full w-full overflow-hidden ${className}`} |
|||
onMouseEnter={() => setIsHovered(true)} |
|||
onMouseLeave={() => setIsHovered(false)} |
|||
onDoubleClickCapture={preventVideoDoubleClick} |
|||
onDoubleClick={preventVideoDoubleClick} |
|||
> |
|||
<video |
|||
ref={videoRef} |
|||
src={playbackUrl ?? undefined} |
|||
poster={poster ?? undefined} |
|||
className={videoClassName} |
|||
preload="auto" |
|||
loop={loop} |
|||
muted={muted} |
|||
playsInline |
|||
disablePictureInPicture |
|||
onDoubleClickCapture={preventVideoDoubleClick} |
|||
onDoubleClick={preventVideoDoubleClick} |
|||
onLoadedMetadata={(event) => { |
|||
const video = event.currentTarget; |
|||
const nextDuration = Number.isFinite(video.duration) ? video.duration : 0; |
|||
setDuration(nextDuration); |
|||
onLoadedMetadata?.({ |
|||
duration: nextDuration || null, |
|||
dimensions: video.videoWidth && video.videoHeight |
|||
? { width: video.videoWidth, height: video.videoHeight } |
|||
: null, |
|||
}); |
|||
}} |
|||
onLoadedData={() => setHasLoadedFrame(true)} |
|||
onTimeUpdate={(event) => { |
|||
if (!isScrubbing) setCurrentTime(event.currentTarget.currentTime); |
|||
}} |
|||
onPlay={() => { |
|||
setHasLoadedFrame(true); |
|||
setIsPlaying(true); |
|||
}} |
|||
onPause={() => setIsPlaying(false)} |
|||
onError={onError} |
|||
/> |
|||
|
|||
{poster && shouldShowPoster && ( |
|||
<img |
|||
src={poster} |
|||
alt="" |
|||
className="pointer-events-none absolute inset-0 h-full w-full object-contain" |
|||
/> |
|||
)} |
|||
|
|||
{isRunning && ( |
|||
<div className="pointer-events-none absolute inset-0 bg-black/20" /> |
|||
)} |
|||
|
|||
{!isRunning && !isPlaying && ( |
|||
<button |
|||
type="button" |
|||
onClick={handleTogglePlayback} |
|||
className="nodrag nopan absolute left-1/2 top-1/2 flex h-12 w-12 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white shadow-lg transition-colors hover:bg-black/70" |
|||
title={playPauseLabel} |
|||
> |
|||
<svg className="ml-0.5 h-5 w-5" viewBox="0 0 24 24" fill="currentColor"> |
|||
<path d="M8 5v14l11-7z" /> |
|||
</svg> |
|||
</button> |
|||
)} |
|||
|
|||
<div className="nodrag nopan nowheel absolute bottom-0 left-0 right-0 flex items-center gap-2 px-3 pb-2 pt-5 text-white"> |
|||
<button |
|||
type="button" |
|||
onClick={handleTogglePlayback} |
|||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 hover:text-white" |
|||
title={playPauseLabel} |
|||
> |
|||
{isPlaying ? ( |
|||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"> |
|||
<path d="M6 5h4v14H6zM14 5h4v14h-4z" /> |
|||
</svg> |
|||
) : ( |
|||
<svg className="ml-0.5 h-4 w-4" viewBox="0 0 24 24" fill="currentColor"> |
|||
<path d="M8 5v14l11-7z" /> |
|||
</svg> |
|||
)} |
|||
</button> |
|||
<span className="w-9 shrink-0 text-[11px] tabular-nums text-white/85">{formatTime(currentTime)}</span> |
|||
<div |
|||
role="slider" |
|||
aria-valuemin={0} |
|||
aria-valuemax={Math.max(0, duration)} |
|||
aria-valuenow={Math.min(currentTime, duration)} |
|||
tabIndex={0} |
|||
className="relative h-5 min-w-0 flex-1 cursor-pointer touch-none" |
|||
onPointerDown={handleProgressPointerDown} |
|||
onPointerMove={handleProgressPointerMove} |
|||
onPointerUp={handleProgressPointerUp} |
|||
onPointerCancel={handleProgressPointerUp} |
|||
> |
|||
<div className="absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-white/30" /> |
|||
<div |
|||
className="absolute left-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-white" |
|||
style={{ width: `${progress}%` }} |
|||
/> |
|||
<div |
|||
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow" |
|||
style={{ left: `${progress}%` }} |
|||
/> |
|||
</div> |
|||
<span className="w-9 shrink-0 text-right text-[11px] tabular-nums text-white/85">{formatTime(duration)}</span> |
|||
<Popover |
|||
trigger="hover" |
|||
placement="top" |
|||
arrow={false} |
|||
color="rgba(0,0,0,0.28)" |
|||
content={volumePopoverContent} |
|||
overlayInnerStyle={{ |
|||
padding: 0, |
|||
borderRadius: 8, |
|||
overflow: "hidden", |
|||
background: "rgba(0,0,0,0.28)", |
|||
boxShadow: "0 8px 22px rgba(0,0,0,0.16)", |
|||
minWidth: 0, |
|||
}} |
|||
> |
|||
<button |
|||
type="button" |
|||
onClick={(event) => { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
}} |
|||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 hover:text-white" |
|||
title={volumeLabel} |
|||
> |
|||
{isMuted || volume === 0 ? ( |
|||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> |
|||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 10v4h4l5 4V6l-5 4H4Z" /> |
|||
<path strokeLinecap="round" strokeLinejoin="round" d="m17 9 4 4m0-4-4 4" /> |
|||
</svg> |
|||
) : ( |
|||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> |
|||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 10v4h4l5 4V6l-5 4H4Z" /> |
|||
<path strokeLinecap="round" strokeLinejoin="round" d="M16 9.5a4 4 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10" /> |
|||
</svg> |
|||
)} |
|||
</button> |
|||
</Popover> |
|||
{onCaptureFrame && ( |
|||
<Popover |
|||
trigger="hover" |
|||
placement="topRight" |
|||
arrow={false} |
|||
color="rgba(0,0,0,0.28)" |
|||
content={capturePopoverContent} |
|||
overlayInnerStyle={{ |
|||
padding: 0, |
|||
borderRadius: 8, |
|||
overflow: "hidden", |
|||
background: "rgba(0,0,0,0.28)", |
|||
boxShadow: "0 8px 22px rgba(0,0,0,0.16)", |
|||
minWidth: 0, |
|||
}} |
|||
> |
|||
<button |
|||
type="button" |
|||
onClick={(event) => { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
}} |
|||
disabled={isCaptureLoading || isRunning || !hasLoadedFrame} |
|||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:opacity-45" |
|||
title={captureTitle} |
|||
> |
|||
{isCaptureLoading ? ( |
|||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"> |
|||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" /> |
|||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> |
|||
</svg> |
|||
) : ( |
|||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}> |
|||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 8.5A2.5 2.5 0 0 1 7.5 6H9l1.2-1.5h3.6L15 6h1.5A2.5 2.5 0 0 1 19 8.5v7A2.5 2.5 0 0 1 16.5 18h-9A2.5 2.5 0 0 1 5 15.5v-7Z" /> |
|||
<circle cx="12" cy="12" r="2.5" /> |
|||
</svg> |
|||
)} |
|||
</button> |
|||
</Popover> |
|||
)} |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,113 @@ |
|||
export interface CapturedVideoFrame { |
|||
blob: Blob; |
|||
dataUrl: string; |
|||
dimensions: { width: number; height: number }; |
|||
} |
|||
|
|||
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> { |
|||
return new Promise((resolve, reject) => { |
|||
canvas.toBlob((blob) => { |
|||
if (blob) { |
|||
resolve(blob); |
|||
return; |
|||
} |
|||
reject(new Error("Frame capture returned no image")); |
|||
}, "image/png"); |
|||
}); |
|||
} |
|||
|
|||
function blobToDataUrl(blob: Blob): Promise<string> { |
|||
return new Promise((resolve, reject) => { |
|||
const reader = new FileReader(); |
|||
reader.onload = () => { |
|||
if (typeof reader.result === "string") { |
|||
resolve(reader.result); |
|||
return; |
|||
} |
|||
reject(new Error("Frame capture could not be encoded")); |
|||
}; |
|||
reader.onerror = () => reject(new Error("Frame capture could not be encoded")); |
|||
reader.readAsDataURL(blob); |
|||
}); |
|||
} |
|||
|
|||
export async function captureFrameFromVideoElement(video: HTMLVideoElement): Promise<CapturedVideoFrame> { |
|||
if (!video.videoWidth || !video.videoHeight) { |
|||
throw new Error("Video frame is not ready"); |
|||
} |
|||
|
|||
const canvas = document.createElement("canvas"); |
|||
canvas.width = video.videoWidth; |
|||
canvas.height = video.videoHeight; |
|||
const context = canvas.getContext("2d"); |
|||
if (!context) { |
|||
throw new Error("Could not get canvas 2d context"); |
|||
} |
|||
|
|||
context.drawImage(video, 0, 0, canvas.width, canvas.height); |
|||
const blob = await canvasToBlob(canvas); |
|||
const dataUrl = await blobToDataUrl(blob); |
|||
return { |
|||
blob, |
|||
dataUrl, |
|||
dimensions: { width: canvas.width, height: canvas.height }, |
|||
}; |
|||
} |
|||
|
|||
function getFetchableMediaUrl(url: string): string { |
|||
if (url.startsWith("http://") || url.startsWith("https://")) { |
|||
return `/api/proxy-media?url=${encodeURIComponent(url)}`; |
|||
} |
|||
return url; |
|||
} |
|||
|
|||
function waitForVideoEvent(video: HTMLVideoElement, eventName: "loadedmetadata" | "seeked"): Promise<void> { |
|||
return new Promise((resolve, reject) => { |
|||
const timeoutId = window.setTimeout(() => { |
|||
cleanup(); |
|||
reject(new Error("Frame capture timed out")); |
|||
}, 30_000); |
|||
|
|||
const cleanup = () => { |
|||
window.clearTimeout(timeoutId); |
|||
video.removeEventListener(eventName, handleEvent); |
|||
video.removeEventListener("error", handleError); |
|||
}; |
|||
const handleEvent = () => { |
|||
cleanup(); |
|||
resolve(); |
|||
}; |
|||
const handleError = () => { |
|||
cleanup(); |
|||
reject(new Error("Failed to load video for frame capture")); |
|||
}; |
|||
|
|||
video.addEventListener(eventName, handleEvent); |
|||
video.addEventListener("error", handleError); |
|||
}); |
|||
} |
|||
|
|||
export async function captureFrameFromUrlAtTime(src: string, time: number): Promise<CapturedVideoFrame> { |
|||
const response = await fetch(getFetchableMediaUrl(src)); |
|||
if (!response.ok) { |
|||
throw new Error(`Frame capture video fetch failed: HTTP ${response.status}`); |
|||
} |
|||
|
|||
const blob = await response.blob(); |
|||
const blobUrl = URL.createObjectURL(blob); |
|||
const video = document.createElement("video"); |
|||
video.preload = "auto"; |
|||
video.muted = true; |
|||
video.playsInline = true; |
|||
|
|||
try { |
|||
video.src = blobUrl; |
|||
await waitForVideoEvent(video, "loadedmetadata"); |
|||
const duration = Number.isFinite(video.duration) ? video.duration : 0; |
|||
video.currentTime = Math.min(Math.max(0, time), Math.max(0, duration - 0.001)); |
|||
await waitForVideoEvent(video, "seeked"); |
|||
return captureFrameFromVideoElement(video); |
|||
} finally { |
|||
URL.revokeObjectURL(blobUrl); |
|||
} |
|||
} |
|||
@ -0,0 +1,52 @@ |
|||
import type { NodeType, SmartImageNodeData, WorkflowNode } from "@/types"; |
|||
import type { CapturedVideoFrame } from "@/utils/videoFrameCapture"; |
|||
|
|||
type AddNode = ( |
|||
type: NodeType, |
|||
position: { x: number; y: number }, |
|||
initialData?: Partial<SmartImageNodeData> |
|||
) => string; |
|||
|
|||
function getNodeWidth(node: WorkflowNode): number { |
|||
if (typeof node.width === "number") return node.width; |
|||
if (typeof node.style?.width === "number") return node.style.width; |
|||
return 300; |
|||
} |
|||
|
|||
export function createVideoFrameSmartImageNode({ |
|||
sourceNode, |
|||
frame, |
|||
addNode, |
|||
selectSingleNode, |
|||
regenerateNode, |
|||
}: { |
|||
sourceNode: WorkflowNode; |
|||
frame: Pick<CapturedVideoFrame, "dataUrl" | "dimensions">; |
|||
addNode: AddNode; |
|||
selectSingleNode: (nodeId: string) => void; |
|||
regenerateNode: (nodeId: string) => Promise<void>; |
|||
}): string { |
|||
const sourcePosition = sourceNode.position ?? { x: 0, y: 0 }; |
|||
const nodeId = addNode("smartImage", { |
|||
x: sourcePosition.x + getNodeWidth(sourceNode) + 220, |
|||
y: sourcePosition.y, |
|||
}, { |
|||
image: frame.dataUrl, |
|||
imageRef: undefined, |
|||
assetId: undefined, |
|||
previewImage: frame.dataUrl, |
|||
filename: `video-frame-${Date.now()}.png`, |
|||
dimensions: frame.dimensions, |
|||
customTitle: "视频截帧", |
|||
derivedImage: { |
|||
sourceNodeId: sourceNode.id, |
|||
operation: "videoFrame", |
|||
}, |
|||
status: "idle", |
|||
error: null, |
|||
}); |
|||
|
|||
selectSingleNode(nodeId); |
|||
void regenerateNode(nodeId); |
|||
return nodeId; |
|||
} |
|||
Loading…
Reference in new issue