@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.
31 lines (27 loc) • 17.4 kB
JavaScript
(function(c,m){typeof exports=="object"&&typeof module<"u"?m(exports):typeof define=="function"&&define.amd?define(["exports"],m):(c=typeof globalThis<"u"?globalThis:c||self,m(c["fast-image-sequence"]={}))})(this,function(c){"use strict";class m{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 I(){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 x(o,e){o.textContent=`${e}`}class y{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 w=0,L=1,v=2;class d{static defaultOptions={tarURL:void 0,imageURL:void 0,useWorker:!b(),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={...d.defaultOptions,...s},this.initFrames()}initFrames(){this.context.frames.forEach(e=>e.images[this.index]||=new y(this,e))}get type(){return v}get maxCachedImages(){const e=this.initialized?this.images.filter(t=>t.available).length:this.context.options.frames;return p(Math.floor(this.options.maxCachedImages),1,e)}get images(){return this.context.frames.map(e=>e.images[this.index])}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),l=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<l||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 R(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 F(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 k=`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 P{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)}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([k],{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 U extends d{tarball;tarLoadProgress=0;get type(){return L}async loadResources(){if(this.options.tarURL!==void 0){const e=await R(this.options.tarURL,t=>{this.tarLoadProgress=t});this.tarball=new P(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 O=`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 z{index=-1e10;worker;resolve;constructor(){const e=new Blob([O],{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 f=[];function S(){return f.length===0&&f.push(new z),f.shift()}function A(o){o.abort(),f.push(o)}class M extends d{get type(){return w}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=S();i.load(this.index,e.imageURL).then(a=>{t(a),A(i)}).catch(a=>s(a))}else{const i=new Image;F(i,e.imageURL).then(()=>{t(i)}).catch(a=>s(a))}else s("Image url not set or image already loading")})}}function b(){return typeof navigator<"u"&&/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)}function p(o,e,t){return Math.min(Math.max(o,e),t)}class u{static defaultOptions={frames:1,src:[],loop:!1,poster:void 0,fillStyle:"#00000000",objectFit:"cover",clearCanvas:!1,showDebugInfo:!1,name:"FastImageSequence",horizontalAlign:.5,verticalAlign:.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;constructor(e,t){if(this.options={...u.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 m(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 U(this,a,i):i.imageURL!==void 0?new M(this,a,i):new d(this,a,i)),this.loadResources().then(()=>{this.initialized=!0,this.log("Frames",this.frames),this.log("Options",this.options),this.options.showDebugInfo&&(this.logElement=I(),this.container.appendChild(this.logElement),this.tick(()=>this.logDebugStatus(this.logElement))),this.drawingLoop(-1)})}get playing(){return this.speed!==0}get paused(){return!this.playing}get loadProgress(){return this.sources.reduce((e,t)=>e+t.getLoadStatus().progress,0)/this.sources.length}get progress(){return this.index/(this.options.frames-1)}set progress(e){this.frame=(this.options.frames-1)*e}set scale(e){this.forceRedraw=this.options.scale!==e,this.options.scale=e}get scale(){return this.options.scale}set horizontalAlign(e){this.forceRedraw=this.options.scale!==e,this.options.horizontalAlign=e}get horizontalAlign(){return this.options.horizontalAlign}set verticalAlign(e){this.forceRedraw=this.options.scale!==e,this.options.verticalAlign=e}get verticalAlign(){return this.options.verticalAlign}get src(){return this.sources[0]}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 m(i)):t>this.frames.length&&(this.frames=this.frames.concat(Array.from({length:t-this.frames.length},(s,i)=>new m(i+this.frames.length))));for(const s of this.sources)s.initFrames(),s.checkImageAvailability()}get frameCount(){return this.options.frames}get index(){return this.wrapIndex(this.frame)}ready(){return new Promise(e=>{const t=()=>{this.sources.every(s=>s.initialized)?e():setTimeout(t,16)};t()})}tick(e){this.tickFunctions.push(e)}play(e=30){this.speed=e}stop(){this.speed=0}async getFrameImage(e){return await this.frames[this.wrapIndex(e)].fetchImage()}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(){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()))}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+.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:p(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 l=this.options.frames-a.priority;l<a.priority&&(a.priority=l)}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,g=(i>a?this.height:this.width/i)|0;(this.canvas.width!==h||this.canvas.height!==g)&&(this.canvas.width=h,this.canvas.height=g)}else{const h=(i>a?this.width:this.height*i)|0,g=(i>a?this.width/i:this.height)|0;(this.canvas.width!==h||this.canvas.height!==g)&&(this.canvas.width=h,this.canvas.height=g)}const n=this.options.scale,l=(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,l,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:l,maxLoaded:r}=i.getLoadStatus();s+=` src[${i.index}] ${i.type===w?"image:":i.type===v?"code: ":"tar: "} ${t(a)}, numLoading: ${n}, numLoaded: ${l}/${r}${i.options.useWorker?", use worker":""}
`}x(e,s)}}c.FastImageSequence=u,c.clamp=p,c.isMobile=b,Object.defineProperty(c,Symbol.toStringTag,{value:"Module"})});