@motion-core/motion-gpu
Version:
Framework-agnostic WebGPU runtime for fullscreen WGSL shaders with explicit Svelte, React, and Vue adapter entrypoints.
231 lines (230 loc) • 8.1 kB
JavaScript
//#region src/lib/core/texture-loader.ts
var resourceCache = /* @__PURE__ */ new Map();
function createAbortError() {
try {
return new DOMException("Texture request was aborted", "AbortError");
} catch {
const error = /* @__PURE__ */ new Error("Texture request was aborted");
error.name = "AbortError";
return error;
}
}
/**
* Checks whether error represents abort cancellation.
*/
function isAbortError(error) {
return error instanceof Error && (error.name === "AbortError" || error.message.toLowerCase().includes("aborted"));
}
function toBodyFingerprint(body) {
if (body == null) return null;
if (typeof body === "string") return `string:${body}`;
if (body instanceof URLSearchParams) return `urlsearchparams:${body.toString()}`;
if (typeof FormData !== "undefined" && body instanceof FormData) return `formdata:${Array.from(body.entries()).map(([key, value]) => `${key}:${String(value)}`).join("&")}`;
if (body instanceof Blob) return `blob:${body.type}:${body.size}`;
if (body instanceof ArrayBuffer) return `arraybuffer:${body.byteLength}`;
if (ArrayBuffer.isView(body)) return `view:${body.byteLength}`;
return `opaque:${Object.prototype.toString.call(body)}`;
}
function normalizeRequestInit(requestInit) {
if (!requestInit) return {};
const headers = new Headers(requestInit.headers);
const headerEntries = Array.from(headers.entries()).sort(([a], [b]) => a.localeCompare(b));
const normalized = {};
normalized.method = (requestInit.method ?? "GET").toUpperCase();
normalized.mode = requestInit.mode ?? null;
normalized.cache = requestInit.cache ?? null;
normalized.credentials = requestInit.credentials ?? null;
normalized.redirect = requestInit.redirect ?? null;
normalized.referrer = requestInit.referrer ?? null;
normalized.referrerPolicy = requestInit.referrerPolicy ?? null;
normalized.integrity = requestInit.integrity ?? null;
normalized.keepalive = requestInit.keepalive ?? false;
normalized.priority = requestInit.priority ?? null;
normalized.headers = headerEntries;
normalized.body = toBodyFingerprint(requestInit.body);
return normalized;
}
function normalizeTextureLoadOptions(options) {
const colorSpace = options.colorSpace ?? "srgb";
const normalized = {
colorSpace,
decode: {
colorSpaceConversion: options.decode?.colorSpaceConversion ?? (colorSpace === "linear" ? "none" : "default"),
premultiplyAlpha: options.decode?.premultiplyAlpha ?? "default",
imageOrientation: options.decode?.imageOrientation ?? "none"
}
};
if (options.requestInit !== void 0) normalized.requestInit = options.requestInit;
if (options.signal !== void 0) normalized.signal = options.signal;
if (options.update !== void 0) normalized.update = options.update;
if (options.flipY !== void 0) normalized.flipY = options.flipY;
if (options.premultipliedAlpha !== void 0) normalized.premultipliedAlpha = options.premultipliedAlpha;
if (options.generateMipmaps !== void 0) normalized.generateMipmaps = options.generateMipmaps;
return normalized;
}
/**
* Builds deterministic resource cache key from full URL IO config.
*/
function buildTextureResourceCacheKey(url, options = {}) {
const normalized = normalizeTextureLoadOptions(options);
return JSON.stringify({
url,
colorSpace: normalized.colorSpace,
requestInit: normalizeRequestInit(normalized.requestInit),
decode: normalized.decode
});
}
/**
* Clears the internal texture resource cache.
*/
function clearTextureBlobCache() {
for (const entry of resourceCache.values()) if (!entry.settled) entry.controller.abort();
resourceCache.clear();
}
function acquireTextureBlob(url, options) {
const key = buildTextureResourceCacheKey(url, options);
const existing = resourceCache.get(key);
if (existing) {
existing.refs += 1;
let released = false;
return {
entry: existing,
release: () => {
if (released) return;
released = true;
existing.refs = Math.max(0, existing.refs - 1);
if (existing.refs === 0) {
if (!existing.settled) existing.controller.abort();
resourceCache.delete(key);
}
}
};
}
const normalized = normalizeTextureLoadOptions(options);
const controller = new AbortController();
const requestInit = {
...normalized.requestInit ?? {},
signal: controller.signal
};
const entry = {
key,
refs: 1,
controller,
settled: false,
blobPromise: fetch(url, requestInit).then(async (response) => {
if (!response.ok) throw new Error(`Texture request failed (${response.status}) for ${url}`);
return response.blob();
}).then((blob) => {
entry.settled = true;
return blob;
}).catch((error) => {
resourceCache.delete(key);
throw error;
})
};
resourceCache.set(key, entry);
let released = false;
return {
entry,
release: () => {
if (released) return;
released = true;
entry.refs = Math.max(0, entry.refs - 1);
if (entry.refs === 0) {
if (!entry.settled) entry.controller.abort();
resourceCache.delete(key);
}
}
};
}
async function awaitWithAbort(promise, signal) {
if (!signal) return promise;
if (signal.aborted) throw createAbortError();
return new Promise((resolve, reject) => {
const onAbort = () => {
reject(createAbortError());
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then(resolve, reject).finally(() => {
signal.removeEventListener("abort", onAbort);
});
});
}
/**
* Loads a single texture from URL and converts it to an `ImageBitmap`.
*
* @param url - Texture URL.
* @param options - Loading options.
* @returns Loaded texture object.
* @throws {Error} When runtime does not support `createImageBitmap` or request fails.
*/
async function loadTextureFromUrl(url, options = {}) {
if (typeof createImageBitmap !== "function") throw new Error("createImageBitmap is not available in this runtime");
const normalized = normalizeTextureLoadOptions(options);
const { entry, release } = acquireTextureBlob(url, options);
let bitmap = null;
try {
const blob = await awaitWithAbort(entry.blobPromise, normalized.signal);
const bitmapOptions = {
colorSpaceConversion: normalized.decode.colorSpaceConversion,
premultiplyAlpha: normalized.decode.premultiplyAlpha,
imageOrientation: normalized.decode.imageOrientation
};
bitmap = bitmapOptions.colorSpaceConversion === "default" && bitmapOptions.premultiplyAlpha === "default" && bitmapOptions.imageOrientation === "none" ? await createImageBitmap(blob) : await createImageBitmap(blob, bitmapOptions);
if (normalized.signal?.aborted) {
bitmap.close();
throw createAbortError();
}
let disposed = false;
const loaded = {
url,
source: bitmap,
width: bitmap.width,
height: bitmap.height,
colorSpace: normalized.colorSpace,
dispose: () => {
if (disposed) return;
disposed = true;
bitmap?.close();
bitmap = null;
}
};
if (normalized.update !== void 0) loaded.update = normalized.update;
if (normalized.flipY !== void 0) loaded.flipY = normalized.flipY;
if (normalized.premultipliedAlpha !== void 0) loaded.premultipliedAlpha = normalized.premultipliedAlpha;
if (normalized.generateMipmaps !== void 0) loaded.generateMipmaps = normalized.generateMipmaps;
return loaded;
} catch (error) {
if (bitmap) bitmap.close();
throw error;
} finally {
release();
}
}
/**
* Loads many textures in parallel from URLs.
*
* @param urls - Texture URLs.
* @param options - Shared loading options.
* @returns Promise resolving to loaded textures in input order.
*/
async function loadTexturesFromUrls(urls, options = {}) {
const settled = await Promise.allSettled(urls.map((url) => loadTextureFromUrl(url, options)));
const loaded = [];
let firstError = null;
for (const entry of settled) {
if (entry.status === "fulfilled") {
loaded.push(entry.value);
continue;
}
firstError ??= entry.reason;
}
if (firstError) {
for (const texture of loaded) texture.dispose();
throw firstError;
}
return loaded;
}
//#endregion
export { buildTextureResourceCacheKey, clearTextureBlobCache, isAbortError, loadTextureFromUrl, loadTexturesFromUrls };
//# sourceMappingURL=texture-loader.js.map