editmamei 1.5.1 → 1.7.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/README.md +22 -1
- package/dist/backends/detect-editors.js +79 -0
- package/dist/backends/gimp/backend.js +99 -0
- package/dist/backends/gimp/bridge/lib.py +1988 -0
- package/dist/backends/gimp/bridge/ops.py +3120 -0
- package/dist/backends/gimp/detect.js +86 -0
- package/dist/backends/gimp/errors.js +21 -0
- package/dist/backends/gimp/launcher.js +6 -0
- package/dist/backends/gimp/session.js +632 -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/config.js +28 -0
- package/dist/core/preview-privacy.js +54 -0
- package/dist/core/server.js +98 -49
- package/dist/core/settings.js +25 -0
- package/dist/core/tool-activity.js +122 -0
- package/dist/core/tool-groups.js +24 -0
- package/dist/core/tool-tiers.js +24 -0
- package/dist/diagnostics/collect.js +4 -0
- package/dist/index.js +5 -1
- package/dist/kernel/kernel.js +3 -0
- package/dist/modules/gimp/index.js +43 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/activity.js +1 -93
- package/dist/tools/adjustment-tools.js +2 -1
- package/dist/tools/gimp-adjustment-tools.js +385 -0
- package/dist/tools/gimp-checkpoint-tools.js +541 -0
- package/dist/tools/gimp-compose-tools.js +399 -0
- package/dist/tools/gimp-core-tools.js +302 -0
- package/dist/tools/gimp-document-tools.js +321 -0
- package/dist/tools/gimp-effect-tools.js +267 -0
- package/dist/tools/gimp-filter-tools.js +94 -0
- package/dist/tools/gimp-geometry-tools.js +223 -0
- package/dist/tools/gimp-inspect-tools.js +157 -0
- package/dist/tools/gimp-layer-tools.js +385 -0
- package/dist/tools/gimp-mask-tools.js +102 -0
- package/dist/tools/gimp-shared.js +58 -0
- package/dist/tools/gimp-verify-tools.js +409 -0
- package/dist/utils/gimp-path.js +27 -0
- package/dist/utils/operation-timeouts.js +24 -0
- package/dist/utils/session-log.js +19 -1
- package/dist/utils/tool-helpers.js +14 -0
- package/dist/utils/validate.js +11 -0
- package/dist/version.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { validateArgs } from '../utils/validate.js';
|
|
6
|
+
import { toolGimpErrorResult, unknownDiscriminator } from '../utils/tool-helpers.js';
|
|
7
|
+
import { GimpError } from '../backends/gimp/errors.js';
|
|
8
|
+
import { GIMP_IMAGE_PROP } from './gimp-shared.js';
|
|
9
|
+
export const MAX_CHECKPOINTS_PER_IMAGE = 5;
|
|
10
|
+
export const MAX_CHECKPOINTS_TOTAL = 20;
|
|
11
|
+
const checkpointSchema = {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
op: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
enum: ['create', 'restore', 'list', 'delete'],
|
|
17
|
+
description: "'create' exports the image's current state — every live filter included — to a " +
|
|
18
|
+
"disk-backed snapshot and returns a checkpoint_id. 'restore' reopens that snapshot as a " +
|
|
19
|
+
'NEW image and closes the old one (replace semantics, not a copy: the old image id stops ' +
|
|
20
|
+
"working once the close succeeds — see 'close_failed' below for when it doesn't). " +
|
|
21
|
+
"'list' reports every checkpoint (optionally scoped to one image): checkpoint_id, the " +
|
|
22
|
+
'image it currently belongs to (open: false if that image is confirmed gone — still ' +
|
|
23
|
+
'restorable; pending: true while still being created), created_at, bytes — never a file ' +
|
|
24
|
+
"path. 'delete' removes one, freeing its slot.",
|
|
25
|
+
},
|
|
26
|
+
image: {
|
|
27
|
+
...GIMP_IMAGE_PROP,
|
|
28
|
+
description: (GIMP_IMAGE_PROP.description ?? '') +
|
|
29
|
+
' Required for create. Optional for list (omit to list checkpoints for every image).',
|
|
30
|
+
},
|
|
31
|
+
checkpoint_id: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'Required for restore / delete. The id create (or a prior list) reported for this checkpoint.',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
required: ['op'],
|
|
37
|
+
};
|
|
38
|
+
function isSessionRestartedError(error) {
|
|
39
|
+
return error instanceof GimpError && error.code === 'gimp_session_restarted';
|
|
40
|
+
}
|
|
41
|
+
function isImageAlreadyClosedError(error) {
|
|
42
|
+
return (error instanceof GimpError &&
|
|
43
|
+
error.code === 'invalid_argument' &&
|
|
44
|
+
/no open image with id/i.test(error.message));
|
|
45
|
+
}
|
|
46
|
+
async function refreshOpenness(gimp, registry, opts) {
|
|
47
|
+
if (registry.size === 0)
|
|
48
|
+
return;
|
|
49
|
+
const applyGenerationCheck = () => {
|
|
50
|
+
const currentGeneration = gimp.generation;
|
|
51
|
+
if (currentGeneration === undefined)
|
|
52
|
+
return;
|
|
53
|
+
for (const record of registry.values()) {
|
|
54
|
+
if (record.open &&
|
|
55
|
+
record.generation !== undefined &&
|
|
56
|
+
record.generation !== currentGeneration) {
|
|
57
|
+
record.open = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
applyGenerationCheck();
|
|
62
|
+
if (opts.requireAlreadyRunning && gimp.state !== 'ready')
|
|
63
|
+
return;
|
|
64
|
+
let ping;
|
|
65
|
+
try {
|
|
66
|
+
ping = await gimp.call('ping', {});
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
applyGenerationCheck();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!Array.isArray(ping.images))
|
|
73
|
+
return;
|
|
74
|
+
const openIds = new Set(ping.images);
|
|
75
|
+
for (const record of registry.values()) {
|
|
76
|
+
if (record.open && !openIds.has(record.image))
|
|
77
|
+
record.open = false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function repointSiblings(registry, from, to, generation, newGeneration) {
|
|
81
|
+
for (const sibling of registry.values()) {
|
|
82
|
+
if (sibling.image !== from)
|
|
83
|
+
continue;
|
|
84
|
+
if (generation !== undefined) {
|
|
85
|
+
if (sibling.generation !== generation)
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
else if (sibling.open === false) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
sibling.image = to;
|
|
92
|
+
sibling.open = true;
|
|
93
|
+
sibling.generation = newGeneration;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function markSiblingsGone(registry, image, generation) {
|
|
97
|
+
for (const sibling of registry.values()) {
|
|
98
|
+
if (sibling.image !== image)
|
|
99
|
+
continue;
|
|
100
|
+
if (generation !== undefined && sibling.generation !== generation)
|
|
101
|
+
continue;
|
|
102
|
+
sibling.open = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const STORE_DIR_NAME_RE = /^checkpoints-(\d+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
106
|
+
function isPidAlive(pid) {
|
|
107
|
+
try {
|
|
108
|
+
process.kill(pid, 0);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
return err.code !== 'ESRCH';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const DEAD_PID_MIN_AGE_MS = 60 * 60 * 1000;
|
|
116
|
+
export function sweepStaleCheckpointDirs(parentDir, ownDir, trackedDirs, processStartedAt = Date.now() - process.uptime() * 1000) {
|
|
117
|
+
const entries = readdirSync(parentDir);
|
|
118
|
+
for (const name of entries) {
|
|
119
|
+
const match = STORE_DIR_NAME_RE.exec(name);
|
|
120
|
+
if (!match)
|
|
121
|
+
continue;
|
|
122
|
+
const path = join(parentDir, name);
|
|
123
|
+
if (path === ownDir)
|
|
124
|
+
continue;
|
|
125
|
+
const pid = Number(match[1]);
|
|
126
|
+
let stat;
|
|
127
|
+
try {
|
|
128
|
+
stat = lstatSync(path);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!stat.isDirectory())
|
|
134
|
+
continue;
|
|
135
|
+
if (pid === process.pid) {
|
|
136
|
+
if (trackedDirs.has(path))
|
|
137
|
+
continue;
|
|
138
|
+
if (stat.birthtimeMs >= processStartedAt)
|
|
139
|
+
continue;
|
|
140
|
+
try {
|
|
141
|
+
rmSync(path, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (isPidAlive(pid))
|
|
148
|
+
continue;
|
|
149
|
+
if (Date.now() - stat.mtimeMs < DEAD_PID_MIN_AGE_MS)
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
rmSync(path, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function cleanupCheckpointDirs(dirs) {
|
|
159
|
+
for (const dir of dirs) {
|
|
160
|
+
try {
|
|
161
|
+
rmSync(dir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const trackedDirs = new Set();
|
|
168
|
+
let exitCleanupRegistered = false;
|
|
169
|
+
function registerDirForExitCleanup(dir) {
|
|
170
|
+
trackedDirs.add(dir);
|
|
171
|
+
if (exitCleanupRegistered)
|
|
172
|
+
return;
|
|
173
|
+
exitCleanupRegistered = true;
|
|
174
|
+
process.once('exit', () => cleanupCheckpointDirs(trackedDirs));
|
|
175
|
+
}
|
|
176
|
+
export function isTrackedForExitCleanup(dir) {
|
|
177
|
+
return trackedDirs.has(dir);
|
|
178
|
+
}
|
|
179
|
+
async function ensureStoreDir(gimp, store) {
|
|
180
|
+
if (store.dir !== undefined)
|
|
181
|
+
return store.dir;
|
|
182
|
+
if (store.dirPromise !== undefined)
|
|
183
|
+
return store.dirPromise;
|
|
184
|
+
const attempt = (async () => {
|
|
185
|
+
await gimp.prepare();
|
|
186
|
+
const dir = gimp.tempPath(`checkpoints-${process.pid}-${randomUUID()}`);
|
|
187
|
+
try {
|
|
188
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
189
|
+
mkdirSync(dir);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
throw new GimpError('gimp_op_failed', 'could not create the checkpoint storage directory.');
|
|
193
|
+
}
|
|
194
|
+
store.dir = dir;
|
|
195
|
+
registerDirForExitCleanup(dir);
|
|
196
|
+
return dir;
|
|
197
|
+
})();
|
|
198
|
+
store.dirPromise = attempt;
|
|
199
|
+
try {
|
|
200
|
+
return await attempt;
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
store.dirPromise = undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export function maybeSweepSiblingDirs(store, dir) {
|
|
207
|
+
if (store.sweepDone)
|
|
208
|
+
return;
|
|
209
|
+
try {
|
|
210
|
+
sweepStaleCheckpointDirs(dirname(dir), dir, trackedDirs);
|
|
211
|
+
store.sweepDone = true;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function checkpointCreate(gimp, args, store) {
|
|
217
|
+
const { registry } = store;
|
|
218
|
+
const image = args.image;
|
|
219
|
+
if (typeof image !== 'number') {
|
|
220
|
+
throw new GimpError('invalid_argument', '"image" is required for op=create.');
|
|
221
|
+
}
|
|
222
|
+
const dir = await ensureStoreDir(gimp, store);
|
|
223
|
+
maybeSweepSiblingDirs(store, dir);
|
|
224
|
+
await refreshOpenness(gimp, registry, { requireAlreadyRunning: false });
|
|
225
|
+
if (registry.size >= MAX_CHECKPOINTS_TOTAL) {
|
|
226
|
+
throw new GimpError('invalid_argument', `this checkpoint store already holds ${MAX_CHECKPOINTS_TOTAL} checkpoints, its overall ` +
|
|
227
|
+
`limit across every image (including ones whose image has since closed — they still hold ` +
|
|
228
|
+
`disk space until deleted). Delete one first — gimp_checkpoint op=delete — or list them ` +
|
|
229
|
+
`with op=list.`);
|
|
230
|
+
}
|
|
231
|
+
const existingOpen = [...registry.values()].filter((r) => r.image === image && r.open);
|
|
232
|
+
if (existingOpen.length >= MAX_CHECKPOINTS_PER_IMAGE) {
|
|
233
|
+
throw new GimpError('invalid_argument', `image ${image} already has ${MAX_CHECKPOINTS_PER_IMAGE} checkpoints, the maximum per image. ` +
|
|
234
|
+
`Delete one first — gimp_checkpoint op=delete, checkpoint_id one of: ` +
|
|
235
|
+
`${existingOpen.map((r) => r.id).join(', ')} — or list them with op=list.`);
|
|
236
|
+
}
|
|
237
|
+
const id = randomUUID();
|
|
238
|
+
const path = join(dir, `${id}.xcf`);
|
|
239
|
+
const createdAt = new Date().toISOString();
|
|
240
|
+
registry.set(id, {
|
|
241
|
+
id,
|
|
242
|
+
image,
|
|
243
|
+
path,
|
|
244
|
+
bytes: 0,
|
|
245
|
+
createdAt,
|
|
246
|
+
open: true,
|
|
247
|
+
pending: true,
|
|
248
|
+
generation: gimp.generation,
|
|
249
|
+
});
|
|
250
|
+
try {
|
|
251
|
+
const result = await gimp.call('export', { image, path });
|
|
252
|
+
const record = registry.get(id);
|
|
253
|
+
if (!record) {
|
|
254
|
+
throw new GimpError('gimp_op_failed', `checkpoint "${id}" vanished from the registry.`);
|
|
255
|
+
}
|
|
256
|
+
record.bytes = result.bytes;
|
|
257
|
+
record.pending = false;
|
|
258
|
+
return {
|
|
259
|
+
content: [
|
|
260
|
+
{
|
|
261
|
+
type: 'text',
|
|
262
|
+
text: `Checkpoint "${id}" created from image ${image} (${result.bytes.toLocaleString()} bytes). ` +
|
|
263
|
+
`Restore it later with gimp_checkpoint op=restore, checkpoint_id: "${id}".`,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
structuredContent: { checkpoint_id: id, image, bytes: result.bytes, created_at: createdAt },
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
registry.delete(id);
|
|
271
|
+
try {
|
|
272
|
+
await rm(path, { force: true });
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
}
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async function checkpointList(gimp, args, store) {
|
|
280
|
+
const { registry } = store;
|
|
281
|
+
await refreshOpenness(gimp, registry, { requireAlreadyRunning: true });
|
|
282
|
+
const image = args.image;
|
|
283
|
+
const scoped = typeof image === 'number';
|
|
284
|
+
const filtered = [...registry.values()].filter((r) => {
|
|
285
|
+
if (!scoped)
|
|
286
|
+
return true;
|
|
287
|
+
return r.image === image && r.open;
|
|
288
|
+
});
|
|
289
|
+
const checkpoints = filtered.map((r) => ({
|
|
290
|
+
checkpoint_id: r.id,
|
|
291
|
+
image: r.image,
|
|
292
|
+
open: r.open,
|
|
293
|
+
pending: r.pending,
|
|
294
|
+
bytes: r.bytes,
|
|
295
|
+
created_at: r.createdAt,
|
|
296
|
+
}));
|
|
297
|
+
const goneCount = checkpoints.filter((c) => !c.open).length;
|
|
298
|
+
const scopedNote = scoped ? ` for image ${image}` : '';
|
|
299
|
+
const goneNote = goneCount > 0
|
|
300
|
+
? ` (${goneCount} for an image that is now gone — still restorable by checkpoint_id)`
|
|
301
|
+
: '';
|
|
302
|
+
return {
|
|
303
|
+
content: [
|
|
304
|
+
{
|
|
305
|
+
type: 'text',
|
|
306
|
+
text: `${checkpoints.length} checkpoint(s)${scopedNote}.${goneNote}`,
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
structuredContent: { checkpoints },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function requireKnownCheckpoint(args, op, registry) {
|
|
313
|
+
const checkpointId = args.checkpoint_id;
|
|
314
|
+
if (typeof checkpointId !== 'string' || checkpointId.length === 0) {
|
|
315
|
+
throw new GimpError('invalid_argument', `"checkpoint_id" is required for op=${op}.`);
|
|
316
|
+
}
|
|
317
|
+
const record = registry.get(checkpointId);
|
|
318
|
+
if (!record) {
|
|
319
|
+
const known = [...registry.keys()];
|
|
320
|
+
throw new GimpError('invalid_argument', `unknown checkpoint_id "${checkpointId}". ` +
|
|
321
|
+
(known.length > 0
|
|
322
|
+
? `Known checkpoint ids: ${known.join(', ')}.`
|
|
323
|
+
: 'No checkpoints exist yet.'));
|
|
324
|
+
}
|
|
325
|
+
if (record.pending) {
|
|
326
|
+
throw new GimpError('invalid_argument', `checkpoint "${checkpointId}" is still being created — try again shortly.`);
|
|
327
|
+
}
|
|
328
|
+
return record;
|
|
329
|
+
}
|
|
330
|
+
async function checkpointDelete(args, store) {
|
|
331
|
+
const { registry } = store;
|
|
332
|
+
const record = requireKnownCheckpoint(args, 'delete', registry);
|
|
333
|
+
try {
|
|
334
|
+
await rm(record.path, { force: true });
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
throw new GimpError('gimp_op_failed', `could not delete checkpoint "${record.id}" — its file may still be open elsewhere.`);
|
|
338
|
+
}
|
|
339
|
+
registry.delete(record.id);
|
|
340
|
+
return {
|
|
341
|
+
content: [
|
|
342
|
+
{
|
|
343
|
+
type: 'text',
|
|
344
|
+
text: `Deleted checkpoint "${record.id}" (was from image ${record.image}, ` +
|
|
345
|
+
`${record.bytes.toLocaleString()} bytes).`,
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
structuredContent: { checkpoint_id: record.id, deleted: true },
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
async function checkpointRestore(gimp, args, store) {
|
|
352
|
+
const { registry } = store;
|
|
353
|
+
const record = requireKnownCheckpoint(args, 'restore', registry);
|
|
354
|
+
let opened;
|
|
355
|
+
try {
|
|
356
|
+
opened = await gimp.call('open', {
|
|
357
|
+
path: record.path,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
catch (openError) {
|
|
361
|
+
if (openError instanceof GimpError && openError.code === 'file_not_found') {
|
|
362
|
+
if (!existsSync(record.path)) {
|
|
363
|
+
registry.delete(record.id);
|
|
364
|
+
throw new GimpError('invalid_argument', `checkpoint "${record.id}"'s file is missing; it has been removed and can no longer be restored.`);
|
|
365
|
+
}
|
|
366
|
+
throw new GimpError('gimp_op_failed', `could not open checkpoint "${record.id}"'s file — retry gimp_checkpoint op=restore.`);
|
|
367
|
+
}
|
|
368
|
+
throw openError;
|
|
369
|
+
}
|
|
370
|
+
const oldImage = record.image;
|
|
371
|
+
const oldGeneration = record.generation;
|
|
372
|
+
const currentGeneration = gimp.generation;
|
|
373
|
+
const generationsKnown = oldGeneration !== undefined && currentGeneration !== undefined;
|
|
374
|
+
const generationChanged = generationsKnown && oldGeneration !== currentGeneration;
|
|
375
|
+
let closeNote;
|
|
376
|
+
let closeFailed = false;
|
|
377
|
+
if (oldImage === opened.image) {
|
|
378
|
+
closeNote = `image ${oldImage} is the id restore just reopened — nothing to close`;
|
|
379
|
+
if (generationChanged) {
|
|
380
|
+
markSiblingsGone(registry, oldImage, oldGeneration);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else if (generationChanged) {
|
|
384
|
+
closeNote =
|
|
385
|
+
`image ${oldImage} belonged to a GIMP process that has since restarted — left untouched ` +
|
|
386
|
+
`(its number may now belong to a different, unrelated image)`;
|
|
387
|
+
markSiblingsGone(registry, oldImage, oldGeneration);
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
try {
|
|
391
|
+
await gimp.call('close', { image: oldImage });
|
|
392
|
+
closeNote = `image ${oldImage} closed`;
|
|
393
|
+
repointSiblings(registry, oldImage, opened.image, oldGeneration, currentGeneration);
|
|
394
|
+
}
|
|
395
|
+
catch (closeError) {
|
|
396
|
+
const generationAfterClose = gimp.generation;
|
|
397
|
+
if (currentGeneration !== undefined &&
|
|
398
|
+
generationAfterClose !== undefined &&
|
|
399
|
+
generationAfterClose !== currentGeneration) {
|
|
400
|
+
throw new GimpError('gimp_op_failed', `the GIMP process restarted while restoring checkpoint "${record.id}" — the checkpoint ` +
|
|
401
|
+
'file itself is unaffected; retry gimp_checkpoint op=restore with the same checkpoint_id.');
|
|
402
|
+
}
|
|
403
|
+
if (isSessionRestartedError(closeError)) {
|
|
404
|
+
closeNote = `image ${oldImage} was already gone (the GIMP session had restarted)`;
|
|
405
|
+
markSiblingsGone(registry, oldImage, oldGeneration);
|
|
406
|
+
}
|
|
407
|
+
else if (isImageAlreadyClosedError(closeError)) {
|
|
408
|
+
closeNote = `image ${oldImage} was already closed`;
|
|
409
|
+
markSiblingsGone(registry, oldImage, oldGeneration);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
closeFailed = true;
|
|
413
|
+
const msg = closeError instanceof Error ? closeError.message : String(closeError);
|
|
414
|
+
closeNote = `image ${oldImage} could not be closed (${msg}) — it is still open`;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
record.image = opened.image;
|
|
419
|
+
record.generation = currentGeneration;
|
|
420
|
+
record.open = true;
|
|
421
|
+
return {
|
|
422
|
+
content: [
|
|
423
|
+
{
|
|
424
|
+
type: 'text',
|
|
425
|
+
text: `${closeNote}; restored as image ${opened.image} ` +
|
|
426
|
+
`(${opened.width}x${opened.height}) — use ${opened.image} from now on.`,
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
structuredContent: {
|
|
430
|
+
checkpoint_id: record.id,
|
|
431
|
+
old_image: oldImage,
|
|
432
|
+
image: opened.image,
|
|
433
|
+
width: opened.width,
|
|
434
|
+
height: opened.height,
|
|
435
|
+
base_type: opened.base_type,
|
|
436
|
+
precision: opened.precision,
|
|
437
|
+
layers: opened.layers,
|
|
438
|
+
close_failed: closeFailed,
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
async function gimpCheckpoint(gimp, rawArgs, store) {
|
|
443
|
+
try {
|
|
444
|
+
const args = validateArgs(checkpointSchema, rawArgs);
|
|
445
|
+
const op = args.op;
|
|
446
|
+
if (op !== 'create' && op !== 'restore' && op !== 'list' && op !== 'delete') {
|
|
447
|
+
return unknownDiscriminator('op', op, ['create', 'restore', 'list', 'delete']);
|
|
448
|
+
}
|
|
449
|
+
if (op === 'create')
|
|
450
|
+
return await checkpointCreate(gimp, args, store);
|
|
451
|
+
if (op === 'list')
|
|
452
|
+
return await checkpointList(gimp, args, store);
|
|
453
|
+
if (op === 'delete')
|
|
454
|
+
return await checkpointDelete(args, store);
|
|
455
|
+
return await checkpointRestore(gimp, args, store);
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
return toolGimpErrorResult('Error managing GIMP checkpoint', error);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
export function createGimpCheckpointTools(gimp) {
|
|
462
|
+
const store = {
|
|
463
|
+
registry: new Map(),
|
|
464
|
+
dir: undefined,
|
|
465
|
+
dirPromise: undefined,
|
|
466
|
+
sweepDone: false,
|
|
467
|
+
};
|
|
468
|
+
return [
|
|
469
|
+
{
|
|
470
|
+
tool: {
|
|
471
|
+
name: 'gimp_checkpoint',
|
|
472
|
+
description: 'Headless GIMP: disk-backed checkpoints — the substitute for undo in a session that has ' +
|
|
473
|
+
"none. op=create exports the image's CURRENT state, every live filter included, to a " +
|
|
474
|
+
'snapshot file and returns a checkpoint_id. op=restore reopens that snapshot as a NEW ' +
|
|
475
|
+
'image and closes the old one — replace semantics, not a copy: the old image id stops ' +
|
|
476
|
+
'working once the close succeeds (if it fails instead — reported as close_failed: true ' +
|
|
477
|
+
'— the old image stays open, and both ids are valid). It returns the new image id and ' +
|
|
478
|
+
'says so plainly, e.g. "image N closed; restored as image M — use M from now on". A ' +
|
|
479
|
+
'checkpoint survives a GIMP crash or timeout: restore reopens the FILE, not anything the ' +
|
|
480
|
+
'crashed process held, so it still works right after a gimp_session_restarted error — ' +
|
|
481
|
+
"that is the whole point of keeping it on disk instead of in GIMP's own memory. Each " +
|
|
482
|
+
`image holds at most ${MAX_CHECKPOINTS_PER_IMAGE} checkpoints; a checkpoint whose own ` +
|
|
483
|
+
"image has since closed doesn't count against a different image that later reuses the " +
|
|
484
|
+
'same number. A 6th op=create for the same still-open image REFUSES outright rather ' +
|
|
485
|
+
'than silently evicting the oldest — delete one first (op=delete) or list them ' +
|
|
486
|
+
`(op=list) to see what exists. This store also holds at most ${MAX_CHECKPOINTS_TOTAL} ` +
|
|
487
|
+
'checkpoints in total, across every image, since each one still takes up disk space ' +
|
|
488
|
+
'until deleted. Checkpoint files are kept while this server runs and removed when it ' +
|
|
489
|
+
'exits; files left by a server that crashed or was killed are cleaned up by a later ' +
|
|
490
|
+
'server, once they are more than an hour old, the next time a checkpoint is made. ' +
|
|
491
|
+
'op=list reports checkpoint_id, the image it currently belongs to, created_at, and its ' +
|
|
492
|
+
'size in bytes — never a file path (a full path carries the username) — plus open: ' +
|
|
493
|
+
'false for a checkpoint whose image has since closed (still restorable; just excluded ' +
|
|
494
|
+
"from that image's own scoped list and cap) and pending: true while a create is still " +
|
|
495
|
+
"in flight. op=restore also returns the reopened image's base_type, precision, and " +
|
|
496
|
+
'layers, the same as gimp_open_document. An unknown checkpoint_id on restore or delete ' +
|
|
497
|
+
'is refused, naming every checkpoint_id that IS known.',
|
|
498
|
+
inputSchema: checkpointSchema,
|
|
499
|
+
outputSchema: {
|
|
500
|
+
type: 'object',
|
|
501
|
+
properties: {
|
|
502
|
+
checkpoint_id: { type: 'string' },
|
|
503
|
+
image: { type: 'number' },
|
|
504
|
+
old_image: { type: 'number' },
|
|
505
|
+
width: { type: 'number' },
|
|
506
|
+
height: { type: 'number' },
|
|
507
|
+
base_type: { type: 'string' },
|
|
508
|
+
precision: { type: 'string' },
|
|
509
|
+
layers: { type: 'array', items: { type: 'string' } },
|
|
510
|
+
bytes: { type: 'number' },
|
|
511
|
+
created_at: { type: 'string' },
|
|
512
|
+
deleted: { type: 'boolean' },
|
|
513
|
+
close_failed: { type: 'boolean' },
|
|
514
|
+
checkpoints: {
|
|
515
|
+
type: 'array',
|
|
516
|
+
items: {
|
|
517
|
+
type: 'object',
|
|
518
|
+
properties: {
|
|
519
|
+
checkpoint_id: { type: 'string' },
|
|
520
|
+
image: { type: 'number' },
|
|
521
|
+
open: { type: 'boolean' },
|
|
522
|
+
pending: { type: 'boolean' },
|
|
523
|
+
bytes: { type: 'number' },
|
|
524
|
+
created_at: { type: 'string' },
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
annotations: {
|
|
531
|
+
title: 'Manage GIMP Checkpoints',
|
|
532
|
+
readOnlyHint: false,
|
|
533
|
+
destructiveHint: true,
|
|
534
|
+
idempotentHint: false,
|
|
535
|
+
openWorldHint: false,
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
handler: async (args) => gimpCheckpoint(gimp, args, store),
|
|
539
|
+
},
|
|
540
|
+
];
|
|
541
|
+
}
|