@kitschpatrol/aphex 0.1.1 → 0.1.3

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
@@ -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 = {
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
@@ -200,7 +202,7 @@ function assertSingleElement(array) {
200
202
  * getDirname(import.meta)
201
203
  */
202
204
  function getDirname(meta) {
203
- return dirname(fileURLToPath(process.env.PISCINA_WORKER_META_URL ?? meta.url));
205
+ return path.dirname(fileURLToPath(process.env.PISCINA_WORKER_META_URL ?? meta.url));
204
206
  }
205
207
  /**
206
208
  * Asset directory path of package or distribution folder (Copied by tsdown)
@@ -226,7 +228,7 @@ function getPackageBinPath(meta) {
226
228
  }
227
229
  //#endregion
228
230
  //#region src/aphex-swift/cli-bridge.ts
229
- const SHELL_SPECIAL_CHARS_REGEX = /[\s'"\\]/;
231
+ const SHELL_SPECIAL_CHARS_REGEX = /[\s'"\\]/v;
230
232
  let interactiveProcess;
231
233
  let currentRequest;
232
234
  /**
@@ -237,8 +239,9 @@ let currentRequest;
237
239
  */
238
240
  async function interactiveSessionStart() {
239
241
  if (interactiveProcess !== void 0) throw new Error("Interactive session is already active");
242
+ const binPath = getPackageBinPath(import.meta);
240
243
  interactiveProcess = spawn("./aphex-swift", ["interactive"], {
241
- cwd: getPackageBinPath(import.meta),
244
+ cwd: binPath,
242
245
  stdio: [
243
246
  "pipe",
244
247
  "pipe",
@@ -258,13 +261,12 @@ async function interactiveSessionStart() {
258
261
  } catch {}
259
262
  });
260
263
  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
- }
264
+ if (currentRequest === void 0) return;
265
+ const error = new Error(data.toString().trim());
266
+ const request = currentRequest;
267
+ currentRequest = void 0;
268
+ request.reject(error);
269
+ processNextCommand();
268
270
  });
269
271
  interactiveProcess.on("close", (code) => {
270
272
  const error = /* @__PURE__ */ new Error(`Interactive session exited with code ${code}`);
@@ -310,7 +312,7 @@ function isInteractiveSessionActive() {
310
312
  }
311
313
  let commandQueue = [];
312
314
  function processNextCommand() {
313
- if (currentRequest !== void 0 || commandQueue.length === 0 || interactiveProcess === void 0) return;
315
+ if (currentRequest !== void 0 || interactiveProcess === void 0 || commandQueue.length === 0) return;
314
316
  const next = commandQueue.shift();
315
317
  currentRequest = {
316
318
  buffer: "",
@@ -472,7 +474,12 @@ async function aphexExport(identifiers, destination, caseSensitive = false, orig
472
474
  }
473
475
  }
474
476
  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;
477
+ if (typeof value === "string" && [
478
+ "dateCreated",
479
+ "dateEnd",
480
+ "dateModified",
481
+ "dateStart"
482
+ ].includes(key)) return value.length > 0 ? new Date(value) : void 0;
476
483
  return value;
477
484
  }
478
485
  //#endregion
@@ -500,15 +507,16 @@ async function resolveIdentifiers(identifiers) {
500
507
  async function resolvePhotoIdentifier(identifier) {
501
508
  if (isPhotoInfo(identifier)) return identifier;
502
509
  const aphexPhotoInfoResult = await aphexPhotoInfo(identifier);
503
- if (aphexPhotoInfoResult.length === 0) throw new Error(`No photo asset found for identifier "${identifier}"`);
510
+ const [photoInfo] = aphexPhotoInfoResult;
511
+ if (photoInfo === void 0) throw new Error(`No photo asset found for identifier "${identifier}"`);
504
512
  if (aphexPhotoInfoResult.length > 1) throw new Error(`Multiple photo assets found for identifier "${identifier} — is it an album?"`);
505
- return aphexPhotoInfoResult[0];
513
+ return photoInfo;
506
514
  }
507
515
  //#endregion
508
516
  //#region src/utilities/defaults.ts
509
517
  const mergeDefaultsInternal = createDefu((object, key, value) => {
510
518
  if (Array.isArray(object[key]) && Array.isArray(value)) {
511
- const merged = [...new Set([...object[key], ...value])];
519
+ const merged = [.../* @__PURE__ */ new Set([...object[key], ...value])];
512
520
  Object.assign(object, { [key]: merged });
513
521
  return true;
514
522
  }
@@ -574,7 +582,7 @@ function lookupImageMimeType(imagePath, validate = false) {
574
582
  }
575
583
  //#endregion
576
584
  //#region src/utilities/image/color.ts
577
- const SIPS_PROFILE_REGEX = /profile: (.+)/;
585
+ const SIPS_PROFILE_REGEX = /profile: (.+)/v;
578
586
  /**
579
587
  * Get the color profile of an image
580
588
  * https://github.com/saucecontrol/Compact-ICC-Profiles?tab=readme-ov-file
@@ -777,7 +785,12 @@ async function getTags(imagePath) {
777
785
  };
778
786
  }
779
787
  function dateReviver(key, value) {
780
- if ((key === "dateCreated" || key === "dateModified" || key === "dateStart" || key === "dateEnd") && typeof value === "string") return value ? new Date(value) : void 0;
788
+ if (typeof value === "string" && [
789
+ "dateCreated",
790
+ "dateEnd",
791
+ "dateModified",
792
+ "dateStart"
793
+ ].includes(key)) return value.length > 0 ? new Date(value) : void 0;
781
794
  return value;
782
795
  }
783
796
  function isAphexMetadata(value) {
@@ -806,12 +819,12 @@ async function setTags(imagePath, imageTags) {
806
819
  const { aphexMetadata, creator, credit, description, label, preservedFileName } = imageTags;
807
820
  const userComment = aphexMetadata ? JSON.stringify(aphexMetadata) : void 0;
808
821
  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 ?? "" } : {}
822
+ ..."creator" in imageTags && { "XMP:Creator": creator ?? "" },
823
+ ..."description" in imageTags && { "XMP:Description": description ?? "" },
824
+ ..."credit" in imageTags && { "XMP:Credit": credit ?? "" },
825
+ ..."label" in imageTags && { "XMP:Label": label ?? "" },
826
+ ..."preservedFileName" in imageTags && { "XMP:PreservedFileName": preservedFileName ?? "" },
827
+ ..."aphexMetadata" in imageTags && { "XMP:UserComment": userComment ?? "" }
815
828
  };
816
829
  await getExiftool().write(imagePath, tags, { writeArgs: ["-overwrite_original_in_place"] });
817
830
  }
@@ -831,8 +844,8 @@ async function cloneTags(sourceImagePath, destinationImagePath, includeKeys, for
831
844
  ];
832
845
  let tagsToAssign = {};
833
846
  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 = {
847
+ const sourceValue = sourceTags[key];
848
+ if (sourceValue !== destinationTags[key] || force) tagsToAssign = {
836
849
  ...tagsToAssign,
837
850
  [key]: sourceValue
838
851
  };
@@ -896,7 +909,7 @@ async function hasAlpha(imagePath) {
896
909
  ]);
897
910
  return Number(stdout) < 1;
898
911
  } catch (error) {
899
- throw new Error(`Error checking file: ${String(error)}`);
912
+ throw new Error(`Error checking file: ${String(error)}`, { cause: error });
900
913
  }
901
914
  }
902
915
  //#endregion
@@ -937,7 +950,7 @@ async function exportViaAppleScript(photoUuid, forceOriginal = false) {
937
950
  return path.join(tempDirectory, exportedFileName);
938
951
  } catch (error) {
939
952
  const errorMessage = error instanceof Error ? error.message : String(error);
940
- throw new Error(`Failed to export photo via AppleScript: ${errorMessage}`);
953
+ throw new Error(`Failed to export photo via AppleScript: ${errorMessage}`, { cause: error });
941
954
  }
942
955
  }
943
956
  //#endregion
@@ -964,8 +977,9 @@ const defaultExportViaAppleScriptGuiOptions = {
964
977
  async function exportViaAppleScriptGui(uuid, options) {
965
978
  const tempDirectory = await getTempDirectory("engine", "applescript-gui");
966
979
  const { colorProfile, fileName, includeLocation, includeMetadata, jpegQuality, maxSizeType, maxSizeValue, photoKind, photoSize, sequentialPrefix, subfolderFormat, tiffBitDepth } = mergeDefaults(options, defaultExportViaAppleScriptGuiOptions);
980
+ const appleScriptPath = path.join(getPackageAssetsPath(import.meta), "applescript-gui.applescript");
967
981
  const { failed, stderr } = await execa("osascript", [
968
- path.join(getPackageAssetsPath(import.meta), "applescript-gui.applescript"),
982
+ appleScriptPath,
969
983
  uuid,
970
984
  tempDirectory,
971
985
  photoKind,
@@ -994,7 +1008,9 @@ async function exportViaAppleScriptGui(uuid, options) {
994
1008
  * a temporary directory Clean up and move the file as needed afterwards
995
1009
  */
996
1010
  async function exportViaFileSystem(photoUuid, forceOriginal = false) {
997
- const [{ edited, original }] = isPhotoInfo(photoUuid) ? [photoUuid] : await aphexPhotoInfo(photoUuid);
1011
+ const [photoInfo] = isPhotoInfo(photoUuid) ? [photoUuid] : await aphexPhotoInfo(photoUuid);
1012
+ if (photoInfo === void 0) throw new Error(`No photo info found for UUID "${isPhotoInfo(photoUuid) ? photoUuid.uuid : photoUuid}"`);
1013
+ const { edited, original } = photoInfo;
998
1014
  const tempDirectory = await getTempDirectory("engine", "file-system");
999
1015
  if (forceOriginal || edited === void 0) {
1000
1016
  const destinationPath = path.join(tempDirectory, original.fileName);
@@ -1012,6 +1028,7 @@ async function exportViaFileSystem(photoUuid, forceOriginal = false) {
1012
1028
  */
1013
1029
  async function exportViaSwiftPhotoKit(photoUuid) {
1014
1030
  const [result] = await aphexExport(photoUuid, await getTempDirectory("engine", "swift-photokit"));
1031
+ if (result === void 0) throw new Error(`Export via PhotoKit returned no files for photo "${photoUuid}"`);
1015
1032
  return result;
1016
1033
  }
1017
1034
  //#endregion
@@ -1073,25 +1090,9 @@ async function exportApplePhotos(identifiers, destinationDirectory, options) {
1073
1090
  const exportedPhoto = {
1074
1091
  exportEngine: engine,
1075
1092
  exportOptions: resolvedOptions,
1076
- path: "",
1093
+ path: await exportPhotoWithEngine(photoInfo, engine, resolvedOptions),
1077
1094
  photoInfo
1078
1095
  };
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
1096
  const resolvedDestinationDirectory = await ensureDirectoryExists(destinationDirectory);
1096
1097
  const normalizedDestinationPath = getImagePathWithFileName(photoInfo, path.join(resolvedDestinationDirectory, path.basename(exportedPhoto.path)), resolvedOptions.fileNameSluggify, resolvedOptions.fileNameAppendUuidFragment, resolvedOptions.fileNameNormalizeExtensions, resolvedOptions.fileNamePrecedence);
1097
1098
  await fse.rename(exportedPhoto.path, normalizedDestinationPath);
@@ -1105,6 +1106,18 @@ async function exportApplePhotos(identifiers, destinationDirectory, options) {
1105
1106
  }
1106
1107
  return exportedPhotos;
1107
1108
  }
1109
+ async function exportPhotoWithEngine(photoInfo, engine, options) {
1110
+ switch (engine) {
1111
+ case "applescript": return exportViaAppleScript(photoInfo.uuid);
1112
+ case "file-system": return exportViaFileSystem(photoInfo);
1113
+ case "photos-gui": {
1114
+ const [exportedPath] = await exportViaAppleScriptGui(photoInfo.uuid, options.appleScriptGuiOptions);
1115
+ if (exportedPath === void 0) throw new Error(`Photos GUI export returned no files for photo "${photoInfo.uuid}"`);
1116
+ return exportedPath;
1117
+ }
1118
+ case "swift-photokit": return exportViaSwiftPhotoKit(photoInfo.uuid);
1119
+ }
1120
+ }
1108
1121
  /** Also normalizes */
1109
1122
  function getImagePathWithFileName(photoInfo, filePath, sluggify = true, fileNameAppendUuidFragment = false, normalizeExtensions = true, namingStrategyPrecedence = [
1110
1123
  "title",
@@ -1114,27 +1127,28 @@ function getImagePathWithFileName(photoInfo, filePath, sluggify = true, fileName
1114
1127
  const normalizedPath = normalizeExtensions ? normalizeExtension(filePath) : filePath;
1115
1128
  const basePath = path.dirname(normalizedPath);
1116
1129
  const extension = path.extname(normalizedPath);
1117
- for (const option of namingStrategyPrecedence) switch (option) {
1130
+ for (const option of namingStrategyPrecedence) {
1131
+ const fileName = getFileNameForStrategy(option, photoInfo, sluggify, fileNameAppendUuidFragment);
1132
+ if (fileName !== void 0) return path.join(basePath, `${fileName}${extension}`);
1133
+ }
1134
+ throw new Error("No valid filename option found, can't name image");
1135
+ }
1136
+ function getFileNameForStrategy(option, photoInfo, sluggify, fileNameAppendUuidFragment) {
1137
+ switch (option) {
1118
1138
  case "fileName": {
1119
1139
  const nameWithoutExtension = path.basename(photoInfo.original.fileName, path.extname(photoInfo.original.fileName));
1120
1140
  const uuidFragment = fileNameAppendUuidFragment ? `-${photoInfo.uuid.slice(-8)}` : "";
1121
- const processedName = sluggify ? slug(`${nameWithoutExtension}${uuidFragment}`) : `${nameWithoutExtension}${uuidFragment}`;
1122
- return path.join(basePath, `${processedName}${extension}`);
1141
+ return sluggify ? slug(`${nameWithoutExtension}${uuidFragment}`) : `${nameWithoutExtension}${uuidFragment}`;
1123
1142
  }
1124
1143
  case "title":
1125
1144
  if (isNonEmptyStringAndNotWhitespace(photoInfo.title)) {
1126
1145
  const title = sluggify ? slug(photoInfo.title) : photoInfo.title;
1127
1146
  const uuidFragment = fileNameAppendUuidFragment ? `-${photoInfo.uuid.slice(-8)}` : "";
1128
- const processedTitle = sluggify ? slug(`${title}${uuidFragment}`) : `${title}${uuidFragment}`;
1129
- return path.join(basePath, `${processedTitle}${extension}`);
1147
+ return sluggify ? slug(`${title}${uuidFragment}`) : `${title}${uuidFragment}`;
1130
1148
  }
1131
- break;
1132
- case "uuid": {
1133
- const processedUuid = sluggify ? slug(photoInfo.uuid) : photoInfo.uuid;
1134
- return path.join(basePath, `${processedUuid}${extension}`);
1135
- }
1149
+ return;
1150
+ case "uuid": return sluggify ? slug(photoInfo.uuid) : photoInfo.uuid;
1136
1151
  }
1137
- throw new Error("No valid filename option found, can't name image");
1138
1152
  }
1139
1153
  async function getEngineForPhoto(photoInfo, options) {
1140
1154
  const { engineEdited, engineEditedAlpha, engineOriginal, engineOriginalAlpha } = mergeDefaults(options, defaultExportApplePhotoOptions);
@@ -1178,7 +1192,11 @@ const defaultManageMetadataOptions = {
1178
1192
  async function manageMetadataBatch(identifiers, targetFilePaths, options, exportOptions) {
1179
1193
  const photoInfos = await resolveIdentifiers(identifiers);
1180
1194
  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)));
1195
+ return Promise.all(photoInfos.map(async (photoInfo, index) => {
1196
+ const targetFilePath = targetFilePaths[index];
1197
+ if (targetFilePath === void 0) throw new Error(`Missing target file path for photo "${photoInfo.uuid}"`);
1198
+ return manageMetadata(photoInfo, targetFilePath, options, exportOptions);
1199
+ }));
1182
1200
  }
1183
1201
  /**
1184
1202
  * Manage metadata...
@@ -1272,22 +1290,14 @@ async function convertToJpeg(sourceImagePath, destinationDirectory, quality, for
1272
1290
  case "mozjpeg": {
1273
1291
  const tgaTempDirectory = await fse.mkdtemp(path.join(os.tmpdir(), `com.kitschpatrol.aphex.${getSlugFilename(sourceImagePath)}.${engine}.`));
1274
1292
  const tgaImagePath = await convertToTga(sourceImagePath, tgaTempDirectory);
1275
- await (jpegQuality === "lossless" ? execa("cjpeg", [
1276
- "--lossless",
1293
+ await execa("cjpeg", [
1294
+ ...jpegQuality === "lossless" ? ["--lossless"] : ["-quality", jpegQuality.toString()],
1277
1295
  "-optimize",
1278
1296
  "-progressive",
1279
1297
  "-outfile",
1280
1298
  destinationImagePath,
1281
1299
  tgaImagePath
1282
- ]) : execa("cjpeg", [
1283
- "-quality",
1284
- jpegQuality.toString(),
1285
- "-optimize",
1286
- "-progressive",
1287
- "-outfile",
1288
- destinationImagePath,
1289
- tgaImagePath
1290
- ]));
1300
+ ]);
1291
1301
  await fse.rm(tgaTempDirectory, { recursive: true });
1292
1302
  break;
1293
1303
  }
@@ -1304,7 +1314,6 @@ async function convertToJpeg(sourceImagePath, destinationDirectory, quality, for
1304
1314
  "--out",
1305
1315
  destinationImagePath
1306
1316
  ]);
1307
- break;
1308
1317
  }
1309
1318
  }
1310
1319
  return destinationImagePath;
@@ -1351,7 +1360,7 @@ async function convertToPng(sourceImagePath, destinationDirectory, force = false
1351
1360
  "--setProperty",
1352
1361
  "format",
1353
1362
  "png",
1354
- ...restoreColorProfile && restoreColorProfile !== "None" ? [
1363
+ ...restoreColorProfile !== void 0 && restoreColorProfile !== "None" ? [
1355
1364
  "--setProperty",
1356
1365
  "profile",
1357
1366
  getPathToColorProfile(restoreColorProfile)
@@ -1407,7 +1416,7 @@ async function convertToAvif(sourceImagePath, destinationDirectory, quality, for
1407
1416
  await execa("avifenc", [
1408
1417
  ...qualityArgs,
1409
1418
  "--icc",
1410
- profile === "None" ? getPathToColorProfile("sRGB IEC61966-2.1") : getPathToColorProfile(profile),
1419
+ getPathToColorProfile(profile === "None" ? "sRGB IEC61966-2.1" : profile),
1411
1420
  "--speed",
1412
1421
  "default",
1413
1422
  sourceImagePath,
@@ -1444,9 +1453,7 @@ async function compressImage(sourceImagePath, destinationDirectory, options) {
1444
1453
  workingImagePath = lookupImageMimeType(sourceImagePath) === "png" ? sourceImagePath : await convertToPng(sourceImagePath, tempDirectory);
1445
1454
  workingImagePath = await optimizePng(workingImagePath, tempDirectory);
1446
1455
  break;
1447
- case "webp":
1448
- workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, "lossless");
1449
- break;
1456
+ case "webp": workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, "lossless");
1450
1457
  }
1451
1458
  if (await getSizeBytes(workingImagePath) <= maxFileSizeBytes) {
1452
1459
  const destinationImagePath = path.join(destinationDirectory, path.basename(workingImagePath));
@@ -1465,9 +1472,7 @@ async function compressImage(sourceImagePath, destinationDirectory, options) {
1465
1472
  case "none":
1466
1473
  workingImagePath = sourceImagePath;
1467
1474
  break;
1468
- case "webp":
1469
- workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, "near-lossless");
1470
- break;
1475
+ case "webp": workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, "near-lossless");
1471
1476
  }
1472
1477
  if (await getSizeBytes(workingImagePath) <= maxFileSizeBytes) {
1473
1478
  const destinationImagePath = path.join(destinationDirectory, path.basename(workingImagePath));
@@ -1492,9 +1497,7 @@ async function compressImage(sourceImagePath, destinationDirectory, options) {
1492
1497
  case "none":
1493
1498
  workingImagePath = sourceImagePath;
1494
1499
  break;
1495
- case "webp":
1496
- workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, lossyQuality, true);
1497
- break;
1500
+ case "webp": workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, lossyQuality, true);
1498
1501
  }
1499
1502
  const destinationImagePath = path.join(destinationDirectory, path.basename(workingImagePath));
1500
1503
  await fse.copyFile(workingImagePath, destinationImagePath);
@@ -1509,8 +1512,8 @@ async function compressImage(sourceImagePath, destinationDirectory, options) {
1509
1512
  }
1510
1513
  //#endregion
1511
1514
  //#region src/utilities/image/compare.ts
1512
- const SSIM_REGEX = /All:\s*([\d.]+)/;
1513
- const PSNR_REGEX = /average:\s*([\d.]+)/;
1515
+ const SSIM_REGEX = /All:\s*([\d.]+)/v;
1516
+ const PSNR_REGEX = /average:\s*([\d.]+)/v;
1514
1517
  /**
1515
1518
  * Get two identically sized PNGs from two images. Uses the smaller image size
1516
1519
  * as the target size. Remember to clean up temp
@@ -1572,9 +1575,9 @@ async function calculateDSSIMInternal(image1, image2) {
1572
1575
  return -1;
1573
1576
  }
1574
1577
  const { stdout } = await execa("dssim", [image1, image2]);
1575
- const [dssimIndex] = stdout.split(" ");
1578
+ const [dssimIndex] = stdout.split(" ", 1);
1576
1579
  if (dssimIndex === void 0) throw new Error("DSSIM index not found in dssim output.");
1577
- return Number.parseFloat(dssimIndex);
1580
+ return Number(dssimIndex);
1578
1581
  }
1579
1582
  async function calculateSSIMInternal(image1, image2) {
1580
1583
  lookupImageMimeType(image1, ["png"]);
@@ -1594,8 +1597,8 @@ async function calculateSSIMInternal(image1, image2) {
1594
1597
  "null",
1595
1598
  "-"
1596
1599
  ]);
1597
- const match = SSIM_REGEX.exec(result.stderr);
1598
- return match?.[1] ? Number.parseFloat(match[1]) : 0;
1600
+ const ssimValue = SSIM_REGEX.exec(result.stderr)?.[1];
1601
+ return ssimValue === void 0 ? 0 : Number(ssimValue);
1599
1602
  }
1600
1603
  /**
1601
1604
  * Calculates the PSNR between two images using ffmpeg.
@@ -1618,8 +1621,8 @@ async function calculatePSNRInternal(image1, image2) {
1618
1621
  "null",
1619
1622
  "-"
1620
1623
  ]);
1621
- const match = PSNR_REGEX.exec(result.stderr);
1622
- return match?.[1] ? Number.parseFloat(match[1]) : 0;
1624
+ const psnrValue = PSNR_REGEX.exec(result.stderr)?.[1];
1625
+ return psnrValue === void 0 ? 0 : Number(psnrValue);
1623
1626
  }
1624
1627
  //#endregion
1625
1628
  //#region src/pipeline/image-process.ts
@@ -1649,8 +1652,9 @@ async function processPhotos(imagePaths, destinationDirectory, options) {
1649
1652
  const resolvedOptions = mergeDefaults(options, defaultProcessImageOptions);
1650
1653
  const tempProcessOutputDirectory = await getTempDirectory("process", "images");
1651
1654
  let processImageResults;
1655
+ const [singleImagePath] = imagePaths;
1652
1656
  try {
1653
- if (imagePaths.length === 1) processImageResults = [await processImage(imagePaths[0], tempProcessOutputDirectory, resolvedOptions)];
1657
+ if (singleImagePath !== void 0 && imagePaths.length === 1) processImageResults = [await processImage(singleImagePath, tempProcessOutputDirectory, resolvedOptions)];
1654
1658
  else {
1655
1659
  const threads = Math.floor(os.availableParallelism() * .5);
1656
1660
  log.debug(`Using ${threads} threads for processing`);
@@ -1664,10 +1668,10 @@ async function processPhotos(imagePaths, destinationDirectory, options) {
1664
1668
  minThreads: threads
1665
1669
  });
1666
1670
  try {
1667
- processImageResults = await Promise.all(imagePaths.map(async (path) => piscina.run({
1671
+ processImageResults = await Promise.all(imagePaths.map(async (imagePath) => piscina.run({
1668
1672
  destinationDirectory: tempProcessOutputDirectory,
1669
1673
  options: resolvedOptions,
1670
- sourceImagePath: path,
1674
+ sourceImagePath: imagePath,
1671
1675
  verbose: log.isLevelEnabled("debug")
1672
1676
  })));
1673
1677
  } finally {
@@ -1809,7 +1813,7 @@ async function getSyncPlanForImages(identifiers, destinationDirectory, options,
1809
1813
  }
1810
1814
  const sourcePhotoFileBaseName = stripExtension(getImagePathWithFileName(sourcePhotoInfo, "", resolvedExportOptions.exportOptions.fileNameSluggify, resolvedExportOptions.exportOptions.fileNameAppendUuidFragment, resolvedExportOptions.exportOptions.fileNameNormalizeExtensions, resolvedExportOptions.exportOptions.fileNamePrecedence));
1811
1815
  const differenceFound = await isDifferent(resolvedOptions.diffStrategies, sourcePhotoInfo, sourcePhotoFileBaseName, matchingDestinationFile.filePath, matchingDestinationFile.tags, resolvedExportOptions, resolvedOptions.forceUpdate);
1812
- if (differenceFound) {
1816
+ if (differenceFound !== false) {
1813
1817
  if (resolvedOptions.deleteTarget) syncResult.toDelete.push(matchingDestinationFile.filePath);
1814
1818
  syncResult.plan.push({
1815
1819
  diffedVia: differenceFound,
@@ -1826,7 +1830,7 @@ async function getSyncPlanForImages(identifiers, destinationDirectory, options,
1826
1830
  status: "unchanged"
1827
1831
  });
1828
1832
  }
1829
- if (resolvedOptions.deleteOthers) syncResult.toDelete = [...syncResult.toDelete, ...destinationFiles.filter((file) => !syncResult.plan.some((plan) => plan.matchFilePath === file.filePath)).map((file) => file.filePath)];
1833
+ if (resolvedOptions.deleteOthers) syncResult.toDelete = [...syncResult.toDelete, ...destinationFiles.filter((file) => syncResult.plan.every((plan) => plan.matchFilePath !== file.filePath)).map((file) => file.filePath)];
1830
1834
  syncResult.toDelete = [...new Set(syncResult.toDelete)];
1831
1835
  return syncResult;
1832
1836
  }
@@ -1836,31 +1840,24 @@ async function getSyncPlanForImages(identifiers, destinationDirectory, options,
1836
1840
  * @returns Undefined if the, or the reason for the difference if found
1837
1841
  */
1838
1842
  async function isDifferent(diffStrategies, sourcePhotoInfo, sourcePhotoFileBaseName, matchPhotoFilePath, matchPhotoTags, exportOptions, forceUpdate) {
1839
- let sourceTags;
1840
1843
  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;
1844
+ let sourceTags;
1845
+ async function strategyFindsDifference(diffStrategy) {
1846
+ switch (diffStrategy) {
1847
+ case "exif-tags":
1848
+ sourceTags ??= await getTags(sourcePhotoInfo.original.filePath);
1849
+ return matchPhotoTags === void 0 || sourceTags.credit !== matchPhotoTags.credit || sourceTags.creator !== matchPhotoTags.creator || sourceTags.description !== matchPhotoTags.description || sourceTags.preservedFileName !== matchPhotoTags.preservedFileName || sourceTags.label !== matchPhotoTags.label;
1850
+ case "export-options": return matchPhotoTags === void 0 || !deepEqual(exportOptions.exportOptions, matchPhotoTags.aphexMetadata?.exportOptions.exportOptions);
1851
+ case "file-name": return stripExtension(path.basename(matchPhotoFilePath)) !== sourcePhotoFileBaseName;
1852
+ case "force-update": return true;
1853
+ case "metadata-options":
1854
+ sourceTags ??= await getTags(sourcePhotoInfo.original.filePath);
1855
+ return matchPhotoTags === void 0 || !deepEqual(exportOptions.metadataOptions, matchPhotoTags.aphexMetadata?.exportOptions.metadataOptions);
1856
+ case "photo-info": return matchPhotoTags === void 0 || !deepEqual(sourcePhotoInfo, matchPhotoTags.aphexMetadata?.photoInfo);
1857
+ case "process-options": return matchPhotoTags === void 0 || !deepEqual(exportOptions.processOptions, matchPhotoTags.aphexMetadata?.exportOptions.processOptions);
1858
+ }
1863
1859
  }
1860
+ for (const diffStrategy of diffStrategies) if (await strategyFindsDifference(diffStrategy)) return diffStrategy;
1864
1861
  return false;
1865
1862
  }
1866
1863
  //#endregion
@@ -1953,9 +1950,8 @@ async function exportPhotos(identifiers, destinationDirectory, options) {
1953
1950
  exportResult.results.exportResult = cleanExportResults(matchingExportResult);
1954
1951
  } else log.warn(`No export result found for photo "${exportResult.photoInfo.title ?? exportResult.photoInfo.uuid}"`);
1955
1952
  }
1956
- let processResults;
1957
1953
  if (processOptions !== "disabled") {
1958
- processResults = await processPhotos(exportResults.filter((exportResult) => exportResult.results.syncResult?.status !== "unchanged").map((exportResult) => exportResult.path), destinationDirectory, processOptions);
1954
+ const processResults = await processPhotos(exportResults.filter((exportResult) => exportResult.results.syncResult?.status !== "unchanged").map((exportResult) => exportResult.path), destinationDirectory, processOptions);
1959
1955
  await fse.rm(exportDirectory, {
1960
1956
  force: true,
1961
1957
  recursive: true
@@ -1969,10 +1965,9 @@ async function exportPhotos(identifiers, destinationDirectory, options) {
1969
1965
  } else log.warn(`No process result found for photo "${exportResult.photoInfo.title ?? exportResult.photoInfo.uuid}"`);
1970
1966
  }
1971
1967
  }
1972
- let metadataResults;
1973
1968
  if (metadataOptions !== "disabled") {
1974
1969
  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);
1970
+ const metadataResults = await manageMetadataBatch(targetPhotos.map((exportResult) => exportResult.photoInfo), targetPhotos.map((exportResult) => exportResult.path), metadataOptions, resolvedOptions);
1976
1971
  for (const exportResult of exportResults) {
1977
1972
  const matchingMetadataResult = metadataResults.find((photoExportResult) => photoExportResult.photoInfo.uuid === exportResult.photoInfo.uuid);
1978
1973
  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,99 +1,101 @@
1
1
  {
2
- "name": "@kitschpatrol/aphex",
3
- "version": "0.1.1",
4
- "description": "Apple Photos Export. TypeScript library and CLI tool to export and process images and albums from your macOS Photos.app library.",
5
- "keywords": [
6
- "apple",
7
- "apple-photos",
8
- "photokit",
9
- "photos",
10
- "npm-package"
11
- ],
12
- "homepage": "https://github.com/kitschpatrol/aphex",
13
- "bugs": "https://github.com/kitschpatrol/aphex/issues",
14
- "repository": {
15
- "type": "git",
16
- "url": "git+https://github.com/kitschpatrol/aphex.git"
17
- },
18
- "license": "MIT",
19
- "author": {
20
- "name": "Eric Mika",
21
- "email": "eric@ericmika.com",
22
- "url": "https://ericmika.com"
23
- },
24
- "sideEffects": false,
25
- "type": "module",
26
- "exports": {
27
- ".": {
28
- "types": "./dist/index.d.ts",
29
- "import": "./dist/index.js"
30
- }
31
- },
32
- "main": "./dist/index.js",
33
- "module": "./dist/index.js",
34
- "types": "./dist/index.d.ts",
35
- "bin": {
36
- "aphex": "./dist/aphex-swift"
37
- },
38
- "files": [
39
- "dist/*"
40
- ],
41
- "dependencies": {
42
- "@sindresorhus/is": "^8.0.0",
43
- "defu": "^6.1.7",
44
- "execa": "^9.6.1",
45
- "exiftool-vendored": "^35.19.0",
46
- "fast-equals": "^6.0.0",
47
- "fs-extra": "^11.3.4",
48
- "github-slugger": "^2.0.0",
49
- "image-size": "^2.0.2",
50
- "importx": "^0.5.2",
51
- "lognow": "^0.6.1",
52
- "mrmime": "^2.0.1",
53
- "piscina": "^5.1.4",
54
- "type-fest": "^5.6.0"
55
- },
56
- "devDependencies": {
57
- "@arethetypeswrong/core": "^0.18.2",
58
- "@clack/prompts": "^1.3.0",
59
- "@kitschpatrol/shared-config": "^7.5.2",
60
- "@types/fs-extra": "^11.0.4",
61
- "@types/node": "~22.18.13",
62
- "@vitest/coverage-v8": "^4.1.5",
63
- "bumpp": "^11.0.1",
64
- "globby": "^16.2.0",
65
- "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.18",
71
- "tsdown": "^0.21.10",
72
- "tsx": "^4.21.0",
73
- "vitest": "^4.1.5"
74
- },
75
- "engines": {
76
- "node": ">=22.18.0"
77
- },
78
- "os": [
79
- "darwin"
80
- ],
81
- "cpu": [
82
- "arm64"
83
- ],
84
- "publishConfig": {
85
- "access": "public"
86
- },
87
- "scripts": {
88
- "build": "pnpm --sequential /^build:/",
89
- "build:1-lib": "tsdown",
90
- "build:2-native": "cd native/aphex-swift && ./build.sh",
91
- "build:3-publint": "publint",
92
- "clean": "git rm -f pnpm-lock.yaml && git clean -fdX -e !.claude/",
93
- "fix": "ksc fix && mdat ./native/aphex-swift/readme.md",
94
- "lint": "ksc lint",
95
- "release": "bumpp --commit 'Release: %s' && pnpm build && NPM_AUTH_TOKEN=$(op read 'op://Personal/npm/token') && pnpm publish",
96
- "test": "vitest run",
97
- "test:coverage": "vitest run --coverage"
98
- }
99
- }
2
+ "name": "@kitschpatrol/aphex",
3
+ "version": "0.1.3",
4
+ "description": "Apple Photos Export. TypeScript library and CLI tool to export and process images and albums from your macOS Photos.app library.",
5
+ "keywords": [
6
+ "apple",
7
+ "apple-photos",
8
+ "photokit",
9
+ "photos",
10
+ "npm-package"
11
+ ],
12
+ "homepage": "https://github.com/kitschpatrol/aphex",
13
+ "bugs": "https://github.com/kitschpatrol/aphex/issues",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/kitschpatrol/aphex.git"
17
+ },
18
+ "license": "MIT",
19
+ "author": {
20
+ "name": "Eric Mika",
21
+ "email": "eric@ericmika.com",
22
+ "url": "https://ericmika.com"
23
+ },
24
+ "sideEffects": false,
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ }
31
+ },
32
+ "main": "./dist/index.js",
33
+ "module": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "bin": {
36
+ "aphex": "./dist/aphex-swift"
37
+ },
38
+ "files": [
39
+ "dist/*"
40
+ ],
41
+ "scripts": {
42
+ "build": "pnpm --sequential /^build:/",
43
+ "build:1-lib": "tsdown",
44
+ "build:2-native": "cd native/aphex-swift && ./build.sh",
45
+ "build:3-publint": "publint",
46
+ "clean": "git rm -f pnpm-lock.yaml && git clean -fdX -e !.claude/",
47
+ "fix": "ksc fix && mdat ./native/aphex-swift/readme.md",
48
+ "lint": "ksc lint",
49
+ "release": "bumpp --commit 'Release: %s' && pnpm build && pnpm publish --otp $(op item get npm --otp)",
50
+ "test": "vitest run",
51
+ "test:coverage": "vitest run --coverage"
52
+ },
53
+ "dependencies": {
54
+ "@sindresorhus/is": "^8.1.0",
55
+ "defu": "^6.1.7",
56
+ "execa": "^10.0.1",
57
+ "exiftool-vendored": "^37.2.0",
58
+ "fast-equals": "^6.0.2",
59
+ "fs-extra": "^11.4.0",
60
+ "github-slugger": "^2.0.0",
61
+ "image-size": "^2.0.2",
62
+ "importx": "^0.5.2",
63
+ "lognow": "^0.8.0",
64
+ "mrmime": "^2.0.1",
65
+ "piscina": "^5.3.1",
66
+ "type-fest": "^5.8.0"
67
+ },
68
+ "devDependencies": {
69
+ "@arethetypeswrong/core": "^0.18.5",
70
+ "@clack/prompts": "^1.7.0",
71
+ "@kitschpatrol/shared-config": "^8.6.0",
72
+ "@types/fs-extra": "^11.0.4",
73
+ "@types/node": "~24.13.3",
74
+ "@vitest/coverage-v8": "^4.1.11",
75
+ "bumpp": "^12.2.1",
76
+ "globby": "^16.2.4",
77
+ "markdown-table": "^3.0.4",
78
+ "mdat-plugin-cli-help": "^3.1.0",
79
+ "open": "^11.0.1",
80
+ "pretty-bytes": "^7.1.1",
81
+ "pretty-ms": "^9.3.0",
82
+ "publint": "^0.3.24",
83
+ "tsdown": "^0.22.14",
84
+ "tsx": "^4.23.12",
85
+ "typescript": "~6.0.3",
86
+ "vitest": "^4.1.11"
87
+ },
88
+ "packageManager": "pnpm@11.22.0",
89
+ "engines": {
90
+ "node": ">=24.16.0"
91
+ },
92
+ "os": [
93
+ "darwin"
94
+ ],
95
+ "cpu": [
96
+ "arm64"
97
+ ],
98
+ "publishConfig": {
99
+ "access": "public"
100
+ }
101
+ }
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,7 +35,7 @@ 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
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.
41
41
 
@@ -77,7 +77,7 @@ npm install @kitschpatrol/aphex
77
77
 
78
78
  In most cases, the application invoking aphex should request permission to access your Photos.app library on first use.
79
79
 
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):
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://web.archive.org/web/https://www.rainforestqa.com/blog/macos-tcc-db-deep-dive):
81
81
 
82
82
  For example, to grant photo library permission to VS Code:
83
83
 
@@ -192,7 +192,7 @@ setLogger(console)
192
192
 
193
193
  ##### Exporting a photo by filename
194
194
 
195
- Let's assume you have an album named "Trip" in your Photos.app library containing a photo with the filename "IMG_1922.jpeg":
195
+ Let's assume you have an album named "Trip" in your Photos.app library containing a photo with the filename "IMG\_1922.jpeg":
196
196
 
197
197
  ```ts
198
198
  const result = await exportPhoto('Trip/IMG_1922.jpeg', '~/Desktop')