@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 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 */colorProfile: 'AdobeRGB' | 'Display P3' | 'Most Compatible' | 'Original' | 'sRGB'; /** "File Name" Drop Down */
80
- fileName: 'Album Name With Number' | 'Sequential' | 'Use File Name' | 'Use Title'; /** "Include Location Information" Checkbox */
81
- includeLocation: boolean; /** "Include Title, Keywords, and Caption" Checkbox */
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; /** "Photo Kind" Drop Down */
101
- photoKind: 'HEIC' | 'JPEG' | 'PNG' | 'TIFF'; /** "Size" Drop Down */
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; /** "Subfolder Format" Drop Down */
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; /** Human Name */
160
- creator?: string | undefined; /** Organization */
161
- credit?: string | undefined; /** Description, used as alt text in HTML content */
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 */defaultColorProfile: ColorProfile; /** Calculate SSIM/PSNR/DSSIM metrics comparing original vs processed images */
282
- logSimilarity: boolean; /** Lossless format used specifically for images with alpha channels */
283
- losslessFormatAlpha: LosslessFormat; /** Lossy format used specifically for images with alpha channels */
284
- lossyFormatAlpha: LossyFormat; /** Maximum pixel dimensions before triggering resize (width OR height) */
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
- }; /** Near-lossless format used specifically for images with alpha channels */
289
- nearLosslessFormatAlpha: NearLosslessFormat; /** Image formats that bypass conversion and compression entirely */
290
- passthroughFormats: ImageMimeType[]; /** Color profiles that are kept unchanged instead of being normalized */
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 { ExportOptions, ExportResult, IMAGE_MIME_TYPES, type ImageMimeType, defaultExportOptions, defaultSyncOptions, endExiftool, exportPhoto, exportPhotos, aphexAlbumInfo as getAlbumInfo, aphexPhotoInfo as getPhotoInfo, interactiveSessionStart, interactiveSessionStop, mergeDefaultExportOptions, processImage, setLogger };
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, { dirname } from "node: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 > 0) {
62
- log.debug(`Ending exiftool processes: ${exiftoolInstance.pids.join(", ")}`);
63
- await exiftoolInstance.end();
64
- log.debug("Exiftool processes ended");
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", async () => {
77
- if (!cleanupScheduled) {
78
- cleanupScheduled = true;
79
- await endExiftool();
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", async () => {
86
- log.debug("Received SIGINT, cleaning up exiftool...");
87
- await endExiftool();
88
- process.exit(130);
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", async () => {
91
- log.debug("Received SIGTERM, cleaning up exiftool...");
92
- await endExiftool();
93
- process.exit(143);
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}$/i;
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
- await fse.rm(filePath, { force: true });
175
- cleanCount += 1;
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: getPackageBinPath(import.meta),
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 !== void 0) {
262
- const error = new Error(data.toString().trim());
263
- const request = currentRequest;
264
- currentRequest = void 0;
265
- request.reject(error);
266
- processNextCommand();
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 || commandQueue.length === 0 || interactiveProcess === void 0) return;
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 ((key === "dateCreated" || key === "dateModified" || key === "dateStart" || key === "dateEnd") && typeof value === "string") return value ? new Date(value) : void 0;
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
- if (aphexPhotoInfoResult.length === 0) throw new Error(`No photo asset found for identifier "${identifier}"`);
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 aphexPhotoInfoResult[0];
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 = [...new Set([...object[key], ...value])];
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 ((key === "dateCreated" || key === "dateModified" || key === "dateStart" || key === "dateEnd") && typeof value === "string") return value ? new Date(value) : void 0;
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 ? { "XMP:Creator": creator ?? "" } : {},
810
- ..."description" in imageTags ? { "XMP:Description": description ?? "" } : {},
811
- ..."credit" in imageTags ? { "XMP:Credit": credit ?? "" } : {},
812
- ..."label" in imageTags ? { "XMP:Label": label ?? "" } : {},
813
- ..."preservedFileName" in imageTags ? { "XMP:PreservedFileName": preservedFileName ?? "" } : {},
814
- ..."aphexMetadata" in imageTags ? { "XMP:UserComment": userComment ?? "" } : {}
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 = key in sourceTags ? sourceTags[key] : void 0;
835
- if (sourceValue !== (key in destinationTags ? destinationTags[key] : void 0) || force) tagsToAssign = {
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
- path.join(getPackageAssetsPath(import.meta), "applescript-gui.applescript"),
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 [{ edited, original }] = isPhotoInfo(photoUuid) ? [photoUuid] : await aphexPhotoInfo(photoUuid);
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) switch (option) {
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
- const processedName = sluggify ? slug(`${nameWithoutExtension}${uuidFragment}`) : `${nameWithoutExtension}${uuidFragment}`;
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
- const processedTitle = sluggify ? slug(`${title}${uuidFragment}`) : `${title}${uuidFragment}`;
1129
- return path.join(basePath, `${processedTitle}${extension}`);
1143
+ return sluggify ? slug(`${title}${uuidFragment}`) : `${title}${uuidFragment}`;
1130
1144
  }
1131
- break;
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) => manageMetadata(photoInfo, targetFilePaths[index], options, exportOptions)));
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 (jpegQuality === "lossless" ? execa("cjpeg", [
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
- ]) : execa("cjpeg", [
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" ? getPathToColorProfile("sRGB IEC61966-2.1") : getPathToColorProfile(profile),
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.parseFloat(dssimIndex);
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 match = SSIM_REGEX.exec(result.stderr);
1598
- return match?.[1] ? Number.parseFloat(match[1]) : 0;
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 match = PSNR_REGEX.exec(result.stderr);
1622
- return match?.[1] ? Number.parseFloat(match[1]) : 0;
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(imagePaths[0], tempProcessOutputDirectory, resolvedOptions)];
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 (path) => piscina.run({
1667
+ processImageResults = await Promise.all(imagePaths.map(async (imagePath) => piscina.run({
1668
1668
  destinationDirectory: tempProcessOutputDirectory,
1669
1669
  options: resolvedOptions,
1670
- sourceImagePath: path,
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) => !syncResult.plan.some((plan) => plan.matchFilePath === file.filePath)).map((file) => file.filePath)];
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
- for (const diffStrategy of diffStrategies) switch (diffStrategy) {
1842
- case "exif-tags":
1843
- sourceTags ??= await getTags(sourcePhotoInfo.original.filePath);
1844
- if (matchPhotoTags === void 0 || sourceTags.credit !== matchPhotoTags.credit || sourceTags.creator !== matchPhotoTags.creator || sourceTags.description !== matchPhotoTags.description || sourceTags.preservedFileName !== matchPhotoTags.preservedFileName || sourceTags.label !== matchPhotoTags.label) return diffStrategy;
1845
- break;
1846
- case "export-options":
1847
- if (matchPhotoTags === void 0 || !deepEqual(exportOptions.exportOptions, matchPhotoTags.aphexMetadata?.exportOptions.exportOptions)) return diffStrategy;
1848
- break;
1849
- case "file-name":
1850
- if (stripExtension(path.basename(matchPhotoFilePath)) !== sourcePhotoFileBaseName) return diffStrategy;
1851
- break;
1852
- case "force-update": return diffStrategy;
1853
- case "metadata-options":
1854
- sourceTags ??= await getTags(sourcePhotoInfo.original.filePath);
1855
- if (matchPhotoTags === void 0 || !deepEqual(exportOptions.metadataOptions, matchPhotoTags.aphexMetadata?.exportOptions.metadataOptions)) return diffStrategy;
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 { processImage } = await import('importx').then(async (x) =>
31
- x.import('../index', import.meta.url),
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.2",
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": "^9.6.1",
45
- "exiftool-vendored": "^35.20.0",
46
- "fast-equals": "^6.0.0",
47
- "fs-extra": "^11.3.5",
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.2",
49
+ "image-size": "^2.0.4",
50
50
  "importx": "^0.5.2",
51
- "lognow": "^0.6.2",
51
+ "lognow": "^0.8.4",
52
52
  "mrmime": "^2.0.1",
53
- "piscina": "^5.1.4",
54
- "type-fest": "^5.6.0"
53
+ "piscina": "^5.3.2",
54
+ "type-fest": "^5.10.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@arethetypeswrong/core": "^0.18.2",
58
- "@clack/prompts": "^1.4.0",
59
- "@kitschpatrol/shared-config": "^7.6.0",
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": "~22.18.13",
62
- "@vitest/coverage-v8": "^4.1.6",
63
- "bumpp": "^11.1.0",
64
- "globby": "^16.2.0",
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.0.0",
67
- "open": "^11.0.0",
68
- "pretty-bytes": "^7.1.0",
69
- "pretty-ms": "^9.3.0",
70
- "publint": "^0.3.21",
71
- "tsdown": "^0.22.0",
72
- "tsx": "^4.22.0",
73
- "vitest": "^4.1.6"
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": ">=22.18.0"
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 && NPM_AUTH_TOKEN=$(op read 'op://Personal/npm/token') pnpm publish",
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
- [![NPM Package @kitschpatrol/aphex](https://img.shields.io/npm/v/@kitschpatrol/aphex.svg)](https://npmjs.com/package/@kitschpatrol/aphex)
12
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/license/mit/)
11
+ [![NPM Package @kitschpatrol/aphex](https://img.shields.io/npm/v/@kitschpatrol/aphex.svg)](https://www.npmjs.com/package/@kitschpatrol/aphex)
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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, instead it provides just enough functionality to support the parts of the methods provided by the `aphex` TypeScript library that can only be implemented natively.
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 "IMG_1922.jpeg":
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')