Skip to content

Commit

Permalink
Merge pull request #498 from allusion-app/better-psd-support
Browse files Browse the repository at this point in the history
Proper PSD support
  • Loading branch information
RvanderLaan authored Oct 1, 2022
2 parents c797d3d + 062883a commit cef4d09
Show file tree
Hide file tree
Showing 17 changed files with 219 additions and 46 deletions.
5 changes: 5 additions & 0 deletions common/ExifIO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof ep.readMetadata>> | undefined = undefined;
try {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions resources/style/inspector.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/backend/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
8 changes: 4 additions & 4 deletions src/entities/File.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<IMetaData> {
export async function getMetaData(stats: FileStats, imageLoader: ImageLoader): Promise<IMetaData> {
const path = stats.absolutePath;
const dimensions = await exifIO.getDimensions(path);
const dimensions = await imageLoader.getImageResolution(stats.absolutePath);

return {
name: Path.basename(path),
Expand Down
5 changes: 0 additions & 5 deletions src/frontend/containers/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,11 +384,6 @@ const imageFormatInts: Partial<Record<IMG_EXTENSIONS_TYPE, ReactNode>> = {
{IconSet.WARNING}
</span>
),
psd: (
<span title="Only a low-resolution thumbnail will be available" className="info-icon">
{IconSet.INFO}
</span>
),
};

const ImageFormatPicker = observer(() => {
Expand Down
4 changes: 2 additions & 2 deletions src/frontend/image/ExrLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImageData> {
return Promise.resolve(decode(buffer));
}
}

Expand Down
46 changes: 43 additions & 3 deletions src/frontend/image/ImageLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -29,7 +31,7 @@ const FormatHandlers: Record<IMG_EXTENSIONS_TYPE, FormatHandlerType> = {
svg: 'none',
tif: 'tifLoader',
tiff: 'tifLoader',
psd: 'extractEmbeddedThumbnailOnly',
psd: 'psdLoader',
kra: 'extractEmbeddedThumbnailOnly',
// xcf: 'extractEmbeddedThumbnailOnly',
exr: 'exrLoader',
Expand All @@ -41,18 +43,20 @@ type ObjectURL = string;
class ImageLoader {
private tifLoader: TifLoader;
private exrLoader: ExrLoader;
private psdLoader: PsdLoader;

private srcBufferCache: WeakMap<ClientFile, ObjectURL> = new WeakMap();
private bufferCacheTimer: WeakMap<ClientFile, number> = new WeakMap();

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<void> {
await Promise.all([this.tifLoader.init(), this.exrLoader.init()]);
await Promise.all([this.tifLoader.init(), this.exrLoader.init(), this.psdLoader.init()]);
}

needsThumbnail(file: FileDTO) {
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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':
Expand All @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions src/frontend/image/PSDLoader.ts
Original file line number Diff line number Diff line change
@@ -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<PsdReaderWorker>;

async init(): Promise<void> {
const worker = new Worker(new URL('src/frontend/workers/psdReader.worker', import.meta.url));

const WorkerFactory = wrap<typeof PsdReaderWorker>(worker);
this.worker = await new WorkerFactory();
}

async decode(buffer: Buffer): Promise<ImageData> {
const { image } = await this.worker!.readImage(buffer);
return image;
}
}

export default PsdLoader;
4 changes: 2 additions & 2 deletions src/frontend/image/TifLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImageData> {
const ifds = UTIF.decode(buffer);
const vsns = ifds[0].subIFD ? ifds.concat(ifds[0].subIFD as any) : ifds;

Expand All @@ -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));
}
}

Expand Down
18 changes: 11 additions & 7 deletions src/frontend/image/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ export interface Loader extends Decoder {
}

export interface Decoder {
decode: (buffer: Buffer) => ImageData;
decode: (buffer: Buffer) => Promise<ImageData>;
}

/** Returns a string that can be used as img src attribute */
export async function getBlob(decoder: Decoder, path: string): Promise<string> {
const buf = await fse.readFile(path);
const data = decoder.decode(buf);
const data = await decoder.decode(buf);
const blob = await new Promise<Blob>((resolve, reject) =>
dataToCanvas(data).toBlob(
(blob) => (blob !== null ? resolve(blob) : reject()),
Expand All @@ -30,8 +30,9 @@ export async function generateThumbnail(
outputPath: string,
thumbnailSize: number,
): Promise<void> {
// 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<ArrayBuffer>((resolve, reject) =>
Expand Down Expand Up @@ -66,23 +67,26 @@ 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,
targetSize: number,
): [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,
Expand Down
24 changes: 12 additions & 12 deletions src/frontend/stores/LocationStore.ts
Original file line number Diff line number Diff line change
@@ -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[] };
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
};

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -550,7 +550,7 @@ export type FileStats = {
export async function pathToIFile(
stats: FileStats,
loc: ClientLocation,
exifIO: ExifIO,
imageLoader: ImageLoader,
): Promise<FileDTO> {
const now = new Date();
return {
Expand All @@ -563,7 +563,7 @@ export async function pathToIFile(
dateAdded: now,
dateModified: now,
dateLastIndexed: now,
...(await getMetaData(stats, exifIO)),
...(await getMetaData(stats, imageLoader)),
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/frontend/workers/folderWatcher.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '/');

Expand Down
Loading

0 comments on commit cef4d09

Please sign in to comment.