diff --git a/common/ExifIO.ts b/common/ExifIO.ts index 5f441951..f6d7f98d 100644 --- a/common/ExifIO.ts +++ b/common/ExifIO.ts @@ -304,6 +304,11 @@ class ExifIO { // } // } + /** + * Extracts the width and height resolution of an image file from its exif data. + * @param filepath The file to read the resolution from + * @returns The width and height of the image, or width and height as 0 if the resolution could not be determined. + */ async getDimensions(filepath: string): Promise<{ width: number; height: number }> { let metadata: Awaited> | undefined = undefined; try { diff --git a/package.json b/package.json index e82f7aaf..09816edb 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "dependencies": { "@floating-ui/core": "^1.0.1", "@floating-ui/react-dom": "^1.0.0", + "ag-psd": "^14.5.3", "chokidar": "^3.5.2", "comlink": "^4.3.1", "dexie": "^3.2.2", diff --git a/resources/style/inspector.scss b/resources/style/inspector.scss index a0c8baa4..5199d76a 100644 --- a/resources/style/inspector.scss +++ b/resources/style/inspector.scss @@ -82,6 +82,14 @@ table#file-info { td { padding-left: 1rem; word-break: break-word; + + line-height: normal; + display: block; + + // for fields with loads of text (like descriptions), limit the height + max-height: 200px; + overflow-y: auto; + // resize: vertical; // would be nice to enable only when overflowing } a { diff --git a/src/backend/backend.ts b/src/backend/backend.ts index 82e09b9e..5e0d5b0e 100644 --- a/src/backend/backend.ts +++ b/src/backend/backend.ts @@ -15,7 +15,7 @@ import { IDataStorage } from '../api/data-storage'; /** * The backend of the application serves as an API, even though it runs on the same machine. - * This helps code organization by enforcing a clear seperation between backend/frontend logic. + * This helps code organization by enforcing a clear separation between backend/frontend logic. * Whenever we want to change things in the backend, this should have no consequences in the frontend. * The backend has access to the database, which is exposed to the frontend through a set of endpoints. */ diff --git a/src/entities/File.ts b/src/entities/File.ts index d1072de5..a15aeaa9 100644 --- a/src/entities/File.ts +++ b/src/entities/File.ts @@ -7,12 +7,12 @@ import { reaction, } from 'mobx'; import Path from 'path'; -import ExifIO from 'common/ExifIO'; +import ImageLoader from 'src/frontend/image/ImageLoader'; import FileStore from 'src/frontend/stores/FileStore'; import { FileStats } from 'src/frontend/stores/LocationStore'; +import { FileDTO, IMG_EXTENSIONS_TYPE } from '../api/file'; import { ID } from '../api/id'; import { ClientTag } from './Tag'; -import { FileDTO, IMG_EXTENSIONS_TYPE } from '../api/file'; /** Retrieved file meta data information */ interface IMetaData { @@ -175,9 +175,9 @@ export class ClientFile { } /** Should be called when after constructing a file before sending it to the backend. */ -export async function getMetaData(stats: FileStats, exifIO: ExifIO): Promise { +export async function getMetaData(stats: FileStats, imageLoader: ImageLoader): Promise { const path = stats.absolutePath; - const dimensions = await exifIO.getDimensions(path); + const dimensions = await imageLoader.getImageResolution(stats.absolutePath); return { name: Path.basename(path), diff --git a/src/frontend/containers/Settings/index.tsx b/src/frontend/containers/Settings/index.tsx index bf1eb964..289d67b3 100644 --- a/src/frontend/containers/Settings/index.tsx +++ b/src/frontend/containers/Settings/index.tsx @@ -384,11 +384,6 @@ const imageFormatInts: Partial> = { {IconSet.WARNING} ), - psd: ( - - {IconSet.INFO} - - ), }; const ImageFormatPicker = observer(() => { diff --git a/src/frontend/image/ExrLoader.ts b/src/frontend/image/ExrLoader.ts index 2c4a289f..ae17e421 100644 --- a/src/frontend/image/ExrLoader.ts +++ b/src/frontend/image/ExrLoader.ts @@ -6,8 +6,8 @@ class ExrLoader implements Loader { await init(new URL('wasm/packages/exr/exr_decoder_bg.wasm', import.meta.url)); } - decode(buffer: Buffer): ImageData { - return decode(buffer); + decode(buffer: Buffer): Promise { + return Promise.resolve(decode(buffer)); } } diff --git a/src/frontend/image/ImageLoader.ts b/src/frontend/image/ImageLoader.ts index bada06e8..2a7f3891 100644 --- a/src/frontend/image/ImageLoader.ts +++ b/src/frontend/image/ImageLoader.ts @@ -9,11 +9,13 @@ import { generateThumbnailUsingWorker } from './ThumbnailGeneration'; import StreamZip from 'node-stream-zip'; import ExrLoader from './ExrLoader'; import { generateThumbnail, getBlob } from './util'; +import PsdLoader from './PSDLoader'; type FormatHandlerType = | 'web' | 'tifLoader' | 'exrLoader' + | 'psdLoader' | 'extractEmbeddedThumbnailOnly' | 'none'; @@ -29,7 +31,7 @@ const FormatHandlers: Record = { svg: 'none', tif: 'tifLoader', tiff: 'tifLoader', - psd: 'extractEmbeddedThumbnailOnly', + psd: 'psdLoader', kra: 'extractEmbeddedThumbnailOnly', // xcf: 'extractEmbeddedThumbnailOnly', exr: 'exrLoader', @@ -41,6 +43,7 @@ type ObjectURL = string; class ImageLoader { private tifLoader: TifLoader; private exrLoader: ExrLoader; + private psdLoader: PsdLoader; private srcBufferCache: WeakMap = new WeakMap(); private bufferCacheTimer: WeakMap = new WeakMap(); @@ -48,11 +51,12 @@ class ImageLoader { constructor(private exifIO: ExifIO) { this.tifLoader = new TifLoader(); this.exrLoader = new ExrLoader(); + this.psdLoader = new PsdLoader(); this.ensureThumbnail = action(this.ensureThumbnail.bind(this)); } async init(): Promise { - await Promise.all([this.tifLoader.init(), this.exrLoader.init()]); + await Promise.all([this.tifLoader.init(), this.exrLoader.init(), this.psdLoader.init()]); } needsThumbnail(file: FileDTO) { @@ -83,7 +87,12 @@ class ImageLoader { }; if (await fse.pathExists(thumbnailPath)) { - return false; + // Files like PSDs have a tendency to change: Check if thumbnail needs an update + const fileStats = await fse.stat(absolutePath); + const thumbStats = await fse.stat(thumbnailPath); + if (fileStats.mtime < thumbStats.ctime) { + return false; // if file mod date is before thumbnail creation date, keep using the same thumbnail + } } const handlerType = FormatHandlers[extension]; @@ -116,6 +125,10 @@ class ImageLoader { updateThumbnailPath(file, thumbnailPath); } break; + case 'psdLoader': + await generateThumbnail(this.psdLoader, absolutePath, thumbnailPath, thumbnailMaxSize); + updateThumbnailPath(file, thumbnailPath); + break; case 'none': // No thumbnail for this format file.setThumbnailPath(file.absolutePath); @@ -146,6 +159,11 @@ class ImageLoader { this.updateCache(file, src); return src; } + case 'psdLoader': + const src = + this.srcBufferCache.get(file) ?? (await getBlob(this.psdLoader, file.absolutePath)); + this.updateCache(file, src); + return src; // TODO: krita has full image also embedded (mergedimage.png) case 'extractEmbeddedThumbnailOnly': case 'none': @@ -156,6 +174,28 @@ class ImageLoader { } } + /** Returns 0 for width and height if they can't be determined */ + async getImageResolution(absolutePath: string): Promise<{ width: number; height: number }> { + // ExifTool should be able to read the resolution from any image file + const dimensions = await this.exifIO.getDimensions(absolutePath); + + // User report: Resolution can't be found for PSD files. + // Can't reproduce myself, but putting a check in place anyway. Maybe due to old PSD format? + // Read the actual file using the PSD loader and get the resolution from there. + if ( + absolutePath.toLowerCase().endsWith('psd') && + (dimensions.width === 0 || dimensions.height === 0) + ) { + try { + const psdData = await this.psdLoader.decode(await fse.readFile(absolutePath)); + dimensions.width = psdData.width; + dimensions.height = psdData.height; + } catch (e) {} + } + + return dimensions; + } + private async extractKritaThumbnail(absolutePath: string, outputPath: string) { const zip = new StreamZip.async({ file: absolutePath }); let success = false; diff --git a/src/frontend/image/PSDLoader.ts b/src/frontend/image/PSDLoader.ts new file mode 100644 index 00000000..ec0ba9d0 --- /dev/null +++ b/src/frontend/image/PSDLoader.ts @@ -0,0 +1,26 @@ +import { Remote, wrap } from 'comlink'; +import { PsdReaderWorker } from '../workers/psdReader.worker'; +import { Loader } from './util'; + +/** + * Uses the ag-psd dependency to create bitmap images of PSD files. + * Uses a worker to offload process intensive work off the main thread + * Based on https://github.com/Agamnentzar/ag-psd#reading-2 + */ +class PsdLoader implements Loader { + worker?: Remote; + + async init(): Promise { + const worker = new Worker(new URL('src/frontend/workers/psdReader.worker', import.meta.url)); + + const WorkerFactory = wrap(worker); + this.worker = await new WorkerFactory(); + } + + async decode(buffer: Buffer): Promise { + const { image } = await this.worker!.readImage(buffer); + return image; + } +} + +export default PsdLoader; diff --git a/src/frontend/image/TifLoader.ts b/src/frontend/image/TifLoader.ts index 332871b8..a7163645 100644 --- a/src/frontend/image/TifLoader.ts +++ b/src/frontend/image/TifLoader.ts @@ -17,7 +17,7 @@ class TifLoader implements Loader { * Based on: https://github.com/photopea/UTIF.js/blob/master/UTIF.js#L1119 * @param buffer Image buffer (e.g. from fse.readFile) */ - decode(buffer: ArrayBuffer): ImageData { + decode(buffer: ArrayBuffer): Promise { const ifds = UTIF.decode(buffer); const vsns = ifds[0].subIFD ? ifds.concat(ifds[0].subIFD as any) : ifds; @@ -41,7 +41,7 @@ class TifLoader implements Loader { (UTIF.decodeImage as any)(buffer, page); const rgba = UTIF.toRGBA8(page); const { width, height } = page; - return new ImageData(new Uint8ClampedArray(rgba.buffer), width, height); + return Promise.resolve(new ImageData(new Uint8ClampedArray(rgba.buffer), width, height)); } } diff --git a/src/frontend/image/util.ts b/src/frontend/image/util.ts index ef37cd56..1fc8c3fa 100644 --- a/src/frontend/image/util.ts +++ b/src/frontend/image/util.ts @@ -7,13 +7,13 @@ export interface Loader extends Decoder { } export interface Decoder { - decode: (buffer: Buffer) => ImageData; + decode: (buffer: Buffer) => Promise; } /** Returns a string that can be used as img src attribute */ export async function getBlob(decoder: Decoder, path: string): Promise { const buf = await fse.readFile(path); - const data = decoder.decode(buf); + const data = await decoder.decode(buf); const blob = await new Promise((resolve, reject) => dataToCanvas(data).toBlob( (blob) => (blob !== null ? resolve(blob) : reject()), @@ -30,8 +30,9 @@ export async function generateThumbnail( outputPath: string, thumbnailSize: number, ): Promise { + // TODO: merge this functionality with the thumbnail worker: it's basically duplicate code const buffer = await fse.readFile(inputPath); - const data = decoder.decode(buffer); + const data = await decoder.decode(buffer); const sampledCanvas = getSampledCanvas(dataToCanvas(data), thumbnailSize); const quality = computeQuality(sampledCanvas, thumbnailSize); const blobBuffer = await new Promise((resolve, reject) => @@ -66,11 +67,14 @@ function getSampledCanvas(canvas: HTMLCanvasElement, targetSize: number): HTMLCa return sampledCanvas; } +/** Dynamically computes the compression quality for a thumbnail based on how much it is scaled compared to the maximum target size */ function computeQuality(canvas: HTMLCanvasElement, targetSize: number): number { - const maxSize = Math.max(canvas.width, canvas.height); - return clamp(targetSize / maxSize, 0.5, 1.0); + const minSize = Math.min(canvas.width, canvas.height); + // A low minimum size needs to correspond to a high quality, to retain details when it is displayed as cropped + return clamp(1 - minSize / targetSize, 0.5, 0.9); } +/** Scales the width and height to be the targetSize in the largest dimension, while retaining the aspect ratio */ function getScaledSize( width: number, height: number, @@ -78,11 +82,11 @@ function getScaledSize( ): [width: number, height: number] { const widthScale = targetSize / width; const heightScale = targetSize / height; - const scale = Math.max(widthScale, heightScale); + const scale = Math.min(widthScale, heightScale); return [Math.floor(width * scale), Math.floor(height * scale)]; } -// Cut out rectangle in center if image has extreme aspect ratios. +/** Cut out rectangle in center if image has extreme aspect ratios. */ function getAreaOfInterest( width: number, height: number, diff --git a/src/frontend/stores/LocationStore.ts b/src/frontend/stores/LocationStore.ts index 66de3c26..2467c845 100644 --- a/src/frontend/stores/LocationStore.ts +++ b/src/frontend/stores/LocationStore.ts @@ -1,20 +1,20 @@ +import { getThumbnailPath } from 'common/fs'; +import { promiseAllLimit } from 'common/promise'; +import fse from 'fs-extra'; import { action, makeObservable, observable, runInAction } from 'mobx'; import SysPath from 'path'; import { IDataStorage } from 'src/api/data-storage'; import { OrderDirection } from 'src/api/data-storage-search'; -import ExifIO from 'common/ExifIO'; -import { getMetaData, mergeMovedFile } from 'src/entities/File'; import { FileDTO, IMG_EXTENSIONS, IMG_EXTENSIONS_TYPE } from 'src/api/file'; import { generateId, ID } from 'src/api/id'; -import { ClientLocation, ClientSubLocation } from 'src/entities/Location'; import { LocationDTO } from 'src/api/location'; +import { getMetaData, mergeMovedFile } from 'src/entities/File'; +import { ClientLocation, ClientSubLocation } from 'src/entities/Location'; import { ClientStringSearchCriteria } from 'src/entities/SearchCriteria'; import { AppToaster } from 'src/frontend/components/Toaster'; import { RendererMessenger } from 'src/ipc/renderer'; -import { getThumbnailPath } from 'common/fs'; -import { promiseAllLimit } from 'common/promise'; +import ImageLoader from '../image/ImageLoader'; import RootStore from './RootStore'; -import fse from 'fs-extra'; const PREFERENCES_STORAGE_KEY = 'location-store-preferences'; type Preferences = { extensions: IMG_EXTENSIONS_TYPE[] }; @@ -168,7 +168,7 @@ class LocationStore { // Find all files that have been created (those on disk but not in DB) const createdPaths = diskFiles.filter((f) => !dbFilesPathSet.has(f.absolutePath)); const createdFiles = await Promise.all( - createdPaths.map((path) => pathToIFile(path, location, this.rootStore.exifTool)), + createdPaths.map((path) => pathToIFile(path, location, this.rootStore.imageLoader)), ); // Find all files of this location that have been removed (those in DB but not on disk anymore) @@ -281,7 +281,7 @@ class LocationStore { const newFile: FileDTO = { ...dbFile, // Recreate metadata which checks the resolution of the image - ...(await getMetaData(diskFile, this.rootStore.exifTool)), + ...(await getMetaData(diskFile, this.rootStore.imageLoader)), dateLastIndexed: new Date(), }; @@ -412,7 +412,7 @@ class LocationStore { // TODO: Should make N configurable, or determine based on the system/disk performance const N = 50; const files = await promiseAllLimit( - filePaths.map((path) => () => pathToIFile(path, location, this.rootStore.exifTool)), + filePaths.map((path) => () => pathToIFile(path, location, this.rootStore.imageLoader)), N, showProgressToaster, () => isCancelled, @@ -459,7 +459,7 @@ class LocationStore { const fileStore = this.rootStore.fileStore; // Gather file data - const file = await pathToIFile(fileStats, location, this.rootStore.exifTool); + const file = await pathToIFile(fileStats, location, this.rootStore.imageLoader); // Check if file is being moved/renamed (which is detected as a "add" event followed by "remove" event) const match = runInAction(() => fileStore.fileList.find((f) => f.ino === fileStats.ino)); @@ -550,7 +550,7 @@ export type FileStats = { export async function pathToIFile( stats: FileStats, loc: ClientLocation, - exifIO: ExifIO, + imageLoader: ImageLoader, ): Promise { const now = new Date(); return { @@ -563,7 +563,7 @@ export async function pathToIFile( dateAdded: now, dateModified: now, dateLastIndexed: now, - ...(await getMetaData(stats, exifIO)), + ...(await getMetaData(stats, imageLoader)), }; } diff --git a/src/frontend/workers/folderWatcher.worker.ts b/src/frontend/workers/folderWatcher.worker.ts index 938ea919..7d3f12dc 100644 --- a/src/frontend/workers/folderWatcher.worker.ts +++ b/src/frontend/workers/folderWatcher.worker.ts @@ -27,7 +27,7 @@ export class FolderWatcherWorker { async watch(directory: string, extensions: IMG_EXTENSIONS_TYPE[]) { this.isCancelled = false; - // Replace backslash with forward slash, recommendeded by chokidar + // Replace backslash with forward slash, recommended by chokidar // See docs for the .watch method: https://github.com/paulmillr/chokidar#api directory = directory.replace(/\\/g, '/'); diff --git a/src/frontend/workers/psdReader.worker.ts b/src/frontend/workers/psdReader.worker.ts new file mode 100644 index 00000000..3a5f2ac3 --- /dev/null +++ b/src/frontend/workers/psdReader.worker.ts @@ -0,0 +1,55 @@ +// Based on https://github.com/Agamnentzar/ag-psd#reading-2 + +import { initializeCanvas, byteArrayToBase64, readPsd } from 'ag-psd'; + +import { expose } from 'comlink'; + +export class PsdReaderWorker { + constructor() { + initializeCanvas(this.createCanvas, this.createCanvasFromData); + } + + private createCanvas(width: number, height: number): HTMLCanvasElement { + const canvas = new OffscreenCanvas(width, height); + canvas.width = width; + canvas.height = height; + return canvas as unknown as HTMLCanvasElement; + } + + private createCanvasFromData(data: Uint8Array): HTMLCanvasElement { + const image = new Image(); + image.src = 'data:image/jpeg;base64,' + byteArrayToBase64(data); + const canvas = new OffscreenCanvas(image.width, image.height); + canvas.width = image.width; + canvas.height = image.height; + + const ctx2D = canvas.getContext('2d'); + if (!ctx2D) { + throw new Error('Context2D not available!'); + } + ctx2D.drawImage(image, 0, 0); + return canvas as unknown as HTMLCanvasElement; + } + + async readImage(data: Buffer) { + // TODO: Could also read files here if passing in the path: const data = await fse.readFile(absolutePath); + + // skipping thumbnail and layer images here so we don't have to clear and convert them all + // before posting data back + // TODO: look into using the skipThumbnail: false option for faster thumbnail extraction + const psd = readPsd(data, { + skipLayerImageData: true, + skipThumbnail: true, + useImageData: true, + }); + + // imageData is available through the useImageData flag + // TODO: compare performance to the normal canvas approach + const imageData = psd.imageData!; + + return { psd: psd, image: imageData }; + } +} + +// https://lorefnon.tech/2019/03/24/using-comlink-with-typescript-and-worker-loader/ +expose(PsdReaderWorker, self); diff --git a/src/frontend/workers/thumbnailGenerator.worker.ts b/src/frontend/workers/thumbnailGenerator.worker.ts index bb10af50..a694619a 100644 --- a/src/frontend/workers/thumbnailGenerator.worker.ts +++ b/src/frontend/workers/thumbnailGenerator.worker.ts @@ -3,6 +3,7 @@ import fse from 'fs-extra'; import { thumbnailFormat, thumbnailMaxSize } from 'common/config'; import { IThumbnailMessage, IThumbnailMessageResponse } from '../image/ThumbnailGeneration'; +// TODO: Merge this with the generateThumbnail func from frontend/image/utils.ts, it's duplicate code const generateThumbnailData = async (filePath: string): Promise => { const inputBuffer = await fse.readFile(filePath); const inputBlob = new Blob([inputBuffer]); diff --git a/src/renderer.tsx b/src/renderer.tsx index 25f4ae48..9a7b3399 100644 --- a/src/renderer.tsx +++ b/src/renderer.tsx @@ -2,26 +2,27 @@ // be executed in the renderer process for that window. // All of the Node.js APIs are available in this process. +import { IS_DEV } from 'common/process'; +import { promiseRetry } from 'common/timeout'; +import { IS_PREVIEW_WINDOW, WINDOW_STORAGE_KEY } from 'common/window'; +import { autorun, reaction, runInAction } from 'mobx'; import React from 'react'; import { createRoot } from 'react-dom/client'; -import { autorun, reaction, runInAction } from 'mobx'; -import { IS_PREVIEW_WINDOW, WINDOW_STORAGE_KEY } from 'common/window'; -import { promiseRetry } from 'common/timeout'; +import { RendererMessenger } from 'src/ipc/renderer'; import Backend from './backend/backend'; import App from './frontend/App'; -import Overlay from './frontend/Overlay'; -import PreviewApp from './frontend/Preview'; import SplashScreen from './frontend/containers/SplashScreen'; import StoreProvider from './frontend/contexts/StoreContext'; -import RootStore from './frontend/stores/RootStore'; +import Overlay from './frontend/Overlay'; +import PreviewApp from './frontend/Preview'; import { FILE_STORAGE_KEY } from './frontend/stores/FileStore'; +import RootStore from './frontend/stores/RootStore'; import { PREFERENCES_STORAGE_KEY } from './frontend/stores/UiStore'; -import { RendererMessenger } from 'src/ipc/renderer'; // Import the styles here to let Webpack know to include them // in the HTML file import './style.scss'; -(async function main(): Promise { +async function main(): Promise { const container = document.getElementById('app'); if (container === null) { @@ -109,7 +110,7 @@ import './style.scss'; throw new Error('Could not find image to set tags for ' + filePath); } } -})(); +} async function setupMainApp(backend: Backend): Promise<[RootStore, () => JSX.Element]> { const [rootStore] = await Promise.all([RootStore.main(backend), backend.setupBackup()]); @@ -203,3 +204,15 @@ async function setupPreviewApp(backend: Backend): Promise<[RootStore, () => JSX. }); return [rootStore, PreviewApp]; } + +main() + .then(() => console.info('Successfully initialized Allusion!')) + .catch((err) => { + console.error('Could not initialize Allusion!', err); + window.alert('An error has occurred, check the console for more details'); + + // In dev mode, the console is already automatically opened: only open in non-dev mode here + if (!IS_DEV) { + RendererMessenger.toggleDevTools(); + } + }); diff --git a/yarn.lock b/yarn.lock index 2dc5211e..7ce20cde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1559,6 +1559,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/base64-js@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@types/base64-js/-/base64-js-1.3.0.tgz#c939fdba49846861caf5a246b165dbf5698a317c" + integrity sha512-ZmI0sZGAUNXUfMWboWwi4LcfpoVUYldyN6Oe0oJ5cCsHDU/LlRq8nQKPXhYLOx36QYSW9bNIb1vvRrD6K7Llgw== + "@types/chrome@^0.0.195": version "0.0.195" resolved "https://registry.yarnpkg.com/@types/chrome/-/chrome-0.0.195.tgz#c570f72857e11caa0a453a444828f75bca592cff" @@ -1694,6 +1699,11 @@ resolved "https://registry.yarnpkg.com/@types/offscreencanvas/-/offscreencanvas-2019.7.0.tgz#e4a932069db47bb3eabeb0b305502d01586fa90d" integrity sha512-PGcyveRIpL1XIqK8eBsmRBt76eFgtzuPiSTyKHZxnGemp2yzGzWpjYKAfK3wIMiU7eH+851yEpiuP8JZerTmWg== +"@types/pako@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/pako/-/pako-2.0.0.tgz#12ab4c19107528452e73ac99132c875ccd43bdfb" + integrity sha512-10+iaz93qR5WYxTo+PMifD5TSxiOtdRaxBf7INGGXMQgTCu8Z/7GYWYFUOS3q/G0nE5boj1r4FEB+WSy7s5gbA== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -2065,6 +2075,16 @@ acorn@^8.2.4, acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w== +ag-psd@^14.5.3: + version "14.5.3" + resolved "https://registry.yarnpkg.com/ag-psd/-/ag-psd-14.5.3.tgz#75613bb122a24759ade86fdc398e7f142dfd74c9" + integrity sha512-0GMkPaUpgakrZJWwjN0FFz9vfZ+3CpkODVaeN3a+kXv9ksDE/oP3CSdy0l5VX25UDivm9OZTiQAW/FQfTRtttg== + dependencies: + "@types/base64-js" "^1.3.0" + "@types/pako" "^2.0.0" + base64-js "^1.5.1" + pako "^2.0.4" + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -5606,6 +5626,11 @@ pako@^1.0.5: resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== +pako@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/pako/-/pako-2.0.4.tgz#6cebc4bbb0b6c73b0d5b8d7e8476e2b2fbea576d" + integrity sha512-v8tweI900AUkZN6heMU/4Uy4cXRc2AYNRggVmTR+dEncawDJgCdLMximOVA2p4qO57WMynangsfGRb5WD6L1Bg== + param-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5"