12 changed files with 208 additions and 54 deletions
@ -0,0 +1,21 @@
@@ -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 @@
@@ -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 @@
@@ -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