diff --git a/docs/examples.md b/docs/examples.md index 4d51bb2..910fbea 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -3,14 +3,17 @@ layout: home hero: text: Examples - tagline: Various demos showcasing the usage of the features of Mediakit + tagline: Demos showcasing the usage of various features of Mediakit features: - title: Metadata extraction - details: See how you can extract various metadata from an input media file. + details: Extract various metadata from an input media file link: /examples/metadata-extraction - - title: Feature B - details: Lorem ipsum dolor sit amet, consectetur adipiscing elit + target: _self + - title: Thumbnail generation + details: Generate multiple small thumbnails for a video track + link: /examples/thumbnail-generation + target: _self - title: Feature C details: Lorem ipsum dolor sit amet, consectetur adipiscing elit - title: Feature A diff --git a/docs/guide/media-sinks.md b/docs/guide/media-sinks.md index f0e6c6e..0bf2ff5 100644 --- a/docs/guide/media-sinks.md +++ b/docs/guide/media-sinks.md @@ -291,7 +291,7 @@ for await (const sample of keyFrameSamples) { While `VideoSampleSink` extracts raw decoded video samples, you can use `CanvasSink` to extract these samples as canvases instead. In doing so, certain operations such as scaling and rotating can also be handled by the sink. The downside is the additional VRAM requirements for the canvases' framebuffers. ::: info -This sink yields `OffscreenCanvas` whenever possible, and falls back to `HTMLCanvasElement` otherwise. +This sink yields `HTMLCanvasElement` whenever possible, and falls back to `OffscreenCanvas` otherwise (in Worker contexts, for example). ::: Create the sink like so: diff --git a/examples/metadata-extraction/index.html b/examples/metadata-extraction/index.html index 5ba19b2..7a98d3a 100644 --- a/examples/metadata-extraction/index.html +++ b/examples/metadata-extraction/index.html @@ -9,7 +9,7 @@ -
+Select or drop a media file, and Mediakit will start extracting various metadata about that file.
@@ -29,7 +29,7 @@ -Mediakit
Select or drop a media file, and Mediakit will extract video thumbnails for it.
+ +Mediakit
+View source code
+ + + diff --git a/examples/thumbnail-generation/thumbnail-generation.ts b/examples/thumbnail-generation/thumbnail-generation.ts new file mode 100644 index 0000000..e187142 --- /dev/null +++ b/examples/thumbnail-generation/thumbnail-generation.ts @@ -0,0 +1,121 @@ +import { Input, ALL_FORMATS, BlobSource, CanvasSink } from 'mediakit'; + +const selectMediaButton = document.querySelector('button')!; +const fileNameElement = document.querySelector('#file-name')!; +const horizontalRule = document.querySelector('hr')!; +const thumbnailContainer = document.querySelector('#thumbnail-container')!; +const errorElement = document.querySelector('#error-element')!; + +const THUMBNAIL_COUNT = 16; +const THUMBNAIL_SIZE = 200; + +const generateThumbnails = async (file: File) => { + fileNameElement.textContent = file.name; + horizontalRule.style.display = ''; + errorElement.innerHTML = ''; + thumbnailContainer.innerHTML = ''; + + try { + // Create a new input from the file + const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, // Accept all formats + }); + + const videoTrack = await input.getPrimaryVideoTrack(); + if (!videoTrack) { + throw new Error('File has no video track.'); + } + + // Compute width and height of the thumbnails such that the larger dimension is equal to THUMBNAIL_SIZE + const width = videoTrack.displayWidth > videoTrack.displayHeight + ? THUMBNAIL_SIZE + : Math.floor(THUMBNAIL_SIZE * videoTrack.displayWidth / videoTrack.displayHeight); + const height = videoTrack.displayHeight > videoTrack.displayWidth + ? THUMBNAIL_SIZE + : Math.floor(THUMBNAIL_SIZE * videoTrack.displayHeight / videoTrack.displayWidth); + + // Create thumbnail elements + const thumbnailElements = []; + for (let i = 0; i < THUMBNAIL_COUNT; i++) { + const thumbnailElement = document.createElement('div'); + thumbnailElement.className = 'rounded-lg overflow-hidden bg-gray-100 relative'; + thumbnailElement.style.width = `${width}px`; + thumbnailElement.style.height = `${height}px`; + thumbnailElements.push(thumbnailElement); + thumbnailContainer.append(thumbnailElement); + } + + // Prepare the timestamps for the thumbnails, equally spaced between the first and last timestamp of the video + const firstTimestamp = await videoTrack.getFirstTimestamp(); + const lastTimestamp = await videoTrack.computeDuration(); + const timestamps = Array.from( + { length: THUMBNAIL_COUNT }, + (_, i) => firstTimestamp + i * (lastTimestamp - firstTimestamp) / THUMBNAIL_COUNT, + ); + + // Create a CanvasSink for extracting resized frames from the video track + const sink = new CanvasSink(videoTrack, { + width: Math.floor(width * window.devicePixelRatio), + height: Math.floor(height * window.devicePixelRatio), + fit: 'fill', + }); + + // Iterate over all thumbnail canvases + let i = 0; + for await (const wrappedCanvas of sink.canvasesAtTimestamps(timestamps)) { + const container = thumbnailElements[i]!; + + if (wrappedCanvas) { + const canvasElement = wrappedCanvas.canvas as HTMLCanvasElement; + canvasElement.className = 'size-full'; + container.append(canvasElement); + + canvasElement.animate( + [{ transform: 'scale(1.2)' }, { transform: 'scale(1)' }], + { duration: 333, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, + ); + + const timestampElement = document.createElement('p'); + timestampElement.textContent = wrappedCanvas.timestamp.toFixed(2) + ' s'; + timestampElement.className + = 'absolute bottom-0 right-0 bg-black/30 text-white px-1 py-0.5 text-[11px] rounded-tl-lg'; + container.append(timestampElement); + } + + i++; + } + } catch (e) { + errorElement.textContent = String(e); + thumbnailContainer.innerHTML = ''; + } +}; + +selectMediaButton.addEventListener('click', () => { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.addEventListener('change', () => { + const file = fileInput.files?.[0]; + if (!file) { + return; + } + + void generateThumbnails(file); + }); + + fileInput.click(); +}); + +document.addEventListener('dragover', (event) => { + event.preventDefault(); + event.dataTransfer!.dropEffect = 'copy'; +}); + +document.addEventListener('drop', (event) => { + event.preventDefault(); + const files = event.dataTransfer?.files; + const file = files && files.length > 0 ? files[0] : undefined; + if (file) { + void generateThumbnails(file); + } +}); diff --git a/src/input-format.ts b/src/input-format.ts index 391b9d0..6e61ad6 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -226,7 +226,7 @@ export class Mp3InputFormat extends InputFormat { const framesStartPos = mp3Reader.pos; await mp3Reader.reader.loadRange(mp3Reader.pos, mp3Reader.pos + 4096); - const firstHeader = mp3Reader.readNextFrameHeader(framesStartPos + 4096); + const firstHeader = mp3Reader.readNextFrameHeader(Math.min(framesStartPos + 4096, sourceSize)); if (!firstHeader) { return false; } @@ -239,7 +239,7 @@ export class Mp3InputFormat extends InputFormat { // Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find // another header nearby: mp3Reader.pos = firstHeader.startPos + firstHeader.totalSize; - const secondHeader = mp3Reader.readNextFrameHeader(framesStartPos + 4096); + const secondHeader = mp3Reader.readNextFrameHeader(Math.min(framesStartPos + 4096, sourceSize)); if (!secondHeader) { return false; } diff --git a/src/media-sink.ts b/src/media-sink.ts index a5c393c..269526d 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -922,6 +922,8 @@ export type CanvasSinkOptions = { * A sink that renders video samples (frames) of the given video track to canvases. This is often more useful than * directly retrieving frames, as it comes with common preprocessing steps such as resizing or applying rotation * metadata. + * + * This sink will yield HTMLCanvasElements when in a DOM context, and OffscreenCanvases otherwise. * @public */ export class CanvasSink { @@ -1008,13 +1010,13 @@ export class CanvasSink { _videoSampleToWrappedCanvas(sample: VideoSample): WrappedCanvas { let canvas = this._canvasPool[this._nextCanvasIndex]; if (!canvas) { - if (typeof OffscreenCanvas !== 'undefined') { - // Prefer an OffscreenCanvas - canvas = new OffscreenCanvas(this._width, this._height); - } else { + if (typeof document !== 'undefined') { + // Prefer an HTMLCanvasElement canvas = document.createElement('canvas'); canvas.width = this._width; canvas.height = this._height; + } else { + canvas = new OffscreenCanvas(this._width, this._height); } if (this._canvasPool.length > 0) {