@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/aphex-swift +0 -0
- package/dist/assets/applescript-gui.applescript +236 -0
- package/dist/assets/profiles/AdobeRGB1998.icc +0 -0
- package/dist/assets/profiles/Display P3.icc +0 -0
- package/dist/assets/profiles/ProPhoto.icm +0 -0
- package/dist/assets/profiles/sRGB Profile.icc +0 -0
- package/dist/index.d.ts +373 -0
- package/dist/index.js +1989 -0
- package/dist/workers/process-image-worker.js +36 -0
- package/license.txt +21 -0
- package/package.json +99 -0
- package/readme.md +329 -0
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 };
|