12 changed files with 208 additions and 54 deletions
@ -0,0 +1,21 @@ |
|||||||
|
export function formatSeconds(secs: number, showHours = false): string { |
||||||
|
if (Number.isNaN(secs)) { |
||||||
|
if (showHours) return "0:00:00"; |
||||||
|
return "0:00"; |
||||||
|
} |
||||||
|
|
||||||
|
let time = secs; |
||||||
|
const seconds = Math.floor(time % 60); |
||||||
|
|
||||||
|
time /= 60; |
||||||
|
const minutes = Math.floor(time % 60); |
||||||
|
|
||||||
|
time /= 60; |
||||||
|
const hours = Math.floor(time); |
||||||
|
|
||||||
|
const paddedSecs = seconds.toString().padStart(2, "0"); |
||||||
|
const paddedMins = minutes.toString().padStart(2, "0"); |
||||||
|
|
||||||
|
if (!showHours) return [paddedMins, paddedSecs].join(":"); |
||||||
|
return [hours, paddedMins, paddedSecs].join(":"); |
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
export interface Thumbnail { |
||||||
|
from: number; |
||||||
|
to: number; |
||||||
|
imgUrl: string; |
||||||
|
} |
||||||
|
export const SCALE_FACTOR = 0.1; |
||||||
|
export default async function* extractThumbnails( |
||||||
|
videoUrl: string, |
||||||
|
numThumbnails: number |
||||||
|
): AsyncGenerator<Thumbnail, Thumbnail> { |
||||||
|
const video = document.createElement("video"); |
||||||
|
video.src = videoUrl; |
||||||
|
video.crossOrigin = "anonymous"; |
||||||
|
|
||||||
|
// Wait for the video metadata to load
|
||||||
|
const metadata = await new Promise((resolve, reject) => { |
||||||
|
video.addEventListener("loadedmetadata", resolve); |
||||||
|
video.addEventListener("error", reject); |
||||||
|
}); |
||||||
|
|
||||||
|
const canvas = document.createElement("canvas"); |
||||||
|
|
||||||
|
canvas.height = video.videoHeight * SCALE_FACTOR; |
||||||
|
canvas.width = video.videoWidth * SCALE_FACTOR; |
||||||
|
const ctx = canvas.getContext("2d"); |
||||||
|
if (!ctx) return { from: 0, to: 0, imgUrl: "" }; |
||||||
|
|
||||||
|
for (let i = 0; i <= numThumbnails; i += 1) { |
||||||
|
const from = (i / (numThumbnails + 1)) * video.duration; |
||||||
|
const to = ((i + 1) / (numThumbnails + 1)) * video.duration; |
||||||
|
|
||||||
|
// Seek to the specified time
|
||||||
|
video.currentTime = from; |
||||||
|
await new Promise((resolve) => { |
||||||
|
video.addEventListener("seeked", resolve); |
||||||
|
}); |
||||||
|
|
||||||
|
// Draw the video frame on the canvas
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height); |
||||||
|
|
||||||
|
// Convert the canvas to a data URL and add it to the list of thumbnails
|
||||||
|
const imgUrl = canvas.toDataURL(); |
||||||
|
|
||||||
|
yield { |
||||||
|
from, |
||||||
|
to, |
||||||
|
imgUrl, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
return { from: 0, to: 0, imgUrl: "" }; |
||||||
|
} |
@ -0,0 +1,62 @@ |
|||||||
|
import { RefObject } from "react"; |
||||||
|
|
||||||
|
import { formatSeconds } from "@/utils/formatSeconds"; |
||||||
|
import { SCALE_FACTOR } from "@/utils/thumbnailCreator"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { VideoProgressEvent } from "@/video/state/logic/progress"; |
||||||
|
import { useSource } from "@/video/state/logic/source"; |
||||||
|
|
||||||
|
export default function ThumbnailAction({ |
||||||
|
parentRef, |
||||||
|
hoverPosition, |
||||||
|
videoTime, |
||||||
|
}: { |
||||||
|
parentRef: RefObject<HTMLDivElement>; |
||||||
|
hoverPosition: number; |
||||||
|
videoTime: VideoProgressEvent; |
||||||
|
}) { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const source = useSource(descriptor); |
||||||
|
if (!parentRef.current) return null; |
||||||
|
const offset = |
||||||
|
(document.getElementsByTagName("video")[0].videoWidth * SCALE_FACTOR) / 2; |
||||||
|
const rect = parentRef.current.getBoundingClientRect(); |
||||||
|
|
||||||
|
const hoverPercent = (hoverPosition - rect.left) / rect.width; |
||||||
|
const hoverTime = videoTime.duration * hoverPercent; |
||||||
|
|
||||||
|
const pos = () => { |
||||||
|
const relativePosition = hoverPosition - rect.left; |
||||||
|
if (relativePosition <= offset) { |
||||||
|
return 0; |
||||||
|
} |
||||||
|
if (relativePosition >= rect.width - offset) { |
||||||
|
return rect.width - offset * 2; |
||||||
|
} |
||||||
|
return relativePosition - offset; |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<img |
||||||
|
style={{ |
||||||
|
left: `${pos()}px`, |
||||||
|
}} |
||||||
|
className="absolute bottom-10 rounded" |
||||||
|
src={ |
||||||
|
source.source?.thumbnails.find( |
||||||
|
(x) => x.from < hoverTime && x.to > hoverTime |
||||||
|
)?.imgUrl |
||||||
|
} |
||||||
|
/> |
||||||
|
<div |
||||||
|
style={{ |
||||||
|
left: `${pos() + offset - 18}px`, |
||||||
|
}} |
||||||
|
className="absolute bottom-3 text-white" |
||||||
|
> |
||||||
|
{formatSeconds(hoverTime)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
Loading…
Reference in new issue