@mediamonks/fast-image-sequence
Version:
The fast-image-sequence-renderer is a powerful package that allows you to display a sequence of images at a high frame rate on your website. Zero dependencies.
714 lines (710 loc) • 25 kB
JavaScript
class g {
index;
images = [];
priority = 0;
constructor(e) {
this.index = e;
}
get image() {
return this.images.find((e) => e.image !== void 0)?.image;
}
async getImage() {
return new Promise(async (e, t) => {
if (this.image !== void 0)
e(this.image);
else {
const s = this.images[this.images.length - 1];
s ? s.fetchImage().then((i) => e(i)).catch(() => t()) : t();
}
});
}
async fetchImage() {
return this.images.find((e) => e.available)?.fetchImage();
}
releaseImage() {
this.images.forEach((e) => e.releaseImage());
}
reset() {
this.images.forEach((e) => e.reset());
}
}
function v() {
const o = document.createElement("pre");
return Object.assign(o.style, {
position: "absolute",
top: "0",
left: "0",
backgroundColor: "rgba(0, 0, 0, 0.5)",
color: "white",
padding: "8px",
fontSize: "12px",
zIndex: "1000",
lineHeight: "20px",
margin: 0,
width: "calc(100% - 16px)"
}), o;
}
function b(o, e) {
o.textContent = `${e}`;
}
class I {
available = !0;
loading = !1;
frame;
_image;
context;
constructor(e, t) {
this.context = e, this.frame = t;
}
get image() {
if (this._image !== void 0 && !this.loading)
return this._image;
}
set image(e) {
e !== this._image && (this.releaseImage(), this._image = e);
}
get imageURL() {
return this.context.getImageURL(this.frame.index);
}
reset() {
this.releaseImage(), this._image = void 0;
}
async fetchImage() {
return this.context.fetchImage(this);
}
releaseImage() {
this._image && (this._image instanceof ImageBitmap && this._image.close(), this._image = void 0), this.loading = !1;
}
}
const f = 0, x = 1, p = 2;
class m {
static defaultOptions = {
tarURL: void 0,
imageURL: void 0,
useWorker: !C(),
maxCachedImages: 32,
maxConnectionLimit: 4,
available: void 0,
image: void 0,
timeout: -1
};
options;
index = -0;
initialized = !1;
context;
constructor(e, t, s) {
this.context = e, this.index = t, this.options = { ...m.defaultOptions, ...s }, this.initFrames();
}
initFrames() {
this.context.frames.forEach((e) => e.images[this.index] ||= new I(this, e));
}
get type() {
return p;
}
get maxCachedImages() {
const e = this.initialized ? this.images.filter((t) => t.available).length : this.context.options.frames;
return u(Math.floor(this.options.maxCachedImages), 1, e);
}
get images() {
return this.context.frames.map((e) => e.images[this.index]);
}
/**
* Set the maximum number of images to cache.
* @param maxCache - The maximum number of images to cache.
* @param onProgress - A callback function that is called with the progress of the loading.
*/
setMaxCachedImages(e, t) {
return this.options.maxCachedImages = e, this.context.onLoadProgress(t);
}
getImageURL(e) {
}
checkImageAvailability() {
for (const e of this.images)
e.available = this.available(e, e.available);
if (!this.images[0]?.available)
throw new Error(`No image available for index 0 in ImageSource${this.index} (${this.images[0]?.imageURL})`);
}
async loadResources() {
this.checkImageAvailability(), this.initialized = !0;
}
process(e) {
e();
let { numLoading: t, numLoaded: s } = this.getLoadStatus();
const i = this.options.maxConnectionLimit, a = this.images.filter((r) => r.available && r.image === void 0 && !r.loading && r.frame.priority).sort((r, h) => r.frame.priority - h.frame.priority), c = this.images.filter((r) => r.available && r.image !== void 0 && !r.loading).sort((r, h) => h.frame.priority - r.frame.priority).shift()?.frame.priority ?? 1e10;
for (; t < i && a.length > 0; ) {
const r = a.shift();
(r.frame.priority < c || s < this.maxCachedImages - t) && (r.loading = !0, this.fetchImage(r).then((h) => {
r.loading && (r.loading = !1, r.image = h, e(), this.releaseImageWithLowestPriority());
}).catch((h) => {
r.reset(), console.error(h);
})), t++;
}
}
getLoadStatus() {
const e = this.images.filter((a) => a.loading).length, t = this.images.filter((a) => a.image !== void 0).length, s = this.maxCachedImages;
return { progress: Math.max(0, t - e) / Math.max(1, s), numLoading: e, numLoaded: t, maxLoaded: s };
}
async fetchImage(e) {
return this.options.image ? this.options.image(e.frame.index) : new Promise((t, s) => {
s("Not implemented");
});
}
destruct() {
this.images.forEach((e) => e.reset());
}
available(e, t = !0) {
return this.options.available ? t && this.options.available(e.frame.index) : t;
}
releaseImageWithLowestPriority() {
const e = this.images.filter((t) => t.image !== void 0 && !t.loading);
if (e.length > this.maxCachedImages) {
const t = e.sort((s, i) => s.frame.priority - i.frame.priority).pop();
t && t.releaseImage();
}
}
}
function y(o, e) {
return new Promise((t, s) => {
const i = new XMLHttpRequest();
i.open("GET", o, !0), i.responseType = "arraybuffer", i.onprogress = function(a) {
if (a.lengthComputable && e) {
const n = a.loaded / a.total;
e(n);
}
}, i.onload = function() {
i.status === 200 ? (e && e(1), t(i.response)) : s(new Error(`Error ${i.status}: ${i.statusText}`));
}, i.onerror = function() {
s(new Error("Request failed"));
}, i.send();
});
}
function L(o, e) {
return new Promise((t, s) => {
o.onerror = (i) => s(i), o.decoding = "async", o.src = e, o.decode().then(() => {
t(o);
}).catch((i) => {
console.error(i), s(i);
});
});
}
const R = `let buffer;
self.onmessage = async (e) => {
if (e.data.cmd === 'init') {
buffer = e.data.buffer;
} else if (e.data.cmd === 'load') {
loadImage(e.data.offset, e.data.size, e.data.index);
}
};
async function loadImage(offset, size, index) {
const view = new Uint8Array(buffer, offset, size);
const blob = new Blob([view], {});
const imageBitmap = await createImageBitmap(blob);
postMessage({msg: 'done', imageBitmap, index}, [imageBitmap]);
}`;
class F {
fileInfo = [];
buffer;
options;
worker;
resolve = [];
defaultOptions = {
useWorker: !0
};
constructor(e, t = {}) {
this.buffer = e, this.options = { ...this.defaultOptions, ...t };
let s = 0;
for (; s < this.buffer.byteLength - 512; ) {
const i = this.readFileName(s);
if (i.length == 0)
break;
const a = this.readFileSize(s);
this.fileInfo.push({
name: i,
size: a,
header_offset: s
}), s += 512 + 512 * Math.trunc(a / 512), a % 512 && (s += 512);
}
}
getInfo(e) {
return this.fileInfo.find((t) => t.name.includes(e));
}
getImage(e, t) {
return this.options.useWorker ? (this.worker || (this.worker = this.createWorker()), new Promise((s, i) => {
const a = this.getInfo(e);
a && !this.resolve[t] ? (this.resolve[t] = s, this.worker.postMessage({ cmd: "load", offset: a.header_offset + 512, size: a.size, index: t })) : i("Image already loading from tar");
})) : new Promise((s, i) => {
const a = this.getBlob(e, "image");
a !== void 0 ? createImageBitmap(a).then((n) => {
s(n);
}).catch(() => {
i();
}) : i();
});
}
destruct() {
this.worker && this.worker.terminate(), this.resolve = [];
}
readFileName(e) {
const t = new Uint8Array(this.buffer, e, 100), s = t.indexOf(0);
return new TextDecoder().decode(t.slice(0, s));
}
readFileSize(e) {
const t = new Uint8Array(this.buffer, e + 124, 12);
let s = "";
for (let i = 0; i < 11; i++)
s += String.fromCharCode(t[i]);
return parseInt(s, 8);
}
// worker functionality
getBlob(e, t = "") {
const s = this.getInfo(e);
if (s) {
const i = new Uint8Array(this.buffer, s.header_offset + 512, s.size);
return new Blob([i], { type: t });
}
}
createWorker() {
const e = new Blob([R], { type: "application/javascript" }), t = new Worker(URL.createObjectURL(e));
return t.addEventListener("message", (s) => {
const i = this.resolve[s.data.index];
this.resolve[s.data.index] = void 0, i ? i(s.data.imageBitmap) : s.data.imageBitmap.close();
}), t.postMessage({ cmd: "init", buffer: this.buffer }, [this.buffer]), t;
}
}
class k extends m {
tarball;
tarLoadProgress = 0;
get type() {
return x;
}
async loadResources() {
if (this.options.tarURL !== void 0) {
const e = await y(this.options.tarURL, (t) => {
this.tarLoadProgress = t;
});
this.tarball = new F(e, { useWorker: this.options.useWorker }), this.context.log("Tarball", this.tarball);
}
return super.loadResources();
}
getImageURL(e) {
return this.options.imageURL ? this.options.imageURL(e) : void 0;
}
getLoadStatus() {
const e = super.getLoadStatus();
return e.progress = this.tarLoadProgress / 2 + e.progress / 2, e;
}
async fetchImage(e) {
return new Promise((t, s) => {
e.available ? this.tarball?.getImage(e.imageURL || "", e.frame.index).then((i) => {
t(i);
}).catch((i) => {
s(i);
}) : s(`Image not available or already loading ${e.imageURL} ${e.loading}`);
});
}
destruct() {
super.destruct(), this.tarball?.destruct(), this.tarball = void 0;
}
available(e, t = !0) {
return t = t && e.imageURL !== void 0 && this.tarball?.getInfo(e.imageURL) !== void 0, super.available(e, t);
}
}
const P = `self.onmessage = async (e) => {
if (e.data.cmd === 'load') {
await loadImage(e.data.url, e.data.index);
}
};
async function loadImage(url, index) {
const response = await fetch(url);
if (!response.ok) throw "network error";
const imageBitmap = await createImageBitmap(await response.blob());
postMessage({msg: 'done', imageBitmap, index}, [imageBitmap]);
}`;
class U {
index = -1e10;
worker;
resolve;
constructor() {
const e = new Blob([P], { type: "application/javascript" }), t = new Worker(URL.createObjectURL(e));
t.addEventListener("message", (s) => {
this.resolve && s.data.index === this.index ? this.resolve(s.data.imageBitmap) : s.data.imageBitmap.close();
}), this.worker = t;
}
load(e, t) {
return this.index = e, new Promise((s, i) => {
this.resolve = s, this.worker.postMessage({ cmd: "load", url: t, index: e });
});
}
abort() {
this.index = -1e10, this.resolve = void 0;
}
}
const d = [];
function z() {
return d.length === 0 && d.push(new U()), d.shift();
}
function O(o) {
o.abort(), d.push(o);
}
class A extends m {
get type() {
return f;
}
getImageURL(e) {
return this.options.imageURL ? new URL(this.options.imageURL(e), window.location.href).href : void 0;
}
async fetchImage(e) {
return new Promise((t, s) => {
if (e.imageURL)
if (this.options.useWorker) {
const i = z();
i.load(this.index, e.imageURL).then((a) => {
t(a), O(i);
}).catch((a) => s(a));
} else {
const i = new Image();
L(i, e.imageURL).then(() => {
t(i);
}).catch((a) => s(a));
}
else
s("Image url not set or image already loading");
});
}
}
function C() {
return typeof navigator < "u" && /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
function u(o, e, t) {
return Math.min(Math.max(o, e), t);
}
class w {
static defaultOptions = {
frames: 1,
src: [],
loop: !1,
poster: void 0,
fillStyle: "#00000000",
objectFit: "cover",
clearCanvas: !1,
// clear canvas before drawing
showDebugInfo: !1,
name: "FastImageSequence",
horizontalAlign: 0.5,
verticalAlign: 0.5,
scale: 1
};
canvas;
options;
width = 0;
height = 0;
frame = 0;
log;
frames = [];
sources = [];
context;
tickFunctions = [];
startTime = -1;
animationRequestId = 0;
container;
resizeObserver;
mutationObserver;
inViewportObserver;
forceRedraw = !0;
speed = 0;
prevFrame = 0;
direction = 1;
lastFrameDrawn = -1;
destructed = !1;
logElement;
initialized = !1;
posterImage;
timeFrameVisible = 0;
lastImageDrawn;
inViewport = !1;
containerWidth = 0;
containerHeight = 0;
/**
* Creates an instance of FastImageSequence.
*
* @param {HTMLElement} container - The HTML element where the image sequence will be displayed.
* @param {FastImageSequenceOptions} options - The options for the image sequence.
*
* @throws {Error} If the number of frames is less than or equal to 0.
*/
constructor(e, t) {
if (this.options = { ...w.defaultOptions, ...t }, this.options.frames <= 0)
throw new Error("FastImageSequence: frames must be greater than 0");
this.container = e, this.canvas = document.createElement("canvas"), this.context = this.canvas.getContext("2d"), this.context.fillStyle = this.options.fillStyle, this.context.clearRect(0, 0, this.canvas.width, this.canvas.height), Object.assign(this.canvas.style, {
inset: "0",
width: "100%",
height: "100%",
margin: "0",
display: "block"
}), this.container.appendChild(this.canvas), this.resizeObserver = new ResizeObserver(() => {
this.forceRedraw = !0, this.containerWidth = e.offsetWidth, this.containerHeight = e.offsetHeight, this.lastFrameDrawn < 0 && this.posterImage && this.drawImage(this.posterImage);
}), this.resizeObserver.observe(this.canvas), this.mutationObserver = new MutationObserver(() => {
this.container.isConnected || (console.error("FastImageSequence: container is not connected to the DOM, fast image sequence will be destroyed"), this.destruct());
}), this.mutationObserver.observe(e, { childList: !0 }), this.inViewportObserver = new IntersectionObserver((i) => {
for (const a of i)
this.inViewport = a.isIntersecting;
}), this.inViewportObserver.observe(this.canvas), this.frames = Array.from({ length: this.options.frames }, (i, a) => new g(a)), this.log = this.options.showDebugInfo ? console.log : () => {
};
const s = this.options.src instanceof Array ? this.options.src : [this.options.src];
this.sources = s.map((i, a) => i.tarURL !== void 0 ? new k(this, a, i) : i.imageURL !== void 0 ? new A(this, a, i) : new m(this, a, i)), this.loadResources().then(() => {
this.initialized = !0, this.log("Frames", this.frames), this.log("Options", this.options), this.options.showDebugInfo && (this.logElement = v(), this.container.appendChild(this.logElement), this.tick(() => this.logDebugStatus(this.logElement))), this.drawingLoop(-1);
});
}
/**
* Get whether the image sequence is playing.
*/
get playing() {
return this.speed !== 0;
}
/**
* Get whether the image sequence is paused.
*/
get paused() {
return !this.playing;
}
/**
* Get the current progress of the image sequence loading.
*/
get loadProgress() {
return this.sources.reduce((e, t) => e + t.getLoadStatus().progress, 0) / this.sources.length;
}
/**
* Get the current progress of the image sequence.
* @returns {number} - The current progress of the image sequence.
*/
get progress() {
return this.index / (this.options.frames - 1);
}
/**
* Set the current progress of the image sequence.
* @param {number} value - The progress value to set.
*/
set progress(e) {
this.frame = (this.options.frames - 1) * e;
}
/**
* Set the scale of the image sequence. Default is 1.
* @param value
*/
set scale(e) {
this.forceRedraw = this.options.scale !== e, this.options.scale = e;
}
/**
* Get the scale of the image sequence.
* @returns {number} - The scale of the image sequence.
*/
get scale() {
return this.options.scale;
}
/**
* Set the horizontal alignment of the image sequence. Default is 0.5.
* @param value
*/
set horizontalAlign(e) {
this.forceRedraw = this.options.scale !== e, this.options.horizontalAlign = e;
}
/**
* Get the horizontal alignment of the image sequence.
*/
get horizontalAlign() {
return this.options.horizontalAlign;
}
/**
* Set the vertical alignment of the image sequence. Default is 0.5.
* @param value
*/
set verticalAlign(e) {
this.forceRedraw = this.options.scale !== e, this.options.verticalAlign = e;
}
/**
* Get the vertical alignment of the image sequence.
*/
get verticalAlign() {
return this.options.verticalAlign;
}
/**
* Get the first ImageSource from the sources array.
* @returns {ImageSource} - The first ImageSource object in the sources array.
*/
get src() {
return this.sources[0];
}
/**
* Set number of frames in the image sequence.
*/
set frameCount(e) {
for (const s of this.frames)
s.reset();
this.forceRedraw = !0;
const t = Math.max(1, e | 0);
this.options.frames = t, t < this.frames.length ? this.frames = Array.from({ length: t }, (s, i) => new g(i)) : t > this.frames.length && (this.frames = this.frames.concat(Array.from({ length: t - this.frames.length }, (s, i) => new g(i + this.frames.length))));
for (const s of this.sources)
s.initFrames(), s.checkImageAvailability();
}
/**
* Get number of frames in the image sequence.
*/
get frameCount() {
return this.options.frames;
}
get index() {
return this.wrapIndex(this.frame);
}
/**
* Returns a promise that resolves when the image sequence is ready to play.
*/
ready() {
return new Promise((e) => {
const t = () => {
this.sources.every((s) => s.initialized) ? e() : setTimeout(t, 16);
};
t();
});
}
/**
* Register a tick function to be called on every frame update.
*
* @param func - The function to be called.
*/
tick(e) {
this.tickFunctions.push(e);
}
/**
* Start playing the image sequence at a specified frame rate.
* @param {number} [fps=30] - The frame rate to play the sequence at.
*/
play(e = 30) {
this.speed = e;
}
/**
* Stop playing the image sequence.
*/
stop() {
this.speed = 0;
}
/**
* Get the image of a specific frame.
* @param {number} index - The index of the frame.
* @returns {Promise<HTMLImageElement | ImageBitmap | undefined>} - A promise that resolves with the image of the frame.
*/
async getFrameImage(e) {
return await this.frames[this.wrapIndex(e)].fetchImage();
}
/**
* Register a callback function that is called with the progress of the loading.
* The function returns a promise that resolves when progress reaches 1.
* @param onProgress - A callback function that is called with the progress of the loading.
*/
async onLoadProgress(e) {
let t = this.loadProgress;
return new Promise((s) => {
const i = () => {
this.loadProgress >= 1 ? (e && e(1), s(!0)) : (e && t !== this.loadProgress && (e(this.loadProgress), t = this.loadProgress), setTimeout(i, 16));
};
i();
});
}
/**
* Destruct the FastImageSequence instance.
*/
destruct() {
this.destructed || (this.destructed = !0, this.animationRequestId && cancelAnimationFrame(this.animationRequestId), this.resizeObserver.disconnect(), this.mutationObserver.disconnect(), this.inViewportObserver.disconnect(), this.container.removeChild(this.canvas), this.logElement && (this.container.removeChild(this.logElement), this.logElement = void 0), this.canvas.replaceWith(this.canvas.cloneNode(!0)), this.sources.forEach((e) => e.destruct()), this.frames.forEach((e) => e.releaseImage()));
}
/**
* Set the size and alignment of the image sequence on the canvas.
*
* @param {Partial<FastImageSequenceDisplayOptions>} options - An object containing the size and alignment options.
* @property {string} options.objectFit - How the image should be resized to fit the canvas. It can be either 'contain' or 'cover'.
* @property {number} options.horizontalAlign - The horizontal alignment of the image. It should be a number between 0 and 1.
* @property {number} options.verticalAlign - The vertical alignment of the image. It should be a number between 0 and 1.
*/
setDisplayOptions(e) {
this.options = { ...this.options, ...e }, this.forceRedraw = !0;
}
setLoadingPriority() {
const e = this.index;
for (const t of this.frames)
t.priority = Math.abs(t.index + 0.25 - e), this.options.loop && (t.priority = Math.min(t.priority, this.options.frames - t.priority));
}
async loadResources() {
if (this.options.poster) {
this.log("Poster image", this.options.poster);
const e = new Image();
e.src = this.options.poster, await e.decode().then(() => {
this.posterImage = e, this.lastFrameDrawn < 0 && this.drawImage(this.posterImage);
}).catch((t) => this.log(t));
}
await Promise.all(this.sources.map((e) => e.loadResources())), await this.getFrameImage(0);
}
wrapIndex(e) {
const t = e | 0;
return this.wrapFrame(t);
}
wrapFrame(e) {
return this.options.loop ? (e % this.options.frames + this.options.frames) % this.options.frames : u(e, 0, this.options.frames - 1);
}
async drawingLoop(e = 0) {
if (this.destructed)
return;
e /= 1e3;
const t = this.initialized ? this.startTime < 0 ? 1 / 60 : Math.min(e - this.startTime, 1 / 30) : 0;
if (this.startTime = e > 0 ? e : -1, this.frame - this.prevFrame < 0 && (this.direction = -1), this.frame - this.prevFrame > 0 && (this.direction = 1), this.frame += this.speed * t, this.frame = this.wrapFrame(this.frame), this.inViewport) {
const s = this.index;
for (const a of this.frames) {
a.priority = Math.abs(a.index - s);
let n = Math.sign(this.frame - this.prevFrame);
if (this.options.loop) {
const c = this.options.frames - a.priority;
c < a.priority && (a.priority = c);
}
a.priority += this.direction * n === -1 ? this.frames.length : 0;
}
this.frames.sort((a, n) => n.priority - a.priority);
const i = this.frames.filter((a) => a.image !== void 0).pop();
i && this.drawFrame(i);
}
this.wrapIndex(this.frame) === this.wrapIndex(this.prevFrame) ? this.timeFrameVisible += t : this.timeFrameVisible = 0, this.process(), this.tickFunctions.forEach((s) => s(t)), this.prevFrame = this.frame, this.animationRequestId = requestAnimationFrame((s) => this.drawingLoop(s));
}
drawFrame(e) {
const t = e.image;
!t || e.index >= this.options.frames || (this.lastFrameDrawn = e.index, this.drawImage(t));
}
drawImage(e) {
const t = e.naturalWidth || e.width || e.videoWidth, s = e.naturalHeight || e.height || e.videoHeight, i = this.containerWidth / this.containerHeight, a = t / s;
if (this.width = Math.max(this.width, t), this.height = Math.max(this.height, s), this.options.objectFit === "contain") {
const h = (i > a ? this.height * i : this.width) | 0, l = (i > a ? this.height : this.width / i) | 0;
(this.canvas.width !== h || this.canvas.height !== l) && (this.canvas.width = h, this.canvas.height = l);
} else {
const h = (i > a ? this.width : this.height * i) | 0, l = (i > a ? this.width / i : this.height) | 0;
(this.canvas.width !== h || this.canvas.height !== l) && (this.canvas.width = h, this.canvas.height = l);
}
const n = this.options.scale, c = (this.canvas.width - this.width * n) * this.options.horizontalAlign, r = (this.canvas.height - this.height * n) * this.options.verticalAlign;
(this.forceRedraw || this.options.clearCanvas) && this.context.clearRect(0, 0, this.canvas.width, this.canvas.height), (this.forceRedraw || this.options.clearCanvas || this.lastImageDrawn !== e) && (this.context.drawImage(e, 0, 0, t, s, c, r, this.width * n, this.height * n), this.lastImageDrawn = e), this.forceRedraw = !1;
}
process() {
for (const e of this.sources)
this.timeFrameVisible >= e.options.timeout / 1e3 && e.process(() => this.setLoadingPriority());
}
logDebugStatus(e) {
const t = (i) => `${Math.abs(i * 100).toFixed(1).padStart(5, " ")}%`;
let s = `${this.options.name} - frames: ${this.frames.length}, loop: ${this.options.loop}, objectFit: ${this.options.objectFit}
loadProgress ${t(this.loadProgress)}, last frame drawn ${this.lastFrameDrawn}/${this.index}
`;
for (const i of this.sources) {
const { progress: a, numLoading: n, numLoaded: c, maxLoaded: r } = i.getLoadStatus();
s += ` src[${i.index}] ${i.type === f ? "image:" : i.type === p ? "code: " : "tar: "} ${t(a)}, numLoading: ${n}, numLoaded: ${c}/${r}${i.options.useWorker ? ", use worker" : ""}
`;
}
b(e, s);
}
}
export {
w as FastImageSequence,
u as clamp,
C as isMobile
};