UNPKG

@loaders.gl/textures

Version:

Framework-independent loaders for compressed and super compressed (basis) textures

437 lines 16.7 kB
// 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