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.
- package/README.md +8 -6
- package/dist/api/extendscript/_helpers.js +97 -13
- package/dist/api/photoshop-api.js +4 -3
- package/dist/api/snippet-client.js +38 -2
- 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 +56 -180
- package/dist/delivery/client.js +50 -1
- package/dist/delivery/provision.js +18 -1
- package/dist/delivery/signing.js +38 -1
- package/dist/delivery/store.js +149 -16
- package/dist/detection/detect-active-doc.js +41 -10
- package/dist/detection/detection-client.js +3 -3
- package/dist/detection/face-detector.js +2 -2
- package/dist/detection/geometry.js +14 -0
- package/dist/detection/landmark-detection-client.js +3 -3
- package/dist/detection/mesh-face.js +2 -3
- package/dist/detection/object-detector.js +2 -2
- package/dist/detection/runtime.js +14 -3
- package/dist/detection/sam-segmenter.js +69 -26
- package/dist/kernel/kernel.js +5 -9
- package/dist/kernel/module-lifecycle.js +181 -0
- package/dist/license/entitlement.js +13 -3
- package/dist/license/store.js +8 -1
- package/dist/modules/ce/index.js +1 -1
- package/dist/perception/facets.js +19 -1
- package/dist/perception/grounding-anchors.js +2 -12
- package/dist/perception/grounding-locate.js +1 -9
- package/dist/perception/grounding-review-crop.js +4 -29
- package/dist/perception/overlay.js +62 -0
- package/dist/perception/pixel-identity.js +41 -0
- package/dist/perception/region-precompute.js +85 -17
- package/dist/perception/scene-model.js +93 -50
- package/dist/perception/select-recipes.js +107 -66
- package/dist/perception/sky-ground-flood.js +0 -5
- package/dist/perception/sky-mask-transfer.js +72 -13
- package/dist/platform/connection.js +42 -11
- package/dist/platform/executor-queue.js +68 -0
- package/dist/platform/macos-detector.js +2 -1
- package/dist/platform/macos-executor.js +18 -67
- package/dist/platform/run-child.js +6 -3
- package/dist/platform/windows-detector.js +14 -7
- package/dist/platform/windows-executor.js +9 -64
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/tools/adjustment-tools.js +78 -130
- package/dist/tools/brush-tools.js +3 -14
- package/dist/tools/channel-compose-tools.js +4 -23
- package/dist/tools/detection-tools.js +59 -47
- package/dist/tools/document-tools.js +121 -131
- package/dist/tools/filter-tools.js +489 -730
- package/dist/tools/group-tools.js +90 -156
- package/dist/tools/guide-tools.js +52 -83
- package/dist/tools/history-tools.js +29 -63
- package/dist/tools/image-placement-tools.js +18 -37
- package/dist/tools/image-tools.js +23 -51
- package/dist/tools/layer-ordering-tools.js +2 -9
- package/dist/tools/layer-properties-tools.js +245 -384
- package/dist/tools/layer-tools.js +115 -181
- package/dist/tools/layer-transform-tools.js +41 -106
- package/dist/tools/metadata-tools.js +2 -9
- package/dist/tools/path-tools.js +3 -14
- package/dist/tools/portrait-tools.js +2 -5
- package/dist/tools/preview-tools.js +53 -65
- package/dist/tools/retouch-tools.js +32 -70
- package/dist/tools/scene-tools.js +94 -73
- package/dist/tools/selection-tools.js +306 -563
- package/dist/tools/shape-tools.js +18 -10
- package/dist/tools/text-tools.js +24 -57
- package/dist/tools/transform-canvas-tools.js +21 -54
- package/dist/tools/vector-mask-tools.js +2 -9
- package/dist/utils/jpeg-quality.js +1 -0
- package/dist/utils/logger.js +3 -0
- package/dist/utils/operation-timeouts.js +7 -0
- package/dist/utils/run-script.js +7 -2
- package/dist/utils/session-log.js +134 -12
- package/dist/utils/template-storage.js +5 -6
- package/dist/utils/tool-helpers.js +37 -0
- package/dist/version.js +1 -1
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 } =
|
|
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:
|
|
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
|
|
267
|
-
const
|
|
268
|
-
if (!
|
|
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', ...
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
102
|
-
await fs.writeFile(maskPath,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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');
|