@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.
@@ -0,0 +1,36 @@
1
+ /* eslint-disable ts/no-unsafe-member-access */
2
+ /* eslint-disable ts/no-unsafe-call */
3
+ /* eslint-disable ts/no-unsafe-return */
4
+ /* eslint-disable ts/no-unsafe-assignment */
5
+
6
+ /**
7
+ * Worker function for processing images in a separate thread
8
+ *
9
+ * Note: Each worker spawns its own exiftool instance via the centralized
10
+ * exiftool module. These are managed by Piscina's worker lifecycle - when
11
+ * the worker terminates, the exiftool process exit handlers will clean up.
12
+ * @param {object} params - Worker parameters
13
+ * @param {string} params.destinationDirectory - Directory where processed image will be saved
14
+ * @param {import('../pipeline/image-process.ts').ProcessImageOptions} params.options - Image processing options
15
+ * @param {string} params.sourceImagePath - Path to the source image file
16
+ * @param {boolean} params.verbose - Whether to log verbose output
17
+ * @returns {Promise<import('../pipeline/image-process.ts').ProcessImageResult>} Processing result with input/output info and report
18
+ */
19
+ export default async function worker({ destinationDirectory, options, sourceImagePath, verbose }) {
20
+ // Weird workaround after issues with ESM imports in worker threads and more recent versions of Node / TSX / Piscina / etc?
21
+ // ts-node didn't work
22
+ const { processImage } = await import('importx').then(async (x) =>
23
+ x.import('../index', import.meta.url),
24
+ )
25
+
26
+ if (verbose) {
27
+ console.log(`Processing image on worker thread:\n${sourceImagePath}`)
28
+ }
29
+ const result = await processImage(sourceImagePath, destinationDirectory, options)
30
+ if (verbose) {
31
+ console.log(
32
+ `Finished processing image on worker thread:\n${sourceImagePath}\nTime:\n${result.report.durationMs / 1000} seconds`,
33
+ )
34
+ }
35
+ return result
36
+ }
package/license.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Eric Mika
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,99 @@
1
+ {
2
+ "name": "@kitschpatrol/aphex",
3
+ "version": "0.1.0",
4
+ "description": "Apple Photos Export. TypeScript library and CLI tool to export and process images and albums from your macOS Photos.app library.",
5
+ "keywords": [
6
+ "apple",
7
+ "apple-photos",
8
+ "photokit",
9
+ "photos",
10
+ "npm-package"
11
+ ],
12
+ "homepage": "https://github.com/kitschpatrol/aphex",
13
+ "bugs": "https://github.com/kitschpatrol/aphex/issues",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/kitschpatrol/aphex.git"
17
+ },
18
+ "license": "MIT",
19
+ "author": {
20
+ "name": "Eric Mika",
21
+ "email": "eric@ericmika.com",
22
+ "url": "https://ericmika.com"
23
+ },
24
+ "sideEffects": false,
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ }
31
+ },
32
+ "main": "./dist/index.js",
33
+ "module": "./dist/index.js",
34
+ "types": "./dist/index.d.ts",
35
+ "bin": {
36
+ "aphex": "./dist/aphex-swift"
37
+ },
38
+ "files": [
39
+ "dist/*"
40
+ ],
41
+ "dependencies": {
42
+ "@sindresorhus/is": "^8.0.0",
43
+ "defu": "^6.1.7",
44
+ "execa": "^9.6.1",
45
+ "exiftool-vendored": "^35.18.0",
46
+ "fast-equals": "^6.0.0",
47
+ "fs-extra": "^11.3.4",
48
+ "github-slugger": "^2.0.0",
49
+ "image-size": "^2.0.2",
50
+ "importx": "^0.5.2",
51
+ "lognow": "^0.6.1",
52
+ "mrmime": "^2.0.1",
53
+ "piscina": "^5.1.4",
54
+ "type-fest": "^5.6.0"
55
+ },
56
+ "devDependencies": {
57
+ "@arethetypeswrong/core": "^0.18.2",
58
+ "@clack/prompts": "^1.2.0",
59
+ "@kitschpatrol/shared-config": "^7.5.0",
60
+ "@types/fs-extra": "^11.0.4",
61
+ "@types/node": "~22.18.13",
62
+ "@vitest/coverage-v8": "^4.1.5",
63
+ "bumpp": "^11.0.1",
64
+ "globby": "^16.2.0",
65
+ "markdown-table": "^3.0.4",
66
+ "mdat-plugin-cli-help": "^3.0.0",
67
+ "open": "^11.0.0",
68
+ "pretty-bytes": "^7.1.0",
69
+ "pretty-ms": "^9.3.0",
70
+ "publint": "^0.3.18",
71
+ "tsdown": "^0.21.10",
72
+ "tsx": "^4.21.0",
73
+ "vitest": "^4.1.5"
74
+ },
75
+ "engines": {
76
+ "node": ">=22.18.0"
77
+ },
78
+ "os": [
79
+ "darwin"
80
+ ],
81
+ "cpu": [
82
+ "arm64"
83
+ ],
84
+ "publishConfig": {
85
+ "access": "public"
86
+ },
87
+ "scripts": {
88
+ "build": "pnpm --sequential /^build:/",
89
+ "build:1-lib": "tsdown",
90
+ "build:2-native": "cd native/aphex-swift && ./build.sh",
91
+ "build:3-publint": "publint",
92
+ "clean": "git rm -f pnpm-lock.yaml && git clean -fdX -e !.claude/",
93
+ "fix": "ksc fix && mdat ./native/aphex-swift/readme.md",
94
+ "lint": "ksc lint",
95
+ "release": "bumpp --commit 'Release: %s' && pnpm build && NPM_AUTH_TOKEN=$(op read 'op://Personal/npm/token') && pnpm publish",
96
+ "test": "vitest run",
97
+ "test:coverage": "vitest run --coverage"
98
+ }
99
+ }
package/readme.md ADDED
@@ -0,0 +1,329 @@
1
+ <!-- // @case-police-ignore VSCode -->
2
+
3
+ <!-- title -->
4
+
5
+ # @kitschpatrol/aphex
6
+
7
+ <!-- /title -->
8
+
9
+ <!-- badges -->
10
+
11
+ [![NPM Package @kitschpatrol/aphex](https://img.shields.io/npm/v/@kitschpatrol/aphex.svg)](https://npmjs.com/package/@kitschpatrol/aphex)
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
13
+
14
+ <!-- /badges -->
15
+
16
+ <!-- short-description -->
17
+
18
+ **Apple Photos Export. TypeScript library and CLI tool to export and process images and albums from your macOS Photos.app library.**
19
+
20
+ <!-- /short-description -->
21
+
22
+ > [!WARNING]
23
+ >
24
+ > **Aphex is still under development. It should not be considered suitable for general use until a 1.0 release.**
25
+ >
26
+ > This project is open-sourced as a curiosity and for my own convenience, but I suspect it's too niche to be of wide interest or utility. I don't currently plan to spend time adding features for more general use-cases.
27
+ >
28
+ > It won't work in CI pipelines. It can only target the system's active Photos.app library. It requires an Apple Silicon (`arm64`) Mac. It has not been tested against iCloud Photos libraries, and assets kept only in the cloud (via "Optimize Mac Storage") are unlikely to export successfully.
29
+ >
30
+ > If you are looking for a proper Apple Photos.app mass-export or backup solution, **I highly recommend using [osxphotos](https://github.com/RhetTbull/osxphotos) instead**.
31
+
32
+ ## Overview
33
+
34
+ Aphex is a TypeScript library for exporting images and albums from your local macOS Photos.app library via a Node-compatible runtime.
35
+
36
+ It makes it simple to export high-quality versions of specific photos or albums from your Photos.app library via a path-like syntax. It can also (optionally) perform image resizing, compression, metadata migration, metadata validation, and color space normalization.
37
+
38
+ I created this library for integration in static website content management asset pipelines, and to attempt to work around some issues related to [exporting high-quality versions of edited images](https://github.com/RhetTbull/osxphotos/discussions/1522) from the Photos.app library. (See the [unplugin-aphex](https://github.com/kitschpatrol/unplugin-aphex) project for an additional layer of integration with various build tools, and the [vscode-aphex](https://github.com/kitschpatrol/vscode-aphex) plugin for hover previews of Aphex links in VS Code.)
39
+
40
+ This repository also embeds the `aphex-swift` CLI project, which provides a minimal and performant wrapper around parts of Apple's PhotoKit framework. It's not intended for direct use, instead it provides just enough functionality to support the parts of the methods provided by the `aphex` TypeScript library that can only be implemented natively.
41
+
42
+ <!-- /* spell-checker:disable */ -->
43
+
44
+ The name "Aphex" is a concatenation of **A**pple **PH**otos **EX**port.
45
+
46
+ <!-- /* spell-checker:enable */ -->
47
+
48
+ ## Getting started
49
+
50
+ ### Dependencies
51
+
52
+ Requires an Apple Silicon (`arm64`) Mac with Photos.app installed and [Node 22.18.0](https://nodejs.org/en/download/) or newer. No Intel (`x86_64`) build of the bundled native binary is provided.
53
+
54
+ Full image processing functionality also requires a number of command-line tools available via [Homebrew](https://brew.sh):
55
+
56
+ | Tool | Used for |
57
+ | ------------- | ------------------------------------------------------------------------- |
58
+ | `libavif` | AVIF encoding |
59
+ | `mozjpeg` | JPEG encoding |
60
+ | `webp` | WebP encoding (lossy, lossless, and near-lossless) |
61
+ | `oxipng` | PNG optimization |
62
+ | `guetzli` | High-quality JPEG recompression (used when size budgets require) |
63
+ | `imagemagick` | General-purpose conversion and color profile handling |
64
+ | `ffmpeg` | Media probing used by some conversion paths |
65
+ | `dssim` | Perceptual similarity metrics (only needed if `logSimilarity` is enabled) |
66
+
67
+ If you skip image processing (`processOptions: 'disabled'`) you can omit the Homebrew dependencies.
68
+
69
+ ### Installation
70
+
71
+ ```sh
72
+ brew install libavif mozjpeg imagemagick webp dssim ffmpeg guetzli oxipng
73
+ npm install @kitschpatrol/aphex
74
+ ```
75
+
76
+ ### Permissions
77
+
78
+ In most cases, the application invoking aphex should request permission to access your Photos.app library on first use.
79
+
80
+ In certain situations, like executing commands in a VS Code terminal, can [fail to prompt for permission](https://errorism.dev/issues/microsoft-vscode-vscode-terminal-doesnt-allowrequest-permissions-to-access-media-devices). You can work around this through some _highly inadvisable_ direct manipulation of the [permissions database](https://www.rainforestqa.com/blog/macos-tcc-db-deep-dive):
81
+
82
+ For example, to grant photo library permission to VS Code:
83
+
84
+ ```sh
85
+ sqlite3 ~/Library/Application\ Support/com.apple.TCC/TCC.db "INSERT OR REPLACE INTO access (service, client, client_type, auth_value, auth_reason, auth_version, indirect_object_identifier) VALUES ('kTCCServicePhotos', 'com.microsoft.VSCode', 0, 2, 3, 1, 'UNUSED');"
86
+ ```
87
+
88
+ To revoke photo library permissions:
89
+
90
+ ```sh
91
+ tccutil reset Photos com.microsoft.VSCode
92
+ ```
93
+
94
+ Adapt the application identifiers (e.g. `com.microsoft.VSCode`) as required to suit your situation.
95
+
96
+ To get an application's bundle identifier:
97
+
98
+ ```sh
99
+ osascript -e 'id of app "Cursor"'
100
+ ```
101
+
102
+ ## Usage
103
+
104
+ Aphex tries to be generous in what it accepts as valid image identifiers.
105
+
106
+ It imagines the contents of your Photos.app library as a hierarchical file system of folders, albums, and photos, where the "name" of each photo is either its filename or, if set, its title.
107
+
108
+ This lets you access specific photos in specific albums via a path-like syntax.
109
+
110
+ Be warned that exporting unedited images is very fast, but exporting _edited_ images can be very (very) slow, since an alternate AppleScript-based export strategy is enabled by default to ensure maximum quality.
111
+
112
+ By default, different export strategies are used for different types of images. The default configuration prioritizes image quality over export speed.
113
+
114
+ ### Library
115
+
116
+ #### API
117
+
118
+ Aphex provides two main export functions, one for individual photos, and one that can take an arbitrary number of identifiers / albums:
119
+
120
+ ##### `exportPhoto`
121
+
122
+ ```ts
123
+ function exportPhoto(
124
+ identifier: PhotoInfo | string,
125
+ destinationDirectory: string,
126
+ options?: PartialDeep<ExportOptions>,
127
+ ): Promise<ExportResult>
128
+ ```
129
+
130
+ ##### `exportPhotos`
131
+
132
+ ```ts
133
+ function exportPhotos(
134
+ identifiers: Array<AlbumInfo | PhotoInfo | string>,
135
+ destinationDirectory: string,
136
+ options?: PartialDeep<ExportOptions>,
137
+ ): Promise<ExportResult[]>
138
+ ```
139
+
140
+ The `options` parameter accepts a deeply partial `ExportOptions` object. All fields have sensible defaults, so you only need to specify what you want to override.
141
+
142
+ | Option group | Description |
143
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
144
+ | `exportOptions` | Controls which export engine is used per image type, file naming conventions (sluggify, UUID fragment, extension normalization), and AppleScript GUI export settings. |
145
+ | `processOptions` | Controls image processing: color profile normalization (`preserveColorProfiles`, `defaultColorProfile`), compression format and quality (`lossyFormat`, `lossyQuality`, `losslessFormat`), max dimensions and file size (`maxDimensionsPixels`, `maxFileSizeBytes`), and format passthrough (`passthroughFormats`). Set to `'disabled'` to skip processing. |
146
+ | `metadataOptions` | Controls metadata written to exported images (creator, credit, description, label). Set to `'disabled'` to skip metadata management. |
147
+ | `syncOptions` | Controls incremental export behavior: diff strategies (`diffStrategies`), whether to delete stale files (`deleteTarget`, `deleteOthers`), and force re-export (`forceUpdate`). Set to `'disabled'` to skip sync. |
148
+
149
+ See the [`ExportOptions` type](https://github.com/kitschpatrol/aphex/blob/main/src/index.ts) and the inline JSDoc on each field in [`src/pipeline/`](https://github.com/kitschpatrol/aphex/tree/main/src/pipeline) for the full set of options.
150
+
151
+ Functions are also provided for querying the contents of the Photos.app library:
152
+
153
+ ##### `getPhotoInfo`
154
+
155
+ ```ts
156
+ function getPhotoInfo(
157
+ identifiers: string | string[],
158
+ caseSensitive?: boolean, // Defaults to false
159
+ ): Promise<PhotoInfo[]>
160
+ ```
161
+
162
+ ```ts
163
+ const [info] = await getPhotoInfo('Pets/Tiny')
164
+ console.log(info.uuid, info.original.fileName, info.dateCreated)
165
+ ```
166
+
167
+ ##### `getAlbumInfo`
168
+
169
+ ```ts
170
+ function getAlbumInfo(
171
+ identifiers: string | string[],
172
+ caseSensitive?: boolean, // Defaults to false
173
+ ): Promise<AlbumInfo[]>
174
+ ```
175
+
176
+ ```ts
177
+ const [album] = await getAlbumInfo('Pets')
178
+ console.log(album.uuid, album.estimatedAssetCount)
179
+ ```
180
+
181
+ #### Logging
182
+
183
+ Aphex logs progress and warnings via [lognow](https://github.com/kitschpatrol/lognow) for logging. You can inject your own logger:
184
+
185
+ ```ts
186
+ import { setLogger } from '@kitschpatrol/aphex'
187
+
188
+ setLogger(console)
189
+ ```
190
+
191
+ #### Examples
192
+
193
+ ##### Exporting a photo by filename
194
+
195
+ Let's assume you have an album named "Trip" in your Photos.app library containing a photo with the filename "IMG_1922.jpeg":
196
+
197
+ ```ts
198
+ const result = await exportPhoto('Trip/IMG_1922.jpeg', '~/Desktop')
199
+
200
+ // '/Users/$USER/Desktop/IMG_1922.jpeg'
201
+ console.log(result.path)
202
+ ```
203
+
204
+ Lookups are case-insensitive by default. Albums nested in folders are also supported, just add them to the identifier path:
205
+
206
+ ```ts
207
+ const result = await exportPhoto('Astrophotography/Regulus/IMG_2036.jpeg', '~/Desktop')
208
+ ```
209
+
210
+ ##### Exporting a photo by title
211
+
212
+ Let's assume you have an album named "Pets" containing a photo you've titled "Tiny":
213
+
214
+ ```ts
215
+ const result = await exportPhoto('Pets/Tiny', '~/Desktop')
216
+
217
+ // '/Users/$USER/Desktop/Tiny.jpeg'
218
+ console.log(result.path)
219
+ ```
220
+
221
+ ##### Exporting a photo by UUID
222
+
223
+ Let's assume you know the local UUID of the photo you want. (Maybe you looked it up using the `getPhotoInfo` function, or the `aphex` CLI command, or `osxphotos`.)
224
+
225
+ ```ts
226
+ const result = await exportPhoto('3AFE81DB-6BDB-42AB-AD27-90EE3A85A404', '~/Desktop')
227
+ ```
228
+
229
+ Note that UUIDs are unique to each instance of your Photos.app library, so if you have the same library synced across several machines, you can't expect the identifiers to be consistent. Technically, they aren't _universal_. Apple uses the term "local identifier" internally for this reason. For the sake of concision and consistency with tools like `osxphotos`, this library uses the term "UUID" interchangeably with "local identifier".
230
+
231
+ #### Exporting an album by name
232
+
233
+ Let's assume you have an album named "Pets" containing a number of photos. Export them all as follows:
234
+
235
+ ```ts
236
+ const results = await exportPhotos(['Pets'], '~/Desktop')
237
+ ```
238
+
239
+ #### Exporting an album by UUID
240
+
241
+ Like photos, albums also have local UUIDs in the Photos.app library.
242
+
243
+ Let's assume you know the local UUID of the photo you want. (Maybe you looked it up using the `getAlbumInfo` function.)
244
+
245
+ ```ts
246
+ const results = await exportPhotos(['2768A20C-9BD0-42CB-B464-9D299952D389'], '~/Desktop')
247
+ ```
248
+
249
+ The same caveat about UUID consistency across library instances applies here.
250
+
251
+ #### Exporting a mix of multiple photos and albums
252
+
253
+ You can mix and match all the identifier forms as you like. Aphex will return a flat list of all the exported photos:
254
+
255
+ ```ts
256
+ const results = await exportPhotos(
257
+ [
258
+ // Photo title
259
+ 'Pets/Tiny',
260
+ // Photo filename
261
+ 'Trip/IMG_1922.jpeg',
262
+ // Photo UUID
263
+ '3AFE81DB-6BDB-42AB-AD27-90EE3A85A404',
264
+ // Album name
265
+ 'Astrophotography/Regulus',
266
+ // Album UUID
267
+ '2768A20C-9BD0-42CB-B464-9D299952D389',
268
+ ],
269
+ '~/Desktop',
270
+ )
271
+ ```
272
+
273
+ ### CLI
274
+
275
+ Aphex uses the bundled aphex-swift CLI tool to query the Photos.app library. The tool allows you to perform simple lookups of photos in your library using the same identifier logic enumerated above. (E.g. searching by file name, album name, title, etc.)
276
+
277
+ It's exposed as a binary as part of this package, so it's accessible on the command line via `aphex` within the scope of the `@kitschpatrol/aphex` package installation.
278
+
279
+ See the [project's readme](https://github.com/kitschpatrol/aphex/blob/main/native/aphex-swift/readme.md) for additional details.
280
+
281
+ ## Implementation notes
282
+
283
+ Currently, the TypeScript code bridges via simple CLI calls to the `aphex-swift` binary, which is a wrapper around parts of Apple's PhotoKit framework. This is flexible and fast enough for now, but projects like Kabir Oberai's [node-swift](https://github.com/kabiroberai/node-swift) could be a good alternative for tighter integration between native Swift code and the TypeScript API.
284
+
285
+ Also, this library bundles a bunch of generically useful image processing functionality, which should probably live in a separate package.
286
+
287
+ ## Resources
288
+
289
+ - [Ole Begemann on PhotoKit’s data model](https://oleb.net/2018/photos-data-model/)
290
+
291
+ ## Maintainers
292
+
293
+ [kitschpatrol](https://github.com/kitschpatrol)
294
+
295
+ ## Acknowledgments
296
+
297
+ Thank you to [Rhet Turnbull](https://github.com/RhetTbull) for creating [osxphotos](https://github.com/RhetTbull/osxphotos), which informed some of the export pipelines in this library.
298
+
299
+ Aphex borrows a technique from [Andreas Bentele](https://github.com/abentele)'s [PhotosExporter](https://github.com/abentele/PhotosExporter) for extracting semi-private values from `PHAssetResource` objects.
300
+
301
+ <!-- contributing -->
302
+
303
+ ## Contributing
304
+
305
+ [Issues](https://github.com/kitschpatrol/aphex/issues) are welcome and appreciated.
306
+
307
+ Please open an issue to discuss changes before submitting a pull request. Unsolicited PRs (especially AI-generated ones) are unlikely to be merged.
308
+
309
+ This repository uses [@kitschpatrol/shared-config](https://github.com/kitschpatrol/shared-config) (via its `ksc` CLI) for linting and formatting, plus [MDAT](https://github.com/kitschpatrol/mdat) for readme placeholder expansion.
310
+
311
+ <!-- /contributing -->
312
+
313
+ ## Disclaimer
314
+
315
+ This is an unofficial library and is not affiliated with or blessed by Apple Inc.
316
+
317
+ The core export commands maintain a "read only" relationship with your library.
318
+
319
+ None of the code paths should modify the contents of your Photos.app library. But regardless, strange things can happen — please back up your Photos.app library before using this tool.
320
+
321
+ This tool has _not_ been tested with iCloud-based Photos libraries.
322
+
323
+ <!-- license -->
324
+
325
+ ## License
326
+
327
+ [MIT](license.txt) © [Eric Mika](https://ericmika.com)
328
+
329
+ <!-- /license -->