editmamei 1.9.0 → 1.9.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.
Binary file
Binary file
Binary file
@@ -1,18 +1,22 @@
1
1
  import { runSnippetTool } from '../utils/tool-helpers.js';
2
2
  import { requireLocalPath } from '../utils/local-path.js';
3
3
  import { SKY_REPLACEMENT_TIMEOUT_MS } from '../utils/operation-timeouts.js';
4
+ import { defaultSkyPreset, loadSkyPresets, matchSkyPreset, } from '../utils/sky-presets.js';
4
5
  const PLACEHOLDER_SKY_ID = '00000000-0000-0000-0000-000000000000';
5
6
  const replaceSkyInputSchema = {
6
7
  type: 'object',
7
8
  properties: {
9
+ sky: {
10
+ type: 'string',
11
+ description: 'Name of one of Photoshop\'s built-in skies, e.g. "Blue Sky 002", "Spectacular 004" or "Sunset Sky 003" (skies the user saved in Photoshop work too). The server finds the image itself, so no file or folder access is needed. Pass "list" to get the installed skies\' names without changing the document. Omit both sky and sky_file to use Photoshop\'s Blue Sky 001 (or the first installed sky).',
12
+ },
8
13
  sky_file: {
9
14
  type: 'string',
10
- description: 'ABSOLUTE path to the image to use as the sky. Any image Photoshop can open works — it does not need to be a registered Photoshop sky preset. The built-in skies live as .jpg files under the Sky_Presets folder inside your Photoshop settings directory, and their paths can be passed here directly.',
15
+ description: 'ABSOLUTE path to any image to use as the sky instead of a built-in one; it does not need to be a Photoshop sky preset. Takes precedence over sky.',
11
16
  },
12
17
  sky_name: {
13
18
  type: 'string',
14
- description: 'Label recorded on the operation. Cosmetic — it does not select the sky, sky_file does.',
15
- default: 'Custom Sky',
19
+ description: 'Label recorded on the operation. Cosmetic — it does not select the sky. Defaults to the built-in sky\'s name, or "Custom Sky" for sky_file.',
16
20
  },
17
21
  shift_edge: {
18
22
  type: 'number',
@@ -64,9 +68,44 @@ const replaceSkyInputSchema = {
64
68
  maximum: 100,
65
69
  },
66
70
  },
67
- required: ['sky_file'],
68
71
  };
69
- async function replaceSky(connection, snippetClient, rawArgs) {
72
+ function resolveSky(args, presets) {
73
+ if (args.sky_file !== undefined) {
74
+ return {
75
+ kind: 'file',
76
+ path: requireLocalPath('sky_file', args.sky_file),
77
+ label: 'Custom Sky',
78
+ };
79
+ }
80
+ const installed = presets();
81
+ if (installed.length === 0) {
82
+ throw new Error("Photoshop's built-in skies were not found on this computer (the Sky_Presets folder in Photoshop's settings). Pass sky_file with the absolute path to any image to use as the sky.");
83
+ }
84
+ const query = typeof args.sky === 'string' && args.sky.trim() !== '' ? args.sky : undefined;
85
+ const preset = query === undefined ? defaultSkyPreset(installed) : matchSkyPreset(installed, query);
86
+ if (!preset) {
87
+ throw new Error(`No built-in sky matches "${query}". Installed skies: ${installed.map((p) => p.name).join('; ')}.`);
88
+ }
89
+ return { kind: 'preset', path: preset.file, label: preset.name };
90
+ }
91
+ async function replaceSky(connection, snippetClient, rawArgs, presets) {
92
+ if (rawArgs.sky_file === undefined &&
93
+ typeof rawArgs.sky === 'string' &&
94
+ rawArgs.sky.trim().toLowerCase() === 'list') {
95
+ const names = presets().map((p) => p.name);
96
+ return {
97
+ content: [
98
+ {
99
+ type: 'text',
100
+ text: names.length
101
+ ? `Installed skies: ${names.join('; ')}.`
102
+ : "Photoshop's built-in skies were not found on this computer. Pass sky_file with the absolute path to any image to use as the sky.",
103
+ },
104
+ ],
105
+ structuredContent: { replaced: false, skies: names },
106
+ };
107
+ }
108
+ let sky;
70
109
  return runSnippetTool({
71
110
  connection,
72
111
  snippetClient,
@@ -75,27 +114,33 @@ async function replaceSky(connection, snippetClient, rawArgs) {
75
114
  snippet: 'replaceSky',
76
115
  errorPrefix: 'Error running Sky Replacement',
77
116
  timeoutMs: SKY_REPLACEMENT_TIMEOUT_MS,
78
- params: (args) => ({
79
- skyPath: requireLocalPath('sky_file', args.sky_file),
80
- skyName: args.sky_name ?? 'Custom Sky',
81
- skyId: PLACEHOLDER_SKY_ID,
82
- shiftEdge: args.shift_edge ?? 0,
83
- borderSmoothness: args.border_smoothness ?? 50,
84
- brightness: args.brightness ?? 0,
85
- temperature: args.temperature ?? 0,
86
- harmonizationOpacity: args.harmonization_opacity ?? 35,
87
- foregroundLightingOpacity: args.foreground_lighting_opacity ?? 78,
88
- edgeLightingOpacity: args.edge_lighting_opacity ?? 70,
89
- }),
90
- successText: (_result, args) => `Sky replaced using ${String(args.sky_file)}`,
117
+ params: (args) => {
118
+ sky = resolveSky(args, presets);
119
+ return {
120
+ skyPath: sky.path,
121
+ skyName: args.sky_name ?? sky.label,
122
+ skyId: PLACEHOLDER_SKY_ID,
123
+ shiftEdge: args.shift_edge ?? 0,
124
+ borderSmoothness: args.border_smoothness ?? 50,
125
+ brightness: args.brightness ?? 0,
126
+ temperature: args.temperature ?? 0,
127
+ harmonizationOpacity: args.harmonization_opacity ?? 35,
128
+ foregroundLightingOpacity: args.foreground_lighting_opacity ?? 78,
129
+ edgeLightingOpacity: args.edge_lighting_opacity ?? 70,
130
+ };
131
+ },
132
+ successText: () => sky?.kind === 'preset'
133
+ ? `Sky replaced using Photoshop's built-in sky "${sky.label}"`
134
+ : `Sky replaced using ${sky?.path ?? 'the given file'}`,
91
135
  });
92
136
  }
93
- export function createSkyTools(connection, snippetClient) {
137
+ export function createSkyTools(connection, snippetClient, options = {}) {
138
+ const presets = options.presets ?? (() => loadSkyPresets());
94
139
  return [
95
140
  {
96
141
  tool: {
97
142
  name: 'ps_replace_sky',
98
- description: 'Run Photoshop\'s Sky Replacement (Adobe Sensei): detect the sky, composite a replacement, and relight/colour-grade the foreground to match. Non-destructive — everything lands in a "Sky Replacement Group" of editable layers above the original, so the result can be tuned or deleted afterwards. sky_file takes an ABSOLUTE path to ANY image on disk, not only Photoshop\'s built-in presets. Reach for harmonization_opacity and foreground_lighting_opacity when the composite reads as pasted-on; those two carry most of the believability. Fails cleanly when the image has no detectable sky (indoor or closed compositions).',
143
+ description: 'Run Photoshop\'s Sky Replacement (Adobe Sensei): detect the sky, composite a replacement, and relight/colour-grade the foreground to match. Non-destructive — everything lands in a "Sky Replacement Group" of editable layers above the original, so the result can be tuned or deleted afterwards. Pick one of Photoshop\'s built-in skies by name with sky (no path needed; sky "list" names them), or pass sky_file with an ABSOLUTE path to ANY image on disk; with neither, Photoshop\'s Blue Sky 001 is used (or the first installed sky). Reach for harmonization_opacity and foreground_lighting_opacity when the composite reads as pasted-on; those two carry most of the believability. Fails cleanly when the image has no detectable sky (indoor or closed compositions).',
99
144
  inputSchema: replaceSkyInputSchema,
100
145
  outputSchema: {
101
146
  type: 'object',
@@ -113,6 +158,11 @@ export function createSkyTools(connection, snippetClient) {
113
158
  },
114
159
  sky_file: { type: 'string' },
115
160
  sky_name: { type: 'string' },
161
+ skies: {
162
+ type: 'array',
163
+ items: { type: 'string' },
164
+ description: 'Installed sky names; returned instead of a composite when sky is "list".',
165
+ },
116
166
  context: { type: 'object' },
117
167
  },
118
168
  },
@@ -121,7 +171,7 @@ export function createSkyTools(connection, snippetClient) {
121
171
  idempotentHint: false,
122
172
  },
123
173
  },
124
- handler: async (args) => replaceSky(connection, snippetClient, args),
174
+ handler: async (args) => replaceSky(connection, snippetClient, args, presets),
125
175
  },
126
176
  ];
127
177
  }
@@ -0,0 +1,114 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ const NAME_KEY = Buffer.from('skpNTEXT', 'latin1');
6
+ const ID_KEY = Buffer.from('SzidTEXT', 'latin1');
7
+ export function prettySkyName(raw) {
8
+ return raw
9
+ .replace(/([a-z])([A-Z0-9])/g, '$1 $2')
10
+ .replace(/(\d)([A-Za-z])/g, '$1 $2')
11
+ .replace(/\s+/g, ' ')
12
+ .trim();
13
+ }
14
+ export function parseSkyPresets(buf, dir) {
15
+ const readText = (at) => {
16
+ if (at + 4 > buf.length)
17
+ return null;
18
+ const units = buf.readUInt32BE(at);
19
+ const end = at + 4 + units * 2;
20
+ if (units === 0 || units > 512 || end > buf.length)
21
+ return null;
22
+ let text = '';
23
+ for (let i = at + 4; i < end; i += 2) {
24
+ const unit = buf.readUInt16BE(i);
25
+ if (unit === 0)
26
+ break;
27
+ text += String.fromCharCode(unit);
28
+ }
29
+ return { text, end };
30
+ };
31
+ const out = [];
32
+ for (let pos = buf.indexOf(NAME_KEY); pos !== -1; pos = buf.indexOf(NAME_KEY, pos + NAME_KEY.length)) {
33
+ const name = readText(pos + NAME_KEY.length);
34
+ if (!name)
35
+ continue;
36
+ const rel = buf.subarray(name.end, name.end + 16 + ID_KEY.length).indexOf(ID_KEY);
37
+ if (rel === -1)
38
+ continue;
39
+ const id = readText(name.end + rel + ID_KEY.length);
40
+ if (!id || !GUID_RE.test(id.text))
41
+ continue;
42
+ const file = join(dir, `${id.text}.jpg`);
43
+ if (existsSync(file))
44
+ out.push({ name: prettySkyName(name.text), file });
45
+ }
46
+ return out;
47
+ }
48
+ export function findSkyPresetsDir(env = process.env, platform = process.platform, home = homedir()) {
49
+ const list = (dir) => {
50
+ try {
51
+ return readdirSync(dir);
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ };
57
+ let candidates = [];
58
+ if (platform === 'win32') {
59
+ if (!env.APPDATA)
60
+ return null;
61
+ const base = join(env.APPDATA, 'Adobe');
62
+ candidates = list(base).flatMap((d) => {
63
+ const m = /^Adobe Photoshop (\d{4})$/.exec(d);
64
+ return m ? [{ year: +m[1], dir: join(base, d, `${d} Settings`, 'Sky_Presets') }] : [];
65
+ });
66
+ }
67
+ else if (platform === 'darwin') {
68
+ const base = join(home, 'Library', 'Preferences');
69
+ candidates = list(base).flatMap((d) => {
70
+ const m = /^Adobe Photoshop (\d{4}) Settings$/.exec(d);
71
+ return m ? [{ year: +m[1], dir: join(base, d, 'Sky_Presets') }] : [];
72
+ });
73
+ }
74
+ candidates.sort((a, b) => b.year - a.year);
75
+ return candidates.find((c) => existsSync(join(c.dir, 'Skies.psp')))?.dir ?? null;
76
+ }
77
+ export function loadSkyPresets(dir = findSkyPresetsDir()) {
78
+ if (!dir)
79
+ return [];
80
+ try {
81
+ return parseSkyPresets(readFileSync(join(dir, 'Skies.psp')), dir);
82
+ }
83
+ catch {
84
+ return [];
85
+ }
86
+ }
87
+ const norm = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
88
+ const familyAndNumber = (s) => {
89
+ const m = /^(\p{L}+?)(\d+)/u.exec(norm(s));
90
+ return m ? { family: m[1], num: Number(m[2]) } : null;
91
+ };
92
+ export function matchSkyPreset(presets, query) {
93
+ const q = norm(query);
94
+ if (!q)
95
+ return undefined;
96
+ const exact = presets.find((p) => norm(p.name) === q);
97
+ if (exact)
98
+ return exact;
99
+ const want = familyAndNumber(query);
100
+ if (want) {
101
+ const hit = presets.find((p) => {
102
+ const have = familyAndNumber(p.name);
103
+ return have !== null && have.family.startsWith(want.family) && have.num === want.num;
104
+ });
105
+ if (hit)
106
+ return hit;
107
+ }
108
+ return presets.find((p) => norm(p.name).startsWith(q));
109
+ }
110
+ export function defaultSkyPreset(presets) {
111
+ return (presets.find((p) => /^blue sky\b/i.test(p.name) && /\bby photoshop$/i.test(p.name)) ??
112
+ presets.find((p) => /^blue sky\b/i.test(p.name)) ??
113
+ presets[0]);
114
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '1.9.0';
1
+ export const VERSION = '1.9.1';
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "editmamei",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "Photoshop MCP server: natural-language AI photo editing in desktop Photoshop (Community Edition)",
5
5
  "mcpName": "io.github.editmamei/editmamei",
6
6
  "editmamei": {
7
7
  "fixesByVersion": {
8
+ "1.9.1": [
9
+ "ps_replace_sky"
10
+ ],
8
11
  "1.9.0": [
9
12
  "ps_save_psd"
10
13
  ],
@@ -14,10 +17,6 @@
14
17
  "ps_select",
15
18
  "ps_modify_selection"
16
19
  ],
17
- "1.2.0": [
18
- "ps_delete_layer",
19
- "ps_select_layer"
20
- ],
21
20
  "1.2.1": [
22
21
  "ps_read_scene"
23
22
  ]