editmamei 0.24.1 → 1.0.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 +6 -30
- package/README.md +39 -7
- package/dist/api/extendscript/_helpers.js +1 -1
- package/dist/api/photoshop-api.js +20 -14
- 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/core/server.js +6 -6
- package/dist/core/session.js +9 -10
- package/dist/core/tool-registry.js +4 -4
- package/dist/delivery/store.js +10 -2
- package/dist/detection/detect-active-doc.js +1 -1
- package/dist/kernel/module-lifecycle.js +7 -1
- package/dist/perception/region-precompute.js +13 -5
- package/dist/perception/select-recipes.js +2 -2
- package/dist/perception/sky-mask-transfer.js +1 -1
- package/dist/platform/connection.js +32 -46
- package/dist/platform/host-platform.js +16 -0
- package/dist/platform/macos-detector.js +41 -71
- package/dist/platform/macos-runner.js +94 -0
- package/dist/platform/script-result.js +39 -0
- package/dist/platform/windows-detector.js +44 -68
- package/dist/platform/windows-runner.js +84 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/tools/adjustment-tools.js +18 -2
- package/dist/tools/brush-tools.js +1 -1
- package/dist/tools/document-tools.js +9 -2
- package/dist/tools/filter-tools.js +1 -1
- package/dist/tools/layer-tools.js +132 -7
- package/dist/tools/path-tools.js +1 -1
- package/dist/tools/preview-tools.js +8 -8
- package/dist/tools/retouch-tools.js +1 -1
- package/dist/tools/scene-tools.js +29 -2
- package/dist/tools/selection-tools.js +81 -3
- package/dist/update/check.js +1 -1
- package/dist/utils/logger.js +53 -46
- package/dist/utils/session-log.js +1 -1
- package/dist/utils/tool-helpers.js +9 -3
- package/dist/version.js +1 -1
- package/package.json +10 -3
- package/LICENSE +0 -81
- package/dist/platform/detector.js +0 -32
- package/dist/platform/macos-executor.js +0 -96
- package/dist/platform/windows-executor.js +0 -105
- /package/dist/platform/{script-executor.js → ports.js} +0 -0
- /package/dist/platform/{executor-queue.js → script-queue.js} +0 -0
|
@@ -3,136 +3,106 @@ import { promisify } from 'util';
|
|
|
3
3
|
import { access, constants, readFile } from 'fs/promises';
|
|
4
4
|
import { Logger } from '../utils/logger.js';
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
|
+
export function bundleDisplayName(bundlePath) {
|
|
7
|
+
const leaf = bundlePath.replace(/\/+$/, '').split('/').pop();
|
|
8
|
+
if (!leaf)
|
|
9
|
+
return undefined;
|
|
10
|
+
return leaf.endsWith('.app') ? leaf.slice(0, -'.app'.length) : leaf;
|
|
11
|
+
}
|
|
6
12
|
export class MacOSDetector {
|
|
7
|
-
logger;
|
|
8
|
-
constructor() {
|
|
9
|
-
this.logger = new Logger('MacOSDetector');
|
|
10
|
-
}
|
|
13
|
+
logger = new Logger('MacOSDetector');
|
|
11
14
|
async detect() {
|
|
12
|
-
this.logger.info('
|
|
13
|
-
const
|
|
14
|
-
if (
|
|
15
|
-
this.logger.debug(
|
|
16
|
-
const info = await this.
|
|
15
|
+
this.logger.info('Looking for a Photoshop install on macOS');
|
|
16
|
+
const override = process.env.PHOTOSHOP_PATH;
|
|
17
|
+
if (override) {
|
|
18
|
+
this.logger.debug('Trying PHOTOSHOP_PATH override', override);
|
|
19
|
+
const info = await this.inspectBundle(override);
|
|
17
20
|
if (info)
|
|
18
21
|
return info;
|
|
19
22
|
}
|
|
20
23
|
try {
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
23
|
-
return
|
|
24
|
+
const fromSpotlight = await this.findViaSpotlight();
|
|
25
|
+
if (fromSpotlight)
|
|
26
|
+
return fromSpotlight;
|
|
24
27
|
}
|
|
25
28
|
catch (error) {
|
|
26
|
-
this.logger.warn('Spotlight
|
|
29
|
+
this.logger.warn('Spotlight lookup did not resolve an install', error);
|
|
27
30
|
}
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const info = await this.checkPath(path);
|
|
31
|
+
for (const path of this.conventionalPaths()) {
|
|
32
|
+
const info = await this.inspectBundle(path);
|
|
31
33
|
if (info)
|
|
32
34
|
return info;
|
|
33
35
|
}
|
|
34
|
-
throw new Error('Photoshop
|
|
36
|
+
throw new Error('No Photoshop install could be found on this machine');
|
|
35
37
|
}
|
|
36
|
-
async
|
|
38
|
+
async findViaSpotlight() {
|
|
37
39
|
try {
|
|
38
40
|
const { stdout } = await execFileAsync('mdfind', [
|
|
39
41
|
'kMDItemCFBundleIdentifier == com.adobe.Photoshop',
|
|
40
42
|
]);
|
|
41
|
-
const
|
|
43
|
+
const bundles = stdout
|
|
42
44
|
.split('\n')
|
|
43
45
|
.filter((line) => line.trim() && line.endsWith('.app'))
|
|
44
46
|
.sort((a, b) => b.localeCompare(a));
|
|
45
|
-
for (const
|
|
46
|
-
const info = await this.
|
|
47
|
+
for (const bundlePath of bundles) {
|
|
48
|
+
const info = await this.inspectBundle(bundlePath);
|
|
47
49
|
if (info)
|
|
48
50
|
return info;
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
catch (error) {
|
|
52
|
-
this.logger.debug('Spotlight
|
|
54
|
+
this.logger.debug('Spotlight query failed', error);
|
|
53
55
|
}
|
|
54
56
|
return null;
|
|
55
57
|
}
|
|
56
|
-
|
|
58
|
+
conventionalPaths() {
|
|
57
59
|
const paths = [];
|
|
58
|
-
const
|
|
59
|
-
for (let year =
|
|
60
|
+
const newestYear = Math.max(new Date().getFullYear() + 1, 2026);
|
|
61
|
+
for (let year = newestYear; year >= 2012; year--) {
|
|
60
62
|
paths.push(`/Applications/Adobe Photoshop ${year}/Adobe Photoshop ${year}.app`, `/Applications/Adobe Photoshop CC ${year}/Adobe Photoshop CC ${year}.app`, `/Applications/Adobe Photoshop ${year}.app`);
|
|
61
63
|
}
|
|
62
64
|
paths.push('/Applications/Adobe Photoshop CC/Adobe Photoshop CC.app', '/Applications/Adobe Photoshop/Adobe Photoshop.app', '/Applications/Adobe Photoshop.app');
|
|
63
65
|
return paths;
|
|
64
66
|
}
|
|
65
|
-
async
|
|
67
|
+
async inspectBundle(path) {
|
|
66
68
|
try {
|
|
67
69
|
const cleanPath = path.trim();
|
|
68
70
|
await access(cleanPath, constants.F_OK);
|
|
69
|
-
const version = await this.
|
|
70
|
-
const appName = cleanPath
|
|
71
|
-
this.logger.info(
|
|
72
|
-
return {
|
|
73
|
-
version,
|
|
74
|
-
path: cleanPath,
|
|
75
|
-
isRunning: await this.checkIfRunning(cleanPath),
|
|
76
|
-
appName,
|
|
77
|
-
};
|
|
71
|
+
const version = await this.readBundleVersion(cleanPath);
|
|
72
|
+
const appName = bundleDisplayName(cleanPath);
|
|
73
|
+
this.logger.info('Using Photoshop at', cleanPath);
|
|
74
|
+
return { version, path: cleanPath, appName };
|
|
78
75
|
}
|
|
79
76
|
catch {
|
|
80
77
|
return null;
|
|
81
78
|
}
|
|
82
79
|
}
|
|
83
|
-
async
|
|
80
|
+
async readBundleVersion(bundlePath) {
|
|
84
81
|
try {
|
|
85
|
-
const plistPath = `${
|
|
82
|
+
const plistPath = `${bundlePath}/Contents/Info.plist`;
|
|
86
83
|
try {
|
|
87
84
|
await access(plistPath, constants.F_OK);
|
|
88
|
-
const { stdout
|
|
85
|
+
const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [
|
|
89
86
|
'-c',
|
|
90
87
|
'Print :CFBundleShortVersionString',
|
|
91
88
|
plistPath,
|
|
92
89
|
]);
|
|
93
|
-
if (
|
|
94
|
-
return
|
|
95
|
-
}
|
|
90
|
+
if (stdout.trim())
|
|
91
|
+
return stdout.trim();
|
|
96
92
|
}
|
|
97
93
|
catch {
|
|
98
94
|
const content = await readFile(plistPath, 'utf8');
|
|
99
95
|
const versionMatch = content.match(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/);
|
|
100
|
-
if (versionMatch)
|
|
96
|
+
if (versionMatch)
|
|
101
97
|
return versionMatch[1];
|
|
102
|
-
}
|
|
103
98
|
}
|
|
104
|
-
const yearMatch =
|
|
105
|
-
if (yearMatch)
|
|
99
|
+
const yearMatch = bundlePath.match(/(\d{4})/);
|
|
100
|
+
if (yearMatch)
|
|
106
101
|
return yearMatch[1];
|
|
107
|
-
}
|
|
108
102
|
}
|
|
109
103
|
catch (err) {
|
|
110
|
-
this.logger.debug('
|
|
104
|
+
this.logger.debug('Could not read a version from the bundle', err);
|
|
111
105
|
}
|
|
112
106
|
return 'Unknown';
|
|
113
107
|
}
|
|
114
|
-
async checkIfRunning(appPath) {
|
|
115
|
-
try {
|
|
116
|
-
const appName = appPath.split('/').pop()?.replace('.app', '') || 'Adobe Photoshop';
|
|
117
|
-
const { stdout } = await execFileAsync('pgrep', ['-f', appName]);
|
|
118
|
-
return stdout.trim().length > 0;
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
async getAppBundleId(appPath) {
|
|
125
|
-
try {
|
|
126
|
-
const plistPath = `${appPath}/Contents/Info.plist`;
|
|
127
|
-
const { stdout } = await execFileAsync('/usr/libexec/PlistBuddy', [
|
|
128
|
-
'-c',
|
|
129
|
-
'Print :CFBundleIdentifier',
|
|
130
|
-
plistPath,
|
|
131
|
-
]);
|
|
132
|
-
return stdout.trim();
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
108
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { exec, spawn } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { Logger } from '../utils/logger.js';
|
|
4
|
+
import { TempDir } from '../utils/temp.js';
|
|
5
|
+
import { ScriptQueue } from './script-queue.js';
|
|
6
|
+
import { runChildWithTimeout } from './run-child.js';
|
|
7
|
+
import { decodeScriptResult } from './script-result.js';
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const DEFAULT_SCRIPT_TIMEOUT_MS = 30_000;
|
|
10
|
+
const LAUNCH_GRACE_MS = 5_000;
|
|
11
|
+
const UNSAFE_IN_APPLESCRIPT_LITERAL = /["\\\r\n\t]/;
|
|
12
|
+
export class MacOSScriptRunner {
|
|
13
|
+
logger = new Logger('MacOSScriptRunner');
|
|
14
|
+
queue = new ScriptQueue(this.logger);
|
|
15
|
+
appName = null;
|
|
16
|
+
useInstall(install) {
|
|
17
|
+
const name = install.appName;
|
|
18
|
+
if (!name) {
|
|
19
|
+
this.logger.warn('This Photoshop install carries no application name, so AppleScript has nothing to address', install.path);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (UNSAFE_IN_APPLESCRIPT_LITERAL.test(name)) {
|
|
23
|
+
throw new Error(`Photoshop's application name (${JSON.stringify(name)}) contains a character that ` +
|
|
24
|
+
'cannot be composed into AppleScript.');
|
|
25
|
+
}
|
|
26
|
+
this.appName = name;
|
|
27
|
+
this.logger.debug('Addressing Photoshop as', name);
|
|
28
|
+
}
|
|
29
|
+
async run(script, timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS) {
|
|
30
|
+
return this.queue.enqueue(() => this.runOnce(script, timeoutMs), timeoutMs);
|
|
31
|
+
}
|
|
32
|
+
async runOnce(script, timeoutMs) {
|
|
33
|
+
const dir = await TempDir.create('editmamei-mac-');
|
|
34
|
+
try {
|
|
35
|
+
const scriptPath = await dir.write('script.jsx', script);
|
|
36
|
+
const wrapperPath = await dir.write('run.scpt', this.buildAppleScriptWrapper(scriptPath, timeoutMs));
|
|
37
|
+
const { stdout, stderr, exitCode } = await runChildWithTimeout('osascript', [wrapperPath], {
|
|
38
|
+
timeout: timeoutMs,
|
|
39
|
+
diagLabel: 'osascript run.scpt',
|
|
40
|
+
});
|
|
41
|
+
if (stderr) {
|
|
42
|
+
this.logger.warn('Script execution reported a warning', stderr);
|
|
43
|
+
}
|
|
44
|
+
if (exitCode !== 0) {
|
|
45
|
+
throw new Error(`osascript exited with code ${exitCode}: ${stderr.trim() || '(no output on stderr)'}`);
|
|
46
|
+
}
|
|
47
|
+
return decodeScriptResult(stdout);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await dir.cleanup();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
buildAppleScriptWrapper(scriptPath, timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS) {
|
|
54
|
+
if (!this.appName) {
|
|
55
|
+
throw new Error('No Photoshop application name has been set for this runner — the install was never ' +
|
|
56
|
+
'handed to useInstall(), so there is no application for AppleScript to address.');
|
|
57
|
+
}
|
|
58
|
+
const posixPath = scriptPath.replace(/\\/g, '/');
|
|
59
|
+
if (posixPath.includes('"') || posixPath.includes('\n')) {
|
|
60
|
+
throw new Error('Refusing to compose the wrapper: script path would break the AppleScript');
|
|
61
|
+
}
|
|
62
|
+
const timeoutSeconds = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.max(1, Math.ceil(timeoutMs / 1000)) : 1;
|
|
63
|
+
const encodedPath = encodeURI(posixPath).replace(/'/g, '%27');
|
|
64
|
+
return `with timeout of ${timeoutSeconds} seconds
|
|
65
|
+
\ttell application "${this.appName}"
|
|
66
|
+
\t\tdo javascript "$.evalFile(decodeURI('${encodedPath}'))"
|
|
67
|
+
\tend tell
|
|
68
|
+
end timeout`;
|
|
69
|
+
}
|
|
70
|
+
async isRunning() {
|
|
71
|
+
try {
|
|
72
|
+
const { stdout } = await execAsync('pgrep -f "Adobe Photoshop"');
|
|
73
|
+
return stdout.trim().length > 0;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async launch(executablePath) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
this.logger.info('Launching Photoshop', executablePath);
|
|
82
|
+
const child = spawn('open', ['-a', executablePath], { detached: true, stdio: 'ignore' });
|
|
83
|
+
child.unref();
|
|
84
|
+
const grace = setTimeout(() => {
|
|
85
|
+
grace.unref();
|
|
86
|
+
resolve();
|
|
87
|
+
}, LAUNCH_GRACE_MS);
|
|
88
|
+
child.on('error', (error) => {
|
|
89
|
+
clearTimeout(grace);
|
|
90
|
+
reject(new Error(`Could not launch Photoshop at ${executablePath}: ${error.message}`));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const TRANSPORT_FAILURE_MARKER = 'ERROR:';
|
|
2
|
+
const ENVELOPE_TAG = '__em';
|
|
3
|
+
function isEnvelope(payload) {
|
|
4
|
+
return (typeof payload === 'object' &&
|
|
5
|
+
payload !== null &&
|
|
6
|
+
ENVELOPE_TAG in payload);
|
|
7
|
+
}
|
|
8
|
+
export class PhotoshopScriptError extends Error {
|
|
9
|
+
psErrorNumber;
|
|
10
|
+
psLine;
|
|
11
|
+
constructor(message, psErrorNumber = null, psLine = null) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'PhotoshopScriptError';
|
|
14
|
+
this.psErrorNumber = psErrorNumber;
|
|
15
|
+
this.psLine = psLine;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function asNumberOrNull(value) {
|
|
19
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
20
|
+
}
|
|
21
|
+
export function decodeScriptResult(raw) {
|
|
22
|
+
const payload = raw.trim();
|
|
23
|
+
if (payload.startsWith(TRANSPORT_FAILURE_MARKER)) {
|
|
24
|
+
throw new PhotoshopScriptError(payload.slice(TRANSPORT_FAILURE_MARKER.length).trim());
|
|
25
|
+
}
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(payload);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return payload;
|
|
32
|
+
}
|
|
33
|
+
if (!isEnvelope(parsed))
|
|
34
|
+
return parsed;
|
|
35
|
+
if (parsed.ok)
|
|
36
|
+
return parsed.value;
|
|
37
|
+
const detail = parsed.error ?? {};
|
|
38
|
+
throw new PhotoshopScriptError(typeof detail.message === 'string' ? detail.message : '', asNumberOrNull(detail.number), asNumberOrNull(detail.line));
|
|
39
|
+
}
|
|
@@ -4,46 +4,42 @@ import { access, constants } from 'fs/promises';
|
|
|
4
4
|
import { Logger } from '../utils/logger.js';
|
|
5
5
|
const execAsync = promisify(exec);
|
|
6
6
|
export class WindowsDetector {
|
|
7
|
-
logger;
|
|
8
|
-
constructor() {
|
|
9
|
-
this.logger = new Logger('WindowsDetector');
|
|
10
|
-
}
|
|
7
|
+
logger = new Logger('WindowsDetector');
|
|
11
8
|
async detect() {
|
|
12
|
-
this.logger.info('
|
|
13
|
-
const
|
|
14
|
-
if (
|
|
15
|
-
this.logger.debug(
|
|
16
|
-
const info = await this.
|
|
9
|
+
this.logger.info('Looking for a Photoshop install on Windows');
|
|
10
|
+
const override = process.env.PHOTOSHOP_PATH;
|
|
11
|
+
if (override) {
|
|
12
|
+
this.logger.debug('Trying PHOTOSHOP_PATH override', override);
|
|
13
|
+
const info = await this.inspectPath(override);
|
|
17
14
|
if (info)
|
|
18
15
|
return info;
|
|
19
16
|
}
|
|
20
17
|
try {
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
23
|
-
return
|
|
18
|
+
const fromRegistry = await this.findViaRegistry();
|
|
19
|
+
if (fromRegistry)
|
|
20
|
+
return fromRegistry;
|
|
24
21
|
}
|
|
25
22
|
catch (error) {
|
|
26
|
-
this.logger.warn('Registry
|
|
23
|
+
this.logger.warn('Registry lookup did not resolve an install', error);
|
|
27
24
|
}
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
throw new Error('Photoshop not found on this system');
|
|
25
|
+
const fromConventionalPaths = await this.probeCandidates(this.conventionalPaths());
|
|
26
|
+
if (fromConventionalPaths)
|
|
27
|
+
return fromConventionalPaths;
|
|
28
|
+
throw new Error('No Photoshop install could be found on this machine');
|
|
33
29
|
}
|
|
34
|
-
async
|
|
30
|
+
async findViaRegistry() {
|
|
35
31
|
try {
|
|
36
|
-
const
|
|
32
|
+
const productKeys = [
|
|
37
33
|
'HKLM\\SOFTWARE\\Adobe\\Photoshop',
|
|
38
34
|
'HKLM\\SOFTWARE\\WOW6432Node\\Adobe\\Photoshop',
|
|
39
35
|
];
|
|
40
|
-
for (const
|
|
36
|
+
for (const key of productKeys) {
|
|
41
37
|
try {
|
|
42
|
-
const { stdout } = await execAsync(`reg query "${
|
|
43
|
-
const entries = this.
|
|
38
|
+
const { stdout } = await execAsync(`reg query "${key}" /s`);
|
|
39
|
+
const entries = this.readRegistryEntries(stdout);
|
|
44
40
|
if (entries.length > 0) {
|
|
45
|
-
const
|
|
46
|
-
const info = await this.
|
|
41
|
+
const newest = entries.sort((a, b) => b.version.localeCompare(a.version))[0];
|
|
42
|
+
const info = await this.inspectPath(newest.path);
|
|
47
43
|
if (info)
|
|
48
44
|
return info;
|
|
49
45
|
}
|
|
@@ -52,16 +48,16 @@ export class WindowsDetector {
|
|
|
52
48
|
continue;
|
|
53
49
|
}
|
|
54
50
|
}
|
|
55
|
-
const
|
|
51
|
+
const comRegistrations = [
|
|
56
52
|
'HKCR\\CLSID\\{06870682-6f3c-4b97-9143-f03e85c0bd3e}\\LocalServer32',
|
|
57
53
|
'HKCR\\Wow6432Node\\CLSID\\{06870682-6f3c-4b97-9143-f03e85c0bd3e}\\LocalServer32',
|
|
58
54
|
];
|
|
59
|
-
for (const
|
|
55
|
+
for (const key of comRegistrations) {
|
|
60
56
|
try {
|
|
61
|
-
const { stdout } = await execAsync(`reg query "${
|
|
62
|
-
const exePath = this.
|
|
57
|
+
const { stdout } = await execAsync(`reg query "${key}" /ve`);
|
|
58
|
+
const exePath = this.readComServerPath(stdout);
|
|
63
59
|
if (exePath) {
|
|
64
|
-
const info = await this.
|
|
60
|
+
const info = await this.inspectPath(exePath);
|
|
65
61
|
if (info)
|
|
66
62
|
return info;
|
|
67
63
|
}
|
|
@@ -72,91 +68,71 @@ export class WindowsDetector {
|
|
|
72
68
|
}
|
|
73
69
|
}
|
|
74
70
|
catch (err) {
|
|
75
|
-
this.logger.error('Registry query failed
|
|
71
|
+
this.logger.error('Registry query failed outright', err);
|
|
76
72
|
}
|
|
77
73
|
return null;
|
|
78
74
|
}
|
|
79
|
-
|
|
75
|
+
readRegistryEntries(output) {
|
|
80
76
|
const entries = [];
|
|
81
|
-
const lines = output.split('\n');
|
|
82
77
|
let currentVersion = '';
|
|
83
|
-
for (const line of
|
|
78
|
+
for (const line of output.split('\n')) {
|
|
84
79
|
const versionMatch = line.match(/Photoshop\\(\d+\.\d+)/);
|
|
85
80
|
if (versionMatch) {
|
|
86
81
|
currentVersion = versionMatch[1];
|
|
87
82
|
}
|
|
88
83
|
const pathMatch = line.match(/ApplicationPath\s+REG_SZ\s+(.+)/);
|
|
89
84
|
if (pathMatch && currentVersion) {
|
|
90
|
-
entries.push({
|
|
91
|
-
version: currentVersion,
|
|
92
|
-
path: pathMatch[1].trim(),
|
|
93
|
-
});
|
|
85
|
+
entries.push({ version: currentVersion, path: pathMatch[1].trim() });
|
|
94
86
|
}
|
|
95
87
|
}
|
|
96
88
|
return entries;
|
|
97
89
|
}
|
|
98
|
-
|
|
90
|
+
readComServerPath(output) {
|
|
99
91
|
const match = output.match(/REG_SZ\s+"?(.+?\.exe)"?/i);
|
|
100
92
|
if (!match)
|
|
101
93
|
return null;
|
|
102
94
|
return match[1].trim();
|
|
103
95
|
}
|
|
104
|
-
|
|
96
|
+
conventionalPaths() {
|
|
105
97
|
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
|
106
98
|
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
|
|
107
99
|
const paths = [];
|
|
108
|
-
const
|
|
109
|
-
for (let year =
|
|
100
|
+
const newestYear = Math.max(new Date().getFullYear() + 1, 2026);
|
|
101
|
+
for (let year = newestYear; year >= 2012; year--) {
|
|
110
102
|
paths.push(`${programFiles}\\Adobe\\Adobe Photoshop ${year}\\Photoshop.exe`, `${programFilesX86}\\Adobe\\Adobe Photoshop ${year}\\Photoshop.exe`, `${programFiles}\\Adobe\\Adobe Photoshop CC ${year}\\Photoshop.exe`, `${programFilesX86}\\Adobe\\Adobe Photoshop CC ${year}\\Photoshop.exe`);
|
|
111
103
|
}
|
|
112
104
|
paths.push(`${programFiles}\\Adobe\\Adobe Photoshop CC\\Photoshop.exe`, `${programFiles}\\Adobe\\Photoshop CC\\Photoshop.exe`);
|
|
113
105
|
return paths;
|
|
114
106
|
}
|
|
115
|
-
async
|
|
116
|
-
const results = await Promise.all(
|
|
107
|
+
async probeCandidates(candidates) {
|
|
108
|
+
const results = await Promise.all(candidates.map((path) => this.inspectPath(path)));
|
|
117
109
|
const selected = results.find((result) => result !== null) ?? null;
|
|
118
110
|
if (selected) {
|
|
119
|
-
this.logger.info(
|
|
111
|
+
this.logger.info('Using Photoshop at', selected.path);
|
|
120
112
|
}
|
|
121
113
|
return selected;
|
|
122
114
|
}
|
|
123
|
-
async
|
|
115
|
+
async inspectPath(path) {
|
|
124
116
|
try {
|
|
125
117
|
let cleanPath = path.trim().replace(/^"|"$/g, '');
|
|
126
118
|
if (!cleanPath.toLowerCase().endsWith('.exe')) {
|
|
127
|
-
cleanPath = `${cleanPath}\\Photoshop.exe`;
|
|
119
|
+
cleanPath = `${cleanPath.replace(/[\\/]+$/, '')}\\Photoshop.exe`;
|
|
128
120
|
}
|
|
129
121
|
await access(cleanPath, constants.F_OK);
|
|
130
|
-
|
|
131
|
-
this.
|
|
132
|
-
return {
|
|
133
|
-
version,
|
|
134
|
-
path: cleanPath,
|
|
135
|
-
isRunning: await this.checkIfRunning(),
|
|
136
|
-
};
|
|
122
|
+
this.logger.debug('Candidate install exists', cleanPath);
|
|
123
|
+
return { version: this.readVersionFromPath(cleanPath), path: cleanPath };
|
|
137
124
|
}
|
|
138
125
|
catch {
|
|
139
126
|
return null;
|
|
140
127
|
}
|
|
141
128
|
}
|
|
142
|
-
|
|
129
|
+
readVersionFromPath(path) {
|
|
143
130
|
const yearMatch = path.match(/(\d{4})/);
|
|
144
|
-
if (yearMatch)
|
|
131
|
+
if (yearMatch)
|
|
145
132
|
return yearMatch[1];
|
|
146
|
-
}
|
|
147
133
|
const versionMatch = path.match(/(\d+\.\d+)/);
|
|
148
|
-
if (versionMatch)
|
|
134
|
+
if (versionMatch)
|
|
149
135
|
return versionMatch[1];
|
|
150
|
-
}
|
|
151
136
|
return 'Unknown';
|
|
152
137
|
}
|
|
153
|
-
async checkIfRunning() {
|
|
154
|
-
try {
|
|
155
|
-
const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq Photoshop.exe"');
|
|
156
|
-
return stdout.toLowerCase().includes('photoshop.exe');
|
|
157
|
-
}
|
|
158
|
-
catch {
|
|
159
|
-
return false;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
138
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { exec, spawn } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { Logger } from '../utils/logger.js';
|
|
4
|
+
import { TempDir } from '../utils/temp.js';
|
|
5
|
+
import { ScriptQueue } from './script-queue.js';
|
|
6
|
+
import { runChildWithTimeout } from './run-child.js';
|
|
7
|
+
import { decodeScriptResult } from './script-result.js';
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const DEFAULT_SCRIPT_TIMEOUT_MS = 30_000;
|
|
10
|
+
const LAUNCH_GRACE_MS = 5_000;
|
|
11
|
+
export class WindowsScriptRunner {
|
|
12
|
+
logger = new Logger('WindowsScriptRunner');
|
|
13
|
+
queue = new ScriptQueue(this.logger);
|
|
14
|
+
async run(script, timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS) {
|
|
15
|
+
return this.queue.enqueue(() => this.runOnce(script, timeoutMs), timeoutMs);
|
|
16
|
+
}
|
|
17
|
+
async runOnce(script, timeoutMs) {
|
|
18
|
+
const dir = await TempDir.create('editmamei-win-');
|
|
19
|
+
try {
|
|
20
|
+
const scriptPath = await dir.write('script.jsx', script);
|
|
21
|
+
const shimPath = await dir.write('run.vbs', this.buildComShim(scriptPath));
|
|
22
|
+
const { stdout, stderr } = await runChildWithTimeout('cscript', ['//nologo', shimPath], {
|
|
23
|
+
timeout: timeoutMs,
|
|
24
|
+
diagLabel: 'cscript run.vbs',
|
|
25
|
+
});
|
|
26
|
+
if (stderr) {
|
|
27
|
+
this.logger.warn('Script execution reported a warning', stderr);
|
|
28
|
+
}
|
|
29
|
+
return decodeScriptResult(stdout);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await dir.cleanup();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
buildComShim(scriptPath) {
|
|
36
|
+
if (scriptPath.includes('"')) {
|
|
37
|
+
throw new Error('Refusing to compose the COM shim: script path contains a double quote');
|
|
38
|
+
}
|
|
39
|
+
return `
|
|
40
|
+
On Error Resume Next
|
|
41
|
+
Dim ps
|
|
42
|
+
Set ps = CreateObject("Photoshop.Application")
|
|
43
|
+
|
|
44
|
+
If Err.Number <> 0 Then
|
|
45
|
+
WScript.Echo "ERROR: could not attach to Photoshop over COM - " & Err.Description
|
|
46
|
+
WScript.Quit 1
|
|
47
|
+
End If
|
|
48
|
+
|
|
49
|
+
Dim scriptResult
|
|
50
|
+
scriptResult = ps.DoJavaScript("$.evalFile(""" & Replace("${scriptPath}", "\\", "\\\\") & """);")
|
|
51
|
+
|
|
52
|
+
If Err.Number <> 0 Then
|
|
53
|
+
WScript.Echo "ERROR: " & Err.Description
|
|
54
|
+
WScript.Quit 1
|
|
55
|
+
Else
|
|
56
|
+
WScript.Echo scriptResult
|
|
57
|
+
End If
|
|
58
|
+
`.trim();
|
|
59
|
+
}
|
|
60
|
+
async isRunning() {
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq Photoshop.exe"');
|
|
63
|
+
return stdout.toLowerCase().includes('photoshop.exe');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async launch(executablePath) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
this.logger.info('Launching Photoshop', executablePath);
|
|
72
|
+
const child = spawn(executablePath, [], { detached: true, stdio: 'ignore' });
|
|
73
|
+
child.unref();
|
|
74
|
+
const grace = setTimeout(() => {
|
|
75
|
+
grace.unref();
|
|
76
|
+
resolve();
|
|
77
|
+
}, LAUNCH_GRACE_MS);
|
|
78
|
+
child.on('error', (error) => {
|
|
79
|
+
clearTimeout(grace);
|
|
80
|
+
reject(new Error(`Could not launch Photoshop at ${executablePath}: ${error.message}`));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
Binary file
|
|
@@ -423,9 +423,24 @@ const addAdjustmentLayerSchema = {
|
|
|
423
423
|
gm_preset: {
|
|
424
424
|
type: 'string',
|
|
425
425
|
enum: ['black_to_white', 'sepia', 'tint'],
|
|
426
|
-
description: 'For type=gradient_map. Preset gradient. "black_to_white" is the canonical tonal B&W via gradient. "sepia" is a warm vintage tone. "tint" maps black→tint_color→white using gm_tint_color_hex.
|
|
426
|
+
description: 'For type=gradient_map. Preset gradient. "black_to_white" is the canonical tonal B&W via gradient. "sepia" is a warm vintage tone. "tint" maps black→tint_color→white using gm_tint_color_hex. For arbitrary color mappings pass gm_stops instead (overrides the preset).',
|
|
427
427
|
default: 'black_to_white',
|
|
428
428
|
},
|
|
429
|
+
gm_stops: {
|
|
430
|
+
type: 'array',
|
|
431
|
+
description: 'For type=gradient_map. Custom color stops overriding gm_preset — each {red,green,blue (0-255), location (0-100 shadows→highlights), midpoint (5-95, default 50)}. At least 2; sorted by location. E.g. a blue→orange stylized grade: [{red:20,green:40,blue:120,location:0},{red:250,green:150,blue:50,location:100}].',
|
|
432
|
+
items: {
|
|
433
|
+
type: 'object',
|
|
434
|
+
properties: {
|
|
435
|
+
red: { type: 'integer', minimum: 0, maximum: 255 },
|
|
436
|
+
green: { type: 'integer', minimum: 0, maximum: 255 },
|
|
437
|
+
blue: { type: 'integer', minimum: 0, maximum: 255 },
|
|
438
|
+
location: { type: 'number', minimum: 0, maximum: 100 },
|
|
439
|
+
midpoint: { type: 'number', minimum: 5, maximum: 95, default: 50 },
|
|
440
|
+
},
|
|
441
|
+
required: ['red', 'green', 'blue', 'location'],
|
|
442
|
+
},
|
|
443
|
+
},
|
|
429
444
|
gm_tint_color_hex: {
|
|
430
445
|
type: 'string',
|
|
431
446
|
description: 'For type=gradient_map with gm_preset=tint. Mid-tone tint color as a hex string like "#5588CC". Only used when gm_preset=tint.',
|
|
@@ -621,7 +636,7 @@ export function createAdjustmentTools(connection, snippetClient) {
|
|
|
621
636
|
{
|
|
622
637
|
tool: {
|
|
623
638
|
name: 'ps_apply_adjustment',
|
|
624
|
-
description: "Apply a DESTRUCTIVE tonal adjustment that Photoshop does NOT offer as an adjustment layer — chosen via `type`. Runs on a DUPLICATE of the active layer by default (auto-duplicate-first
|
|
639
|
+
description: "Apply a DESTRUCTIVE tonal adjustment that Photoshop does NOT offer as an adjustment layer — chosen via `type`. Runs on a DUPLICATE of the active layer by default (auto-duplicate-first — the original is preserved; revert by deleting the copy); pass `apply_to_active_layer: true` to bake in place. Auto-rasterizes text/smart-object layers. `shadows_highlights` recovers blown highlights + crushed shadows in one pass (defaults match Adobe's dialog: 35 shadow amount, +20 color correction); `equalize` stretches/flattens the histogram (parameter-free); `color_lookup` bakes a 3DLUT grade (cl_lut_name required — leaf name of a file in Presets/3DLUTs/ or an absolute .cube/.3dl/.look path). For EDITABLE tonal/color work, prefer ps_add_adjustment_layer.",
|
|
625
640
|
inputSchema: APPLY_ADJUSTMENT_INPUT_SCHEMA,
|
|
626
641
|
outputSchema: {
|
|
627
642
|
type: 'object',
|
|
@@ -772,6 +787,7 @@ const FORWARDED_KEYS = new Set([
|
|
|
772
787
|
'gm_tint_color_hex',
|
|
773
788
|
'gm_reverse',
|
|
774
789
|
'gm_dither',
|
|
790
|
+
'gm_stops',
|
|
775
791
|
'exp_exposure',
|
|
776
792
|
'exp_offset',
|
|
777
793
|
'exp_gamma',
|
|
@@ -119,7 +119,7 @@ export function createBrushTools(connection, snippetClient, client = new OnnxLan
|
|
|
119
119
|
{
|
|
120
120
|
tool: {
|
|
121
121
|
name: 'ps_apply_brush_stroke',
|
|
122
|
-
description: 'Paint along a path with one of PS\'s 16 brush-family tools — supply the path EITHER as an anchor-relational `placement` (preferred: a path relation → the stroke traces a resolved, gate-verified curve along a traced edge / landmark / between two anchors, no pixel-guessing) OR as an explicit `path` list of anchor points — the retouch tools (`healing_brush`, `clone_stamp`, `burn`, `dodge`, `blur`, `sharpen`, `smudge`), the paint family (`brush`, `pencil`, `eraser`), and the specialty tools (`pattern_stamp`, `art_history_brush`, `history_brush`, `color_replacement`, `background_eraser`, `sponge`). The `path` parameter takes a list of anchor points with optional bezier handles, so the same tool handles straight-line strokes, freeform curves, and closed shapes — all by varying the path geometry. **Reach for this when**: (a) cloning out a distraction along a specific shape (clone_stamp with a source_point + a path tracing the unwanted edge); (b) healing a scratch or seam (healing_brush with source_point); (c) dodging / burning to redirect tonal balance along a contour; (d) painting a freehand line into the canvas (brush + foreground_color). Active layer must be a normal pixel layer (background auto-promotes); rasterize adjustment/shape/text/smart-object layers first. Auto-duplicates
|
|
122
|
+
description: 'Paint along a path with one of PS\'s 16 brush-family tools — supply the path EITHER as an anchor-relational `placement` (preferred: a path relation → the stroke traces a resolved, gate-verified curve along a traced edge / landmark / between two anchors, no pixel-guessing) OR as an explicit `path` list of anchor points — the retouch tools (`healing_brush`, `clone_stamp`, `burn`, `dodge`, `blur`, `sharpen`, `smudge`), the paint family (`brush`, `pencil`, `eraser`), and the specialty tools (`pattern_stamp`, `art_history_brush`, `history_brush`, `color_replacement`, `background_eraser`, `sponge`). The `path` parameter takes a list of anchor points with optional bezier handles, so the same tool handles straight-line strokes, freeform curves, and closed shapes — all by varying the path geometry. **Reach for this when**: (a) cloning out a distraction along a specific shape (clone_stamp with a source_point + a path tracing the unwanted edge); (b) healing a scratch or seam (healing_brush with source_point); (c) dodging / burning to redirect tonal balance along a contour; (d) painting a freehand line into the canvas (brush + foreground_color). Active layer must be a normal pixel layer (background auto-promotes); rasterize adjustment/shape/text/smart-object layers first. Auto-duplicates — the original is preserved and a "Brush Stroke (<name>)" copy receives the paint. **Brush dynamics**: hardness, opacity, and flow are independently settable via `hardness_pct` / `opacity_pct` / `flow_pct` (the live tool options are mutated before stroking and restored to the user\'s prior state in `finally`). Sampled brushes (custom shape-stamp presets) silently ignore hardness/diameter mutations — vary their character via `brush_preset` instead.',
|
|
123
123
|
inputSchema: applyBrushStrokeSchema,
|
|
124
124
|
outputSchema: {
|
|
125
125
|
type: 'object',
|