editmamei 1.2.1 → 1.4.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 (41) hide show
  1. package/README.md +6 -5
  2. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  3. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  4. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  5. package/dist/cli/repair.js +11 -3
  6. package/dist/core/server.js +3 -3
  7. package/dist/core/tool-groups.js +3 -3
  8. package/dist/core/tool-registry.js +3 -1
  9. package/dist/core/tool-tiers.js +3 -3
  10. package/dist/delivery/provision.js +19 -1
  11. package/dist/kernel/kernel.js +3 -1
  12. package/dist/kernel/module-lifecycle.js +24 -4
  13. package/dist/modules/ce/index.js +8 -1
  14. package/dist/perception/facets.js +4 -3
  15. package/dist/perception/grounding-locate.js +7 -5
  16. package/dist/perception/region-scorer.js +1 -1
  17. package/dist/perception/scene-model.js +1 -1
  18. package/dist/perception/select-recipes.js +14 -3
  19. package/dist/platform/launch-readiness.js +25 -0
  20. package/dist/platform/macos-runner.js +18 -8
  21. package/dist/platform/script-queue.js +5 -19
  22. package/dist/platform/windows-runner.js +18 -8
  23. package/dist/skills/editmamei-skill.zip +0 -0
  24. package/dist/telemetry/client.js +11 -4
  25. package/dist/telemetry/events.js +5 -2
  26. package/dist/tools/detection-tools.js +4 -4
  27. package/dist/tools/document-tools.js +0 -1
  28. package/dist/tools/image-tools.js +2 -1
  29. package/dist/tools/layer-transform-tools.js +2 -1
  30. package/dist/tools/path-tools.js +1 -1
  31. package/dist/tools/preview-tools.js +1 -1
  32. package/dist/tools/scene-tools.js +8 -6
  33. package/dist/tools/selection-tools.js +156 -110
  34. package/dist/tools/sequence-tools.js +435 -0
  35. package/dist/tools/shape-tools.js +1 -1
  36. package/dist/utils/operation-timeouts.js +92 -0
  37. package/dist/utils/run-script.js +25 -1
  38. package/dist/utils/session-log.js +15 -1
  39. package/dist/utils/tool-budget-context.js +14 -0
  40. package/dist/version.js +1 -1
  41. package/package.json +8 -2
package/README.md CHANGED
@@ -47,7 +47,7 @@ Then restart your AI client and ask it: _"Is Photoshop connected?"_
47
47
 
48
48
  Prefer not to use a terminal? Claude Desktop users can install the one-click [`.mcpb` extension](https://github.com/editmamei/editmamei/releases/latest/download/editmamei.mcpb) directly (no system Node required, Claude Desktop ships its own runtime). Full walkthrough in the [install guide](docs/installation.md).
49
49
 
50
- **Requirements:** Adobe Photoshop 2026 (v27.x), Windows 10/11 or macOS 12+, Node.js 22+ (for the npm path), and an MCP-compatible AI client (Claude Desktop, Cursor, Claude Code).
50
+ **Requirements:** Adobe Photoshop 2026 (v27.x), Windows 10/11 or macOS 13+, Node.js 22+ (for the npm path), and an MCP-compatible AI client (Claude Desktop, Cursor, Claude Code).
51
51
 
52
52
  ## A session, end to end
53
53
 
@@ -75,14 +75,14 @@ These are the choices that shape the surface, and the reason an AI assistant can
75
75
 
76
76
  ## Tool surface
77
77
 
78
- **79 tools across 16 capability groups** (59 Community, 20 Pro). Every tool is namespaced `ps_*` and discoverable at runtime via `tools/list`. Community tools ship in both editions; Pro tools unlock with a license.
78
+ **82 tools across 16 capability groups** (62 Community, 20 Pro). Every tool is namespaced `ps_*` and discoverable at runtime via `tools/list`. Community tools ship in both editions; Pro tools unlock with a license.
79
79
 
80
80
  | Group | Edition | Tools |
81
81
  | --- | --- | --- |
82
82
  | **Core** | Community | `ping` · `overview` · `list_capabilities` · `undo` · `redo` · `report_problem` |
83
83
  | **Inspect** | Community | `inspect` · `get_preview` |
84
84
  | **Verify** | Community | `get_histogram` · `compare_regions` · `get_layer_bounds_diff` · `get_selection_preview` |
85
- | **Document & canvas** | Community | `create_document` · `open_document` · `close_document` · `save_psd` · `export` · `place_image` · `resize_image` · `crop_document` · `convert_image_mode` · `transform_canvas` · `guides` |
85
+ | **Document & canvas** | Community | `document` · `create_document` · `open_document` · `close_document` · `save_psd` · `export` · `place_image` · `resize_image` · `crop_document` · `convert_image_mode` · `transform_canvas` · `guides` |
86
86
  | **Selection** | Community | `select` · `modify_selection` · `selection_channel` |
87
87
  | **Adjustments** | Community | `add_adjustment_layer` · `apply_adjustment` |
88
88
  | **Filters** | Community | `filter` |
@@ -91,7 +91,8 @@ These are the choices that shape the surface, and the reason an AI assistant can
91
91
  | **Masks & paths** | Community | `layer_mask` · `clipping_mask` · `path` · `vector_mask` · `apply_image` · `calculations` |
92
92
  | **Type** | Community | `text` |
93
93
  | **Perception** | Community | `detect` · `read_scene` · `select_by_reference` |
94
- | **AI selection** | Community | `select_subject` · `select_sky` |
94
+ | **AI selection** | Community | `select_subject` · `select_sky` · `replace_sky` |
95
+ | **Automation** | Community | `sequence` |
95
96
  | **AI selection** | Pro | `select_subject_instance` · `select_object` |
96
97
  | **Filters** | Pro | `apply_camera_raw` |
97
98
  | **Layers (warp)** | Pro | `warp_layer` |
@@ -119,7 +120,7 @@ npm run build
119
120
  npm test
120
121
  ```
121
122
 
122
- You need Node.js 22+ 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.
123
+ You need Node.js 22+ and Go 1.21+ (the build compiles the `editmamei-core` binary that generates Photoshop scripts; it needs Go 1.27, which Go fetches for you from 1.21 on). 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.
123
124
 
124
125
  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.
125
126
 
Binary file
Binary file
@@ -26,11 +26,19 @@ export async function runRepair(opts = {}) {
26
26
  out(` Installed ${m.sku} module v${m.version}.\n`);
27
27
  for (const s of prov.skipped)
28
28
  out(` Skipped ${s.sku} v${s.version}: ${s.reason}.\n`);
29
- for (const e of prov.errors) {
29
+ const tooNew = prov.errors.filter((e) => e.code === 'abi_too_new');
30
+ const failures = prov.errors.filter((e) => e.code !== 'abi_too_new');
31
+ for (const e of tooNew) {
32
+ out(` The published ${e.sku} module needs a newer Editmamei than this one — ` +
33
+ `update Editmamei to load it. Your installed module is unchanged.\n`);
34
+ }
35
+ for (const e of failures) {
30
36
  err(` Error: could not provision the ${e.sku} module: ${e.message}\n`);
31
37
  }
32
- if (prov.errors.length > 0) {
38
+ if (failures.length > 0) {
33
39
  throw new Error('module re-provisioning failed');
34
40
  }
35
- out('\nRestart your MCP client (Claude Desktop / Claude Code) to load Pro tools.\n');
41
+ if (prov.installed.length > 0 || tooNew.length === 0) {
42
+ out('\nRestart your MCP client (Claude Desktop / Claude Code) to load Pro tools.\n');
43
+ }
36
44
  }
@@ -8,7 +8,7 @@ import { groupOf, GROUPS } from './tool-groups.js';
8
8
  import { EDITION } from '../edition.js';
9
9
  import { VERSION } from '../version.js';
10
10
  import { Session } from './session.js';
11
- import { SessionLog, classifyError } from '../utils/session-log.js';
11
+ import { SessionLog, classifyError, NO_ERROR_TEXT_CLASS } from '../utils/session-log.js';
12
12
  import { loadSettings, applyTelemetryEnvOverrides, applyUpdateCheckEnvOverride, } from './settings.js';
13
13
  import { TelemetryClient } from '../telemetry/client.js';
14
14
  import { resolveInstallChannel } from '../install-channel.js';
@@ -107,7 +107,7 @@ export class EditmameiServer {
107
107
  const telemetrySuccess = entry.tool === 'ps_ping' && this.lastPingReachedPs === false ? false : entry.success;
108
108
  const isPingDowngrade = entry.tool === 'ps_ping' && !telemetrySuccess && entry.error === undefined;
109
109
  const errorClass = classifyError(entry.error) ??
110
- (telemetrySuccess ? null : isPingDowngrade ? 'ps_not_running' : 'other');
110
+ (telemetrySuccess ? null : isPingDowngrade ? 'ps_not_running' : NO_ERROR_TEXT_CLASS);
111
111
  this.telemetry.recordCall({
112
112
  tool: entry.tool,
113
113
  success: telemetrySuccess,
@@ -117,7 +117,7 @@ export class EditmameiServer {
117
117
  if (!telemetrySuccess) {
118
118
  this.telemetry.recordDiagnostic({
119
119
  tool: entry.tool,
120
- error_class: errorClass ?? 'other',
120
+ error_class: errorClass ?? NO_ERROR_TEXT_CLASS,
121
121
  error_message: entry.error ||
122
122
  (isPingDowngrade
123
123
  ? 'ps_ping did not reach Photoshop'
@@ -76,8 +76,8 @@ export const GROUPS = {
76
76
  },
77
77
  automation: {
78
78
  id: 'automation',
79
- label: 'Automation (Pro)',
80
- purpose: 'Batch files, play recorded Actions, and the ExtendScript escape hatch (Pro).',
79
+ label: 'Automation',
80
+ purpose: 'Run several tool calls in one round trip (community), plus batch files, recorded Actions, and the ExtendScript escape hatch (Pro).',
81
81
  },
82
82
  };
83
83
  export const TOOL_GROUPS = {
@@ -112,7 +112,6 @@ export const TOOL_GROUPS = {
112
112
  ps_select_sky: 'select_ai',
113
113
  ps_select_subject_instance: 'select_ai',
114
114
  ps_select_object: 'select_ai',
115
- ps_select_focus_area: 'select_ai',
116
115
  ps_replace_sky: 'select_ai',
117
116
  ps_add_adjustment_layer: 'adjust',
118
117
  ps_apply_adjustment: 'adjust',
@@ -166,6 +165,7 @@ export const TOOL_GROUPS = {
166
165
  ps_template_verify: 'templates',
167
166
  ps_template_recall: 'templates',
168
167
  ps_template_delete: 'templates',
168
+ ps_sequence: 'automation',
169
169
  ps_list_actions: 'automation',
170
170
  ps_play_action: 'automation',
171
171
  ps_execute_script: 'automation',
@@ -1,4 +1,6 @@
1
1
  import { Logger } from '../utils/logger.js';
2
+ import { getToolTimeoutMs } from '../utils/operation-timeouts.js';
3
+ import { budgetContextFor, runWithToolBudget } from '../utils/tool-budget-context.js';
2
4
  export class ToolRegistry {
3
5
  logger;
4
6
  tools;
@@ -46,7 +48,7 @@ export class ToolRegistry {
46
48
  let error;
47
49
  let result;
48
50
  try {
49
- result = await definition.handler(args);
51
+ result = await runWithToolBudget(budgetContextFor(name, getToolTimeoutMs(name)), () => definition.handler(args));
50
52
  if (result?.isError) {
51
53
  success = false;
52
54
  const firstText = result.content?.find((c) => c.type === 'text');
@@ -6,9 +6,10 @@ export const TOOL_TIERS = {
6
6
  ps_play_action: 'pro',
7
7
  ps_execute_script: 'pro',
8
8
  ps_batch: 'pro',
9
+ ps_sequence: 'community',
9
10
  ps_add_adjustment_layer: 'community',
10
11
  ps_apply_adjustment: 'community',
11
- ps_document: 'dev',
12
+ ps_document: 'community',
12
13
  ps_create_document: 'community',
13
14
  ps_close_document: 'community',
14
15
  ps_open_document: 'community',
@@ -68,8 +69,7 @@ export const TOOL_TIERS = {
68
69
  ps_select_sky: 'community',
69
70
  ps_select_subject_instance: 'pro',
70
71
  ps_select_object: 'pro',
71
- ps_select_focus_area: 'dev',
72
- ps_replace_sky: 'dev',
72
+ ps_replace_sky: 'community',
73
73
  ps_modify_selection: 'community',
74
74
  ps_get_selection_preview: 'community',
75
75
  ps_selection_channel: 'community',
@@ -2,6 +2,7 @@ import { DeliveryClient, DeliveryError } from './client.js';
2
2
  import { sha256Hex } from './crypto.js';
3
3
  import { verifyModuleSignature, verifyModuleSignatureV2, digestsRootSha256Hex, isModuleFileDigestArray, } from './signing.js';
4
4
  import { installModule, readInstalledModule } from './store.js';
5
+ import { KERNEL_ABI } from '../kernel/host-api.js';
5
6
  import { Logger } from '../utils/logger.js';
6
7
  const logger = new Logger('Modules');
7
8
  const SKU_RE = /^[a-z0-9-]{2,32}$/;
@@ -52,6 +53,23 @@ export async function provisionModules(key, opts = {}) {
52
53
  });
53
54
  continue;
54
55
  }
56
+ const abi = entry.abi;
57
+ if (!Number.isInteger(abi) || abi < 1) {
58
+ result.errors.push({
59
+ sku,
60
+ code: 'abi_invalid',
61
+ message: `the manifest entry for ${sku} declares an invalid abi '${String(abi)}' (expected a whole number of at least 1) — refusing to install it`,
62
+ });
63
+ continue;
64
+ }
65
+ if (abi > KERNEL_ABI) {
66
+ result.errors.push({
67
+ sku,
68
+ code: 'abi_too_new',
69
+ message: `the published ${sku} module needs a newer Editmamei than this one (module abi ${abi}, this host runs up to abi ${KERNEL_ABI}) — update Editmamei; the installed module is left as it is`,
70
+ });
71
+ continue;
72
+ }
55
73
  const installed = readInstalledModule(sku, opts);
56
74
  if (installed && installed.version === latest && !opts.force) {
57
75
  result.skipped.push({ sku, version: latest, reason: 'up-to-date' });
@@ -119,7 +137,7 @@ export async function provisionModules(key, opts = {}) {
119
137
  installModule({
120
138
  sku,
121
139
  version: latest,
122
- abi: entry.abi,
140
+ abi,
123
141
  sha256: digest,
124
142
  alg: contentKey.alg,
125
143
  content_key: contentKey.key,
@@ -35,6 +35,7 @@ export class Kernel {
35
35
  abi: KERNEL_ABI,
36
36
  registerTools: (defs) => this.registry.registerAll(defs),
37
37
  invokeTool: (name, args) => this.invokeTool(name, args),
38
+ hasTool: (name) => this.registry.get(name) !== undefined,
38
39
  connection: this.connection,
39
40
  executeScript: (innerBody, timeoutMs) => runScript(this.connection, innerBody, timeoutMs),
40
41
  snippet: this.snippetFor(manifest),
@@ -73,9 +74,10 @@ export class Kernel {
73
74
  throw new Error('downloaded module export is not an EditmameiModule (expected a default export with a manifest + register())');
74
75
  }
75
76
  if (this.abiTooNew(mod.manifest))
76
- return;
77
+ return 'abi-too-new';
77
78
  const before = this.registry.count();
78
79
  await mod.register(this.hostApiFor(mod.manifest));
79
80
  this.logger.info(`Loaded downloaded module '${mod.manifest.id}' (${mod.manifest.name}) — ${this.registry.count() - before} tools.`);
81
+ return 'loaded';
80
82
  }
81
83
  }
@@ -7,7 +7,7 @@ import { isProEntitled } from '../license/entitlement.js';
7
7
  import { loadVerifiedModule, readInstalledModule, installedPath, PRO_SKU, } from '../delivery/store.js';
8
8
  import { provisionModules, compareVersions, VERSION_RE, } from '../delivery/provision.js';
9
9
  import { readLicense } from '../license/store.js';
10
- import { HOST_MIN_ABI } from './host-api.js';
10
+ import { HOST_MIN_ABI, KERNEL_ABI } from './host-api.js';
11
11
  import { VERSION } from '../version.js';
12
12
  export function classifyModuleOutcome(inputs) {
13
13
  if (inputs.proModuleLoaded && inputs.skipReason === null)
@@ -79,9 +79,21 @@ export class ModuleLifecycle {
79
79
  this._moduleSkipReason = 'incompatible';
80
80
  return;
81
81
  }
82
+ if (this._proModule.abi !== null && this._proModule.abi > KERNEL_ABI) {
83
+ this.deps.logger.warn(`Pro module (abi ${this._proModule.abi}) needs a newer host than this build ` +
84
+ `(kernel abi ${KERNEL_ABI}) — booting Community; update Editmamei to load it.`);
85
+ this._moduleSkipReason = 'incompatible';
86
+ return;
87
+ }
82
88
  const snapshot = this.deps.toolRegistry.snapshot();
83
89
  try {
84
- await this.kernel.loadDownloaded(this._proModule.importer);
90
+ const outcome = await this.kernel.loadDownloaded(this._proModule.importer);
91
+ if (outcome === 'abi-too-new') {
92
+ this.deps.logger.warn(`The installed Pro module needs a newer host than this build (kernel abi ` +
93
+ `${KERNEL_ABI}) — booting Community; update Editmamei to load it.`);
94
+ this._moduleSkipReason = 'incompatible';
95
+ return;
96
+ }
85
97
  if (this._proModule.version !== null &&
86
98
  VERSION_RE.test(this._proModule.version) &&
87
99
  compareVersions(this._proModule.version, VERSION) > 0) {
@@ -159,11 +171,14 @@ export class ModuleLifecycle {
159
171
  this.deps.logger.warn('Module delivery is not configured — staying Community.');
160
172
  return;
161
173
  }
174
+ const abiTooNew = prov.errors.some((e) => e.code === 'abi_too_new');
162
175
  for (const e of prov.errors) {
176
+ if (e.code === 'abi_too_new')
177
+ continue;
163
178
  this.deps.logger.warn(`Could not re-provision the ${e.sku} module (staying Community): ${e.message}`);
164
179
  }
165
- if (prov.errors.length === 0) {
166
- if (reason === 'incompatible') {
180
+ if (prov.errors.length === 0 || abiTooNew) {
181
+ if (reason === 'incompatible' || abiTooNew) {
167
182
  this.deps.logger.warn('The published Pro module does not yet support this host version — staying ' +
168
183
  'Community. Update Editmamei when a compatible release ships; ' +
169
184
  '`editmamei report` files a diagnostic.');
@@ -201,6 +216,11 @@ export class ModuleLifecycle {
201
216
  this.deps.logger.info(`Pro module updated to v${m.version} — restart to load.`);
202
217
  }
203
218
  for (const e of prov.errors) {
219
+ if (e.code === 'abi_too_new') {
220
+ this.deps.logger.info(`A newer Pro module is published but needs a newer Editmamei than this ` +
221
+ `one — staying on the installed version, which works. Update Editmamei to get it.`);
222
+ continue;
223
+ }
204
224
  this.deps.logger.warn(`Pro-module freshness check could not provision the ${e.sku} module ` +
205
225
  `(staying on the installed version): ${e.message}`);
206
226
  }
@@ -30,7 +30,8 @@ import { createVectorMaskTools } from '../../tools/vector-mask-tools.js';
30
30
  import { createChannelComposeTools } from '../../tools/channel-compose-tools.js';
31
31
  import { createShapeTools } from '../../tools/shape-tools.js';
32
32
  import { createSkyTools } from '../../tools/sky-tools.js';
33
- const ceFactories = [
33
+ import { createSequenceTools } from '../../tools/sequence-tools.js';
34
+ export const ceFactories = [
34
35
  createDocumentTools,
35
36
  createLayerTools,
36
37
  createGroupTools,
@@ -67,6 +68,12 @@ export const ceModule = {
67
68
  const defs = [
68
69
  ...ceFactories.flatMap((f) => f(connection, snippet)),
69
70
  ...createSceneTools(connection, snippet, { invokeTool: host.invokeTool }),
71
+ ...createSequenceTools(host.invokeTool, (name) => {
72
+ if (!host.hasTool) {
73
+ throw new Error(`cannot validate "${name}" — this host does not expose hasTool`);
74
+ }
75
+ return host.hasTool(name);
76
+ }),
70
77
  ].filter((def) => isToolAllowedInEdition(def.tool.name, EDITION));
71
78
  host.registerTools(defs);
72
79
  },
@@ -41,7 +41,7 @@ export function estimateHorizon(rowMeans, docHeight, pick, mainSubjectBox, prima
41
41
  const split = pick.bright_fraction;
42
42
  const splitConfidence = Math.max(0, Math.min(1, 1 - Math.abs(0.5 - split) * 2)) * 0.6 + 0.2;
43
43
  if (!rowMeans || rowMeans.length === 0) {
44
- return { y: Math.round(docHeight / 3), placement: 1 / 3, confidence: 0.2 };
44
+ return { detected: false, reason: 'no-row-profile' };
45
45
  }
46
46
  const R = rowMeans.length;
47
47
  let crossRow = -1;
@@ -52,14 +52,15 @@ export function estimateHorizon(rowMeans, docHeight, pick, mainSubjectBox, prima
52
52
  }
53
53
  }
54
54
  if (crossRow <= 0) {
55
- return { y: Math.round(docHeight / 3), placement: 1 / 3, confidence: 0.2 };
55
+ return { detected: false, reason: 'no-luminance-crossing' };
56
56
  }
57
57
  const y = Math.round((crossRow / R) * docHeight);
58
58
  if ((mainSubjectBox && isDominantSubjectCrossing(y, docHeight, mainSubjectBox)) ||
59
59
  (primaryFaceBox && isPortraitFaceCrossing(y, docHeight, primaryFaceBox))) {
60
- return { y: Math.round(docHeight / 3), placement: 1 / 3, confidence: 0.2 };
60
+ return { detected: false, reason: 'dominant-subject' };
61
61
  }
62
62
  return {
63
+ detected: true,
63
64
  y,
64
65
  placement: y / docHeight,
65
66
  confidence: Math.max(0, Math.min(1, splitConfidence)),
@@ -254,17 +254,19 @@ export const PLACEMENT_SCHEMA = {
254
254
  type: 'object',
255
255
  description: 'ANCHOR-RELATIONAL placement (preferred over guessing pixels): name anchors + a relation and the ' +
256
256
  'spatial-grounding resolver computes the geometry, verified by an objective gate; the action runs ONLY if the ' +
257
- 'gate PASSES. For a HARD or ambiguous placement, first concur on ps_resolve_placement (review_crop: true) — a ' +
258
- 'read-only zoomed crop with a marker at the resolved spot — then pass the SAME anchors + relation here. See ' +
259
- 'ps_resolve_placement for the full anchors + relation vocabulary.',
257
+ 'gate PASSES. For a HARD or ambiguous placement, first concur with the placement-resolver tool — when this ' +
258
+ 'build has one, it appears in tools/list — by calling it with review_crop: true for a read-only zoomed crop ' +
259
+ 'with a marker at the resolved spot, then pass the SAME anchors + relation here. The full anchors + relation ' +
260
+ 'vocabulary is documented on that tool.',
260
261
  properties: {
261
262
  anchors: {
262
263
  type: 'array',
263
- description: 'Named anchors, same vocabulary as ps_resolve_placement (face/object/grid/extremum/corner/edge/landmark).',
264
+ description: 'Named anchors, same vocabulary as the placement-resolver tool, when this build has one ' +
265
+ '(face/object/grid/extremum/corner/edge/landmark).',
264
266
  },
265
267
  relation: {
266
268
  type: 'object',
267
- description: 'The relation, same vocabulary as ps_resolve_placement.',
269
+ description: 'The relation, same vocabulary as the placement-resolver tool, when this build has one.',
268
270
  },
269
271
  max_dimension: {
270
272
  type: 'number',
@@ -49,7 +49,7 @@ export function buildRegionSignals(info, scene) {
49
49
  centroidY = (bounds.top + bounds.bottom) / 2 / docH;
50
50
  touchesBottom = bounds.bottom >= docH * 0.97;
51
51
  touchesTop = bounds.top <= docH * 0.03;
52
- if (scene.horizonConfidence > 0) {
52
+ if (scene.horizonConfidence > 0 && scene.horizonY !== null) {
53
53
  lowerEdgeAlign = 1 - Math.min(1, Math.abs(bounds.bottom - scene.horizonY) / docH);
54
54
  upperEdgeAlign = 1 - Math.min(1, Math.abs(bounds.top - scene.horizonY) / docH);
55
55
  }
@@ -142,7 +142,7 @@ export async function buildSceneModel(connection, snippet, client, opts = {}) {
142
142
  recipe: { kind: 'posterize_region', levels: 3, sample: 'below_horizon' },
143
143
  },
144
144
  ];
145
- const composition = computeComposition(mainBox, allBoxes, docW, docH, horizon.placement);
145
+ const composition = computeComposition(mainBox, allBoxes, docW, docH, horizon.detected ? horizon.placement : null);
146
146
  const backends = {
147
147
  faces: det.result.backends.faces,
148
148
  objects: det.result.backends.objects,
@@ -58,8 +58,8 @@ function sceneCtx(model) {
58
58
  return {
59
59
  docW: model.doc.width,
60
60
  docH: model.doc.height,
61
- horizonY: model.horizon.y,
62
- horizonConfidence: model.horizon.confidence,
61
+ horizonY: model.horizon.detected ? model.horizon.y : null,
62
+ horizonConfidence: model.horizon.detected ? model.horizon.confidence : 0,
63
63
  indoorObjectCount,
64
64
  };
65
65
  }
@@ -490,7 +490,18 @@ async function resolveFaceFeature(connection, snippet, target, feature, opts) {
490
490
  };
491
491
  }
492
492
  async function resolveAboveHorizon(connection, snippet, model, composition) {
493
- const y = Math.max(1, Math.min(model.doc.height, model.horizon.y));
493
+ const horizon = model.horizon;
494
+ if (!horizon.detected) {
495
+ return {
496
+ target: 'above_horizon',
497
+ method: 'none',
498
+ passed: false,
499
+ confidence: 0,
500
+ reasons: [`no horizon was measured in this frame (${horizon.reason})`],
501
+ detail: { horizon_detected: false, horizon_reason: horizon.reason },
502
+ };
503
+ }
504
+ const y = Math.max(1, Math.min(model.doc.height, horizon.y));
494
505
  const result = (await runScript(connection, await snippet.build('selectRectangle', {
495
506
  left: 0,
496
507
  top: 0,
@@ -0,0 +1,25 @@
1
+ const DEFAULT_POLL_INTERVAL_MS = 250;
2
+ export const LAUNCH_READY_MAX_WAIT_MS = 5_000;
3
+ function defaultSleep(ms) {
4
+ return new Promise((resolve) => {
5
+ setTimeout(resolve, ms).unref();
6
+ });
7
+ }
8
+ export async function waitForLaunchReady(isReady, options = {}) {
9
+ const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
10
+ const maxWaitMs = options.maxWaitMs ?? LAUNCH_READY_MAX_WAIT_MS;
11
+ const sleep = options.sleep ?? defaultSleep;
12
+ const isAborted = options.isAborted ?? (() => false);
13
+ const now = options.now ?? Date.now;
14
+ const deadline = now() + maxWaitMs;
15
+ while (now() < deadline) {
16
+ if (isAborted())
17
+ return false;
18
+ if (await isReady())
19
+ return true;
20
+ if (now() >= deadline)
21
+ break;
22
+ await sleep(intervalMs);
23
+ }
24
+ return false;
25
+ }
@@ -2,12 +2,14 @@ import { exec, spawn } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { Logger } from '../utils/logger.js';
4
4
  import { TempDir } from '../utils/temp.js';
5
+ import { DEFAULT_SCRIPT_TIMEOUT_MS } from '../utils/operation-timeouts.js';
5
6
  import { ScriptQueue } from './script-queue.js';
6
7
  import { runChildWithTimeout } from './run-child.js';
7
8
  import { decodeScriptResult } from './script-result.js';
9
+ import { waitForLaunchReady } from './launch-readiness.js';
8
10
  const execAsync = promisify(exec);
9
- const DEFAULT_SCRIPT_TIMEOUT_MS = 30_000;
10
- const LAUNCH_GRACE_MS = 5_000;
11
+ const LAUNCH_PROBE_TIMEOUT_MS = 2_000;
12
+ const IS_RUNNING_EXEC_TIMEOUT_MS = 3_000;
11
13
  const UNSAFE_IN_APPLESCRIPT_LITERAL = /["\\\r\n\t]/;
12
14
  export class MacOSScriptRunner {
13
15
  logger = new Logger('MacOSScriptRunner');
@@ -69,7 +71,9 @@ end timeout`;
69
71
  }
70
72
  async isRunning() {
71
73
  try {
72
- const { stdout } = await execAsync('pgrep -f "Adobe Photoshop"');
74
+ const { stdout } = await execAsync('pgrep -f "Adobe Photoshop"', {
75
+ timeout: IS_RUNNING_EXEC_TIMEOUT_MS,
76
+ });
73
77
  return stdout.trim().length > 0;
74
78
  }
75
79
  catch {
@@ -81,14 +85,20 @@ end timeout`;
81
85
  this.logger.info('Launching Photoshop', executablePath);
82
86
  const child = spawn('open', ['-a', executablePath], { detached: true, stdio: 'ignore' });
83
87
  child.unref();
84
- const grace = setTimeout(() => {
85
- grace.unref();
86
- resolve();
87
- }, LAUNCH_GRACE_MS);
88
+ let aborted = false;
88
89
  child.on('error', (error) => {
89
- clearTimeout(grace);
90
+ aborted = true;
90
91
  reject(new Error(`Could not launch Photoshop at ${executablePath}: ${error.message}`));
91
92
  });
93
+ const probe = () => this.run("'pong';", LAUNCH_PROBE_TIMEOUT_MS)
94
+ .then(() => true)
95
+ .catch(() => false);
96
+ waitForLaunchReady(probe, { isAborted: () => aborted })
97
+ .catch(() => false)
98
+ .then(() => {
99
+ if (!aborted)
100
+ resolve();
101
+ });
92
102
  });
93
103
  }
94
104
  }
@@ -8,23 +8,7 @@ export class ScriptQueue {
8
8
  }
9
9
  enqueue(run, timeout) {
10
10
  return new Promise((resolve, reject) => {
11
- const task = {
12
- run,
13
- resolve,
14
- reject,
15
- cancelled: false,
16
- settled: false,
17
- timeoutId: null,
18
- };
19
- task.timeoutId = setTimeout(() => {
20
- if (task.settled)
21
- return;
22
- task.cancelled = true;
23
- task.settled = true;
24
- task.timeoutId = null;
25
- reject(new Error('Script execution timeout'));
26
- }, timeout + QUEUE_SLACK_MS);
27
- this.queue.push(task);
11
+ this.queue.push({ run, timeout, resolve, reject, settled: false, timeoutId: null });
28
12
  void this.processQueue();
29
13
  });
30
14
  }
@@ -45,8 +29,10 @@ export class ScriptQueue {
45
29
  try {
46
30
  while (this.queue.length > 0) {
47
31
  const task = this.queue.shift();
48
- if (task.cancelled)
49
- continue;
32
+ task.timeoutId = setTimeout(() => {
33
+ this.settleTask(task, () => task.reject(new Error('Script execution timeout')));
34
+ }, task.timeout + QUEUE_SLACK_MS);
35
+ task.timeoutId.unref?.();
50
36
  try {
51
37
  const result = await task.run();
52
38
  this.settleTask(task, () => task.resolve(result));
@@ -2,12 +2,14 @@ import { exec, spawn } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { Logger } from '../utils/logger.js';
4
4
  import { TempDir } from '../utils/temp.js';
5
+ import { DEFAULT_SCRIPT_TIMEOUT_MS } from '../utils/operation-timeouts.js';
5
6
  import { ScriptQueue } from './script-queue.js';
6
7
  import { runChildWithTimeout } from './run-child.js';
7
8
  import { decodeScriptResult } from './script-result.js';
9
+ import { waitForLaunchReady } from './launch-readiness.js';
8
10
  const execAsync = promisify(exec);
9
- const DEFAULT_SCRIPT_TIMEOUT_MS = 30_000;
10
- const LAUNCH_GRACE_MS = 5_000;
11
+ const LAUNCH_PROBE_TIMEOUT_MS = 2_000;
12
+ const IS_RUNNING_EXEC_TIMEOUT_MS = 3_000;
11
13
  export class WindowsScriptRunner {
12
14
  logger = new Logger('WindowsScriptRunner');
13
15
  queue = new ScriptQueue(this.logger);
@@ -59,7 +61,9 @@ End If
59
61
  }
60
62
  async isRunning() {
61
63
  try {
62
- const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq Photoshop.exe"');
64
+ const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq Photoshop.exe"', {
65
+ timeout: IS_RUNNING_EXEC_TIMEOUT_MS,
66
+ });
63
67
  return stdout.toLowerCase().includes('photoshop.exe');
64
68
  }
65
69
  catch {
@@ -71,14 +75,20 @@ End If
71
75
  this.logger.info('Launching Photoshop', executablePath);
72
76
  const child = spawn(executablePath, [], { detached: true, stdio: 'ignore' });
73
77
  child.unref();
74
- const grace = setTimeout(() => {
75
- grace.unref();
76
- resolve();
77
- }, LAUNCH_GRACE_MS);
78
+ let aborted = false;
78
79
  child.on('error', (error) => {
79
- clearTimeout(grace);
80
+ aborted = true;
80
81
  reject(new Error(`Could not launch Photoshop at ${executablePath}: ${error.message}`));
81
82
  });
83
+ const probe = () => this.run("'pong';", LAUNCH_PROBE_TIMEOUT_MS)
84
+ .then(() => true)
85
+ .catch(() => false);
86
+ waitForLaunchReady(probe, { isAborted: () => aborted })
87
+ .catch(() => false)
88
+ .then(() => {
89
+ if (!aborted)
90
+ resolve();
91
+ });
82
92
  });
83
93
  }
84
94
  }
Binary file