editmamei 0.24.1 → 1.0.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.
Files changed (46) hide show
  1. package/NOTICES.md +6 -30
  2. package/README.md +39 -7
  3. package/dist/api/extendscript/_helpers.js +1 -1
  4. package/dist/api/photoshop-api.js +20 -14
  5. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  6. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  7. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  8. package/dist/core/server.js +6 -6
  9. package/dist/core/session.js +9 -10
  10. package/dist/core/tool-registry.js +4 -4
  11. package/dist/delivery/store.js +10 -2
  12. package/dist/detection/detect-active-doc.js +1 -1
  13. package/dist/kernel/module-lifecycle.js +7 -1
  14. package/dist/perception/region-precompute.js +13 -5
  15. package/dist/perception/select-recipes.js +2 -2
  16. package/dist/perception/sky-mask-transfer.js +1 -1
  17. package/dist/platform/connection.js +32 -46
  18. package/dist/platform/host-platform.js +16 -0
  19. package/dist/platform/macos-detector.js +41 -71
  20. package/dist/platform/macos-runner.js +94 -0
  21. package/dist/platform/script-result.js +39 -0
  22. package/dist/platform/windows-detector.js +44 -68
  23. package/dist/platform/windows-runner.js +84 -0
  24. package/dist/skills/editmamei-skill.zip +0 -0
  25. package/dist/tools/adjustment-tools.js +18 -2
  26. package/dist/tools/brush-tools.js +1 -1
  27. package/dist/tools/document-tools.js +9 -2
  28. package/dist/tools/filter-tools.js +1 -1
  29. package/dist/tools/layer-tools.js +132 -7
  30. package/dist/tools/path-tools.js +1 -1
  31. package/dist/tools/preview-tools.js +8 -8
  32. package/dist/tools/retouch-tools.js +1 -1
  33. package/dist/tools/scene-tools.js +29 -2
  34. package/dist/tools/selection-tools.js +81 -3
  35. package/dist/update/check.js +1 -1
  36. package/dist/utils/logger.js +53 -46
  37. package/dist/utils/session-log.js +1 -1
  38. package/dist/utils/tool-helpers.js +9 -3
  39. package/dist/version.js +1 -1
  40. package/package.json +10 -3
  41. package/LICENSE +0 -81
  42. package/dist/platform/detector.js +0 -32
  43. package/dist/platform/macos-executor.js +0 -96
  44. package/dist/platform/windows-executor.js +0 -105
  45. /package/dist/platform/{script-executor.js → ports.js} +0 -0
  46. /package/dist/platform/{executor-queue.js → script-queue.js} +0 -0
package/NOTICES.md CHANGED
@@ -72,36 +72,12 @@ above.
72
72
  terms (Apache-2.0 §4(d)) are preserved by reference to that source.
73
73
  - Used to detect the 80 COCO object classes.
74
74
 
75
- ### MediaPipe FaceMesh (`models/pro/face_mesh_468.onnx`) — facial landmarks (Pro)
76
-
77
- - **Upstream:** Google MediaPipe Face Landmark / FaceMesh model (the
78
- `face_landmarks_detector` weight from `face_landmarker.task`), trained by Google
79
- on its own data. Converted to ONNX (468-point variant) and redistributed via the
80
- Hugging Face `Heliosoph/mediapipe-face-onnx` repository.
81
- - **License:** Apache-2.0 (the model is permissively licensed by Google;
82
- commercial-safe — see `docs/product/pro-vision-models.md` for the weights-license
83
- diligence). Any upstream `NOTICE`/attribution terms (Apache-2.0 §4(d)) are
84
- preserved by reference to those sources.
85
- - Runs on-device via the same `onnxruntime-web` backend; the image never leaves the
86
- machine. Used by the Pro `ps_detect_landmarks` tool to locate the 468-point
87
- face mesh (eyes, brows, lips, nose, jaw). Ships in the **Pro module**
88
- (`models/pro/`), not the CE tarball.
89
-
90
- ### MobileSAM (`models/pro/mobile_sam_{encoder,decoder}.onnx`) — promptable segmentation (Pro)
91
-
92
- - **Upstream:** MobileSAM ("Faster Segment Anything: Towards Lightweight SAM for
93
- Mobile Applications", Zhang et al.; `ChaoningZhang/MobileSAM`) — a distilled
94
- TinyViT image encoder paired with the Segment Anything prompt-mask decoder,
95
- exported to ONNX (encoder + decoder). Redistributed here as an **FP16** build
96
- converted from the FP32 ONNX export (halves the download; INT8 dynamic
97
- quantization was evaluated and rejected — it collapsed the encoder to
98
- whole-frame masks).
99
- - **License:** Apache-2.0. Any upstream `NOTICE`/attribution terms (Apache-2.0
100
- §4(d)) are preserved by reference to that source.
101
- - Runs on-device via the same `onnxruntime-web` backend; the image never leaves the
102
- machine. Used by the Pro `ps_select_object` tool to turn a box/point prompt into a
103
- pixel-accurate organic selection. Ships in the **Pro module** (`models/pro/`), not
104
- the CE tarball.
75
+ ### Separately licensed modules
76
+
77
+ The Pro module is delivered as its own artifact and bundles additional third-party
78
+ models. Redistribution obligations follow the artifact that carries the files, so
79
+ those notices and their full license texts ship **inside the Pro module**, beside
80
+ its manifest, rather than being enumerated here.
105
81
 
106
82
  ---
107
83
 
package/README.md CHANGED
@@ -2,12 +2,10 @@
2
2
 
3
3
  **Unlock Photoshop with natural-language photo editing.** AI orchestration, not generation.
4
4
 
5
- (Pronounced like _edamame_. Yes, the snack.)
6
-
7
5
  > Independent project, not affiliated with or endorsed by Adobe Inc.
8
6
 
9
7
  [![npm version](https://img.shields.io/npm/v/editmamei.svg)](https://www.npmjs.com/package/editmamei)
10
- [![License](https://img.shields.io/badge/License-Proprietary-blue.svg)](https://editmamei.com/license)
8
+ [![License](https://img.shields.io/badge/License-FSL--1.1--MIT-blue.svg)](LICENSE.md)
11
9
  [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS-lightgrey.svg)]()
12
10
 
13
11
  Editmamei is a Model Context Protocol (MCP) server that drives the Adobe Photoshop you already have. You describe the edit in plain words, your AI assistant plans the steps, and your own copy of Photoshop carries them out with its standard adjustment layers, masks, selections, and filters. The AI directs and Photoshop edits. No generative model touches your pixels, and no image is uploaded to perform an edit.
@@ -108,6 +106,24 @@ These are the choices that shape the surface, and the reason an AI assistant can
108
106
 
109
107
  The split is detailed in [pro-features.md](https://github.com/editmamei/editmamei-wiki/blob/main/docs/pro-features.md). Pricing is at [editmamei.com](https://editmamei.com).
110
108
 
109
+ This repository holds the Community source. Pro is a separately licensed module and its source is not published here; Community never imports it, so this tree builds and runs on its own.
110
+
111
+ ## Build from source
112
+
113
+ ```bash
114
+ git clone https://github.com/editmamei/editmamei.git
115
+ cd editmamei
116
+ npm install
117
+ npm run build
118
+ npm test
119
+ ```
120
+
121
+ You need Node.js 20+ and a Go toolchain (the build compiles the `editmamei-core` binary that generates Photoshop scripts). The build warns instead of failing when Go is missing, so you can still run the test suite; set `EDITMAMEI_CORE_BIN` to a prebuilt binary if you would rather not install Go.
122
+
123
+ The test suite runs without Photoshop. It verifies the ExtendScript Editmamei generates, never that Photoshop accepted it, so live verification against a real Photoshop is a separate step.
124
+
125
+ Contributions are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md), which covers the CLA, the branch and pull-request flow, and what to include in a bug report.
126
+
111
127
  ## Configuration
112
128
 
113
129
  Settings live in `~/.editmamei/settings.json`, managed by the `editmamei config` CLI:
@@ -124,17 +140,33 @@ editmamei config set ps_path "/path/to/Photoshop"
124
140
 
125
141
  Editmamei runs on your computer and edits in your own Photoshop. No image content, document data, or file paths are sent to Editmamei's servers. It reports anonymous, content-free usage telemetry (on by default, turn it off with `editmamei config set telemetry.usage false`). When your AI assistant needs to see a result, Editmamei sends it a downscaled preview, the same as dropping a photo into a chat with that assistant. Every field is documented in [privacy.md](https://github.com/editmamei/editmamei-wiki/blob/main/docs/privacy.md).
126
142
 
143
+ The source in this repository is the same code published to npm, so none of the above has to be taken on trust.
144
+
127
145
  ## Docs and support
128
146
 
129
147
  - **Features and pricing:** [editmamei.com](https://editmamei.com)
130
148
  - **Install, getting started, FAQ, Pro features:** [the wiki](https://github.com/editmamei/editmamei-wiki)
131
149
  - **Bugs and feature requests:** [the issue tracker](https://github.com/editmamei/editmamei-wiki/issues). If something's broken, ask your assistant to "report a problem" (or run `editmamei report`) to drop an anonymized diagnostic bundle in your Downloads folder, then attach it to the issue.
132
- - **Security:** see [editmamei.com/security](https://editmamei.com/security) (don't file security issues publicly)
150
+ - **Security:** see [SECURITY.md](SECURITY.md) (don't file security issues publicly)
133
151
 
134
152
  ## License
135
153
 
136
- Proprietary, free to install and use. See [the license](https://editmamei.com/license). Third-party dependencies keep their own licenses, listed in the bundled `NOTICES.md`.
154
+ Editmamei CE is [Fair Source](https://fair.io) software under the
155
+ [Functional Source License, v1.1, MIT Future License](LICENSE.md) (FSL-1.1-MIT).
156
+
157
+ You can read, run, modify, and redistribute the code for almost anything: commercial photo
158
+ editing, internal tools, education, research, security review. The one thing the license
159
+ holds back, for two years per release, is offering the code to others in a commercial
160
+ product or service that competes with Editmamei CE or Pro. If you redistribute the code,
161
+ keep the license and copyright notices with it. Two years after each version ships, that
162
+ version automatically becomes available under plain [MIT](LICENSE.md).
163
+
164
+ This section is a plain-English summary. The [LICENSE](LICENSE.md) file is the license;
165
+ where they differ, the LICENSE file controls.
166
+
167
+ Editmamei is not open source under the OSI definition, and we don't call it that. It is
168
+ source-available, with the full CE stack developed in the open.
137
169
 
138
- ---
170
+ Editmamei Pro is a separate, commercially licensed module; its source is not published.
139
171
 
140
- _Pairs well with: a layered PSD, a willing AI, and a small bowl of edamame._
172
+ Third-party dependencies keep their own licenses, listed in [NOTICES.md](NOTICES.md).
@@ -182,7 +182,7 @@ export function duplicateForOp(opName, applyToActiveLayer) {
182
182
  `;
183
183
  }
184
184
  return `
185
- // Bundle O auto-duplicate-first: the destructive op below will act on
185
+ // Auto-duplicate-first: the destructive op below will act on
186
186
  // a fresh copy of the active layer so the original is preserved.
187
187
  // Undoing the op is as simple as deleting the copy.
188
188
  var __opOriginal = doc.activeLayer;
@@ -12,7 +12,7 @@ export class PhotoshopAPIFactory {
12
12
  throw new Error('Photoshop info not available — the local install could not be detected. ' +
13
13
  'Check that Photoshop is installed (set PHOTOSHOP_PATH to override detection).');
14
14
  }
15
- this.logger.debug(`Creating ExtendScript API for Photoshop version ${info.version}`);
15
+ this.logger.debug(`Photoshop ${info.version} ready for scripting`);
16
16
  return new ExtendScriptPhotoshopAPI(this.connection);
17
17
  }
18
18
  }
@@ -120,17 +120,26 @@ class ExtendScriptPhotoshopAPI {
120
120
  } catch (e) {}
121
121
  }
122
122
 
123
- if (typeof result === 'object' && result !== null) {
124
- return __mcpJsonEncode(result);
125
- }
126
- if (typeof result === 'string') {
127
- // Plain string return — wrap as a JSON string so the Node-side parser
128
- // sees valid JSON. Handlers that expect a string still receive one.
129
- return __mcpJsonEncode(result);
130
- }
131
- return String(result);
123
+ // Every outcome leaves inside an envelope, so the value a script returned
124
+ // is never confused with how it ended. A script that legitimately returns
125
+ // a string beginning with the failure marker used to come back as a thrown
126
+ // error; inside an envelope it round-trips untouched.
127
+ return __mcpJsonEncode({ __em: 1, ok: true, value: result });
132
128
  } catch (error) {
133
- return 'ERROR: ' + (error.message || String(error) || ('code ' + error.number) || 'no error message');
129
+ // Photoshop's error carries more than a message. Passing the number and
130
+ // line through means a failure can be identified rather than string-matched
131
+ // on prose that changes between releases and locales.
132
+ var __message = '';
133
+ try { __message = (error && error.message) ? String(error.message) : String(error); } catch (e) { __message = 'unreadable error'; }
134
+ var __number = null;
135
+ try { if (error && typeof error.number === 'number') __number = error.number; } catch (e) {}
136
+ var __line = null;
137
+ try { if (error && typeof error.line === 'number') __line = error.line; } catch (e) {}
138
+ return __mcpJsonEncode({
139
+ __em: 1,
140
+ ok: false,
141
+ error: { message: __message, number: __number, line: __line }
142
+ });
134
143
  } finally {
135
144
  try { if (__originalRulerUnits !== null) app.preferences.rulerUnits = __originalRulerUnits; } catch (e) {}
136
145
  try { if (__originalTypeUnits !== null) app.preferences.typeUnits = __originalTypeUnits; } catch (e) {}
@@ -139,7 +148,4 @@ class ExtendScriptPhotoshopAPI {
139
148
  })();
140
149
  `.trim();
141
150
  }
142
- getAPIType() {
143
- return 'ExtendScript';
144
- }
145
151
  }
Binary file
Binary file
@@ -268,13 +268,13 @@ export class EditmameiServer {
268
268
  }
269
269
  setupHandlers() {
270
270
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
271
- this.logger.debug('Listing available tools');
271
+ this.logger.debug('Reporting the registered tool surface');
272
272
  return {
273
273
  tools: this.toolRegistry.list(),
274
274
  };
275
275
  });
276
276
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
277
- this.logger.debug(`Tool called: ${request.params.name}`);
277
+ this.logger.debug(`Received a call for ${request.params.name}`);
278
278
  const args = request.params.arguments || {};
279
279
  return this.handleToolCall(request.params.name, args);
280
280
  });
@@ -314,7 +314,7 @@ export class EditmameiServer {
314
314
  if (!alive) {
315
315
  return {
316
316
  content: [
317
- { type: 'text', text: 'Failed to connect to Photoshop' + this.updateNote() },
317
+ { type: 'text', text: 'Photoshop did not respond' + this.updateNote() },
318
318
  ],
319
319
  structuredContent: { connected: false, update_available: this.updateInfo },
320
320
  };
@@ -330,7 +330,7 @@ export class EditmameiServer {
330
330
  this.logger.warn(`pingState snippet failed: ${err instanceof Error ? err.message : String(err)}`);
331
331
  return {
332
332
  content: [
333
- { type: 'text', text: 'Failed to connect to Photoshop' + this.updateNote() },
333
+ { type: 'text', text: 'Photoshop did not respond' + this.updateNote() },
334
334
  ],
335
335
  structuredContent: { connected: false, update_available: this.updateInfo },
336
336
  };
@@ -397,12 +397,12 @@ export class EditmameiServer {
397
397
  this.server.onclose = () => {
398
398
  void this.telemetry.shutdown();
399
399
  };
400
- this.logger.info('MCP Server connected via stdio');
400
+ this.logger.info('Editmamei is listening on stdio');
401
401
  }
402
402
  async stop() {
403
403
  await this.telemetry.shutdown();
404
404
  await this.session.disconnect();
405
405
  await this.sessionLog.close();
406
- this.logger.info('MCP Server stopped');
406
+ this.logger.info('Editmamei has shut down');
407
407
  }
408
408
  }
@@ -3,10 +3,10 @@ import { PhotoshopConnection } from '../platform/connection.js';
3
3
  import { generateSessionId } from '../utils/session-log.js';
4
4
  export class Session {
5
5
  logger;
6
- connection = null;
7
6
  config;
8
- lastActivity;
9
7
  sessionId;
8
+ connection = null;
9
+ lastActivity;
10
10
  constructor(config = {}) {
11
11
  this.logger = new Logger('Session');
12
12
  this.config = { autoConnect: true, ...config };
@@ -23,30 +23,29 @@ export class Session {
23
23
  return this.sessionId;
24
24
  }
25
25
  async initialize() {
26
- this.logger.info('Initializing session...');
26
+ this.logger.info('Starting session', this.sessionId);
27
27
  if (this.config.autoConnect) {
28
28
  await this.connect();
29
29
  }
30
30
  }
31
31
  async connect() {
32
32
  try {
33
- this.logger.info('Connecting to Photoshop...');
34
- const connected = await this.ensureConnection().ping();
35
- if (connected) {
33
+ const reached = await this.ensureConnection().ping();
34
+ if (reached) {
36
35
  this.updateActivity();
37
- this.logger.info('Successfully connected to Photoshop');
36
+ this.logger.info('Photoshop is reachable');
38
37
  return true;
39
38
  }
40
- this.logger.warn('Failed to connect to Photoshop');
39
+ this.logger.warn('Photoshop did not answer');
41
40
  return false;
42
41
  }
43
42
  catch (error) {
44
- this.logger.error('Connection error:', error);
43
+ this.logger.error('Could not reach Photoshop', error);
45
44
  return false;
46
45
  }
47
46
  }
48
47
  async disconnect() {
49
- this.logger.info('Disconnecting session...');
48
+ this.logger.info('Ending session', this.sessionId);
50
49
  }
51
50
  getConnection() {
52
51
  return this.ensureConnection();
@@ -10,10 +10,10 @@ export class ToolRegistry {
10
10
  }
11
11
  register(name, definition) {
12
12
  if (this.tools.has(name)) {
13
- this.logger.warn(`Tool '${name}' already registered, overwriting`);
13
+ this.logger.warn(`Replacing an already-registered tool: ${name}`);
14
14
  }
15
15
  this.tools.set(name, definition);
16
- this.logger.debug(`Registered tool: ${name}`);
16
+ this.logger.debug(`Registered ${name}`);
17
17
  }
18
18
  registerAll(definitions) {
19
19
  for (const def of definitions) {
@@ -35,9 +35,9 @@ export class ToolRegistry {
35
35
  async execute(name, args) {
36
36
  const definition = this.tools.get(name);
37
37
  if (!definition) {
38
- throw new Error(`Tool not found: ${name}`);
38
+ throw new Error(`No tool is registered under the name '${name}'`);
39
39
  }
40
- this.logger.debug(`Executing tool: ${name}`);
40
+ this.logger.debug(`Dispatching ${name}`);
41
41
  const started = Date.now();
42
42
  let success = true;
43
43
  let error;
@@ -77,6 +77,14 @@ export function readInstalledModule(sku, opts = {}) {
77
77
  return null;
78
78
  }
79
79
  }
80
+ function removeQuietly(path) {
81
+ try {
82
+ rmSync(path, { recursive: true, force: true });
83
+ }
84
+ catch (err) {
85
+ logger.warn(`could not remove superseded directory ${path}: ${err instanceof Error ? err.message : String(err)} (harmless; the current tree is unaffected)`);
86
+ }
87
+ }
80
88
  function writeFileAtomic(path, data) {
81
89
  const dir = dirname(path);
82
90
  mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -277,12 +285,12 @@ export function loadVerifiedModule(sku, opts = {}, pubKeys = MODULE_SIGNING_PUBL
277
285
  renameSync(dir, oldDir);
278
286
  try {
279
287
  renameSync(regenDir, dir);
280
- rmSync(oldDir, { recursive: true, force: true });
288
+ removeQuietly(oldDir);
281
289
  }
282
290
  catch (err) {
283
291
  rmSync(regenDir, { recursive: true, force: true });
284
292
  if (existsSync(dir)) {
285
- rmSync(oldDir, { recursive: true, force: true });
293
+ removeQuietly(oldDir);
286
294
  }
287
295
  else {
288
296
  renameSync(oldDir, dir);
@@ -18,7 +18,7 @@ export async function detectActiveDoc(connection, client, opts, deps = {}) {
18
18
  const tempPath = dir.path('detect.jpg');
19
19
  const script = `
20
20
  ${getContextInfo}
21
- if (app.documents.length === 0) { throw new Error('No active document'); }
21
+ if (app.documents.length === 0) { throw new Error('No document is open in Photoshop'); }
22
22
  for (var __pi = app.documents.length - 1; __pi >= 0; __pi--) {
23
23
  try {
24
24
  var __pd = app.documents[__pi];
@@ -51,8 +51,14 @@ export class ModuleLifecycle {
51
51
  }
52
52
  }
53
53
  if (EDITION === 'dev') {
54
+ const inTreeProSpecifier = '../modules/pro/index.js';
55
+ const inTreePresent = ['../modules/pro/index.js', '../modules/pro/index.ts'].some((rel) => existsSync(new URL(rel, import.meta.url)));
56
+ if (!inTreePresent) {
57
+ this._proModule = null;
58
+ return null;
59
+ }
54
60
  this._proModule = {
55
- importer: () => import('../modules/pro/index.js'),
61
+ importer: () => import(inTreeProSpecifier),
56
62
  binDir: dirname(resolveProBinaryPath()),
57
63
  abi: null,
58
64
  };
@@ -26,7 +26,7 @@ const PRECOMPUTE_TARGETS = [
26
26
  ];
27
27
  function deleteSceneChannelsScript() {
28
28
  return `
29
- if (app.documents.length === 0) { throw new Error('No active document'); }
29
+ if (app.documents.length === 0) { throw new Error('No document is open in Photoshop'); }
30
30
  var doc = app.activeDocument;
31
31
  var removed = 0;
32
32
  for (var i = doc.channels.length - 1; i >= 0; i--) {
@@ -63,7 +63,7 @@ function channelsExistScript(names) {
63
63
  const namesLit = `[${names.map((n) => jsLit(n)).join(', ')}]`;
64
64
  return `
65
65
  // __mcp_scene_chk__ cheap existence check for previously-saved scene:* channels
66
- if (app.documents.length === 0) { throw new Error('No active document'); }
66
+ if (app.documents.length === 0) { throw new Error('No document is open in Photoshop'); }
67
67
  var doc = app.activeDocument;
68
68
  var have = {};
69
69
  for (var i = 0; i < doc.channels.length; i++) {
@@ -92,7 +92,17 @@ async function channelsExist(connection, names) {
92
92
  }
93
93
  function loadNamedChannelScript(name) {
94
94
  return `
95
- if (app.documents.length === 0) { throw new Error('No active document'); }
95
+ // getSelectionInfo's own source begins with the restoreCompositeChannel
96
+ // definition (see _helpers.ts), so interpolating it here brings BOTH into
97
+ // scope. Interpolating restoreCompositeChannel separately as well emitted a
98
+ // second, byte-identical copy of that function body into every load script —
99
+ // legal (a later function declaration just wins) but pure waste in a string
100
+ // that crosses the COM/AppleScript boundary on every select_by_reference.
101
+ // Hoisted to the TOP so the restoreCompositeChannel(doc) call below sits
102
+ // after its definition textually, rather than relying on hoisting across an
103
+ // interpolation boundary.
104
+ ${getSelectionInfo}
105
+ if (app.documents.length === 0) { throw new Error('No document is open in Photoshop'); }
96
106
  var doc = app.activeDocument;
97
107
  var ch = null;
98
108
  for (var i = 0; i < doc.channels.length; i++) {
@@ -100,12 +110,10 @@ function loadNamedChannelScript(name) {
100
110
  }
101
111
  if (!ch) { return { loaded: false }; }
102
112
  doc.selection.load(ch, SelectionType.REPLACE);
103
- ${restoreCompositeChannel}
104
113
  restoreCompositeChannel(doc);
105
114
  // Measure in the SAME round trip. The fast path used to return
106
115
  // selection_info:null, which forced any caller that wanted to verify the mask
107
116
  // into a second PS call — the one thing this path exists to avoid.
108
- ${getSelectionInfo}
109
117
  return {
110
118
  loaded: true,
111
119
  width: doc.width.as('px'),
@@ -134,7 +134,7 @@ function thresholdSkyScript(level, seedX, seedY) {
134
134
  ${getSelectionInfo}
135
135
  function cTID(s){ return app.charIDToTypeID(s); }
136
136
  function sTID(s){ return app.stringIDToTypeID(s); }
137
- if (app.documents.length === 0) { throw new Error('No active document'); }
137
+ if (app.documents.length === 0) { throw new Error('No document is open in Photoshop'); }
138
138
  var doc = app.activeDocument;
139
139
  var __mk = new ActionDescriptor();
140
140
  var __ref = new ActionReference();
@@ -170,7 +170,7 @@ function posterizeRegionScript(seedX, seedY) {
170
170
  ${getSelectionInfo}
171
171
  function cTID(s){ return app.charIDToTypeID(s); }
172
172
  function sTID(s){ return app.stringIDToTypeID(s); }
173
- if (app.documents.length === 0) { throw new Error('No active document'); }
173
+ if (app.documents.length === 0) { throw new Error('No document is open in Photoshop'); }
174
174
  var doc = app.activeDocument;
175
175
  var __mk = new ActionDescriptor();
176
176
  var __ref = new ActionReference();
@@ -86,7 +86,7 @@ function buildLoadScript(maskPath, docW, docH, selType) {
86
86
  return app.executeActionGet(r).hasKey(app.stringIDToTypeID('selection'));
87
87
  } catch (eSe) { return false; }
88
88
  }
89
- if (app.documents.length === 0) { throw new Error('No active document'); }
89
+ if (app.documents.length === 0) { throw new Error('No document is open in Photoshop'); }
90
90
  var orig = app.activeDocument;
91
91
  var combine = '${selType}';
92
92
 
@@ -1,8 +1,5 @@
1
- import { platform } from 'os';
2
1
  import { Logger } from '../utils/logger.js';
3
- import { PhotoshopDetector } from './detector.js';
4
- import { WindowsExecutor } from './windows-executor.js';
5
- import { MacOSExecutor } from './macos-executor.js';
2
+ import { resolveHostPlatform } from './host-platform.js';
6
3
  export function truncateForLog(s, maxLen) {
7
4
  if (s.length <= maxLen)
8
5
  return s;
@@ -11,37 +8,24 @@ export function truncateForLog(s, maxLen) {
11
8
  export const RUNNING_LATCH_TTL_MS = 30_000;
12
9
  export class PhotoshopConnection {
13
10
  logger;
14
- detector;
15
- executor;
11
+ host;
12
+ now;
16
13
  photoshopInfo = null;
17
14
  detectInFlight = null;
18
- macosExecutor;
19
- now;
20
15
  runningLatchUntil = 0;
21
16
  constructor(opts = {}) {
22
17
  this.logger = new Logger('PhotoshopConnection');
23
- this.detector = new PhotoshopDetector();
24
18
  this.now = opts.now ?? Date.now;
25
- const platformType = platform();
26
- if (platformType === 'win32') {
27
- this.executor = new WindowsExecutor();
28
- }
29
- else if (platformType === 'darwin') {
30
- this.macosExecutor = new MacOSExecutor();
31
- this.executor = this.macosExecutor;
32
- }
33
- else {
34
- throw new Error(`Unsupported platform: ${platformType}`);
35
- }
19
+ this.host = opts.host ?? resolveHostPlatform();
36
20
  }
37
21
  async ping() {
38
22
  try {
39
- this.logger.debug('Pinging Photoshop...');
23
+ this.logger.debug('Pinging Photoshop');
40
24
  const result = await this.executeScript("'pong';");
41
25
  return typeof result === 'string' && result.trim() === 'pong';
42
26
  }
43
27
  catch (error) {
44
- this.logger.error('Ping failed:', error);
28
+ this.logger.error('Ping failed', error);
45
29
  return false;
46
30
  }
47
31
  }
@@ -49,11 +33,13 @@ export class PhotoshopConnection {
49
33
  if (this.photoshopInfo)
50
34
  return this.photoshopInfo;
51
35
  if (!this.detectInFlight) {
52
- this.detectInFlight = this.detector
36
+ this.detectInFlight = this.host.detector
53
37
  .detect()
54
38
  .then((info) => {
55
- if (info)
39
+ if (info) {
40
+ this.host.adapter.useInstall?.(info);
56
41
  this.photoshopInfo = info;
42
+ }
57
43
  return info;
58
44
  })
59
45
  .finally(() => {
@@ -68,7 +54,7 @@ export class PhotoshopConnection {
68
54
  return this.photoshopInfo?.version || 'Unknown';
69
55
  }
70
56
  catch (error) {
71
- this.logger.error('Failed to get version:', error);
57
+ this.logger.error('Failed to read the Photoshop version', error);
72
58
  throw error;
73
59
  }
74
60
  }
@@ -79,37 +65,37 @@ export class PhotoshopConnection {
79
65
  throw new Error('Photoshop info not available — the local install could not be detected. ' +
80
66
  'Check that Photoshop is installed (set PHOTOSHOP_PATH to override detection).');
81
67
  }
82
- if (this.macosExecutor && this.photoshopInfo.appName) {
83
- this.macosExecutor.setAppName(this.photoshopInfo.appName);
84
- }
85
- if (this.now() >= this.runningLatchUntil) {
86
- const isRunning = await this.executor.isPhotoshopRunning();
87
- if (!isRunning) {
88
- this.logger.info('Photoshop not running, launching...');
89
- await this.executor.launchPhotoshop(this.photoshopInfo.path);
90
- }
91
- else {
92
- this.runningLatchUntil = this.now() + RUNNING_LATCH_TTL_MS;
93
- }
94
- }
68
+ await this.ensureRunning(this.photoshopInfo);
95
69
  if (this.logger.isDebugEnabled()) {
96
- this.logger.debug(`Executing script (${script.length} chars, PS ${this.photoshopInfo.version}, ${platform()}): ${truncateForLog(script, 200)}`);
70
+ this.logger.debug(`Executing script (${script.length} chars, PS ${this.photoshopInfo.version}, ${this.host.os}): ${truncateForLog(script, 200)}`);
97
71
  }
98
- const result = await this.executor.execute(script, timeout);
72
+ const result = await this.host.adapter.run(script, timeout);
99
73
  this.runningLatchUntil = this.now() + RUNNING_LATCH_TTL_MS;
100
74
  return result;
101
75
  }
102
76
  catch (error) {
103
77
  this.runningLatchUntil = 0;
104
- const psVersion = this.photoshopInfo?.version ?? 'unknown';
105
- const head = truncateForLog(script, 200);
106
- this.logger.error(`Script execution failed (PS ${psVersion}, ${platform()}, ${script.length} chars):`, error, `\n--- script head (200 chars) ---\n${head}`);
107
- if (process.env.LOG_SCRIPT_ON_ERROR === '1') {
108
- this.logger.error(`--- full failing script ---\n${script}\n--- end script ---`);
109
- }
78
+ this.logFailure(script, error);
110
79
  throw error;
111
80
  }
112
81
  }
82
+ async ensureRunning(info) {
83
+ if (this.now() < this.runningLatchUntil)
84
+ return;
85
+ if (await this.host.adapter.isRunning()) {
86
+ this.runningLatchUntil = this.now() + RUNNING_LATCH_TTL_MS;
87
+ return;
88
+ }
89
+ this.logger.info('Photoshop is not running; launching it');
90
+ await this.host.adapter.launch(info.path);
91
+ }
92
+ logFailure(script, error) {
93
+ const psVersion = this.photoshopInfo?.version ?? 'unknown';
94
+ this.logger.error(`Script execution failed (PS ${psVersion}, ${this.host.os}, ${script.length} chars)`, error, `\n--- script head (200 chars) ---\n${truncateForLog(script, 200)}`);
95
+ if (process.env.LOG_SCRIPT_ON_ERROR === '1') {
96
+ this.logger.error(`--- full failing script ---\n${script}\n--- end script ---`);
97
+ }
98
+ }
113
99
  getPhotoshopInfo() {
114
100
  return this.photoshopInfo;
115
101
  }
@@ -0,0 +1,16 @@
1
+ import { platform } from 'os';
2
+ import { MacOSDetector } from './macos-detector.js';
3
+ import { MacOSScriptRunner } from './macos-runner.js';
4
+ import { WindowsDetector } from './windows-detector.js';
5
+ import { WindowsScriptRunner } from './windows-runner.js';
6
+ export function resolveHostPlatform() {
7
+ const os = platform();
8
+ if (os === 'win32') {
9
+ return { os, adapter: new WindowsScriptRunner(), detector: new WindowsDetector() };
10
+ }
11
+ if (os === 'darwin') {
12
+ return { os, adapter: new MacOSScriptRunner(), detector: new MacOSDetector() };
13
+ }
14
+ throw new Error(`Editmamei runs on Windows and macOS; this process is on "${os}". ` +
15
+ 'Adobe ships no Linux build of Photoshop, so there is nothing here to drive.');
16
+ }