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
@@ -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) {
@@ -0,0 +1,181 @@
1
+ import { dirname } from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { existsSync } from 'node:fs';
4
+ import { EDITION } from '../edition.js';
5
+ import { resolveProBinaryPath } from '../api/snippet-client.js';
6
+ import { isProEntitled } from '../license/entitlement.js';
7
+ import { loadVerifiedModule, readInstalledModule, installedPath, PRO_SKU, } from '../delivery/store.js';
8
+ import { provisionModules } from '../delivery/provision.js';
9
+ import { readLicense } from '../license/store.js';
10
+ import { HOST_MIN_ABI } from './host-api.js';
11
+ export function classifyModuleOutcome(inputs) {
12
+ if (inputs.proModuleLoaded && inputs.skipReason === null)
13
+ return 'loaded';
14
+ if (inputs.skipReason === 'corrupt')
15
+ return 'skipped_corrupt';
16
+ if (inputs.skipReason === 'incompatible')
17
+ return 'skipped_incompatible';
18
+ return inputs.entitled ? 'absent' : 'lapsed';
19
+ }
20
+ export class ModuleLifecycle {
21
+ deps;
22
+ kernel;
23
+ _proModule = null;
24
+ _moduleSkipReason = null;
25
+ constructor(deps) {
26
+ this.deps = deps;
27
+ }
28
+ get proModule() {
29
+ return this._proModule;
30
+ }
31
+ get moduleSkipReason() {
32
+ return this._moduleSkipReason;
33
+ }
34
+ setKernel(kernel) {
35
+ this.kernel = kernel;
36
+ }
37
+ resolveProModule() {
38
+ if (isProEntitled()) {
39
+ const verified = loadVerifiedModule(PRO_SKU);
40
+ if (verified) {
41
+ const abi = readInstalledModule(PRO_SKU)?.abi ?? null;
42
+ this._proModule = {
43
+ importer: () => import(pathToFileURL(verified.handlersPath).href),
44
+ binDir: verified.binDir,
45
+ abi,
46
+ };
47
+ return this._proModule;
48
+ }
49
+ if (EDITION !== 'dev' && existsSync(installedPath(PRO_SKU))) {
50
+ this._moduleSkipReason = 'corrupt';
51
+ }
52
+ }
53
+ if (EDITION === 'dev') {
54
+ this._proModule = {
55
+ importer: () => import('../modules/pro/index.js'),
56
+ binDir: dirname(resolveProBinaryPath()),
57
+ abi: null,
58
+ };
59
+ return this._proModule;
60
+ }
61
+ this._proModule = null;
62
+ return null;
63
+ }
64
+ async loadModules() {
65
+ if (!this._proModule)
66
+ return;
67
+ if (this._proModule.abi !== null && this._proModule.abi < HOST_MIN_ABI) {
68
+ this.deps.logger.warn(`Pro module (abi ${this._proModule.abi}) is older than this host requires ` +
69
+ `(min abi ${HOST_MIN_ABI}) — booting Community; will re-provision in the background.`);
70
+ this._moduleSkipReason = 'incompatible';
71
+ return;
72
+ }
73
+ const snapshot = this.deps.toolRegistry.snapshot();
74
+ try {
75
+ await this.kernel.loadDownloaded(this._proModule.importer);
76
+ this.deps.assertToolsClassified();
77
+ }
78
+ catch (err) {
79
+ const changed = this.deps.toolRegistry.count() - snapshot.size;
80
+ this.deps.toolRegistry.restore(snapshot);
81
+ this.deps.logger.warn(`Pro module could not be loaded on this host — booting Community and rolling back ` +
82
+ `${changed} module tool change(s); will re-provision in the background: ` +
83
+ `${err instanceof Error ? err.message : String(err)}`);
84
+ this._moduleSkipReason = 'incompatible';
85
+ }
86
+ }
87
+ computeModuleStatus() {
88
+ if (!readLicense())
89
+ return null;
90
+ const installed = readInstalledModule(PRO_SKU);
91
+ return {
92
+ module: PRO_SKU,
93
+ outcome: classifyModuleOutcome({
94
+ proModuleLoaded: this._proModule !== null,
95
+ skipReason: this._moduleSkipReason,
96
+ entitled: isProEntitled(),
97
+ }),
98
+ module_version: installed?.version ?? null,
99
+ abi: installed?.abi ?? null,
100
+ };
101
+ }
102
+ async reprovisionIfModuleSkipped(delivery = {}) {
103
+ const reason = this._moduleSkipReason;
104
+ if (reason === null)
105
+ return;
106
+ const license = readLicense();
107
+ if (!license) {
108
+ this.deps.logger.warn('A Pro module was skipped but no cached license was found — staying Community. ' +
109
+ 'Run `editmamei activate <key>` to restore Pro.');
110
+ return;
111
+ }
112
+ try {
113
+ const prov = await provisionModules(license.key, {
114
+ force: reason === 'corrupt',
115
+ config: delivery.config,
116
+ fetchImpl: delivery.fetchImpl,
117
+ signingKeys: delivery.signingKeys,
118
+ sleep: delivery.sleep,
119
+ });
120
+ if (prov.installed.length > 0) {
121
+ for (const m of prov.installed) {
122
+ this.deps.logger.info(`Pro module updated to v${m.version}, restart to load.`);
123
+ }
124
+ return;
125
+ }
126
+ if (prov.notConfigured) {
127
+ this.deps.logger.warn('Module delivery is not configured — staying Community.');
128
+ return;
129
+ }
130
+ for (const e of prov.errors) {
131
+ this.deps.logger.warn(`Could not re-provision the ${e.sku} module (staying Community): ${e.message}`);
132
+ }
133
+ if (prov.errors.length === 0) {
134
+ if (reason === 'incompatible') {
135
+ this.deps.logger.warn('The published Pro module does not yet support this host version — staying ' +
136
+ 'Community. Update Editmamei when a compatible release ships; ' +
137
+ '`editmamei report` files a diagnostic.');
138
+ }
139
+ else {
140
+ this.deps.logger.warn('Re-provision installed nothing for the corrupt Pro module — staying ' +
141
+ 'Community. Try `editmamei repair`; `editmamei report` if it persists.');
142
+ }
143
+ }
144
+ }
145
+ catch (err) {
146
+ this.deps.logger.warn(`Background Pro-module re-provision failed (staying Community): ` +
147
+ `${err instanceof Error ? err.message : String(err)}`);
148
+ }
149
+ }
150
+ async ensureEntitledModuleFresh(delivery = {}) {
151
+ if (this._moduleSkipReason !== null)
152
+ return;
153
+ if (!isProEntitled())
154
+ return;
155
+ const underTest = process.env.VITEST !== undefined || process.env.NODE_ENV === 'test';
156
+ if (underTest && !delivery.fetchImpl)
157
+ return;
158
+ const license = readLicense();
159
+ if (!license)
160
+ return;
161
+ try {
162
+ const prov = await provisionModules(license.key, {
163
+ config: delivery.config,
164
+ fetchImpl: delivery.fetchImpl,
165
+ signingKeys: delivery.signingKeys,
166
+ sleep: delivery.sleep,
167
+ });
168
+ for (const m of prov.installed) {
169
+ this.deps.logger.info(`Pro module updated to v${m.version} — restart to load.`);
170
+ }
171
+ for (const e of prov.errors) {
172
+ this.deps.logger.warn(`Pro-module freshness check could not provision the ${e.sku} module ` +
173
+ `(staying on the installed version): ${e.message}`);
174
+ }
175
+ }
176
+ catch (err) {
177
+ this.deps.logger.warn(`Background Pro-module freshness check failed (staying on the installed version): ` +
178
+ `${err instanceof Error ? err.message : String(err)}`);
179
+ }
180
+ }
181
+ }
@@ -1,6 +1,6 @@
1
1
  import { resolvePolarConfig } from './config.js';
2
2
  import { computeDeviceHash } from './device-hash.js';
3
- import { readLicense, writeLicense, clearLicense, } from './store.js';
3
+ import { readLicense, writeLicense, clearLicense, nextHighWaterMark, } from './store.js';
4
4
  import { PolarLicenseClient, PolarLicenseError } from './polar-client.js';
5
5
  import { Logger } from '../utils/logger.js';
6
6
  const logger = new Logger('License');
@@ -22,6 +22,12 @@ export function evaluateEntitlement(rec, now) {
22
22
  if (!Number.isFinite(last) || now - last > GRACE_MS) {
23
23
  return { entitled: false, reason: 'grace-expired' };
24
24
  }
25
+ if (rec.high_water_mark !== undefined) {
26
+ const hwm = Date.parse(rec.high_water_mark);
27
+ if (Number.isFinite(hwm) && now < hwm - CLOCK_SKEW_TOLERANCE_MS) {
28
+ return { entitled: false, reason: 'grace-expired' };
29
+ }
30
+ }
25
31
  return { entitled: true, reason: 'granted' };
26
32
  }
27
33
  const defaultFetch = (url, init) => fetch(url, init);
@@ -51,6 +57,7 @@ export async function activate(key, ops = {}) {
51
57
  const deviceHash = computeDeviceHash();
52
58
  const activation = await polar.activate(trimmed, deviceHash);
53
59
  const v = await polar.validate(trimmed);
60
+ const nowMs = now();
54
61
  const rec = {
55
62
  key: trimmed,
56
63
  organization_id: cfg.organizationId,
@@ -59,7 +66,8 @@ export async function activate(key, ops = {}) {
59
66
  activation_id: activation.id,
60
67
  device_hash: deviceHash,
61
68
  display_key: v.display_key,
62
- last_validated_at: new Date(now()).toISOString(),
69
+ last_validated_at: new Date(nowMs).toISOString(),
70
+ high_water_mark: nextHighWaterMark(existing, nowMs),
63
71
  };
64
72
  writeLicense(rec, ops);
65
73
  return rec;
@@ -120,12 +128,14 @@ export async function refresh(ops = {}) {
120
128
  const { polar } = makeClient(ops);
121
129
  const now = ops.now ?? Date.now;
122
130
  const v = await polar.validate(rec.key);
131
+ const nowMs = now();
123
132
  const updated = {
124
133
  ...rec,
125
134
  status: v.status,
126
135
  expires_at: v.expires_at,
127
136
  display_key: v.display_key,
128
- last_validated_at: new Date(now()).toISOString(),
137
+ last_validated_at: new Date(nowMs).toISOString(),
138
+ high_water_mark: nextHighWaterMark(rec, nowMs),
129
139
  };
130
140
  writeLicense(updated, ops);
131
141
  return updated;
@@ -18,7 +18,8 @@ function isLicenseRecord(v) {
18
18
  typeof r.activation_id === 'string' &&
19
19
  typeof r.device_hash === 'string' &&
20
20
  typeof r.display_key === 'string' &&
21
- typeof r.last_validated_at === 'string');
21
+ typeof r.last_validated_at === 'string' &&
22
+ (r.high_water_mark === undefined || typeof r.high_water_mark === 'string'));
22
23
  }
23
24
  export function readLicense(opts = {}) {
24
25
  const path = licensePath(opts);
@@ -45,6 +46,12 @@ export function writeLicense(rec, opts = {}) {
45
46
  writeFileSync(tmp, JSON.stringify(rec, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
46
47
  renameSync(tmp, path);
47
48
  }
49
+ export function nextHighWaterMark(existing, ...candidatesMs) {
50
+ const priorMs = existing?.high_water_mark ? Date.parse(existing.high_water_mark) : NaN;
51
+ const finite = [priorMs, ...candidatesMs].filter((ms) => Number.isFinite(ms));
52
+ const hwm = finite.length ? Math.max(...finite) : Date.now();
53
+ return new Date(hwm).toISOString();
54
+ }
48
55
  export function clearLicense(opts = {}) {
49
56
  const path = licensePath(opts);
50
57
  if (existsSync(path))
@@ -64,7 +64,7 @@ export const ceModule = {
64
64
  const { connection, snippet } = host;
65
65
  const defs = [
66
66
  ...ceFactories.flatMap((f) => f(connection, snippet)),
67
- ...createSceneTools(connection, snippet, undefined, host.invokeTool),
67
+ ...createSceneTools(connection, snippet, { invokeTool: host.invokeTool }),
68
68
  ].filter((def) => isToolAllowedInEdition(def.tool.name, EDITION));
69
69
  host.registerTools(defs);
70
70
  },
@@ -37,7 +37,7 @@ export function pickThresholdLevel(hist) {
37
37
  bright += bins[i];
38
38
  return { level, bright_fraction: bright / total };
39
39
  }
40
- export function estimateHorizon(rowMeans, docHeight, pick) {
40
+ export function estimateHorizon(rowMeans, docHeight, pick, mainSubjectBox, primaryFaceBox) {
41
41
  const split = pick.bright_fraction;
42
42
  const splitConfidence = Math.max(0, Math.min(1, 1 - Math.abs(0.5 - split) * 2)) * 0.6 + 0.2;
43
43
  if (!rowMeans || rowMeans.length === 0) {
@@ -55,12 +55,30 @@ export function estimateHorizon(rowMeans, docHeight, pick) {
55
55
  return { y: Math.round(docHeight / 3), placement: 1 / 3, confidence: 0.2 };
56
56
  }
57
57
  const y = Math.round((crossRow / R) * docHeight);
58
+ if ((mainSubjectBox && isDominantSubjectCrossing(y, docHeight, mainSubjectBox)) ||
59
+ (primaryFaceBox && isPortraitFaceCrossing(y, docHeight, primaryFaceBox))) {
60
+ return { y: Math.round(docHeight / 3), placement: 1 / 3, confidence: 0.2 };
61
+ }
58
62
  return {
59
63
  y,
60
64
  placement: y / docHeight,
61
65
  confidence: Math.max(0, Math.min(1, splitConfidence)),
62
66
  };
63
67
  }
68
+ function isDominantSubjectCrossing(y, docHeight, box) {
69
+ if (docHeight <= 0)
70
+ return false;
71
+ const [, top, , bottom] = box;
72
+ const spans = (bottom - top) / docHeight;
73
+ return spans >= 0.6 && y >= top && y <= bottom;
74
+ }
75
+ function isPortraitFaceCrossing(y, docHeight, face) {
76
+ if (docHeight <= 0)
77
+ return false;
78
+ const [, top, , bottom] = face;
79
+ const spans = (bottom - top) / docHeight;
80
+ return spans >= 0.25 && y >= top;
81
+ }
64
82
  export function computeTonalZones(hist) {
65
83
  const { bins } = hist;
66
84
  const total = hist.total || bins.reduce((a, b) => a + b, 0);
@@ -1,19 +1,9 @@
1
+ import { orderBySpatialPick } from '../detection/geometry.js';
1
2
  export function bboxToBox(b) {
2
3
  return { left: b[0], top: b[1], right: b[2], bottom: b[3] };
3
4
  }
4
- const cx = (b) => (b[0] + b[2]) / 2;
5
- const cy = (b) => (b[1] + b[3]) / 2;
6
- const area = (b) => (b[2] - b[0]) * (b[3] - b[1]);
7
5
  function orderBy(items, pick) {
8
- const cmp = {
9
- leftmost: (a, b) => cx(a.bbox) - cx(b.bbox),
10
- rightmost: (a, b) => cx(b.bbox) - cx(a.bbox),
11
- topmost: (a, b) => cy(a.bbox) - cy(b.bbox),
12
- bottommost: (a, b) => cy(b.bbox) - cy(a.bbox),
13
- largest: (a, b) => area(b.bbox) - area(a.bbox),
14
- smallest: (a, b) => area(a.bbox) - area(b.bbox),
15
- };
16
- return pick === 'confidence' ? items : [...items].sort(cmp[pick]);
6
+ return pick === 'confidence' ? items : orderBySpatialPick(items, pick);
17
7
  }
18
8
  export function localizeAnchor(det, sel) {
19
9
  const candidates = sel.kind === 'face'
@@ -1,4 +1,3 @@
1
- import { decode } from 'jpeg-js';
2
1
  import { ValidationError } from '../utils/validate.js';
3
2
  import { detectActiveDoc } from '../detection/detect-active-doc.js';
4
3
  import { localizeAnchor } from './grounding-anchors.js';
@@ -214,14 +213,7 @@ export async function resolveToGeometry(connection, client, input) {
214
213
  });
215
214
  const docW = det.docWidth;
216
215
  const docH = det.docHeight;
217
- let exportImg = null;
218
- try {
219
- if (det.exportBytes.length > 0)
220
- exportImg = decode(det.exportBytes, { useTArray: true, formatAsRGBA: true });
221
- }
222
- catch {
223
- exportImg = null;
224
- }
216
+ const exportImg = det.decoded ?? null;
225
217
  const s = exportImg ? exportImg.width / docW : 1;
226
218
  const anchors = {};
227
219
  const anchorMeta = {};
@@ -1,31 +1,5 @@
1
1
  import { boundsOf } from './grounding-geometry.js';
2
- function setPx(img, x, y, [r, g, b]) {
3
- if (x < 0 || y < 0 || x >= img.width || y >= img.height)
4
- return;
5
- const i = (y * img.width + x) * 4;
6
- img.data[i] = r;
7
- img.data[i + 1] = g;
8
- img.data[i + 2] = b;
9
- img.data[i + 3] = 255;
10
- }
11
- function drawRing(img, c, radius, thick, color) {
12
- const cx = Math.round(c.x), cy = Math.round(c.y);
13
- const outer = radius + thick;
14
- for (let dy = -outer; dy <= outer; dy++) {
15
- for (let dx = -outer; dx <= outer; dx++) {
16
- const d = Math.hypot(dx, dy);
17
- if (d >= radius - thick && d <= radius + thick)
18
- setPx(img, cx + dx, cy + dy, color);
19
- }
20
- }
21
- }
22
- function disc(img, c, radius, color) {
23
- const cx = Math.round(c.x), cy = Math.round(c.y);
24
- for (let dy = -radius; dy <= radius; dy++)
25
- for (let dx = -radius; dx <= radius; dx++)
26
- if (dx * dx + dy * dy <= radius * radius)
27
- setPx(img, cx + dx, cy + dy, color);
28
- }
2
+ import { drawRing, fillDisc } from './overlay.js';
29
3
  function drawPolyline(img, pts, thick, color, closed = false) {
30
4
  const r = Math.max(0, thick - 1);
31
5
  const seq = closed && pts.length > 2 ? [...pts, pts[0]] : pts;
@@ -34,7 +8,7 @@ function drawPolyline(img, pts, thick, color, closed = false) {
34
8
  const steps = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y)));
35
9
  for (let s = 0; s <= steps; s++) {
36
10
  const t = s / steps;
37
- disc(img, { x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) }, r, color);
11
+ fillDisc(img, a.x + t * (b.x - a.x), a.y + t * (b.y - a.y), r, color);
38
12
  }
39
13
  }
40
14
  }
@@ -73,7 +47,8 @@ export function renderReviewCrop(img, placement, opts = {}) {
73
47
  }
74
48
  const toCrop = (p) => ({ x: p.x - ox, y: p.y - oy });
75
49
  if (placement.kind === 'point') {
76
- drawRing(out, toCrop(placement.point), opts.ringRadius ?? 13, opts.ringThickness ?? 2, color);
50
+ const p = toCrop(placement.point);
51
+ drawRing(out, p.x, p.y, opts.ringRadius ?? 13, opts.ringThickness ?? 2, color);
77
52
  }
78
53
  else if (placement.kind === 'curve') {
79
54
  drawPolyline(out, placement.curve.map(toCrop), opts.strokeThickness ?? 2, color);
@@ -0,0 +1,62 @@
1
+ export const ANNOTATION_RGB = {
2
+ face: [0, 220, 255],
3
+ object: [255, 0, 220],
4
+ horizon: [255, 230, 0],
5
+ };
6
+ export function annotationThickness(img) {
7
+ return Math.max(2, Math.round(Math.max(img.width, img.height) / 400));
8
+ }
9
+ export function setPx(img, x, y, [r, g, b]) {
10
+ if (x < 0 || y < 0 || x >= img.width || y >= img.height)
11
+ return;
12
+ const i = (y * img.width + x) * 4;
13
+ img.data[i] = r;
14
+ img.data[i + 1] = g;
15
+ img.data[i + 2] = b;
16
+ img.data[i + 3] = 255;
17
+ }
18
+ export function drawBoxOutline(img, box, rgb, thickness) {
19
+ const [x1, y1, x2, y2] = box.map(Math.round);
20
+ for (let x = x1; x <= x2; x++)
21
+ for (let k = 0; k < thickness; k++) {
22
+ setPx(img, x, y1 + k, rgb);
23
+ setPx(img, x, y2 - k, rgb);
24
+ }
25
+ for (let y = y1; y <= y2; y++)
26
+ for (let k = 0; k < thickness; k++) {
27
+ setPx(img, x1 + k, y, rgb);
28
+ setPx(img, x2 - k, y, rgb);
29
+ }
30
+ }
31
+ export function drawHLine(img, y, rgb, thickness) {
32
+ for (let x = 0; x < img.width; x++)
33
+ for (let k = 0; k < thickness; k++)
34
+ setPx(img, x, y + k, rgb);
35
+ }
36
+ export function fillSquare(img, cx, cy, radius, rgb) {
37
+ const x0 = Math.round(cx);
38
+ const y0 = Math.round(cy);
39
+ for (let dy = -radius; dy <= radius; dy++)
40
+ for (let dx = -radius; dx <= radius; dx++)
41
+ setPx(img, x0 + dx, y0 + dy, rgb);
42
+ }
43
+ export function drawRing(img, cx, cy, radius, thick, rgb) {
44
+ const x0 = Math.round(cx);
45
+ const y0 = Math.round(cy);
46
+ const outer = radius + thick;
47
+ for (let dy = -outer; dy <= outer; dy++) {
48
+ for (let dx = -outer; dx <= outer; dx++) {
49
+ const d = Math.hypot(dx, dy);
50
+ if (d >= radius - thick && d <= radius + thick)
51
+ setPx(img, x0 + dx, y0 + dy, rgb);
52
+ }
53
+ }
54
+ }
55
+ export function fillDisc(img, cx, cy, radius, rgb) {
56
+ const x0 = Math.round(cx);
57
+ const y0 = Math.round(cy);
58
+ for (let dy = -radius; dy <= radius; dy++)
59
+ for (let dx = -radius; dx <= radius; dx++)
60
+ if (dx * dx + dy * dy <= radius * radius)
61
+ setPx(img, x0 + dx, y0 + dy, rgb);
62
+ }
@@ -0,0 +1,41 @@
1
+ const HASH_SAMPLE_BYTES = 4096;
2
+ const HASH_STRIDE_SAMPLES = 1024;
3
+ function fnv1a(data, start, end, seed) {
4
+ let h = seed;
5
+ for (let i = start; i < end; i++) {
6
+ h = Math.imul(h ^ data[i], 0x01000193) >>> 0;
7
+ }
8
+ return h;
9
+ }
10
+ export function docKeyFrom(context, docWidth, docHeight) {
11
+ const doc = context
12
+ ?.document;
13
+ const name = typeof doc?.name === 'string' ? doc.name : null;
14
+ if (name === null)
15
+ return null;
16
+ const sel = doc?.hasSelection === true ? 'S' : 'N';
17
+ return `${name}:${docWidth}x${docHeight}:${sel}`;
18
+ }
19
+ export function computePixelIdentity(decoded, docKey) {
20
+ const { width, height, data } = decoded;
21
+ const n = data.length;
22
+ let h = fnv1a(data, 0, Math.min(HASH_SAMPLE_BYTES, n), 0x811c9dc5);
23
+ h = fnv1a(data, Math.max(0, n - HASH_SAMPLE_BYTES), n, h);
24
+ const stride = Math.max(1, Math.floor(n / HASH_STRIDE_SAMPLES));
25
+ for (let i = 0; i < n; i += stride) {
26
+ h = Math.imul(h ^ data[i], 0x01000193) >>> 0;
27
+ }
28
+ return { docKey, width, height, byteLength: n, hash: h };
29
+ }
30
+ export function samePixelIdentity(a, b) {
31
+ if (a.docKey === null || b.docKey === null)
32
+ return false;
33
+ return (a.docKey === b.docKey &&
34
+ a.width === b.width &&
35
+ a.height === b.height &&
36
+ a.byteLength === b.byteLength &&
37
+ a.hash === b.hash);
38
+ }
39
+ export function identityKeyString(identity) {
40
+ return `${identity.docKey}:${identity.width}x${identity.height}:${identity.byteLength}:${identity.hash.toString(16)}`;
41
+ }