@kitschpatrol/aphex 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aphex-swift +0 -0
- package/dist/index.d.ts +48 -32
- package/dist/index.js +140 -149
- package/dist/workers/process-image-worker.js +2 -3
- package/package.json +30 -28
- package/readme.md +21 -6
package/dist/aphex-swift
CHANGED
|
Binary file
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { ExifTool } from "exiftool-vendored";
|
|
2
2
|
import { ILogBasic, ILogLayer } from "lognow";
|
|
3
3
|
import { OmitDeep, PartialDeep, Simplify } from "type-fest";
|
|
4
|
-
|
|
5
4
|
//#region src/aphex-swift/cli-bridge.d.ts
|
|
6
5
|
/**
|
|
7
6
|
* Start an interactive session with aphex-swift. Commands will be sent to this
|
|
@@ -9,11 +8,11 @@ import { OmitDeep, PartialDeep, Simplify } from "type-fest";
|
|
|
9
8
|
*
|
|
10
9
|
* @throws {Error} If the session is already active or fails to start
|
|
11
10
|
*/
|
|
12
|
-
declare function interactiveSessionStart(): Promise<void>;
|
|
11
|
+
export declare function interactiveSessionStart(): Promise<void>;
|
|
13
12
|
/**
|
|
14
13
|
* Stop the interactive session if one is active.
|
|
15
14
|
*/
|
|
16
|
-
declare function interactiveSessionStop(): Promise<void>;
|
|
15
|
+
export declare function interactiveSessionStop(): Promise<void>;
|
|
17
16
|
/**
|
|
18
17
|
* TypeScript type definition for ResourceInfo from the Swift implementation
|
|
19
18
|
*/
|
|
@@ -64,7 +63,7 @@ declare function aphexPhotoInfo(identifiers: string | string[], caseSensitive?:
|
|
|
64
63
|
declare function aphexAlbumInfo(identifiers: string | string[], caseSensitive?: boolean): Promise<AlbumInfo[]>;
|
|
65
64
|
//#endregion
|
|
66
65
|
//#region src/utilities/image/mime.d.ts
|
|
67
|
-
declare const IMAGE_MIME_TYPES: readonly ["arw", "avif", "bmp", "cr2", "cr3", "crw", "tga", "dng", "gif", "heic", "heif", "jpeg", "nef", "pef", "png", "psd", "svg+xml", "tiff", "webp"];
|
|
66
|
+
export declare const IMAGE_MIME_TYPES: readonly ["arw", "avif", "bmp", "cr2", "cr3", "crw", "tga", "dng", "gif", "heic", "heif", "jpeg", "nef", "pef", "png", "psd", "svg+xml", "tiff", "webp"];
|
|
68
67
|
type ImageMimeType = (typeof IMAGE_MIME_TYPES)[number];
|
|
69
68
|
//#endregion
|
|
70
69
|
//#region src/pipeline/engines/applescript-gui.d.ts
|
|
@@ -76,9 +75,13 @@ type ImageMimeType = (typeof IMAGE_MIME_TYPES)[number];
|
|
|
76
75
|
* options in the GUI.
|
|
77
76
|
*/
|
|
78
77
|
type ExportViaAppleScriptGuiOptions = {
|
|
79
|
-
/** "Color Profile" Drop Down */
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
/** "Color Profile" Drop Down */
|
|
79
|
+
colorProfile: 'AdobeRGB' | 'Display P3' | 'Most Compatible' | 'Original' | 'sRGB';
|
|
80
|
+
/** "File Name" Drop Down */
|
|
81
|
+
fileName: 'Album Name With Number' | 'Sequential' | 'Use File Name' | 'Use Title';
|
|
82
|
+
/** "Include Location Information" Checkbox */
|
|
83
|
+
includeLocation: boolean;
|
|
84
|
+
/** "Include Title, Keywords, and Caption" Checkbox */
|
|
82
85
|
includeMetadata: boolean;
|
|
83
86
|
/**
|
|
84
87
|
* "JPEG Quality" Drop Down
|
|
@@ -97,15 +100,18 @@ type ExportViaAppleScriptGuiOptions = {
|
|
|
97
100
|
*
|
|
98
101
|
* Only applies if `photoSize` is `Custom`
|
|
99
102
|
*/
|
|
100
|
-
maxSizeValue: number;
|
|
101
|
-
|
|
103
|
+
maxSizeValue: number;
|
|
104
|
+
/** "Photo Kind" Drop Down */
|
|
105
|
+
photoKind: 'HEIC' | 'JPEG' | 'PNG' | 'TIFF';
|
|
106
|
+
/** "Size" Drop Down */
|
|
102
107
|
photoSize: 'Custom' | 'Full Size' | 'Large' | 'Medium' | 'Small';
|
|
103
108
|
/**
|
|
104
109
|
* "Sequential Prefix" Text Field
|
|
105
110
|
*
|
|
106
111
|
* Only applies if `fileName` is `Sequential`
|
|
107
112
|
*/
|
|
108
|
-
sequentialPrefix: string;
|
|
113
|
+
sequentialPrefix: string;
|
|
114
|
+
/** "Subfolder Format" Drop Down */
|
|
109
115
|
subfolderFormat: 'Moment Name' | 'None';
|
|
110
116
|
/**
|
|
111
117
|
* "16 Bit" Checkbox
|
|
@@ -142,7 +148,6 @@ type ExportApplePhotoResult = {
|
|
|
142
148
|
photoInfo: PhotoInfo;
|
|
143
149
|
};
|
|
144
150
|
type FileNameOptions = 'fileName' | 'title' | 'uuid';
|
|
145
|
-
/** Also normalizes */
|
|
146
151
|
//#endregion
|
|
147
152
|
//#region src/utilities/image/tags.d.ts
|
|
148
153
|
declare const VALID_LABELS: readonly ["animation", "diagram", "illustration", "image", "photo", "render", "screenshot", "video"];
|
|
@@ -156,9 +161,12 @@ type AphexMetadata = {
|
|
|
156
161
|
photoInfo: PhotoInfo;
|
|
157
162
|
};
|
|
158
163
|
type ImageTags = {
|
|
159
|
-
aphexMetadata?: AphexMetadata | undefined;
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
aphexMetadata?: AphexMetadata | undefined;
|
|
165
|
+
/** Human Name */
|
|
166
|
+
creator?: string | undefined;
|
|
167
|
+
/** Organization */
|
|
168
|
+
credit?: string | undefined;
|
|
169
|
+
/** Description, used as alt text in HTML content */
|
|
162
170
|
description?: string | undefined;
|
|
163
171
|
/**
|
|
164
172
|
* Image Type, e.g. 'animation', 'diagram', 'illustration', 'screenshot',
|
|
@@ -278,16 +286,24 @@ type ImageInfo = {
|
|
|
278
286
|
//#endregion
|
|
279
287
|
//#region src/pipeline/image-process.d.ts
|
|
280
288
|
type ProcessImageOptions = CompressImageOptions & {
|
|
281
|
-
/** Fallback color profile applied when source profile is not in preserve list */
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
289
|
+
/** Fallback color profile applied when source profile is not in preserve list */
|
|
290
|
+
defaultColorProfile: ColorProfile;
|
|
291
|
+
/** Calculate SSIM/PSNR/DSSIM metrics comparing original vs processed images */
|
|
292
|
+
logSimilarity: boolean;
|
|
293
|
+
/** Lossless format used specifically for images with alpha channels */
|
|
294
|
+
losslessFormatAlpha: LosslessFormat;
|
|
295
|
+
/** Lossy format used specifically for images with alpha channels */
|
|
296
|
+
lossyFormatAlpha: LossyFormat;
|
|
297
|
+
/** Maximum pixel dimensions before triggering resize (width OR height) */
|
|
285
298
|
maxDimensionsPixels: {
|
|
286
299
|
width: number;
|
|
287
300
|
height: number;
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
|
|
301
|
+
};
|
|
302
|
+
/** Near-lossless format used specifically for images with alpha channels */
|
|
303
|
+
nearLosslessFormatAlpha: NearLosslessFormat;
|
|
304
|
+
/** Image formats that bypass conversion and compression entirely */
|
|
305
|
+
passthroughFormats: ImageMimeType[];
|
|
306
|
+
/** Color profiles that are kept unchanged instead of being normalized */
|
|
291
307
|
preserveColorProfiles: ColorProfile[];
|
|
292
308
|
};
|
|
293
309
|
type ProcessImageResult = {
|
|
@@ -311,7 +327,7 @@ type ProcessImageResult = {
|
|
|
311
327
|
*
|
|
312
328
|
* Run in parallel through a worker for album processing
|
|
313
329
|
*/
|
|
314
|
-
declare function processImage(sourceImagePath: string, destinationDirectory: string, options: ProcessImageOptions): Promise<ProcessImageResult>;
|
|
330
|
+
export declare function processImage(sourceImagePath: string, destinationDirectory: string, options: ProcessImageOptions): Promise<ProcessImageResult>;
|
|
315
331
|
//#endregion
|
|
316
332
|
//#region src/pipeline/image-sync.d.ts
|
|
317
333
|
type DiffStrategy = 'exif-tags' | 'export-options' | 'file-name' | 'force-update' | 'metadata-options' | 'photo-info' | 'process-options';
|
|
@@ -338,7 +354,7 @@ type SyncResult = {
|
|
|
338
354
|
* This should be called when you're done using exiftool to prevent hanging
|
|
339
355
|
* processes. It's safe to call multiple times.
|
|
340
356
|
*/
|
|
341
|
-
declare function endExiftool(): Promise<void>;
|
|
357
|
+
export declare function endExiftool(): Promise<void>;
|
|
342
358
|
//#endregion
|
|
343
359
|
//#region src/utilities/log.d.ts
|
|
344
360
|
/**
|
|
@@ -348,22 +364,22 @@ declare function endExiftool(): Promise<void>;
|
|
|
348
364
|
* @param logger - Accepts either a LogLayer instance or a Console- or
|
|
349
365
|
* Stream-like log target
|
|
350
366
|
*/
|
|
351
|
-
declare function setLogger(logger?: ILogBasic | ILogLayer<unknown>): void;
|
|
367
|
+
export declare function setLogger(logger?: ILogBasic | ILogLayer<unknown>): void;
|
|
352
368
|
//#endregion
|
|
353
369
|
//#region src/index.d.ts
|
|
354
370
|
/**
|
|
355
371
|
* Helper for deep merging ExportOptions object against library defaults.
|
|
356
372
|
* Exported for unplugin-aphex.
|
|
357
373
|
*/
|
|
358
|
-
declare function mergeDefaultExportOptions(options: PartialDeep<ExportOptions> | undefined): ExportOptions;
|
|
359
|
-
type ExportOptions = {
|
|
374
|
+
export declare function mergeDefaultExportOptions(options: PartialDeep<ExportOptions> | undefined): ExportOptions;
|
|
375
|
+
export type ExportOptions = {
|
|
360
376
|
exportOptions: ExportApplePhotoOptions;
|
|
361
377
|
metadataOptions: 'disabled' | ManageMetadataOptions;
|
|
362
378
|
processOptions: 'disabled' | ProcessImageOptions;
|
|
363
379
|
syncOptions: 'disabled' | SyncOptions;
|
|
364
380
|
};
|
|
365
|
-
declare const defaultSyncOptions: SyncOptions;
|
|
366
|
-
declare const defaultExportOptions: ExportOptions;
|
|
381
|
+
export declare const defaultSyncOptions: SyncOptions;
|
|
382
|
+
export declare const defaultExportOptions: ExportOptions;
|
|
367
383
|
/** Some fields omitted for relevance... */
|
|
368
384
|
type ExportResults = {
|
|
369
385
|
exportResult: Simplify<Omit<ExportApplePhotoResult, 'exportOptions' | 'path' | 'photoInfo'>> | undefined;
|
|
@@ -371,7 +387,7 @@ type ExportResults = {
|
|
|
371
387
|
processResult: Simplify<OmitDeep<ProcessImageResult, 'input.path' | 'output.path' | 'path'>> | undefined;
|
|
372
388
|
syncResult: Simplify<Omit<SyncResult['plan'][number], 'photoInfo'>> | undefined;
|
|
373
389
|
};
|
|
374
|
-
type ExportResult = {
|
|
390
|
+
export type ExportResult = {
|
|
375
391
|
options: ExportOptions;
|
|
376
392
|
path: string;
|
|
377
393
|
photoInfo: PhotoInfo;
|
|
@@ -380,10 +396,10 @@ type ExportResult = {
|
|
|
380
396
|
/**
|
|
381
397
|
* Export a single photo
|
|
382
398
|
*/
|
|
383
|
-
declare function exportPhoto(identifier: PhotoInfo | string, destinationDirectory: string, options?: PartialDeep<ExportOptions>): Promise<ExportResult>;
|
|
399
|
+
export declare function exportPhoto(identifier: PhotoInfo | string, destinationDirectory: string, options?: PartialDeep<ExportOptions>): Promise<ExportResult>;
|
|
384
400
|
/**
|
|
385
401
|
* Export photos
|
|
386
402
|
*/
|
|
387
|
-
declare function exportPhotos(identifiers: Array<AlbumInfo | PhotoInfo | string>, destinationDirectory: string, options?: PartialDeep<ExportOptions>): Promise<ExportResult[]>;
|
|
403
|
+
export declare function exportPhotos(identifiers: Array<AlbumInfo | PhotoInfo | string>, destinationDirectory: string, options?: PartialDeep<ExportOptions>): Promise<ExportResult[]>;
|
|
388
404
|
//#endregion
|
|
389
|
-
export {
|
|
405
|
+
export { type ImageMimeType, aphexAlbumInfo as getAlbumInfo, aphexPhotoInfo as getPhotoInfo };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fse from "fs-extra";
|
|
2
|
-
import path
|
|
2
|
+
import path from "node:path";
|
|
3
3
|
import is, { assert, isNonEmptyStringAndNotWhitespace } from "@sindresorhus/is";
|
|
4
4
|
import { execa } from "execa";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
@@ -58,11 +58,10 @@ function getExiftool() {
|
|
|
58
58
|
* processes. It's safe to call multiple times.
|
|
59
59
|
*/
|
|
60
60
|
async function endExiftool() {
|
|
61
|
-
if (exiftoolInstance.pids.length
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
61
|
+
if (exiftoolInstance.pids.length === 0) return;
|
|
62
|
+
log.debug(`Ending exiftool processes: ${exiftoolInstance.pids.join(", ")}`);
|
|
63
|
+
await exiftoolInstance.end();
|
|
64
|
+
log.debug("Exiftool processes ended");
|
|
66
65
|
}
|
|
67
66
|
/**
|
|
68
67
|
* Install process exit handlers for automatic cleanup
|
|
@@ -73,24 +72,27 @@ async function endExiftool() {
|
|
|
73
72
|
function scheduleProcessCleanup() {
|
|
74
73
|
if (processExitHandlerInstalled) return;
|
|
75
74
|
processExitHandlerInstalled = true;
|
|
76
|
-
process.on("beforeExit",
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
75
|
+
process.on("beforeExit", () => {
|
|
76
|
+
if (cleanupScheduled) return;
|
|
77
|
+
cleanupScheduled = true;
|
|
78
|
+
endExiftool();
|
|
81
79
|
});
|
|
82
80
|
process.on("exit", () => {
|
|
83
81
|
if (exiftoolInstance.pids.length > 0) log.debug(`Exiftool processes still running at exit: ${exiftoolInstance.pids.join(", ")}`);
|
|
84
82
|
});
|
|
85
|
-
process.on("SIGINT",
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
process.on("SIGINT", () => {
|
|
84
|
+
(async () => {
|
|
85
|
+
log.debug("Received SIGINT, cleaning up exiftool...");
|
|
86
|
+
await endExiftool();
|
|
87
|
+
process.exit(130);
|
|
88
|
+
})();
|
|
89
89
|
});
|
|
90
|
-
process.on("SIGTERM",
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
process.on("SIGTERM", () => {
|
|
91
|
+
(async () => {
|
|
92
|
+
log.debug("Received SIGTERM, cleaning up exiftool...");
|
|
93
|
+
await endExiftool();
|
|
94
|
+
process.exit(143);
|
|
95
|
+
})();
|
|
94
96
|
});
|
|
95
97
|
}
|
|
96
98
|
//#endregion
|
|
@@ -155,7 +157,7 @@ async function getTempDirectory(...labels) {
|
|
|
155
157
|
}
|
|
156
158
|
//#endregion
|
|
157
159
|
//#region src/utilities/general.ts
|
|
158
|
-
const UUID_REGEX = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/
|
|
160
|
+
const UUID_REGEX = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/iv;
|
|
159
161
|
/**
|
|
160
162
|
* Sips makes a mess of the temp folder and does not respect TMPDIR, so we need
|
|
161
163
|
* to clean up after it ourselves
|
|
@@ -170,10 +172,9 @@ async function sipsTempCleanup() {
|
|
|
170
172
|
"--mime",
|
|
171
173
|
filePath
|
|
172
174
|
]);
|
|
173
|
-
if (stdout.startsWith("image/"))
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
175
|
+
if (!stdout.startsWith("image/")) continue;
|
|
176
|
+
await fse.rm(filePath, { force: true });
|
|
177
|
+
cleanCount += 1;
|
|
177
178
|
}
|
|
178
179
|
return cleanCount;
|
|
179
180
|
}
|
|
@@ -200,7 +201,7 @@ function assertSingleElement(array) {
|
|
|
200
201
|
* getDirname(import.meta)
|
|
201
202
|
*/
|
|
202
203
|
function getDirname(meta) {
|
|
203
|
-
return dirname(fileURLToPath(process.env.PISCINA_WORKER_META_URL ?? meta.url));
|
|
204
|
+
return path.dirname(fileURLToPath(process.env.PISCINA_WORKER_META_URL ?? meta.url));
|
|
204
205
|
}
|
|
205
206
|
/**
|
|
206
207
|
* Asset directory path of package or distribution folder (Copied by tsdown)
|
|
@@ -226,7 +227,7 @@ function getPackageBinPath(meta) {
|
|
|
226
227
|
}
|
|
227
228
|
//#endregion
|
|
228
229
|
//#region src/aphex-swift/cli-bridge.ts
|
|
229
|
-
const SHELL_SPECIAL_CHARS_REGEX = /[\s'"\\]
|
|
230
|
+
const SHELL_SPECIAL_CHARS_REGEX = /[\s'"\\]/v;
|
|
230
231
|
let interactiveProcess;
|
|
231
232
|
let currentRequest;
|
|
232
233
|
/**
|
|
@@ -237,8 +238,9 @@ let currentRequest;
|
|
|
237
238
|
*/
|
|
238
239
|
async function interactiveSessionStart() {
|
|
239
240
|
if (interactiveProcess !== void 0) throw new Error("Interactive session is already active");
|
|
241
|
+
const binPath = getPackageBinPath(import.meta);
|
|
240
242
|
interactiveProcess = spawn("./aphex-swift", ["interactive"], {
|
|
241
|
-
cwd:
|
|
243
|
+
cwd: binPath,
|
|
242
244
|
stdio: [
|
|
243
245
|
"pipe",
|
|
244
246
|
"pipe",
|
|
@@ -258,13 +260,12 @@ async function interactiveSessionStart() {
|
|
|
258
260
|
} catch {}
|
|
259
261
|
});
|
|
260
262
|
interactiveProcess.stderr?.on("data", (data) => {
|
|
261
|
-
if (currentRequest
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
263
|
+
if (currentRequest === void 0) return;
|
|
264
|
+
const error = new Error(data.toString().trim());
|
|
265
|
+
const request = currentRequest;
|
|
266
|
+
currentRequest = void 0;
|
|
267
|
+
request.reject(error);
|
|
268
|
+
processNextCommand();
|
|
268
269
|
});
|
|
269
270
|
interactiveProcess.on("close", (code) => {
|
|
270
271
|
const error = /* @__PURE__ */ new Error(`Interactive session exited with code ${code}`);
|
|
@@ -310,7 +311,7 @@ function isInteractiveSessionActive() {
|
|
|
310
311
|
}
|
|
311
312
|
let commandQueue = [];
|
|
312
313
|
function processNextCommand() {
|
|
313
|
-
if (currentRequest !== void 0 ||
|
|
314
|
+
if (currentRequest !== void 0 || interactiveProcess === void 0 || commandQueue.length === 0) return;
|
|
314
315
|
const next = commandQueue.shift();
|
|
315
316
|
currentRequest = {
|
|
316
317
|
buffer: "",
|
|
@@ -338,10 +339,7 @@ async function executeInteractiveCommand(command) {
|
|
|
338
339
|
* Arguments with spaces or special characters are quoted.
|
|
339
340
|
*/
|
|
340
341
|
function escapeCommand(args) {
|
|
341
|
-
return args.map((arg) => {
|
|
342
|
-
if (SHELL_SPECIAL_CHARS_REGEX.test(arg)) return `'${arg.replaceAll("'", String.raw`'\''`)}'`;
|
|
343
|
-
return arg;
|
|
344
|
-
}).join(" ");
|
|
342
|
+
return args.map((arg) => SHELL_SPECIAL_CHARS_REGEX.test(arg) ? `'${arg.replaceAll("'", String.raw`'\''`)}'` : arg).join(" ");
|
|
345
343
|
}
|
|
346
344
|
/**
|
|
347
345
|
* Runtime type guard for ResourceInfo
|
|
@@ -472,7 +470,12 @@ async function aphexExport(identifiers, destination, caseSensitive = false, orig
|
|
|
472
470
|
}
|
|
473
471
|
}
|
|
474
472
|
function dateReviver$1(key, value) {
|
|
475
|
-
if (
|
|
473
|
+
if (typeof value === "string" && [
|
|
474
|
+
"dateCreated",
|
|
475
|
+
"dateEnd",
|
|
476
|
+
"dateModified",
|
|
477
|
+
"dateStart"
|
|
478
|
+
].includes(key)) return value.length > 0 ? new Date(value) : void 0;
|
|
476
479
|
return value;
|
|
477
480
|
}
|
|
478
481
|
//#endregion
|
|
@@ -500,15 +503,16 @@ async function resolveIdentifiers(identifiers) {
|
|
|
500
503
|
async function resolvePhotoIdentifier(identifier) {
|
|
501
504
|
if (isPhotoInfo(identifier)) return identifier;
|
|
502
505
|
const aphexPhotoInfoResult = await aphexPhotoInfo(identifier);
|
|
503
|
-
|
|
506
|
+
const [photoInfo] = aphexPhotoInfoResult;
|
|
507
|
+
if (photoInfo === void 0) throw new Error(`No photo asset found for identifier "${identifier}"`);
|
|
504
508
|
if (aphexPhotoInfoResult.length > 1) throw new Error(`Multiple photo assets found for identifier "${identifier} — is it an album?"`);
|
|
505
|
-
return
|
|
509
|
+
return photoInfo;
|
|
506
510
|
}
|
|
507
511
|
//#endregion
|
|
508
512
|
//#region src/utilities/defaults.ts
|
|
509
513
|
const mergeDefaultsInternal = createDefu((object, key, value) => {
|
|
510
514
|
if (Array.isArray(object[key]) && Array.isArray(value)) {
|
|
511
|
-
const merged = [
|
|
515
|
+
const merged = [.../* @__PURE__ */ new Set([...object[key], ...value])];
|
|
512
516
|
Object.assign(object, { [key]: merged });
|
|
513
517
|
return true;
|
|
514
518
|
}
|
|
@@ -574,7 +578,7 @@ function lookupImageMimeType(imagePath, validate = false) {
|
|
|
574
578
|
}
|
|
575
579
|
//#endregion
|
|
576
580
|
//#region src/utilities/image/color.ts
|
|
577
|
-
const SIPS_PROFILE_REGEX = /profile: (.+)
|
|
581
|
+
const SIPS_PROFILE_REGEX = /profile: (.+)/v;
|
|
578
582
|
/**
|
|
579
583
|
* Get the color profile of an image
|
|
580
584
|
* https://github.com/saucecontrol/Compact-ICC-Profiles?tab=readme-ov-file
|
|
@@ -777,7 +781,12 @@ async function getTags(imagePath) {
|
|
|
777
781
|
};
|
|
778
782
|
}
|
|
779
783
|
function dateReviver(key, value) {
|
|
780
|
-
if (
|
|
784
|
+
if (typeof value === "string" && [
|
|
785
|
+
"dateCreated",
|
|
786
|
+
"dateEnd",
|
|
787
|
+
"dateModified",
|
|
788
|
+
"dateStart"
|
|
789
|
+
].includes(key)) return value.length > 0 ? new Date(value) : void 0;
|
|
781
790
|
return value;
|
|
782
791
|
}
|
|
783
792
|
function isAphexMetadata(value) {
|
|
@@ -806,12 +815,12 @@ async function setTags(imagePath, imageTags) {
|
|
|
806
815
|
const { aphexMetadata, creator, credit, description, label, preservedFileName } = imageTags;
|
|
807
816
|
const userComment = aphexMetadata ? JSON.stringify(aphexMetadata) : void 0;
|
|
808
817
|
const tags = {
|
|
809
|
-
..."creator" in imageTags
|
|
810
|
-
..."description" in imageTags
|
|
811
|
-
..."credit" in imageTags
|
|
812
|
-
..."label" in imageTags
|
|
813
|
-
..."preservedFileName" in imageTags
|
|
814
|
-
..."aphexMetadata" in imageTags
|
|
818
|
+
..."creator" in imageTags && { "XMP:Creator": creator ?? "" },
|
|
819
|
+
..."description" in imageTags && { "XMP:Description": description ?? "" },
|
|
820
|
+
..."credit" in imageTags && { "XMP:Credit": credit ?? "" },
|
|
821
|
+
..."label" in imageTags && { "XMP:Label": label ?? "" },
|
|
822
|
+
..."preservedFileName" in imageTags && { "XMP:PreservedFileName": preservedFileName ?? "" },
|
|
823
|
+
..."aphexMetadata" in imageTags && { "XMP:UserComment": userComment ?? "" }
|
|
815
824
|
};
|
|
816
825
|
await getExiftool().write(imagePath, tags, { writeArgs: ["-overwrite_original_in_place"] });
|
|
817
826
|
}
|
|
@@ -831,8 +840,8 @@ async function cloneTags(sourceImagePath, destinationImagePath, includeKeys, for
|
|
|
831
840
|
];
|
|
832
841
|
let tagsToAssign = {};
|
|
833
842
|
for (const key of keys) {
|
|
834
|
-
const sourceValue =
|
|
835
|
-
if (sourceValue !==
|
|
843
|
+
const sourceValue = sourceTags[key];
|
|
844
|
+
if (sourceValue !== destinationTags[key] || force) tagsToAssign = {
|
|
836
845
|
...tagsToAssign,
|
|
837
846
|
[key]: sourceValue
|
|
838
847
|
};
|
|
@@ -896,7 +905,7 @@ async function hasAlpha(imagePath) {
|
|
|
896
905
|
]);
|
|
897
906
|
return Number(stdout) < 1;
|
|
898
907
|
} catch (error) {
|
|
899
|
-
throw new Error(`Error checking file: ${String(error)}
|
|
908
|
+
throw new Error(`Error checking file: ${String(error)}`, { cause: error });
|
|
900
909
|
}
|
|
901
910
|
}
|
|
902
911
|
//#endregion
|
|
@@ -937,7 +946,7 @@ async function exportViaAppleScript(photoUuid, forceOriginal = false) {
|
|
|
937
946
|
return path.join(tempDirectory, exportedFileName);
|
|
938
947
|
} catch (error) {
|
|
939
948
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
940
|
-
throw new Error(`Failed to export photo via AppleScript: ${errorMessage}
|
|
949
|
+
throw new Error(`Failed to export photo via AppleScript: ${errorMessage}`, { cause: error });
|
|
941
950
|
}
|
|
942
951
|
}
|
|
943
952
|
//#endregion
|
|
@@ -964,8 +973,9 @@ const defaultExportViaAppleScriptGuiOptions = {
|
|
|
964
973
|
async function exportViaAppleScriptGui(uuid, options) {
|
|
965
974
|
const tempDirectory = await getTempDirectory("engine", "applescript-gui");
|
|
966
975
|
const { colorProfile, fileName, includeLocation, includeMetadata, jpegQuality, maxSizeType, maxSizeValue, photoKind, photoSize, sequentialPrefix, subfolderFormat, tiffBitDepth } = mergeDefaults(options, defaultExportViaAppleScriptGuiOptions);
|
|
976
|
+
const appleScriptPath = path.join(getPackageAssetsPath(import.meta), "applescript-gui.applescript");
|
|
967
977
|
const { failed, stderr } = await execa("osascript", [
|
|
968
|
-
|
|
978
|
+
appleScriptPath,
|
|
969
979
|
uuid,
|
|
970
980
|
tempDirectory,
|
|
971
981
|
photoKind,
|
|
@@ -994,7 +1004,9 @@ async function exportViaAppleScriptGui(uuid, options) {
|
|
|
994
1004
|
* a temporary directory Clean up and move the file as needed afterwards
|
|
995
1005
|
*/
|
|
996
1006
|
async function exportViaFileSystem(photoUuid, forceOriginal = false) {
|
|
997
|
-
const [
|
|
1007
|
+
const [photoInfo] = isPhotoInfo(photoUuid) ? [photoUuid] : await aphexPhotoInfo(photoUuid);
|
|
1008
|
+
if (photoInfo === void 0) throw new Error(`No photo info found for UUID "${isPhotoInfo(photoUuid) ? photoUuid.uuid : photoUuid}"`);
|
|
1009
|
+
const { edited, original } = photoInfo;
|
|
998
1010
|
const tempDirectory = await getTempDirectory("engine", "file-system");
|
|
999
1011
|
if (forceOriginal || edited === void 0) {
|
|
1000
1012
|
const destinationPath = path.join(tempDirectory, original.fileName);
|
|
@@ -1012,6 +1024,7 @@ async function exportViaFileSystem(photoUuid, forceOriginal = false) {
|
|
|
1012
1024
|
*/
|
|
1013
1025
|
async function exportViaSwiftPhotoKit(photoUuid) {
|
|
1014
1026
|
const [result] = await aphexExport(photoUuid, await getTempDirectory("engine", "swift-photokit"));
|
|
1027
|
+
if (result === void 0) throw new Error(`Export via PhotoKit returned no files for photo "${photoUuid}"`);
|
|
1015
1028
|
return result;
|
|
1016
1029
|
}
|
|
1017
1030
|
//#endregion
|
|
@@ -1073,25 +1086,9 @@ async function exportApplePhotos(identifiers, destinationDirectory, options) {
|
|
|
1073
1086
|
const exportedPhoto = {
|
|
1074
1087
|
exportEngine: engine,
|
|
1075
1088
|
exportOptions: resolvedOptions,
|
|
1076
|
-
path:
|
|
1089
|
+
path: await exportPhotoWithEngine(photoInfo, engine, resolvedOptions),
|
|
1077
1090
|
photoInfo
|
|
1078
1091
|
};
|
|
1079
|
-
switch (engine) {
|
|
1080
|
-
case "applescript":
|
|
1081
|
-
exportedPhoto.path = await exportViaAppleScript(photoInfo.uuid);
|
|
1082
|
-
break;
|
|
1083
|
-
case "file-system":
|
|
1084
|
-
exportedPhoto.path = await exportViaFileSystem(photoInfo);
|
|
1085
|
-
break;
|
|
1086
|
-
case "photos-gui": {
|
|
1087
|
-
const [exportedPath] = await exportViaAppleScriptGui(photoInfo.uuid, resolvedOptions.appleScriptGuiOptions);
|
|
1088
|
-
exportedPhoto.path = exportedPath;
|
|
1089
|
-
break;
|
|
1090
|
-
}
|
|
1091
|
-
case "swift-photokit":
|
|
1092
|
-
exportedPhoto.path = await exportViaSwiftPhotoKit(photoInfo.uuid);
|
|
1093
|
-
break;
|
|
1094
|
-
}
|
|
1095
1092
|
const resolvedDestinationDirectory = await ensureDirectoryExists(destinationDirectory);
|
|
1096
1093
|
const normalizedDestinationPath = getImagePathWithFileName(photoInfo, path.join(resolvedDestinationDirectory, path.basename(exportedPhoto.path)), resolvedOptions.fileNameSluggify, resolvedOptions.fileNameAppendUuidFragment, resolvedOptions.fileNameNormalizeExtensions, resolvedOptions.fileNamePrecedence);
|
|
1097
1094
|
await fse.rename(exportedPhoto.path, normalizedDestinationPath);
|
|
@@ -1105,6 +1102,18 @@ async function exportApplePhotos(identifiers, destinationDirectory, options) {
|
|
|
1105
1102
|
}
|
|
1106
1103
|
return exportedPhotos;
|
|
1107
1104
|
}
|
|
1105
|
+
async function exportPhotoWithEngine(photoInfo, engine, options) {
|
|
1106
|
+
switch (engine) {
|
|
1107
|
+
case "applescript": return exportViaAppleScript(photoInfo.uuid);
|
|
1108
|
+
case "file-system": return exportViaFileSystem(photoInfo);
|
|
1109
|
+
case "photos-gui": {
|
|
1110
|
+
const [exportedPath] = await exportViaAppleScriptGui(photoInfo.uuid, options.appleScriptGuiOptions);
|
|
1111
|
+
if (exportedPath === void 0) throw new Error(`Photos GUI export returned no files for photo "${photoInfo.uuid}"`);
|
|
1112
|
+
return exportedPath;
|
|
1113
|
+
}
|
|
1114
|
+
case "swift-photokit": return exportViaSwiftPhotoKit(photoInfo.uuid);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1108
1117
|
/** Also normalizes */
|
|
1109
1118
|
function getImagePathWithFileName(photoInfo, filePath, sluggify = true, fileNameAppendUuidFragment = false, normalizeExtensions = true, namingStrategyPrecedence = [
|
|
1110
1119
|
"title",
|
|
@@ -1114,27 +1123,28 @@ function getImagePathWithFileName(photoInfo, filePath, sluggify = true, fileName
|
|
|
1114
1123
|
const normalizedPath = normalizeExtensions ? normalizeExtension(filePath) : filePath;
|
|
1115
1124
|
const basePath = path.dirname(normalizedPath);
|
|
1116
1125
|
const extension = path.extname(normalizedPath);
|
|
1117
|
-
for (const option of namingStrategyPrecedence)
|
|
1126
|
+
for (const option of namingStrategyPrecedence) {
|
|
1127
|
+
const fileName = getFileNameForStrategy(option, photoInfo, sluggify, fileNameAppendUuidFragment);
|
|
1128
|
+
if (fileName !== void 0) return path.join(basePath, `${fileName}${extension}`);
|
|
1129
|
+
}
|
|
1130
|
+
throw new Error("No valid filename option found, can't name image");
|
|
1131
|
+
}
|
|
1132
|
+
function getFileNameForStrategy(option, photoInfo, sluggify, fileNameAppendUuidFragment) {
|
|
1133
|
+
switch (option) {
|
|
1118
1134
|
case "fileName": {
|
|
1119
1135
|
const nameWithoutExtension = path.basename(photoInfo.original.fileName, path.extname(photoInfo.original.fileName));
|
|
1120
1136
|
const uuidFragment = fileNameAppendUuidFragment ? `-${photoInfo.uuid.slice(-8)}` : "";
|
|
1121
|
-
|
|
1122
|
-
return path.join(basePath, `${processedName}${extension}`);
|
|
1137
|
+
return sluggify ? slug(`${nameWithoutExtension}${uuidFragment}`) : `${nameWithoutExtension}${uuidFragment}`;
|
|
1123
1138
|
}
|
|
1124
1139
|
case "title":
|
|
1125
1140
|
if (isNonEmptyStringAndNotWhitespace(photoInfo.title)) {
|
|
1126
1141
|
const title = sluggify ? slug(photoInfo.title) : photoInfo.title;
|
|
1127
1142
|
const uuidFragment = fileNameAppendUuidFragment ? `-${photoInfo.uuid.slice(-8)}` : "";
|
|
1128
|
-
|
|
1129
|
-
return path.join(basePath, `${processedTitle}${extension}`);
|
|
1143
|
+
return sluggify ? slug(`${title}${uuidFragment}`) : `${title}${uuidFragment}`;
|
|
1130
1144
|
}
|
|
1131
|
-
|
|
1132
|
-
case "uuid":
|
|
1133
|
-
const processedUuid = sluggify ? slug(photoInfo.uuid) : photoInfo.uuid;
|
|
1134
|
-
return path.join(basePath, `${processedUuid}${extension}`);
|
|
1135
|
-
}
|
|
1145
|
+
return;
|
|
1146
|
+
case "uuid": return sluggify ? slug(photoInfo.uuid) : photoInfo.uuid;
|
|
1136
1147
|
}
|
|
1137
|
-
throw new Error("No valid filename option found, can't name image");
|
|
1138
1148
|
}
|
|
1139
1149
|
async function getEngineForPhoto(photoInfo, options) {
|
|
1140
1150
|
const { engineEdited, engineEditedAlpha, engineOriginal, engineOriginalAlpha } = mergeDefaults(options, defaultExportApplePhotoOptions);
|
|
@@ -1178,7 +1188,11 @@ const defaultManageMetadataOptions = {
|
|
|
1178
1188
|
async function manageMetadataBatch(identifiers, targetFilePaths, options, exportOptions) {
|
|
1179
1189
|
const photoInfos = await resolveIdentifiers(identifiers);
|
|
1180
1190
|
if (photoInfos.length !== targetFilePaths.length) throw new Error("Photo infos and target files length mismatch");
|
|
1181
|
-
return Promise.all(photoInfos.map(async (photoInfo, index) =>
|
|
1191
|
+
return Promise.all(photoInfos.map(async (photoInfo, index) => {
|
|
1192
|
+
const targetFilePath = targetFilePaths[index];
|
|
1193
|
+
if (targetFilePath === void 0) throw new Error(`Missing target file path for photo "${photoInfo.uuid}"`);
|
|
1194
|
+
return manageMetadata(photoInfo, targetFilePath, options, exportOptions);
|
|
1195
|
+
}));
|
|
1182
1196
|
}
|
|
1183
1197
|
/**
|
|
1184
1198
|
* Manage metadata...
|
|
@@ -1272,22 +1286,14 @@ async function convertToJpeg(sourceImagePath, destinationDirectory, quality, for
|
|
|
1272
1286
|
case "mozjpeg": {
|
|
1273
1287
|
const tgaTempDirectory = await fse.mkdtemp(path.join(os.tmpdir(), `com.kitschpatrol.aphex.${getSlugFilename(sourceImagePath)}.${engine}.`));
|
|
1274
1288
|
const tgaImagePath = await convertToTga(sourceImagePath, tgaTempDirectory);
|
|
1275
|
-
await
|
|
1276
|
-
"--lossless",
|
|
1289
|
+
await execa("cjpeg", [
|
|
1290
|
+
...jpegQuality === "lossless" ? ["--lossless"] : ["-quality", jpegQuality.toString()],
|
|
1277
1291
|
"-optimize",
|
|
1278
1292
|
"-progressive",
|
|
1279
1293
|
"-outfile",
|
|
1280
1294
|
destinationImagePath,
|
|
1281
1295
|
tgaImagePath
|
|
1282
|
-
])
|
|
1283
|
-
"-quality",
|
|
1284
|
-
jpegQuality.toString(),
|
|
1285
|
-
"-optimize",
|
|
1286
|
-
"-progressive",
|
|
1287
|
-
"-outfile",
|
|
1288
|
-
destinationImagePath,
|
|
1289
|
-
tgaImagePath
|
|
1290
|
-
]));
|
|
1296
|
+
]);
|
|
1291
1297
|
await fse.rm(tgaTempDirectory, { recursive: true });
|
|
1292
1298
|
break;
|
|
1293
1299
|
}
|
|
@@ -1304,7 +1310,6 @@ async function convertToJpeg(sourceImagePath, destinationDirectory, quality, for
|
|
|
1304
1310
|
"--out",
|
|
1305
1311
|
destinationImagePath
|
|
1306
1312
|
]);
|
|
1307
|
-
break;
|
|
1308
1313
|
}
|
|
1309
1314
|
}
|
|
1310
1315
|
return destinationImagePath;
|
|
@@ -1351,7 +1356,7 @@ async function convertToPng(sourceImagePath, destinationDirectory, force = false
|
|
|
1351
1356
|
"--setProperty",
|
|
1352
1357
|
"format",
|
|
1353
1358
|
"png",
|
|
1354
|
-
...restoreColorProfile && restoreColorProfile !== "None" ? [
|
|
1359
|
+
...restoreColorProfile !== void 0 && restoreColorProfile !== "None" ? [
|
|
1355
1360
|
"--setProperty",
|
|
1356
1361
|
"profile",
|
|
1357
1362
|
getPathToColorProfile(restoreColorProfile)
|
|
@@ -1407,7 +1412,7 @@ async function convertToAvif(sourceImagePath, destinationDirectory, quality, for
|
|
|
1407
1412
|
await execa("avifenc", [
|
|
1408
1413
|
...qualityArgs,
|
|
1409
1414
|
"--icc",
|
|
1410
|
-
profile === "None" ?
|
|
1415
|
+
getPathToColorProfile(profile === "None" ? "sRGB IEC61966-2.1" : profile),
|
|
1411
1416
|
"--speed",
|
|
1412
1417
|
"default",
|
|
1413
1418
|
sourceImagePath,
|
|
@@ -1444,9 +1449,7 @@ async function compressImage(sourceImagePath, destinationDirectory, options) {
|
|
|
1444
1449
|
workingImagePath = lookupImageMimeType(sourceImagePath) === "png" ? sourceImagePath : await convertToPng(sourceImagePath, tempDirectory);
|
|
1445
1450
|
workingImagePath = await optimizePng(workingImagePath, tempDirectory);
|
|
1446
1451
|
break;
|
|
1447
|
-
case "webp":
|
|
1448
|
-
workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, "lossless");
|
|
1449
|
-
break;
|
|
1452
|
+
case "webp": workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, "lossless");
|
|
1450
1453
|
}
|
|
1451
1454
|
if (await getSizeBytes(workingImagePath) <= maxFileSizeBytes) {
|
|
1452
1455
|
const destinationImagePath = path.join(destinationDirectory, path.basename(workingImagePath));
|
|
@@ -1465,9 +1468,7 @@ async function compressImage(sourceImagePath, destinationDirectory, options) {
|
|
|
1465
1468
|
case "none":
|
|
1466
1469
|
workingImagePath = sourceImagePath;
|
|
1467
1470
|
break;
|
|
1468
|
-
case "webp":
|
|
1469
|
-
workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, "near-lossless");
|
|
1470
|
-
break;
|
|
1471
|
+
case "webp": workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, "near-lossless");
|
|
1471
1472
|
}
|
|
1472
1473
|
if (await getSizeBytes(workingImagePath) <= maxFileSizeBytes) {
|
|
1473
1474
|
const destinationImagePath = path.join(destinationDirectory, path.basename(workingImagePath));
|
|
@@ -1492,9 +1493,7 @@ async function compressImage(sourceImagePath, destinationDirectory, options) {
|
|
|
1492
1493
|
case "none":
|
|
1493
1494
|
workingImagePath = sourceImagePath;
|
|
1494
1495
|
break;
|
|
1495
|
-
case "webp":
|
|
1496
|
-
workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, lossyQuality, true);
|
|
1497
|
-
break;
|
|
1496
|
+
case "webp": workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, lossyQuality, true);
|
|
1498
1497
|
}
|
|
1499
1498
|
const destinationImagePath = path.join(destinationDirectory, path.basename(workingImagePath));
|
|
1500
1499
|
await fse.copyFile(workingImagePath, destinationImagePath);
|
|
@@ -1509,8 +1508,8 @@ async function compressImage(sourceImagePath, destinationDirectory, options) {
|
|
|
1509
1508
|
}
|
|
1510
1509
|
//#endregion
|
|
1511
1510
|
//#region src/utilities/image/compare.ts
|
|
1512
|
-
const SSIM_REGEX = /All:\s*([\d.]+)
|
|
1513
|
-
const PSNR_REGEX = /average:\s*([\d.]+)
|
|
1511
|
+
const SSIM_REGEX = /All:\s*([\d.]+)/v;
|
|
1512
|
+
const PSNR_REGEX = /average:\s*([\d.]+)/v;
|
|
1514
1513
|
/**
|
|
1515
1514
|
* Get two identically sized PNGs from two images. Uses the smaller image size
|
|
1516
1515
|
* as the target size. Remember to clean up temp
|
|
@@ -1572,9 +1571,9 @@ async function calculateDSSIMInternal(image1, image2) {
|
|
|
1572
1571
|
return -1;
|
|
1573
1572
|
}
|
|
1574
1573
|
const { stdout } = await execa("dssim", [image1, image2]);
|
|
1575
|
-
const [dssimIndex] = stdout.split(" ");
|
|
1574
|
+
const [dssimIndex] = stdout.split(" ", 1);
|
|
1576
1575
|
if (dssimIndex === void 0) throw new Error("DSSIM index not found in dssim output.");
|
|
1577
|
-
return Number
|
|
1576
|
+
return Number(dssimIndex);
|
|
1578
1577
|
}
|
|
1579
1578
|
async function calculateSSIMInternal(image1, image2) {
|
|
1580
1579
|
lookupImageMimeType(image1, ["png"]);
|
|
@@ -1594,8 +1593,8 @@ async function calculateSSIMInternal(image1, image2) {
|
|
|
1594
1593
|
"null",
|
|
1595
1594
|
"-"
|
|
1596
1595
|
]);
|
|
1597
|
-
const
|
|
1598
|
-
return
|
|
1596
|
+
const ssimValue = SSIM_REGEX.exec(result.stderr)?.[1];
|
|
1597
|
+
return ssimValue === void 0 ? 0 : Number(ssimValue);
|
|
1599
1598
|
}
|
|
1600
1599
|
/**
|
|
1601
1600
|
* Calculates the PSNR between two images using ffmpeg.
|
|
@@ -1618,8 +1617,8 @@ async function calculatePSNRInternal(image1, image2) {
|
|
|
1618
1617
|
"null",
|
|
1619
1618
|
"-"
|
|
1620
1619
|
]);
|
|
1621
|
-
const
|
|
1622
|
-
return
|
|
1620
|
+
const psnrValue = PSNR_REGEX.exec(result.stderr)?.[1];
|
|
1621
|
+
return psnrValue === void 0 ? 0 : Number(psnrValue);
|
|
1623
1622
|
}
|
|
1624
1623
|
//#endregion
|
|
1625
1624
|
//#region src/pipeline/image-process.ts
|
|
@@ -1649,8 +1648,9 @@ async function processPhotos(imagePaths, destinationDirectory, options) {
|
|
|
1649
1648
|
const resolvedOptions = mergeDefaults(options, defaultProcessImageOptions);
|
|
1650
1649
|
const tempProcessOutputDirectory = await getTempDirectory("process", "images");
|
|
1651
1650
|
let processImageResults;
|
|
1651
|
+
const [singleImagePath] = imagePaths;
|
|
1652
1652
|
try {
|
|
1653
|
-
if (imagePaths.length === 1) processImageResults = [await processImage(
|
|
1653
|
+
if (singleImagePath !== void 0 && imagePaths.length === 1) processImageResults = [await processImage(singleImagePath, tempProcessOutputDirectory, resolvedOptions)];
|
|
1654
1654
|
else {
|
|
1655
1655
|
const threads = Math.floor(os.availableParallelism() * .5);
|
|
1656
1656
|
log.debug(`Using ${threads} threads for processing`);
|
|
@@ -1664,10 +1664,10 @@ async function processPhotos(imagePaths, destinationDirectory, options) {
|
|
|
1664
1664
|
minThreads: threads
|
|
1665
1665
|
});
|
|
1666
1666
|
try {
|
|
1667
|
-
processImageResults = await Promise.all(imagePaths.map(async (
|
|
1667
|
+
processImageResults = await Promise.all(imagePaths.map(async (imagePath) => piscina.run({
|
|
1668
1668
|
destinationDirectory: tempProcessOutputDirectory,
|
|
1669
1669
|
options: resolvedOptions,
|
|
1670
|
-
sourceImagePath:
|
|
1670
|
+
sourceImagePath: imagePath,
|
|
1671
1671
|
verbose: log.isLevelEnabled("debug")
|
|
1672
1672
|
})));
|
|
1673
1673
|
} finally {
|
|
@@ -1809,7 +1809,7 @@ async function getSyncPlanForImages(identifiers, destinationDirectory, options,
|
|
|
1809
1809
|
}
|
|
1810
1810
|
const sourcePhotoFileBaseName = stripExtension(getImagePathWithFileName(sourcePhotoInfo, "", resolvedExportOptions.exportOptions.fileNameSluggify, resolvedExportOptions.exportOptions.fileNameAppendUuidFragment, resolvedExportOptions.exportOptions.fileNameNormalizeExtensions, resolvedExportOptions.exportOptions.fileNamePrecedence));
|
|
1811
1811
|
const differenceFound = await isDifferent(resolvedOptions.diffStrategies, sourcePhotoInfo, sourcePhotoFileBaseName, matchingDestinationFile.filePath, matchingDestinationFile.tags, resolvedExportOptions, resolvedOptions.forceUpdate);
|
|
1812
|
-
if (differenceFound) {
|
|
1812
|
+
if (differenceFound !== false) {
|
|
1813
1813
|
if (resolvedOptions.deleteTarget) syncResult.toDelete.push(matchingDestinationFile.filePath);
|
|
1814
1814
|
syncResult.plan.push({
|
|
1815
1815
|
diffedVia: differenceFound,
|
|
@@ -1826,7 +1826,7 @@ async function getSyncPlanForImages(identifiers, destinationDirectory, options,
|
|
|
1826
1826
|
status: "unchanged"
|
|
1827
1827
|
});
|
|
1828
1828
|
}
|
|
1829
|
-
if (resolvedOptions.deleteOthers) syncResult.toDelete = [...syncResult.toDelete, ...destinationFiles.filter((file) =>
|
|
1829
|
+
if (resolvedOptions.deleteOthers) syncResult.toDelete = [...syncResult.toDelete, ...destinationFiles.filter((file) => syncResult.plan.every((plan) => plan.matchFilePath !== file.filePath)).map((file) => file.filePath)];
|
|
1830
1830
|
syncResult.toDelete = [...new Set(syncResult.toDelete)];
|
|
1831
1831
|
return syncResult;
|
|
1832
1832
|
}
|
|
@@ -1836,31 +1836,24 @@ async function getSyncPlanForImages(identifiers, destinationDirectory, options,
|
|
|
1836
1836
|
* @returns Undefined if the, or the reason for the difference if found
|
|
1837
1837
|
*/
|
|
1838
1838
|
async function isDifferent(diffStrategies, sourcePhotoInfo, sourcePhotoFileBaseName, matchPhotoFilePath, matchPhotoTags, exportOptions, forceUpdate) {
|
|
1839
|
-
let sourceTags;
|
|
1840
1839
|
if (forceUpdate) return "force-update";
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
break;
|
|
1857
|
-
case "photo-info":
|
|
1858
|
-
if (matchPhotoTags === void 0 || !deepEqual(sourcePhotoInfo, matchPhotoTags.aphexMetadata?.photoInfo)) return diffStrategy;
|
|
1859
|
-
break;
|
|
1860
|
-
case "process-options":
|
|
1861
|
-
if (matchPhotoTags === void 0 || !deepEqual(exportOptions.processOptions, matchPhotoTags.aphexMetadata?.exportOptions.processOptions)) return diffStrategy;
|
|
1862
|
-
break;
|
|
1840
|
+
let sourceTags;
|
|
1841
|
+
async function strategyFindsDifference(diffStrategy) {
|
|
1842
|
+
switch (diffStrategy) {
|
|
1843
|
+
case "exif-tags":
|
|
1844
|
+
sourceTags ??= await getTags(sourcePhotoInfo.original.filePath);
|
|
1845
|
+
return matchPhotoTags === void 0 || sourceTags.credit !== matchPhotoTags.credit || sourceTags.creator !== matchPhotoTags.creator || sourceTags.description !== matchPhotoTags.description || sourceTags.preservedFileName !== matchPhotoTags.preservedFileName || sourceTags.label !== matchPhotoTags.label;
|
|
1846
|
+
case "export-options": return matchPhotoTags === void 0 || !deepEqual(exportOptions.exportOptions, matchPhotoTags.aphexMetadata?.exportOptions.exportOptions);
|
|
1847
|
+
case "file-name": return stripExtension(path.basename(matchPhotoFilePath)) !== sourcePhotoFileBaseName;
|
|
1848
|
+
case "force-update": return true;
|
|
1849
|
+
case "metadata-options":
|
|
1850
|
+
sourceTags ??= await getTags(sourcePhotoInfo.original.filePath);
|
|
1851
|
+
return matchPhotoTags === void 0 || !deepEqual(exportOptions.metadataOptions, matchPhotoTags.aphexMetadata?.exportOptions.metadataOptions);
|
|
1852
|
+
case "photo-info": return matchPhotoTags === void 0 || !deepEqual(sourcePhotoInfo, matchPhotoTags.aphexMetadata?.photoInfo);
|
|
1853
|
+
case "process-options": return matchPhotoTags === void 0 || !deepEqual(exportOptions.processOptions, matchPhotoTags.aphexMetadata?.exportOptions.processOptions);
|
|
1854
|
+
}
|
|
1863
1855
|
}
|
|
1856
|
+
for (const diffStrategy of diffStrategies) if (await strategyFindsDifference(diffStrategy)) return diffStrategy;
|
|
1864
1857
|
return false;
|
|
1865
1858
|
}
|
|
1866
1859
|
//#endregion
|
|
@@ -1953,9 +1946,8 @@ async function exportPhotos(identifiers, destinationDirectory, options) {
|
|
|
1953
1946
|
exportResult.results.exportResult = cleanExportResults(matchingExportResult);
|
|
1954
1947
|
} else log.warn(`No export result found for photo "${exportResult.photoInfo.title ?? exportResult.photoInfo.uuid}"`);
|
|
1955
1948
|
}
|
|
1956
|
-
let processResults;
|
|
1957
1949
|
if (processOptions !== "disabled") {
|
|
1958
|
-
processResults = await processPhotos(exportResults.filter((exportResult) => exportResult.results.syncResult?.status !== "unchanged").map((exportResult) => exportResult.path), destinationDirectory, processOptions);
|
|
1950
|
+
const processResults = await processPhotos(exportResults.filter((exportResult) => exportResult.results.syncResult?.status !== "unchanged").map((exportResult) => exportResult.path), destinationDirectory, processOptions);
|
|
1959
1951
|
await fse.rm(exportDirectory, {
|
|
1960
1952
|
force: true,
|
|
1961
1953
|
recursive: true
|
|
@@ -1969,10 +1961,9 @@ async function exportPhotos(identifiers, destinationDirectory, options) {
|
|
|
1969
1961
|
} else log.warn(`No process result found for photo "${exportResult.photoInfo.title ?? exportResult.photoInfo.uuid}"`);
|
|
1970
1962
|
}
|
|
1971
1963
|
}
|
|
1972
|
-
let metadataResults;
|
|
1973
1964
|
if (metadataOptions !== "disabled") {
|
|
1974
1965
|
const targetPhotos = exportResults.filter((exportResult) => exportResult.results.syncResult?.status !== "unchanged");
|
|
1975
|
-
metadataResults = await manageMetadataBatch(targetPhotos.map((exportResult) => exportResult.photoInfo), targetPhotos.map((exportResult) => exportResult.path), metadataOptions, resolvedOptions);
|
|
1966
|
+
const metadataResults = await manageMetadataBatch(targetPhotos.map((exportResult) => exportResult.photoInfo), targetPhotos.map((exportResult) => exportResult.path), metadataOptions, resolvedOptions);
|
|
1976
1967
|
for (const exportResult of exportResults) {
|
|
1977
1968
|
const matchingMetadataResult = metadataResults.find((photoExportResult) => photoExportResult.photoInfo.uuid === exportResult.photoInfo.uuid);
|
|
1978
1969
|
if (matchingMetadataResult) exportResult.results.metadataResult = cleanMetadataResults(matchingMetadataResult);
|
|
@@ -27,9 +27,8 @@
|
|
|
27
27
|
export default async function worker({ destinationDirectory, options, sourceImagePath, verbose }) {
|
|
28
28
|
// Weird workaround after issues with ESM imports in worker threads and more recent versions of Node / TSX / Piscina / etc?
|
|
29
29
|
// ts-node didn't work
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
)
|
|
30
|
+
const importx = await import('importx')
|
|
31
|
+
const { processImage } = await importx.import('../index', import.meta.url)
|
|
33
32
|
|
|
34
33
|
if (verbose) {
|
|
35
34
|
console.log(`Processing image on worker thread:\n${sourceImagePath}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitschpatrol/aphex",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Apple Photos Export. TypeScript library and CLI tool to export and process images and albums from your macOS Photos.app library.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"apple",
|
|
@@ -41,39 +41,41 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@sindresorhus/is": "^8.1.0",
|
|
43
43
|
"defu": "^6.1.7",
|
|
44
|
-
"execa": "^
|
|
45
|
-
"exiftool-vendored": "^
|
|
46
|
-
"fast-equals": "^6.0.
|
|
47
|
-
"fs-extra": "^11.
|
|
44
|
+
"execa": "^10.0.1",
|
|
45
|
+
"exiftool-vendored": "^38.1.0",
|
|
46
|
+
"fast-equals": "^6.0.4",
|
|
47
|
+
"fs-extra": "^11.4.0",
|
|
48
48
|
"github-slugger": "^2.0.0",
|
|
49
|
-
"image-size": "^2.0.
|
|
49
|
+
"image-size": "^2.0.4",
|
|
50
50
|
"importx": "^0.5.2",
|
|
51
|
-
"lognow": "^0.
|
|
51
|
+
"lognow": "^0.8.4",
|
|
52
52
|
"mrmime": "^2.0.1",
|
|
53
|
-
"piscina": "^5.
|
|
54
|
-
"type-fest": "^5.
|
|
53
|
+
"piscina": "^5.3.2",
|
|
54
|
+
"type-fest": "^5.10.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@arethetypeswrong/core": "^0.18.
|
|
58
|
-
"@clack/prompts": "^1.
|
|
59
|
-
"@kitschpatrol/shared-config": "^
|
|
57
|
+
"@arethetypeswrong/core": "^0.18.5",
|
|
58
|
+
"@clack/prompts": "^1.8.1",
|
|
59
|
+
"@kitschpatrol/shared-config": "^8.10.0",
|
|
60
60
|
"@types/fs-extra": "^11.0.4",
|
|
61
|
-
"@types/node": "~
|
|
62
|
-
"@vitest/coverage-v8": "^
|
|
63
|
-
"
|
|
64
|
-
"
|
|
61
|
+
"@types/node": "~24.13.4",
|
|
62
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
63
|
+
"brewpub": "^0.3.0",
|
|
64
|
+
"bumpp": "^12.3.0",
|
|
65
|
+
"globby": "^16.2.4",
|
|
65
66
|
"markdown-table": "^3.0.4",
|
|
66
|
-
"mdat-plugin-cli-help": "^3.
|
|
67
|
-
"open": "^11.0.
|
|
68
|
-
"pretty-bytes": "^7.1.
|
|
69
|
-
"pretty-ms": "^9.3.
|
|
70
|
-
"publint": "^0.3.
|
|
71
|
-
"tsdown": "^0.
|
|
72
|
-
"tsx": "^4.
|
|
73
|
-
"
|
|
67
|
+
"mdat-plugin-cli-help": "^3.2.0",
|
|
68
|
+
"open": "^11.0.4",
|
|
69
|
+
"pretty-bytes": "^7.1.3",
|
|
70
|
+
"pretty-ms": "^9.3.1",
|
|
71
|
+
"publint": "^0.3.24",
|
|
72
|
+
"tsdown": "^0.23.0",
|
|
73
|
+
"tsx": "^4.23.15",
|
|
74
|
+
"typescript": "~6.0.3",
|
|
75
|
+
"vitest": "^5.0.1"
|
|
74
76
|
},
|
|
75
77
|
"engines": {
|
|
76
|
-
"node": ">=
|
|
78
|
+
"node": ">=24.16.0"
|
|
77
79
|
},
|
|
78
80
|
"os": [
|
|
79
81
|
"darwin"
|
|
@@ -85,14 +87,14 @@
|
|
|
85
87
|
"access": "public"
|
|
86
88
|
},
|
|
87
89
|
"scripts": {
|
|
88
|
-
"build": "pnpm --sequential /^build:/",
|
|
90
|
+
"build": "pnpm run --sequential /^build:/",
|
|
89
91
|
"build:1-lib": "tsdown",
|
|
90
92
|
"build:2-native": "cd native/aphex-swift && ./build.sh",
|
|
91
93
|
"build:3-publint": "publint",
|
|
92
|
-
"clean": "git rm -f pnpm-lock.yaml && git clean -fdX -e !.claude/",
|
|
94
|
+
"clean-deep": "git rm -f pnpm-lock.yaml && git clean -fdX -e !.claude/",
|
|
93
95
|
"fix": "ksc fix && mdat ./native/aphex-swift/readme.md",
|
|
94
96
|
"lint": "ksc lint",
|
|
95
|
-
"release": "bumpp --commit 'Release: %s' && pnpm build &&
|
|
97
|
+
"release": "bumpp --commit 'Release: %s' && pnpm build && pnpm publish --otp $(op item get npm --otp) && brewpub --tap kitschpatrol/tap",
|
|
96
98
|
"test": "vitest run",
|
|
97
99
|
"test:coverage": "vitest run --coverage"
|
|
98
100
|
}
|
package/readme.md
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
<!-- badges -->
|
|
10
10
|
|
|
11
|
-
[](https://npmjs.com/package/@kitschpatrol/aphex)
|
|
12
|
-
[](https://opensource.org/license/mit
|
|
11
|
+
[](https://www.npmjs.com/package/@kitschpatrol/aphex)
|
|
12
|
+
[](https://opensource.org/license/mit)
|
|
13
13
|
|
|
14
14
|
<!-- /badges -->
|
|
15
15
|
|
|
@@ -35,9 +35,9 @@ Aphex is a TypeScript library for exporting images and albums from your local ma
|
|
|
35
35
|
|
|
36
36
|
It makes it simple to export high-quality versions of specific photos or albums from your Photos.app library via a path-like syntax. It can also (optionally) perform image resizing, compression, metadata migration, metadata validation, and color space normalization.
|
|
37
37
|
|
|
38
|
-
I created this library for integration in static website content management asset pipelines, and to attempt to work around some issues related to [exporting high-quality versions of edited images](https://github.com/RhetTbull/osxphotos/discussions/1522) from the Photos.app library. (See the [unplugin-aphex](https://github.com/kitschpatrol/unplugin-aphex) project for an additional layer of integration with various build tools, and the [vscode-aphex](https://github.com/kitschpatrol/vscode-aphex) plugin for hover previews of Aphex links in VS Code.)
|
|
38
|
+
I created this library for integration in static website content management asset pipelines, and to attempt to work around some issues related to [exporting high-quality versions of edited images](https://github.com/RhetTbull/osxphotos/discussions/1522) from the Photos.app library. (See the [unplugin-aphex](https://github.com/kitschpatrol/unplugin-aphex) project for an additional layer of integration with various build tools, and the [vscode-aphex-preview](https://github.com/kitschpatrol/vscode-aphex-preview) plugin for hover previews of Aphex links in VS Code.)
|
|
39
39
|
|
|
40
|
-
This repository also embeds the `aphex-swift` CLI project, which provides a minimal and performant wrapper around parts of Apple's PhotoKit framework. It's not intended for direct use
|
|
40
|
+
This repository also embeds the `aphex-swift` CLI project, which provides a minimal and performant wrapper around parts of Apple's PhotoKit framework. It's not really intended for direct use beyond querying the Photos library and simple exports. It provides just enough functionality to support the parts of the methods provided by the `aphex` TypeScript library that can only be implemented natively.
|
|
41
41
|
|
|
42
42
|
<!-- /* spell-checker:disable */ -->
|
|
43
43
|
|
|
@@ -68,8 +68,23 @@ If you skip image processing (`processOptions: 'disabled'`) you can omit the Hom
|
|
|
68
68
|
|
|
69
69
|
### Installation
|
|
70
70
|
|
|
71
|
+
The easiest way to install the CLI tool is via Homebrew:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
brew install kitschpatrol/tap/aphex
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
If you're working in a Node project or prefer to install through npm, you will need some additional dependencies for the image processing functionality exposed through the library API.
|
|
78
|
+
|
|
79
|
+
These are easiest to install via Homebrew:
|
|
80
|
+
|
|
71
81
|
```sh
|
|
72
82
|
brew install libavif mozjpeg imagemagick webp dssim ffmpeg guetzli oxipng
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Then, in your node project:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
73
88
|
npm install @kitschpatrol/aphex
|
|
74
89
|
```
|
|
75
90
|
|
|
@@ -77,7 +92,7 @@ npm install @kitschpatrol/aphex
|
|
|
77
92
|
|
|
78
93
|
In most cases, the application invoking aphex should request permission to access your Photos.app library on first use.
|
|
79
94
|
|
|
80
|
-
In certain situations, like executing commands in a VS Code terminal, can [fail to prompt for permission](https://errorism.dev/issues/microsoft-vscode-vscode-terminal-doesnt-allowrequest-permissions-to-access-media-devices). You can work around this through some _highly inadvisable_ direct manipulation of the [permissions database](https://www.rainforestqa.com/blog/macos-tcc-db-deep-dive):
|
|
95
|
+
In certain situations, like executing commands in a VS Code terminal, can [fail to prompt for permission](https://errorism.dev/issues/microsoft-vscode-vscode-terminal-doesnt-allowrequest-permissions-to-access-media-devices). You can work around this through some _highly inadvisable_ direct manipulation of the [permissions database](https://web.archive.org/web/https://www.rainforestqa.com/blog/macos-tcc-db-deep-dive):
|
|
81
96
|
|
|
82
97
|
For example, to grant photo library permission to VS Code:
|
|
83
98
|
|
|
@@ -192,7 +207,7 @@ setLogger(console)
|
|
|
192
207
|
|
|
193
208
|
##### Exporting a photo by filename
|
|
194
209
|
|
|
195
|
-
Let's assume you have an album named "Trip" in your Photos.app library containing a photo with the filename "
|
|
210
|
+
Let's assume you have an album named "Trip" in your Photos.app library containing a photo with the filename "IMG\_1922.jpeg":
|
|
196
211
|
|
|
197
212
|
```ts
|
|
198
213
|
const result = await exportPhoto('Trip/IMG_1922.jpeg', '~/Desktop')
|