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
@@ -11,6 +11,7 @@ export class DeliveryError extends Error {
11
11
  this.name = 'DeliveryError';
12
12
  }
13
13
  }
14
+ const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
14
15
  const USER_AGENT = 'editmamei-delivery-client/1';
15
16
  const HEADER_KEY = 'x-editmamei-license-key';
16
17
  const defaultFetch = (url, init) => fetch(url, init);
@@ -23,6 +24,7 @@ export class DeliveryClient {
23
24
  retry;
24
25
  sleep;
25
26
  minIntervalMs;
27
+ maxArtifactBytes;
26
28
  lastRequestAt = 0;
27
29
  constructor(opts = {}) {
28
30
  this.cfg = opts.config ?? resolveDeliveryConfig();
@@ -30,6 +32,7 @@ export class DeliveryClient {
30
32
  this.retry = { ...DEFAULT_RETRY, ...opts.retry };
31
33
  this.sleep = opts.sleep ?? defaultSleep;
32
34
  this.minIntervalMs = opts.minRequestIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
35
+ this.maxArtifactBytes = opts.maxArtifactBytes ?? MAX_ARTIFACT_BYTES;
33
36
  if (!this.cfg.baseUrl) {
34
37
  throw new DeliveryError('Delivery endpoint is not configured. Set EDITMAMEI_DELIVERY_URL ' +
35
38
  '(e.g. http://localhost:8787 for a local wrangler dev Worker).', 0, 'not_configured');
@@ -52,9 +55,55 @@ export class DeliveryClient {
52
55
  const res = await this.get(`/v1/modules/${sku}/${version}`, key);
53
56
  if (!res.ok)
54
57
  throw this.errorFor(res.status, await safeText(res), this.retryAfterMsOf(res));
55
- return new Uint8Array(await res.arrayBuffer());
58
+ const declared = Number(res.headers?.get('content-length'));
59
+ if (Number.isFinite(declared) && declared > this.maxArtifactBytes) {
60
+ throw this.oversizeError(res.status, declared);
61
+ }
62
+ const body = res.body;
63
+ if (!body) {
64
+ const buf = new Uint8Array(await res.arrayBuffer());
65
+ if (buf.length > this.maxArtifactBytes)
66
+ throw this.oversizeError(res.status, buf.length);
67
+ return buf;
68
+ }
69
+ return await this.readCapped(body, res.status);
56
70
  });
57
71
  }
72
+ async readCapped(body, status) {
73
+ const reader = body.getReader();
74
+ const chunks = [];
75
+ let total = 0;
76
+ try {
77
+ for (;;) {
78
+ const { done, value } = await reader.read();
79
+ if (done)
80
+ break;
81
+ total += value.byteLength;
82
+ if (total > this.maxArtifactBytes) {
83
+ try {
84
+ await reader.cancel('artifact exceeds byte cap');
85
+ }
86
+ catch {
87
+ }
88
+ throw this.oversizeError(status, total);
89
+ }
90
+ chunks.push(value);
91
+ }
92
+ }
93
+ finally {
94
+ reader.releaseLock();
95
+ }
96
+ const out = new Uint8Array(total);
97
+ let offset = 0;
98
+ for (const chunk of chunks) {
99
+ out.set(chunk, offset);
100
+ offset += chunk.byteLength;
101
+ }
102
+ return out;
103
+ }
104
+ oversizeError(status, sizeBytes) {
105
+ return new DeliveryError(`Artifact exceeds the ${this.maxArtifactBytes}-byte cap (${sizeBytes} bytes) — refusing to buffer further.`, status, 'oversize');
106
+ }
58
107
  async withRetry(fn) {
59
108
  let lastErr;
60
109
  for (let attempt = 1; attempt <= this.retry.attempts; attempt++) {
@@ -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
  }