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' &&