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.
Files changed (69) hide show
  1. package/NOTICES.md +43 -0
  2. package/README.md +91 -8
  3. package/dist/api/extendscript/_helpers.js +8 -0
  4. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  5. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  6. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  7. package/dist/cli/clients/json-config.js +1 -1
  8. package/dist/core/server.js +92 -24
  9. package/dist/core/tool-groups.js +178 -0
  10. package/dist/core/tool-tiers.js +34 -48
  11. package/dist/delivery/bundle.js +8 -0
  12. package/dist/delivery/config.js +23 -0
  13. package/dist/delivery/provision.js +30 -2
  14. package/dist/delivery/store.js +40 -1
  15. package/dist/detection/detect-active-doc.js +84 -0
  16. package/dist/detection/detection-client.js +62 -0
  17. package/dist/detection/face-contour-geometry.js +137 -0
  18. package/dist/detection/face-detector.js +34 -0
  19. package/dist/detection/face-feature-geometry.js +180 -0
  20. package/dist/detection/geometry.js +35 -0
  21. package/dist/detection/landmark-detection-client.js +38 -0
  22. package/dist/detection/landmark-detector.js +63 -0
  23. package/dist/detection/landmark-spec.js +29 -0
  24. package/dist/detection/mesh-face.js +36 -0
  25. package/dist/detection/object-detector.js +135 -0
  26. package/dist/detection/runtime.js +46 -0
  27. package/dist/license/env-activation.js +46 -7
  28. package/dist/models/dfine-s-coco-quant.onnx +0 -0
  29. package/dist/models/ultraface-rfb-320.onnx +0 -0
  30. package/dist/modules/ce/index.js +23 -5
  31. package/dist/perception/facets.js +148 -0
  32. package/dist/perception/region-precompute.js +122 -0
  33. package/dist/perception/region-scorer.js +151 -0
  34. package/dist/perception/scene-model.js +156 -0
  35. package/dist/perception/select-recipes.js +493 -0
  36. package/dist/perception/sky-ground-flood.js +234 -0
  37. package/dist/perception/sky-mask-transfer.js +77 -0
  38. package/dist/platform/macos-executor.js +0 -1
  39. package/dist/platform/windows-executor.js +6 -1
  40. package/dist/preview/coordinate-grid.js +250 -0
  41. package/dist/skills/editmamei-skill.zip +0 -0
  42. package/dist/telemetry/events.js +2 -1
  43. package/dist/telemetry/sanitize.js +15 -1
  44. package/dist/tools/adjustment-tools.js +46 -51
  45. package/dist/tools/detection-tools.js +209 -0
  46. package/dist/tools/document-tools.js +41 -30
  47. package/dist/tools/filter-tools.js +616 -101
  48. package/dist/tools/guide-tools.js +186 -0
  49. package/dist/tools/history-tools.js +1 -39
  50. package/dist/tools/image-tools.js +88 -0
  51. package/dist/tools/inspect-tools.js +86 -0
  52. package/dist/tools/layer-properties-tools.js +195 -106
  53. package/dist/tools/layer-tools.js +55 -14
  54. package/dist/tools/layer-transform-tools.js +236 -73
  55. package/dist/tools/metadata-tools.js +2 -32
  56. package/dist/tools/object-tools.js +193 -0
  57. package/dist/tools/overview-tools.js +69 -43
  58. package/dist/tools/path-tools.js +274 -0
  59. package/dist/tools/portrait-tools.js +146 -0
  60. package/dist/tools/preview-tools.js +78 -3
  61. package/dist/tools/retouch-tools.js +45 -33
  62. package/dist/tools/scene-tools.js +401 -0
  63. package/dist/tools/selection-tools.js +766 -228
  64. package/dist/tools/text-on-object-tools.js +225 -0
  65. package/dist/tools/text-tools.js +47 -56
  66. package/dist/tools/transform-canvas-tools.js +138 -0
  67. package/dist/tools/vector-mask-tools.js +116 -0
  68. package/dist/version.js +1 -1
  69. package/package.json +4 -2
@@ -15,10 +15,19 @@ import { createSelectionTools } from '../../tools/selection-tools.js';
15
15
  import { createHistoryTools } from '../../tools/history-tools.js';
16
16
  import { createLayerOrderingTools } from '../../tools/layer-ordering-tools.js';
17
17
  import { createPreviewTools } from '../../tools/preview-tools.js';
18
- import { createMetadataTools } from '../../tools/metadata-tools.js';
18
+ import { createInspectTools } from '../../tools/inspect-tools.js';
19
19
  import { createOverviewTools } from '../../tools/overview-tools.js';
20
20
  import { createRetouchTools } from '../../tools/retouch-tools.js';
21
21
  import { createBrushTools } from '../../tools/brush-tools.js';
22
+ import { createTransformCanvasTools } from '../../tools/transform-canvas-tools.js';
23
+ import { createGuideTools } from '../../tools/guide-tools.js';
24
+ import { createDetectionTools } from '../../tools/detection-tools.js';
25
+ import { createObjectTools } from '../../tools/object-tools.js';
26
+ import { createPortraitTools } from '../../tools/portrait-tools.js';
27
+ import { createTextOnObjectTools } from '../../tools/text-on-object-tools.js';
28
+ import { createSceneTools } from '../../tools/scene-tools.js';
29
+ import { createPathTools } from '../../tools/path-tools.js';
30
+ import { createVectorMaskTools } from '../../tools/vector-mask-tools.js';
22
31
  const ceFactories = [
23
32
  createDocumentTools,
24
33
  createLayerTools,
@@ -34,18 +43,27 @@ const ceFactories = [
34
43
  createHistoryTools,
35
44
  createLayerOrderingTools,
36
45
  createPreviewTools,
37
- createMetadataTools,
46
+ createInspectTools,
38
47
  createOverviewTools,
39
48
  createRetouchTools,
40
49
  createBrushTools,
50
+ createTransformCanvasTools,
51
+ createGuideTools,
52
+ createDetectionTools,
53
+ createObjectTools,
54
+ createPortraitTools,
55
+ createTextOnObjectTools,
56
+ createPathTools,
57
+ createVectorMaskTools,
41
58
  ];
42
59
  export const ceModule = {
43
60
  manifest: { id: 'ce', name: 'Editmamei Community Tools', abi: KERNEL_ABI },
44
61
  register(host) {
45
62
  const { connection, snippet } = host;
46
- const defs = ceFactories
47
- .flatMap((f) => f(connection, snippet))
48
- .filter((def) => isToolAllowedInEdition(def.tool.name, EDITION));
63
+ const defs = [
64
+ ...ceFactories.flatMap((f) => f(connection, snippet)),
65
+ ...createSceneTools(connection, snippet, undefined, host.invokeTool),
66
+ ].filter((def) => isToolAllowedInEdition(def.tool.name, EDITION));
49
67
  host.registerTools(defs);
50
68
  },
51
69
  };
@@ -0,0 +1,148 @@
1
+ export function pickThresholdLevel(hist) {
2
+ const { bins } = hist;
3
+ const total = hist.total || bins.reduce((a, b) => a + b, 0);
4
+ if (total <= 0)
5
+ return { level: 128, bright_fraction: 0 };
6
+ let sumAll = 0;
7
+ for (let i = 0; i < 256; i++)
8
+ sumAll += i * bins[i];
9
+ let wB = 0;
10
+ let sumB = 0;
11
+ let bestFirst = 128;
12
+ let bestLast = 128;
13
+ let bestVar = -1;
14
+ for (let t = 0; t < 256; t++) {
15
+ wB += bins[t];
16
+ if (wB === 0)
17
+ continue;
18
+ const wF = total - wB;
19
+ if (wF === 0)
20
+ break;
21
+ sumB += t * bins[t];
22
+ const meanB = sumB / wB;
23
+ const meanF = (sumAll - sumB) / wF;
24
+ const between = wB * wF * (meanB - meanF) * (meanB - meanF);
25
+ if (between > bestVar) {
26
+ bestVar = between;
27
+ bestFirst = t;
28
+ bestLast = t;
29
+ }
30
+ else if (between === bestVar) {
31
+ bestLast = t;
32
+ }
33
+ }
34
+ const level = Math.round((bestFirst + bestLast) / 2);
35
+ let bright = 0;
36
+ for (let i = level + 1; i < 256; i++)
37
+ bright += bins[i];
38
+ return { level, bright_fraction: bright / total };
39
+ }
40
+ export function estimateHorizon(rowMeans, docHeight, pick) {
41
+ const split = pick.bright_fraction;
42
+ const splitConfidence = Math.max(0, Math.min(1, 1 - Math.abs(0.5 - split) * 2)) * 0.6 + 0.2;
43
+ if (!rowMeans || rowMeans.length === 0) {
44
+ return { y: Math.round(docHeight / 3), placement: 1 / 3, confidence: 0.2 };
45
+ }
46
+ const R = rowMeans.length;
47
+ let crossRow = -1;
48
+ for (let r = 0; r < R; r++) {
49
+ if (rowMeans[r] < pick.level) {
50
+ crossRow = r;
51
+ break;
52
+ }
53
+ }
54
+ if (crossRow <= 0) {
55
+ return { y: Math.round(docHeight / 3), placement: 1 / 3, confidence: 0.2 };
56
+ }
57
+ const y = Math.round((crossRow / R) * docHeight);
58
+ return {
59
+ y,
60
+ placement: y / docHeight,
61
+ confidence: Math.max(0, Math.min(1, splitConfidence)),
62
+ };
63
+ }
64
+ export function computeTonalZones(hist) {
65
+ const { bins } = hist;
66
+ const total = hist.total || bins.reduce((a, b) => a + b, 0);
67
+ const coverageBetween = (lo, hi) => {
68
+ if (total <= 0)
69
+ return 0;
70
+ let c = 0;
71
+ for (let i = lo; i <= hi; i++)
72
+ c += bins[i];
73
+ return c / total;
74
+ };
75
+ let cum = 0;
76
+ let cut1 = 85;
77
+ let cut2 = 170;
78
+ let found1 = false;
79
+ if (total > 0) {
80
+ for (let i = 0; i < 256; i++) {
81
+ cum += bins[i];
82
+ const frac = cum / total;
83
+ if (!found1 && frac >= 1 / 3) {
84
+ cut1 = i;
85
+ found1 = true;
86
+ }
87
+ if (frac >= 2 / 3) {
88
+ cut2 = i;
89
+ break;
90
+ }
91
+ }
92
+ }
93
+ cut1 = Math.max(20, Math.min(120, cut1));
94
+ cut2 = Math.max(cut1 + 20, Math.min(235, cut2));
95
+ return {
96
+ shadows: { lower: 0, upper: cut1, coverage: coverageBetween(0, cut1) },
97
+ midtones: { lower: cut1 + 1, upper: cut2, coverage: coverageBetween(cut1 + 1, cut2) },
98
+ highlights: { lower: cut2 + 1, upper: 255, coverage: coverageBetween(cut2 + 1, 255) },
99
+ };
100
+ }
101
+ function boxCenter(b) {
102
+ return { x: (b[0] + b[2]) / 2, y: (b[1] + b[3]) / 2 };
103
+ }
104
+ export function boxArea(b) {
105
+ return Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]);
106
+ }
107
+ export function computeComposition(mainBox, allBoxes, docWidth, docHeight, horizonPlacement) {
108
+ let cell = null;
109
+ let headroom = null;
110
+ if (mainBox && docWidth > 0 && docHeight > 0) {
111
+ const c = boxCenter(mainBox);
112
+ const col = c.x < docWidth / 3 ? 'left' : c.x < (2 * docWidth) / 3 ? 'center' : 'right';
113
+ const row = c.y < docHeight / 3 ? 'top' : c.y < (2 * docHeight) / 3 ? 'middle' : 'bottom';
114
+ cell = { col, row };
115
+ const top = Math.min(...allBoxes.map((b) => b[1]), mainBox[1]);
116
+ headroom = Math.max(0, Math.min(1, top / docHeight));
117
+ }
118
+ let balance = 'unknown';
119
+ if (allBoxes.length > 0 && docWidth > 0) {
120
+ let wsum = 0;
121
+ let xsum = 0;
122
+ for (const b of allBoxes) {
123
+ const a = boxArea(b);
124
+ wsum += a;
125
+ xsum += a * boxCenter(b).x;
126
+ }
127
+ if (wsum > 0) {
128
+ const cx = xsum / wsum / docWidth;
129
+ balance = cx < 0.42 ? 'left-weighted' : cx > 0.58 ? 'right-weighted' : 'balanced';
130
+ }
131
+ }
132
+ return {
133
+ main_subject_cell: cell,
134
+ balance,
135
+ headroom,
136
+ horizon_placement: horizonPlacement,
137
+ };
138
+ }
139
+ export function toLumaHistogram(raw) {
140
+ const bins = Array.isArray(raw.bins) && raw.bins.length === 256 ? raw.bins : new Array(256).fill(0);
141
+ const total = typeof raw.total_pixels === 'number' ? raw.total_pixels : bins.reduce((a, b) => a + b, 0);
142
+ return {
143
+ bins,
144
+ total,
145
+ mean: typeof raw.mean === 'number' ? raw.mean : 0,
146
+ median: typeof raw.median === 'number' ? raw.median : 0,
147
+ };
148
+ }
@@ -0,0 +1,122 @@
1
+ import { runScript } from '../utils/run-script.js';
2
+ import { jsLit } from '../utils/jsx.js';
3
+ import { restoreCompositeChannel } from '../api/extendscript/_helpers.js';
4
+ import { resolveSelection, } from './select-recipes.js';
5
+ let lastPrecomputedKey = null;
6
+ let cachedMenu = [];
7
+ export function __resetPrecompute() {
8
+ lastPrecomputedKey = null;
9
+ cachedMenu = [];
10
+ }
11
+ export const CHANNEL_PREFIX = 'scene:';
12
+ export async function saveSelectionAsSceneChannel(connection, target) {
13
+ await runScript(connection, saveSelectionToNamedChannelScript(`${CHANNEL_PREFIX}${target}`));
14
+ }
15
+ const PRECOMPUTE_TARGETS = [
16
+ 'sky',
17
+ 'ground',
18
+ 'shadows',
19
+ 'highlights',
20
+ 'skin',
21
+ 'subject',
22
+ 'face',
23
+ ];
24
+ function deleteSceneChannelsScript() {
25
+ return `
26
+ if (app.documents.length === 0) { throw new Error('No active document'); }
27
+ var doc = app.activeDocument;
28
+ var removed = 0;
29
+ for (var i = doc.channels.length - 1; i >= 0; i--) {
30
+ if (String(doc.channels[i].name).indexOf(${jsLit(CHANNEL_PREFIX)}) === 0) {
31
+ try { doc.channels[i].remove(); removed++; } catch (e) {}
32
+ }
33
+ }
34
+ ${restoreCompositeChannel}
35
+ restoreCompositeChannel(doc);
36
+ return { removed: removed };
37
+ `;
38
+ }
39
+ function saveSelectionToNamedChannelScript(name) {
40
+ return `
41
+ var doc = app.activeDocument;
42
+ for (var i = doc.channels.length - 1; i >= 0; i--) {
43
+ if (String(doc.channels[i].name) === ${jsLit(name)}) { try { doc.channels[i].remove(); } catch (e) {} }
44
+ }
45
+ var ch = doc.channels.add();
46
+ doc.selection.store(ch, SelectionType.REPLACE);
47
+ ch.name = ${jsLit(name)};
48
+ ${restoreCompositeChannel}
49
+ restoreCompositeChannel(doc);
50
+ return { saved: true, name: ${jsLit(name)} };
51
+ `;
52
+ }
53
+ function loadNamedChannelScript(name) {
54
+ return `
55
+ if (app.documents.length === 0) { throw new Error('No active document'); }
56
+ var doc = app.activeDocument;
57
+ var ch = null;
58
+ for (var i = 0; i < doc.channels.length; i++) {
59
+ if (String(doc.channels[i].name) === ${jsLit(name)}) { ch = doc.channels[i]; break; }
60
+ }
61
+ if (!ch) { return { loaded: false }; }
62
+ doc.selection.load(ch, SelectionType.REPLACE);
63
+ ${restoreCompositeChannel}
64
+ restoreCompositeChannel(doc);
65
+ return { loaded: true, width: doc.width.as('px'), height: doc.height.as('px') };
66
+ `;
67
+ }
68
+ export async function precomputeRegions(connection, snippet, model, composition, opts = {}) {
69
+ if (!opts.force &&
70
+ lastPrecomputedKey !== null &&
71
+ lastPrecomputedKey === model.provenance.cache_key) {
72
+ return cachedMenu;
73
+ }
74
+ await runScript(connection, deleteSceneChannelsScript());
75
+ const menu = [];
76
+ for (const target of PRECOMPUTE_TARGETS) {
77
+ try {
78
+ const res = await resolveSelection(connection, snippet, model, target, {
79
+ composition,
80
+ proRefine: opts.proRefine,
81
+ skyCtx: opts.skyCtx,
82
+ });
83
+ if (res.passed) {
84
+ const key = `${CHANNEL_PREFIX}${target}`;
85
+ await runScript(connection, saveSelectionToNamedChannelScript(key));
86
+ menu.push({
87
+ key,
88
+ target,
89
+ method: res.method,
90
+ confidence: res.confidence,
91
+ label: res.detail?.label,
92
+ bounds: (res.selection_info?.bounds ?? null),
93
+ });
94
+ }
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ }
100
+ if (opts.precomputeFace) {
101
+ try {
102
+ const faceItems = await opts.precomputeFace();
103
+ menu.push(...faceItems);
104
+ }
105
+ catch {
106
+ }
107
+ }
108
+ try {
109
+ await runScript(connection, await snippet.build('deselect'));
110
+ }
111
+ catch {
112
+ }
113
+ lastPrecomputedKey = model.provenance.cache_key;
114
+ cachedMenu = menu;
115
+ return menu;
116
+ }
117
+ export async function loadPrecomputedRegion(connection, target) {
118
+ const r = (await runScript(connection, loadNamedChannelScript(`${CHANNEL_PREFIX}${target}`)));
119
+ if (r.loaded !== true)
120
+ return null;
121
+ return { width: r.width ?? 0, height: r.height ?? 0 };
122
+ }
@@ -0,0 +1,151 @@
1
+ const DEFAULTS = {
2
+ skyCoverageMax: 0.55,
3
+ groundCoverageMax: 0.92,
4
+ passThreshold: 0.6,
5
+ };
6
+ function resolvePriors(ctx) {
7
+ let skyMax = DEFAULTS.skyCoverageMax;
8
+ let groundMax = DEFAULTS.groundCoverageMax;
9
+ switch (ctx?.profile) {
10
+ case 'big_sky':
11
+ case 'minimal':
12
+ skyMax = 0.9;
13
+ break;
14
+ case 'big_foreground':
15
+ skyMax = 0.4;
16
+ break;
17
+ case 'tight_subject':
18
+ case 'balanced':
19
+ default:
20
+ break;
21
+ }
22
+ if (typeof ctx?.skyCoverageMax === 'number')
23
+ skyMax = ctx.skyCoverageMax;
24
+ if (typeof ctx?.groundCoverageMax === 'number')
25
+ groundMax = ctx.groundCoverageMax;
26
+ const passThreshold = typeof ctx?.passThreshold === 'number' ? ctx.passThreshold : DEFAULTS.passThreshold;
27
+ return { skyCoverageMax: skyMax, groundCoverageMax: groundMax, passThreshold };
28
+ }
29
+ function rampDown(v, good, bad) {
30
+ if (v <= good)
31
+ return 1;
32
+ if (v >= bad)
33
+ return 0;
34
+ return 1 - (v - good) / (bad - good);
35
+ }
36
+ export function buildRegionSignals(info, scene) {
37
+ const hasSelection = info?.has_selection ?? (info?.bounds ? true : false);
38
+ const bounds = info?.bounds ?? null;
39
+ const docH = scene.docH > 0 ? scene.docH : 1;
40
+ const coverage = Math.max(0, Math.min(1, (info?.area_percent ?? 0) / 100));
41
+ const edgeComplexity = Math.max(0, Math.min(1, info?.edge_complexity ?? 0));
42
+ const fillRatio = Math.max(0, Math.min(1, info?.bounds_fill_ratio ?? 0));
43
+ let centroidY = 0.5;
44
+ let touchesBottom = false;
45
+ let touchesTop = false;
46
+ let lowerEdgeAlign = null;
47
+ let upperEdgeAlign = null;
48
+ if (bounds) {
49
+ centroidY = (bounds.top + bounds.bottom) / 2 / docH;
50
+ touchesBottom = bounds.bottom >= docH * 0.97;
51
+ touchesTop = bounds.top <= docH * 0.03;
52
+ if (scene.horizonConfidence > 0) {
53
+ lowerEdgeAlign = 1 - Math.min(1, Math.abs(bounds.bottom - scene.horizonY) / docH);
54
+ upperEdgeAlign = 1 - Math.min(1, Math.abs(bounds.top - scene.horizonY) / docH);
55
+ }
56
+ }
57
+ return {
58
+ coverage,
59
+ edgeComplexity,
60
+ fillRatio,
61
+ bounds,
62
+ centroidY,
63
+ touchesBottom,
64
+ touchesTop,
65
+ horizonAlignment: lowerEdgeAlign,
66
+ upperEdgeAlignment: upperEdgeAlign,
67
+ horizonConfidence: Math.max(0, Math.min(1, scene.horizonConfidence)),
68
+ indoorness: Math.max(0, Math.min(1, (scene.indoorObjectCount ?? 0) / 2)),
69
+ hasSelection,
70
+ };
71
+ }
72
+ export function scoreRegion(kind, signals, ctx) {
73
+ const priors = resolvePriors(ctx);
74
+ const reasons = [];
75
+ if (!signals.hasSelection || signals.coverage <= 0) {
76
+ return {
77
+ kind,
78
+ confidence: 0,
79
+ passed: false,
80
+ reasons: ['empty selection'],
81
+ signals,
82
+ };
83
+ }
84
+ let confidence = 0;
85
+ switch (kind) {
86
+ case 'sky': {
87
+ const upper = rampDown(signals.centroidY, 0.35, 0.65);
88
+ const notBottom = signals.touchesBottom ? 0 : 1;
89
+ const align = signals.horizonAlignment ?? 0.5;
90
+ const floor = 0.34 * upper + 0.3 * notBottom + 0.22 * align + 0.14 * signals.horizonConfidence;
91
+ const coverageGate = rampDown(signals.coverage, priors.skyCoverageMax, priors.skyCoverageMax + 0.3);
92
+ const indoorPenalty = 1 - 0.7 * signals.indoorness;
93
+ confidence = floor * coverageGate * indoorPenalty;
94
+ reasons.push(`floor=${floor.toFixed(2)}`, `upper=${upper.toFixed(2)}`, `notBottom=${notBottom}`, `align=${align.toFixed(2)}`, `coverageGate=${coverageGate.toFixed(2)}(cov=${signals.coverage.toFixed(2)}/${priors.skyCoverageMax})`, `indoorPenalty=${indoorPenalty.toFixed(2)}(indoor=${signals.indoorness.toFixed(2)})`);
95
+ break;
96
+ }
97
+ case 'ground': {
98
+ const lower = rampDown(1 - signals.centroidY, 0.35, 0.65);
99
+ const align = signals.upperEdgeAlignment ?? signals.horizonAlignment ?? 0.5;
100
+ const floor = 0.35 + 0.3 * lower + 0.2 * align + 0.15 * signals.horizonConfidence;
101
+ const coverageGate = rampDown(signals.coverage, priors.groundCoverageMax, 0.99);
102
+ confidence = floor * coverageGate;
103
+ reasons.push(`floor=${floor.toFixed(2)}`, `lower=${lower.toFixed(2)}`, `upperAlign=${align.toFixed(2)}`, `coverageGate=${coverageGate.toFixed(2)}`);
104
+ break;
105
+ }
106
+ case 'foliage': {
107
+ const sized = rampDown(Math.abs(signals.coverage - 0.3), 0.25, 0.6);
108
+ const notAll = signals.coverage < 0.85 ? 1 : 0;
109
+ confidence = 0.6 * sized + 0.4 * notAll;
110
+ reasons.push(`coverage=${signals.coverage.toFixed(2)}`, `notAll=${notAll}`);
111
+ break;
112
+ }
113
+ case 'shadows':
114
+ case 'highlights': {
115
+ const sized = signals.coverage >= 0.03 && signals.coverage <= 0.95 ? 1 : 0.3;
116
+ confidence = 0.85 * sized + 0.15;
117
+ reasons.push(`coverage=${signals.coverage.toFixed(2)}`);
118
+ break;
119
+ }
120
+ case 'skin': {
121
+ const present = signals.coverage >= 0.01 ? 1 : 0;
122
+ const solid = Math.min(1, signals.fillRatio * 2);
123
+ confidence = 0.6 * present + 0.4 * solid;
124
+ reasons.push(`coverage=${signals.coverage.toFixed(2)}`, `fill=${signals.fillRatio.toFixed(2)}`);
125
+ break;
126
+ }
127
+ case 'subject':
128
+ case 'face': {
129
+ const present = signals.coverage >= 0.005 ? 1 : 0;
130
+ const solid = signals.fillRatio >= 0.15 ? 1 : signals.fillRatio / 0.15;
131
+ confidence = present * (0.55 + 0.45 * solid);
132
+ reasons.push(`coverage=${signals.coverage.toFixed(2)}`, `fill=${signals.fillRatio.toFixed(2)}`);
133
+ break;
134
+ }
135
+ case 'above_horizon': {
136
+ confidence = signals.horizonConfidence > 0 ? 0.8 : 0.5;
137
+ reasons.push(`horizonConf=${signals.horizonConfidence.toFixed(2)}`);
138
+ break;
139
+ }
140
+ default:
141
+ confidence = 0.5;
142
+ }
143
+ confidence = Math.max(0, Math.min(1, confidence));
144
+ return {
145
+ kind,
146
+ confidence,
147
+ passed: confidence >= priors.passThreshold,
148
+ reasons,
149
+ signals,
150
+ };
151
+ }
@@ -0,0 +1,156 @@
1
+ import { decode } from 'jpeg-js';
2
+ import { runScript } from '../utils/run-script.js';
3
+ import { getContextInfo } from '../api/extendscript/_helpers.js';
4
+ import { detectActiveDoc } from '../detection/detect-active-doc.js';
5
+ import { computeComposition, computeTonalZones, estimateHorizon, pickThresholdLevel, boxArea, toLumaHistogram, } from './facets.js';
6
+ let cache = null;
7
+ export function __clearSceneCache() {
8
+ cache = null;
9
+ }
10
+ async function readCacheKey(connection) {
11
+ const script = `
12
+ if (app.documents.length === 0) { throw new Error('No active document'); }
13
+ var doc = app.activeDocument;
14
+ var id = -1;
15
+ try { id = doc.id; } catch (eId) {}
16
+ var hs = -1;
17
+ try { hs = doc.historyStates.length; } catch (eHs) {}
18
+ return { doc_id: id, history_states: hs };
19
+ `;
20
+ const r = (await runScript(connection, script));
21
+ return `${r.doc_id ?? -1}:${r.history_states ?? -1}`;
22
+ }
23
+ export function rowBrightnessProfile(exportBytes, strips = 64) {
24
+ if (!exportBytes || exportBytes.length === 0)
25
+ return undefined;
26
+ let img;
27
+ try {
28
+ img = decode(exportBytes, { useTArray: true, formatAsRGBA: true });
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ const { width: w, height: h, data } = img;
34
+ if (w <= 0 || h <= 0)
35
+ return undefined;
36
+ const R = Math.max(1, Math.min(strips, h));
37
+ const sums = new Array(R).fill(0);
38
+ const counts = new Array(R).fill(0);
39
+ for (let y = 0; y < h; y++) {
40
+ const strip = Math.min(R - 1, Math.floor((y / h) * R));
41
+ let rowSum = 0;
42
+ for (let x = 0; x < w; x++) {
43
+ const i = (y * w + x) * 4;
44
+ rowSum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
45
+ }
46
+ sums[strip] += rowSum;
47
+ counts[strip] += w;
48
+ }
49
+ return sums.map((s, i) => (counts[i] > 0 ? s / counts[i] : 0));
50
+ }
51
+ export async function buildSceneModel(connection, snippet, client, opts = {}) {
52
+ const useCache = opts.useCache ?? true;
53
+ const key = await readCacheKey(connection);
54
+ if (useCache && cache && cache.key === key) {
55
+ const cached = cache.result;
56
+ return {
57
+ ...cached,
58
+ model: {
59
+ ...cached.model,
60
+ provenance: { ...cached.model.provenance, cached: true },
61
+ },
62
+ };
63
+ }
64
+ const det = await detectActiveDoc(connection, client, {
65
+ faces: true,
66
+ objects: true,
67
+ maxDimension: opts.maxDimension,
68
+ objectThreshold: opts.objectThreshold,
69
+ faceThreshold: opts.faceThreshold,
70
+ });
71
+ const docW = det.docWidth;
72
+ const docH = det.docHeight;
73
+ const objects = det.result.objects ?? [];
74
+ let mainIdx = -1;
75
+ let mainArea = -1;
76
+ objects.forEach((o, i) => {
77
+ const a = boxArea(o.bbox);
78
+ if (a > mainArea) {
79
+ mainArea = a;
80
+ mainIdx = i;
81
+ }
82
+ });
83
+ const subjects = objects.map((o, i) => ({
84
+ id: `s${i + 1}`,
85
+ label: o.label,
86
+ bbox: o.bbox,
87
+ confidence: o.confidence,
88
+ is_main: i === mainIdx,
89
+ }));
90
+ const detFaces = det.result.faces ?? [];
91
+ let primaryFaceIdx = -1;
92
+ let bestFaceConf = -1;
93
+ detFaces.forEach((f, i) => {
94
+ if (f.confidence > bestFaceConf) {
95
+ bestFaceConf = f.confidence;
96
+ primaryFaceIdx = i;
97
+ }
98
+ });
99
+ const faces = detFaces.map((f, i) => ({
100
+ bbox: f.bbox,
101
+ confidence: f.confidence,
102
+ is_primary: i === primaryFaceIdx,
103
+ }));
104
+ const histRaw = (await runScript(connection, await snippet.build('getHistogram', { channel: 'luminosity' })));
105
+ const hist = toLumaHistogram(histRaw);
106
+ const pick = pickThresholdLevel(hist);
107
+ const tonalZones = computeTonalZones(hist);
108
+ const rowMeans = rowBrightnessProfile(det.exportBytes);
109
+ const horizon = estimateHorizon(rowMeans, docH, pick);
110
+ const skyCoverage = pick.bright_fraction;
111
+ const regions = [
112
+ {
113
+ kind: 'sky',
114
+ coverage: skyCoverage,
115
+ recipe: { kind: 'threshold_white', level: pick.level },
116
+ },
117
+ {
118
+ kind: 'ground',
119
+ coverage: Math.max(0, 1 - skyCoverage),
120
+ recipe: { kind: 'posterize_region', levels: 3, sample: 'below_horizon' },
121
+ },
122
+ ];
123
+ const allBoxes = objects.map((o) => o.bbox);
124
+ const mainBox = mainIdx >= 0 ? objects[mainIdx].bbox : null;
125
+ const composition = computeComposition(mainBox, allBoxes, docW, docH, horizon.placement);
126
+ const model = {
127
+ doc: { width: docW, height: docH },
128
+ subjects,
129
+ faces,
130
+ regions,
131
+ horizon,
132
+ tonal_zones: tonalZones,
133
+ composition,
134
+ provenance: {
135
+ backends: {
136
+ faces: det.result.backends.faces,
137
+ objects: det.result.backends.objects,
138
+ regions: 'heuristic',
139
+ },
140
+ edition: 'ce',
141
+ cache_key: key,
142
+ cached: false,
143
+ },
144
+ };
145
+ const result = {
146
+ model,
147
+ exportBytes: det.exportBytes,
148
+ rawFaces: (det.raw.faces ?? []).map((f) => f.bbox),
149
+ rawObjects: (det.raw.objects ?? []).map((o) => ({ label: o.label, bbox: o.bbox })),
150
+ exportImage: det.raw.image,
151
+ context: det.context,
152
+ };
153
+ cache = { key, result };
154
+ return result;
155
+ }
156
+ export { getContextInfo };