editmamei 0.22.4 → 0.24.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.
Files changed (80) hide show
  1. package/README.md +8 -6
  2. package/dist/api/extendscript/_helpers.js +97 -13
  3. package/dist/api/photoshop-api.js +4 -3
  4. package/dist/api/snippet-client.js +38 -2
  5. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  6. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  7. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  8. package/dist/core/server.js +56 -180
  9. package/dist/delivery/client.js +50 -1
  10. package/dist/delivery/provision.js +18 -1
  11. package/dist/delivery/signing.js +38 -1
  12. package/dist/delivery/store.js +149 -16
  13. package/dist/detection/detect-active-doc.js +41 -10
  14. package/dist/detection/detection-client.js +3 -3
  15. package/dist/detection/face-detector.js +2 -2
  16. package/dist/detection/geometry.js +14 -0
  17. package/dist/detection/landmark-detection-client.js +3 -3
  18. package/dist/detection/mesh-face.js +2 -3
  19. package/dist/detection/object-detector.js +2 -2
  20. package/dist/detection/runtime.js +14 -3
  21. package/dist/detection/sam-segmenter.js +69 -26
  22. package/dist/kernel/kernel.js +5 -9
  23. package/dist/kernel/module-lifecycle.js +181 -0
  24. package/dist/license/entitlement.js +13 -3
  25. package/dist/license/store.js +8 -1
  26. package/dist/modules/ce/index.js +1 -1
  27. package/dist/perception/facets.js +19 -1
  28. package/dist/perception/grounding-anchors.js +2 -12
  29. package/dist/perception/grounding-locate.js +1 -9
  30. package/dist/perception/grounding-review-crop.js +4 -29
  31. package/dist/perception/overlay.js +62 -0
  32. package/dist/perception/pixel-identity.js +41 -0
  33. package/dist/perception/region-precompute.js +85 -17
  34. package/dist/perception/scene-model.js +93 -50
  35. package/dist/perception/select-recipes.js +107 -66
  36. package/dist/perception/sky-ground-flood.js +0 -5
  37. package/dist/perception/sky-mask-transfer.js +72 -13
  38. package/dist/platform/connection.js +42 -11
  39. package/dist/platform/executor-queue.js +68 -0
  40. package/dist/platform/macos-detector.js +2 -1
  41. package/dist/platform/macos-executor.js +18 -67
  42. package/dist/platform/run-child.js +6 -3
  43. package/dist/platform/windows-detector.js +14 -7
  44. package/dist/platform/windows-executor.js +9 -64
  45. package/dist/skills/editmamei-skill.zip +0 -0
  46. package/dist/tools/adjustment-tools.js +78 -130
  47. package/dist/tools/brush-tools.js +3 -14
  48. package/dist/tools/channel-compose-tools.js +4 -23
  49. package/dist/tools/detection-tools.js +59 -47
  50. package/dist/tools/document-tools.js +121 -131
  51. package/dist/tools/filter-tools.js +489 -730
  52. package/dist/tools/group-tools.js +90 -156
  53. package/dist/tools/guide-tools.js +52 -83
  54. package/dist/tools/history-tools.js +29 -63
  55. package/dist/tools/image-placement-tools.js +18 -37
  56. package/dist/tools/image-tools.js +23 -51
  57. package/dist/tools/layer-ordering-tools.js +2 -9
  58. package/dist/tools/layer-properties-tools.js +245 -384
  59. package/dist/tools/layer-tools.js +115 -181
  60. package/dist/tools/layer-transform-tools.js +41 -106
  61. package/dist/tools/metadata-tools.js +2 -9
  62. package/dist/tools/path-tools.js +3 -14
  63. package/dist/tools/portrait-tools.js +2 -5
  64. package/dist/tools/preview-tools.js +53 -65
  65. package/dist/tools/retouch-tools.js +32 -70
  66. package/dist/tools/scene-tools.js +94 -73
  67. package/dist/tools/selection-tools.js +306 -563
  68. package/dist/tools/shape-tools.js +18 -10
  69. package/dist/tools/text-tools.js +24 -57
  70. package/dist/tools/transform-canvas-tools.js +21 -54
  71. package/dist/tools/vector-mask-tools.js +2 -9
  72. package/dist/utils/jpeg-quality.js +1 -0
  73. package/dist/utils/logger.js +3 -0
  74. package/dist/utils/operation-timeouts.js +7 -0
  75. package/dist/utils/run-script.js +7 -2
  76. package/dist/utils/session-log.js +134 -12
  77. package/dist/utils/template-storage.js +5 -6
  78. package/dist/utils/tool-helpers.js +37 -0
  79. package/dist/version.js +1 -1
  80. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { runScript } from '../utils/run-script.js';
2
2
  import { jsNum } from '../utils/jsx.js';
3
3
  import { getContextInfo, getSelectionInfo } from '../api/extendscript/_helpers.js';
4
- import { computeSkyMaskFromJpeg } from './sky-ground-flood.js';
4
+ import { computeSkyMask } from './sky-ground-flood.js';
5
5
  import { loadSkyMaskAsSelection } from './sky-mask-transfer.js';
6
6
  import { Logger } from '../utils/logger.js';
7
7
  const logger = new Logger('select-recipes');
@@ -63,8 +63,7 @@ function sceneCtx(model) {
63
63
  indoorObjectCount,
64
64
  };
65
65
  }
66
- async function measureAndScore(connection, model, kind, composition) {
67
- const info = await measure(connection);
66
+ function scoreInfo(info, model, kind, composition) {
68
67
  const signals = buildRegionSignals({
69
68
  has_selection: info.has_selection,
70
69
  bounds: info.bounds ?? null,
@@ -72,7 +71,11 @@ async function measureAndScore(connection, model, kind, composition) {
72
71
  edge_complexity: info.edge_complexity,
73
72
  bounds_fill_ratio: info.bounds_fill_ratio,
74
73
  }, sceneCtx(model));
75
- return { info, score: scoreRegion(kind, signals, composition) };
74
+ return scoreRegion(kind, signals, composition);
75
+ }
76
+ async function measureAndScore(connection, model, kind, composition) {
77
+ const info = await measure(connection);
78
+ return { info, score: scoreInfo(info, model, kind, composition) };
76
79
  }
77
80
  async function deselect(connection, snippet) {
78
81
  try {
@@ -98,13 +101,14 @@ async function gateResult(connection, snippet, target, method, info, score, deta
98
101
  async function bestOf(connection, snippet, model, kind, composition, target, candidates, detail = {}) {
99
102
  let winner = null;
100
103
  for (const c of candidates) {
104
+ let info;
101
105
  try {
102
- await c.produce();
106
+ info = await c.produce();
103
107
  }
104
108
  catch {
105
109
  continue;
106
110
  }
107
- const { score } = await measureAndScore(connection, model, kind, composition);
111
+ const score = scoreInfo(info, model, kind, composition);
108
112
  if (!winner || score.confidence > winner.confidence)
109
113
  winner = { c, confidence: score.confidence };
110
114
  }
@@ -119,14 +123,15 @@ async function bestOf(connection, snippet, model, kind, composition, target, can
119
123
  detail,
120
124
  };
121
125
  }
122
- await winner.c.produce();
123
- const { info, score } = await measureAndScore(connection, model, kind, composition);
126
+ const info = await winner.c.produce();
127
+ const score = scoreInfo(info, model, kind, composition);
124
128
  return gateResult(connection, snippet, target, winner.c.method, info, score, detail);
125
129
  }
126
130
  function thresholdSkyScript(level, seedX, seedY) {
127
131
  return `
128
132
  // __mcp_scene_thr__ region-select glue
129
133
  ${getContextInfo}
134
+ ${getSelectionInfo}
130
135
  function cTID(s){ return app.charIDToTypeID(s); }
131
136
  function sTID(s){ return app.stringIDToTypeID(s); }
132
137
  if (app.documents.length === 0) { throw new Error('No active document'); }
@@ -155,13 +160,14 @@ function thresholdSkyScript(level, seedX, seedY) {
155
160
  __wand.putBoolean(cTID('Mrgd'), true);
156
161
  app.executeAction(cTID('setd'), __wand, DialogModes.NO);
157
162
  try { __adj.remove(); } catch (eRm) {}
158
- return { ok: true };
163
+ return getSelectionInfo();
159
164
  `;
160
165
  }
161
166
  function posterizeRegionScript(seedX, seedY) {
162
167
  return `
163
168
  // __mcp_scene_pst__ region-select glue
164
169
  ${getContextInfo}
170
+ ${getSelectionInfo}
165
171
  function cTID(s){ return app.charIDToTypeID(s); }
166
172
  function sTID(s){ return app.stringIDToTypeID(s); }
167
173
  if (app.documents.length === 0) { throw new Error('No active document'); }
@@ -190,7 +196,7 @@ function posterizeRegionScript(seedX, seedY) {
190
196
  __wand.putBoolean(cTID('Mrgd'), true);
191
197
  app.executeAction(cTID('setd'), __wand, DialogModes.NO);
192
198
  try { __adj.remove(); } catch (eRm) {}
193
- return { ok: true };
199
+ return getSelectionInfo();
194
200
  `;
195
201
  }
196
202
  function skySeed(model) {
@@ -226,17 +232,16 @@ export async function resolveSelection(connection, snippet, model, target, opts
226
232
  throw new Error(`Unknown select-by-reference target: ${String(target)}`);
227
233
  }
228
234
  }
229
- async function produceThresholdSky(connection, model) {
235
+ async function deriveAndMeasureThresholdSky(connection, model) {
230
236
  const skyRecipe = model.regions.find((r) => r.kind === 'sky');
231
237
  const level = skyRecipe && skyRecipe.recipe.kind === 'threshold_white' ? skyRecipe.recipe.level : 128;
232
238
  const seed = skySeed(model);
233
- await runScript(connection, thresholdSkyScript(level, seed.x, seed.y));
234
- return { level, seed };
239
+ return (await runScript(connection, thresholdSkyScript(level, seed.x, seed.y)));
235
240
  }
236
241
  async function resolveSky(connection, snippet, model, composition, skyCtx) {
237
- if (skyCtx) {
242
+ if (skyCtx?.decoded) {
238
243
  try {
239
- const { mask, width, height } = computeSkyMaskFromJpeg(skyCtx.exportBytes, skyCtx.boxes);
244
+ const { mask, width, height } = computeSkyMask(skyCtx.decoded.data, skyCtx.decoded.width, skyCtx.decoded.height, skyCtx.boxes);
240
245
  await loadSkyMaskAsSelection(connection, mask, width, height, skyCtx.docW, skyCtx.docH);
241
246
  const { info, score } = await measureAndScore(connection, model, 'sky', composition);
242
247
  if (score.passed) {
@@ -250,45 +255,59 @@ async function resolveSky(connection, snippet, model, composition, skyCtx) {
250
255
  return bestOf(connection, snippet, model, 'sky', composition, 'sky', [
251
256
  {
252
257
  method: 'threshold_white',
253
- produce: async () => {
254
- await produceThresholdSky(connection, model);
255
- },
258
+ produce: () => deriveAndMeasureThresholdSky(connection, model),
256
259
  },
257
260
  {
258
261
  method: 'sensei_select_sky',
259
262
  produce: async () => {
260
263
  await runScript(connection, await snippet.build('selectSky', { sampleAllLayers: true, selectionType: 'replace' }));
264
+ return measure(connection);
261
265
  },
262
266
  },
263
267
  ]);
264
268
  }
265
269
  async function resolveGround(connection, snippet, model, composition) {
266
- await produceThresholdSky(connection, model);
267
- const sky = await measureAndScore(connection, model, 'sky', composition);
268
- if (!sky.score.passed) {
270
+ const skyInfo = await deriveAndMeasureThresholdSky(connection, model);
271
+ const skyScore = scoreInfo(skyInfo, model, 'sky', composition);
272
+ if (!skyScore.passed) {
269
273
  await deselect(connection, snippet);
270
274
  return {
271
275
  target: 'ground',
272
276
  method: 'invert_sky_minus_subjects',
273
277
  passed: false,
274
278
  confidence: 0,
275
- reasons: ['no confident sky to invert', ...sky.score.reasons],
279
+ reasons: ['no confident sky to invert', ...skyScore.reasons],
276
280
  detail: { note: 'ground = invert(confident sky) − subjects; no confident sky found' },
277
281
  };
278
282
  }
279
- await runScript(connection, await snippet.build('invertSelection'));
280
- for (const s of model.subjects) {
281
- const [l, t, r, b] = s.bbox;
282
- await runScript(connection, await snippet.build('selectRectangle', {
283
- left: l,
284
- top: t,
285
- right: r,
286
- bottom: b,
287
- featherPx: 0,
288
- selectionType: 'subtract',
289
- }));
283
+ const [invertScript, ...subtractScripts] = await Promise.all([
284
+ snippet.build('invertSelection'),
285
+ ...model.subjects.map((s) => {
286
+ const [l, t, r, b] = s.bbox;
287
+ return snippet.build('selectRectangle', {
288
+ left: l,
289
+ top: t,
290
+ right: r,
291
+ bottom: b,
292
+ featherPx: 0,
293
+ selectionType: 'subtract',
294
+ });
295
+ }),
296
+ ]);
297
+ await runScript(connection, invertScript);
298
+ let lastResult;
299
+ for (const script of subtractScripts) {
300
+ lastResult = (await runScript(connection, script));
301
+ }
302
+ let info;
303
+ let score;
304
+ if (lastResult?.selection_info) {
305
+ info = lastResult.selection_info;
306
+ score = scoreInfo(info, model, 'ground', composition);
307
+ }
308
+ else {
309
+ ({ info, score } = await measureAndScore(connection, model, 'ground', composition));
290
310
  }
291
- const { info, score } = await measureAndScore(connection, model, 'ground', composition);
292
311
  return gateResult(connection, snippet, 'ground', 'invert_sky_minus_subjects', info, score, {
293
312
  subjects_subtracted: model.subjects.length,
294
313
  });
@@ -296,44 +315,64 @@ async function resolveGround(connection, snippet, model, composition) {
296
315
  async function resolveFoliage(connection, snippet, model, composition) {
297
316
  const seedX = Math.round(model.doc.width / 2);
298
317
  const seedY = Math.round(model.doc.height * 0.8);
299
- await runScript(connection, posterizeRegionScript(seedX, seedY));
300
- const { info, score } = await measureAndScore(connection, model, 'foliage', composition);
318
+ const info = (await runScript(connection, posterizeRegionScript(seedX, seedY)));
319
+ const score = scoreInfo(info, model, 'foliage', composition);
301
320
  return gateResult(connection, snippet, 'foliage', 'posterize_region', info, score, {
302
321
  seed: { x: seedX, y: seedY },
303
322
  });
304
323
  }
305
324
  async function resolveLuminance(connection, snippet, model, band, composition) {
306
- await runScript(connection, await snippet.build('selectLuminanceRange', {
325
+ const result = (await runScript(connection, await snippet.build('selectLuminanceRange', {
307
326
  mode: band,
308
327
  fuzziness: 40,
309
328
  selectionType: 'replace',
310
- }));
311
- const { info, score } = await measureAndScore(connection, model, band, composition);
329
+ })));
330
+ const info = result.selection_info ?? (await measure(connection));
331
+ const score = scoreInfo(info, model, band, composition);
312
332
  return gateResult(connection, snippet, band, 'luminance_range', info, score, { band });
313
333
  }
314
334
  async function resolveSkin(connection, snippet, model, composition) {
315
- await runScript(connection, await snippet.build('selectColorPreset', {
316
- preset: 'skin_tones',
317
- fuzziness: 40,
318
- useFaces: false,
319
- selectionType: 'replace',
320
- }));
321
335
  const person = model.subjects.find((s) => s.label === 'person');
322
336
  const box = person?.bbox ?? model.faces.find((f) => f.is_primary)?.bbox;
323
- let intersected = false;
324
- if (box) {
325
- const [l, t, r, b] = box;
326
- await runScript(connection, await snippet.build('selectRectangle', {
327
- left: l,
328
- top: t,
329
- right: r,
330
- bottom: b,
331
- featherPx: 0,
332
- selectionType: 'intersect',
333
- }));
334
- intersected = true;
337
+ if (!box) {
338
+ await deselect(connection, snippet);
339
+ return {
340
+ target: 'skin',
341
+ method: 'color_range_skin_∩_box',
342
+ passed: false,
343
+ confidence: 0,
344
+ reasons: [
345
+ 'no person or face detected — skin-tone colour alone cannot tell skin from ' +
346
+ 'any other warm subject (tungsten light, wood, amber signage), so nothing was selected',
347
+ ],
348
+ detail: { intersected_with_box: false, no_person_or_face: true },
349
+ };
350
+ }
351
+ const [colorPresetScript, intersectScript] = await Promise.all([
352
+ snippet.build('selectColorPreset', {
353
+ preset: 'skin_tones',
354
+ fuzziness: 40,
355
+ useFaces: false,
356
+ selectionType: 'replace',
357
+ }),
358
+ box
359
+ ? snippet.build('selectRectangle', {
360
+ left: box[0],
361
+ top: box[1],
362
+ right: box[2],
363
+ bottom: box[3],
364
+ featherPx: 0,
365
+ selectionType: 'intersect',
366
+ })
367
+ : Promise.resolve(undefined),
368
+ ]);
369
+ let lastResult = (await runScript(connection, colorPresetScript));
370
+ const intersected = intersectScript !== undefined;
371
+ if (intersectScript !== undefined) {
372
+ lastResult = (await runScript(connection, intersectScript));
335
373
  }
336
- const { info, score } = await measureAndScore(connection, model, 'skin', composition);
374
+ const info = lastResult.selection_info ?? (await measure(connection));
375
+ const score = scoreInfo(info, model, 'skin', composition);
337
376
  return gateResult(connection, snippet, 'skin', 'color_range_skin_∩_box', info, score, {
338
377
  intersected_with_box: intersected,
339
378
  });
@@ -368,8 +407,8 @@ async function resolveSubject(connection, snippet, model, opts) {
368
407
  }
369
408
  const cx = Math.round((box.bbox[0] + box.bbox[2]) / 2);
370
409
  const cy = Math.round((box.bbox[1] + box.bbox[3]) / 2);
371
- await runScript(connection, posterizeRegionScript(cx, cy));
372
- const { info, score } = await measureAndScore(connection, model, 'subject', opts.composition);
410
+ const info = (await runScript(connection, posterizeRegionScript(cx, cy)));
411
+ const score = scoreInfo(info, model, 'subject', opts.composition);
373
412
  return gateResult(connection, snippet, 'subject', 'box_posterize_wand', info, score, {
374
413
  label: box.label,
375
414
  box: box.bbox,
@@ -397,7 +436,7 @@ async function resolveFace(connection, snippet, model, opts) {
397
436
  }
398
437
  const [l, t, r2, b] = face.bbox;
399
438
  const feather = Math.max(4, Math.round(Math.min(r2 - l, b - t) * 0.08));
400
- await runScript(connection, await snippet.build('selectEllipse', {
439
+ const result = (await runScript(connection, await snippet.build('selectEllipse', {
401
440
  left: l,
402
441
  top: t,
403
442
  right: r2,
@@ -405,8 +444,9 @@ async function resolveFace(connection, snippet, model, opts) {
405
444
  featherPx: feather,
406
445
  antiAlias: true,
407
446
  selectionType: 'replace',
408
- }));
409
- const { info, score } = await measureAndScore(connection, model, 'face', opts.composition);
447
+ })));
448
+ const info = result.selection_info ?? (await measure(connection));
449
+ const score = scoreInfo(info, model, 'face', opts.composition);
410
450
  return gateResult(connection, snippet, 'face', 'face_box_ellipse', info, score, {
411
451
  box: face.bbox,
412
452
  feather,
@@ -451,15 +491,16 @@ async function resolveFaceFeature(connection, snippet, target, feature, opts) {
451
491
  }
452
492
  async function resolveAboveHorizon(connection, snippet, model, composition) {
453
493
  const y = Math.max(1, Math.min(model.doc.height, model.horizon.y));
454
- await runScript(connection, await snippet.build('selectRectangle', {
494
+ const result = (await runScript(connection, await snippet.build('selectRectangle', {
455
495
  left: 0,
456
496
  top: 0,
457
497
  right: model.doc.width,
458
498
  bottom: y,
459
499
  featherPx: Math.max(2, Math.round(model.doc.height * 0.01)),
460
500
  selectionType: 'replace',
461
- }));
462
- const { info, score } = await measureAndScore(connection, model, 'above_horizon', composition);
501
+ })));
502
+ const info = result.selection_info ?? (await measure(connection));
503
+ const score = scoreInfo(info, model, 'above_horizon', composition);
463
504
  return gateResult(connection, snippet, 'above_horizon', 'rectangle_to_horizon', info, score, {
464
505
  horizon_y: y,
465
506
  });
@@ -1,4 +1,3 @@
1
- import { decode } from 'jpeg-js';
2
1
  const GREEN_SCALE = 35;
3
2
  const DARK_LUM = 60;
4
3
  const DARK_SCALE = 45;
@@ -79,10 +78,6 @@ export function computeSkyMask(rgba, srcW, srcH, boxes, opts = {}) {
79
78
  const finalMask = close(mask, W, H, CLOSE_R);
80
79
  return { mask: finalMask, width: W, height: H };
81
80
  }
82
- export function computeSkyMaskFromJpeg(jpegBytes, boxes, opts = {}) {
83
- const img = decode(jpegBytes, { useTArray: true, formatAsRGBA: true });
84
- return computeSkyMask(img.data, img.width, img.height, boxes, opts);
85
- }
86
81
  function rasterizeBoxes(boxes, W, H, F) {
87
82
  const m = new Uint8Array(W * H);
88
83
  for (const b of boxes) {
@@ -1,19 +1,69 @@
1
1
  import * as fs from 'node:fs/promises';
2
- import { encode } from 'jpeg-js';
3
2
  import { runScript } from '../utils/run-script.js';
4
3
  import { TempDir, userOwnedTempRoot } from '../utils/temp.js';
5
4
  import { getContextInfo } from '../api/extendscript/_helpers.js';
6
5
  import { jsLit, jsNum } from '../utils/jsx.js';
7
- function encodeMaskJpeg(mask, w, h) {
8
- const rgba = Buffer.alloc(w * h * 4);
9
- for (let i = 0; i < w * h; i++) {
10
- const v = mask[i] ? 255 : 0;
11
- rgba[i * 4] = v;
12
- rgba[i * 4 + 1] = v;
13
- rgba[i * 4 + 2] = v;
14
- rgba[i * 4 + 3] = 255;
6
+ const BMP_FILE_HEADER_SIZE = 14;
7
+ const BMP_INFO_HEADER_SIZE = 40;
8
+ const BMP_PALETTE_ENTRIES = 256;
9
+ const BMP_PALETTE_SIZE = BMP_PALETTE_ENTRIES * 4;
10
+ const BMP_PIXEL_OFFSET = BMP_FILE_HEADER_SIZE + BMP_INFO_HEADER_SIZE + BMP_PALETTE_SIZE;
11
+ export function encodeMaskBmp(mask, w, h) {
12
+ if (!Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0) {
13
+ throw new Error(`encodeMaskBmp: w/h must be positive integers (got w=${w}, h=${h})`);
15
14
  }
16
- return encode({ data: rgba, width: w, height: h }, 92).data;
15
+ if (mask.length < w * h) {
16
+ throw new Error(`encodeMaskBmp: mask.length (${mask.length}) is smaller than w*h (${w * h}) — refusing to silently encode the missing tail as black`);
17
+ }
18
+ const rowSize = (w + 3) & ~3;
19
+ const pixelArraySize = rowSize * h;
20
+ const fileSize = BMP_PIXEL_OFFSET + pixelArraySize;
21
+ const buf = Buffer.alloc(fileSize);
22
+ let o = 0;
23
+ buf.write('BM', o, 'ascii');
24
+ o += 2;
25
+ buf.writeUInt32LE(fileSize, o);
26
+ o += 4;
27
+ buf.writeUInt32LE(0, o);
28
+ o += 4;
29
+ buf.writeUInt32LE(BMP_PIXEL_OFFSET, o);
30
+ o += 4;
31
+ buf.writeUInt32LE(BMP_INFO_HEADER_SIZE, o);
32
+ o += 4;
33
+ buf.writeInt32LE(w, o);
34
+ o += 4;
35
+ buf.writeInt32LE(h, o);
36
+ o += 4;
37
+ buf.writeUInt16LE(1, o);
38
+ o += 2;
39
+ buf.writeUInt16LE(8, o);
40
+ o += 2;
41
+ buf.writeUInt32LE(0, o);
42
+ o += 4;
43
+ buf.writeUInt32LE(pixelArraySize, o);
44
+ o += 4;
45
+ buf.writeInt32LE(0, o);
46
+ o += 4;
47
+ buf.writeInt32LE(0, o);
48
+ o += 4;
49
+ buf.writeUInt32LE(BMP_PALETTE_ENTRIES, o);
50
+ o += 4;
51
+ buf.writeUInt32LE(0, o);
52
+ o += 4;
53
+ for (let i = 0; i < BMP_PALETTE_ENTRIES; i++) {
54
+ buf[o++] = i;
55
+ buf[o++] = i;
56
+ buf[o++] = i;
57
+ buf[o++] = 0;
58
+ }
59
+ for (let y = 0; y < h; y++) {
60
+ const srcRow = h - 1 - y;
61
+ const rowStart = BMP_PIXEL_OFFSET + y * rowSize;
62
+ for (let x = 0; x < w; x++) {
63
+ buf[rowStart + x] = mask[srcRow * w + x] ? 255 : 0;
64
+ }
65
+ }
66
+ return buf;
17
67
  }
18
68
  function buildLoadScript(maskPath, docW, docH, selType) {
19
69
  return `
@@ -50,6 +100,15 @@ function buildLoadScript(maskPath, docW, docH, selType) {
50
100
  // Bring the upscaled mask onto the clipboard, then paste it INTO a fresh full-canvas
51
101
  // alpha channel on the original (selectAll + paste-into keeps it aligned to canvas).
52
102
  var maskDoc = app.open(new File(${jsLit(maskPath)}));
103
+ // TRAP: encodeMaskBmp writes an 8bpp PALETTIZED bitmap (a 256-entry grayscale
104
+ // palette over indexed pixel data — see sky-mask-transfer.ts's encodeMaskBmp),
105
+ // so Photoshop opens this file in INDEXED COLOR mode, not Grayscale. In Indexed
106
+ // Color, resizeImage's ResampleMethod argument is silently IGNORED — there is no
107
+ // palette-space bicubic — so the upscale below would degrade to nearest-neighbor
108
+ // and hard-edge the sky/SAM selection despite asking for BICUBIC. Indexed →
109
+ // Grayscale is a legal direct conversion (no intermediate RGB round trip needed)
110
+ // and must happen BEFORE resizeImage for the requested resample method to apply.
111
+ maskDoc.changeMode(ChangeMode.GRAYSCALE);
53
112
  maskDoc.resizeImage(
54
113
  UnitValue(${jsNum(docW, 1)}, 'px'),
55
114
  UnitValue(${jsNum(docH, 1)}, 'px'),
@@ -93,13 +152,13 @@ const SELECTION_TYPE_ENUM = {
93
152
  intersect: 'INTERSECT',
94
153
  };
95
154
  export async function loadMaskAsSelection(connection, mask, maskW, maskH, docW, docH, selectionType = 'replace') {
96
- const jpg = encodeMaskJpeg(mask, maskW, maskH);
155
+ const bmp = encodeMaskBmp(mask, maskW, maskH);
97
156
  const dir = process.platform === 'darwin'
98
157
  ? await TempDir.createWithRoot(userOwnedTempRoot(), 'editmamei-mask-')
99
158
  : await TempDir.create('editmamei-mask-');
100
159
  try {
101
- const maskPath = dir.path('mask.jpg');
102
- await fs.writeFile(maskPath, jpg);
160
+ const maskPath = dir.path('mask.bmp');
161
+ await fs.writeFile(maskPath, bmp);
103
162
  await runScript(connection, buildLoadScript(maskPath, docW, docH, SELECTION_TYPE_ENUM[selectionType]));
104
163
  }
105
164
  finally {
@@ -3,20 +3,25 @@ import { Logger } from '../utils/logger.js';
3
3
  import { PhotoshopDetector } from './detector.js';
4
4
  import { WindowsExecutor } from './windows-executor.js';
5
5
  import { MacOSExecutor } from './macos-executor.js';
6
- function truncateForLog(s, maxLen) {
6
+ export function truncateForLog(s, maxLen) {
7
7
  if (s.length <= maxLen)
8
8
  return s;
9
9
  return `${s.slice(0, maxLen)}…[+${s.length - maxLen} chars]`;
10
10
  }
11
+ export const RUNNING_LATCH_TTL_MS = 30_000;
11
12
  export class PhotoshopConnection {
12
13
  logger;
13
14
  detector;
14
15
  executor;
15
16
  photoshopInfo = null;
17
+ detectInFlight = null;
16
18
  macosExecutor;
17
- constructor() {
19
+ now;
20
+ runningLatchUntil = 0;
21
+ constructor(opts = {}) {
18
22
  this.logger = new Logger('PhotoshopConnection');
19
23
  this.detector = new PhotoshopDetector();
24
+ this.now = opts.now ?? Date.now;
20
25
  const platformType = platform();
21
26
  if (platformType === 'win32') {
22
27
  this.executor = new WindowsExecutor();
@@ -40,11 +45,26 @@ export class PhotoshopConnection {
40
45
  return false;
41
46
  }
42
47
  }
48
+ async ensureDetected() {
49
+ if (this.photoshopInfo)
50
+ return this.photoshopInfo;
51
+ if (!this.detectInFlight) {
52
+ this.detectInFlight = this.detector
53
+ .detect()
54
+ .then((info) => {
55
+ if (info)
56
+ this.photoshopInfo = info;
57
+ return info;
58
+ })
59
+ .finally(() => {
60
+ this.detectInFlight = null;
61
+ });
62
+ }
63
+ return this.detectInFlight;
64
+ }
43
65
  async getVersion() {
44
66
  try {
45
- if (!this.photoshopInfo) {
46
- this.photoshopInfo = await this.detector.detect();
47
- }
67
+ await this.ensureDetected();
48
68
  return this.photoshopInfo?.version || 'Unknown';
49
69
  }
50
70
  catch (error) {
@@ -54,22 +74,33 @@ export class PhotoshopConnection {
54
74
  }
55
75
  async executeScript(script, timeout) {
56
76
  try {
77
+ await this.ensureDetected();
57
78
  if (!this.photoshopInfo) {
58
- this.photoshopInfo = await this.detector.detect();
79
+ throw new Error('Photoshop info not available — the local install could not be detected. ' +
80
+ 'Check that Photoshop is installed (set PHOTOSHOP_PATH to override detection).');
59
81
  }
60
82
  if (this.macosExecutor && this.photoshopInfo.appName) {
61
83
  this.macosExecutor.setAppName(this.photoshopInfo.appName);
62
84
  }
63
- const isRunning = await this.executor.isPhotoshopRunning();
64
- if (!isRunning) {
65
- this.logger.info('Photoshop not running, launching...');
66
- await this.executor.launchPhotoshop(this.photoshopInfo.path);
85
+ if (this.now() >= this.runningLatchUntil) {
86
+ const isRunning = await this.executor.isPhotoshopRunning();
87
+ if (!isRunning) {
88
+ this.logger.info('Photoshop not running, launching...');
89
+ await this.executor.launchPhotoshop(this.photoshopInfo.path);
90
+ }
91
+ else {
92
+ this.runningLatchUntil = this.now() + RUNNING_LATCH_TTL_MS;
93
+ }
94
+ }
95
+ if (this.logger.isDebugEnabled()) {
96
+ this.logger.debug(`Executing script (${script.length} chars, PS ${this.photoshopInfo.version}, ${platform()}): ${truncateForLog(script, 200)}`);
67
97
  }
68
- this.logger.debug(`Executing script (${script.length} chars, PS ${this.photoshopInfo.version}, ${platform()}): ${truncateForLog(script, 200)}`);
69
98
  const result = await this.executor.execute(script, timeout);
99
+ this.runningLatchUntil = this.now() + RUNNING_LATCH_TTL_MS;
70
100
  return result;
71
101
  }
72
102
  catch (error) {
103
+ this.runningLatchUntil = 0;
73
104
  const psVersion = this.photoshopInfo?.version ?? 'unknown';
74
105
  const head = truncateForLog(script, 200);
75
106
  this.logger.error(`Script execution failed (PS ${psVersion}, ${platform()}, ${script.length} chars):`, error, `\n--- script head (200 chars) ---\n${head}`);
@@ -0,0 +1,68 @@
1
+ export const QUEUE_SLACK_MS = 3500;
2
+ export class ScriptQueue {
3
+ logger;
4
+ queue = [];
5
+ isProcessing = false;
6
+ constructor(logger) {
7
+ this.logger = logger;
8
+ }
9
+ enqueue(run, timeout) {
10
+ return new Promise((resolve, reject) => {
11
+ const task = {
12
+ run,
13
+ resolve,
14
+ reject,
15
+ cancelled: false,
16
+ settled: false,
17
+ timeoutId: null,
18
+ };
19
+ task.timeoutId = setTimeout(() => {
20
+ if (task.settled)
21
+ return;
22
+ task.cancelled = true;
23
+ task.settled = true;
24
+ task.timeoutId = null;
25
+ reject(new Error('Script execution timeout'));
26
+ }, timeout + QUEUE_SLACK_MS);
27
+ this.queue.push(task);
28
+ void this.processQueue();
29
+ });
30
+ }
31
+ settleTask(task, fn) {
32
+ if (task.settled)
33
+ return;
34
+ task.settled = true;
35
+ if (task.timeoutId !== null) {
36
+ clearTimeout(task.timeoutId);
37
+ task.timeoutId = null;
38
+ }
39
+ fn();
40
+ }
41
+ async processQueue() {
42
+ if (this.isProcessing)
43
+ return;
44
+ this.isProcessing = true;
45
+ try {
46
+ while (this.queue.length > 0) {
47
+ const task = this.queue.shift();
48
+ if (task.cancelled)
49
+ continue;
50
+ try {
51
+ const result = await task.run();
52
+ this.settleTask(task, () => task.resolve(result));
53
+ }
54
+ catch (error) {
55
+ if (task.settled) {
56
+ this.logger.error('Script execution failed after settle:', error);
57
+ }
58
+ else {
59
+ this.settleTask(task, () => task.reject(error));
60
+ }
61
+ }
62
+ }
63
+ }
64
+ finally {
65
+ this.isProcessing = false;
66
+ }
67
+ }
68
+ }
@@ -55,7 +55,8 @@ export class MacOSDetector {
55
55
  }
56
56
  getCommonPaths() {
57
57
  const paths = [];
58
- for (let year = 2025; year >= 2012; year--) {
58
+ const maxYear = Math.max(new Date().getFullYear() + 1, 2026);
59
+ for (let year = maxYear; year >= 2012; year--) {
59
60
  paths.push(`/Applications/Adobe Photoshop ${year}/Adobe Photoshop ${year}.app`, `/Applications/Adobe Photoshop CC ${year}/Adobe Photoshop CC ${year}.app`, `/Applications/Adobe Photoshop ${year}.app`);
60
61
  }
61
62
  paths.push('/Applications/Adobe Photoshop CC/Adobe Photoshop CC.app', '/Applications/Adobe Photoshop/Adobe Photoshop.app', '/Applications/Adobe Photoshop.app');