@higgsfield/video-editor-mcp 1.0.5 → 1.0.7
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/dist/index.html +158 -144
- package/dist/server.mjs +69 -24
- package/node_modules/@higgsfield/higgsedit/dist/chunk-C6SASQHE.mjs +10 -76
- package/node_modules/@higgsfield/higgsedit/dist/chunk-QLPCYN6K.mjs +22 -5
- package/node_modules/@higgsfield/higgsedit/dist/chunk-VI6IV6JP.mjs +35 -19
- package/node_modules/@higgsfield/higgsedit/dist/higgsedit.mjs +14 -24
- package/node_modules/@higgsfield/higgsedit/dist/history.mjs +295 -0
- package/package.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/plugin.json +1 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const MAX_BYTES = 16 * 1024 * 1024;
|
|
5
|
+
const MAX_ENTRIES = 100;
|
|
6
|
+
const STALE = "edit history changed in another editor; reopen the project before editing or undoing";
|
|
7
|
+
const message = (error) => error instanceof Error ? error.message : String(error);
|
|
8
|
+
const missing = (error) => error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
9
|
+
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
const safePath = (path) => !/[\\\0]/.test(path) && path.split("/").every((part) => part !== "" && part !== "." && part !== ".." && part !== ".higgsedit-cache") && !/^[A-Za-z]:/.test(path);
|
|
11
|
+
const identity = (stat) => `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
12
|
+
function encode(undo, redo, revision, text = (entry) => JSON.stringify(entryValue(entry))) {
|
|
13
|
+
return `{"version":1,"revision":${JSON.stringify(revision)},"undo":[${undo.map(text).join(",")}],"redo":[${redo.map(text).join(",")}]}`;
|
|
14
|
+
}
|
|
15
|
+
const entryValue = ({ name, files }) => ({ name, files: [...files].map(([path, texts]) => ({ path, ...texts })) });
|
|
16
|
+
function decode(text) {
|
|
17
|
+
let value;
|
|
18
|
+
try {
|
|
19
|
+
value = JSON.parse(text);
|
|
20
|
+
} catch {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!record(value) || value.version !== 1 || typeof value.revision !== "string" || !Array.isArray(value.undo) || !Array.isArray(value.redo) || value.undo.length + value.redo.length > MAX_ENTRIES) return;
|
|
24
|
+
const entries = (stack) => {
|
|
25
|
+
const result = [];
|
|
26
|
+
for (const entry of stack) {
|
|
27
|
+
if (!record(entry) || typeof entry.name !== "string" || !Array.isArray(entry.files) || !entry.files.length) return;
|
|
28
|
+
const files = /* @__PURE__ */ new Map();
|
|
29
|
+
for (const file of entry.files) {
|
|
30
|
+
if (!record(file) || typeof file.path !== "string" || !safePath(file.path) || typeof file.before !== "string" || typeof file.after !== "string" || files.has(file.path)) return;
|
|
31
|
+
files.set(file.path, { before: file.before, after: file.after });
|
|
32
|
+
}
|
|
33
|
+
result.push({ name: entry.name, files });
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
const undo = entries(value.undo), redo = entries(value.redo);
|
|
38
|
+
return undo && redo ? { undo, redo } : void 0;
|
|
39
|
+
}
|
|
40
|
+
class EditHistory {
|
|
41
|
+
constructor(dir, onWrite, limits = {}) {
|
|
42
|
+
this.dir = dir;
|
|
43
|
+
this.onWrite = onWrite;
|
|
44
|
+
this.maxBytes = Math.min(MAX_BYTES, limits.maxBytes ?? MAX_BYTES);
|
|
45
|
+
this.maxEntries = Math.min(MAX_ENTRIES, limits.maxEntries ?? MAX_ENTRIES);
|
|
46
|
+
if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes < 100 || !Number.isSafeInteger(this.maxEntries) || this.maxEntries < 1) throw new Error("invalid edit history limits");
|
|
47
|
+
try {
|
|
48
|
+
const saved = this.readJournal();
|
|
49
|
+
this.revision = saved.revision;
|
|
50
|
+
const parsed = saved.text === void 0 ? void 0 : decode(saved.text);
|
|
51
|
+
if (parsed) {
|
|
52
|
+
const bounded = this.bound(parsed.undo, parsed.redo);
|
|
53
|
+
this.undoStack = bounded.undo;
|
|
54
|
+
this.redoStack = bounded.redo;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
dir;
|
|
60
|
+
onWrite;
|
|
61
|
+
undoStack = [];
|
|
62
|
+
redoStack = [];
|
|
63
|
+
revision = "missing";
|
|
64
|
+
maxBytes;
|
|
65
|
+
maxEntries;
|
|
66
|
+
// Committed entries never change. Weak keys release evicted history and abandoned transactions.
|
|
67
|
+
encoded = /* @__PURE__ */ new WeakMap();
|
|
68
|
+
begin(name) {
|
|
69
|
+
if (typeof name !== "string") throw new Error("edit history name must be a string");
|
|
70
|
+
this.assertCurrent();
|
|
71
|
+
const entry = { name, files: /* @__PURE__ */ new Map() };
|
|
72
|
+
const previousWrites = /* @__PURE__ */ new Map();
|
|
73
|
+
const fileBytes = /* @__PURE__ */ new Map();
|
|
74
|
+
let entryBytes = Buffer.byteLength(encode([entry], [], randomUUID()));
|
|
75
|
+
let finished = false;
|
|
76
|
+
return {
|
|
77
|
+
onWrite: (path, text) => {
|
|
78
|
+
if (finished) throw new Error("edit history transaction already finished");
|
|
79
|
+
const prior = entry.files.get(path);
|
|
80
|
+
const current = this.readSource(path);
|
|
81
|
+
if (prior && current !== prior.after) throw new Error(`${path} changed outside the editor since ${entry.name}`);
|
|
82
|
+
const texts = { before: prior?.before ?? current, after: text };
|
|
83
|
+
const bytes = Buffer.byteLength(JSON.stringify({ path, ...texts }));
|
|
84
|
+
const nextBytes = entryBytes - (fileBytes.get(path) ?? 0) + bytes + (!prior && entry.files.size ? 1 : 0);
|
|
85
|
+
if (nextBytes > this.maxBytes) throw new Error(`edit history size limit exceeded (${this.maxBytes} bytes); split the edit or reduce the affected source files`);
|
|
86
|
+
entryBytes = nextBytes;
|
|
87
|
+
fileBytes.set(path, bytes);
|
|
88
|
+
previousWrites.set(path, current);
|
|
89
|
+
entry.files.set(path, texts);
|
|
90
|
+
this.encoded.delete(entry);
|
|
91
|
+
this.onWrite?.(path, text);
|
|
92
|
+
},
|
|
93
|
+
commit: () => {
|
|
94
|
+
if (finished) throw new Error("edit history transaction already finished");
|
|
95
|
+
if (entry.files.size) {
|
|
96
|
+
this.checkSources(entry, "after");
|
|
97
|
+
this.publish([...this.undoStack, entry], []);
|
|
98
|
+
}
|
|
99
|
+
finished = true;
|
|
100
|
+
},
|
|
101
|
+
rollback: () => {
|
|
102
|
+
if (finished) return;
|
|
103
|
+
this.restore(entry, "after", "before", previousWrites);
|
|
104
|
+
finished = true;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
state() {
|
|
109
|
+
return {
|
|
110
|
+
canUndo: this.undoStack.length > 0,
|
|
111
|
+
canRedo: this.redoStack.length > 0,
|
|
112
|
+
undoName: this.undoStack.at(-1)?.name ?? null,
|
|
113
|
+
redoName: this.redoStack.at(-1)?.name ?? null
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
undo() {
|
|
117
|
+
return this.step(this.undoStack, this.redoStack, "after", "before");
|
|
118
|
+
}
|
|
119
|
+
redo() {
|
|
120
|
+
return this.step(this.redoStack, this.undoStack, "before", "after");
|
|
121
|
+
}
|
|
122
|
+
step(from, to, expect, write) {
|
|
123
|
+
const entry = from.at(-1);
|
|
124
|
+
if (!entry) return { ok: false, reason: `nothing to ${write === "before" ? "undo" : "redo"}` };
|
|
125
|
+
try {
|
|
126
|
+
this.assertCurrent();
|
|
127
|
+
this.checkSources(entry, expect);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return { ok: false, reason: message(error) };
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
for (const [path, texts] of entry.files) this.write(path, texts[write]);
|
|
133
|
+
const remaining = from.slice(0, -1), moved = [...to, entry];
|
|
134
|
+
this.publish(write === "before" ? remaining : moved, write === "before" ? moved : remaining);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
try {
|
|
137
|
+
this.restore(entry, write, expect);
|
|
138
|
+
} catch (rollbackError) {
|
|
139
|
+
return { ok: false, reason: `${message(error)}; rollback failed: ${message(rollbackError)}` };
|
|
140
|
+
}
|
|
141
|
+
return { ok: false, reason: message(error) };
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, written: [...entry.files.keys()] };
|
|
144
|
+
}
|
|
145
|
+
checkSources(entry, expect) {
|
|
146
|
+
for (const [path, texts] of entry.files) {
|
|
147
|
+
if (this.readSource(path) !== texts[expect]) throw new Error(`${path} changed outside the editor since ${entry.name}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** A failed write may still contain the original text. Never roll back someone else's version. */
|
|
151
|
+
restore(entry, expect, restore, previousWrites) {
|
|
152
|
+
const pending = [];
|
|
153
|
+
for (const [path, texts] of entry.files) {
|
|
154
|
+
const current = this.readSource(path);
|
|
155
|
+
if (current === texts[restore]) continue;
|
|
156
|
+
if (current !== texts[expect] && current !== previousWrites?.get(path)) throw new Error(`${path} changed outside the editor since ${entry.name}`);
|
|
157
|
+
pending.push([path, texts[restore]]);
|
|
158
|
+
}
|
|
159
|
+
for (const [path, text] of pending) this.write(path, text);
|
|
160
|
+
}
|
|
161
|
+
readSource(path) {
|
|
162
|
+
if (!safePath(path)) throw new Error("unsafe edit history source path");
|
|
163
|
+
let target = this.dir;
|
|
164
|
+
for (const part of path.split("/")) {
|
|
165
|
+
target = join(target, part);
|
|
166
|
+
try {
|
|
167
|
+
if (lstatSync(target).isSymbolicLink()) throw new Error(`${path} is a symlink; cannot restore edit history`);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (missing(error)) throw new Error(`${path} no longer exists`, { cause: error });
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return readFileSync(target, "utf8");
|
|
174
|
+
}
|
|
175
|
+
write(path, text) {
|
|
176
|
+
const target = join(this.dir, path);
|
|
177
|
+
this.onWrite?.(path, text);
|
|
178
|
+
this.atomicWrite(target, text, lstatSync(target).mode & 511);
|
|
179
|
+
}
|
|
180
|
+
cacheDir() {
|
|
181
|
+
const cache = join(this.dir, ".higgsedit-cache");
|
|
182
|
+
try {
|
|
183
|
+
const stat = lstatSync(cache);
|
|
184
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe edit history cache directory");
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (!missing(error)) throw error;
|
|
187
|
+
}
|
|
188
|
+
return cache;
|
|
189
|
+
}
|
|
190
|
+
readJournal() {
|
|
191
|
+
const file = join(this.cacheDir(), "history.json");
|
|
192
|
+
let fd;
|
|
193
|
+
try {
|
|
194
|
+
if (!lstatSync(file).isFile()) throw new Error("edit history journal is not a regular file");
|
|
195
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (missing(error)) return { revision: "missing" };
|
|
198
|
+
throw new Error(`cannot read edit history: ${message(error)}`, { cause: error });
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const stat = fstatSync(fd, { bigint: true });
|
|
202
|
+
if (!stat.isFile()) throw new Error("edit history journal is not a regular file");
|
|
203
|
+
const revision = identity(stat);
|
|
204
|
+
if (stat.size > BigInt(this.maxBytes)) return { revision };
|
|
205
|
+
const bytes = Buffer.alloc(Number(stat.size) + 1);
|
|
206
|
+
let length = 0, count = 0;
|
|
207
|
+
do {
|
|
208
|
+
count = readSync(fd, bytes, length, bytes.length - length, null);
|
|
209
|
+
length += count;
|
|
210
|
+
} while (count && length < bytes.length);
|
|
211
|
+
if (BigInt(length) !== stat.size || identity(fstatSync(fd, { bigint: true })) !== revision) throw new Error("edit history changed while reading");
|
|
212
|
+
const data = bytes.subarray(0, length);
|
|
213
|
+
return { revision, text: data.toString("utf8") };
|
|
214
|
+
} finally {
|
|
215
|
+
closeSync(fd);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
assertCurrent() {
|
|
219
|
+
let revision;
|
|
220
|
+
try {
|
|
221
|
+
const stat = lstatSync(join(this.cacheDir(), "history.json"), { bigint: true });
|
|
222
|
+
if (!stat.isFile()) throw new Error("edit history journal is not a regular file");
|
|
223
|
+
revision = identity(stat);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (!missing(error)) throw error;
|
|
226
|
+
revision = "missing";
|
|
227
|
+
}
|
|
228
|
+
if (revision !== this.revision) throw new Error(STALE);
|
|
229
|
+
}
|
|
230
|
+
serialized(entry) {
|
|
231
|
+
let saved = this.encoded.get(entry);
|
|
232
|
+
if (!saved) {
|
|
233
|
+
const text = JSON.stringify(entryValue(entry));
|
|
234
|
+
saved = { text, bytes: Buffer.byteLength(text) };
|
|
235
|
+
this.encoded.set(entry, saved);
|
|
236
|
+
}
|
|
237
|
+
return saved;
|
|
238
|
+
}
|
|
239
|
+
bound(undo, redo) {
|
|
240
|
+
const revision = randomUUID();
|
|
241
|
+
const sized = (stack) => stack.map((entry) => ({ entry, bytes: this.serialized(entry).bytes }));
|
|
242
|
+
const keepUndo = sized(undo), keepRedo = sized(redo);
|
|
243
|
+
let bytes = Buffer.byteLength(encode([], [], revision)) + [...keepUndo, ...keepRedo].reduce((sum, entry) => sum + entry.bytes, 0);
|
|
244
|
+
const separators = () => Math.max(0, keepUndo.length - 1) + Math.max(0, keepRedo.length - 1);
|
|
245
|
+
while (keepUndo.length + keepRedo.length > this.maxEntries || bytes + separators() > this.maxBytes) {
|
|
246
|
+
const removed = keepUndo.length > 1 || !keepRedo.length ? keepUndo.shift() : keepRedo.shift();
|
|
247
|
+
if (!removed) throw new Error("edit history size limit exceeded");
|
|
248
|
+
bytes -= removed.bytes;
|
|
249
|
+
}
|
|
250
|
+
undo = keepUndo.map(({ entry }) => entry);
|
|
251
|
+
redo = keepRedo.map(({ entry }) => entry);
|
|
252
|
+
return { undo, redo, text: encode(undo, redo, revision, (entry) => this.serialized(entry).text) };
|
|
253
|
+
}
|
|
254
|
+
publish(undo, redo) {
|
|
255
|
+
const next = this.bound(undo, redo);
|
|
256
|
+
this.assertCurrent();
|
|
257
|
+
const cache = this.cacheDir();
|
|
258
|
+
mkdirSync(cache, { recursive: true, mode: 448 });
|
|
259
|
+
const target = join(cache, "history.json");
|
|
260
|
+
try {
|
|
261
|
+
this.revision = this.atomicWrite(target, next.text, 384);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
throw new Error(`cannot save edit history: ${message(error)}`, { cause: error });
|
|
264
|
+
}
|
|
265
|
+
this.undoStack = next.undo;
|
|
266
|
+
this.redoStack = next.redo;
|
|
267
|
+
}
|
|
268
|
+
atomicWrite(target, text, mode) {
|
|
269
|
+
const tmp = `${target}.higgsedit-${process.pid}-${randomUUID()}.tmp`;
|
|
270
|
+
let published = false;
|
|
271
|
+
let fd;
|
|
272
|
+
try {
|
|
273
|
+
fd = openSync(tmp, "wx", mode);
|
|
274
|
+
writeFileSync(fd, text);
|
|
275
|
+
fsyncSync(fd);
|
|
276
|
+
renameSync(tmp, target);
|
|
277
|
+
published = true;
|
|
278
|
+
return identity(fstatSync(fd, { bigint: true }));
|
|
279
|
+
} finally {
|
|
280
|
+
try {
|
|
281
|
+
if (fd !== void 0) closeSync(fd);
|
|
282
|
+
} finally {
|
|
283
|
+
if (!published) {
|
|
284
|
+
try {
|
|
285
|
+
unlinkSync(tmp);
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
export {
|
|
294
|
+
EditHistory
|
|
295
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@higgsfield/video-editor-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Higgsfield Video Editor as a local MCP server over stdio: edits TSX video projects on this machine with the higgsedit CLI and serves the editor panel.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "higgsfield-video-editor-local",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Edit and render video projects on your Mac with Higgsfield. Arrange clips, add text, audio and motion graphics, preview your timeline in the interactive editor, and render finished videos. Generate images and videos in the editor by connecting your Higgsfield account.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Higgsfield AI, Inc."
|
package/plugin/plugin.json
CHANGED