editmamei 0.23.0 → 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 (79) hide show
  1. package/README.md +8 -6
  2. package/dist/api/extendscript/_helpers.js +0 -100
  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 +7 -62
  42. package/dist/platform/windows-detector.js +14 -7
  43. package/dist/platform/windows-executor.js +9 -64
  44. package/dist/skills/editmamei-skill.zip +0 -0
  45. package/dist/tools/adjustment-tools.js +62 -130
  46. package/dist/tools/brush-tools.js +3 -14
  47. package/dist/tools/channel-compose-tools.js +4 -23
  48. package/dist/tools/detection-tools.js +59 -47
  49. package/dist/tools/document-tools.js +87 -129
  50. package/dist/tools/filter-tools.js +489 -730
  51. package/dist/tools/group-tools.js +75 -156
  52. package/dist/tools/guide-tools.js +52 -83
  53. package/dist/tools/history-tools.js +29 -63
  54. package/dist/tools/image-placement-tools.js +18 -37
  55. package/dist/tools/image-tools.js +23 -51
  56. package/dist/tools/layer-ordering-tools.js +2 -9
  57. package/dist/tools/layer-properties-tools.js +207 -395
  58. package/dist/tools/layer-tools.js +95 -186
  59. package/dist/tools/layer-transform-tools.js +41 -106
  60. package/dist/tools/metadata-tools.js +2 -9
  61. package/dist/tools/path-tools.js +3 -14
  62. package/dist/tools/portrait-tools.js +2 -5
  63. package/dist/tools/preview-tools.js +51 -64
  64. package/dist/tools/retouch-tools.js +32 -70
  65. package/dist/tools/scene-tools.js +94 -73
  66. package/dist/tools/selection-tools.js +305 -563
  67. package/dist/tools/shape-tools.js +2 -9
  68. package/dist/tools/text-tools.js +24 -57
  69. package/dist/tools/transform-canvas-tools.js +21 -54
  70. package/dist/tools/vector-mask-tools.js +2 -9
  71. package/dist/utils/jpeg-quality.js +1 -0
  72. package/dist/utils/logger.js +3 -0
  73. package/dist/utils/operation-timeouts.js +1 -0
  74. package/dist/utils/run-script.js +7 -2
  75. package/dist/utils/session-log.js +133 -11
  76. package/dist/utils/template-storage.js +5 -6
  77. package/dist/utils/tool-helpers.js +37 -0
  78. package/dist/version.js +1 -1
  79. package/package.json +1 -1
@@ -1,7 +1,9 @@
1
1
  import { DeliveryClient, DeliveryError } from './client.js';
2
2
  import { sha256Hex } from './crypto.js';
3
- import { verifyModuleSignature } from './signing.js';
3
+ import { verifyModuleSignature, verifyModuleSignatureV2, digestsRootSha256Hex, isModuleFileDigestArray, } from './signing.js';
4
4
  import { installModule, readInstalledModule } from './store.js';
5
+ import { Logger } from '../utils/logger.js';
6
+ const logger = new Logger('Modules');
5
7
  const SKU_RE = /^[a-z0-9-]{2,32}$/;
6
8
  const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
7
9
  const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
@@ -99,6 +101,20 @@ export async function provisionModules(key, opts = {}) {
99
101
  });
100
102
  continue;
101
103
  }
104
+ let filesForRec;
105
+ let sigV2ForRec;
106
+ if (vEntry.files !== undefined || vEntry.sig_v2 !== undefined) {
107
+ if (isModuleFileDigestArray(vEntry.files) &&
108
+ typeof vEntry.sig_v2 === 'string' &&
109
+ verifyModuleSignatureV2(sku, latest, digest, digestsRootSha256Hex(vEntry.files), vEntry.sig_v2, opts.signingKeys)) {
110
+ filesForRec = vEntry.files;
111
+ sigV2ForRec = vEntry.sig_v2;
112
+ }
113
+ else {
114
+ logger.warn(`${sku} v${latest} manifest carried v2 fast-boot fields that failed verification — ` +
115
+ `installing via v1 trust only (boot will keep doing the full regen for this module)`);
116
+ }
117
+ }
102
118
  const contentKey = await client.fetchKey(key, sku);
103
119
  installModule({
104
120
  sku,
@@ -108,6 +124,7 @@ export async function provisionModules(key, opts = {}) {
108
124
  alg: contentKey.alg,
109
125
  content_key: contentKey.key,
110
126
  sig: sig,
127
+ ...(filesForRec && sigV2ForRec ? { files: filesForRec, sig_v2: sigV2ForRec } : {}),
111
128
  }, blob, opts);
112
129
  result.installed.push({ sku, version: latest });
113
130
  }
@@ -1,4 +1,4 @@
1
- import { createPublicKey, verify } from 'node:crypto';
1
+ import { createHash, createPublicKey, verify } from 'node:crypto';
2
2
  export const DEV_MODULE_SIGNING_PUBLIC_KEY = 'MCowBQYDK2VwAyEA0DfcGMets2SlBofK4iFzLddKZB6mJF/E3beKFpLRN2o=';
3
3
  const PROD_MODULE_SIGNING_PUBLIC_KEY = 'MCowBQYDK2VwAyEAweoGOWvtf+aukiAD0FpuECSbCdZMeFzF2XYzbFWwfwo=';
4
4
  export const MODULE_SIGNING_PUBLIC_KEYS = [PROD_MODULE_SIGNING_PUBLIC_KEY];
@@ -33,3 +33,40 @@ export function verifyModuleSignature(sku, version, sha256Hex, sigB64, pubKeys =
33
33
  }
34
34
  return false;
35
35
  }
36
+ export function isModuleFileDigestArray(v) {
37
+ return (Array.isArray(v) &&
38
+ v.every((e) => typeof e === 'object' &&
39
+ e !== null &&
40
+ typeof e.path === 'string' &&
41
+ typeof e.sha256 === 'string'));
42
+ }
43
+ export function canonicalDigestListString(files) {
44
+ const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
45
+ return JSON.stringify(sorted.map((f) => ({ path: f.path, sha256: f.sha256 })));
46
+ }
47
+ export function digestsRootSha256Hex(files) {
48
+ return createHash('sha256').update(canonicalDigestListString(files), 'utf8').digest('hex');
49
+ }
50
+ export function moduleSigV2Message(sku, version, artifactSha256Hex, digestsRootHex) {
51
+ return Buffer.from(`editmamei-module-sig-v2\n${sku}\n${version}\n${artifactSha256Hex}\n${digestsRootHex}`, 'utf8');
52
+ }
53
+ export function verifyModuleSignatureV2(sku, version, artifactSha256Hex, digestsRootHex, sigB64, pubKeys = MODULE_SIGNING_PUBLIC_KEYS) {
54
+ if (!sigB64)
55
+ return false;
56
+ const sig = Buffer.from(sigB64, 'base64');
57
+ if (sig.length === 0)
58
+ return false;
59
+ const msg = moduleSigV2Message(sku, version, artifactSha256Hex, digestsRootHex);
60
+ for (const b64 of pubKeys) {
61
+ const key = importSpki(b64);
62
+ if (!key)
63
+ continue;
64
+ try {
65
+ if (verify(null, msg, key, sig))
66
+ return true;
67
+ }
68
+ catch {
69
+ }
70
+ }
71
+ return false;
72
+ }
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdir
3
3
  import { settingsDir } from '../core/settings.js';
4
4
  import { installBundle } from './bundle.js';
5
5
  import { sha256Hex } from './crypto.js';
6
- import { verifyModuleSignature, MODULE_SIGNING_PUBLIC_KEYS } from './signing.js';
6
+ import { verifyModuleSignature, verifyModuleSignatureV2, digestsRootSha256Hex, isModuleFileDigestArray, MODULE_SIGNING_PUBLIC_KEYS, } from './signing.js';
7
7
  import { Logger } from '../utils/logger.js';
8
8
  const logger = new Logger('Modules');
9
9
  const INSTALLED_FILENAME = 'installed.json';
@@ -31,7 +31,7 @@ export function moduleBinDir(sku, version, opts = {}) {
31
31
  export function moduleArtifactPath(sku, version, opts = {}) {
32
32
  return join(installedModuleDir(sku, version, opts), MODULE_ARTIFACT);
33
33
  }
34
- function isInstalledModule(v) {
34
+ function isInstalledModuleBase(v) {
35
35
  if (typeof v !== 'object' || v === null)
36
36
  return false;
37
37
  const r = v;
@@ -50,16 +50,27 @@ export function readInstalledModule(sku, opts = {}) {
50
50
  return null;
51
51
  try {
52
52
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
53
- if (!isInstalledModule(parsed)) {
53
+ if (!isInstalledModuleBase(parsed)) {
54
54
  logger.warn(`installed.json for '${sku}' malformed — ignoring`);
55
55
  return null;
56
56
  }
57
- const manifest = join(installedModuleDir(sku, parsed.version, opts), MODULE_MANIFEST);
57
+ const filesOk = parsed.files === undefined || isModuleFileDigestArray(parsed.files);
58
+ const sigV2Ok = parsed.sig_v2 === undefined || typeof parsed.sig_v2 === 'string';
59
+ const bothPresent = parsed.files !== undefined && parsed.sig_v2 !== undefined;
60
+ const bothAbsent = parsed.files === undefined && parsed.sig_v2 === undefined;
61
+ if (!filesOk || !sigV2Ok || !(bothPresent || bothAbsent)) {
62
+ logger.warn(`installed.json for '${sku}' carried malformed v2 fast-boot fields — dropping ` +
63
+ `them and falling back to the v1 path`);
64
+ delete parsed.files;
65
+ delete parsed.sig_v2;
66
+ }
67
+ const rec = parsed;
68
+ const manifest = join(installedModuleDir(sku, rec.version, opts), MODULE_MANIFEST);
58
69
  if (!existsSync(manifest)) {
59
- logger.warn(`module '${sku}' v${parsed.version} install incomplete — ignoring pointer`);
70
+ logger.warn(`module '${sku}' v${rec.version} install incomplete — ignoring pointer`);
60
71
  return null;
61
72
  }
62
- return parsed;
73
+ return rec;
63
74
  }
64
75
  catch (err) {
65
76
  logger.warn(`installed.json for '${sku}' unreadable: ${err instanceof Error ? err.message : String(err)}`);
@@ -73,6 +84,9 @@ function writeFileAtomic(path, data) {
73
84
  writeFileSync(tmp, data, { mode: 0o600 });
74
85
  renameSync(tmp, path);
75
86
  }
87
+ function writeInstalledModule(rec, opts = {}) {
88
+ writeFileAtomic(installedPath(rec.sku, opts), JSON.stringify(rec, null, 2) + '\n');
89
+ }
76
90
  export function installModule(rec, blob, opts = {}) {
77
91
  const finalDir = installedModuleDir(rec.sku, rec.version, opts);
78
92
  const skuDir = dirname(finalDir);
@@ -87,19 +101,37 @@ export function installModule(rec, blob, opts = {}) {
87
101
  throw err;
88
102
  }
89
103
  if (opts.force && existsSync(finalDir)) {
90
- rmSync(finalDir, { recursive: true, force: true });
91
- }
92
- try {
93
- renameSync(tmpDir, finalDir);
104
+ const oldDir = join(skuDir, `${TMP_PREFIX}old-${rec.version}-${process.pid}`);
105
+ rmSync(oldDir, { recursive: true, force: true });
106
+ renameSync(finalDir, oldDir);
107
+ try {
108
+ renameSync(tmpDir, finalDir);
109
+ rmSync(oldDir, { recursive: true, force: true });
110
+ }
111
+ catch (err) {
112
+ rmSync(tmpDir, { recursive: true, force: true });
113
+ if (existsSync(finalDir)) {
114
+ rmSync(oldDir, { recursive: true, force: true });
115
+ }
116
+ else {
117
+ renameSync(oldDir, finalDir);
118
+ throw err;
119
+ }
120
+ }
94
121
  }
95
- catch (err) {
96
- rmSync(tmpDir, { recursive: true, force: true });
97
- if (!existsSync(finalDir))
98
- throw err;
122
+ else {
123
+ try {
124
+ renameSync(tmpDir, finalDir);
125
+ }
126
+ catch (err) {
127
+ rmSync(tmpDir, { recursive: true, force: true });
128
+ if (!existsSync(finalDir))
129
+ throw err;
130
+ }
99
131
  }
100
132
  const now = opts.now ?? Date.now;
101
133
  const installed = { ...rec, installed_at: new Date(now()).toISOString() };
102
- writeFileAtomic(installedPath(rec.sku, opts), JSON.stringify(installed, null, 2) + '\n');
134
+ writeInstalledModule(installed, opts);
103
135
  pruneOldModuleVersions(rec.sku, rec.version, opts);
104
136
  return installed;
105
137
  }
@@ -139,12 +171,72 @@ export function pruneOldModuleVersions(sku, keepVersion, opts = {}) {
139
171
  }
140
172
  }
141
173
  }
174
+ function verifyFastPath(sku, version, dir, artifactSha256Hex, files, sigV2, pubKeys) {
175
+ const digestsRoot = digestsRootSha256Hex(files);
176
+ if (!verifyModuleSignatureV2(sku, version, artifactSha256Hex, digestsRoot, sigV2, pubKeys)) {
177
+ return { ok: false, reason: 'sig_v2 did not verify against the pinned key' };
178
+ }
179
+ const expected = new Set(files.map((f) => f.path));
180
+ expected.add(MODULE_ARTIFACT);
181
+ let onDisk;
182
+ try {
183
+ onDisk = listFilesRecursive(dir);
184
+ }
185
+ catch (err) {
186
+ return {
187
+ ok: false,
188
+ reason: `tree enumeration failed: ${err instanceof Error ? err.message : String(err)}`,
189
+ };
190
+ }
191
+ const onDiskSet = new Set(onDisk);
192
+ for (const p of expected) {
193
+ if (!onDiskSet.has(p))
194
+ return { ok: false, reason: `missing file '${p}'` };
195
+ }
196
+ for (const p of onDisk) {
197
+ if (!expected.has(p))
198
+ return { ok: false, reason: `unexpected file '${p}'` };
199
+ }
200
+ for (const f of files) {
201
+ let bytes;
202
+ try {
203
+ bytes = readFileSync(join(dir, f.path));
204
+ }
205
+ catch (err) {
206
+ return {
207
+ ok: false,
208
+ reason: `could not read '${f.path}': ${err instanceof Error ? err.message : String(err)}`,
209
+ };
210
+ }
211
+ if (sha256Hex(bytes) !== f.sha256) {
212
+ return { ok: false, reason: `content mismatch on '${f.path}'` };
213
+ }
214
+ }
215
+ return { ok: true };
216
+ }
217
+ function listFilesRecursive(dir) {
218
+ const out = [];
219
+ const walk = (sub) => {
220
+ for (const entry of readdirSync(join(dir, sub), { withFileTypes: true })) {
221
+ const relPath = sub ? `${sub}/${entry.name}` : entry.name;
222
+ if (entry.isDirectory()) {
223
+ walk(relPath);
224
+ }
225
+ else {
226
+ out.push(relPath);
227
+ }
228
+ }
229
+ };
230
+ walk('');
231
+ return out;
232
+ }
142
233
  export function loadVerifiedModule(sku, opts = {}, pubKeys = MODULE_SIGNING_PUBLIC_KEYS) {
143
234
  const rec = readInstalledModule(sku, opts);
144
235
  if (!rec)
145
236
  return null;
146
237
  const version = rec.version;
147
238
  const dir = installedModuleDir(sku, version, opts);
239
+ const skuDir = dirname(dir);
148
240
  try {
149
241
  const blob = readFileSync(moduleArtifactPath(sku, version, opts));
150
242
  const digest = sha256Hex(blob);
@@ -156,12 +248,53 @@ export function loadVerifiedModule(sku, opts = {}, pubKeys = MODULE_SIGNING_PUBL
156
248
  logger.warn(`module '${sku}' v${version} signature did not verify at boot — refusing to load`);
157
249
  return null;
158
250
  }
159
- installBundle(blob, rec.content_key, dir);
251
+ if (rec.files && rec.sig_v2) {
252
+ const fast = verifyFastPath(sku, version, dir, digest, rec.files, rec.sig_v2, pubKeys);
253
+ if (fast.ok) {
254
+ return {
255
+ version,
256
+ dir,
257
+ handlersPath: moduleHandlersPath(sku, version, opts),
258
+ binDir: moduleBinDir(sku, version, opts),
259
+ regenerated: false,
260
+ };
261
+ }
262
+ logger.warn(`module '${sku}' v${version} fast-path check failed (${fast.reason}) — ` +
263
+ `regenerating from the verified artifact`);
264
+ }
265
+ const regenDir = join(skuDir, `${TMP_PREFIX}regen-${version}-${process.pid}`);
266
+ rmSync(regenDir, { recursive: true, force: true });
267
+ try {
268
+ installBundle(blob, rec.content_key, regenDir);
269
+ writeFileSync(join(regenDir, MODULE_ARTIFACT), Buffer.from(blob), { mode: 0o600 });
270
+ }
271
+ catch (err) {
272
+ rmSync(regenDir, { recursive: true, force: true });
273
+ throw err;
274
+ }
275
+ const oldDir = join(skuDir, `${TMP_PREFIX}regen-old-${version}-${process.pid}`);
276
+ rmSync(oldDir, { recursive: true, force: true });
277
+ renameSync(dir, oldDir);
278
+ try {
279
+ renameSync(regenDir, dir);
280
+ rmSync(oldDir, { recursive: true, force: true });
281
+ }
282
+ catch (err) {
283
+ rmSync(regenDir, { recursive: true, force: true });
284
+ if (existsSync(dir)) {
285
+ rmSync(oldDir, { recursive: true, force: true });
286
+ }
287
+ else {
288
+ renameSync(oldDir, dir);
289
+ throw err;
290
+ }
291
+ }
160
292
  return {
161
293
  version,
162
294
  dir,
163
295
  handlersPath: moduleHandlersPath(sku, version, opts),
164
296
  binDir: moduleBinDir(sku, version, opts),
297
+ regenerated: true,
165
298
  };
166
299
  }
167
300
  catch (err) {
@@ -3,8 +3,13 @@ import { getContextInfo } from '../api/extendscript/_helpers.js';
3
3
  import { jsLit, jsNum } from '../utils/jsx.js';
4
4
  import { runScript } from '../utils/run-script.js';
5
5
  import { TempDir, userOwnedTempRoot } from '../utils/temp.js';
6
+ import { decodeJpegBuffer } from './runtime.js';
6
7
  import { mapDetectionToDoc, } from './detection-client.js';
7
- export async function detectActiveDoc(connection, client, opts) {
8
+ import { Logger } from '../utils/logger.js';
9
+ const logger = new Logger('detect-active-doc');
10
+ export async function detectActiveDoc(connection, client, opts, deps = {}) {
11
+ const readFileFn = deps.readFile ?? readFile;
12
+ const decodeFn = deps.decode ?? decodeJpegBuffer;
8
13
  const maxDimension = opts.maxDimension ?? 1024;
9
14
  const dir = process.platform === 'darwin'
10
15
  ? await TempDir.createWithRoot(userOwnedTempRoot(), 'editmamei-detect-')
@@ -62,21 +67,47 @@ export async function detectActiveDoc(connection, client, opts) {
62
67
  const exp = (await runScript(connection, script));
63
68
  const docWidth = exp.doc_width ?? 0;
64
69
  const docHeight = exp.doc_height ?? 0;
65
- const raw = await client.detect(tempPath, {
66
- faces: opts.faces,
67
- objects: opts.objects,
68
- faceThreshold: opts.faceThreshold,
69
- objectThreshold: opts.objectThreshold,
70
- maxObjects: opts.maxObjects,
71
- });
72
70
  let exportBytes = Buffer.alloc(0);
71
+ let decoded;
73
72
  try {
74
- exportBytes = await readFile(tempPath);
73
+ exportBytes = await readFileFn(tempPath);
75
74
  }
76
75
  catch {
77
76
  }
77
+ if (exportBytes.length > 0) {
78
+ try {
79
+ decoded = decodeFn(exportBytes);
80
+ }
81
+ catch {
82
+ }
83
+ }
84
+ let doDetect = true;
85
+ if (opts.shouldDetect) {
86
+ try {
87
+ doDetect = opts.shouldDetect({ decoded, context: exp.context, docWidth, docHeight });
88
+ }
89
+ catch (err) {
90
+ const msg = err instanceof Error ? err.message : String(err);
91
+ logger.debug(`shouldDetect threw (${msg}) — degrading to detect normally`);
92
+ doDetect = true;
93
+ }
94
+ }
95
+ const raw = doDetect
96
+ ? await client.detect(tempPath, {
97
+ faces: opts.faces,
98
+ objects: opts.objects,
99
+ faceThreshold: opts.faceThreshold,
100
+ objectThreshold: opts.objectThreshold,
101
+ maxObjects: opts.maxObjects,
102
+ }, decoded)
103
+ : {
104
+ image: decoded
105
+ ? { width: decoded.width, height: decoded.height }
106
+ : { width: 0, height: 0 },
107
+ backends: {},
108
+ };
78
109
  const result = mapDetectionToDoc(raw, docWidth, docHeight);
79
- return { docWidth, docHeight, result, raw, context: exp.context, exportBytes };
110
+ return { docWidth, docHeight, result, raw, context: exp.context, exportBytes, decoded };
80
111
  }
81
112
  finally {
82
113
  await dir.cleanup();
@@ -1,13 +1,13 @@
1
1
  import { detectFaces } from './face-detector.js';
2
2
  import { detectObjects } from './object-detector.js';
3
3
  export class OnnxDetectionClient {
4
- async detect(imagePath, opts) {
4
+ async detect(imagePath, opts, decoded) {
5
5
  const wantFaces = opts.faces ?? false;
6
6
  const wantObjects = opts.objects ?? false;
7
7
  const [faceRes, objRes] = await Promise.all([
8
- wantFaces ? detectFaces(imagePath, opts.faceThreshold) : Promise.resolve(null),
8
+ wantFaces ? detectFaces(imagePath, opts.faceThreshold, decoded) : Promise.resolve(null),
9
9
  wantObjects
10
- ? detectObjects(imagePath, { threshold: opts.objectThreshold, maxObjects: opts.maxObjects })
10
+ ? detectObjects(imagePath, { threshold: opts.objectThreshold, maxObjects: opts.maxObjects }, decoded)
11
11
  : Promise.resolve(null),
12
12
  ]);
13
13
  const dims = faceRes ?? objRes;
@@ -5,8 +5,8 @@ const IN_W = 320;
5
5
  const IN_H = 240;
6
6
  const DEFAULT_THRESHOLD = 0.7;
7
7
  const IOU_THRESHOLD = 0.4;
8
- export async function detectFaces(imagePath, threshold = DEFAULT_THRESHOLD) {
9
- const img = decodeJpeg(imagePath);
8
+ export async function detectFaces(imagePath, threshold = DEFAULT_THRESHOLD, decoded) {
9
+ const img = decoded ?? decodeJpeg(imagePath);
10
10
  const session = await loadModel(resolveModelPath(MODEL_FILE));
11
11
  const input = resizeToCHW(img, IN_W, IN_H, (v) => (v - 127) / 128);
12
12
  const feeds = { input: new ort.Tensor('float32', input, [1, 3, IN_H, IN_W]) };
@@ -7,6 +7,20 @@ export function iou(a, b) {
7
7
  const ua = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter;
8
8
  return inter / (ua + 1e-9);
9
9
  }
10
+ const cx = (b) => (b[0] + b[2]) / 2;
11
+ const cy = (b) => (b[1] + b[3]) / 2;
12
+ const boxArea = (b) => (b[2] - b[0]) * (b[3] - b[1]);
13
+ export function orderBySpatialPick(items, pick) {
14
+ const cmp = {
15
+ leftmost: (a, b) => cx(a.bbox) - cx(b.bbox),
16
+ rightmost: (a, b) => cx(b.bbox) - cx(a.bbox),
17
+ topmost: (a, b) => cy(a.bbox) - cy(b.bbox),
18
+ bottommost: (a, b) => cy(b.bbox) - cy(a.bbox),
19
+ largest: (a, b) => boxArea(b.bbox) - boxArea(a.bbox),
20
+ smallest: (a, b) => boxArea(a.bbox) - boxArea(b.bbox),
21
+ };
22
+ return [...items].sort(cmp[pick]);
23
+ }
10
24
  export function greedyNms(items, iouThreshold, sameGroup) {
11
25
  const keep = [];
12
26
  for (const item of items) {
@@ -14,11 +14,11 @@ export class OnnxLandmarkDetectionClient {
14
14
  this.detectLandmarks = deps.detectLandmarks ?? realDetectLandmarks;
15
15
  this.decodeJpeg = deps.decodeJpeg ?? realDecodeJpeg;
16
16
  }
17
- async detect(imagePath, opts) {
18
- const result = await this.base.detect(imagePath, opts);
17
+ async detect(imagePath, opts, decoded) {
18
+ const result = await this.base.detect(imagePath, opts, decoded);
19
19
  if (!opts.faces || !result.faces || result.faces.length === 0)
20
20
  return result;
21
- const img = this.decodeJpeg(imagePath);
21
+ const img = decoded ?? this.decodeJpeg(imagePath);
22
22
  const minScore = this.opts.minScore ?? 0.5;
23
23
  const lmOpts = { margin: this.opts.margin };
24
24
  const correctEyes = this.opts.correctEyes ?? true;
@@ -1,3 +1,4 @@
1
+ import { orderBySpatialPick } from './geometry.js';
1
2
  export function meshFaces(faces) {
2
3
  const out = [];
3
4
  for (const f of faces) {
@@ -21,11 +22,9 @@ export function pickFace(faces, which) {
21
22
  case 'best':
22
23
  return [...faces].sort((a, b) => b.score - a.score || area(b) - area(a))[0];
23
24
  case 'leftmost':
24
- return byLeft[0];
25
25
  case 'rightmost':
26
- return byLeft[byLeft.length - 1];
27
26
  case 'largest':
28
- return [...faces].sort((a, b) => area(b) - area(a))[0];
27
+ return orderBySpatialPick(faces, which)[0];
29
28
  default: {
30
29
  const idx = Number.parseInt(which, 10);
31
30
  if (String(idx) === which.trim() && idx >= 0 && idx < byLeft.length)
@@ -90,10 +90,10 @@ export const COCO_LABELS = [
90
90
  'toothbrush',
91
91
  ];
92
92
  const sigmoid = (x) => 1 / (1 + Math.exp(-x));
93
- export async function detectObjects(imagePath, opts = {}) {
93
+ export async function detectObjects(imagePath, opts = {}, decoded) {
94
94
  const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
95
95
  const maxObjects = opts.maxObjects ?? DEFAULT_MAX;
96
- const img = decodeJpeg(imagePath);
96
+ const img = decoded ?? decodeJpeg(imagePath);
97
97
  const session = await loadModel(resolveModelPath(MODEL_FILE));
98
98
  const input = resizeToCHW(img, IN_SIZE, IN_SIZE, (v) => v / 255);
99
99
  const feeds = { pixel_values: new ort.Tensor('float32', input, [1, 3, IN_SIZE, IN_SIZE]) };
@@ -57,14 +57,25 @@ export function resolveModelPath(filename) {
57
57
  }
58
58
  return join(hostModelsDir(), filename);
59
59
  }
60
+ function decodeJpegBytes(bytes) {
61
+ const img = decode(bytes, { useTArray: true, formatAsRGBA: true });
62
+ return { width: img.width, height: img.height, data: img.data };
63
+ }
64
+ export function decodeJpegBuffer(bytes) {
65
+ try {
66
+ return decodeJpegBytes(bytes);
67
+ }
68
+ catch (err) {
69
+ const msg = err instanceof Error ? err.message : String(err);
70
+ throw new Error(`failed to decode JPEG buffer: ${msg}`);
71
+ }
72
+ }
60
73
  export function decodeJpeg(path) {
61
- let img;
62
74
  try {
63
- img = decode(readFileSync(path), { useTArray: true, formatAsRGBA: true });
75
+ return decodeJpegBytes(readFileSync(path));
64
76
  }
65
77
  catch (err) {
66
78
  const msg = err instanceof Error ? err.message : String(err);
67
79
  throw new Error(`failed to decode JPEG at ${path}: ${msg}`);
68
80
  }
69
- return { width: img.width, height: img.height, data: img.data };
70
81
  }
@@ -37,40 +37,83 @@ export function thresholdMask(maskData, n) {
37
37
  mask[i] = maskData[i] > 0 ? 1 : 0;
38
38
  return mask;
39
39
  }
40
+ const HASH_SAMPLE_BYTES = 4096;
41
+ const HASH_STRIDE_SAMPLES = 1024;
42
+ function fnv1a(data, start, end, seed) {
43
+ let h = seed;
44
+ for (let i = start; i < end; i++) {
45
+ h = Math.imul(h ^ data[i], 0x01000193) >>> 0;
46
+ }
47
+ return h;
48
+ }
49
+ function computeImageKey(img) {
50
+ const { width, height, data } = img;
51
+ const n = data.length;
52
+ let h = fnv1a(data, 0, Math.min(HASH_SAMPLE_BYTES, n), 0x811c9dc5);
53
+ h = fnv1a(data, Math.max(0, n - HASH_SAMPLE_BYTES), n, h);
54
+ const stride = Math.max(1, Math.floor(n / HASH_STRIDE_SAMPLES));
55
+ for (let i = 0; i < n; i += stride) {
56
+ h = Math.imul(h ^ data[i], 0x01000193) >>> 0;
57
+ }
58
+ return { width, height, byteLength: n, hash: h };
59
+ }
60
+ function sameImageKey(a, b) {
61
+ return (a.width === b.width &&
62
+ a.height === b.height &&
63
+ a.byteLength === b.byteLength &&
64
+ a.hash === b.hash);
65
+ }
66
+ async function realRunEncoder(img) {
67
+ const session = await loadModel(resolveModelPath(MODEL_ENCODER));
68
+ const input = new ort.Tensor('float32', toEncoderInput(img), [img.height, img.width, 3]);
69
+ const out = await session.run({ input_image: input });
70
+ return out.image_embeddings;
71
+ }
72
+ async function realRunDecoder(embedding, coords, labels, imgW, imgH) {
73
+ const session = await loadModel(resolveModelPath(MODEL_DECODER));
74
+ const nPts = labels.length;
75
+ const out = await session.run({
76
+ image_embeddings: embedding,
77
+ point_coords: new ort.Tensor('float32', coords, [1, nPts, 2]),
78
+ point_labels: new ort.Tensor('float32', labels, [1, nPts]),
79
+ mask_input: new ort.Tensor('float32', new Float32Array(256 * 256), [1, 1, 256, 256]),
80
+ has_mask_input: new ort.Tensor('float32', Float32Array.from([0]), [1]),
81
+ orig_im_size: new ort.Tensor('float32', Float32Array.from([imgH, imgW]), [2]),
82
+ });
83
+ const masks = out.masks;
84
+ const [, , mh, mw] = masks.dims;
85
+ return {
86
+ mask: thresholdMask(masks.data, mh * mw),
87
+ width: mw,
88
+ height: mh,
89
+ iou: out.iou_predictions.data[0] ?? 0,
90
+ };
91
+ }
40
92
  export class OnnxSamSegmenter {
41
- async encodeImage(img) {
42
- const session = await loadModel(resolveModelPath(MODEL_ENCODER));
43
- const input = new ort.Tensor('float32', toEncoderInput(img), [img.height, img.width, 3]);
44
- const out = await session.run({ input_image: input });
45
- return out.image_embeddings;
93
+ runEncoderImpl;
94
+ runDecoderImpl;
95
+ cachedKey = null;
96
+ cachedEmbedding = null;
97
+ constructor(deps = {}) {
98
+ this.runEncoderImpl = deps.runEncoder ?? realRunEncoder;
99
+ this.runDecoderImpl = deps.runDecoder ?? realRunDecoder;
46
100
  }
47
- async runDecoder(embedding, coords, labels, imgW, imgH) {
48
- const session = await loadModel(resolveModelPath(MODEL_DECODER));
49
- const nPts = labels.length;
50
- const out = await session.run({
51
- image_embeddings: embedding,
52
- point_coords: new ort.Tensor('float32', coords, [1, nPts, 2]),
53
- point_labels: new ort.Tensor('float32', labels, [1, nPts]),
54
- mask_input: new ort.Tensor('float32', new Float32Array(256 * 256), [1, 1, 256, 256]),
55
- has_mask_input: new ort.Tensor('float32', Float32Array.from([0]), [1]),
56
- orig_im_size: new ort.Tensor('float32', Float32Array.from([imgH, imgW]), [2]),
57
- });
58
- const masks = out.masks;
59
- const [, , mh, mw] = masks.dims;
60
- return {
61
- mask: thresholdMask(masks.data, mh * mw),
62
- width: mw,
63
- height: mh,
64
- iou: out.iou_predictions.data[0] ?? 0,
65
- };
101
+ async encodeImage(img) {
102
+ const key = computeImageKey(img);
103
+ if (this.cachedKey && sameImageKey(this.cachedKey, key))
104
+ return this.cachedEmbedding;
105
+ const embedding = await this.runEncoderImpl(img);
106
+ this.cachedKey = key;
107
+ this.cachedEmbedding = embedding;
108
+ return embedding;
66
109
  }
67
110
  async segmentBox(embedding, box, imgW, imgH) {
68
111
  const { coords, labels } = boxToPromptCoords(box, imgW, imgH);
69
- return this.runDecoder(embedding, coords, labels, imgW, imgH);
112
+ return this.runDecoderImpl(embedding, coords, labels, imgW, imgH);
70
113
  }
71
114
  async segmentPointsAt(embedding, fg, bg, imgW, imgH) {
72
115
  const { coords, labels } = pointsToPromptCoords(fg, bg, imgW, imgH);
73
- return this.runDecoder(embedding, coords, labels, imgW, imgH);
116
+ return this.runDecoderImpl(embedding, coords, labels, imgW, imgH);
74
117
  }
75
118
  async segment(img, box) {
76
119
  const embedding = await this.encodeImage(img);
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  import { CompositeSnippetClient } from '../api/snippet-client.js';
2
3
  import { Logger } from '../utils/logger.js';
3
4
  import { runScript } from '../utils/run-script.js';
@@ -10,7 +11,7 @@ export class Kernel {
10
11
  detection;
11
12
  sessionId;
12
13
  logger;
13
- invokeDepth = 0;
14
+ invokeDepth = new AsyncLocalStorage();
14
15
  static MAX_INVOKE_DEPTH = 8;
15
16
  constructor(deps) {
16
17
  this.registry = deps.registry;
@@ -43,16 +44,11 @@ export class Kernel {
43
44
  };
44
45
  }
45
46
  async invokeTool(name, args) {
46
- if (this.invokeDepth >= Kernel.MAX_INVOKE_DEPTH) {
47
+ const depth = this.invokeDepth.getStore() ?? 0;
48
+ if (depth >= Kernel.MAX_INVOKE_DEPTH) {
47
49
  throw new Error(`invokeTool recursion limit (${Kernel.MAX_INVOKE_DEPTH}) exceeded invoking '${name}'`);
48
50
  }
49
- this.invokeDepth++;
50
- try {
51
- return await this.registry.execute(name, args);
52
- }
53
- finally {
54
- this.invokeDepth--;
55
- }
51
+ return this.invokeDepth.run(depth + 1, () => this.registry.execute(name, args));
56
52
  }
57
53
  abiTooNew(manifest) {
58
54
  if (manifest.abi > KERNEL_ABI) {