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.
- package/README.md +6 -5
- package/dist/bin/editmamei-core-darwin-arm64 +0 -0
- package/dist/bin/editmamei-core-darwin-x64 +0 -0
- package/dist/bin/editmamei-core-win-x64.exe +0 -0
- package/dist/cli/repair.js +11 -3
- package/dist/core/server.js +3 -3
- package/dist/core/tool-groups.js +3 -3
- package/dist/core/tool-registry.js +3 -1
- package/dist/core/tool-tiers.js +3 -3
- package/dist/delivery/provision.js +19 -1
- package/dist/kernel/kernel.js +3 -1
- package/dist/kernel/module-lifecycle.js +24 -4
- package/dist/modules/ce/index.js +8 -1
- package/dist/perception/facets.js +4 -3
- package/dist/perception/grounding-locate.js +7 -5
- package/dist/perception/region-scorer.js +1 -1
- package/dist/perception/scene-model.js +1 -1
- package/dist/perception/select-recipes.js +14 -3
- package/dist/platform/launch-readiness.js +25 -0
- package/dist/platform/macos-runner.js +18 -8
- package/dist/platform/script-queue.js +5 -19
- package/dist/platform/windows-runner.js +18 -8
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/client.js +11 -4
- package/dist/telemetry/events.js +5 -2
- package/dist/tools/detection-tools.js +4 -4
- package/dist/tools/document-tools.js +0 -1
- package/dist/tools/image-tools.js +2 -1
- package/dist/tools/layer-transform-tools.js +2 -1
- package/dist/tools/path-tools.js +1 -1
- package/dist/tools/preview-tools.js +1 -1
- package/dist/tools/scene-tools.js +8 -6
- package/dist/tools/selection-tools.js +156 -110
- package/dist/tools/sequence-tools.js +435 -0
- package/dist/tools/shape-tools.js +1 -1
- package/dist/utils/operation-timeouts.js +92 -0
- package/dist/utils/run-script.js +25 -1
- package/dist/utils/session-log.js +15 -1
- package/dist/utils/tool-budget-context.js +14 -0
- package/dist/version.js +1 -1
- 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
|
|
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
|
-
**
|
|
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
|
|
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
|
|
Binary file
|
package/dist/cli/repair.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
38
|
+
if (failures.length > 0) {
|
|
33
39
|
throw new Error('module re-provisioning failed');
|
|
34
40
|
}
|
|
35
|
-
|
|
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
|
}
|
package/dist/core/server.js
CHANGED
|
@@ -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' :
|
|
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 ??
|
|
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'
|
package/dist/core/tool-groups.js
CHANGED
|
@@ -76,8 +76,8 @@ export const GROUPS = {
|
|
|
76
76
|
},
|
|
77
77
|
automation: {
|
|
78
78
|
id: 'automation',
|
|
79
|
-
label: 'Automation
|
|
80
|
-
purpose: '
|
|
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');
|
package/dist/core/tool-tiers.js
CHANGED
|
@@ -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: '
|
|
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
|
-
|
|
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
|
|
140
|
+
abi,
|
|
123
141
|
sha256: digest,
|
|
124
142
|
alg: contentKey.alg,
|
|
125
143
|
content_key: contentKey.key,
|
package/dist/kernel/kernel.js
CHANGED
|
@@ -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
|
}
|
package/dist/modules/ce/index.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
258
|
-
'
|
|
259
|
-
'
|
|
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
|
|
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
|
|
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
|
|
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
|
|
10
|
-
const
|
|
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
|
-
|
|
85
|
-
grace.unref();
|
|
86
|
-
resolve();
|
|
87
|
-
}, LAUNCH_GRACE_MS);
|
|
88
|
+
let aborted = false;
|
|
88
89
|
child.on('error', (error) => {
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
10
|
-
const
|
|
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
|
-
|
|
75
|
-
grace.unref();
|
|
76
|
-
resolve();
|
|
77
|
-
}, LAUNCH_GRACE_MS);
|
|
78
|
+
let aborted = false;
|
|
78
79
|
child.on('error', (error) => {
|
|
79
|
-
|
|
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
|