@loaders.gl/textures
Version:
Framework-independent loaders for compressed and super compressed (basis) textures
437 lines • 16.7 kB
JavaScript
// loaders.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
import { parseFromContext, path, resolvePath } from '@loaders.gl/loader-utils';
import { ImageLoader, getImageSize, isImage } from '@loaders.gl/images';
import { asyncDeepMap } from "../texture-api/async-deep-map.js";
import { IMAGE_TEXTURE_CUBE_FACES } from "./image-texture-cube.js";
export async function parseCompositeImageManifest(text, expectedShape, options = {}, context) {
const manifest = parseCompositeImageManifestJSON(text);
if (manifest.shape !== expectedShape) {
throw new Error(`Expected ${expectedShape} manifest, got ${manifest.shape}`);
}
return await loadCompositeImageManifest(manifest, options, context);
}
export function testCompositeImageManifestShape(text, shape) {
try {
return parseCompositeImageManifestJSON(text).shape === shape;
}
catch {
return false;
}
}
export async function loadCompositeImageManifest(manifest, options = {}, context) {
const normalizedOptions = normalizeCompositeImageManifestOptions(options);
const urlTree = await getCompositeImageUrlTree(manifest, normalizedOptions, context);
const imageData = await loadCompositeImageUrlTree(urlTree, normalizedOptions, context);
return convertCompositeImageToTexture(manifest.shape, imageData);
}
export async function loadCompositeImageUrlTree(urlTree, options = {}, context) {
const normalizedOptions = normalizeCompositeImageOptions(options);
return await asyncDeepMap(urlTree, async (url) => await loadCompositeImageMember(url, normalizedOptions, context));
}
export async function loadCompositeImageMember(url, options = {}, context) {
const resolvedUrl = resolveCompositeImageUrl(url, options, context);
const fetch = getCompositeImageFetch(options, context);
const response = await fetch(resolvedUrl);
const subloaderOptions = getCompositeImageSubloaderOptions(options);
if (context) {
const childContext = getCompositeImageMemberContext(resolvedUrl, response, context);
return await parseFromContext(response, [ImageLoader], subloaderOptions, childContext);
}
const arrayBuffer = await response.arrayBuffer();
return await ImageLoader.parse(arrayBuffer, subloaderOptions);
}
export async function getCompositeImageUrlTree(manifest, options = {}, context) {
switch (manifest.shape) {
case 'image-texture':
return await getImageTextureSource(manifest, options, context);
case 'image-texture-array':
if (!Array.isArray(manifest.layers) || manifest.layers.length === 0) {
throw new Error('image-texture-array manifest must define one or more layers');
}
return await Promise.all(manifest.layers.map(async (layer, index) => await getNormalizedImageTextureSource(layer, options, context, { index })));
case 'image-texture-cube':
return await getImageTextureCubeUrls(manifest, options, context);
case 'image-texture-cube-array':
if (!Array.isArray(manifest.layers) || manifest.layers.length === 0) {
throw new Error('image-texture-cube-array manifest must define one or more layers');
}
return await Promise.all(manifest.layers.map(async (layer, index) => await getImageTextureCubeUrls(layer, options, context, { index })));
default:
throw new Error('Unsupported composite image manifest');
}
}
export function normalizeCompositeImageOptions(options = {}) {
if (options.core?.baseUrl) {
return options;
}
const fallbackBaseUrl = options.baseUrl;
if (!fallbackBaseUrl) {
return options;
}
return {
...options,
core: {
...options.core,
baseUrl: fallbackBaseUrl
}
};
}
export function resolveCompositeImageUrl(url, options = {}, context) {
const resolvedUrl = resolvePath(url);
if (isAbsoluteCompositeImageUrl(url)) {
return resolvedUrl;
}
const baseUrl = getCompositeImageBaseUrl(options, context);
if (!baseUrl) {
if (resolvedUrl !== url || url.startsWith('@')) {
return resolvedUrl;
}
throw new Error(`Unable to resolve relative image URL ${url} without a base URL`);
}
return resolvePath(joinCompositeImageUrl(baseUrl, url));
}
function parseCompositeImageManifestJSON(text) {
const manifest = JSON.parse(text);
if (!manifest?.shape) {
throw new Error('Composite image manifest must contain a shape field');
}
return manifest;
}
async function getImageTextureSource(manifest, options, context) {
if ((manifest.image || manifest.mipmaps) && manifest.template) {
throw new Error('image-texture manifest must define image, mipmaps, or template source');
}
if (manifest.image && manifest.mipmaps) {
throw new Error('image-texture manifest must define image, mipmaps, or template source');
}
if (manifest.image) {
return manifest.image;
}
if (manifest.mipmaps?.length) {
return manifest.mipmaps;
}
if (manifest.template) {
return await expandImageTextureSource({ mipLevels: manifest.mipLevels ?? 'auto', template: manifest.template }, options, context, {});
}
throw new Error('image-texture manifest must define image, mipmaps, or template source');
}
async function getImageTextureCubeUrls(manifest, options, context, templateOptions = {}) {
const urls = {};
for (const { face, name, direction, axis, sign } of IMAGE_TEXTURE_CUBE_FACES) {
const source = manifest.faces?.[name] || manifest.faces?.[direction];
if (!source) {
throw new Error(`image-texture-cube manifest is missing ${name} face`);
}
urls[face] = await getNormalizedImageTextureSource(source, options, context, {
...templateOptions,
face: name,
direction,
axis,
sign
});
}
return urls;
}
async function getNormalizedImageTextureSource(source, options, context, templateOptions) {
if (typeof source === 'string') {
return source;
}
if (Array.isArray(source) && source.length > 0) {
return source;
}
if (isImageTextureTemplateSource(source)) {
return await expandImageTextureSource(source, options, context, templateOptions);
}
throw new Error('Composite image source entries must be strings or non-empty mip arrays');
}
async function expandImageTextureSource(source, options, context, templateOptions) {
const mipLevels = source.mipLevels === 'auto'
? await getAutoMipLevels(source.template, options, context, templateOptions)
: source.mipLevels;
if (!Number.isFinite(mipLevels) || mipLevels <= 0) {
throw new Error(`Invalid mipLevels value ${source.mipLevels}`);
}
const urls = [];
for (let lod = 0; lod < mipLevels; lod++) {
urls.push(expandTemplate(source.template, { ...templateOptions, lod }));
}
return urls;
}
async function getAutoMipLevels(template, options, context, templateOptions) {
if (!template.includes('{lod}')) {
throw new Error('Template sources with mipLevels: auto must include a {lod} placeholder');
}
const level0Url = expandTemplate(template, { ...templateOptions, lod: 0 });
const image = await loadCompositeImageMember(level0Url, normalizeCompositeImageOptions(options), context);
const { width, height } = getImageSize(image);
return 1 + Math.floor(Math.log2(Math.max(width, height)));
}
function expandTemplate(template, templateOptions) {
let expanded = '';
for (let index = 0; index < template.length; index++) {
const character = template[index];
if (character === '\\') {
const nextCharacter = template[index + 1];
if (nextCharacter === '{' || nextCharacter === '}' || nextCharacter === '\\') {
expanded += nextCharacter;
index++;
continue;
}
throw new Error(`Invalid escape sequence \\${nextCharacter || ''} in template ${template}`);
}
if (character === '}') {
throw new Error(`Unexpected } in template ${template}`);
}
if (character !== '{') {
expanded += character;
continue;
}
const closingBraceIndex = findClosingBraceIndex(template, index + 1);
if (closingBraceIndex < 0) {
throw new Error(`Unterminated placeholder in template ${template}`);
}
const placeholder = template.slice(index + 1, closingBraceIndex);
if (!/^[a-z][a-zA-Z0-9]*$/.test(placeholder)) {
throw new Error(`Invalid placeholder {${placeholder}} in template ${template}`);
}
const value = getTemplateValue(placeholder, templateOptions);
if (value === undefined) {
throw new Error(`Template ${template} uses unsupported placeholder {${placeholder}} for this source`);
}
expanded += String(value);
index = closingBraceIndex;
}
return expanded;
}
function findClosingBraceIndex(template, startIndex) {
for (let index = startIndex; index < template.length; index++) {
const character = template[index];
if (character === '\\') {
index++;
continue;
}
if (character === '{') {
throw new Error(`Nested placeholders are not supported in template ${template}`);
}
if (character === '}') {
return index;
}
}
return -1;
}
function getTemplateValue(placeholder, templateOptions) {
switch (placeholder) {
case 'lod':
return templateOptions.lod;
case 'index':
return templateOptions.index;
case 'face':
return templateOptions.face;
case 'direction':
return templateOptions.direction;
case 'axis':
return templateOptions.axis;
case 'sign':
return templateOptions.sign;
default:
return undefined;
}
}
function isImageTextureTemplateSource(source) {
return typeof source === 'object' && source !== null && !Array.isArray(source);
}
function getCompositeImageBaseUrl(options, context) {
if (context?.baseUrl) {
return context.baseUrl;
}
if (options.baseUrl) {
return stripTrailingSlash(options.baseUrl);
}
if (options.core?.baseUrl) {
return getSourceUrlDirectory(options.core.baseUrl);
}
return null;
}
function stripTrailingSlash(baseUrl) {
if (baseUrl.endsWith('/')) {
return baseUrl.slice(0, -1);
}
return baseUrl;
}
function getSourceUrlDirectory(baseUrl) {
return stripTrailingSlash(path.dirname(baseUrl));
}
function joinCompositeImageUrl(baseUrl, url) {
if (isRequestLikeUrl(baseUrl)) {
return new URL(url, `${stripTrailingSlash(baseUrl)}/`).toString();
}
const normalizedBaseUrl = baseUrl.startsWith('/') ? baseUrl : `/${baseUrl}`;
const normalizedUrl = path.resolve(normalizedBaseUrl, url);
return baseUrl.startsWith('/') ? normalizedUrl : normalizedUrl.slice(1);
}
function isRequestLikeUrl(url) {
return (url.startsWith('http:') ||
url.startsWith('https:') ||
url.startsWith('file:') ||
url.startsWith('blob:'));
}
function getCompositeImageFetch(options, context) {
const fetchOption = options.fetch ?? options.core?.fetch;
if (context?.fetch) {
return context.fetch;
}
if (typeof fetchOption === 'function') {
return fetchOption;
}
if (fetchOption && typeof fetchOption === 'object') {
return (url) => fetch(url, fetchOption);
}
return fetch;
}
function getCompositeImageSubloaderOptions(options) {
const core = options.core;
const rest = { ...options };
delete rest.baseUrl;
if (!core?.baseUrl) {
return rest;
}
const restCore = { ...core };
delete restCore.baseUrl;
return {
...rest,
core: restCore
};
}
function normalizeCompositeImageManifestOptions(options) {
if (options.image?.type || typeof ImageBitmap === 'undefined') {
return options;
}
return {
...options,
image: {
...options.image,
type: 'imagebitmap'
}
};
}
function getCompositeImageMemberContext(resolvedUrl, response, context) {
const url = response.url || resolvedUrl;
const [urlWithoutQueryString, queryString = ''] = url.split('?');
return {
...context,
url,
response,
filename: path.filename(urlWithoutQueryString),
baseUrl: path.dirname(urlWithoutQueryString),
queryString
};
}
function convertCompositeImageToTexture(shape, imageData) {
switch (shape) {
case 'image-texture': {
const data = normalizeCompositeImageMember(imageData);
return {
shape: 'texture',
type: '2d',
format: getCompositeTextureFormat(data),
data
};
}
case 'image-texture-array': {
const data = imageData.map((layer) => normalizeCompositeImageMember(layer));
return {
shape: 'texture',
type: '2d-array',
format: getCompositeTextureFormat(data[0]),
data
};
}
case 'image-texture-cube': {
const data = IMAGE_TEXTURE_CUBE_FACES.map(({ face }) => normalizeCompositeImageMember(imageData[face]));
return {
shape: 'texture',
type: 'cube',
format: getCompositeTextureFormat(data[0]),
data
};
}
case 'image-texture-cube-array': {
const data = imageData.map((layer) => IMAGE_TEXTURE_CUBE_FACES.map(({ face }) => normalizeCompositeImageMember(layer[face])));
return {
shape: 'texture',
type: 'cube-array',
format: getCompositeTextureFormat(data[0][0]),
data
};
}
default:
throw new Error(`Unsupported composite image shape ${shape}`);
}
}
function normalizeCompositeImageMember(imageData) {
if (Array.isArray(imageData)) {
if (imageData.length === 0) {
throw new Error('Composite image members must not be empty');
}
if (imageData.every(isTextureLevel)) {
return imageData;
}
if (imageData.every(isImage)) {
return imageData.map((image) => getTextureLevelFromImage(image));
}
if (imageData.every((entry) => Array.isArray(entry) && entry.every(isTextureLevel))) {
if (imageData.length !== 1) {
throw new Error('Composite image members must resolve to a single image or mip chain');
}
return imageData[0];
}
}
if (isTexture(imageData)) {
if (imageData.type !== '2d') {
throw new Error(`Composite image members must resolve to 2d textures, got ${imageData.type}`);
}
return imageData.data;
}
if (isTextureLevel(imageData)) {
return [imageData];
}
if (isImage(imageData)) {
return [getTextureLevelFromImage(imageData)];
}
throw new Error('Composite image members must resolve to an image, mip chain, or texture');
}
function getTextureLevelFromImage(image) {
const { width, height } = getImageSize(image);
return {
shape: 'texture-level',
compressed: false,
width,
height,
imageBitmap: typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap ? image : undefined,
data: new Uint8Array(0),
textureFormat: 'rgba8unorm'
};
}
function getCompositeTextureFormat(textureLevels) {
return textureLevels[0]?.textureFormat || 'rgba8unorm';
}
function isTextureLevel(textureLevel) {
return Boolean(textureLevel &&
typeof textureLevel === 'object' &&
'shape' in textureLevel &&
textureLevel.shape === 'texture-level');
}
function isTexture(texture) {
return Boolean(texture && typeof texture === 'object' && 'shape' in texture && texture.shape === 'texture');
}
function isAbsoluteCompositeImageUrl(url) {
return (url.startsWith('data:') ||
url.startsWith('blob:') ||
url.startsWith('file:') ||
url.startsWith('http:') ||
url.startsWith('https:') ||
url.startsWith('/'));
}
//# sourceMappingURL=parse-composite-image.js.map