editmamei 0.17.4 → 0.18.0
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/NOTICES.md +43 -0
- package/README.md +91 -8
- package/dist/api/extendscript/_helpers.js +8 -0
- 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/cli/clients/json-config.js +1 -1
- package/dist/core/server.js +92 -24
- package/dist/core/tool-groups.js +178 -0
- package/dist/core/tool-tiers.js +34 -48
- package/dist/delivery/bundle.js +8 -0
- package/dist/delivery/config.js +23 -0
- package/dist/delivery/provision.js +30 -2
- package/dist/delivery/store.js +40 -1
- package/dist/detection/detect-active-doc.js +84 -0
- package/dist/detection/detection-client.js +62 -0
- package/dist/detection/face-contour-geometry.js +137 -0
- package/dist/detection/face-detector.js +34 -0
- package/dist/detection/face-feature-geometry.js +180 -0
- package/dist/detection/geometry.js +35 -0
- package/dist/detection/landmark-detection-client.js +38 -0
- package/dist/detection/landmark-detector.js +63 -0
- package/dist/detection/landmark-spec.js +29 -0
- package/dist/detection/mesh-face.js +36 -0
- package/dist/detection/object-detector.js +135 -0
- package/dist/detection/runtime.js +46 -0
- package/dist/license/env-activation.js +46 -7
- package/dist/models/dfine-s-coco-quant.onnx +0 -0
- package/dist/models/ultraface-rfb-320.onnx +0 -0
- package/dist/modules/ce/index.js +23 -5
- package/dist/perception/facets.js +148 -0
- package/dist/perception/region-precompute.js +122 -0
- package/dist/perception/region-scorer.js +151 -0
- package/dist/perception/scene-model.js +156 -0
- package/dist/perception/select-recipes.js +493 -0
- package/dist/perception/sky-ground-flood.js +234 -0
- package/dist/perception/sky-mask-transfer.js +77 -0
- package/dist/platform/macos-executor.js +0 -1
- package/dist/platform/windows-executor.js +6 -1
- package/dist/preview/coordinate-grid.js +250 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/events.js +2 -1
- package/dist/telemetry/sanitize.js +15 -1
- package/dist/tools/adjustment-tools.js +46 -51
- package/dist/tools/detection-tools.js +209 -0
- package/dist/tools/document-tools.js +41 -30
- package/dist/tools/filter-tools.js +616 -101
- package/dist/tools/guide-tools.js +186 -0
- package/dist/tools/history-tools.js +1 -39
- package/dist/tools/image-tools.js +88 -0
- package/dist/tools/inspect-tools.js +86 -0
- package/dist/tools/layer-properties-tools.js +195 -106
- package/dist/tools/layer-tools.js +55 -14
- package/dist/tools/layer-transform-tools.js +236 -73
- package/dist/tools/metadata-tools.js +2 -32
- package/dist/tools/object-tools.js +193 -0
- package/dist/tools/overview-tools.js +69 -43
- package/dist/tools/path-tools.js +274 -0
- package/dist/tools/portrait-tools.js +146 -0
- package/dist/tools/preview-tools.js +78 -3
- package/dist/tools/retouch-tools.js +45 -33
- package/dist/tools/scene-tools.js +401 -0
- package/dist/tools/selection-tools.js +766 -228
- package/dist/tools/text-on-object-tools.js +225 -0
- package/dist/tools/text-tools.js +47 -56
- package/dist/tools/transform-canvas-tools.js +138 -0
- package/dist/tools/vector-mask-tools.js +116 -0
- package/dist/version.js +1 -1
- package/package.json +4 -2
package/dist/core/tool-tiers.js
CHANGED
|
@@ -1,26 +1,17 @@
|
|
|
1
1
|
export const TOOL_TIERS = {
|
|
2
2
|
photoshop_ping: 'community',
|
|
3
|
+
photoshop_list_capabilities: 'community',
|
|
3
4
|
photoshop_list_actions: 'pro',
|
|
4
5
|
photoshop_play_action: 'pro',
|
|
5
6
|
photoshop_execute_script: 'pro',
|
|
6
7
|
photoshop_add_adjustment_layer: 'community',
|
|
7
|
-
|
|
8
|
-
photoshop_apply_equalize: 'community',
|
|
9
|
-
photoshop_apply_color_lookup: 'community',
|
|
8
|
+
photoshop_apply_adjustment: 'community',
|
|
10
9
|
photoshop_create_document: 'community',
|
|
11
10
|
photoshop_close_document: 'community',
|
|
12
11
|
photoshop_open_document: 'community',
|
|
13
12
|
photoshop_save_psd: 'community',
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
photoshop_apply_gaussian_blur: 'community',
|
|
17
|
-
photoshop_apply_sharpen: 'community',
|
|
18
|
-
photoshop_apply_noise: 'community',
|
|
19
|
-
photoshop_apply_motion_blur: 'community',
|
|
20
|
-
photoshop_apply_lens_blur: 'community',
|
|
21
|
-
photoshop_apply_smart_sharpen: 'community',
|
|
22
|
-
photoshop_apply_reduce_noise: 'community',
|
|
23
|
-
photoshop_apply_high_pass: 'community',
|
|
13
|
+
photoshop_export: 'community',
|
|
14
|
+
photoshop_apply_filter: 'community',
|
|
24
15
|
photoshop_create_group: 'community',
|
|
25
16
|
photoshop_move_layer_to_group: 'community',
|
|
26
17
|
photoshop_set_group_blend_mode: 'community',
|
|
@@ -30,59 +21,57 @@ export const TOOL_TIERS = {
|
|
|
30
21
|
photoshop_release_clipping_mask: 'dev',
|
|
31
22
|
photoshop_undo: 'community',
|
|
32
23
|
photoshop_redo: 'community',
|
|
33
|
-
photoshop_get_history: 'community',
|
|
34
24
|
photoshop_place_image: 'community',
|
|
35
25
|
photoshop_resize_image: 'community',
|
|
36
26
|
photoshop_crop_document: 'community',
|
|
27
|
+
photoshop_convert_image_mode: 'community',
|
|
37
28
|
photoshop_move_layer_to_position: 'community',
|
|
38
29
|
photoshop_convert_to_smart_object: 'community',
|
|
39
30
|
photoshop_rasterize_layer: 'community',
|
|
40
|
-
|
|
41
|
-
photoshop_set_layer_blend_mode: 'community',
|
|
42
|
-
photoshop_set_layer_visibility: 'community',
|
|
43
|
-
photoshop_set_layer_locked: 'community',
|
|
44
|
-
photoshop_rename_layer: 'community',
|
|
31
|
+
photoshop_set_layer: 'community',
|
|
45
32
|
photoshop_duplicate_layer: 'community',
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
33
|
+
photoshop_layer_via_copy: 'community',
|
|
34
|
+
photoshop_merge: 'community',
|
|
35
|
+
photoshop_bake_layer: 'community',
|
|
49
36
|
photoshop_add_layer_style: 'community',
|
|
50
37
|
photoshop_create_layer: 'community',
|
|
51
38
|
photoshop_delete_layer: 'community',
|
|
52
39
|
photoshop_create_text_layer: 'community',
|
|
53
40
|
photoshop_fill_layer: 'community',
|
|
54
|
-
|
|
41
|
+
photoshop_add_fill_layer: 'community',
|
|
55
42
|
photoshop_select_layer: 'community',
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
43
|
+
photoshop_transform_layer: 'community',
|
|
44
|
+
photoshop_warp_layer: 'pro',
|
|
45
|
+
photoshop_warp_layer_mesh: 'pro',
|
|
46
|
+
photoshop_transform_canvas: 'community',
|
|
47
|
+
photoshop_guides: 'community',
|
|
48
|
+
photoshop_inspect: 'community',
|
|
61
49
|
photoshop_overview: 'community',
|
|
62
50
|
photoshop_get_preview: 'community',
|
|
63
51
|
photoshop_get_layer_bounds_diff: 'community',
|
|
64
52
|
photoshop_compare_regions: 'community',
|
|
65
53
|
photoshop_get_histogram: 'community',
|
|
66
|
-
|
|
67
|
-
photoshop_apply_patch: 'community',
|
|
68
|
-
photoshop_apply_content_aware_move: 'community',
|
|
54
|
+
photoshop_retouch: 'community',
|
|
69
55
|
photoshop_apply_brush_stroke: 'dev',
|
|
70
|
-
|
|
56
|
+
photoshop_detect: 'community',
|
|
57
|
+
photoshop_object: 'dev',
|
|
58
|
+
photoshop_portrait_touchup: 'dev',
|
|
59
|
+
photoshop_text_on_object: 'dev',
|
|
60
|
+
photoshop_detect_landmarks: 'pro',
|
|
61
|
+
photoshop_select_face_feature: 'pro',
|
|
62
|
+
photoshop_stroke_face_contour: 'dev',
|
|
63
|
+
photoshop_scene: 'community',
|
|
64
|
+
photoshop_select_by_reference: 'community',
|
|
65
|
+
photoshop_select: 'community',
|
|
71
66
|
photoshop_select_subject: 'pro',
|
|
72
67
|
photoshop_select_sky: 'pro',
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
photoshop_get_selection_info: 'community',
|
|
68
|
+
photoshop_select_subject_instance: 'pro',
|
|
69
|
+
photoshop_modify_selection: 'community',
|
|
76
70
|
photoshop_get_selection_preview: 'community',
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
photoshop_save_selection_to_channel: 'community',
|
|
82
|
-
photoshop_load_selection_from_channel: 'community',
|
|
83
|
-
photoshop_create_layer_mask: 'community',
|
|
84
|
-
photoshop_delete_layer_mask: 'community',
|
|
85
|
-
photoshop_apply_layer_mask: 'community',
|
|
71
|
+
photoshop_selection_channel: 'community',
|
|
72
|
+
photoshop_layer_mask: 'community',
|
|
73
|
+
photoshop_path: 'community',
|
|
74
|
+
photoshop_vector_mask: 'community',
|
|
86
75
|
photoshop_template_create_evidence: 'pro',
|
|
87
76
|
photoshop_template_save: 'pro',
|
|
88
77
|
photoshop_template_list: 'pro',
|
|
@@ -90,10 +79,7 @@ export const TOOL_TIERS = {
|
|
|
90
79
|
photoshop_template_verify: 'pro',
|
|
91
80
|
photoshop_template_recall: 'pro',
|
|
92
81
|
photoshop_template_delete: 'pro',
|
|
93
|
-
|
|
94
|
-
photoshop_set_text_color: 'community',
|
|
95
|
-
photoshop_set_text_alignment: 'community',
|
|
96
|
-
photoshop_update_text_content: 'community',
|
|
82
|
+
photoshop_set_text: 'community',
|
|
97
83
|
};
|
|
98
84
|
export function tierOf(toolName) {
|
|
99
85
|
const tier = TOOL_TIERS[toolName];
|
package/dist/delivery/bundle.js
CHANGED
|
@@ -27,6 +27,9 @@ export function unzipBundle(container) {
|
|
|
27
27
|
const out = [];
|
|
28
28
|
let total = 0;
|
|
29
29
|
for (const e of entries) {
|
|
30
|
+
if (isUnsafeEntryName(e.entryName)) {
|
|
31
|
+
throw new BundleError(`bundle entry '${e.entryName}' escapes the install directory`);
|
|
32
|
+
}
|
|
30
33
|
const declared = e.header?.size ?? 0;
|
|
31
34
|
if (declared > MAX_ENTRY_BYTES) {
|
|
32
35
|
throw new BundleError(`bundle entry '${e.entryName}' is too large (${declared} bytes)`);
|
|
@@ -68,6 +71,11 @@ export function safeResolve(root, name) {
|
|
|
68
71
|
}
|
|
69
72
|
return out;
|
|
70
73
|
}
|
|
74
|
+
export function isUnsafeEntryName(name) {
|
|
75
|
+
if (isAbsolute(name) || /^[A-Za-z]:[\\/]/.test(name) || /^[\\/]{2}/.test(name))
|
|
76
|
+
return true;
|
|
77
|
+
return name.split(/[\\/]/).some((seg) => seg === '..');
|
|
78
|
+
}
|
|
71
79
|
function isCoreBinary(name) {
|
|
72
80
|
const base = name.split('/').pop() ?? name;
|
|
73
81
|
return base.startsWith('editmamei-core-');
|
package/dist/delivery/config.js
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
const DEFAULT_BASE_URL = 'https://editmamei-delivery.editmamei.workers.dev';
|
|
2
2
|
export function resolveDeliveryConfig(url = process.env.EDITMAMEI_DELIVERY_URL) {
|
|
3
3
|
const baseUrl = (url ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
4
|
+
assertSecureEndpoint(baseUrl);
|
|
4
5
|
return { baseUrl };
|
|
5
6
|
}
|
|
7
|
+
function assertSecureEndpoint(baseUrl) {
|
|
8
|
+
if (!baseUrl)
|
|
9
|
+
return;
|
|
10
|
+
let u;
|
|
11
|
+
try {
|
|
12
|
+
u = new URL(baseUrl);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new Error(`EDITMAMEI_DELIVERY_URL is not a valid URL: ${baseUrl}`);
|
|
16
|
+
}
|
|
17
|
+
if (u.protocol === 'https:')
|
|
18
|
+
return;
|
|
19
|
+
const host = u.hostname;
|
|
20
|
+
const isLoopback = host === 'localhost' ||
|
|
21
|
+
host === '::1' ||
|
|
22
|
+
host === '[::1]' ||
|
|
23
|
+
/^127(?:\.\d{1,3}){3}$/.test(host);
|
|
24
|
+
if (u.protocol === 'http:' && isLoopback)
|
|
25
|
+
return;
|
|
26
|
+
throw new Error(`EDITMAMEI_DELIVERY_URL must use https (got ${u.protocol}//${host}); ` +
|
|
27
|
+
'plaintext http is allowed only for localhost during local development.');
|
|
28
|
+
}
|
|
@@ -25,7 +25,8 @@ export async function provisionModules(key, opts = {}) {
|
|
|
25
25
|
result.notConfigured = true;
|
|
26
26
|
return result;
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
result.errors.push({ sku: '*', message: errMsg(e) });
|
|
29
|
+
return result;
|
|
29
30
|
}
|
|
30
31
|
let manifest;
|
|
31
32
|
try {
|
|
@@ -54,6 +55,14 @@ export async function provisionModules(key, opts = {}) {
|
|
|
54
55
|
result.skipped.push({ sku, version: latest, reason: 'up-to-date' });
|
|
55
56
|
continue;
|
|
56
57
|
}
|
|
58
|
+
if (installed && compareVersions(latest, installed.version) < 0) {
|
|
59
|
+
result.skipped.push({
|
|
60
|
+
sku,
|
|
61
|
+
version: latest,
|
|
62
|
+
reason: `rollback-blocked (installed v${installed.version} is newer than manifest latest v${latest})`,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
57
66
|
const vEntry = entry.versions[latest];
|
|
58
67
|
if (!vEntry) {
|
|
59
68
|
result.errors.push({ sku, message: `manifest has no descriptor for ${sku} v${latest}` });
|
|
@@ -82,7 +91,8 @@ export async function provisionModules(key, opts = {}) {
|
|
|
82
91
|
});
|
|
83
92
|
continue;
|
|
84
93
|
}
|
|
85
|
-
|
|
94
|
+
const sig = vEntry.sig;
|
|
95
|
+
if (!verifyModuleSignature(sku, latest, digest, sig, opts.signingKeys)) {
|
|
86
96
|
result.errors.push({
|
|
87
97
|
sku,
|
|
88
98
|
message: `signature verification failed for ${sku} v${latest} — refusing to install unverified module code`,
|
|
@@ -97,6 +107,7 @@ export async function provisionModules(key, opts = {}) {
|
|
|
97
107
|
sha256: digest,
|
|
98
108
|
alg: contentKey.alg,
|
|
99
109
|
content_key: contentKey.key,
|
|
110
|
+
sig: sig,
|
|
100
111
|
}, blob, opts);
|
|
101
112
|
result.installed.push({ sku, version: latest });
|
|
102
113
|
}
|
|
@@ -109,3 +120,20 @@ export async function provisionModules(key, opts = {}) {
|
|
|
109
120
|
function errMsg(e) {
|
|
110
121
|
return e instanceof Error ? e.message : String(e);
|
|
111
122
|
}
|
|
123
|
+
function compareVersions(a, b) {
|
|
124
|
+
const [aCore, aPre] = a.split('-', 2);
|
|
125
|
+
const [bCore, bPre] = b.split('-', 2);
|
|
126
|
+
const ap = aCore.split('.').map(Number);
|
|
127
|
+
const bp = bCore.split('.').map(Number);
|
|
128
|
+
for (let i = 0; i < 3; i++) {
|
|
129
|
+
if (ap[i] !== bp[i])
|
|
130
|
+
return ap[i] - bp[i];
|
|
131
|
+
}
|
|
132
|
+
if (aPre === bPre)
|
|
133
|
+
return 0;
|
|
134
|
+
if (aPre === undefined)
|
|
135
|
+
return 1;
|
|
136
|
+
if (bPre === undefined)
|
|
137
|
+
return -1;
|
|
138
|
+
return aPre < bPre ? -1 : 1;
|
|
139
|
+
}
|
package/dist/delivery/store.js
CHANGED
|
@@ -2,12 +2,15 @@ import { join, dirname } from 'node:path';
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
3
|
import { settingsDir } from '../core/settings.js';
|
|
4
4
|
import { installBundle } from './bundle.js';
|
|
5
|
+
import { sha256Hex } from './crypto.js';
|
|
6
|
+
import { verifyModuleSignature, MODULE_SIGNING_PUBLIC_KEYS } from './signing.js';
|
|
5
7
|
import { Logger } from '../utils/logger.js';
|
|
6
8
|
const logger = new Logger('Modules');
|
|
7
9
|
const INSTALLED_FILENAME = 'installed.json';
|
|
8
10
|
export const PRO_SKU = 'pro';
|
|
9
11
|
export const PRO_HANDLERS_ENTRY = 'pro-handlers.mjs';
|
|
10
12
|
const MODULE_MANIFEST = 'manifest.json';
|
|
13
|
+
const MODULE_ARTIFACT = 'artifact.enc';
|
|
11
14
|
export function modulesRoot(opts = {}) {
|
|
12
15
|
return join(settingsDir(opts), 'modules');
|
|
13
16
|
}
|
|
@@ -23,6 +26,9 @@ export function moduleHandlersPath(sku, version, opts = {}) {
|
|
|
23
26
|
export function moduleBinDir(sku, version, opts = {}) {
|
|
24
27
|
return join(installedModuleDir(sku, version, opts), 'bin');
|
|
25
28
|
}
|
|
29
|
+
export function moduleArtifactPath(sku, version, opts = {}) {
|
|
30
|
+
return join(installedModuleDir(sku, version, opts), MODULE_ARTIFACT);
|
|
31
|
+
}
|
|
26
32
|
function isInstalledModule(v) {
|
|
27
33
|
if (typeof v !== 'object' || v === null)
|
|
28
34
|
return false;
|
|
@@ -33,6 +39,7 @@ function isInstalledModule(v) {
|
|
|
33
39
|
typeof r.sha256 === 'string' &&
|
|
34
40
|
typeof r.alg === 'string' &&
|
|
35
41
|
typeof r.content_key === 'string' &&
|
|
42
|
+
typeof r.sig === 'string' &&
|
|
36
43
|
typeof r.installed_at === 'string');
|
|
37
44
|
}
|
|
38
45
|
export function readInstalledModule(sku, opts = {}) {
|
|
@@ -65,9 +72,41 @@ function writeFileAtomic(path, data) {
|
|
|
65
72
|
renameSync(tmp, path);
|
|
66
73
|
}
|
|
67
74
|
export function installModule(rec, blob, opts = {}) {
|
|
68
|
-
|
|
75
|
+
const dir = installedModuleDir(rec.sku, rec.version, opts);
|
|
76
|
+
installBundle(blob, rec.content_key, dir);
|
|
77
|
+
writeFileSync(moduleArtifactPath(rec.sku, rec.version, opts), Buffer.from(blob), { mode: 0o600 });
|
|
69
78
|
const now = opts.now ?? Date.now;
|
|
70
79
|
const installed = { ...rec, installed_at: new Date(now()).toISOString() };
|
|
71
80
|
writeFileAtomic(installedPath(rec.sku, opts), JSON.stringify(installed, null, 2) + '\n');
|
|
72
81
|
return installed;
|
|
73
82
|
}
|
|
83
|
+
export function loadVerifiedModule(sku, opts = {}, pubKeys = MODULE_SIGNING_PUBLIC_KEYS) {
|
|
84
|
+
const rec = readInstalledModule(sku, opts);
|
|
85
|
+
if (!rec)
|
|
86
|
+
return null;
|
|
87
|
+
const version = rec.version;
|
|
88
|
+
const dir = installedModuleDir(sku, version, opts);
|
|
89
|
+
try {
|
|
90
|
+
const blob = readFileSync(moduleArtifactPath(sku, version, opts));
|
|
91
|
+
const digest = sha256Hex(blob);
|
|
92
|
+
if (digest !== rec.sha256) {
|
|
93
|
+
logger.warn(`module '${sku}' v${version} artifact hash changed since install — refusing to load`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (!verifyModuleSignature(sku, version, digest, rec.sig, pubKeys)) {
|
|
97
|
+
logger.warn(`module '${sku}' v${version} signature did not verify at boot — refusing to load`);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
installBundle(blob, rec.content_key, dir);
|
|
101
|
+
return {
|
|
102
|
+
version,
|
|
103
|
+
dir,
|
|
104
|
+
handlersPath: moduleHandlersPath(sku, version, opts),
|
|
105
|
+
binDir: moduleBinDir(sku, version, opts),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
logger.warn(`module '${sku}' v${version} failed boot verification: ${err instanceof Error ? err.message : String(err)}`);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { getContextInfo } from '../api/extendscript/_helpers.js';
|
|
3
|
+
import { jsLit, jsNum } from '../utils/jsx.js';
|
|
4
|
+
import { runScript } from '../utils/run-script.js';
|
|
5
|
+
import { TempDir, userOwnedTempRoot } from '../utils/temp.js';
|
|
6
|
+
import { mapDetectionToDoc, } from './detection-client.js';
|
|
7
|
+
export async function detectActiveDoc(connection, client, opts) {
|
|
8
|
+
const maxDimension = opts.maxDimension ?? 1024;
|
|
9
|
+
const dir = process.platform === 'darwin'
|
|
10
|
+
? await TempDir.createWithRoot(userOwnedTempRoot(), 'editmamei-detect-')
|
|
11
|
+
: await TempDir.create('editmamei-detect-');
|
|
12
|
+
try {
|
|
13
|
+
const tempPath = dir.path('detect.jpg');
|
|
14
|
+
const script = `
|
|
15
|
+
${getContextInfo}
|
|
16
|
+
if (app.documents.length === 0) { throw new Error('No active document'); }
|
|
17
|
+
for (var __pi = app.documents.length - 1; __pi >= 0; __pi--) {
|
|
18
|
+
try {
|
|
19
|
+
var __pd = app.documents[__pi];
|
|
20
|
+
if (String(__pd.name).indexOf('__mcp_detect__') !== -1) {
|
|
21
|
+
try { __pd.close(SaveOptions.DONOTSAVECHANGES); } catch (e0) {}
|
|
22
|
+
}
|
|
23
|
+
} catch (e1) {}
|
|
24
|
+
}
|
|
25
|
+
var orig = app.activeDocument;
|
|
26
|
+
var origW = orig.width.as('px');
|
|
27
|
+
var origH = orig.height.as('px');
|
|
28
|
+
var dup = orig.duplicate(orig.name + ' __mcp_detect__');
|
|
29
|
+
try {
|
|
30
|
+
var w = dup.width.as('px');
|
|
31
|
+
var h = dup.height.as('px');
|
|
32
|
+
var longEdge = (w > h) ? w : h;
|
|
33
|
+
if (longEdge > ${jsNum(maxDimension, 1024)}) {
|
|
34
|
+
var scale = ${jsNum(maxDimension, 1024)} / longEdge;
|
|
35
|
+
dup.resizeImage(
|
|
36
|
+
UnitValue(Math.round(w * scale), 'px'),
|
|
37
|
+
UnitValue(Math.round(h * scale), 'px'),
|
|
38
|
+
null,
|
|
39
|
+
ResampleMethod.BICUBIC
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
var outFile = new File(${jsLit(tempPath)});
|
|
43
|
+
var opts = new JPEGSaveOptions();
|
|
44
|
+
opts.quality = 8;
|
|
45
|
+
opts.embedColorProfile = true;
|
|
46
|
+
opts.formatOptions = FormatOptions.STANDARDBASELINE;
|
|
47
|
+
dup.saveAs(outFile, opts, true, Extension.LOWERCASE);
|
|
48
|
+
if (!outFile.exists) {
|
|
49
|
+
try { dup.close(SaveOptions.DONOTSAVECHANGES); } catch (eD) {}
|
|
50
|
+
try { app.activeDocument = orig; } catch (eA) {}
|
|
51
|
+
throw new Error('JPEG saveAs reported success but no file at ' + outFile.fsName);
|
|
52
|
+
}
|
|
53
|
+
dup.close(SaveOptions.DONOTSAVECHANGES);
|
|
54
|
+
app.activeDocument = orig;
|
|
55
|
+
return { ok: true, doc_width: origW, doc_height: origH, context: getContextInfo() };
|
|
56
|
+
} catch (e) {
|
|
57
|
+
try { dup.close(SaveOptions.DONOTSAVECHANGES); } catch (e2) {}
|
|
58
|
+
try { app.activeDocument = orig; } catch (e3) {}
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
const exp = (await runScript(connection, script));
|
|
63
|
+
const docWidth = exp.doc_width ?? 0;
|
|
64
|
+
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
|
+
let exportBytes = Buffer.alloc(0);
|
|
73
|
+
try {
|
|
74
|
+
exportBytes = await readFile(tempPath);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
}
|
|
78
|
+
const result = mapDetectionToDoc(raw, docWidth, docHeight);
|
|
79
|
+
return { docWidth, docHeight, result, raw, context: exp.context, exportBytes };
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await dir.cleanup();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { detectFaces } from './face-detector.js';
|
|
2
|
+
import { detectObjects } from './object-detector.js';
|
|
3
|
+
export class OnnxDetectionClient {
|
|
4
|
+
async detect(imagePath, opts) {
|
|
5
|
+
const wantFaces = opts.faces ?? false;
|
|
6
|
+
const wantObjects = opts.objects ?? false;
|
|
7
|
+
const [faceRes, objRes] = await Promise.all([
|
|
8
|
+
wantFaces ? detectFaces(imagePath, opts.faceThreshold) : Promise.resolve(null),
|
|
9
|
+
wantObjects
|
|
10
|
+
? detectObjects(imagePath, { threshold: opts.objectThreshold, maxObjects: opts.maxObjects })
|
|
11
|
+
: Promise.resolve(null),
|
|
12
|
+
]);
|
|
13
|
+
const dims = faceRes ?? objRes;
|
|
14
|
+
const result = {
|
|
15
|
+
image: { width: dims?.width ?? 0, height: dims?.height ?? 0 },
|
|
16
|
+
backends: {},
|
|
17
|
+
};
|
|
18
|
+
if (faceRes) {
|
|
19
|
+
result.faces = faceRes.faces.map((f) => ({ bbox: f.bbox, confidence: f.confidence }));
|
|
20
|
+
result.backends.faces = 'ultraface';
|
|
21
|
+
}
|
|
22
|
+
if (objRes) {
|
|
23
|
+
result.objects = objRes.objects.map((o) => ({
|
|
24
|
+
label: o.label,
|
|
25
|
+
class_id: o.class_id,
|
|
26
|
+
bbox: o.bbox,
|
|
27
|
+
confidence: o.confidence,
|
|
28
|
+
}));
|
|
29
|
+
result.backends.objects = 'dfine-s';
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function mapDetectionToDoc(result, docWidth, docHeight) {
|
|
35
|
+
const sx = result.image.width ? docWidth / result.image.width : 1;
|
|
36
|
+
const sy = result.image.height ? docHeight / result.image.height : 1;
|
|
37
|
+
const clamp = (v, max) => Math.min(Math.max(v, 0), max);
|
|
38
|
+
const rect = (r) => {
|
|
39
|
+
const x1 = clamp(Math.round(r[0] * sx), docWidth);
|
|
40
|
+
const y1 = clamp(Math.round(r[1] * sy), docHeight);
|
|
41
|
+
const x2 = clamp(Math.round(r[2] * sx), docWidth);
|
|
42
|
+
const y2 = clamp(Math.round(r[3] * sy), docHeight);
|
|
43
|
+
return [Math.min(x1, x2), Math.min(y1, y2), Math.max(x1, x2), Math.max(y1, y2)];
|
|
44
|
+
};
|
|
45
|
+
const scaleFeatures = (feat) => ({
|
|
46
|
+
...feat,
|
|
47
|
+
points: feat.points.map((p) => ({ x: p.x * sx, y: p.y * sy, z: p.z })),
|
|
48
|
+
});
|
|
49
|
+
const mapFace = (f) => ({
|
|
50
|
+
...f,
|
|
51
|
+
bbox: rect(f.bbox),
|
|
52
|
+
...(f.features ? { features: scaleFeatures(f.features) } : {}),
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
image: { width: docWidth, height: docHeight },
|
|
56
|
+
backends: result.backends,
|
|
57
|
+
...(result.faces ? { faces: result.faces.map(mapFace) } : {}),
|
|
58
|
+
...(result.objects
|
|
59
|
+
? { objects: result.objects.map((o) => ({ ...o, bbox: rect(o.bbox) })) }
|
|
60
|
+
: {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { LANDMARK_GROUPS } from './landmark-spec.js';
|
|
2
|
+
export const FACE_CONTOURS = [
|
|
3
|
+
'jawline',
|
|
4
|
+
'nose_bridge',
|
|
5
|
+
'cheekbone_left',
|
|
6
|
+
'cheekbone_right',
|
|
7
|
+
'cheekbones',
|
|
8
|
+
'under_eye_left',
|
|
9
|
+
'under_eye_right',
|
|
10
|
+
'under_eyes',
|
|
11
|
+
];
|
|
12
|
+
const JAWLINE_IDX = [
|
|
13
|
+
234, 93, 132, 58, 172, 136, 150, 149, 176, 148, 152, 377, 400, 378, 379, 365, 397, 288, 361, 454,
|
|
14
|
+
];
|
|
15
|
+
const NOSE_BRIDGE_IDX = [168, 6, 197, 195, 5, 4, 1];
|
|
16
|
+
const ALA_LEFT = 358;
|
|
17
|
+
const ALA_RIGHT = 129;
|
|
18
|
+
function poly(points, idx) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const i of idx) {
|
|
21
|
+
const p = points[i];
|
|
22
|
+
if (p)
|
|
23
|
+
out.push([p.x, p.y]);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function midY(pts) {
|
|
28
|
+
if (pts.length === 0)
|
|
29
|
+
return 0;
|
|
30
|
+
return pts.reduce((s, p) => s + p[1], 0) / pts.length;
|
|
31
|
+
}
|
|
32
|
+
function lowerLid(points, idx) {
|
|
33
|
+
const ring = poly(points, idx);
|
|
34
|
+
if (ring.length < 3)
|
|
35
|
+
return [];
|
|
36
|
+
const cy = midY(ring);
|
|
37
|
+
const lower = ring.filter((p) => p[1] >= cy);
|
|
38
|
+
lower.sort((a, b) => a[0] - b[0]);
|
|
39
|
+
return lower;
|
|
40
|
+
}
|
|
41
|
+
function cheekbone(points, eyeIdx, alaIdx) {
|
|
42
|
+
const ring = poly(points, eyeIdx);
|
|
43
|
+
if (ring.length < 3)
|
|
44
|
+
return [];
|
|
45
|
+
const ala = points[alaIdx];
|
|
46
|
+
if (!ala)
|
|
47
|
+
return [];
|
|
48
|
+
let minX = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
49
|
+
for (const [x, y] of ring) {
|
|
50
|
+
if (x < minX)
|
|
51
|
+
minX = x;
|
|
52
|
+
if (x > maxX)
|
|
53
|
+
maxX = x;
|
|
54
|
+
if (y > maxY)
|
|
55
|
+
maxY = y;
|
|
56
|
+
}
|
|
57
|
+
const outerX = Math.abs(minX - ala.x) > Math.abs(maxX - ala.x) ? minX : maxX;
|
|
58
|
+
const cy = midY(ring);
|
|
59
|
+
const innerX = outerX === minX ? maxX : minX;
|
|
60
|
+
const apple = [(innerX + ala.x) / 2, (maxY + ala.y) / 2];
|
|
61
|
+
const mid = [(apple[0] + outerX) / 2, (apple[1] + cy) / 2];
|
|
62
|
+
const temple = [outerX, cy];
|
|
63
|
+
return [apple, mid, temple];
|
|
64
|
+
}
|
|
65
|
+
export function catmullRomAnchors(line) {
|
|
66
|
+
const n = line.length;
|
|
67
|
+
if (n === 0)
|
|
68
|
+
return [];
|
|
69
|
+
if (n === 1)
|
|
70
|
+
return [{ x: line[0][0], y: line[0][1] }];
|
|
71
|
+
if (n === 2) {
|
|
72
|
+
return [
|
|
73
|
+
{ x: line[0][0], y: line[0][1] },
|
|
74
|
+
{ x: line[1][0], y: line[1][1] },
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
const anchors = [];
|
|
78
|
+
for (let i = 0; i < n; i++) {
|
|
79
|
+
const [x, y] = line[i];
|
|
80
|
+
const prev = line[i - 1] ?? line[i];
|
|
81
|
+
const next = line[i + 1] ?? line[i];
|
|
82
|
+
const tx = (next[0] - prev[0]) / 6;
|
|
83
|
+
const ty = (next[1] - prev[1]) / 6;
|
|
84
|
+
const anchor = { x, y };
|
|
85
|
+
if (i > 0)
|
|
86
|
+
anchor.in = [x - tx, y - ty];
|
|
87
|
+
if (i < n - 1)
|
|
88
|
+
anchor.out = [x + tx, y + ty];
|
|
89
|
+
anchors.push(anchor);
|
|
90
|
+
}
|
|
91
|
+
return anchors;
|
|
92
|
+
}
|
|
93
|
+
function roundAnchor(a) {
|
|
94
|
+
const r = { x: Math.round(a.x), y: Math.round(a.y) };
|
|
95
|
+
if (a.in)
|
|
96
|
+
r.in = [Math.round(a.in[0]), Math.round(a.in[1])];
|
|
97
|
+
if (a.out)
|
|
98
|
+
r.out = [Math.round(a.out[0]), Math.round(a.out[1])];
|
|
99
|
+
return r;
|
|
100
|
+
}
|
|
101
|
+
export function buildContourAnchors(points, contour) {
|
|
102
|
+
const line = contourPolyline(points, contour);
|
|
103
|
+
if (!line || line.length < 2)
|
|
104
|
+
return null;
|
|
105
|
+
return catmullRomAnchors(line).map(roundAnchor);
|
|
106
|
+
}
|
|
107
|
+
function contourPolyline(points, contour) {
|
|
108
|
+
switch (contour) {
|
|
109
|
+
case 'jawline':
|
|
110
|
+
return orNull(poly(points, JAWLINE_IDX));
|
|
111
|
+
case 'nose_bridge':
|
|
112
|
+
return orNull(poly(points, NOSE_BRIDGE_IDX));
|
|
113
|
+
case 'cheekbone_left':
|
|
114
|
+
return orNull(cheekbone(points, LANDMARK_GROUPS.leftEye, ALA_LEFT));
|
|
115
|
+
case 'cheekbone_right':
|
|
116
|
+
return orNull(cheekbone(points, LANDMARK_GROUPS.rightEye, ALA_RIGHT));
|
|
117
|
+
case 'under_eye_left':
|
|
118
|
+
return orNull(lowerLid(points, LANDMARK_GROUPS.leftEye));
|
|
119
|
+
case 'under_eye_right':
|
|
120
|
+
return orNull(lowerLid(points, LANDMARK_GROUPS.rightEye));
|
|
121
|
+
case 'cheekbones':
|
|
122
|
+
case 'under_eyes':
|
|
123
|
+
return null;
|
|
124
|
+
default:
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function orNull(p) {
|
|
129
|
+
return p.length >= 2 ? p : null;
|
|
130
|
+
}
|
|
131
|
+
export function contourSides(contour) {
|
|
132
|
+
if (contour === 'cheekbones')
|
|
133
|
+
return ['cheekbone_left', 'cheekbone_right'];
|
|
134
|
+
if (contour === 'under_eyes')
|
|
135
|
+
return ['under_eye_left', 'under_eye_right'];
|
|
136
|
+
return [contour];
|
|
137
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { loadModel, resolveModelPath, decodeJpeg, ort } from './runtime.js';
|
|
2
|
+
import { greedyNms, resizeToCHW } from './geometry.js';
|
|
3
|
+
const MODEL_FILE = 'ultraface-rfb-320.onnx';
|
|
4
|
+
const IN_W = 320;
|
|
5
|
+
const IN_H = 240;
|
|
6
|
+
const DEFAULT_THRESHOLD = 0.7;
|
|
7
|
+
const IOU_THRESHOLD = 0.4;
|
|
8
|
+
export async function detectFaces(imagePath, threshold = DEFAULT_THRESHOLD) {
|
|
9
|
+
const img = decodeJpeg(imagePath);
|
|
10
|
+
const session = await loadModel(resolveModelPath(MODEL_FILE));
|
|
11
|
+
const input = resizeToCHW(img, IN_W, IN_H, (v) => (v - 127) / 128);
|
|
12
|
+
const feeds = { input: new ort.Tensor('float32', input, [1, 3, IN_H, IN_W]) };
|
|
13
|
+
const out = await session.run(feeds);
|
|
14
|
+
const scores = out.scores.data;
|
|
15
|
+
const boxes = out.boxes.data;
|
|
16
|
+
const n = out.scores.dims[1];
|
|
17
|
+
const cand = [];
|
|
18
|
+
for (let i = 0; i < n; i++) {
|
|
19
|
+
const faceProb = scores[i * 2 + 1];
|
|
20
|
+
if (faceProb > threshold) {
|
|
21
|
+
cand.push({
|
|
22
|
+
confidence: faceProb,
|
|
23
|
+
bbox: [
|
|
24
|
+
boxes[i * 4] * img.width,
|
|
25
|
+
boxes[i * 4 + 1] * img.height,
|
|
26
|
+
boxes[i * 4 + 2] * img.width,
|
|
27
|
+
boxes[i * 4 + 3] * img.height,
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
cand.sort((p, q) => q.confidence - p.confidence);
|
|
33
|
+
return { width: img.width, height: img.height, faces: greedyNms(cand, IOU_THRESHOLD) };
|
|
34
|
+
}
|