editmamei 1.1.0 → 1.2.1

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 CHANGED
@@ -75,7 +75,7 @@ These are the choices that shape the surface, and the reason an AI assistant can
75
75
 
76
76
  ## Tool surface
77
77
 
78
- **82 tools across 16 capability groups** (59 Community, 23 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. `tools/list` reports eight more than the table below: the names superseded by `filter`, `group`, and `text` (`apply_filter`, `create_group`, `move_layer_to_group`, `set_group_blend_mode`, `ungroup`, `delete_group`, `create_text_layer`, `set_text`) stay registered as deprecated aliases for one release, so existing calls keep working.
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.
79
79
 
80
80
  | Group | Edition | Tools |
81
81
  | --- | --- | --- |
@@ -94,11 +94,11 @@ These are the choices that shape the surface, and the reason an AI assistant can
94
94
  | **AI selection** | Community | `select_subject` · `select_sky` |
95
95
  | **AI selection** | Pro | `select_subject_instance` · `select_object` |
96
96
  | **Filters** | Pro | `apply_camera_raw` |
97
- | **Layers (warp)** | Pro | `warp_layer` · `warp_layer_mesh` · `warp_layer_along` · `warp_layer_region` · `warp_layer_to` |
97
+ | **Layers (warp)** | Pro | `warp_layer` |
98
98
  | **Perception** | Pro | `edit_object` · `add_text_to_object` · `resolve_placement` |
99
99
  | **Face mesh** | Pro | `detect_landmarks` · `select_face_feature` |
100
100
  | **Templates** | Pro | `template_create_evidence` · `template_save` · `template_list` · `template_apply` · `template_verify` · `template_recall` · `template_delete` |
101
- | **Automation** | Pro | `list_actions` · `play_action` · `execute_script` |
101
+ | **Automation** | Pro | `list_actions` · `play_action` · `execute_script` · `batch` |
102
102
 
103
103
  ## Editions
104
104
 
@@ -27,12 +27,13 @@ function __notFoundMessage(label, requested, groupsOnly) {
27
27
  var out = '';
28
28
  for (var c = 0; c < s.length; c++) {
29
29
  var code = s.charCodeAt(c);
30
- if (code >= 32 && code <= 126 && code !== 92) {
31
- out += s.charAt(c);
32
- } else {
30
+ // Control characters and backslash only — see the doc comment above.
31
+ if (code < 32 || code === 127 || code === 92) {
33
32
  var hex = code.toString(16);
34
33
  while (hex.length < 4) hex = '0' + hex;
35
34
  out += '\\\\u' + hex;
35
+ } else {
36
+ out += s.charAt(c);
36
37
  }
37
38
  }
38
39
  return out;
@@ -47,6 +48,11 @@ function __notFoundMessage(label, requested, groupsOnly) {
47
48
  nm = nm.substring(0, 40);
48
49
  var cut = nm.lastIndexOf('\\\\u');
49
50
  if (cut > 34) nm = nm.substring(0, cut);
51
+ // Non-ASCII is no longer escaped, so the cut counts raw UTF-16 units and
52
+ // can land between the halves of a surrogate pair. Drop a trailing high
53
+ // surrogate rather than emit a lone one.
54
+ var lastCode = nm.length > 0 ? nm.charCodeAt(nm.length - 1) : 0;
55
+ if (lastCode >= 0xD800 && lastCode <= 0xDBFF) nm = nm.substring(0, nm.length - 1);
50
56
  nm = nm + '...';
51
57
  }
52
58
  kept.push(nm);
@@ -55,6 +55,21 @@ class ExtendScriptPhotoshopAPI {
55
55
  // numbers, booleans, null/undefined, arrays, and plain objects. Cycles
56
56
  // and very deep nesting are capped via __depth to avoid stack overflow
57
57
  // on pathological inputs.
58
+ //
59
+ // Everything outside printable ASCII is escaped as a JSON u-escape, not
60
+ // just the control range. The Windows cscript stdout transport is
61
+ // codepage-bound and flattens raw non-ASCII to '?' (measured live,
62
+ // PS 27.2.0), so a layer Photoshop named itself in a non-English UI —
63
+ // 'Farbfuellung 1' spelled with the u-umlaut — used to reach the caller as
64
+ // 'Farbf?llung 1'. Naming that layer back at us then missed, because '?'
65
+ // is not what the layer is called. The escape is valid JSON, survives the
66
+ // transport, and JSON.parse restores the exact character, so the round trip
67
+ // is lossless rather than merely legible. Every outcome the script itself
68
+ // produces routes through here, errors included, so this covers messages as
69
+ // well as values. It does NOT cover the Windows shim's own COM failures
70
+ // (windows-runner.ts echoes Err.Description straight to stdout), nor raw
71
+ // non-ASCII sitting in a snippet's own source text — that arrives by a
72
+ // different path and is not addressed here.
58
73
  function __mcpJsonEncode(v, __depth) {
59
74
  if (__depth === undefined) __depth = 0;
60
75
  if (__depth > 32) return '"<<truncated:max-depth>>"';
@@ -72,7 +87,7 @@ class ExtendScriptPhotoshopAPI {
72
87
  else if (c === '\\t') s += '\\\\t';
73
88
  else if (c === '\\b') s += '\\\\b';
74
89
  else if (c === '\\f') s += '\\\\f';
75
- else if (cc < 32) {
90
+ else if (cc < 32 || cc > 126) {
76
91
  var hex = cc.toString(16);
77
92
  while (hex.length < 4) hex = '0' + hex;
78
93
  s += '\\\\u' + hex;
Binary file
Binary file
package/dist/cli/help.js CHANGED
@@ -48,5 +48,6 @@ Per-user data and session logs live in ~/.editmamei/; uninstall preserves them.
48
48
 
49
49
  Docs: https://editmamei.com/docs
50
50
  Issues: https://github.com/editmamei/editmamei/issues
51
+ Release notes by email: https://editmamei.com/blog?src=cli
51
52
  `);
52
53
  }
@@ -13,6 +13,7 @@ import { loadSettings, applyTelemetryEnvOverrides, applyUpdateCheckEnvOverride,
13
13
  import { TelemetryClient } from '../telemetry/client.js';
14
14
  import { resolveInstallChannel } from '../install-channel.js';
15
15
  import { checkForUpdate, shouldCheckForUpdate } from '../update/check.js';
16
+ import { previousSessionFailureCounts, relevantFixes } from '../update/session-fixes.js';
16
17
  import { join } from 'node:path';
17
18
  import { GoSnippetClient, coreBinaryName } from '../api/snippet-client.js';
18
19
  import { isProEntitled } from '../license/entitlement.js';
@@ -63,6 +64,8 @@ export class EditmameiServer {
63
64
  lastPingReachedPs = null;
64
65
  liveVersionResolutionAttempted = false;
65
66
  updateInfo = null;
67
+ updateCheck = null;
68
+ updateNoticeShown = false;
66
69
  snippetClient = new GoSnippetClient();
67
70
  refreshLicenseOnPing = createPingLicenseRefresher();
68
71
  constructor() {
@@ -82,7 +85,7 @@ export class EditmameiServer {
82
85
  if (created)
83
86
  this.logger.info(FIRST_RUN_DISCLOSURE);
84
87
  if (shouldCheckForUpdate(effectiveSettings.update_check)) {
85
- void checkForUpdate().then((info) => {
88
+ this.updateCheck = checkForUpdate().then((info) => {
86
89
  this.updateInfo = info;
87
90
  });
88
91
  }
@@ -102,18 +105,23 @@ export class EditmameiServer {
102
105
  ...(entry.error ? { error: entry.error } : {}),
103
106
  }, entry.result);
104
107
  const telemetrySuccess = entry.tool === 'ps_ping' && this.lastPingReachedPs === false ? false : entry.success;
105
- const errorClass = classifyError(entry.error);
108
+ const isPingDowngrade = entry.tool === 'ps_ping' && !telemetrySuccess && entry.error === undefined;
109
+ const errorClass = classifyError(entry.error) ??
110
+ (telemetrySuccess ? null : isPingDowngrade ? 'ps_not_running' : 'other');
106
111
  this.telemetry.recordCall({
107
112
  tool: entry.tool,
108
113
  success: telemetrySuccess,
109
114
  duration_ms: entry.duration_ms,
110
115
  error_class: errorClass,
111
116
  });
112
- if (!entry.success && entry.error) {
117
+ if (!telemetrySuccess) {
113
118
  this.telemetry.recordDiagnostic({
114
119
  tool: entry.tool,
115
120
  error_class: errorClass ?? 'other',
116
- error_message: entry.error,
121
+ error_message: entry.error ||
122
+ (isPingDowngrade
123
+ ? 'ps_ping did not reach Photoshop'
124
+ : 'tool reported failure with no message'),
117
125
  });
118
126
  }
119
127
  },
@@ -155,14 +163,19 @@ export class EditmameiServer {
155
163
  },
156
164
  update_available: {
157
165
  type: ['object', 'null'],
158
- description: 'Set when a newer Editmamei version is published, else null. When present, TELL THE USER: relay current → latest and how_to_update verbatim. { current, latest, channel (npm/mcpb/dev), how_to_update }. Anonymous npm-registry check at boot; opt out with `editmamei config set update_check false`.',
166
+ description: 'Set when a newer Editmamei version is published, else null: { current, latest, channel (npm/mcpb/dev), how_to_update, fixed_tools (tools whose recorded failures the newer version fixes) }. The relay instruction rides the ping TEXT on the first ping — see notify_user. Anonymous npm-registry check at boot; opt out with `editmamei config set update_check false`.',
159
167
  properties: {
160
168
  current: { type: 'string' },
161
169
  latest: { type: 'string' },
162
170
  channel: { type: 'string' },
163
171
  how_to_update: { type: 'string' },
172
+ fixed_tools: { type: 'array', items: { type: 'string' } },
164
173
  },
165
174
  },
175
+ notify_user: {
176
+ type: 'boolean',
177
+ description: 'True on the one ping whose text carries the update notice — relay that notice to the user before continuing. False on later pings and when no update is available.',
178
+ },
166
179
  },
167
180
  },
168
181
  annotations: {
@@ -209,6 +222,7 @@ export class EditmameiServer {
209
222
  toolRegistry: this.toolRegistry,
210
223
  logger: this.logger,
211
224
  assertToolsClassified: () => this.assertToolsClassified(),
225
+ classifyTool: (name) => this.classifyTool(name),
212
226
  });
213
227
  const proModule = this.moduleLifecycle.resolveProModule();
214
228
  this.kernel = new Kernel({
@@ -239,10 +253,13 @@ export class EditmameiServer {
239
253
  async ensureEntitledModuleFresh(delivery = {}) {
240
254
  return this.moduleLifecycle.ensureEntitledModuleFresh(delivery);
241
255
  }
256
+ classifyTool(name) {
257
+ tierOf(name);
258
+ groupOf(name);
259
+ }
242
260
  assertToolsClassified() {
243
261
  for (const tool of this.toolRegistry.list()) {
244
- tierOf(tool.name);
245
- groupOf(tool.name);
262
+ this.classifyTool(tool.name);
246
263
  }
247
264
  }
248
265
  listCapabilities() {
@@ -346,6 +363,9 @@ export class EditmameiServer {
346
363
  async pingPhotoshop() {
347
364
  this.lastPingReachedPs = null;
348
365
  this.refreshLicenseOnPing();
366
+ if (this.updateCheck)
367
+ await this.updateCheck;
368
+ const update = await this.buildUpdateNotice();
349
369
  const connection = this.session.getConnection();
350
370
  let version = 'Unknown';
351
371
  let actionSetsCount = 0;
@@ -359,6 +379,23 @@ export class EditmameiServer {
359
379
  }
360
380
  catch {
361
381
  }
382
+ if (connection.getPhotoshopInfo() !== null && !(await connection.isCurrentlyRunning())) {
383
+ this.lastPingReachedPs = false;
384
+ return {
385
+ content: [
386
+ {
387
+ type: 'text',
388
+ text: 'Photoshop does not appear to be running. Start Photoshop, then call ps_ping again.' +
389
+ update.note,
390
+ },
391
+ ],
392
+ structuredContent: {
393
+ connected: false,
394
+ update_available: this.updateInfo,
395
+ notify_user: update.notify,
396
+ },
397
+ };
398
+ }
362
399
  let pingStateSnippet = null;
363
400
  try {
364
401
  pingStateSnippet = await this.snippetClient.build('pingState');
@@ -373,10 +410,14 @@ export class EditmameiServer {
373
410
  content: [
374
411
  {
375
412
  type: 'text',
376
- text: (reason ?? 'Photoshop did not respond') + this.updateNote(),
413
+ text: (reason ?? 'Photoshop did not respond') + update.note,
377
414
  },
378
415
  ],
379
- structuredContent: { connected: false, update_available: this.updateInfo },
416
+ structuredContent: {
417
+ connected: false,
418
+ update_available: this.updateInfo,
419
+ notify_user: update.notify,
420
+ },
380
421
  };
381
422
  }
382
423
  degraded.push('pingState');
@@ -390,10 +431,12 @@ export class EditmameiServer {
390
431
  this.logger.warn(`pingState snippet failed: ${err instanceof Error ? err.message : String(err)}`);
391
432
  this.lastPingReachedPs = false;
392
433
  return {
393
- content: [
394
- { type: 'text', text: 'Photoshop did not respond' + this.updateNote() },
395
- ],
396
- structuredContent: { connected: false, update_available: this.updateInfo },
434
+ content: [{ type: 'text', text: 'Photoshop did not respond' + update.note }],
435
+ structuredContent: {
436
+ connected: false,
437
+ update_available: this.updateInfo,
438
+ notify_user: update.notify,
439
+ },
397
440
  };
398
441
  }
399
442
  if (state.version) {
@@ -431,7 +474,7 @@ export class EditmameiServer {
431
474
  `${actionSetsCount} custom action set(s), ${userTemplates} saved template(s), ` +
432
475
  `${openDocuments.length} open document(s)${openDocuments.length ? ': ' + openDocuments.join(', ') : ''}` +
433
476
  `${degradedNote}.` +
434
- this.updateNote(),
477
+ update.note,
435
478
  },
436
479
  ],
437
480
  structuredContent: {
@@ -442,14 +485,45 @@ export class EditmameiServer {
442
485
  open_documents: openDocuments,
443
486
  degraded,
444
487
  update_available: this.updateInfo,
488
+ notify_user: update.notify,
445
489
  },
446
490
  };
447
491
  }
448
- updateNote() {
492
+ async buildUpdateNotice() {
449
493
  const u = this.updateInfo;
450
494
  if (!u)
451
- return '';
452
- return ` Update available: v${u.current} → v${u.latest}. ${u.how_to_update}`;
495
+ return { note: '', notify: false };
496
+ if (this.updateNoticeShown) {
497
+ return {
498
+ note: ` Update available: v${u.current} → v${u.latest}. ${u.how_to_update}`,
499
+ notify: false,
500
+ };
501
+ }
502
+ this.updateNoticeShown = true;
503
+ let fixNote = '';
504
+ if (u.fixed_tools.length > 0) {
505
+ try {
506
+ const counts = await previousSessionFailureCounts(this.session.getSessionId(), {
507
+ dir: this.sessionLog.directory,
508
+ });
509
+ const hits = relevantFixes(counts, u.fixed_tools);
510
+ if (hits.length > 0) {
511
+ const named = hits
512
+ .map((h) => `${h.tool} (failed ${h.failures} time${h.failures === 1 ? '' : 's'} last session)`)
513
+ .join(', ');
514
+ fixNote = ` v${u.latest} fixes known failures in ${named}.`;
515
+ }
516
+ }
517
+ catch {
518
+ }
519
+ }
520
+ return {
521
+ note: ` IMPORTANT — tell the user before continuing: Editmamei v${u.latest} is available` +
522
+ ` (this install runs v${u.current}).${fixNote} To update: ${u.how_to_update}` +
523
+ ` Not urgent: they can also read and subscribe to release notes at` +
524
+ ` https://editmamei.com/blog?src=update_notice`,
525
+ notify: true,
526
+ };
453
527
  }
454
528
  async start() {
455
529
  await this.loadModules();
@@ -77,7 +77,7 @@ export const GROUPS = {
77
77
  automation: {
78
78
  id: 'automation',
79
79
  label: 'Automation (Pro)',
80
- purpose: 'Play recorded Actions and the ExtendScript escape hatch (Pro).',
80
+ purpose: 'Batch files, play recorded Actions, and the ExtendScript escape hatch (Pro).',
81
81
  },
82
82
  };
83
83
  export const TOOL_GROUPS = {
@@ -93,6 +93,7 @@ export const TOOL_GROUPS = {
93
93
  ps_compare_regions: 'verify',
94
94
  ps_get_layer_bounds_diff: 'verify',
95
95
  ps_get_selection_preview: 'verify',
96
+ ps_document: 'document',
96
97
  ps_create_document: 'document',
97
98
  ps_open_document: 'document',
98
99
  ps_close_document: 'document',
@@ -111,10 +112,11 @@ export const TOOL_GROUPS = {
111
112
  ps_select_sky: 'select_ai',
112
113
  ps_select_subject_instance: 'select_ai',
113
114
  ps_select_object: 'select_ai',
115
+ ps_select_focus_area: 'select_ai',
116
+ ps_replace_sky: 'select_ai',
114
117
  ps_add_adjustment_layer: 'adjust',
115
118
  ps_apply_adjustment: 'adjust',
116
119
  ps_filter: 'filter',
117
- ps_apply_filter: 'filter',
118
120
  ps_apply_camera_raw: 'filter',
119
121
  ps_retouch: 'retouch',
120
122
  ps_apply_brush_stroke: 'retouch',
@@ -139,11 +141,6 @@ export const TOOL_GROUPS = {
139
141
  ps_warp_layer_region: 'layers',
140
142
  ps_warp_layer_to: 'layers',
141
143
  ps_group: 'layers',
142
- ps_create_group: 'layers',
143
- ps_move_layer_to_group: 'layers',
144
- ps_set_group_blend_mode: 'layers',
145
- ps_ungroup: 'layers',
146
- ps_delete_group: 'layers',
147
144
  ps_shape: 'layers',
148
145
  ps_layer_mask: 'masks',
149
146
  ps_clipping_mask: 'masks',
@@ -152,8 +149,6 @@ export const TOOL_GROUPS = {
152
149
  ps_apply_image: 'masks',
153
150
  ps_calculations: 'masks',
154
151
  ps_text: 'type',
155
- ps_create_text_layer: 'type',
156
- ps_set_text: 'type',
157
152
  ps_detect: 'perception',
158
153
  ps_read_scene: 'perception',
159
154
  ps_select_by_reference: 'perception',
@@ -174,6 +169,7 @@ export const TOOL_GROUPS = {
174
169
  ps_list_actions: 'automation',
175
170
  ps_play_action: 'automation',
176
171
  ps_execute_script: 'automation',
172
+ ps_batch: 'automation',
177
173
  };
178
174
  export function groupOf(toolName) {
179
175
  const group = TOOL_GROUPS[toolName];
@@ -26,6 +26,9 @@ export class ToolRegistry {
26
26
  restore(snap) {
27
27
  this.tools = new Map(snap);
28
28
  }
29
+ unregister(name) {
30
+ this.tools.delete(name);
31
+ }
29
32
  get(name) {
30
33
  return this.tools.get(name);
31
34
  }
@@ -5,21 +5,17 @@ export const TOOL_TIERS = {
5
5
  ps_list_actions: 'pro',
6
6
  ps_play_action: 'pro',
7
7
  ps_execute_script: 'pro',
8
+ ps_batch: 'pro',
8
9
  ps_add_adjustment_layer: 'community',
9
10
  ps_apply_adjustment: 'community',
11
+ ps_document: 'dev',
10
12
  ps_create_document: 'community',
11
13
  ps_close_document: 'community',
12
14
  ps_open_document: 'community',
13
15
  ps_save_psd: 'community',
14
16
  ps_export: 'community',
15
17
  ps_filter: 'community',
16
- ps_apply_filter: 'community',
17
18
  ps_group: 'community',
18
- ps_create_group: 'community',
19
- ps_move_layer_to_group: 'community',
20
- ps_set_group_blend_mode: 'community',
21
- ps_ungroup: 'community',
22
- ps_delete_group: 'community',
23
19
  ps_clipping_mask: 'community',
24
20
  ps_undo: 'community',
25
21
  ps_redo: 'community',
@@ -38,7 +34,6 @@ export const TOOL_TIERS = {
38
34
  ps_add_layer_style: 'community',
39
35
  ps_create_layer: 'community',
40
36
  ps_delete_layer: 'community',
41
- ps_create_text_layer: 'community',
42
37
  ps_fill_layer: 'community',
43
38
  ps_add_fill_layer: 'community',
44
39
  ps_select_layer: 'community',
@@ -73,6 +68,8 @@ export const TOOL_TIERS = {
73
68
  ps_select_sky: 'community',
74
69
  ps_select_subject_instance: 'pro',
75
70
  ps_select_object: 'pro',
71
+ ps_select_focus_area: 'dev',
72
+ ps_replace_sky: 'dev',
76
73
  ps_modify_selection: 'community',
77
74
  ps_get_selection_preview: 'community',
78
75
  ps_selection_channel: 'community',
@@ -91,7 +88,6 @@ export const TOOL_TIERS = {
91
88
  ps_template_recall: 'pro',
92
89
  ps_template_delete: 'pro',
93
90
  ps_text: 'community',
94
- ps_set_text: 'community',
95
91
  };
96
92
  export function tierOf(toolName) {
97
93
  const tier = TOOL_TIERS[toolName];
@@ -5,7 +5,7 @@ import { installModule, readInstalledModule } from './store.js';
5
5
  import { Logger } from '../utils/logger.js';
6
6
  const logger = new Logger('Modules');
7
7
  const SKU_RE = /^[a-z0-9-]{2,32}$/;
8
- const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
8
+ export const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
9
9
  const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
10
10
  export async function provisionModules(key, opts = {}) {
11
11
  const result = {
@@ -137,7 +137,7 @@ export async function provisionModules(key, opts = {}) {
137
137
  function errMsg(e) {
138
138
  return e instanceof Error ? e.message : String(e);
139
139
  }
140
- function compareVersions(a, b) {
140
+ export function compareVersions(a, b) {
141
141
  const [aCore, aPre] = a.split('-', 2);
142
142
  const [bCore, bPre] = b.split('-', 2);
143
143
  const ap = aCore.split('.').map(Number);
@@ -5,9 +5,10 @@ import { EDITION } from '../edition.js';
5
5
  import { resolveProBinaryPath } from '../api/snippet-client.js';
6
6
  import { isProEntitled } from '../license/entitlement.js';
7
7
  import { loadVerifiedModule, readInstalledModule, installedPath, PRO_SKU, } from '../delivery/store.js';
8
- import { provisionModules } from '../delivery/provision.js';
8
+ import { provisionModules, compareVersions, VERSION_RE, } from '../delivery/provision.js';
9
9
  import { readLicense } from '../license/store.js';
10
10
  import { HOST_MIN_ABI } from './host-api.js';
11
+ import { VERSION } from '../version.js';
11
12
  export function classifyModuleOutcome(inputs) {
12
13
  if (inputs.proModuleLoaded && inputs.skipReason === null)
13
14
  return 'loaded';
@@ -38,11 +39,12 @@ export class ModuleLifecycle {
38
39
  if (isProEntitled()) {
39
40
  const verified = loadVerifiedModule(PRO_SKU);
40
41
  if (verified) {
41
- const abi = readInstalledModule(PRO_SKU)?.abi ?? null;
42
+ const installed = readInstalledModule(PRO_SKU);
42
43
  this._proModule = {
43
44
  importer: () => import(pathToFileURL(verified.handlersPath).href),
44
45
  binDir: verified.binDir,
45
- abi,
46
+ abi: installed?.abi ?? null,
47
+ version: installed?.version ?? null,
46
48
  };
47
49
  return this._proModule;
48
50
  }
@@ -61,6 +63,7 @@ export class ModuleLifecycle {
61
63
  importer: () => import(inTreeProSpecifier),
62
64
  binDir: dirname(resolveProBinaryPath()),
63
65
  abi: null,
66
+ version: null,
64
67
  };
65
68
  return this._proModule;
66
69
  }
@@ -79,10 +82,33 @@ export class ModuleLifecycle {
79
82
  const snapshot = this.deps.toolRegistry.snapshot();
80
83
  try {
81
84
  await this.kernel.loadDownloaded(this._proModule.importer);
85
+ if (this._proModule.version !== null &&
86
+ VERSION_RE.test(this._proModule.version) &&
87
+ compareVersions(this._proModule.version, VERSION) > 0) {
88
+ const added = this.deps.toolRegistry
89
+ .list()
90
+ .map((tool) => tool.name)
91
+ .filter((name) => !snapshot.has(name));
92
+ for (const name of added) {
93
+ try {
94
+ this.deps.classifyTool(name);
95
+ }
96
+ catch {
97
+ this.deps.toolRegistry.unregister(name);
98
+ this.deps.logger.warn(`Module tool '${name}' is not recognized by this host — skipping it; the ` +
99
+ `rest of the module is loaded. Update Editmamei to use it.`);
100
+ }
101
+ }
102
+ }
82
103
  this.deps.assertToolsClassified();
83
104
  }
84
105
  catch (err) {
85
- const changed = this.deps.toolRegistry.count() - snapshot.size;
106
+ let changed = 0;
107
+ for (const tool of this.deps.toolRegistry.list()) {
108
+ const prior = snapshot.get(tool.name);
109
+ if (!prior || this.deps.toolRegistry.get(tool.name) !== prior)
110
+ changed++;
111
+ }
86
112
  this.deps.toolRegistry.restore(snapshot);
87
113
  this.deps.logger.warn(`Pro module could not be loaded on this host — booting Community and rolling back ` +
88
114
  `${changed} module tool change(s); will re-provision in the background: ` +
@@ -29,6 +29,7 @@ import { createPathTools } from '../../tools/path-tools.js';
29
29
  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
+ import { createSkyTools } from '../../tools/sky-tools.js';
32
33
  const ceFactories = [
33
34
  createDocumentTools,
34
35
  createLayerTools,
@@ -57,6 +58,7 @@ const ceFactories = [
57
58
  createVectorMaskTools,
58
59
  createChannelComposeTools,
59
60
  createShapeTools,
61
+ createSkyTools,
60
62
  ];
61
63
  export const ceModule = {
62
64
  manifest: { id: 'ce', name: 'Editmamei Community Tools', abi: KERNEL_ABI },
@@ -10,11 +10,24 @@ let cachedMenu = [];
10
10
  export function __resetPrecompute() {
11
11
  lastPrecomputedKey = null;
12
12
  cachedMenu = [];
13
+ channelsDocState = null;
13
14
  }
14
15
  export const CHANNEL_PREFIX = 'scene:';
15
16
  export async function saveSelectionAsSceneChannel(connection, target) {
16
17
  await runScript(connection, saveSelectionToNamedChannelScript(`${CHANNEL_PREFIX}${target}`), SCENE_CHANNEL_TIMEOUT_MS);
17
18
  }
19
+ let channelsDocState = null;
20
+ export async function invalidateSceneChannelsIfStale(connection, cacheKey) {
21
+ if (channelsDocState === cacheKey)
22
+ return;
23
+ try {
24
+ await runScript(connection, deleteSceneChannelsScript(), SCENE_CHANNEL_TIMEOUT_MS);
25
+ channelsDocState = cacheKey;
26
+ }
27
+ catch {
28
+ channelsDocState = null;
29
+ }
30
+ }
18
31
  const PRECOMPUTE_TARGETS = [
19
32
  'sky',
20
33
  'ground',
@@ -24,6 +37,31 @@ const PRECOMPUTE_TARGETS = [
24
37
  'subject',
25
38
  'face',
26
39
  ];
40
+ export function candidateMenu(model) {
41
+ const advertise = (target) => ({
42
+ key: `${CHANNEL_PREFIX}${target}`,
43
+ target,
44
+ method: 'on_demand',
45
+ bounds: null,
46
+ on_demand: true,
47
+ });
48
+ const hasFace = model.faces.length > 0;
49
+ const hasSubject = model.subjects.length > 0;
50
+ const hasPerson = model.subjects.some((s) => s.label === 'person');
51
+ const menu = [
52
+ advertise('sky'),
53
+ advertise('ground'),
54
+ advertise('shadows'),
55
+ advertise('highlights'),
56
+ ];
57
+ if (hasPerson || hasFace)
58
+ menu.push(advertise('skin'));
59
+ if (hasSubject)
60
+ menu.push(advertise('subject'));
61
+ if (hasFace)
62
+ menu.push(advertise('face'));
63
+ return menu;
64
+ }
27
65
  function deleteSceneChannelsScript() {
28
66
  return `
29
67
  if (app.documents.length === 0) { throw new Error('No document is open in Photoshop'); }
@@ -145,6 +183,7 @@ export async function precomputeRegions(connection, snippet, model, composition,
145
183
  const tally = { scripts: 0 };
146
184
  const countedConnection = countingConnection(connection, tally);
147
185
  await runScript(countedConnection, deleteSceneChannelsScript(), SCENE_CHANNEL_TIMEOUT_MS);
186
+ channelsDocState = model.provenance.cache_key;
148
187
  const menu = [];
149
188
  for (const target of PRECOMPUTE_TARGETS) {
150
189
  try {
Binary file