This commit is contained in:
Sudev Kiyada 2025-06-03 13:09:21 +05:30
parent f8421b675e
commit a2502283f8
Failed to extract signature
9 changed files with 524 additions and 353 deletions

View file

@ -174,7 +174,7 @@
<ScrollyVideo
{...args}
src={videoSrc.Goldengate}
useWebCodecs={true}
useWebCodecs={false}
autoplay={true}
></ScrollyVideo>
{/key}

View file

@ -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;
}

View file

@ -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;

View 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 {};
}

View file

@ -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;

View file

@ -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;
}

View 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;
}

View file

@ -1,6 +0,0 @@
declare function _default(
src: string,
emitFrame: (frame: ImageBitmap) => void,
debug: boolean
): Promise<never> | Promise<void>;
export default _default;

View file

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