interface RenderItem { index: number run: () => void promise: Promise } export default class Thumbnailer { video = document.createElement('video') canvas = new OffscreenCanvas(0, 0) ctx = this.canvas.getContext('2d')! thumbnails: string[] = [] size = 800 interval = 10 currentTask: RenderItem | undefined nextTask: RenderItem | undefined src constructor (src: string) { this.video.preload = 'none' this.video.src = this.src = src this.video.load() this.video.playbackRate = 0 this.video.muted = true } setVideo (currentVideo: HTMLVideoElement) { currentVideo.addEventListener('timeupdate', () => { const index = Math.floor(currentVideo.currentTime / this.interval) const thumbnail = this.thumbnails[index] if (!thumbnail) this._paintThumbnail(currentVideo, index) }) } _createTask (index: number): RenderItem { const { promise, resolve } = Promise.withResolvers() const run = () => { this.video.requestVideoFrameCallback((_now, meta) => { resolve(this._paintThumbnail(this.video, index, meta.width, meta.height)) this.video.currentTime = 0 this.currentTask = undefined if (this.nextTask) { this.currentTask = this.nextTask this.nextTask = undefined this.currentTask.run() } }) this.video.currentTime = index * this.interval } return { index, run, promise } } // get a task or create one to create a thumbnail // don't touch currently running task, overwrite next task _createThumbnail (index: number) { if (!this.currentTask) { this.currentTask = this._createTask(index) this.currentTask.run() return this.currentTask.promise } if (index === this.currentTask.index) return this.currentTask.promise if (!this.nextTask) { this.nextTask = this._createTask(index) return this.nextTask.promise } if (index === this.nextTask.index) return this.nextTask.promise this.nextTask = this._createTask(index) return this.nextTask.promise } // generate and store the thumbnail async _paintThumbnail (video: HTMLVideoElement, index: number, width = video.videoWidth, height = video.videoHeight) { this.canvas.width = this.size this.canvas.height = height / width * this.size this.ctx.drawImage(video, 0, 0, this.canvas.width, this.canvas.height) this.thumbnails[index] = URL.createObjectURL(await this.canvas.convertToBlob({ type: 'image/webp', quality: 0.6 })) return this.thumbnails[index] } async getThumbnail (index: number): Promise { const thumbnail = this.thumbnails[index] if (thumbnail) return thumbnail return await this._createThumbnail(index) } updateSource (src: string) { if (src === this.src) return for (const thumbnail of this.thumbnails) URL.revokeObjectURL(thumbnail) this.thumbnails = [] this.currentTask = undefined this.nextTask = undefined this.video.src = this.src = src this.video.load() } }