UNPKG

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