diff --git a/src/components/ScrollyVideo/ScrollyVideo.stories.svelte b/src/components/ScrollyVideo/ScrollyVideo.stories.svelte index 0d7e09be..478f0528 100644 --- a/src/components/ScrollyVideo/ScrollyVideo.stories.svelte +++ b/src/components/ScrollyVideo/ScrollyVideo.stories.svelte @@ -174,7 +174,7 @@ {/key} diff --git a/src/components/ScrollyVideo/js/ScrollyVideo.d.ts b/src/components/ScrollyVideo/js/ScrollyVideo.d.ts deleted file mode 100644 index 78ceb82a..00000000 --- a/src/components/ScrollyVideo/js/ScrollyVideo.d.ts +++ /dev/null @@ -1,140 +0,0 @@ -/* eslint-disable @typescript-eslint/no-empty-object-type */ - -export default ScrollyVideo; -/** - * ____ _ _ __ ___ _ - * / ___| ___ _ __ ___ | | |_ \ \ / (_) __| | ___ ___ - * \___ \ / __| '__/ _ \| | | | | \ \ / /| |/ _` |/ _ \/ _ \ - * ___) | (__| | | (_) | | | |_| |\ V / | | (_| | __/ (_) | - * |____/ \___|_| \___/|_|_|\__, | \_/ |_|\__,_|\___|\___/ - * |___/ - * - * Responsive scrollable videos without obscure video encoding requirements. - * Compatible with React, Svelte, Vue, and plain HTML. - */ -declare class ScrollyVideo { - constructor({ - src, - scrollyVideoContainer, - objectFit, - sticky, - full, - trackScroll, - lockScroll, - transitionSpeed, - frameThreshold, - useWebCodecs, - onReady, - onChange, - debug, - autoplay, - }: { - src?: string; - scrollyVideoContainer: string | HTMLDivElement | undefined; - objectFit?: string; - sticky?: boolean; - full?: boolean; - trackScroll?: boolean; - lockScroll?: boolean; - transitionSpeed?: number; - frameThreshold?: number; - useWebCodecs?: boolean; - onReady?: () => void; - onChange?: () => void; - debug?: boolean; - autoplay?: boolean; - }); - container: Element; - src: string; - transitionSpeed: number; - frameThreshold: number; - useWebCodecs: boolean; - objectFit: string; - sticky: boolean; - trackScroll: boolean; - onReady: () => void; - onChange: () => void; - debug: boolean; - autoplay: boolean; - video: HTMLVideoElement; - videoPercentage: number; - isSafari: boolean; - currentTime: number; - targetTime: number; - canvas: HTMLCanvasElement; - context: CanvasRenderingContext2D; - frames: ImageBitmap[] | null; - frameRate: number; - currentFrameNumber: number; - updateScrollPercentage: (jump: boolean) => void; - targetScrollPosition: number | null; - resize: () => void; - /** - * Sets the currentTime of the video as a specified percentage of its total duration. - * - * @param percentage - The percentage of the video duration to set as the current time. - * @param options - Configuration options for adjusting the video playback. - * - jump: boolean - If true, the video currentTime will jump directly to the specified percentage. If false, the change will be animated over time. - * - transitionSpeed: number - Defines the speed of the transition when `jump` is false. Represents the duration of the transition in milliseconds. Default is 8. - * - easing: (progress: number) => number - A function that defines the easing curve for the transition. It takes the progress ratio (a number between 0 and 1) as an argument and returns the eased value, affecting the playback speed during the transition. - */ - setVideoPercentage(percentage: number, options?: {}): void; - /** - * Sets the style of the video or canvas to "cover" it's container - * - * @param el - */ - setCoverStyle(el: string): void; - /** - * Uses webCodecs to decode the video into frames - */ - decodeVideo(): Promise; - /** - * Paints the frame of to the canvas - * - * @param frameNum - */ - paintCanvasFrame(frameNum: number): void; - /** - * Transitions the video or the canvas to the proper frame. - * - * @param options - Configuration options for adjusting the video playback. - * - jump: boolean - If true, the video currentTime will jump directly to the specified percentage. If false, the change will be animated over time. - * - transitionSpeed: number - Defines the speed of the transition when `jump` is false. Represents the duration of the transition in milliseconds. Default is 8. - * - easing: (progress: number) => number - A function that defines the easing curve for the transition. It takes the progress ratio (a number between 0 and 1) as an argument and returns the eased value, affecting the playback speed during the transition. - */ - transitionToTargetTime({ - jump, - transitionSpeed, - easing, - }: { - jump: boolean; - transitionSpeed?: number; - easing?: (progress: number) => number; - }): void; - transitioningRaf: number; - /** - * Sets the currentTime of the video as a specified percentage of its total duration. - * - * @param percentage - The percentage of the video duration to set as the current time. - * @param options - Configuration options for adjusting the video playback. - * - jump: boolean - If true, the video currentTime will jump directly to the specified percentage. If false, the change will be animated over time. - * - transitionSpeed: number - Defines the speed of the transition when `jump` is false. Represents the duration of the transition in milliseconds. Default is 8. - * - easing: (progress: number) => number - A function that defines the easing curve for the transition. It takes the progress ratio (a number between 0 and 1) as an argument and returns the eased value, affecting the playback speed during the transition. - */ - setTargetTimePercent(percentage: number, options?: {}): void; - /** - * Simulate trackScroll programmatically (scrolls on page by percentage of video) - * - * @param percentage - */ - setScrollPercent(percentage: number): void; - /** - * Call to destroy this ScrollyVideo object - */ - destroy(): void; - /** - * Call to initiate autoplay - */ - autoplayScroll(): void; -} diff --git a/src/components/ScrollyVideo/js/ScrollyVideo.js b/src/components/ScrollyVideo/js/ScrollyVideo.ts similarity index 75% rename from src/components/ScrollyVideo/js/ScrollyVideo.js rename to src/components/ScrollyVideo/js/ScrollyVideo.ts index 0f74727a..94d85e7c 100644 --- a/src/components/ScrollyVideo/js/ScrollyVideo.js +++ b/src/components/ScrollyVideo/js/ScrollyVideo.ts @@ -3,23 +3,111 @@ import videoDecoder from './videoDecoder'; import { debounce, isScrollPositionAtTarget, map } from './utils'; import { scrollyVideoState } from './state.svelte'; +interface ScrollyVideoArgs { + src?: string; + scrollyVideoContainer: HTMLElement | string; + objectFit?: string; + sticky?: boolean; + full?: boolean; + trackScroll?: boolean; + lockScroll?: boolean; + transitionSpeed?: number; + frameThreshold?: number; + useWebCodecs?: boolean; + onReady?: () => void; + onChange?: (percentage?: number) => void; + debug?: boolean; + autoplay?: boolean; +} + +interface TransitionOptions { + jump: boolean; + transitionSpeed?: number; + easing?: ((progress: number) => number) | null; + autoplay?: boolean; +} + class ScrollyVideo { + container: HTMLElement | null; + scrollyVideoContainer: Element | string | undefined; + src: string; + transitionSpeed: number; + frameThreshold: number; + useWebCodecs: boolean; + objectFit: string; + sticky: boolean; + trackScroll: boolean; + onReady: () => void; + onChange: (percentage?: number) => void; + debug: boolean; + autoplay: boolean; + video: HTMLVideoElement | undefined; + videoPercentage: number; + isSafari: boolean; + currentTime: number; + targetTime: number; + canvas: HTMLCanvasElement | null; + context: CanvasRenderingContext2D | null; + frames: ImageBitmap[] | null; + frameRate: number; + targetScrollPosition: number | null = null; + currentFrame: number; + usingWebCodecs: boolean; // Whether we are using webCodecs + totalTime: number; // The total time of the video, used for calculating percentage + transitioningRaf: number | null; + + updateScrollPercentage: ((jump: boolean) => void) | undefined; + resize: (() => void) | undefined; + constructor({ - src = 'https://scrollyvideo.js.org/goldengate.mp4', // The src of the video, required - scrollyVideoContainer, // The dom element or id that this object will be created in, required - objectFit = 'cover', // Whether the video should "cover" inside the container - sticky = true, // Whether the video should "stick" to the top of the container - full = true, // Whether the container should expand to 100vh and 100vw - trackScroll = true, // Whether this object should automatically respond to scroll - lockScroll = true, // Whether it ignores human scroll while it runs `setVideoPercentage` with enabled `trackScroll` - transitionSpeed = 8, // How fast the video transitions between points - frameThreshold = 0.1, // When to stop the video animation, in seconds - useWebCodecs = true, // Whether to try using the webcodecs approach - onReady = () => {}, // A callback that invokes on video decode - onChange = () => {}, // A callback that invokes on video percentage change - debug = false, // Whether to print debug stats to the console - autoplay = false, // Whether to autoplay the video when it is ready - }) { + src = 'https://scrollyvideo.js.org/goldengate.mp4', + scrollyVideoContainer, + objectFit = 'cover', + sticky = true, + full = true, + trackScroll = true, + lockScroll = true, + transitionSpeed = 8, + frameThreshold = 0.1, + useWebCodecs = true, + onReady = () => {}, + onChange = (_percentage?: number) => {}, + debug = false, + autoplay = false, + }: ScrollyVideoArgs) { + this.src = src; + this.scrollyVideoContainer = scrollyVideoContainer; + this.objectFit = objectFit; + this.sticky = sticky; + this.trackScroll = trackScroll; + this.transitionSpeed = transitionSpeed; + this.frameThreshold = frameThreshold; + this.useWebCodecs = useWebCodecs; + this.onReady = onReady; + this.onChange = onChange; + this.debug = debug; + this.autoplay = autoplay; + this.videoPercentage = 0; + this.isSafari = false; + this.currentTime = 0; + this.targetTime = 0; + this.canvas = null; + this.context = null; + this.container = null; + this.frames = null; + this.frameRate = 0; + this.currentTime = 0; // Saves the currentTime of the video, synced with this.video.currentTime + this.targetTime = 0; // The target time before a transition happens + this.canvas = null; // The canvas for drawing the frames decoded by webCodecs + this.context = null; // The canvas context + this.frames = []; // The frames decoded by webCodecs + this.frameRate = 0; // Calculation of frameRate so we know which frame to paint + this.currentFrame = 0; + this.videoPercentage = 0; + this.usingWebCodecs = false; // Whether we are using webCodecs + this.totalTime = 0; // The total time of the video, used for calculating percentage + this.transitioningRaf = null; + // Make sure that we have a DOM if (typeof document !== 'object') { console.error('ScrollyVideo must be initiated in a DOM context'); @@ -38,30 +126,17 @@ class ScrollyVideo { // Save the container. If the container is a string we get the element - if (scrollyVideoContainer instanceof Element) + if (scrollyVideoContainer && scrollyVideoContainer instanceof HTMLElement) this.container = scrollyVideoContainer; // otherwise it should better be an element else if (typeof scrollyVideoContainer === 'string') { - this.container = document.getElementById(scrollyVideoContainer); + this.container = document.getElementById(scrollyVideoContainer) || null; if (!this.container) throw new Error('scrollyVideoContainer must be a valid DOM object'); } else { throw new Error('scrollyVideoContainer must be a valid DOM object'); } - // Save the constructor options - this.src = src; - this.transitionSpeed = transitionSpeed; - this.frameThreshold = frameThreshold; - this.useWebCodecs = useWebCodecs; - this.objectFit = objectFit; - this.sticky = sticky; - this.trackScroll = trackScroll; - this.onReady = onReady; - this.onChange = onChange; - this.debug = debug; - this.autoplay = autoplay; - // Create the initial video object. Even if we are going to use webcodecs, // we start with a paused video object @@ -69,7 +144,7 @@ class ScrollyVideo { this.video.src = src; this.video.preload = 'auto'; this.video.tabIndex = 0; - this.video.autobuffer = true; + this.video.preload = 'auto'; this.video.playsInline = true; this.video.muted = true; this.video.pause(); @@ -115,18 +190,6 @@ class ScrollyVideo { this.isSafari = browserEngine.name === 'WebKit'; if (debug && this.isSafari) console.info('Safari browser detected'); - // Initialize state variables - this.currentTime = 0; // Saves the currentTime of the video, synced with this.video.currentTime - this.targetTime = 0; // The target time before a transition happens - this.canvas = null; // The canvas for drawing the frames decoded by webCodecs - this.context = null; // The canvas context - this.frames = []; // The frames decoded by webCodecs - this.frameRate = 0; // Calculation of frameRate so we know which frame to paint - this.currentFrame = 0; - this.videoPercentage = 0; - this.usingWebCodecs = false; // Whether we are using webCodecs - this.totalTime = 0; // The total time of the video, used for calculating percentage - const debouncedScroll = debounce(() => { window.requestAnimationFrame(() => { this.setScrollPercent(this.videoPercentage); @@ -134,10 +197,25 @@ class ScrollyVideo { }, 100); // Add scroll listener for responding to scroll position - this.updateScrollPercentage = (jump) => { + this.updateScrollPercentage = (jump = false) => { // Used for internally setting the scroll percentage based on built-in listeners - const containerBoundingClientRect = - this.container.parentNode.getBoundingClientRect(); + let containerBoundingClientRect; + if ( + this.container && + this.container.parentNode && + (this.container.parentNode as Element).getBoundingClientRect + ) { + containerBoundingClientRect = ( + this.container.parentNode as Element + ).getBoundingClientRect(); + } else { + if (this.debug) { + console.error( + 'ScrollyVideo: container or parentNode is null or invalid.' + ); + } + return; + } // Calculate the current scroll percent of the video let scrollPercent = @@ -160,7 +238,7 @@ class ScrollyVideo { } // toggle autoplaying state on manual intervention - if (scrollyVideoState.isAutoPlaying) { + if (scrollyVideoState.isAutoPlaying && this.frames) { if (this.debug) console.warn('Stopping autoplay due to manual scroll'); if (this.usingWebCodecs) { @@ -192,14 +270,22 @@ class ScrollyVideo { // Add our event listeners for handling changes to the window or scroll if (this.trackScroll) { - window.addEventListener('scroll', this.updateScrollPercentage); + window.addEventListener('scroll', () => { + if (this.updateScrollPercentage) { + this.updateScrollPercentage(false); + } + }); // Set the initial scroll percentage this.video.addEventListener( 'loadedmetadata', () => { - this.updateScrollPercentage(true); - this.totalTime = this.video.duration; + if (this.updateScrollPercentage) { + this.updateScrollPercentage(true); + } + if (this.video) { + this.totalTime = this.video.duration; + } this.setCoverStyle(this.canvas || this.video); }, { once: true } @@ -209,7 +295,9 @@ class ScrollyVideo { 'loadedmetadata', () => { this.setTargetTimePercent(0, { jump: true }); - this.totalTime = this.video.duration; + if (this.video) { + this.totalTime = this.video.duration; + } this.setCoverStyle(this.canvas || this.video); }, { once: true } @@ -243,7 +331,7 @@ class ScrollyVideo { * - transitionSpeed: number - Defines the speed of the transition when `jump` is false. Represents the duration of the transition in milliseconds. Default is 8. * - easing: (progress: number) => number - A function that defines the easing curve for the transition. It takes the progress ratio (a number between 0 and 1) as an argument and returns the eased value, affecting the playback speed during the transition. */ - setVideoPercentage(percentage, options = { autoplay: false }) { + setVideoPercentage(percentage: number, options: TransitionOptions): void { // Early termination if the video percentage is already at the percentage that is intended. if (this.videoPercentage === percentage) return; @@ -267,7 +355,12 @@ class ScrollyVideo { * * @param el */ - setCoverStyle(el) { + setCoverStyle(el: HTMLElement | HTMLCanvasElement | undefined): void { + if (!el) { + if (this.debug) console.warn('No element to set cover style on'); + return; + } + if (this.objectFit) { el.style.position = 'absolute'; el.style.top = '50%'; @@ -278,11 +371,18 @@ class ScrollyVideo { // Gets the width and height of the container const { width: containerWidth, height: containerHeight } = - this.container.getBoundingClientRect(); + this.container?.getBoundingClientRect() || { width: 0, height: 0 }; - // Gets the width and height of the video frames - const width = el.videoWidth || el.width; - const height = el.videoHeight || el.height; + let width = 0, + height = 0; + + if (el instanceof HTMLVideoElement) { + width = el.videoWidth; + height = el.videoHeight; + } else if (el instanceof HTMLCanvasElement) { + width = el.width; + height = el.height; + } if (this.debug) console.info('Container dimensions:', [ @@ -334,7 +434,7 @@ class ScrollyVideo { await videoDecoder( this.src, (frame) => { - this.frames.push(frame); + this.frames?.push(frame); }, this.debug ).then(() => { @@ -348,11 +448,11 @@ class ScrollyVideo { this.frames = []; // Force a video reload when videoDecoder fails - this.video.load(); + this.video?.load(); } // If no frames, something went wrong - if (this.frames.length === 0) { + if (this.frames?.length === 0) { if (this.debug) console.error('No frames were received from webCodecs'); this.onReady(); @@ -360,11 +460,12 @@ class ScrollyVideo { } // Calculate the frameRate based on number of frames and the duration - this.frameRate = this.frames.length / this.video.duration; + this.frameRate = + this.frames && this.video ? this.frames.length / this.video.duration : 0; if (this.debug) console.info( 'Received', - this.frames.length, + this.frames?.length, 'frames. Video frame rate:', this.frameRate ); @@ -374,8 +475,12 @@ class ScrollyVideo { this.context = this.canvas.getContext('2d'); // Hide the video and add the canvas to the container - this.video.style.display = 'none'; - this.container.appendChild(this.canvas); + if (this.video) { + this.video.style.display = 'none'; + } + if (this.container) { + this.container.appendChild(this.canvas); + } if (this.objectFit) this.setCoverStyle(this.canvas); // Paint our first frame @@ -390,7 +495,12 @@ class ScrollyVideo { * * @param frameNum */ - paintCanvasFrame(frameNum) { + paintCanvasFrame(frameNum: number): void { + if (!this.frames) { + if (this.debug) console.warn('No frames available to paint'); + return; + } + // Get the frame and paint it to the canvas const currFrame = this.frames[frameNum]; this.currentFrame = frameNum; @@ -406,7 +516,10 @@ class ScrollyVideo { // Make sure the canvas is scaled properly, similar to setCoverStyle this.canvas.width = currFrame.width; this.canvas.height = currFrame.height; - const { width, height } = this.container.getBoundingClientRect(); + const { width, height } = this.container?.getBoundingClientRect() || { + width: 0, + height: 0, + }; if (this.objectFit == 'cover') { if (width / height > currFrame.width / currFrame.height) { @@ -427,6 +540,11 @@ class ScrollyVideo { } // Draw the frame to the canvas context + if (!this.context) { + if (this.debug) console.warn('No canvas context available to paint'); + return; + } + this.context.drawImage(currFrame, 0, 0, currFrame.width, currFrame.height); this.updateDebugInfo(); } @@ -443,7 +561,12 @@ class ScrollyVideo { jump, transitionSpeed = this.transitionSpeed, easing = null, - }) { + }: TransitionOptions) { + if (!this.video) { + console.warn('No video found'); + return; + } + if (this.debug) { console.info( 'Transitioning targetTime:', @@ -458,7 +581,20 @@ class ScrollyVideo { const duration = distance * 1000; const isForwardTransition = diff > 0; - const tick = ({ startCurrentTime, startTimestamp, timestamp }) => { + const tick = ({ + startCurrentTime, + startTimestamp, + timestamp, + }: { + startCurrentTime: number; + startTimestamp: number; + timestamp: number; + }) => { + if (!this.video) { + console.warn('No video found during transition tick'); + return; + } + const progress = (timestamp - startTimestamp) / duration; // if frameThreshold is too low to catch condition Math.abs(this.targetTime - this.currentTime) < this.frameThreshold @@ -479,7 +615,7 @@ class ScrollyVideo { Math.abs(this.targetTime - this.currentTime) < this.frameThreshold || hasPassedThreshold ) { - this.video.pause(); + this.video?.pause(); if (this.transitioningRaf) { cancelAnimationFrame(this.transitioningRaf); @@ -497,7 +633,7 @@ class ScrollyVideo { // How far forward we need to transition const transitionForward = this.targetTime - this.currentTime; const easedProgress = - easing && Number.isFinite(progress) ? easing(progress) : null; + easing && Number.isFinite(progress) ? easing(progress) : 0; const easedCurrentTime = isForwardTransition ? startCurrentTime + @@ -584,11 +720,14 @@ class ScrollyVideo { * - transitionSpeed: number - Defines the speed of the transition when `jump` is false. Represents the duration of the transition in milliseconds. Default is 8. * - easing: (progress: number) => number - A function that defines the easing curve for the transition. It takes the progress ratio (a number between 0 and 1) as an argument and returns the eased value, affecting the playback speed during the transition. */ - setTargetTimePercent(percentage, options = {}) { + setTargetTimePercent( + percentage: number, + options: TransitionOptions = { jump: false, transitionSpeed: 8 } + ) { const targetDuration = - this.frames.length && this.frameRate ? + this.frames?.length && this.frameRate ? this.frames.length / this.frameRate - : this.video.duration; + : this.video?.duration || 0; // The time we want to transition to this.targetTime = Math.max(Math.min(percentage, 1), 0) * targetDuration; @@ -600,7 +739,7 @@ class ScrollyVideo { return; // Play the video if we are in video mode - if (!this.canvas && !this.video.paused) this.video.play(); + if (!this.canvas && !this.video?.paused) this.video?.play(); this.transitionToTargetTime(options); } @@ -610,14 +749,21 @@ class ScrollyVideo { * * @param percentage */ - setScrollPercent(percentage) { + setScrollPercent(percentage: number) { if (!this.trackScroll) { console.warn('`setScrollPercent` requires enabled `trackScroll`'); return; } - const parent = this.container.parentNode; - const { top, height } = parent.getBoundingClientRect(); + const parent = this.container?.parentNode; + let top = 0, + height = 0; + + if (parent && parent instanceof Element) { + const rect = parent.getBoundingClientRect(); + top = rect.top; + height = rect.height; + } const startPoint = top + window.pageYOffset; @@ -638,10 +784,12 @@ class ScrollyVideo { destroy() { if (this.debug) console.info('Destroying ScrollyVideo'); - if (this.trackScroll) - window.removeEventListener('scroll', this.updateScrollPercentage); + if (this.trackScroll && this.updateScrollPercentage) + window.removeEventListener('scroll', () => this.updateScrollPercentage); - window.removeEventListener('resize', this.resize); + if (this.resize) { + window.removeEventListener('resize', this.resize); + } // Clear component if (this.container) this.container.innerHTML = ''; @@ -659,14 +807,21 @@ class ScrollyVideo { updateDebugInfo() { scrollyVideoState.generalData.src = this.src; - scrollyVideoState.generalData.videoPercentage = - this.videoPercentage.toFixed(4); - scrollyVideoState.generalData.frameRate = this.frameRate.toFixed(2); - scrollyVideoState.generalData.currentTime = this.currentTime.toFixed(4); - scrollyVideoState.generalData.totalTime = this.totalTime.toFixed(4); + scrollyVideoState.generalData.videoPercentage = parseFloat( + this.videoPercentage.toFixed(4) + ); + scrollyVideoState.generalData.frameRate = parseFloat( + this.frameRate.toFixed(2) + ); + scrollyVideoState.generalData.currentTime = parseFloat( + this.currentTime.toFixed(4) + ); + scrollyVideoState.generalData.totalTime = parseFloat( + this.totalTime.toFixed(4) + ); scrollyVideoState.usingWebCodecs = this.usingWebCodecs; scrollyVideoState.framesData.currentFrame = this.currentFrame; - scrollyVideoState.framesData.totalFrames = this.frames.length; + scrollyVideoState.framesData.totalFrames = this.frames?.length || 0; } } export default ScrollyVideo; diff --git a/src/components/ScrollyVideo/js/mp4box.d.ts b/src/components/ScrollyVideo/js/mp4box.d.ts new file mode 100644 index 00000000..ed3a45e4 --- /dev/null +++ b/src/components/ScrollyVideo/js/mp4box.d.ts @@ -0,0 +1,137 @@ +declare module 'mp4box' { + export interface MP4MediaTrack { + id: number; + created: Date; + modified: Date; + movie_duration: number; + movie_timescale: number; + layer: number; + alternate_group: number; + volume: number; + track_width: number; + track_height: number; + timescale: number; + duration: number; + bitrate: number; + codec: string; + language: string; + nb_samples: number; + } + + export interface MP4VideoData { + width: number; + height: number; + } + + export interface MP4VideoTrack extends MP4MediaTrack { + video: MP4VideoData; + } + + export interface MP4AudioData { + sample_rate: number; + channel_count: number; + sample_size: number; + } + + export interface MP4AudioTrack extends MP4MediaTrack { + audio: MP4AudioData; + } + + export type MP4Track = MP4VideoTrack | MP4AudioTrack; + + export interface MP4Info { + duration: number; + timescale: number; + fragment_duration: number; + isFragmented: boolean; + isProgressive: boolean; + hasIOD: boolean; + brands: string[]; + created: Date; + modified: Date; + tracks: MP4Track[]; + audioTracks: MP4AudioTrack[]; + videoTracks: MP4VideoTrack[]; + } + + export interface MP4Sample { + alreadyRead: number; + chunk_index: number; + chunk_run_index: number; + cts: number; + data: Uint8Array; + degradation_priority: number; + depends_on: number; + description: any; + description_index: number; + dts: number; + duration: number; + has_redundancy: number; + is_depended_on: number; + is_leading: number; + is_sync: boolean; + number: number; + offset: number; + size: number; + timescale: number; + track_id: number; + } + + export type MP4ArrayBuffer = ArrayBuffer & { fileStart: number }; + + export class DataStream { + static BIG_ENDIAN: boolean; + static LITTLE_ENDIAN: boolean; + buffer: ArrayBuffer; + constructor( + arrayBuffer?: ArrayBuffer, + byteOffset: number, + endianness: boolean + ): void; + // TODO: Complete interface + } + + export interface Trak { + mdia?: { + minf?: { + stbl?: { + stsd?: { + entries: { + avcC?: { + write: (stream: DataStream) => void; + }; + hvcC?: { + write: (stream: DataStream) => void; + }; + }[]; + }; + }; + }; + }; + // TODO: Complete interface + } + + export interface MP4File { + [x: string]: any; + onMoovStart?: () => void; + onReady?: (info: MP4Info) => void; + onError?: (e: string) => void; + onSamples?: (id: number, user: any, samples: MP4Sample[]) => any; + + appendBuffer(data: MP4ArrayBuffer): number; + start(): void; + stop(): void; + flush(): void; + releaseUsedSamples(trackId: number, sampleNumber: number): void; + setExtractionOptions( + trackId: number, + user?: any, + options?: { nbSamples?: number; rapAlignment?: number } + ): void; + getTrackById(trackId: number): Trak; + } + + export function createFile(): MP4File; + + export {}; +} diff --git a/src/components/ScrollyVideo/js/utils.d.ts b/src/components/ScrollyVideo/js/utils.d.ts deleted file mode 100644 index 1a38d614..00000000 --- a/src/components/ScrollyVideo/js/utils.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ScrollyVideoState } from './types'; - -type FlattenedScrollyVideoState = { - src: string; - videoPercentage: number; - frameRate: number; - currentTime: number; - totalTime: number; - usingWebCodecs: boolean; - codec: string; - currentFrame: number; - totalFrames: number; - isAutoPlaying: boolean; - autoplayProgress: number; -}; - -export function debounce unknown>( - func: T, - delay?: number -): (...args: Parameters) => void; -export function isScrollPositionAtTarget( - targetScrollPosition: number | null, - threshold?: number -): boolean; -function constrain(n: number, low: number, high: number): number; -export function map( - n: number, - start1: number, - stop1: number, - start2: number, - stop2: number, - withinBounds?: boolean -): number; -export function flattenObject( - obj: ScrollyVideoState -): FlattenedScrollyVideoState; diff --git a/src/components/ScrollyVideo/js/utils.js b/src/components/ScrollyVideo/js/utils.js deleted file mode 100644 index 409db621..00000000 --- a/src/components/ScrollyVideo/js/utils.js +++ /dev/null @@ -1,68 +0,0 @@ -export function debounce(func, delay = 0) { - let timeoutId; - - return (...args) => { - // Clear the previous timeout if it exists - clearTimeout(timeoutId); - - // Set a new timeout to call the function later - timeoutId = setTimeout(() => { - func.apply(this, args); - }, delay); - }; -} - -export const isScrollPositionAtTarget = ( - targetScrollPosition, - threshold = 1 -) => { - const currentScrollPosition = window.pageYOffset; - const difference = Math.abs(currentScrollPosition - targetScrollPosition); - - return difference < threshold; -}; - -function constrain(n, low, high) { - return Math.max(Math.min(n, high), low); -} - -export function map(n, start1, stop1, start2, stop2, withinBounds = true) { - const newval = ((n - start1) / (stop1 - start1)) * (stop2 - start2) + start2; - if (!withinBounds) { - return newval; - } - if (start2 < stop2) { - return constrain(newval, start2, stop2); - } else { - return constrain(newval, stop2, start2); - } -} - -export function flattenObject(obj) { - const result = {}; - - function flatten(current, property) { - if (Object(current) !== current) { - result[property] = current; - } else if (Array.isArray(current)) { - for (let i = 0, l = current.length; i < l; i++) { - flatten(current[i], property + '[' + i + ']'); - if (l === 0) { - result[property] = []; - } - } - } else { - let isEmpty = true; - for (const p in current) { - isEmpty = false; - flatten(current[p], p); - } - if (isEmpty && property) { - result[property] = {}; - } - } - } - - flatten(obj, ''); - return result; -} diff --git a/src/components/ScrollyVideo/js/utils.ts b/src/components/ScrollyVideo/js/utils.ts new file mode 100644 index 00000000..520e8ed4 --- /dev/null +++ b/src/components/ScrollyVideo/js/utils.ts @@ -0,0 +1,96 @@ +import type { ScrollyVideoState } from './state.svelte'; + +type FlattenedScrollyVideoState = { + src: string; + videoPercentage: number; + frameRate: number; + currentTime: number; + totalTime: number; + usingWebCodecs: boolean; + codec: string; + currentFrame: number; + totalFrames: number; + isAutoPlaying: boolean; + autoplayProgress: number; +}; + +export function debounce void>( + func: T, + delay = 0 +) { + let timeoutId: ReturnType | undefined; + + return (...args: Parameters) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + func(...args); + }, delay); + }; +} + +export const isScrollPositionAtTarget = ( + targetScrollPosition: number, + threshold: number = 1 +) => { + const currentScrollPosition = window.pageYOffset; + const difference = Math.abs(currentScrollPosition - targetScrollPosition); + + return difference < threshold; +}; + +function constrain(n: number, low: number, high: number): number { + return Math.max(Math.min(n, high), low); +} + +export function map( + n: number, + start1: number, + stop1: number, + start2: number, + stop2: number, + withinBounds: boolean = true +): number { + const newval = ((n - start1) / (stop1 - start1)) * (stop2 - start2) + start2; + if (!withinBounds) { + return newval; + } + if (start2 < stop2) { + return constrain(newval, start2, stop2); + } else { + return constrain(newval, stop2, start2); + } +} + +export function flattenObject( + obj: ScrollyVideoState +): FlattenedScrollyVideoState { + const result: { [key: string]: unknown } = {}; + + function flatten(current: string | unknown[] | object, property: string) { + if (Object(current) !== current) { + result[property] = current; + } else if (Array.isArray(current)) { + for (let i = 0, l = current.length; i < l; i++) { + flatten(current[i], property + '[' + i + ']'); + if (l === 0) { + result[property] = []; + } + } + } else if (typeof current === 'object') { + let isEmpty = true; + for (const p in current) { + isEmpty = false; + flatten( + (current as { [key: string]: string | object | unknown[] })[p], + p + ); + } + if (isEmpty && property) { + result[property] = {}; + } + } + } + + flatten(obj, ''); + return result as FlattenedScrollyVideoState; +} diff --git a/src/components/ScrollyVideo/js/videoDecoder.d.ts b/src/components/ScrollyVideo/js/videoDecoder.d.ts deleted file mode 100644 index c6f05a14..00000000 --- a/src/components/ScrollyVideo/js/videoDecoder.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare function _default( - src: string, - emitFrame: (frame: ImageBitmap) => void, - debug: boolean -): Promise | Promise; -export default _default; diff --git a/src/components/ScrollyVideo/js/videoDecoder.js b/src/components/ScrollyVideo/js/videoDecoder.ts similarity index 81% rename from src/components/ScrollyVideo/js/videoDecoder.js rename to src/components/ScrollyVideo/js/videoDecoder.ts index b0ac043d..ae59c0d3 100644 --- a/src/components/ScrollyVideo/js/videoDecoder.js +++ b/src/components/ScrollyVideo/js/videoDecoder.ts @@ -1,28 +1,36 @@ import * as MP4Box from 'mp4box'; import { scrollyVideoState } from './state.svelte'; +interface MP4BoxBuffer extends ArrayBuffer { + fileStart: number; +} + /** * Taken from https://github.com/w3c/webcodecs/blob/main/samples/mp4-decode/mp4_demuxer.js */ class Writer { - constructor(size) { + data: Uint8Array; + idx: number; + size: number; + + constructor(size: number) { this.data = new Uint8Array(size); this.idx = 0; this.size = size; } - getData() { + getData(): Uint8Array { if (this.idx !== this.size) throw new Error('Mismatch between size reserved and sized used'); return this.data.slice(0, this.idx); } - writeUint8(value) { + writeUint8(value: number): void { this.data.set([value], this.idx); this.idx += 1; } - writeUint16(value) { + writeUint16(value: number): void { const arr = new Uint16Array(1); arr[0] = value; const buffer = new Uint8Array(arr.buffer); @@ -30,7 +38,7 @@ class Writer { this.idx += 2; } - writeUint8Array(value) { + writeUint8Array(value: number[]): void { this.data.set(value, this.idx); this.idx += value.length; } @@ -42,7 +50,17 @@ class Writer { * @param avccBox * @returns {*} */ -const getExtradata = (avccBox) => { +const getExtradata = (avccBox: { + SPS: string | unknown[]; + PPS: string | unknown[]; + configurationVersion: number; + AVCProfileIndication: number; + profile_compatibility: number; + AVCLevelIndication: number; + lengthSizeMinusOne: number; + nb_SPS_nalus: number; + nb_PPS_nalus: number; +}) => { let i; let size = 7; for (i = 0; i < avccBox.SPS.length; i += 1) { @@ -94,11 +112,19 @@ const getExtradata = (avccBox) => { * @returns {Promise} */ const decodeVideo = ( - src, - emitFrame, - { VideoDecoder, EncodedVideoChunk, debug } -) => - new Promise((resolve, reject) => { + src: string, + emitFrame: (frame: ImageBitmap) => void, + { + VideoDecoder, + EncodedVideoChunk, + debug, + }: { + VideoDecoder: typeof window.VideoDecoder; + EncodedVideoChunk: typeof window.EncodedVideoChunk; + debug: boolean; + } +): Promise => + new Promise((resolve, reject) => { if (debug) console.info('Decoding video from', src); try { @@ -170,16 +196,19 @@ const decodeVideo = ( // Fetches the file into arraybuffers fetch(src).then((res) => { + if (!res.body) throw new Error('Response body is null'); const reader = res.body.getReader(); let offset = 0; - function appendBuffers({ done, value }) { - if (done) { + function appendBuffers( + result: ReadableStreamReadResult + ): Promise { + if (result.done) { mp4boxfile.flush(); - return null; + return Promise.resolve(null); } - const buf = value.buffer; + const buf = result.value.buffer as MP4BoxBuffer; buf.fileStart = offset; offset += buf.byteLength; mp4boxfile.appendBuffer(buf); @@ -203,7 +232,11 @@ const decodeVideo = ( * @param debug * @returns {Promise|Promise*} */ -export default (src, emitFrame, debug) => { +export default ( + src: string, + emitFrame: (frame: ImageBitmap) => void, + debug: boolean = false +) => { // If our browser supports WebCodecs natively if ( typeof VideoDecoder === 'function' &&