@kitschpatrol/aphex 0.1.0

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/index.js ADDED
@@ -0,0 +1,1989 @@
1
+ import fse from "fs-extra";
2
+ import path, { dirname } from "node:path";
3
+ import is, { assert, isNonEmptyStringAndNotWhitespace } from "@sindresorhus/is";
4
+ import { execa } from "execa";
5
+ import { spawn } from "node:child_process";
6
+ import { ExifTool } from "exiftool-vendored";
7
+ import { createLogger, injectionHelper } from "lognow";
8
+ import os from "node:os";
9
+ import { slug } from "github-slugger";
10
+ import { fileURLToPath } from "node:url";
11
+ import { createDefu } from "defu";
12
+ import { imageSizeFromFile } from "image-size/fromFile";
13
+ import { lookup, mimes } from "mrmime";
14
+ import { Piscina } from "piscina";
15
+ import { deepEqual } from "fast-equals";
16
+ //#endregion
17
+ //#region src/utilities/log.ts
18
+ /**
19
+ * The default logger instance for the library.
20
+ */
21
+ let log = createLogger("@kitschpatrol/aphex");
22
+ /**
23
+ * Set the logger instance for the module.
24
+ * Export this for library consumers to inject their own logger.
25
+ * @param logger - Accepts either a LogLayer instance or a Console- or Stream-like log target
26
+ */
27
+ function setLogger(logger) {
28
+ log = injectionHelper(logger);
29
+ }
30
+ //#endregion
31
+ //#region src/utilities/exiftool.ts
32
+ /**
33
+ * Centralized exiftool instance management
34
+ *
35
+ * This module provides a managed exiftool instance with proper lifecycle
36
+ * management to prevent hanging processes. The exiftool-vendored library
37
+ * spawns persistent Perl processes that must be explicitly ended.
38
+ */
39
+ const exiftoolInstance = new ExifTool({
40
+ maxProcs: 4,
41
+ onIdleIntervalMillis: 1e3
42
+ });
43
+ let cleanupScheduled = false;
44
+ let processExitHandlerInstalled = false;
45
+ /**
46
+ * Get the managed exiftool instance
47
+ */
48
+ function getExiftool() {
49
+ scheduleProcessCleanup();
50
+ return exiftoolInstance;
51
+ }
52
+ /**
53
+ * End the exiftool process gracefully
54
+ *
55
+ * This should be called when you're done using exiftool to prevent
56
+ * hanging processes. It's safe to call multiple times.
57
+ */
58
+ async function endExiftool() {
59
+ if (exiftoolInstance.pids.length > 0) {
60
+ log.debug(`Ending exiftool processes: ${exiftoolInstance.pids.join(", ")}`);
61
+ await exiftoolInstance.end();
62
+ log.debug("Exiftool processes ended");
63
+ }
64
+ }
65
+ /**
66
+ * Install process exit handlers for automatic cleanup
67
+ *
68
+ * This ensures exiftool processes are cleaned up even if the user
69
+ * forgets to call endExiftool(). Only installs handlers once.
70
+ */
71
+ function scheduleProcessCleanup() {
72
+ if (processExitHandlerInstalled) return;
73
+ processExitHandlerInstalled = true;
74
+ process.on("beforeExit", async () => {
75
+ if (!cleanupScheduled) {
76
+ cleanupScheduled = true;
77
+ await endExiftool();
78
+ }
79
+ });
80
+ process.on("exit", () => {
81
+ if (exiftoolInstance.pids.length > 0) log.debug(`Exiftool processes still running at exit: ${exiftoolInstance.pids.join(", ")}`);
82
+ });
83
+ process.on("SIGINT", async () => {
84
+ log.debug("Received SIGINT, cleaning up exiftool...");
85
+ await endExiftool();
86
+ process.exit(130);
87
+ });
88
+ process.on("SIGTERM", async () => {
89
+ log.debug("Received SIGTERM, cleaning up exiftool...");
90
+ await endExiftool();
91
+ process.exit(143);
92
+ });
93
+ }
94
+ //#endregion
95
+ //#region src/utilities/file.ts
96
+ /**
97
+ * Removes the file extension from a file path
98
+ */
99
+ function stripExtension(filePath) {
100
+ return path.join(path.dirname(filePath), path.basename(filePath, path.extname(filePath)));
101
+ }
102
+ /**
103
+ * Gets the size of a file in bytes
104
+ */
105
+ async function getSizeBytes(filePath) {
106
+ const { size } = await fse.stat(filePath);
107
+ return size;
108
+ }
109
+ /**
110
+ * Normalizes file extensions, particularly for image files
111
+ * Converts to lowercase.
112
+ * Converts .jpg to .jpeg and .tif to .tiff
113
+ */
114
+ function normalizeExtension(filePath) {
115
+ const directory = path.dirname(filePath);
116
+ const fileExtension = path.extname(filePath).toLowerCase();
117
+ const fileBase = path.basename(filePath, path.extname(filePath));
118
+ switch (fileExtension) {
119
+ case ".jpg": return path.join(directory, `${fileBase}.jpeg`);
120
+ case ".tif": return path.join(directory, `${fileBase}.tiff`);
121
+ default: return path.join(directory, `${fileBase}${fileExtension}`);
122
+ }
123
+ }
124
+ /**
125
+ * Converts a filename to a GitHub-style slug format
126
+ * @param filePath - The path of the file
127
+ * @returns The slug version of the filename without extension
128
+ */
129
+ function getSlugFilename(filePath) {
130
+ return slug(path.basename(filePath, path.extname(filePath)));
131
+ }
132
+ /**
133
+ * Ensures the directory for a given file path exists, creating it if necessary.
134
+ * Expands '~' to the user's home directory.
135
+ * @param filePath - The full file path for which to ensure the directory exists
136
+ * @returns The fully expanded directory path which definitely exists
137
+ */
138
+ async function ensureDirectoryExists(filePath) {
139
+ const expandedDirectory = filePath.startsWith("~") ? path.join(os.homedir(), filePath.slice(1)) : filePath;
140
+ await fse.ensureDir(expandedDirectory);
141
+ return expandedDirectory;
142
+ }
143
+ /**
144
+ * Creates a temporary directory with an optional custom prefix based on provided labels
145
+ */
146
+ async function getTempDirectory(...labels) {
147
+ const prefix = labels.length > 0 ? `com.kitschpatrol.aphex.${labels.map((l) => l.toLowerCase()).join(".")}.` : "com.kitschpatrol.aphex.";
148
+ return fse.mkdtemp(path.join(os.tmpdir(), prefix));
149
+ }
150
+ //#endregion
151
+ //#region src/utilities/general.ts
152
+ const UUID_REGEX = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
153
+ /**
154
+ * Sips makes a mess of the temp folder and does not respect TMPDIR, so we need
155
+ * to clean up after it ourselves
156
+ */
157
+ async function sipsTempCleanup() {
158
+ const uuidFiles = fse.readdirSync(os.tmpdir()).filter((file) => UUID_REGEX.test(file));
159
+ let cleanCount = 0;
160
+ for (const file of uuidFiles) {
161
+ const filePath = path.join(os.tmpdir(), file);
162
+ const { stdout } = await execa("file", [
163
+ "-b",
164
+ "--mime",
165
+ filePath
166
+ ]);
167
+ if (stdout.startsWith("image/")) {
168
+ await fse.rm(filePath, { force: true });
169
+ cleanCount += 1;
170
+ }
171
+ }
172
+ return cleanCount;
173
+ }
174
+ /**
175
+ * Ensure the value is an array. If it's undefined or null, return an empty
176
+ * array.
177
+ */
178
+ function ensureArray(value) {
179
+ if (value === void 0 || value === null) return [];
180
+ return Array.isArray(value) ? value : [value];
181
+ }
182
+ /**
183
+ * Ensure the length has a single item
184
+ */
185
+ function assertSingleElement(array) {
186
+ if (array.length !== 1) throw new Error(`Expected array of length 1, got ${array.length}`);
187
+ }
188
+ //#endregion
189
+ //#region src/utilities/paths.ts
190
+ /**
191
+ * import.meta.dirname for Node >= 20.19.0
192
+ * @example getDirname(import.meta)
193
+ */
194
+ function getDirname(meta) {
195
+ return dirname(fileURLToPath(process.env.PISCINA_WORKER_META_URL ?? meta.url));
196
+ }
197
+ /**
198
+ * Asset directory path of package or distribution folder (Copied by tsdown)
199
+ */
200
+ function getPackageAssetsPath(meta) {
201
+ const dirname = getDirname(meta);
202
+ return path.basename(dirname) === "dist" ? path.join(dirname, "assets") : path.join("src", "assets");
203
+ }
204
+ /**
205
+ * Asset directory path of package or distribution folder (Copied by tsdown)
206
+ */
207
+ function getPackageWorkersPath(meta) {
208
+ const dirname = getDirname(meta);
209
+ return path.basename(dirname) === "dist" ? path.join(dirname, "workers") : path.join(dirname, "..", "workers");
210
+ }
211
+ /**
212
+ * Binary directory path of package or distribution folder (Copied by native build script)
213
+ */
214
+ function getPackageBinPath(meta) {
215
+ const dirname = getDirname(meta);
216
+ return path.basename(dirname) === "dist" ? path.join(dirname) : path.join("dist");
217
+ }
218
+ //#endregion
219
+ //#region src/aphex-swift/cli-bridge.ts
220
+ const SHELL_SPECIAL_CHARS_REGEX = /[\s'"\\]/;
221
+ let interactiveProcess;
222
+ let currentRequest;
223
+ /**
224
+ * Start an interactive session with aphex-swift.
225
+ * Commands will be sent to this persistent process instead of spawning new ones.
226
+ * @throws {Error} If the session is already active or fails to start
227
+ */
228
+ async function interactiveSessionStart() {
229
+ if (interactiveProcess !== void 0) throw new Error("Interactive session is already active");
230
+ interactiveProcess = spawn("./aphex-swift", ["interactive"], {
231
+ cwd: getPackageBinPath(import.meta),
232
+ stdio: [
233
+ "pipe",
234
+ "pipe",
235
+ "pipe"
236
+ ]
237
+ });
238
+ interactiveProcess.stdout?.on("data", (data) => {
239
+ if (currentRequest === void 0) return;
240
+ currentRequest.buffer += data.toString();
241
+ try {
242
+ JSON.parse(currentRequest.buffer);
243
+ const response = currentRequest.buffer;
244
+ const request = currentRequest;
245
+ currentRequest = void 0;
246
+ request.resolve(response);
247
+ processNextCommand();
248
+ } catch {}
249
+ });
250
+ interactiveProcess.stderr?.on("data", (data) => {
251
+ if (currentRequest !== void 0) {
252
+ const error = new Error(data.toString().trim());
253
+ const request = currentRequest;
254
+ currentRequest = void 0;
255
+ request.reject(error);
256
+ processNextCommand();
257
+ }
258
+ });
259
+ interactiveProcess.on("close", (code) => {
260
+ const error = /* @__PURE__ */ new Error(`Interactive session exited with code ${code}`);
261
+ if (currentRequest !== void 0) {
262
+ currentRequest.reject(error);
263
+ currentRequest = void 0;
264
+ }
265
+ for (const queued of commandQueue) queued.reject(error);
266
+ commandQueue = [];
267
+ interactiveProcess = void 0;
268
+ });
269
+ await new Promise((resolve) => {
270
+ setTimeout(resolve, 100);
271
+ });
272
+ }
273
+ /**
274
+ * Stop the interactive session if one is active.
275
+ */
276
+ async function interactiveSessionStop() {
277
+ if (interactiveProcess === void 0) return;
278
+ const processToStop = interactiveProcess;
279
+ processToStop.stdin?.write("exit\n");
280
+ processToStop.stdin?.end();
281
+ await new Promise((resolve) => {
282
+ processToStop.on("close", () => {
283
+ resolve();
284
+ });
285
+ setTimeout(() => {
286
+ processToStop.kill("SIGKILL");
287
+ resolve();
288
+ }, 1e3);
289
+ });
290
+ interactiveProcess = void 0;
291
+ currentRequest = void 0;
292
+ commandQueue = [];
293
+ await endExiftool();
294
+ }
295
+ /**
296
+ * Check if an interactive session is currently active
297
+ */
298
+ function isInteractiveSessionActive() {
299
+ return interactiveProcess !== void 0;
300
+ }
301
+ let commandQueue = [];
302
+ function processNextCommand() {
303
+ if (currentRequest !== void 0 || commandQueue.length === 0 || interactiveProcess === void 0) return;
304
+ const next = commandQueue.shift();
305
+ currentRequest = {
306
+ buffer: "",
307
+ reject: next.reject,
308
+ resolve: next.resolve
309
+ };
310
+ interactiveProcess.stdin?.write(next.command + "\n");
311
+ }
312
+ /**
313
+ * Send a command to the interactive session and wait for the response
314
+ */
315
+ async function executeInteractiveCommand(command) {
316
+ if (interactiveProcess === void 0) throw new Error("No interactive session active");
317
+ return new Promise((resolve, reject) => {
318
+ commandQueue.push({
319
+ command,
320
+ reject,
321
+ resolve
322
+ });
323
+ processNextCommand();
324
+ });
325
+ }
326
+ /**
327
+ * Escape and join arguments into a single command string for interactive mode.
328
+ * Arguments with spaces or special characters are quoted.
329
+ */
330
+ function escapeCommand(args) {
331
+ return args.map((arg) => {
332
+ if (SHELL_SPECIAL_CHARS_REGEX.test(arg)) return `'${arg.replaceAll("'", String.raw`'\''`)}'`;
333
+ return arg;
334
+ }).join(" ");
335
+ }
336
+ /**
337
+ * Runtime type guard for ResourceInfo
338
+ */
339
+ function isResourceInfo(value) {
340
+ if (!is.plainObject(value)) return false;
341
+ const object = value;
342
+ return is.string(object.contentType) && is.string(object.fileName) && is.string(object.filePath) && is.number(object.fileSize) && is.number(object.height) && is.number(object.width);
343
+ }
344
+ /**
345
+ * Runtime type guard for PhotoInfo
346
+ */
347
+ function isPhotoInfo(value) {
348
+ if (!is.plainObject(value)) return false;
349
+ const object = value;
350
+ if (!is.string(object.uuid) || !is.boolean(object.favorite) || !is.boolean(object.hidden) || !isResourceInfo(object.original) || !is.date(object.dateCreated) || !is.date(object.dateModified)) return false;
351
+ if (object.title !== void 0 && !is.string(object.title) || object.edited !== void 0 && !isResourceInfo(object.edited)) return false;
352
+ return true;
353
+ }
354
+ /**
355
+ * Runtime type guard for PhotoInfo array
356
+ */
357
+ function isPhotoInfoArray(value) {
358
+ return is.array(value) && value.every((element) => isPhotoInfo(element));
359
+ }
360
+ /**
361
+ * Runtime type guard for AlbumInfo
362
+ */
363
+ function isAlbumInfo(value) {
364
+ if (!is.plainObject(value)) return false;
365
+ const object = value;
366
+ if (!is.string(object.uuid) || !is.number(object.type) || !is.number(object.subtype) || !is.number(object.estimatedAssetCount) || !is.string(object.title) || !is.string(object.path)) return false;
367
+ if (object.dateStart !== void 0 && !is.date(object.dateStart) || object.dateEnd !== void 0 && !is.date(object.dateEnd)) return false;
368
+ return true;
369
+ }
370
+ /**
371
+ * Assert that a value is an array of PhotoInfo
372
+ */
373
+ function assertPhotoInfoArray(value) {
374
+ if (!isPhotoInfoArray(value)) throw new Error("Invalid PhotoInfo array");
375
+ }
376
+ /**
377
+ * Runtime type guard for AlbumInfo array
378
+ */
379
+ function isAlbumInfoArray(value) {
380
+ return is.array(value) && value.every((element) => isAlbumInfo(element));
381
+ }
382
+ /**
383
+ * Assert that a value is an AlbumInfo array
384
+ */
385
+ function assertAlbumInfoArray(value) {
386
+ if (!isAlbumInfoArray(value)) throw new Error("Invalid AlbumInfo array");
387
+ }
388
+ /**
389
+ * Get photo asset information for given identifiers (ID, filename, album name, or photo path)
390
+ * @throws {Error} If the command fails
391
+ */
392
+ async function aphexPhotoInfo(identifiers, caseSensitive = false) {
393
+ const identifiersArray = ensureArray(identifiers);
394
+ if (identifiersArray.length === 0) return [];
395
+ const args = [
396
+ "photo-info",
397
+ ...identifiersArray,
398
+ ...caseSensitive ? ["--case-sensitive"] : []
399
+ ];
400
+ let stdout;
401
+ if (isInteractiveSessionActive()) stdout = await executeInteractiveCommand(escapeCommand(args));
402
+ else stdout = (await execa("./aphex-swift", args, { cwd: getPackageBinPath(import.meta) })).stdout;
403
+ try {
404
+ const output = JSON.parse(stdout, dateReviver$1);
405
+ assertPhotoInfoArray(output);
406
+ return output;
407
+ } catch {
408
+ throw new Error(`Error fetching photos: ${stdout}`);
409
+ }
410
+ }
411
+ /**
412
+ * Get album info
413
+ * @throws {Error} If the command fails
414
+ */
415
+ async function aphexAlbumInfo(identifiers, caseSensitive = false) {
416
+ const identifiersArray = ensureArray(identifiers);
417
+ if (identifiersArray.length === 0) return [];
418
+ const args = [
419
+ "album-info",
420
+ ...identifiersArray,
421
+ ...caseSensitive ? ["--case-sensitive"] : []
422
+ ];
423
+ let stdout;
424
+ if (isInteractiveSessionActive()) stdout = await executeInteractiveCommand(escapeCommand(args));
425
+ else stdout = (await execa("./aphex-swift", args, { cwd: getPackageBinPath(import.meta) })).stdout;
426
+ try {
427
+ const output = JSON.parse(stdout, dateReviver$1);
428
+ assertAlbumInfoArray(output);
429
+ return output;
430
+ } catch {
431
+ throw new Error(`Error fetching album info: ${stdout}`);
432
+ }
433
+ }
434
+ /**
435
+ * Export photos for given identifiers to a destination directory
436
+ * @throws {Error} If the command fails
437
+ */
438
+ async function aphexExport(identifiers, destination, caseSensitive = false, originals = false) {
439
+ const identifiersArray = ensureArray(identifiers);
440
+ if (identifiersArray.length === 0) return [];
441
+ const args = [
442
+ "export",
443
+ ...identifiersArray,
444
+ "--destination",
445
+ destination,
446
+ ...caseSensitive ? ["--case-sensitive"] : [],
447
+ ...originals ? ["--originals"] : []
448
+ ];
449
+ let stdout;
450
+ if (isInteractiveSessionActive()) stdout = await executeInteractiveCommand(escapeCommand(args));
451
+ else stdout = (await execa("./aphex-swift", args, { cwd: getPackageBinPath(import.meta) })).stdout;
452
+ try {
453
+ const output = JSON.parse(stdout, dateReviver$1);
454
+ assert.array(output);
455
+ return output;
456
+ } catch {
457
+ throw new Error(`Error exporting: ${stdout}`);
458
+ }
459
+ }
460
+ function dateReviver$1(key, value) {
461
+ if ((key === "dateCreated" || key === "dateModified" || key === "dateStart" || key === "dateEnd") && typeof value === "string") return value ? new Date(value) : void 0;
462
+ return value;
463
+ }
464
+ //#endregion
465
+ //#region src/aphex-swift/identifiers.ts
466
+ /**
467
+ * Takes a mix of photos, albums, strings, all resolved to a single array of PhotoInfo objects
468
+ */
469
+ async function resolveIdentifiers(identifiers) {
470
+ const photoInfos = identifiers.filter((identifier) => isPhotoInfo(identifier));
471
+ const identifierAlbumUuids = identifiers.filter((identifier) => isAlbumInfo(identifier)).map((identifier) => identifier.uuid);
472
+ const identifierStrings = identifiers.filter((identifier) => typeof identifier === "string");
473
+ photoInfos.push(...await aphexPhotoInfo([...identifierAlbumUuids, ...identifierStrings]));
474
+ if (photoInfos.length === 0) throw new Error(`No photos found for identifiers "${identifiers.map((identifier) => JSON.stringify(identifier)).join(", ")}"`);
475
+ const seen = /* @__PURE__ */ new Set();
476
+ return photoInfos.filter((photo) => {
477
+ if (seen.has(photo.uuid)) return false;
478
+ seen.add(photo.uuid);
479
+ return true;
480
+ });
481
+ }
482
+ /**
483
+ * Get photo info if needed, and throw errors if it's not a photo
484
+ */
485
+ async function resolvePhotoIdentifier(identifier) {
486
+ if (isPhotoInfo(identifier)) return identifier;
487
+ const aphexPhotoInfoResult = await aphexPhotoInfo(identifier);
488
+ if (aphexPhotoInfoResult.length === 0) throw new Error(`No photo asset found for identifier "${identifier}"`);
489
+ if (aphexPhotoInfoResult.length > 1) throw new Error(`Multiple photo assets found for identifier "${identifier} — is it an album?"`);
490
+ return aphexPhotoInfoResult[0];
491
+ }
492
+ //#endregion
493
+ //#region src/utilities/defaults.ts
494
+ const mergeDefaultsInternal = createDefu((object, key, value) => {
495
+ if (Array.isArray(object[key]) && Array.isArray(value)) {
496
+ const merged = [...new Set([...object[key], ...value])];
497
+ Object.assign(object, { [key]: merged });
498
+ return true;
499
+ }
500
+ return false;
501
+ });
502
+ /**
503
+ * Merge, treating arrays like sets
504
+ */
505
+ function mergeDefaults(options, defaults) {
506
+ if (options === void 0) return defaults;
507
+ return mergeDefaultsInternal(options, defaults);
508
+ }
509
+ //#endregion
510
+ //#region src/utilities/image/mime.ts
511
+ mimes.arw = "image/arw";
512
+ mimes.cr2 = "image/cr2";
513
+ mimes.cr3 = "image/cr3";
514
+ mimes.crw = "image/crw";
515
+ mimes.dng = "image/dng";
516
+ mimes.nef = "image/nef";
517
+ mimes.pef = "image/pef";
518
+ mimes.psb = "image/psb";
519
+ mimes.psd = "image/psd";
520
+ mimes.psd = "image/psd";
521
+ mimes.raw = "image/raw";
522
+ mimes.tga = "image/tga";
523
+ const IMAGE_MIME_TYPES = [
524
+ "arw",
525
+ "avif",
526
+ "bmp",
527
+ "cr2",
528
+ "cr3",
529
+ "crw",
530
+ "tga",
531
+ "dng",
532
+ "gif",
533
+ "heic",
534
+ "heif",
535
+ "jpeg",
536
+ "nef",
537
+ "pef",
538
+ "png",
539
+ "psd",
540
+ "svg+xml",
541
+ "tiff",
542
+ "webp"
543
+ ];
544
+ /**
545
+ * Look up the MIME type of an image
546
+ *
547
+ * Note overloads
548
+ */
549
+ function lookupImageMimeType(imagePath, validate = false) {
550
+ const mime = lookup(imagePath);
551
+ const shouldValidate = Array.isArray(validate) || validate;
552
+ if (mime === void 0) {
553
+ if (shouldValidate) throw new Error(`No known image MIME type for ${imagePath}`);
554
+ return;
555
+ }
556
+ const matchedMimeTypes = (Array.isArray(validate) ? validate : IMAGE_MIME_TYPES).find((type) => `image/${type}` === mime);
557
+ if (shouldValidate && matchedMimeTypes === void 0) throw new Error(`Invalid image MIME type: ${mime} for ${imagePath}`);
558
+ return matchedMimeTypes;
559
+ }
560
+ //#endregion
561
+ //#region src/utilities/image/color.ts
562
+ const SIPS_PROFILE_REGEX = /profile: (.+)/;
563
+ /**
564
+ * Get the color profile of an image
565
+ * https://github.com/saucecontrol/Compact-ICC-Profiles?tab=readme-ov-file
566
+ */
567
+ async function getColorProfile(imagePath) {
568
+ const { stdout } = await execa("sips", [
569
+ "-g",
570
+ "profile",
571
+ imagePath
572
+ ]);
573
+ const rawResult = SIPS_PROFILE_REGEX.exec(stdout)?.[1]?.trim();
574
+ const sipsProfile = rawResult === "<nil>" ? void 0 : rawResult;
575
+ const { ProfileDescription: rawExiftoolProfile } = await getExiftool().read(imagePath);
576
+ const exiftoolProfile = typeof rawExiftoolProfile === "string" ? rawExiftoolProfile.trim() : rawExiftoolProfile;
577
+ if (sipsProfile !== void 0 && exiftoolProfile !== void 0 && sipsProfile !== exiftoolProfile) log.error(`Conflicting color profiles: sips: "${sipsProfile}", exiftool: "${exiftoolProfile}" in image "${imagePath}"`);
578
+ const profile = sipsProfile ?? exiftoolProfile;
579
+ switch (profile) {
580
+ case "Adobe RGB (1998)": return "Adobe RGB (1998)";
581
+ case "Apple Wide Color Sharing Profile": return "Apple Wide Color Sharing Profile";
582
+ case "Display P3": return "Display P3";
583
+ case "ProPhoto RGB": return "ProPhoto RGB";
584
+ case "sRGB IEC61966-2.1": return "sRGB IEC61966-2.1";
585
+ case void 0: return "None";
586
+ default:
587
+ log.warn(`Unsupported color profile "${profile}" in image "${imagePath}"`);
588
+ return "Unsupported";
589
+ }
590
+ }
591
+ /**
592
+ * Get the path to a color profile
593
+ */
594
+ function getPathToColorProfile(profile) {
595
+ const basePath = path.join(getPackageAssetsPath(import.meta), "profiles");
596
+ switch (profile) {
597
+ case "Adobe RGB (1998)": return path.join(basePath, "AdobeRGB1998.icc");
598
+ case "Display P3": return path.join(basePath, "Display P3.icc");
599
+ case "ProPhoto RGB": return path.join(basePath, "ProPhoto.icm");
600
+ case "sRGB IEC61966-2.1": return path.join(basePath, "sRGB Profile.icc");
601
+ default: throw new Error(`Unsupported profile: ${profile}`);
602
+ }
603
+ }
604
+ /**
605
+ * Convert the color profile of an image
606
+ */
607
+ async function convertColorProfile(imagePath, profile) {
608
+ const currentProfile = await getColorProfile(imagePath);
609
+ if (profile === "Unsupported") throw new Error(`Can't assign unsupported profile`);
610
+ if (currentProfile === profile) return;
611
+ if (currentProfile === "None") {
612
+ await getExiftool().write(imagePath, {}, { writeArgs: ["-ICC_Profile=", "-overwrite_original_in_place"] });
613
+ return;
614
+ }
615
+ const mime = lookupImageMimeType(imagePath, true);
616
+ if ([
617
+ "png",
618
+ "psd",
619
+ "tif"
620
+ ].includes(mime)) {
621
+ log.debug(`Converting ${path.basename(imagePath)} from ${currentProfile} to ${profile}`);
622
+ await execa("sips", [
623
+ "--matchTo",
624
+ getPathToColorProfile(profile),
625
+ imagePath
626
+ ]);
627
+ } else if ([
628
+ "avif",
629
+ "gif",
630
+ "heic",
631
+ "jpeg",
632
+ "webp"
633
+ ].includes(mime)) throw new Error(`Color profile conversion would be destructive for image ${path.basename(imagePath)}, aborting`);
634
+ else throw new Error(`Unsupported image format: ${mime}`);
635
+ }
636
+ /**
637
+ * Assign a color profile to an image
638
+ */
639
+ async function assignColorProfile(imagePath, profile) {
640
+ if (profile === "Unsupported") throw new Error(`Can't assign unsupported profile`);
641
+ if (profile === await getColorProfile(imagePath)) return;
642
+ if (profile === "None") {
643
+ await getExiftool().write(imagePath, {}, { writeArgs: ["-ICC_Profile=", "-overwrite_original_in_place"] });
644
+ return;
645
+ }
646
+ const mime = lookupImageMimeType(imagePath, true);
647
+ if (mime === "avif") log.warn(`Unsupported mime type for color profile assignment: ${mime}, skipping`);
648
+ else await getExiftool().write(imagePath, {}, { writeArgs: [`-ICC_Profile<=${getPathToColorProfile(profile)}`, "-overwrite_original_in_place"] });
649
+ }
650
+ /**
651
+ * Check if an image needs color conversion
652
+ */
653
+ async function needsColorConversion(imagePath, preserveColorProfiles = [
654
+ "ProPhoto RGB",
655
+ "Display P3",
656
+ "sRGB IEC61966-2.1",
657
+ "Adobe RGB (1998)"
658
+ ]) {
659
+ const profile = await getColorProfile(imagePath);
660
+ return !["None", ...preserveColorProfiles].includes(profile);
661
+ }
662
+ /**
663
+ * Normalize the color profile of an image
664
+ */
665
+ async function normalizeColorProfile(imagePath, preserveColorProfiles = [
666
+ "ProPhoto RGB",
667
+ "Display P3",
668
+ "sRGB IEC61966-2.1",
669
+ "Adobe RGB (1998)"
670
+ ], defaultColorProfile = "sRGB IEC61966-2.1") {
671
+ const profile = await getColorProfile(imagePath);
672
+ if (preserveColorProfiles.includes(profile)) return "no-action";
673
+ if (profile === "None") {
674
+ await assignColorProfile(imagePath, defaultColorProfile);
675
+ return "assigned";
676
+ }
677
+ await convertColorProfile(imagePath, defaultColorProfile);
678
+ return "converted";
679
+ }
680
+ //#endregion
681
+ //#region src/utilities/image/tags.ts
682
+ const VALID_LABELS = [
683
+ "animation",
684
+ "diagram",
685
+ "illustration",
686
+ "image",
687
+ "photo",
688
+ "render",
689
+ "screenshot",
690
+ "video"
691
+ ];
692
+ function isValidLabel(value) {
693
+ return VALID_LABELS.includes(value);
694
+ }
695
+ /**
696
+ * Strip all metadata from an image
697
+ */
698
+ async function stripTags(imagePath) {
699
+ if (lookupImageMimeType(imagePath, true) === "avif") await getExiftool().write(imagePath, {}, { writeArgs: [
700
+ "-all=",
701
+ "--icc_profile:all",
702
+ "-overwrite_original_in_place"
703
+ ] });
704
+ else await getExiftool().write(imagePath, {}, { writeArgs: ["-all=", "-overwrite_original_in_place"] });
705
+ }
706
+ /**
707
+ * Validate that an image has the required tags
708
+ */
709
+ async function validateTags(imagePathOrTags, andKeys, orKeys, logWarnings = true) {
710
+ const result = {
711
+ issues: [],
712
+ valid: true
713
+ };
714
+ const tags = typeof imagePathOrTags === "string" ? await getTags(imagePathOrTags) : imagePathOrTags;
715
+ if (orKeys === void 0 && andKeys === void 0) {
716
+ const keysUnseen = [
717
+ "creator",
718
+ "credit",
719
+ "label",
720
+ "preservedFileName"
721
+ ].filter((key) => tags[key] === void 0);
722
+ if (keysUnseen.length > 0) {
723
+ result.issues.push(`Tags are missing keys: ${keysUnseen.join(", ")}`);
724
+ if (logWarnings) log.warn(result.issues.at(-1));
725
+ result.valid = false;
726
+ }
727
+ }
728
+ if (orKeys !== void 0 && orKeys.length > 0) {
729
+ if (orKeys.filter((key) => tags[key] !== void 0).length === 0) {
730
+ result.issues.push(`Tags should have at least one of the keys: ${orKeys.join(", ")}`);
731
+ if (logWarnings) log.warn(result.issues.at(-1));
732
+ result.valid = false;
733
+ }
734
+ }
735
+ if (andKeys !== void 0 && andKeys.length > 0) {
736
+ const andKeysUnseen = andKeys.filter((key) => tags[key] === void 0);
737
+ if (andKeysUnseen.length > 0) {
738
+ result.issues.push(`Tags are missing the keys: ${andKeysUnseen.join(", ")}`);
739
+ if (logWarnings) log.warn(result.issues.at(-1));
740
+ result.valid = false;
741
+ }
742
+ }
743
+ if (tags.label !== void 0 && !VALID_LABELS.includes(tags.label)) {
744
+ result.issues.push(`Tag value for 'label' is invalid: ${tags.label}`);
745
+ if (logWarnings) log.warn(result.issues.at(-1));
746
+ result.valid = false;
747
+ }
748
+ return result;
749
+ }
750
+ /**
751
+ * Get the tags from an image
752
+ */
753
+ async function getTags(imagePath) {
754
+ const { Creator: creator, Credit: credit, Description: description, Label: label, PreservedFileName: preservedFileName, UserComment: userComment } = (await getExiftool().readRaw(imagePath, { readArgs: ["-g", "-xmp:all"] })).XMP ?? {};
755
+ return {
756
+ aphexMetadata: parseUserComment(userComment),
757
+ creator: creator === void 0 ? void 0 : typeof creator === "string" ? creator : creator[0],
758
+ credit,
759
+ description,
760
+ label: label !== void 0 && isValidLabel(label) ? label : void 0,
761
+ preservedFileName
762
+ };
763
+ }
764
+ function dateReviver(key, value) {
765
+ if ((key === "dateCreated" || key === "dateModified" || key === "dateStart" || key === "dateEnd") && typeof value === "string") return value ? new Date(value) : void 0;
766
+ return value;
767
+ }
768
+ function isAphexMetadata(value) {
769
+ if (typeof value !== "object" || value === null) return false;
770
+ const maybeObject = value;
771
+ return typeof maybeObject.exportOptions === "object" && maybeObject.exportOptions !== null && typeof maybeObject.photoInfo === "object" && maybeObject.photoInfo !== null;
772
+ }
773
+ function parseUserComment(userComment) {
774
+ if (userComment === void 0) return;
775
+ try {
776
+ const parsed = JSON.parse(userComment, dateReviver);
777
+ if (!isAphexMetadata(parsed)) {
778
+ log.warn(`UserComment JSON does not match AphexMetadata shape: ${userComment}`);
779
+ return;
780
+ }
781
+ return parsed;
782
+ } catch (error) {
783
+ log.withError(error).error(`Error parsing UserComment JSON: ${userComment}`);
784
+ return;
785
+ }
786
+ }
787
+ /**
788
+ * Set the tags on an image
789
+ */
790
+ async function setTags(imagePath, imageTags) {
791
+ const { aphexMetadata, creator, credit, description, label, preservedFileName } = imageTags;
792
+ const userComment = aphexMetadata ? JSON.stringify(aphexMetadata) : void 0;
793
+ const tags = {
794
+ ..."creator" in imageTags ? { "XMP:Creator": creator ?? "" } : {},
795
+ ..."description" in imageTags ? { "XMP:Description": description ?? "" } : {},
796
+ ..."credit" in imageTags ? { "XMP:Credit": credit ?? "" } : {},
797
+ ..."label" in imageTags ? { "XMP:Label": label ?? "" } : {},
798
+ ..."preservedFileName" in imageTags ? { "XMP:PreservedFileName": preservedFileName ?? "" } : {},
799
+ ..."aphexMetadata" in imageTags ? { "XMP:UserComment": userComment ?? "" } : {}
800
+ };
801
+ await getExiftool().write(imagePath, tags, { writeArgs: ["-overwrite_original_in_place"] });
802
+ }
803
+ /**
804
+ * Clone tags from one image to another
805
+ */
806
+ async function cloneTags(sourceImagePath, destinationImagePath, includeKeys, force = false) {
807
+ const sourceTags = await getTags(sourceImagePath);
808
+ const destinationTags = await getTags(destinationImagePath);
809
+ const keys = includeKeys ?? [
810
+ "creator",
811
+ "credit",
812
+ "description",
813
+ "label",
814
+ "preservedFileName",
815
+ "aphexMetadata"
816
+ ];
817
+ let tagsToAssign = {};
818
+ for (const key of keys) {
819
+ const sourceValue = key in sourceTags ? sourceTags[key] : void 0;
820
+ if (sourceValue !== (key in destinationTags ? destinationTags[key] : void 0) || force) tagsToAssign = {
821
+ ...tagsToAssign,
822
+ [key]: sourceValue
823
+ };
824
+ }
825
+ if (Object.keys(tagsToAssign).length > 0) await setTags(destinationImagePath, tagsToAssign);
826
+ return Object.keys(tagsToAssign);
827
+ }
828
+ //#endregion
829
+ //#region src/utilities/image/image.ts
830
+ /**
831
+ * Get all available information about an image
832
+ */
833
+ async function getImageInfo(imagePath) {
834
+ return {
835
+ alpha: await hasAlpha(imagePath),
836
+ colorProfile: await getColorProfile(imagePath),
837
+ dimensionsPixels: await getImageDimensions(imagePath),
838
+ mime: lookupImageMimeType(imagePath, true),
839
+ path: imagePath,
840
+ sizeBytes: await getSizeBytes(imagePath),
841
+ tags: await getTags(imagePath)
842
+ };
843
+ }
844
+ /**
845
+ * Get the dimensions of an image
846
+ */
847
+ async function getImageDimensions(imagePath) {
848
+ const { width, height } = await imageSizeFromFile(imagePath);
849
+ return {
850
+ width,
851
+ height
852
+ };
853
+ }
854
+ /**
855
+ * Check if an image has an alpha channel
856
+ */
857
+ async function hasAlpha(imagePath) {
858
+ const mime = lookupImageMimeType(imagePath, true);
859
+ if (mime === "jpeg") return false;
860
+ if (mime !== "psd" && mime !== "tiff") {
861
+ const { stdout: channelOutput } = await execa("identify", [
862
+ "-format",
863
+ "%[channels]",
864
+ imagePath
865
+ ]);
866
+ if (!channelOutput.includes("rgba")) return false;
867
+ }
868
+ try {
869
+ const { stdout } = await execa("magick", [
870
+ imagePath,
871
+ "-background",
872
+ "transparent",
873
+ "-flatten",
874
+ "-channel",
875
+ "A",
876
+ "-separate",
877
+ "+channel",
878
+ "-format",
879
+ "%[fx:minima]",
880
+ "info:"
881
+ ]);
882
+ return Number(stdout) < 1;
883
+ } catch (error) {
884
+ throw new Error(`Error checking file: ${String(error)}`);
885
+ }
886
+ }
887
+ //#endregion
888
+ //#region src/pipeline/engines/applescript.ts
889
+ const UUID_SUFFIX_PHOTO = "/L0/001";
890
+ function escapeForAppleScript(text) {
891
+ return text.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
892
+ }
893
+ /**
894
+ * Export a photo via AppleScript using the Photos app's scripting dictionary
895
+ * @param photoUuid - UUID of the photo to export
896
+ * @param forceOriginal - Whether to force export of original version
897
+ * @returns Promise<string> - Path to the exported file
898
+ */
899
+ async function exportViaAppleScript(photoUuid, forceOriginal = false) {
900
+ const tempDirectory = await getTempDirectory("engine", "applescript");
901
+ const exportFlag = forceOriginal ? "with originals" : "without originals";
902
+ const appleScript = `
903
+ tell application "Photos"
904
+ -- Find the media item by UUID
905
+ set targetPhoto to (first media item whose id is "${escapeForAppleScript(`${photoUuid}${UUID_SUFFIX_PHOTO}`)}")
906
+
907
+ -- Export the photo to temp directory
908
+ export {targetPhoto} to POSIX file "${escapeForAppleScript(tempDirectory)}" ${exportFlag}
909
+
910
+ -- Get the exported file name
911
+ set photoFilename to filename of targetPhoto
912
+
913
+ -- Return the filename for path construction
914
+ return photoFilename
915
+ end tell
916
+ `;
917
+ try {
918
+ const { stdout } = await execa("osascript", ["-e", appleScript]);
919
+ const exportedFileName = stdout.trim();
920
+ return path.join(tempDirectory, exportedFileName);
921
+ } catch (error) {
922
+ const errorMessage = error instanceof Error ? error.message : String(error);
923
+ throw new Error(`Failed to export photo via AppleScript: ${errorMessage}`);
924
+ }
925
+ }
926
+ //#endregion
927
+ //#region src/pipeline/engines/applescript-gui.ts
928
+ /** Apple's defaults, but these are overridden by Aphex by default */
929
+ const defaultExportViaAppleScriptGuiOptions = {
930
+ colorProfile: "Most Compatible",
931
+ fileName: "Use File Name",
932
+ includeLocation: true,
933
+ includeMetadata: true,
934
+ jpegQuality: "High",
935
+ maxSizeType: "Dimension",
936
+ maxSizeValue: 2048,
937
+ photoKind: "JPEG",
938
+ photoSize: "Large",
939
+ sequentialPrefix: "",
940
+ subfolderFormat: "None",
941
+ tiffBitDepth: 8
942
+ };
943
+ /**
944
+ * Export a photo via the Photos.app GUI to a temporary directory
945
+ * Clean up and move the file as needed afterwards
946
+ */
947
+ async function exportViaAppleScriptGui(uuid, options) {
948
+ const tempDirectory = await getTempDirectory("engine", "applescript-gui");
949
+ const { colorProfile, fileName, includeLocation, includeMetadata, jpegQuality, maxSizeType, maxSizeValue, photoKind, photoSize, sequentialPrefix, subfolderFormat, tiffBitDepth } = mergeDefaults(options, defaultExportViaAppleScriptGuiOptions);
950
+ const { failed, stderr } = await execa("osascript", [
951
+ path.join(getPackageAssetsPath(import.meta), "applescript-gui.applescript"),
952
+ uuid,
953
+ tempDirectory,
954
+ photoKind,
955
+ jpegQuality,
956
+ tiffBitDepth.toString(),
957
+ colorProfile,
958
+ photoSize,
959
+ maxSizeType,
960
+ maxSizeValue.toString(),
961
+ includeMetadata.toString(),
962
+ includeLocation.toString(),
963
+ fileName,
964
+ sequentialPrefix,
965
+ subfolderFormat
966
+ ]);
967
+ if (failed) throw new Error(`Error exporting album "${uuid}": ${stderr}`);
968
+ const results = stderr.split("\n").filter((line) => line.trim() !== "");
969
+ if (results.length === 0) throw new Error(`No photos exported for album "${uuid}"`);
970
+ if (typeof results[0] !== "string") throw new TypeError(`Unexpected export results for album "${uuid}": ${JSON.stringify(results)}`);
971
+ return results;
972
+ }
973
+ //#endregion
974
+ //#region src/pipeline/engines/file-system.ts
975
+ /**
976
+ * Export a photo via direct file system copy of the original or edited file to a temporary directory
977
+ * Clean up and move the file as needed afterwards
978
+ */
979
+ async function exportViaFileSystem(photoUuid, forceOriginal = false) {
980
+ const [{ edited, original }] = isPhotoInfo(photoUuid) ? [photoUuid] : await aphexPhotoInfo(photoUuid);
981
+ const tempDirectory = await getTempDirectory("engine", "file-system");
982
+ if (forceOriginal || edited === void 0) {
983
+ const destinationPath = path.join(tempDirectory, original.fileName);
984
+ await fse.copy(original.filePath, destinationPath, { overwrite: true });
985
+ return destinationPath;
986
+ }
987
+ const destinationPath = path.join(tempDirectory, edited.fileName);
988
+ await fse.copy(edited.filePath, destinationPath, { overwrite: true });
989
+ return destinationPath;
990
+ }
991
+ //#endregion
992
+ //#region src/pipeline/engines/swift-photokit.ts
993
+ /**
994
+ * Export a photo via aphex-swift command line tool using PhotoKit
995
+ */
996
+ async function exportViaSwiftPhotoKit(photoUuid) {
997
+ const [result] = await aphexExport(photoUuid, await getTempDirectory("engine", "swift-photokit"));
998
+ return result;
999
+ }
1000
+ //#endregion
1001
+ //#region src/pipeline/image-export.ts
1002
+ const defaultExportApplePhotoOptions = {
1003
+ appleScriptGuiOptions: {
1004
+ colorProfile: "sRGB",
1005
+ fileName: "Use File Name",
1006
+ includeLocation: false,
1007
+ includeMetadata: false,
1008
+ jpegQuality: "Maximum",
1009
+ maxSizeType: "Dimension",
1010
+ maxSizeValue: Number.MAX_SAFE_INTEGER,
1011
+ photoKind: "PNG",
1012
+ photoSize: "Full Size",
1013
+ sequentialPrefix: "",
1014
+ subfolderFormat: "None",
1015
+ tiffBitDepth: 8
1016
+ },
1017
+ engineEdited: "photos-gui",
1018
+ engineEditedAlpha: "swift-photokit",
1019
+ engineOriginal: {
1020
+ arw: "photos-gui",
1021
+ avif: "swift-photokit",
1022
+ cr2: "photos-gui",
1023
+ cr3: "photos-gui",
1024
+ crw: "photos-gui",
1025
+ dng: "photos-gui",
1026
+ gif: "swift-photokit",
1027
+ heic: "swift-photokit",
1028
+ heif: "swift-photokit",
1029
+ jpeg: "swift-photokit",
1030
+ nef: "photos-gui",
1031
+ pef: "photos-gui",
1032
+ png: "photos-gui",
1033
+ psd: "photos-gui",
1034
+ tiff: "photos-gui",
1035
+ webp: "swift-photokit"
1036
+ },
1037
+ engineOriginalAlpha: "swift-photokit",
1038
+ fileNameAppendUuidFragment: false,
1039
+ fileNameNormalizeExtensions: true,
1040
+ fileNamePrecedence: [
1041
+ "title",
1042
+ "fileName",
1043
+ "uuid"
1044
+ ],
1045
+ fileNameSluggify: true
1046
+ };
1047
+ /**
1048
+ * Export a batch of photos
1049
+ */
1050
+ async function exportApplePhotos(identifiers, destinationDirectory, options) {
1051
+ const resolvedOptions = mergeDefaults(options, defaultExportApplePhotoOptions);
1052
+ const photoInfos = await resolveIdentifiers(identifiers);
1053
+ const exportedPhotos = [];
1054
+ for (const photoInfo of photoInfos) {
1055
+ const engine = await getEngineForPhoto(photoInfo, resolvedOptions);
1056
+ const exportedPhoto = {
1057
+ exportEngine: engine,
1058
+ exportOptions: resolvedOptions,
1059
+ path: "",
1060
+ photoInfo
1061
+ };
1062
+ switch (engine) {
1063
+ case "applescript":
1064
+ exportedPhoto.path = await exportViaAppleScript(photoInfo.uuid);
1065
+ break;
1066
+ case "file-system":
1067
+ exportedPhoto.path = await exportViaFileSystem(photoInfo);
1068
+ break;
1069
+ case "photos-gui": {
1070
+ const [exportedPath] = await exportViaAppleScriptGui(photoInfo.uuid, resolvedOptions.appleScriptGuiOptions);
1071
+ exportedPhoto.path = exportedPath;
1072
+ break;
1073
+ }
1074
+ case "swift-photokit":
1075
+ exportedPhoto.path = await exportViaSwiftPhotoKit(photoInfo.uuid);
1076
+ break;
1077
+ }
1078
+ const resolvedDestinationDirectory = await ensureDirectoryExists(destinationDirectory);
1079
+ const normalizedDestinationPath = getImagePathWithFileName(photoInfo, path.join(resolvedDestinationDirectory, path.basename(exportedPhoto.path)), resolvedOptions.fileNameSluggify, resolvedOptions.fileNameAppendUuidFragment, resolvedOptions.fileNameNormalizeExtensions, resolvedOptions.fileNamePrecedence);
1080
+ await fse.rename(exportedPhoto.path, normalizedDestinationPath);
1081
+ const tempDirectory = path.dirname(exportedPhoto.path);
1082
+ await fse.rm(tempDirectory, {
1083
+ force: true,
1084
+ recursive: true
1085
+ });
1086
+ exportedPhoto.path = normalizedDestinationPath;
1087
+ exportedPhotos.push(exportedPhoto);
1088
+ }
1089
+ return exportedPhotos;
1090
+ }
1091
+ /** Also normalizes */
1092
+ function getImagePathWithFileName(photoInfo, filePath, sluggify = true, fileNameAppendUuidFragment = false, normalizeExtensions = true, namingStrategyPrecedence = [
1093
+ "title",
1094
+ "fileName",
1095
+ "uuid"
1096
+ ]) {
1097
+ const normalizedPath = normalizeExtensions ? normalizeExtension(filePath) : filePath;
1098
+ const basePath = path.dirname(normalizedPath);
1099
+ const extension = path.extname(normalizedPath);
1100
+ for (const option of namingStrategyPrecedence) switch (option) {
1101
+ case "fileName": {
1102
+ const nameWithoutExtension = path.basename(photoInfo.original.fileName, path.extname(photoInfo.original.fileName));
1103
+ const uuidFragment = fileNameAppendUuidFragment ? `-${photoInfo.uuid.slice(-8)}` : "";
1104
+ const processedName = sluggify ? slug(`${nameWithoutExtension}${uuidFragment}`) : `${nameWithoutExtension}${uuidFragment}`;
1105
+ return path.join(basePath, `${processedName}${extension}`);
1106
+ }
1107
+ case "title":
1108
+ if (isNonEmptyStringAndNotWhitespace(photoInfo.title)) {
1109
+ const title = sluggify ? slug(photoInfo.title) : photoInfo.title;
1110
+ const uuidFragment = fileNameAppendUuidFragment ? `-${photoInfo.uuid.slice(-8)}` : "";
1111
+ const processedTitle = sluggify ? slug(`${title}${uuidFragment}`) : `${title}${uuidFragment}`;
1112
+ return path.join(basePath, `${processedTitle}${extension}`);
1113
+ }
1114
+ break;
1115
+ case "uuid": {
1116
+ const processedUuid = sluggify ? slug(photoInfo.uuid) : photoInfo.uuid;
1117
+ return path.join(basePath, `${processedUuid}${extension}`);
1118
+ }
1119
+ }
1120
+ throw new Error("No valid filename option found, can't name image");
1121
+ }
1122
+ async function getEngineForPhoto(photoInfo, options) {
1123
+ const { engineEdited, engineEditedAlpha, engineOriginal, engineOriginalAlpha } = mergeDefaults(options, defaultExportApplePhotoOptions);
1124
+ if (!photoInfo.edited) {
1125
+ const alpha = await hasAlpha(photoInfo.original.filePath);
1126
+ const type = lookupImageMimeType(photoInfo.original.filePath, true);
1127
+ const engine = alpha ? typeof engineOriginalAlpha === "string" ? engineOriginalAlpha : engineOriginalAlpha[type] : typeof engineOriginal === "string" ? engineOriginal : engineOriginal[type];
1128
+ if (engine === void 0) throw new Error(`No export engine found for original photo "${photoInfo.title}" of type "${type}"${alpha ? " with alpha channel" : ""}`);
1129
+ return engine;
1130
+ }
1131
+ const alpha = await hasAlpha(photoInfo.edited.filePath);
1132
+ if (alpha) log.warn(`Edited photo "${photoInfo.title}" has true transparency, it probably won't export correctly. Only un-edited files preserve transparency on export.`);
1133
+ const type = lookupImageMimeType(photoInfo.edited.filePath, true);
1134
+ const engine = alpha ? typeof engineEditedAlpha === "string" ? engineEditedAlpha : engineEditedAlpha[type] : typeof engineEdited === "string" ? engineEdited : engineEdited[type];
1135
+ if (engine === void 0) throw new Error(`No export engine found for edited photo "${photoInfo.title}" of type "${type}"${alpha ? " with alpha channel" : ""}`);
1136
+ return engine;
1137
+ }
1138
+ /**
1139
+ * Export a single album to a folder
1140
+ * Optionally sync the output directory with the album, deleting any images that are no longer in the album
1141
+ * Optionally audit the album for duplicate titles
1142
+ * Does NOT further process the images
1143
+ */
1144
+ //#endregion
1145
+ //#region src/pipeline/image-metadata.ts
1146
+ const defaultManageMetadataOptions = {
1147
+ syncToEdited: true,
1148
+ syncToTarget: true,
1149
+ tagsToSync: [
1150
+ "label",
1151
+ "preservedFileName",
1152
+ "creator",
1153
+ "credit",
1154
+ "description"
1155
+ ],
1156
+ validate: true,
1157
+ writeAphexMetadata: true
1158
+ };
1159
+ /**
1160
+ * Manage metadata for a batch of photos
1161
+ */
1162
+ async function manageMetadataBatch(identifiers, targetFilePaths, options, exportOptions) {
1163
+ const photoInfos = await resolveIdentifiers(identifiers);
1164
+ if (photoInfos.length !== targetFilePaths.length) throw new Error("Photo infos and target files length mismatch");
1165
+ return Promise.all(photoInfos.map(async (photoInfo, index) => manageMetadata(photoInfo, targetFilePaths[index], options, exportOptions)));
1166
+ }
1167
+ /**
1168
+ * Manage metadata...
1169
+ */
1170
+ async function manageMetadata(identifier, targetFilePath, options, exportOptions) {
1171
+ const resolvedOptions = mergeDefaults(options, defaultManageMetadataOptions);
1172
+ const photoInfo = await resolvePhotoIdentifier(identifier);
1173
+ let tagsToTarget;
1174
+ if (resolvedOptions.syncToTarget) tagsToTarget = await cloneTags(photoInfo.original.filePath, targetFilePath, resolvedOptions.tagsToSync);
1175
+ let tagsToEdited;
1176
+ if (photoInfo.edited && resolvedOptions.syncToEdited) tagsToEdited = await cloneTags(photoInfo.original.filePath, photoInfo.edited.filePath, resolvedOptions.tagsToSync);
1177
+ if (exportOptions !== void 0 && resolvedOptions.writeAphexMetadata) await setTags(targetFilePath, { aphexMetadata: {
1178
+ exportOptions,
1179
+ photoInfo
1180
+ } });
1181
+ let validationResult;
1182
+ if (resolvedOptions.validate) validationResult = await validateTags(targetFilePath);
1183
+ return {
1184
+ photoInfo,
1185
+ tagsToEdited,
1186
+ tagsToTarget,
1187
+ validationResult
1188
+ };
1189
+ }
1190
+ //#endregion
1191
+ //#region src/utilities/image/convert.ts
1192
+ /**
1193
+ * Optimize a PNG image
1194
+ */
1195
+ async function optimizePng(imagePath, destinationDirectory) {
1196
+ lookupImageMimeType(imagePath, ["png"]);
1197
+ const destinationImagePath = path.join(destinationDirectory, path.basename(imagePath));
1198
+ await execa("oxipng", [
1199
+ "--scale16",
1200
+ "--alpha",
1201
+ "--preserve",
1202
+ "--nc",
1203
+ "--out",
1204
+ destinationImagePath,
1205
+ imagePath
1206
+ ]);
1207
+ return destinationImagePath;
1208
+ }
1209
+ /**
1210
+ * Resize a PNG image to fit within a maximum width and height
1211
+ * Copies the image unchanged if it already fits within the target size
1212
+ * @returns The destination image path
1213
+ */
1214
+ async function resizePngToFit(imagePath, destinationDirectory, maxWidth, maxHeight) {
1215
+ lookupImageMimeType(imagePath, ["png"]);
1216
+ if (maxWidth <= 0 || maxHeight <= 0) throw new Error(`Invalid target size: ${maxWidth} x ${maxHeight}`);
1217
+ const { width, height } = await getImageDimensions(imagePath);
1218
+ const destinationImagePath = path.join(destinationDirectory, path.basename(imagePath));
1219
+ if (height <= maxHeight && width <= maxWidth) await fse.copy(imagePath, destinationImagePath);
1220
+ else await execa("convert", [
1221
+ imagePath,
1222
+ "-resize",
1223
+ `${maxWidth}x${maxHeight}`,
1224
+ "-filter",
1225
+ "Lanczos",
1226
+ destinationImagePath
1227
+ ]);
1228
+ return destinationImagePath;
1229
+ }
1230
+ /**
1231
+ * Resize a PNG image to fit within a maximum width and height
1232
+ */
1233
+ async function convertToJpeg(sourceImagePath, destinationDirectory, quality, force = false, engine = "mozjpeg") {
1234
+ const mime = lookupImageMimeType(sourceImagePath, [
1235
+ "bmp",
1236
+ "jpeg",
1237
+ "png",
1238
+ "tiff",
1239
+ "webp"
1240
+ ]);
1241
+ const destinationImagePath = path.join(destinationDirectory, path.basename(sourceImagePath, path.extname(sourceImagePath)) + ".jpeg");
1242
+ if (!force && mime === "jpeg") await fse.copy(sourceImagePath, destinationImagePath);
1243
+ else {
1244
+ const jpegQuality = quality === "lossless" ? "lossless" : Math.round(quality * 100);
1245
+ switch (engine) {
1246
+ case "guetzli":
1247
+ if (jpegQuality === "lossless") throw new Error("Lossless JPEG conversion not supported with guetzli");
1248
+ await execa("guetzli", [
1249
+ "--quality",
1250
+ jpegQuality.toString(),
1251
+ sourceImagePath,
1252
+ destinationImagePath
1253
+ ]);
1254
+ break;
1255
+ case "mozjpeg": {
1256
+ const tgaTempDirectory = await fse.mkdtemp(path.join(os.tmpdir(), `com.kitschpatrol.aphex.${getSlugFilename(sourceImagePath)}.${engine}.`));
1257
+ const tgaImagePath = await convertToTga(sourceImagePath, tgaTempDirectory);
1258
+ await (jpegQuality === "lossless" ? execa("cjpeg", [
1259
+ "--lossless",
1260
+ "-optimize",
1261
+ "-progressive",
1262
+ "-outfile",
1263
+ destinationImagePath,
1264
+ tgaImagePath
1265
+ ]) : execa("cjpeg", [
1266
+ "-quality",
1267
+ jpegQuality.toString(),
1268
+ "-optimize",
1269
+ "-progressive",
1270
+ "-outfile",
1271
+ destinationImagePath,
1272
+ tgaImagePath
1273
+ ]));
1274
+ await fse.rm(tgaTempDirectory, { recursive: true });
1275
+ break;
1276
+ }
1277
+ case "sips":
1278
+ if (jpegQuality === "lossless") throw new Error("Lossless JPEG conversion not supported with sips");
1279
+ await execa("sips", [
1280
+ "-s",
1281
+ "format",
1282
+ "jpeg",
1283
+ "-s",
1284
+ "formatOptions",
1285
+ jpegQuality.toString(),
1286
+ sourceImagePath,
1287
+ "--out",
1288
+ destinationImagePath
1289
+ ]);
1290
+ break;
1291
+ }
1292
+ }
1293
+ return destinationImagePath;
1294
+ }
1295
+ /**
1296
+ * Convert an image to a WebP
1297
+ */
1298
+ async function convertToWebp(sourceImagePath, destinationDirectory, quality, force = false) {
1299
+ const mime = lookupImageMimeType(sourceImagePath, true);
1300
+ const destinationImagePath = path.join(destinationDirectory, path.basename(sourceImagePath, path.extname(sourceImagePath)) + ".webp");
1301
+ if (!force && mime === "webp") await fse.copy(sourceImagePath, destinationImagePath);
1302
+ else {
1303
+ const webpQuality = typeof quality === "number" ? Math.round(quality * 100) : quality;
1304
+ await execa("cwebp", [
1305
+ ...quality === "lossless" ? ["-lossless"] : quality === "near-lossless" ? ["-near_lossless", "100"] : ["-q", String(webpQuality)],
1306
+ "-mt",
1307
+ "-metadata",
1308
+ "all",
1309
+ sourceImagePath,
1310
+ "-o",
1311
+ destinationImagePath
1312
+ ]);
1313
+ }
1314
+ return destinationImagePath;
1315
+ }
1316
+ /**
1317
+ * Convert an image to a PNG
1318
+ * Supports width / height to help quality comparison tools match target sizes in one step
1319
+ */
1320
+ async function convertToPng(sourceImagePath, destinationDirectory, force = false, width, height) {
1321
+ const mime = lookupImageMimeType(sourceImagePath, true);
1322
+ const destinationImagePath = path.join(destinationDirectory, path.basename(sourceImagePath, path.extname(sourceImagePath)) + ".png");
1323
+ const needsResize = width !== void 0 && height !== void 0;
1324
+ if (!force && mime === "png" && !needsResize) await fse.copyFile(sourceImagePath, destinationImagePath);
1325
+ else {
1326
+ const resizeArgs = needsResize ? [
1327
+ "--resampleHeightWidth",
1328
+ height.toString(),
1329
+ width.toString()
1330
+ ] : [];
1331
+ const restoreColorProfile = mime === "psd" ? await getColorProfile(sourceImagePath) : void 0;
1332
+ await execa("sips", [
1333
+ ...resizeArgs,
1334
+ "--setProperty",
1335
+ "format",
1336
+ "png",
1337
+ ...restoreColorProfile && restoreColorProfile !== "None" ? [
1338
+ "--setProperty",
1339
+ "profile",
1340
+ getPathToColorProfile(restoreColorProfile)
1341
+ ] : [],
1342
+ sourceImagePath,
1343
+ "--out",
1344
+ destinationImagePath
1345
+ ]);
1346
+ }
1347
+ return destinationImagePath;
1348
+ }
1349
+ /**
1350
+ * Convert an image to a TGA
1351
+ * Only viable input to mozjpeg is TGA, BMP, and some other esoteric stuff...
1352
+ * BMP emitted by sips is too modern for cjpeg, so we need to convert to TGA instead
1353
+ */
1354
+ async function convertToTga(sourceImagePath, destinationDirectory, force = false) {
1355
+ const mime = lookupImageMimeType(sourceImagePath, [
1356
+ "tga",
1357
+ "png",
1358
+ "tiff",
1359
+ "psd",
1360
+ "jpeg",
1361
+ "heic",
1362
+ "avif"
1363
+ ]);
1364
+ const destinationImagePath = path.join(destinationDirectory, path.basename(sourceImagePath, path.extname(sourceImagePath)) + ".bmp");
1365
+ await (!force && mime === "tga" ? fse.copyFile(sourceImagePath, destinationImagePath) : execa("sips", [
1366
+ "--setProperty",
1367
+ "format",
1368
+ "tga",
1369
+ sourceImagePath,
1370
+ "--out",
1371
+ destinationImagePath
1372
+ ]));
1373
+ return destinationImagePath;
1374
+ }
1375
+ /**
1376
+ * Convert an image to an AVIF
1377
+ */
1378
+ async function convertToAvif(sourceImagePath, destinationDirectory, quality, force = false) {
1379
+ const mime = lookupImageMimeType(sourceImagePath, true);
1380
+ const destinationImagePath = path.join(destinationDirectory, path.basename(sourceImagePath, path.extname(sourceImagePath)) + ".avif");
1381
+ if (!force && mime === "avif") await fse.copy(sourceImagePath, destinationImagePath);
1382
+ else {
1383
+ const qualityArgs = quality === "lossless" ? ["--lossless"] : typeof quality === "number" ? ["-q", Math.round(quality * 63).toString()] : [
1384
+ "-qcolor",
1385
+ quality.color.toString(),
1386
+ "--qalpha",
1387
+ quality.alpha.toString()
1388
+ ];
1389
+ const profile = await getColorProfile(sourceImagePath);
1390
+ await execa("avifenc", [
1391
+ ...qualityArgs,
1392
+ "--icc",
1393
+ profile === "None" ? getPathToColorProfile("sRGB IEC61966-2.1") : getPathToColorProfile(profile),
1394
+ "--speed",
1395
+ "default",
1396
+ sourceImagePath,
1397
+ destinationImagePath
1398
+ ]);
1399
+ }
1400
+ return destinationImagePath;
1401
+ }
1402
+ /**
1403
+ * Compress an image to a target size
1404
+ */
1405
+ async function compressImage(sourceImagePath, destinationDirectory, options) {
1406
+ const { forceCompression, losslessFormat, lossyFormat, lossyQuality, maxFileSizeBytes, nearLosslessFormat } = options;
1407
+ if (losslessFormat === "none" && nearLosslessFormat === "none" && lossyFormat === "none") throw new Error("At least one compression format must be enabled");
1408
+ const initialSize = await getSizeBytes(sourceImagePath);
1409
+ if (!forceCompression && initialSize <= maxFileSizeBytes) {
1410
+ const destinationImagePath = path.join(destinationDirectory, path.basename(sourceImagePath));
1411
+ await fse.copyFile(sourceImagePath, destinationImagePath);
1412
+ return {
1413
+ compression: "none",
1414
+ path: destinationImagePath
1415
+ };
1416
+ }
1417
+ let workingImagePath;
1418
+ const tempDirectory = await fse.mkdtemp(path.join(os.tmpdir(), `com.kitschpatrol.aphex.${getSlugFilename(sourceImagePath)}.compress.`));
1419
+ switch (losslessFormat) {
1420
+ case "avif":
1421
+ workingImagePath = await convertToAvif(sourceImagePath, tempDirectory, "lossless");
1422
+ break;
1423
+ case "none":
1424
+ workingImagePath = sourceImagePath;
1425
+ break;
1426
+ case "png":
1427
+ workingImagePath = lookupImageMimeType(sourceImagePath) === "png" ? sourceImagePath : await convertToPng(sourceImagePath, tempDirectory);
1428
+ workingImagePath = await optimizePng(workingImagePath, tempDirectory);
1429
+ break;
1430
+ case "webp":
1431
+ workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, "lossless");
1432
+ break;
1433
+ }
1434
+ if (await getSizeBytes(workingImagePath) <= maxFileSizeBytes) {
1435
+ const destinationImagePath = path.join(destinationDirectory, path.basename(workingImagePath));
1436
+ await fse.copyFile(workingImagePath, destinationImagePath);
1437
+ await fse.rm(tempDirectory, {
1438
+ force: true,
1439
+ recursive: true
1440
+ });
1441
+ return {
1442
+ compression: "lossless",
1443
+ path: destinationImagePath
1444
+ };
1445
+ }
1446
+ if (workingImagePath !== sourceImagePath) await fse.rm(workingImagePath);
1447
+ switch (nearLosslessFormat) {
1448
+ case "none":
1449
+ workingImagePath = sourceImagePath;
1450
+ break;
1451
+ case "webp":
1452
+ workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, "near-lossless");
1453
+ break;
1454
+ }
1455
+ if (await getSizeBytes(workingImagePath) <= maxFileSizeBytes) {
1456
+ const destinationImagePath = path.join(destinationDirectory, path.basename(workingImagePath));
1457
+ await fse.copyFile(workingImagePath, destinationImagePath);
1458
+ await fse.rm(tempDirectory, {
1459
+ force: true,
1460
+ recursive: true
1461
+ });
1462
+ return {
1463
+ compression: "near-lossless",
1464
+ path: destinationImagePath
1465
+ };
1466
+ }
1467
+ if (workingImagePath !== sourceImagePath) await fse.rm(workingImagePath);
1468
+ switch (lossyFormat) {
1469
+ case "avif":
1470
+ workingImagePath = await convertToAvif(sourceImagePath, tempDirectory, lossyQuality, true);
1471
+ break;
1472
+ case "jpeg":
1473
+ workingImagePath = await convertToJpeg(sourceImagePath, tempDirectory, lossyQuality, true, "mozjpeg");
1474
+ break;
1475
+ case "none":
1476
+ workingImagePath = sourceImagePath;
1477
+ break;
1478
+ case "webp":
1479
+ workingImagePath = await convertToWebp(sourceImagePath, tempDirectory, lossyQuality, true);
1480
+ break;
1481
+ }
1482
+ const destinationImagePath = path.join(destinationDirectory, path.basename(workingImagePath));
1483
+ await fse.copyFile(workingImagePath, destinationImagePath);
1484
+ await fse.rm(tempDirectory, {
1485
+ force: true,
1486
+ recursive: true
1487
+ });
1488
+ return {
1489
+ compression: lossyFormat === "none" ? "none" : "lossy",
1490
+ path: destinationImagePath
1491
+ };
1492
+ }
1493
+ //#endregion
1494
+ //#region src/utilities/image/compare.ts
1495
+ const SSIM_REGEX = /All:\s*([\d.]+)/;
1496
+ const PSNR_REGEX = /average:\s*([\d.]+)/;
1497
+ /**
1498
+ * Get two identically sized PNGs from two images.
1499
+ * Uses the smaller image size as the target size.
1500
+ * Remember to clean up temp
1501
+ */
1502
+ async function getTwoIdenticallySizedPng(image1, image2) {
1503
+ const tempDirectory1 = await fse.mkdtemp(path.join(os.tmpdir(), `com.kitschpatrol.aphex.get-two-identically-sized-png.${getSlugFilename(image1)}.`));
1504
+ const tempDirectory2 = await fse.mkdtemp(path.join(os.tmpdir(), `com.kitschpatrol.aphex.get-two-identically-sized-png.${getSlugFilename(image2)}.`));
1505
+ const { width: w1, height: h1 } = await getImageDimensions(image1);
1506
+ const { width: w2, height: h2 } = await getImageDimensions(image2);
1507
+ const targetWidth = Math.min(w1, w2);
1508
+ const targetHeight = Math.min(h1, h2);
1509
+ return {
1510
+ image1Png: await convertToPng(image1, tempDirectory1, false, targetWidth, targetHeight),
1511
+ image2Png: await convertToPng(image2, tempDirectory2, false, targetWidth, targetHeight)
1512
+ };
1513
+ }
1514
+ /**
1515
+ * Check if two images have the same pixel dimensions.
1516
+ */
1517
+ async function isIdenticalSize(image1, image2) {
1518
+ const { width: w1, height: h1 } = await getImageDimensions(image1);
1519
+ const { width: w2, height: h2 } = await getImageDimensions(image2);
1520
+ return h1 === h2 && w1 === w2;
1521
+ }
1522
+ /**
1523
+ * Quantify the similarity between two images.
1524
+ *
1525
+ * Bundles image conversion for performance across several measurements
1526
+ */
1527
+ async function calculateSimilarity(image1, image2) {
1528
+ const { image1Png, image2Png } = await getTwoIdenticallySizedPng(image1, image2);
1529
+ try {
1530
+ const [dssim, ssim, psnr] = await Promise.all([
1531
+ calculateDSSIMInternal(image1Png, image2Png),
1532
+ calculateSSIMInternal(image1Png, image2Png),
1533
+ calculatePSNRInternal(image1Png, image2Png)
1534
+ ]);
1535
+ return {
1536
+ dssim,
1537
+ psnr,
1538
+ ssim
1539
+ };
1540
+ } finally {
1541
+ await fse.rm(path.dirname(image1Png), {
1542
+ force: true,
1543
+ recursive: true
1544
+ });
1545
+ await fse.rm(path.dirname(image2Png), {
1546
+ force: true,
1547
+ recursive: true
1548
+ });
1549
+ }
1550
+ }
1551
+ async function calculateDSSIMInternal(image1, image2) {
1552
+ lookupImageMimeType(image1, ["png"]);
1553
+ lookupImageMimeType(image2, ["png"]);
1554
+ if (!await isIdenticalSize(image1, image2)) {
1555
+ log.error(`Images are not the same size:\n${image1}\n${image2}`);
1556
+ return -1;
1557
+ }
1558
+ const { stdout } = await execa("dssim", [image1, image2]);
1559
+ const [dssimIndex] = stdout.split(" ");
1560
+ if (dssimIndex === void 0) throw new Error("DSSIM index not found in dssim output.");
1561
+ return Number.parseFloat(dssimIndex);
1562
+ }
1563
+ async function calculateSSIMInternal(image1, image2) {
1564
+ lookupImageMimeType(image1, ["png"]);
1565
+ lookupImageMimeType(image2, ["png"]);
1566
+ if (!await isIdenticalSize(image1, image2)) {
1567
+ log.error(`Images are not the same size:\n${image1}\n${image2}`);
1568
+ return -1;
1569
+ }
1570
+ const result = await execa("ffmpeg", [
1571
+ "-i",
1572
+ image1,
1573
+ "-i",
1574
+ image2,
1575
+ "-filter_complex",
1576
+ "ssim",
1577
+ "-f",
1578
+ "null",
1579
+ "-"
1580
+ ]);
1581
+ const match = SSIM_REGEX.exec(result.stderr);
1582
+ return match?.[1] ? Number.parseFloat(match[1]) : 0;
1583
+ }
1584
+ /**
1585
+ * Calculates the PSNR between two images using ffmpeg.
1586
+ */
1587
+ async function calculatePSNRInternal(image1, image2) {
1588
+ lookupImageMimeType(image1, ["png"]);
1589
+ lookupImageMimeType(image2, ["png"]);
1590
+ if (!await isIdenticalSize(image1, image2)) {
1591
+ log.error(`Images are not the same size:\n${image1}\n${image2}`);
1592
+ return -1;
1593
+ }
1594
+ const result = await execa("ffmpeg", [
1595
+ "-i",
1596
+ image1,
1597
+ "-i",
1598
+ image2,
1599
+ "-filter_complex",
1600
+ "psnr",
1601
+ "-f",
1602
+ "null",
1603
+ "-"
1604
+ ]);
1605
+ const match = PSNR_REGEX.exec(result.stderr);
1606
+ return match?.[1] ? Number.parseFloat(match[1]) : 0;
1607
+ }
1608
+ //#endregion
1609
+ //#region src/pipeline/image-process.ts
1610
+ const defaultProcessImageOptions = {
1611
+ defaultColorProfile: "sRGB IEC61966-2.1",
1612
+ forceCompression: false,
1613
+ logSimilarity: false,
1614
+ losslessFormat: "webp",
1615
+ losslessFormatAlpha: "png",
1616
+ lossyFormat: "jpeg",
1617
+ lossyFormatAlpha: "webp",
1618
+ lossyQuality: .95,
1619
+ maxDimensionsPixels: {
1620
+ width: Number.MAX_SAFE_INTEGER,
1621
+ height: Number.MAX_SAFE_INTEGER
1622
+ },
1623
+ maxFileSizeBytes: Number.MAX_SAFE_INTEGER,
1624
+ nearLosslessFormat: "none",
1625
+ nearLosslessFormatAlpha: "none",
1626
+ passthroughFormats: [],
1627
+ preserveColorProfiles: ["sRGB IEC61966-2.1"]
1628
+ };
1629
+ /**
1630
+ * Process one or more exported photos
1631
+ */
1632
+ async function processPhotos(imagePaths, destinationDirectory, options) {
1633
+ const resolvedOptions = mergeDefaults(options, defaultProcessImageOptions);
1634
+ const tempProcessOutputDirectory = await getTempDirectory("process", "images");
1635
+ let processImageResults;
1636
+ try {
1637
+ if (imagePaths.length === 1) processImageResults = [await processImage(imagePaths[0], tempProcessOutputDirectory, resolvedOptions)];
1638
+ else {
1639
+ const threads = Math.floor(os.availableParallelism() * .5);
1640
+ log.debug(`Using ${threads} threads for processing`);
1641
+ const piscina = new Piscina({
1642
+ env: {
1643
+ ...process.env,
1644
+ PISCINA_WORKER_META_URL: import.meta.url
1645
+ },
1646
+ filename: new URL(path.join(getPackageWorkersPath(import.meta), "process-image-worker.js"), "file://").href,
1647
+ maxThreads: threads,
1648
+ minThreads: threads
1649
+ });
1650
+ try {
1651
+ processImageResults = await Promise.all(imagePaths.map(async (path) => piscina.run({
1652
+ destinationDirectory: tempProcessOutputDirectory,
1653
+ options: resolvedOptions,
1654
+ sourceImagePath: path,
1655
+ verbose: log.isLevelEnabled("debug")
1656
+ })));
1657
+ } finally {
1658
+ await piscina.destroy();
1659
+ }
1660
+ }
1661
+ const resolvedDestinationDirectory = await ensureDirectoryExists(destinationDirectory);
1662
+ for (const result of processImageResults) {
1663
+ const finalOutputPath = path.join(resolvedDestinationDirectory, path.basename(result.output.path));
1664
+ await fse.move(result.path, finalOutputPath, { overwrite: true });
1665
+ result.path = finalOutputPath;
1666
+ }
1667
+ } finally {
1668
+ await fse.rm(tempProcessOutputDirectory, {
1669
+ force: true,
1670
+ recursive: true
1671
+ });
1672
+ const sipsTempFileCount = await sipsTempCleanup();
1673
+ log.debug(`Cleaned up ${sipsTempFileCount} probable SIPS temp files from "${os.tmpdir()}"`);
1674
+ }
1675
+ return processImageResults;
1676
+ }
1677
+ /**
1678
+ * Process an image
1679
+ *
1680
+ * Run in parallel through a worker for album processing
1681
+ */
1682
+ async function processImage(sourceImagePath, destinationDirectory, options) {
1683
+ const startTime = performance.now();
1684
+ const input = await getImageInfo(sourceImagePath);
1685
+ const report = { date: /* @__PURE__ */ new Date() };
1686
+ let workingImagePath = sourceImagePath;
1687
+ const tempDirectory = await getTempDirectory("process", "image");
1688
+ if (options.passthroughFormats.includes("png") && input.mime === "png") workingImagePath = await optimizePng(workingImagePath, tempDirectory);
1689
+ const isOverweight = input.sizeBytes > options.maxFileSizeBytes;
1690
+ const isOversize = input.dimensionsPixels.width > options.maxDimensionsPixels.width || input.dimensionsPixels.height > options.maxDimensionsPixels.height;
1691
+ const isInvalidColorProfile = await needsColorConversion(workingImagePath, options.preserveColorProfiles);
1692
+ const isInvalidOutputFormat = !options.passthroughFormats.includes(input.mime);
1693
+ if (input.mime !== "png" && (isInvalidOutputFormat || isOverweight || isInvalidColorProfile || isOversize && isOverweight)) {
1694
+ workingImagePath = await convertToPng(workingImagePath, tempDirectory);
1695
+ workingImagePath = await optimizePng(workingImagePath, tempDirectory);
1696
+ } else {
1697
+ workingImagePath = path.join(tempDirectory, path.basename(sourceImagePath));
1698
+ await fse.copyFile(sourceImagePath, workingImagePath);
1699
+ }
1700
+ report.color = await normalizeColorProfile(workingImagePath, options.preserveColorProfiles, options.defaultColorProfile);
1701
+ const normalizedColorProfile = await getColorProfile(workingImagePath);
1702
+ if (isInvalidOutputFormat || isOverweight || isInvalidColorProfile || isOversize && isOverweight) {
1703
+ if (isOversize) workingImagePath = await resizePngToFit(workingImagePath, tempDirectory, options.maxDimensionsPixels.width, options.maxDimensionsPixels.height);
1704
+ const compressionResult = await compressImage(workingImagePath, tempDirectory, {
1705
+ forceCompression: options.forceCompression,
1706
+ losslessFormat: input.alpha ? options.losslessFormatAlpha : options.losslessFormat,
1707
+ lossyFormat: input.alpha ? options.lossyFormatAlpha : options.lossyFormat,
1708
+ lossyQuality: options.lossyQuality,
1709
+ maxFileSizeBytes: options.maxFileSizeBytes,
1710
+ nearLosslessFormat: input.alpha ? options.nearLosslessFormatAlpha : options.nearLosslessFormat
1711
+ });
1712
+ workingImagePath = compressionResult.path;
1713
+ report.compression = compressionResult.compression;
1714
+ } else report.compression = "none";
1715
+ await stripTags(workingImagePath);
1716
+ await assignColorProfile(workingImagePath, normalizedColorProfile);
1717
+ const destinationImagePath = path.join(destinationDirectory, path.basename(workingImagePath));
1718
+ await fse.copyFile(workingImagePath, destinationImagePath);
1719
+ await fse.rm(tempDirectory, {
1720
+ force: true,
1721
+ recursive: true
1722
+ });
1723
+ const output = await getImageInfo(destinationImagePath);
1724
+ if (options.logSimilarity && (report.compression === "lossy" || report.compression === "near-lossless")) {
1725
+ const { dssim, psnr, ssim } = await calculateSimilarity(sourceImagePath, destinationImagePath);
1726
+ report.similarity = {
1727
+ dssim,
1728
+ psnr,
1729
+ ssim
1730
+ };
1731
+ }
1732
+ report.durationMs = Math.round(performance.now() - startTime);
1733
+ if (!isProcessImageResultReport(report)) throw new Error("Invalid report");
1734
+ return {
1735
+ input,
1736
+ output,
1737
+ path: destinationImagePath,
1738
+ report
1739
+ };
1740
+ }
1741
+ function isProcessImageResultReport(object) {
1742
+ if (!(object.color !== void 0 && object.compression !== void 0 && object.date instanceof Date && typeof object.durationMs === "number")) return false;
1743
+ if (object.similarity !== void 0) return typeof object.similarity.dssim === "number" && typeof object.similarity.psnr === "number" && typeof object.similarity.ssim === "number";
1744
+ return true;
1745
+ }
1746
+ //#endregion
1747
+ //#region src/pipeline/image-sync.ts
1748
+ async function getDestinationFiles(destinationDirectory) {
1749
+ const files = (await fse.readdir(destinationDirectory, { withFileTypes: true })).filter((entry) => entry.isFile());
1750
+ return Promise.all(files.map(async (file) => {
1751
+ const filePath = path.join(destinationDirectory, file.name);
1752
+ if (!await fse.pathExists(filePath)) return {
1753
+ filePath,
1754
+ tags: void 0
1755
+ };
1756
+ try {
1757
+ return {
1758
+ filePath,
1759
+ tags: await getTags(filePath)
1760
+ };
1761
+ } catch {
1762
+ return {
1763
+ filePath,
1764
+ tags: void 0
1765
+ };
1766
+ }
1767
+ }));
1768
+ }
1769
+ /**
1770
+ * Create a sync plan
1771
+ *
1772
+ * TODO how to handle non-image files?
1773
+ */
1774
+ async function getSyncPlanForImages(identifiers, destinationDirectory, options, exportOptions) {
1775
+ const resolvedOptions = mergeDefaults(options, defaultSyncOptions);
1776
+ const resolvedExportOptions = mergeDefaults(exportOptions, defaultExportOptions);
1777
+ const photoInfos = await resolveIdentifiers(identifiers);
1778
+ const syncResult = {
1779
+ plan: [],
1780
+ toDelete: []
1781
+ };
1782
+ const destinationFiles = await getDestinationFiles(destinationDirectory);
1783
+ for (const sourcePhotoInfo of photoInfos) {
1784
+ const matchingDestinationFile = destinationFiles.find((file) => file.tags?.aphexMetadata?.photoInfo.uuid === sourcePhotoInfo.uuid);
1785
+ if (matchingDestinationFile === void 0) {
1786
+ syncResult.plan.push({
1787
+ diffedVia: void 0,
1788
+ matchFilePath: void 0,
1789
+ photoInfo: sourcePhotoInfo,
1790
+ status: "new"
1791
+ });
1792
+ continue;
1793
+ }
1794
+ const sourcePhotoFileBaseName = stripExtension(getImagePathWithFileName(sourcePhotoInfo, "", resolvedExportOptions.exportOptions.fileNameSluggify, resolvedExportOptions.exportOptions.fileNameAppendUuidFragment, resolvedExportOptions.exportOptions.fileNameNormalizeExtensions, resolvedExportOptions.exportOptions.fileNamePrecedence));
1795
+ const differenceFound = await isDifferent(resolvedOptions.diffStrategies, sourcePhotoInfo, sourcePhotoFileBaseName, matchingDestinationFile.filePath, matchingDestinationFile.tags, resolvedExportOptions, resolvedOptions.forceUpdate);
1796
+ if (differenceFound) {
1797
+ if (resolvedOptions.deleteTarget) syncResult.toDelete.push(matchingDestinationFile.filePath);
1798
+ syncResult.plan.push({
1799
+ diffedVia: differenceFound,
1800
+ matchFilePath: matchingDestinationFile.filePath,
1801
+ photoInfo: sourcePhotoInfo,
1802
+ status: "changed"
1803
+ });
1804
+ continue;
1805
+ }
1806
+ syncResult.plan.push({
1807
+ diffedVia: void 0,
1808
+ matchFilePath: matchingDestinationFile.filePath,
1809
+ photoInfo: sourcePhotoInfo,
1810
+ status: "unchanged"
1811
+ });
1812
+ }
1813
+ if (resolvedOptions.deleteOthers) syncResult.toDelete = [...syncResult.toDelete, ...destinationFiles.filter((file) => !syncResult.plan.some((plan) => plan.matchFilePath === file.filePath)).map((file) => file.filePath)];
1814
+ syncResult.toDelete = [...new Set(syncResult.toDelete)];
1815
+ return syncResult;
1816
+ }
1817
+ /**
1818
+ * See if there's a difference between the source image and the target image
1819
+ * @returns Undefined if the, or the reason for the difference if found
1820
+ */
1821
+ async function isDifferent(diffStrategies, sourcePhotoInfo, sourcePhotoFileBaseName, matchPhotoFilePath, matchPhotoTags, exportOptions, forceUpdate) {
1822
+ let sourceTags;
1823
+ if (forceUpdate) return "force-update";
1824
+ for (const diffStrategy of diffStrategies) switch (diffStrategy) {
1825
+ case "exif-tags":
1826
+ sourceTags ??= await getTags(sourcePhotoInfo.original.filePath);
1827
+ 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;
1828
+ break;
1829
+ case "export-options":
1830
+ if (matchPhotoTags === void 0 || !deepEqual(exportOptions.exportOptions, matchPhotoTags.aphexMetadata?.exportOptions.exportOptions)) return diffStrategy;
1831
+ break;
1832
+ case "file-name":
1833
+ if (stripExtension(path.basename(matchPhotoFilePath)) !== sourcePhotoFileBaseName) return diffStrategy;
1834
+ break;
1835
+ case "force-update": return diffStrategy;
1836
+ case "metadata-options":
1837
+ sourceTags ??= await getTags(sourcePhotoInfo.original.filePath);
1838
+ if (matchPhotoTags === void 0 || !deepEqual(exportOptions.metadataOptions, matchPhotoTags.aphexMetadata?.exportOptions.metadataOptions)) return diffStrategy;
1839
+ break;
1840
+ case "photo-info":
1841
+ if (matchPhotoTags === void 0 || !deepEqual(sourcePhotoInfo, matchPhotoTags.aphexMetadata?.photoInfo)) return diffStrategy;
1842
+ break;
1843
+ case "process-options":
1844
+ if (matchPhotoTags === void 0 || !deepEqual(exportOptions.processOptions, matchPhotoTags.aphexMetadata?.exportOptions.processOptions)) return diffStrategy;
1845
+ break;
1846
+ }
1847
+ return false;
1848
+ }
1849
+ //#endregion
1850
+ //#region src/index.ts
1851
+ /**
1852
+ * Helper for deep merging ExportOptions object against library defaults.
1853
+ * Exported for unplugin-aphex.
1854
+ */
1855
+ function mergeDefaultExportOptions(options) {
1856
+ return mergeDefaults(options, defaultExportOptions);
1857
+ }
1858
+ const defaultSyncOptions = {
1859
+ deleteOthers: false,
1860
+ deleteTarget: true,
1861
+ diffStrategies: [
1862
+ "file-name",
1863
+ "photo-info",
1864
+ "export-options",
1865
+ "process-options",
1866
+ "metadata-options",
1867
+ "exif-tags"
1868
+ ],
1869
+ forceUpdate: false
1870
+ };
1871
+ const defaultExportOptions = {
1872
+ exportOptions: defaultExportApplePhotoOptions,
1873
+ metadataOptions: defaultManageMetadataOptions,
1874
+ processOptions: defaultProcessImageOptions,
1875
+ syncOptions: defaultSyncOptions
1876
+ };
1877
+ /**
1878
+ * Export a single photo
1879
+ */
1880
+ async function exportPhoto(identifier, destinationDirectory, options) {
1881
+ const result = await exportPhotos([await resolvePhotoIdentifier(identifier)], destinationDirectory, options);
1882
+ assertSingleElement(result);
1883
+ return result[0];
1884
+ }
1885
+ /**
1886
+ * Export photos
1887
+ */
1888
+ async function exportPhotos(identifiers, destinationDirectory, options) {
1889
+ const resolvedOptions = mergeDefaultExportOptions(options);
1890
+ const { exportOptions, metadataOptions, processOptions, syncOptions } = resolvedOptions;
1891
+ const resolvedDestinationDirectory = await ensureDirectoryExists(destinationDirectory);
1892
+ const photoInfos = await resolveIdentifiers(identifiers);
1893
+ let syncResult;
1894
+ if (syncOptions !== "disabled") {
1895
+ syncResult = await getSyncPlanForImages(photoInfos, resolvedDestinationDirectory, syncOptions, resolvedOptions);
1896
+ await Promise.all(syncResult.toDelete.map(async (file) => fse.rm(file, { force: true })));
1897
+ if (syncResult.plan.length !== photoInfos.length) throw new Error("Sync plan length does not match photos length");
1898
+ }
1899
+ const exportResults = photoInfos.map((photoInfo, index) => {
1900
+ const syncPlan = syncResult?.plan[index];
1901
+ return {
1902
+ options: resolvedOptions,
1903
+ path: syncPlan?.status === "unchanged" ? syncPlan.matchFilePath ?? "" : "",
1904
+ photoInfo,
1905
+ results: {
1906
+ exportResult: void 0,
1907
+ metadataResult: void 0,
1908
+ processResult: void 0,
1909
+ syncResult: syncPlan ? cleanSyncResult(syncPlan) : void 0
1910
+ }
1911
+ };
1912
+ });
1913
+ if (exportResults.every((exportResult) => exportResult.results.syncResult?.status === "unchanged")) return exportResults;
1914
+ const seenBaseNames = /* @__PURE__ */ new Set();
1915
+ let hasCollision = false;
1916
+ for (const exportResult of exportResults) {
1917
+ if (exportResult.results.syncResult?.status === "unchanged") continue;
1918
+ const baseName = path.basename(getImagePathWithFileName(exportResult.photoInfo, "placeholder.jpg", exportOptions.fileNameSluggify, exportOptions.fileNameAppendUuidFragment, exportOptions.fileNameNormalizeExtensions, exportOptions.fileNamePrecedence), path.extname("placeholder.jpg"));
1919
+ if (seenBaseNames.has(baseName)) {
1920
+ hasCollision = true;
1921
+ break;
1922
+ }
1923
+ seenBaseNames.add(baseName);
1924
+ }
1925
+ if (hasCollision && !exportOptions.fileNameAppendUuidFragment) {
1926
+ log.warn("Name collision detected among exported photos, enabling UUID fragment to disambiguate");
1927
+ exportOptions.fileNameAppendUuidFragment = true;
1928
+ }
1929
+ const exportDirectory = processOptions === "disabled" ? resolvedDestinationDirectory : await getTempDirectory("export-photo");
1930
+ const applePhotosExportResults = await exportApplePhotos(exportResults.filter((exportResult) => exportResult.results.syncResult?.status !== "unchanged").map((exportResult) => exportResult.photoInfo), exportDirectory, exportOptions);
1931
+ for (const exportResult of exportResults) {
1932
+ if (exportResult.results.syncResult?.status === "unchanged") continue;
1933
+ const matchingExportResult = applePhotosExportResults.find((photoExportResult) => photoExportResult.photoInfo.uuid === exportResult.photoInfo.uuid);
1934
+ if (matchingExportResult) {
1935
+ exportResult.path = matchingExportResult.path;
1936
+ exportResult.results.exportResult = cleanExportResults(matchingExportResult);
1937
+ } else log.warn(`No export result found for photo "${exportResult.photoInfo.title ?? exportResult.photoInfo.uuid}"`);
1938
+ }
1939
+ let processResults;
1940
+ if (processOptions !== "disabled") {
1941
+ processResults = await processPhotos(exportResults.filter((exportResult) => exportResult.results.syncResult?.status !== "unchanged").map((exportResult) => exportResult.path), destinationDirectory, processOptions);
1942
+ await fse.rm(exportDirectory, {
1943
+ force: true,
1944
+ recursive: true
1945
+ });
1946
+ for (const exportResult of exportResults) {
1947
+ if (exportResult.results.syncResult?.status === "unchanged") continue;
1948
+ const matchingProcessResult = processResults.find((processResult) => stripExtension(path.basename(processResult.path)) === stripExtension(path.basename(exportResult.path)));
1949
+ if (matchingProcessResult) {
1950
+ exportResult.path = matchingProcessResult.path;
1951
+ exportResult.results.processResult = cleanProcessResults(matchingProcessResult);
1952
+ } else log.warn(`No process result found for photo "${exportResult.photoInfo.title ?? exportResult.photoInfo.uuid}"`);
1953
+ }
1954
+ }
1955
+ let metadataResults;
1956
+ if (metadataOptions !== "disabled") {
1957
+ const targetPhotos = exportResults.filter((exportResult) => exportResult.results.syncResult?.status !== "unchanged");
1958
+ metadataResults = await manageMetadataBatch(targetPhotos.map((exportResult) => exportResult.photoInfo), targetPhotos.map((exportResult) => exportResult.path), metadataOptions, resolvedOptions);
1959
+ for (const exportResult of exportResults) {
1960
+ const matchingMetadataResult = metadataResults.find((photoExportResult) => photoExportResult.photoInfo.uuid === exportResult.photoInfo.uuid);
1961
+ if (matchingMetadataResult) exportResult.results.metadataResult = cleanMetadataResults(matchingMetadataResult);
1962
+ }
1963
+ }
1964
+ return exportResults;
1965
+ }
1966
+ function cleanExportResults(result) {
1967
+ return { exportEngine: result.exportEngine };
1968
+ }
1969
+ function cleanMetadataResults(result) {
1970
+ const { photoInfo: _, ...rest } = result;
1971
+ return { ...rest };
1972
+ }
1973
+ function cleanSyncResult(result) {
1974
+ const { photoInfo: _, ...rest } = result;
1975
+ return { ...rest };
1976
+ }
1977
+ function cleanProcessResults(result) {
1978
+ if (result === void 0) return;
1979
+ const { input, output, path: _path, ...rest } = result;
1980
+ const { path: _inputPath, ...strippedInput } = input;
1981
+ const { path: _outputPath, ...strippedOutput } = output;
1982
+ return {
1983
+ ...rest,
1984
+ input: strippedInput,
1985
+ output: strippedOutput
1986
+ };
1987
+ }
1988
+ //#endregion
1989
+ export { IMAGE_MIME_TYPES, defaultExportOptions, defaultSyncOptions, endExiftool, exportPhoto, exportPhotos, aphexAlbumInfo as getAlbumInfo, aphexPhotoInfo as getPhotoInfo, interactiveSessionStart, interactiveSessionStop, mergeDefaultExportOptions, processImage, setLogger };