Files
Foundry-VTT-Docker/resources/app/client/pixi/core/loader.js
2025-01-04 00:34:03 +01:00

513 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* A Loader class which helps with loading video and image textures.
*/
class TextureLoader {
/**
* The duration in milliseconds for which a texture will remain cached
* @type {number}
*/
static CACHE_TTL = 1000 * 60 * 15;
/**
* Record the timestamps when each asset path is retrieved from cache.
* @type {Map<PIXI.BaseTexture|PIXI.Spritesheet,{src:string,time:number}>}
*/
static #cacheTime = new Map();
/**
* A mapping of cached texture data
* @type {WeakMap<PIXI.BaseTexture,Map<string, TextureAlphaData>>}
*/
static #textureDataMap = new WeakMap();
/**
* Create a fixed retry string to use for CORS retries.
* @type {string}
*/
static #retryString = Date.now().toString();
/**
* To know if the basis transcoder has been initialized
* @type {boolean}
*/
static #basisTranscoderInitialized = false;
/* -------------------------------------------- */
/**
* Initialize the basis transcoder for PIXI.Assets
* @returns {Promise<*>}
*/
static async initializeBasisTranscoder() {
if ( this.#basisTranscoderInitialized ) return;
this.#basisTranscoderInitialized = true;
return await PIXI.TranscoderWorker.loadTranscoder(
"scripts/basis_transcoder.js",
"scripts/basis_transcoder.wasm"
);
}
/* -------------------------------------------- */
/**
* Check if a source has a text file extension.
* @param {string} src The source.
* @returns {boolean} If the source has a text extension or not.
*/
static hasTextExtension(src) {
let rgx = new RegExp(`(\\.${Object.keys(CONST.TEXT_FILE_EXTENSIONS).join("|\\.")})(\\?.*)?`, "i");
return rgx.test(src);
}
/* -------------------------------------------- */
/**
* @typedef {Object} TextureAlphaData
* @property {number} width The width of the (downscaled) texture.
* @property {number} height The height of the (downscaled) texture.
* @property {number} minX The minimum x-coordinate with alpha > 0.
* @property {number} minY The minimum y-coordinate with alpha > 0.
* @property {number} maxX The maximum x-coordinate with alpha > 0 plus 1.
* @property {number} maxY The maximum y-coordinate with alpha > 0 plus 1.
* @property {Uint8Array} data The array containing the texture alpha values (0-255)
* with the dimensions (maxX-minX)×(maxY-minY).
*/
/**
* Use the texture to create a cached mapping of pixel alpha and cache it.
* Cache the bounding box of non-transparent pixels for the un-rotated shape.
* @param {PIXI.Texture} texture The provided texture.
* @param {number} [resolution=1] Resolution of the texture data output.
* @returns {TextureAlphaData|undefined} The texture data if the texture is valid, else undefined.
*/
static getTextureAlphaData(texture, resolution=1) {
// If texture is not present
if ( !texture?.valid ) return;
// Get the base tex and the stringified frame + width/height
const width = Math.ceil(Math.round(texture.width * texture.resolution) * resolution);
const height = Math.ceil(Math.round(texture.height * texture.resolution) * resolution);
const baseTex = texture.baseTexture;
const frame = texture.frame;
const sframe = `${frame.x},${frame.y},${frame.width},${frame.height},${width},${height}`;
// Get frameDataMap and textureData if they exist
let textureData;
let frameDataMap = this.#textureDataMap.get(baseTex);
if ( frameDataMap ) textureData = frameDataMap.get(sframe);
// If texture data exists for the baseTex/frame couple, we return it
if ( textureData ) return textureData;
else textureData = {};
// Create a temporary Sprite using the provided texture
const sprite = new PIXI.Sprite(texture);
sprite.width = textureData.width = width;
sprite.height = textureData.height = height;
sprite.anchor.set(0, 0);
// Create or update the alphaMap render texture
const tex = PIXI.RenderTexture.create({width: width, height: height});
canvas.app.renderer.render(sprite, {renderTexture: tex});
sprite.destroy(false);
const pixels = canvas.app.renderer.extract.pixels(tex);
tex.destroy(true);
// Trim pixels with zero alpha
let minX = width;
let minY = height;
let maxX = 0;
let maxY = 0;
for ( let i = 3, y = 0; y < height; y++ ) {
for ( let x = 0; x < width; x++, i += 4 ) {
const alpha = pixels[i];
if ( alpha === 0 ) continue;
if ( x < minX ) minX = x;
if ( x >= maxX ) maxX = x + 1;
if ( y < minY ) minY = y;
if ( y >= maxY ) maxY = y + 1;
}
}
// Special case when the whole texture is alpha 0
if ( minX > maxX ) minX = minY = maxX = maxY = 0;
// Set the bounds of the trimmed region
textureData.minX = minX;
textureData.minY = minY;
textureData.maxX = maxX;
textureData.maxY = maxY;
// Create new buffer for storing the alpha channel only
const data = textureData.data = new Uint8Array((maxX - minX) * (maxY - minY));
for ( let i = 0, y = minY; y < maxY; y++ ) {
for ( let x = minX; x < maxX; x++, i++ ) {
data[i] = pixels[(((width * y) + x) * 4) + 3];
}
}
// Saving the texture data
if ( !frameDataMap ) {
frameDataMap = new Map();
this.#textureDataMap.set(baseTex, frameDataMap);
}
frameDataMap.set(sframe, textureData);
return textureData;
}
/* -------------------------------------------- */
/**
* Load all the textures which are required for a particular Scene
* @param {Scene} scene The Scene to load
* @param {object} [options={}] Additional options that configure texture loading
* @param {boolean} [options.expireCache=true] Destroy other expired textures
* @param {boolean} [options.additionalSources=[]] Additional sources to load during canvas initialize
* @param {number} [options.maxConcurrent] The maximum number of textures that can be loaded concurrently
* @returns {Promise<void[]>}
*/
static loadSceneTextures(scene, {expireCache=true, additionalSources=[], maxConcurrent}={}) {
let toLoad = [];
// Scene background and foreground textures
if ( scene.background.src ) toLoad.push(scene.background.src);
if ( scene.foreground ) toLoad.push(scene.foreground);
if ( scene.fog.overlay ) toLoad.push(scene.fog.overlay);
// Tiles
toLoad = toLoad.concat(scene.tiles.reduce((arr, t) => {
if ( t.texture.src ) arr.push(t.texture.src);
return arr;
}, []));
// Tokens
toLoad.push(CONFIG.Token.ring.spritesheet);
toLoad = toLoad.concat(scene.tokens.reduce((arr, t) => {
if ( t.texture.src ) arr.push(t.texture.src);
if ( t.ring.enabled ) arr.push(t.ring.subject.texture);
return arr;
}, []));
// Control Icons
toLoad = toLoad.concat(Object.values(CONFIG.controlIcons));
// Status Effect textures
toLoad = toLoad.concat(CONFIG.statusEffects.map(e => e.img ?? /** @deprecated since v12 */ e.icon));
// Configured scene textures
toLoad.push(...Object.values(canvas.sceneTextures));
// Additional requested sources
toLoad.push(...additionalSources);
// Load files
const showName = scene.active || scene.visible;
const loadName = showName ? (scene.navName || scene.name) : "...";
return this.loader.load(toLoad, {
message: game.i18n.format("SCENES.Loading", {name: loadName}),
expireCache,
maxConcurrent
});
}
/* -------------------------------------------- */
/**
* Load an Array of provided source URL paths
* @param {string[]} sources The source URLs to load
* @param {object} [options={}] Additional options which modify loading
* @param {string} [options.message] The status message to display in the load bar
* @param {boolean} [options.expireCache=false] Expire other cached textures?
* @param {number} [options.maxConcurrent] The maximum number of textures that can be loaded concurrently.
* @param {boolean} [options.displayProgress] Display loading progress bar
* @returns {Promise<void[]>} A Promise which resolves once all textures are loaded
*/
async load(sources, {message, expireCache=false, maxConcurrent, displayProgress=true}={}) {
sources = new Set(sources);
const progress = {message: message, loaded: 0, failed: 0, total: sources.size, pct: 0};
console.groupCollapsed(`${vtt} | Loading ${sources.size} Assets`);
const loadTexture = async src => {
try {
await this.loadTexture(src);
if ( displayProgress ) TextureLoader.#onProgress(src, progress);
} catch(err) {
TextureLoader.#onError(src, progress, err);
}
};
const promises = [];
if ( maxConcurrent ) {
const semaphore = new foundry.utils.Semaphore(maxConcurrent);
for ( const src of sources ) promises.push(semaphore.add(loadTexture, src));
} else {
for ( const src of sources ) promises.push(loadTexture(src));
}
await Promise.allSettled(promises);
console.groupEnd();
if ( expireCache ) await this.expireCache();
}
/* -------------------------------------------- */
/**
* Load a single texture or spritesheet on-demand from a given source URL path
* @param {string} src The source texture path to load
* @returns {Promise<PIXI.BaseTexture|PIXI.Spritesheet|null>} The loaded texture object
*/
async loadTexture(src) {
const loadAsset = async (src, bustCache=false) => {
if ( bustCache ) src = TextureLoader.getCacheBustURL(src);
if ( !src ) return null;
try {
return await PIXI.Assets.load(src);
} catch ( err ) {
if ( bustCache ) throw err;
return await loadAsset(src, true);
}
};
let asset = await loadAsset(src);
if ( !asset?.baseTexture?.valid ) return null;
if ( asset instanceof PIXI.Texture ) asset = asset.baseTexture;
this.setCache(src, asset);
return asset;
}
/* --------------------------------------------- */
/**
* Use the Fetch API to retrieve a resource and return a Blob instance for it.
* @param {string} src
* @param {object} [options] Options to configure the loading behaviour.
* @param {boolean} [options.bustCache=false] Append a cache-busting query parameter to the request.
* @returns {Promise<Blob>} A Blob containing the loaded data
*/
static async fetchResource(src, {bustCache=false}={}) {
const fail = `Failed to load texture ${src}`;
const req = bustCache ? TextureLoader.getCacheBustURL(src) : src;
if ( !req ) throw new Error(`${fail}: Invalid URL`);
let res;
try {
res = await fetch(req, {mode: "cors", credentials: "same-origin"});
} catch(err) {
// We may have encountered a common CORS limitation: https://bugs.chromium.org/p/chromium/issues/detail?id=409090
if ( !bustCache ) return this.fetchResource(src, {bustCache: true});
throw new Error(`${fail}: CORS failure`);
}
if ( !res.ok ) throw new Error(`${fail}: Server responded with ${res.status}`);
return res.blob();
}
/* -------------------------------------------- */
/**
* Log texture loading progress in the console and in the Scene loading bar
* @param {string} src The source URL being loaded
* @param {object} progress Loading progress
* @private
*/
static #onProgress(src, progress) {
progress.loaded++;
progress.pct = Math.round((progress.loaded + progress.failed) * 100 / progress.total);
SceneNavigation.displayProgressBar({label: progress.message, pct: progress.pct});
console.log(`Loaded ${src} (${progress.pct}%)`);
}
/* -------------------------------------------- */
/**
* Log failed texture loading
* @param {string} src The source URL being loaded
* @param {object} progress Loading progress
* @param {Error} error The error which occurred
* @private
*/
static #onError(src, progress, error) {
progress.failed++;
progress.pct = Math.round((progress.loaded + progress.failed) * 100 / progress.total);
SceneNavigation.displayProgressBar({label: progress.message, pct: progress.pct});
console.warn(`Loading failed for ${src} (${progress.pct}%): ${error.message}`);
}
/* -------------------------------------------- */
/* Cache Controls */
/* -------------------------------------------- */
/**
* Add an image or a sprite sheet url to the assets cache.
* @param {string} src The source URL.
* @param {PIXI.BaseTexture|PIXI.Spritesheet} asset The asset
*/
setCache(src, asset) {
TextureLoader.#cacheTime.set(asset, {src, time: Date.now()});
}
/* -------------------------------------------- */
/**
* Retrieve a texture or a sprite sheet from the assets cache
* @param {string} src The source URL
* @returns {PIXI.BaseTexture|PIXI.Spritesheet|null} The cached texture, a sprite sheet or undefined
*/
getCache(src) {
if ( !src ) return null;
if ( !PIXI.Assets.cache.has(src) ) src = TextureLoader.getCacheBustURL(src) || src;
let asset = PIXI.Assets.get(src);
if ( !asset?.baseTexture?.valid ) return null;
if ( asset instanceof PIXI.Texture ) asset = asset.baseTexture;
this.setCache(src, asset);
return asset;
}
/* -------------------------------------------- */
/**
* Expire and unload assets from the cache which have not been used for more than CACHE_TTL milliseconds.
*/
async expireCache() {
const promises = [];
const t = Date.now();
for ( const [asset, {src, time}] of TextureLoader.#cacheTime.entries() ) {
const baseTexture = asset instanceof PIXI.Spritesheet ? asset.baseTexture : asset;
if ( !baseTexture || baseTexture.destroyed ) {
TextureLoader.#cacheTime.delete(asset);
continue;
}
if ( (t - time) <= TextureLoader.CACHE_TTL ) continue;
console.log(`${vtt} | Expiring cached texture: ${src}`);
promises.push(PIXI.Assets.unload(src));
TextureLoader.#cacheTime.delete(asset);
}
await Promise.allSettled(promises);
}
/* -------------------------------------------- */
/**
* Return a URL with a cache-busting query parameter appended.
* @param {string} src The source URL being attempted
* @returns {string|boolean} The new URL, or false on a failure.
*/
static getCacheBustURL(src) {
const url = URL.parseSafe(src);
if ( !url ) return false;
if ( url.origin === window.location.origin ) return false;
url.searchParams.append("cors-retry", TextureLoader.#retryString);
return url.href;
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async loadImageTexture(src) {
const warning = "TextureLoader#loadImageTexture is deprecated. Use TextureLoader#loadTexture instead.";
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
return this.loadTexture(src);
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
async loadVideoTexture(src) {
const warning = "TextureLoader#loadVideoTexture is deprecated. Use TextureLoader#loadTexture instead.";
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
return this.loadTexture(src);
}
/**
* @deprecated since v12
* @ignore
*/
static get textureBufferDataMap() {
const warning = "TextureLoader.textureBufferDataMap is deprecated without replacement. Use " +
"TextureLoader.getTextureAlphaData to create a texture data map and cache it automatically, or create your own" +
" caching system.";
foundry.utils.logCompatibilityWarning(warning, {since: 12, until: 14});
return this.#textureBufferDataMap;
}
/**
* @deprecated since v12
* @ignore
*/
static #textureBufferDataMap = new Map();
}
/**
* A global reference to the singleton texture loader
* @type {TextureLoader}
*/
TextureLoader.loader = new TextureLoader();
/* -------------------------------------------- */
/**
* Test whether a file source exists by performing a HEAD request against it
* @param {string} src The source URL or path to test
* @returns {Promise<boolean>} Does the file exist at the provided url?
*/
async function srcExists(src) {
return foundry.utils.fetchWithTimeout(src, { method: "HEAD" }).then(resp => {
return resp.status < 400;
}).catch(() => false);
}
/* -------------------------------------------- */
/**
* Get a single texture or sprite sheet from the cache.
* @param {string} src The texture path to load.
* @returns {PIXI.Texture|PIXI.Spritesheet|null} A texture, a sprite sheet or null if not found in cache.
*/
function getTexture(src) {
const asset = TextureLoader.loader.getCache(src);
const baseTexture = asset instanceof PIXI.Spritesheet ? asset.baseTexture : asset;
if ( !baseTexture?.valid ) return null;
return (asset instanceof PIXI.Spritesheet ? asset : new PIXI.Texture(asset));
}
/* -------------------------------------------- */
/**
* Load a single asset and return a Promise which resolves once the asset is ready to use
* @param {string} src The requested asset source
* @param {object} [options] Additional options which modify asset loading
* @param {string} [options.fallback] A fallback texture URL to use if the requested source is unavailable
* @returns {PIXI.Texture|PIXI.Spritesheet|null} The loaded Texture or sprite sheet,
* or null if loading failed with no fallback
*/
async function loadTexture(src, {fallback}={}) {
let asset;
let error;
try {
asset = await TextureLoader.loader.loadTexture(src);
const baseTexture = asset instanceof PIXI.Spritesheet ? asset.baseTexture : asset;
if ( !baseTexture?.valid ) error = new Error(`Invalid Asset ${src}`);
}
catch(err) {
err.message = `The requested asset ${src} could not be loaded: ${err.message}`;
error = err;
}
if ( error ) {
console.error(error);
if ( TextureLoader.hasTextExtension(src) ) return null; // No fallback for spritesheets
return fallback ? loadTexture(fallback) : null;
}
if ( asset instanceof PIXI.Spritesheet ) return asset;
return new PIXI.Texture(asset);
}