js to ts
This commit is contained in:
parent
f8421b675e
commit
a2502283f8
9 changed files with 524 additions and 353 deletions
|
|
@ -174,7 +174,7 @@
|
|||
<ScrollyVideo
|
||||
{...args}
|
||||
src={videoSrc.Goldengate}
|
||||
useWebCodecs={true}
|
||||
useWebCodecs={false}
|
||||
autoplay={true}
|
||||
></ScrollyVideo>
|
||||
{/key}
|
||||
|
|
|
|||
140
src/components/ScrollyVideo/js/ScrollyVideo.d.ts
vendored
140
src/components/ScrollyVideo/js/ScrollyVideo.d.ts
vendored
|
|
@ -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<void>;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
137
src/components/ScrollyVideo/js/mp4box.d.ts
vendored
Normal file
137
src/components/ScrollyVideo/js/mp4box.d.ts
vendored
Normal file
|
|
@ -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 {};
|
||||
}
|
||||
36
src/components/ScrollyVideo/js/utils.d.ts
vendored
36
src/components/ScrollyVideo/js/utils.d.ts
vendored
|
|
@ -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<T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
delay?: number
|
||||
): (...args: Parameters<T>) => 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
96
src/components/ScrollyVideo/js/utils.ts
Normal file
96
src/components/ScrollyVideo/js/utils.ts
Normal file
|
|
@ -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<T extends (...args: unknown[]) => void>(
|
||||
func: T,
|
||||
delay = 0
|
||||
) {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
declare function _default(
|
||||
src: string,
|
||||
emitFrame: (frame: ImageBitmap) => void,
|
||||
debug: boolean
|
||||
): Promise<never> | Promise<void>;
|
||||
export default _default;
|
||||
|
|
@ -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<unknown>}
|
||||
*/
|
||||
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<unknown> =>
|
||||
new Promise<void>((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<Uint8Array>
|
||||
): Promise<void | null> {
|
||||
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<never>|Promise<void>*}
|
||||
*/
|
||||
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' &&
|
||||
Loading…
Reference in a new issue