editmamei 1.3.0 → 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/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 +1 -1
- package/dist/kernel/kernel.js +1 -0
- package/dist/modules/ce/index.js +7 -0
- package/dist/perception/facets.js +4 -3
- 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/preview-tools.js +1 -1
- package/dist/tools/scene-tools.js +8 -6
- package/dist/tools/selection-tools.js +155 -109
- package/dist/tools/sequence-tools.js +435 -0
- 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/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,6 +6,7 @@ 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
12
|
ps_document: 'community',
|
|
@@ -68,7 +69,6 @@ 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
72
|
ps_replace_sky: 'community',
|
|
73
73
|
ps_modify_selection: 'community',
|
|
74
74
|
ps_get_selection_preview: 'community',
|
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),
|
package/dist/modules/ce/index.js
CHANGED
|
@@ -30,6 +30,7 @@ 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
|
+
import { createSequenceTools } from '../../tools/sequence-tools.js';
|
|
33
34
|
export const ceFactories = [
|
|
34
35
|
createDocumentTools,
|
|
35
36
|
createLayerTools,
|
|
@@ -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)),
|
|
@@ -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
|
package/dist/telemetry/client.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Logger } from '../utils/logger.js';
|
|
|
2
2
|
import { EDITION } from '../edition.js';
|
|
3
3
|
import { VERSION } from '../version.js';
|
|
4
4
|
import { resolveInstallChannel } from '../install-channel.js';
|
|
5
|
-
import { buildDiagnosticEvent, buildModuleStatus, buildSessionStart, buildSessionSummary, buildUsageEvent, dayBucket, isContentSafe, PS_VERSION_UNKNOWN, } from './events.js';
|
|
5
|
+
import { buildDiagnosticEvent, buildModuleStatus, buildSessionStart, buildSessionSummary, buildUsageEvent, dayBucket, normalizeDayBucket, isContentSafe, PS_VERSION_UNKNOWN, } from './events.js';
|
|
6
6
|
import { sanitizeMessage, sanitizeSnippet, sanitizeStderrTail } from './sanitize.js';
|
|
7
7
|
import { httpTransport, resolveEndpoint } from './transport.js';
|
|
8
8
|
import { appendOutboxSync, clearOutbox, clearSessionState, readOutbox, readSessionState, writeSessionStateSync, } from './outbox.js';
|
|
@@ -32,6 +32,7 @@ export class TelemetryClient {
|
|
|
32
32
|
distinctTools = new Set();
|
|
33
33
|
anyFailures = false;
|
|
34
34
|
lastSessionPersistMs = 0;
|
|
35
|
+
startDayBucket = null;
|
|
35
36
|
constructor(opts) {
|
|
36
37
|
this.settings = opts.settings;
|
|
37
38
|
this.transport = opts.transport ?? httpTransport();
|
|
@@ -70,6 +71,7 @@ export class TelemetryClient {
|
|
|
70
71
|
recordCall(call) {
|
|
71
72
|
if (!this.active || !this.settings.telemetry.usage)
|
|
72
73
|
return;
|
|
74
|
+
this.ensureStartDayBucket();
|
|
73
75
|
this.toolCallCount += 1;
|
|
74
76
|
this.distinctTools.add(call.tool);
|
|
75
77
|
if (!call.success)
|
|
@@ -81,6 +83,11 @@ export class TelemetryClient {
|
|
|
81
83
|
const v = this.dims.getPsVersion();
|
|
82
84
|
return v && v.length > 0 ? v : PS_VERSION_UNKNOWN;
|
|
83
85
|
}
|
|
86
|
+
ensureStartDayBucket() {
|
|
87
|
+
if (this.startDayBucket === null)
|
|
88
|
+
this.startDayBucket = dayBucket(this.now());
|
|
89
|
+
return this.startDayBucket;
|
|
90
|
+
}
|
|
84
91
|
persistSessionStateThrottled() {
|
|
85
92
|
const nowMs = this.now().getTime();
|
|
86
93
|
if (this.lastSessionPersistMs !== 0 &&
|
|
@@ -90,7 +97,7 @@ export class TelemetryClient {
|
|
|
90
97
|
this.lastSessionPersistMs = nowMs;
|
|
91
98
|
const state = {
|
|
92
99
|
install_id: this.dims.install_id,
|
|
93
|
-
ts_bucket:
|
|
100
|
+
ts_bucket: this.ensureStartDayBucket(),
|
|
94
101
|
editmamei_version: this.dims.editmamei_version,
|
|
95
102
|
edition: this.dims.edition,
|
|
96
103
|
platform: this.dims.platform,
|
|
@@ -170,7 +177,7 @@ export class TelemetryClient {
|
|
|
170
177
|
tool_call_count: this.toolCallCount,
|
|
171
178
|
distinct_tools: this.distinctTools.size,
|
|
172
179
|
any_failures: this.anyFailures,
|
|
173
|
-
}, this.now()));
|
|
180
|
+
}, this.ensureStartDayBucket(), this.now()));
|
|
174
181
|
}
|
|
175
182
|
if (this.queue.length > 0) {
|
|
176
183
|
appendOutboxSync(this.restampPsVersion(this.queue.splice(0)).filter(isContentSafe), this.outboxOpts);
|
|
@@ -225,7 +232,7 @@ function summaryFromState(s) {
|
|
|
225
232
|
v: 2,
|
|
226
233
|
type: 'session_summary',
|
|
227
234
|
install_id: s.install_id,
|
|
228
|
-
ts_bucket: s.ts_bucket,
|
|
235
|
+
ts_bucket: normalizeDayBucket(s.ts_bucket, new Date()),
|
|
229
236
|
editmamei_version: s.editmamei_version,
|
|
230
237
|
edition: s.edition,
|
|
231
238
|
platform: s.platform,
|
package/dist/telemetry/events.js
CHANGED
|
@@ -10,6 +10,9 @@ export function normalizeErrorClass(value) {
|
|
|
10
10
|
.slice(0, 48);
|
|
11
11
|
return cleaned.length > 0 ? cleaned : 'other';
|
|
12
12
|
}
|
|
13
|
+
export function normalizeDayBucket(value, now) {
|
|
14
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : dayBucket(now);
|
|
15
|
+
}
|
|
13
16
|
function psVersionOf(dims) {
|
|
14
17
|
const v = dims.getPsVersion();
|
|
15
18
|
return v && v.length > 0 ? v : PS_VERSION_UNKNOWN;
|
|
@@ -30,12 +33,12 @@ export function buildUsageEvent(dims, call, now) {
|
|
|
30
33
|
duration_ms: call.duration_ms,
|
|
31
34
|
};
|
|
32
35
|
}
|
|
33
|
-
export function buildSessionSummary(dims, summary, now) {
|
|
36
|
+
export function buildSessionSummary(dims, summary, tsBucket, now) {
|
|
34
37
|
return {
|
|
35
38
|
v: TELEMETRY_SCHEMA_VERSION,
|
|
36
39
|
type: 'session_summary',
|
|
37
40
|
install_id: dims.install_id,
|
|
38
|
-
ts_bucket:
|
|
41
|
+
ts_bucket: normalizeDayBucket(tsBucket, now),
|
|
39
42
|
editmamei_version: dims.editmamei_version,
|
|
40
43
|
edition: dims.edition,
|
|
41
44
|
platform: dims.platform,
|
|
@@ -45,8 +45,8 @@ const detectSchema = {
|
|
|
45
45
|
},
|
|
46
46
|
annotate: {
|
|
47
47
|
type: 'boolean',
|
|
48
|
-
default:
|
|
49
|
-
description: '
|
|
48
|
+
default: false,
|
|
49
|
+
description: 'Also return an annotated preview JPEG with the detected boxes drawn (faces cyan, objects magenta). Default false: the labeled boxes returned by this call are already complete on their own — ask for the image only when you actually need to visually confirm a detection.',
|
|
50
50
|
},
|
|
51
51
|
},
|
|
52
52
|
};
|
|
@@ -76,7 +76,7 @@ async function detect(connection, client, rawArgs, detectDeps) {
|
|
|
76
76
|
try {
|
|
77
77
|
const args = validateArgs(detectSchema, rawArgs);
|
|
78
78
|
const target = args.target ?? 'both';
|
|
79
|
-
const annotate = args.annotate ??
|
|
79
|
+
const annotate = args.annotate ?? false;
|
|
80
80
|
const wantFaces = target === 'faces' || target === 'both';
|
|
81
81
|
const wantObjects = target === 'objects' || target === 'both';
|
|
82
82
|
const detectKey = JSON.stringify({
|
|
@@ -174,7 +174,7 @@ export function createDetectionTools(connection, _snippetClient, client = new On
|
|
|
174
174
|
{
|
|
175
175
|
tool: {
|
|
176
176
|
name: 'ps_detect',
|
|
177
|
-
description: 'The cheap, narrow read: labeled bounding boxes only — faces and/or COCO-80 objects (person, dog, car, chair, sofa, …) in DOCUMENT-pixel space
|
|
177
|
+
description: 'The cheap, narrow read: labeled bounding boxes only — faces and/or COCO-80 objects (person, dog, car, chair, sofa, …) in DOCUMENT-pixel space. LOCAL on-device computer vision; the image is never sent anywhere. Use this for real coordinates before a spatially-targeted edit when boxes are all you need — far more reliable than estimating positions from a preview. For the full scene model (regions, horizon, tonal zones, composition, and a menu of selectable named regions), use ps_read_scene instead. `target` selects faces / objects / both. Read-only: renders a throwaway duplicate, never modifies the working document. Boxes are [x1, y1, x2, y2]. Pass `annotate:true` for an annotated preview JPEG (faces cyan, objects magenta) when you need to visually confirm a surprising result.',
|
|
178
178
|
inputSchema: detectSchema,
|
|
179
179
|
outputSchema: {
|
|
180
180
|
type: 'object',
|
|
@@ -162,7 +162,7 @@ const histogramSchema = {
|
|
|
162
162
|
channel: {
|
|
163
163
|
type: 'string',
|
|
164
164
|
enum: ['composite', 'red', 'green', 'blue', 'luminosity', 'gray'],
|
|
165
|
-
description: 'Which channel to read. "composite" (default) is the visible flattened image; if the active layer is an adjustment/fill/shape layer the tool transparently switches to a pixel layer to read it. "red"/"green"/"blue" require an RGB doc; "gray" a grayscale doc. "luminosity" dispatches per doc mode — Lab uses the Lightness channel (exact), Grayscale uses Gray (exact), RGB
|
|
165
|
+
description: 'Which channel to read. "composite" (default) is the visible flattened image; if the active layer is an adjustment/fill/shape layer the tool transparently switches to a pixel layer to read it. "red"/"green"/"blue" require an RGB doc; "gray" a grayscale doc. "luminosity" dispatches per doc mode — Lab uses the Lightness channel (exact), Grayscale uses Gray (exact), and RGB reads the per-pixel luminance Photoshop reports, weighted 0.30/0.59/0.11, so its shape and any clipping or percentile read taken from it are sound. Note that weighting is not Rec.709, so a luminosity mean will not match one computed as 0.2126/0.7152/0.0722. A channel value naming a marginal mixture means the document histogram was unavailable and the read fell back to combining the channel histograms: that mean is still sound, its shape is not. The result\'s `channel` field annotates which path landed when a fallback was used.',
|
|
166
166
|
default: 'composite',
|
|
167
167
|
},
|
|
168
168
|
},
|
|
@@ -71,8 +71,8 @@ const sceneSchema = {
|
|
|
71
71
|
properties: {
|
|
72
72
|
annotate: {
|
|
73
73
|
type: 'boolean',
|
|
74
|
-
default:
|
|
75
|
-
description: '
|
|
74
|
+
default: false,
|
|
75
|
+
description: 'Also return an annotated preview JPEG with subject boxes (magenta), faces (cyan), and the horizon line (yellow) drawn. Default false: the structured scene model returned by this call is already complete on its own — ask for the image only when you actually need to see the annotation drawn.',
|
|
76
76
|
},
|
|
77
77
|
refresh: {
|
|
78
78
|
type: 'boolean',
|
|
@@ -130,7 +130,9 @@ function summarizeScene(model) {
|
|
|
130
130
|
const parts = [
|
|
131
131
|
`${model.subjects.length} subject(s)${subjStr ? ` (${subjStr})` : ''}`,
|
|
132
132
|
`${model.faces.length} face(s)`,
|
|
133
|
-
|
|
133
|
+
model.horizon.detected
|
|
134
|
+
? `horizon at y=${model.horizon.y} (${Math.round(model.horizon.placement * 100)}% down, conf ${model.horizon.confidence.toFixed(2)})`
|
|
135
|
+
: `no horizon measured (${model.horizon.reason})`,
|
|
134
136
|
`sky ~${Math.round((model.regions.find((r) => r.kind === 'sky')?.coverage ?? 0) * 100)}%`,
|
|
135
137
|
];
|
|
136
138
|
if (main && cell)
|
|
@@ -140,7 +142,7 @@ function summarizeScene(model) {
|
|
|
140
142
|
async function scene(connection, snippet, client, rawArgs, proRefine, hasPro = false, detectDeps) {
|
|
141
143
|
try {
|
|
142
144
|
const args = validateArgs(sceneSchema, rawArgs);
|
|
143
|
-
const annotate = args.annotate ??
|
|
145
|
+
const annotate = args.annotate ?? false;
|
|
144
146
|
const refresh = args.refresh ?? false;
|
|
145
147
|
const saveRegions = args.save_regions ?? false;
|
|
146
148
|
const built = await buildSceneModel(connection, snippet, client, {
|
|
@@ -177,7 +179,7 @@ async function scene(connection, snippet, client, rawArgs, proRefine, hasPro = f
|
|
|
177
179
|
try {
|
|
178
180
|
const exportH = built.exportImage.height || 0;
|
|
179
181
|
const sy = model.doc.height > 0 ? exportH / model.doc.height : 1;
|
|
180
|
-
const horizonExportY = Math.round(model.horizon.y * sy);
|
|
182
|
+
const horizonExportY = model.horizon.detected ? Math.round(model.horizon.y * sy) : null;
|
|
181
183
|
const annotated = annotateScene(built.decoded, built.rawFaces, built.rawObjects.map((o) => o.bbox), horizonExportY);
|
|
182
184
|
content.push({
|
|
183
185
|
type: 'image',
|
|
@@ -391,7 +393,7 @@ export function createSceneTools(connection, snippetClient, opts = {}) {
|
|
|
391
393
|
{
|
|
392
394
|
tool: {
|
|
393
395
|
name: 'ps_read_scene',
|
|
394
|
-
description: 'The full scene model — run this before a spatially-targeted edit, not the cheaper ps_detect: detected subjects (with the main one flagged) and faces in document pixels, a coarse sky/ground region map, the horizon line (y + placement + confidence), tonal zones (shadow/midtone/highlight bands + coverage), composition geometry (which thirds cell the subject sits in, balance, headroom),
|
|
396
|
+
description: 'The full scene model — run this before a spatially-targeted edit, not the cheaper ps_detect: detected subjects (with the main one flagged) and faces in document pixels, a coarse sky/ground region map, the horizon line (y + placement + confidence), tonal zones (shadow/midtone/highlight bands + coverage), composition geometry (which thirds cell the subject sits in, balance, headroom), and the menu of selectable named regions. The structured model is complete on its own — pass `annotate:true` for an annotated preview JPEG when you actually need to see it drawn. Built using LOCAL on-device vision + classical CV; the image never leaves the machine. Select regions by name with ps_select_by_reference instead of guessing a rectangle. Read-only: renders a throwaway duplicate. Perception is cached per document state, so repeated reads are cheap.',
|
|
395
397
|
inputSchema: sceneSchema,
|
|
396
398
|
outputSchema: {
|
|
397
399
|
type: 'object',
|