@higgsfield/video-editor-mcp 1.0.2 → 1.0.4
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 +3 -3
- package/dist/index.html +255 -138
- package/dist/server.mjs +634 -262
- package/node_modules/@higgsfield/higgsedit/dist/capture-page.js +113 -39
- package/node_modules/@higgsfield/higgsedit/dist/chunk-4D6AXU4B.mjs +45 -27
- package/node_modules/@higgsfield/higgsedit/dist/chunk-C6SASQHE.mjs +130 -176
- package/node_modules/@higgsfield/higgsedit/dist/export-settings.mjs +75 -0
- package/node_modules/@higgsfield/higgsedit/dist/media-upload.mjs +381 -0
- package/node_modules/@higgsfield/higgsedit/dist/proxy.mjs +170 -0
- package/package.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/plugin.json +1 -1
- package/plugin/skills/video-editing/references/local-setup.md +1 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
constants,
|
|
4
|
+
linkSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmdirSync,
|
|
12
|
+
statSync
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { open, rm } from "node:fs/promises";
|
|
15
|
+
import { extname, join } from "node:path";
|
|
16
|
+
const json = (res, status, body) => {
|
|
17
|
+
res.statusCode = status;
|
|
18
|
+
res.setHeader("content-type", "application/json");
|
|
19
|
+
res.end(JSON.stringify(body));
|
|
20
|
+
};
|
|
21
|
+
const MEDIA_UPLOAD_PART_BYTES = 2 << 20;
|
|
22
|
+
const MEDIA_UPLOAD_TEMP_PREFIX = ".higgsedit-upload-";
|
|
23
|
+
function isPrivateMediaUploadPath(path) {
|
|
24
|
+
return path.split(/[/\\]/).some((part) => part.toUpperCase().startsWith(MEDIA_UPLOAD_TEMP_PREFIX.toUpperCase()));
|
|
25
|
+
}
|
|
26
|
+
const defaults = {
|
|
27
|
+
maxBytes: 8 * 1024 ** 3,
|
|
28
|
+
idleMs: 6e4,
|
|
29
|
+
totalMs: 30 * 6e4,
|
|
30
|
+
maxConcurrent: 4
|
|
31
|
+
};
|
|
32
|
+
const envKeys = {
|
|
33
|
+
maxBytes: "HIGGSEDIT_MEDIA_MAX_BYTES",
|
|
34
|
+
idleMs: "HIGGSEDIT_MEDIA_IDLE_MS",
|
|
35
|
+
totalMs: "HIGGSEDIT_MEDIA_TOTAL_MS",
|
|
36
|
+
maxConcurrent: "HIGGSEDIT_MEDIA_MAX_CONCURRENT"
|
|
37
|
+
};
|
|
38
|
+
class UploadError extends Error {
|
|
39
|
+
constructor(message, status) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.status = status;
|
|
42
|
+
}
|
|
43
|
+
status;
|
|
44
|
+
}
|
|
45
|
+
class MediaUploads {
|
|
46
|
+
active = /* @__PURE__ */ new Map();
|
|
47
|
+
root;
|
|
48
|
+
rootIdentity;
|
|
49
|
+
closed = false;
|
|
50
|
+
limits;
|
|
51
|
+
constructor(dir, limits = {}) {
|
|
52
|
+
this.root = realpathSync(dir);
|
|
53
|
+
this.rootIdentity = lstatSync(this.root);
|
|
54
|
+
this.limits = { ...defaults };
|
|
55
|
+
for (const key of Object.keys(defaults)) {
|
|
56
|
+
const value = limits[key] ?? (process.env[envKeys[key]] ? Number(process.env[envKeys[key]]) : defaults[key]);
|
|
57
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
58
|
+
throw new Error(`${envKeys[key]} must be a positive safe integer`);
|
|
59
|
+
this.limits[key] = value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
get busy() {
|
|
63
|
+
return this.active.size > 0;
|
|
64
|
+
}
|
|
65
|
+
async close() {
|
|
66
|
+
this.closed = true;
|
|
67
|
+
await Promise.all(
|
|
68
|
+
[...this.active.values()].map((u) => this.dispose(u, new UploadError("editor closed", 503)))
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
checkMedia() {
|
|
72
|
+
const root = lstatSync(this.root);
|
|
73
|
+
if (root.isSymbolicLink() || root.dev !== this.rootIdentity.dev || root.ino !== this.rootIdentity.ino)
|
|
74
|
+
throw new UploadError("project directory changed during upload", 400);
|
|
75
|
+
const media = join(this.root, "media");
|
|
76
|
+
mkdirSync(media, { recursive: true });
|
|
77
|
+
const st = lstatSync(media);
|
|
78
|
+
if (!st.isDirectory() || st.isSymbolicLink() || realpathSync(media) !== media)
|
|
79
|
+
throw new UploadError("media must be a real project directory, not a symlink", 400);
|
|
80
|
+
return media;
|
|
81
|
+
}
|
|
82
|
+
async begin(name) {
|
|
83
|
+
if (!name || name.startsWith(".") || /[/\\]/.test(name) || [...name].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127) || Buffer.byteLength(name) > 240)
|
|
84
|
+
throw new UploadError("invalid media filename", 400);
|
|
85
|
+
if (this.closed) throw new UploadError("editor closed", 503);
|
|
86
|
+
if (this.active.size >= this.limits.maxConcurrent)
|
|
87
|
+
throw new UploadError("too many active media uploads; try again after one finishes", 429);
|
|
88
|
+
const directory = lstatSync(this.checkMedia());
|
|
89
|
+
const temp = mkdtempSync(join(this.checkMedia(), MEDIA_UPLOAD_TEMP_PREFIX));
|
|
90
|
+
const u = {
|
|
91
|
+
id: randomUUID(),
|
|
92
|
+
name,
|
|
93
|
+
temp,
|
|
94
|
+
directory,
|
|
95
|
+
staging: lstatSync(temp),
|
|
96
|
+
bytes: 0,
|
|
97
|
+
busy: true,
|
|
98
|
+
abort: new AbortController()
|
|
99
|
+
};
|
|
100
|
+
this.active.set(u.id, u);
|
|
101
|
+
try {
|
|
102
|
+
u.file = await open(
|
|
103
|
+
join(temp, "body"),
|
|
104
|
+
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
|
|
105
|
+
384
|
|
106
|
+
);
|
|
107
|
+
if (u.abort.signal.aborted) {
|
|
108
|
+
await u.file.close();
|
|
109
|
+
u.abort.signal.throwIfAborted();
|
|
110
|
+
}
|
|
111
|
+
u.total = setTimeout(() => {
|
|
112
|
+
void this.dispose(u, new UploadError("media upload exceeded its total time limit", 408));
|
|
113
|
+
}, this.limits.totalMs);
|
|
114
|
+
u.total.unref();
|
|
115
|
+
u.busy = false;
|
|
116
|
+
this.touch(u);
|
|
117
|
+
return u;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
await this.dispose(u, error);
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
touch(u) {
|
|
124
|
+
clearTimeout(u.idle);
|
|
125
|
+
u.idle = setTimeout(() => {
|
|
126
|
+
void this.dispose(u, new UploadError("media upload stalled", 408));
|
|
127
|
+
}, this.limits.idleMs);
|
|
128
|
+
u.idle.unref();
|
|
129
|
+
}
|
|
130
|
+
dispose(u, reason = new UploadError("media upload cancelled", 499)) {
|
|
131
|
+
if (u.cleanup) return u.cleanup;
|
|
132
|
+
u.abort.abort(reason);
|
|
133
|
+
clearTimeout(u.idle);
|
|
134
|
+
clearTimeout(u.total);
|
|
135
|
+
u.cleanup = (async () => {
|
|
136
|
+
let failure;
|
|
137
|
+
try {
|
|
138
|
+
await u.file?.close();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
failure = error;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
let current;
|
|
144
|
+
try {
|
|
145
|
+
current = lstatSync(u.temp);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error.code !== "ENOENT") throw error;
|
|
148
|
+
}
|
|
149
|
+
if (current?.isDirectory() && !current.isSymbolicLink() && current.dev === u.staging.dev && current.ino === u.staging.ino)
|
|
150
|
+
await rm(u.temp, { recursive: true, force: true });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
failure ??= error;
|
|
153
|
+
}
|
|
154
|
+
this.active.delete(u.id);
|
|
155
|
+
if (failure) throw failure;
|
|
156
|
+
})();
|
|
157
|
+
void u.cleanup.catch(() => {
|
|
158
|
+
});
|
|
159
|
+
return u.cleanup;
|
|
160
|
+
}
|
|
161
|
+
declared(req, maximum) {
|
|
162
|
+
const length = req.headers["content-length"];
|
|
163
|
+
if (length !== void 0 && (!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length)) || Number(length) > maximum)) {
|
|
164
|
+
throw new UploadError(`media upload exceeds the ${maximum} byte limit`, 413);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async append(u, req, maxPart) {
|
|
168
|
+
this.declared(req, Math.min(maxPart, this.limits.maxBytes - u.bytes));
|
|
169
|
+
let partBytes = 0;
|
|
170
|
+
const signal = u.abort.signal;
|
|
171
|
+
while (true) {
|
|
172
|
+
signal.throwIfAborted();
|
|
173
|
+
if (req.aborted || req.destroyed && !req.complete)
|
|
174
|
+
throw new UploadError("media upload disconnected", 499);
|
|
175
|
+
const bytes = req.read();
|
|
176
|
+
if (bytes === null) {
|
|
177
|
+
if (req.readableEnded) break;
|
|
178
|
+
await new Promise((resolve, reject) => {
|
|
179
|
+
const clean = () => {
|
|
180
|
+
req.off("readable", ready);
|
|
181
|
+
req.off("end", ready);
|
|
182
|
+
req.off("error", fail);
|
|
183
|
+
req.off("aborted", disconnected);
|
|
184
|
+
signal.removeEventListener("abort", aborted);
|
|
185
|
+
};
|
|
186
|
+
const ready = () => {
|
|
187
|
+
clean();
|
|
188
|
+
resolve();
|
|
189
|
+
};
|
|
190
|
+
const fail = (error) => {
|
|
191
|
+
clean();
|
|
192
|
+
reject(error);
|
|
193
|
+
};
|
|
194
|
+
const disconnected = () => fail(new UploadError("media upload disconnected", 499));
|
|
195
|
+
const aborted = () => fail(signal.reason);
|
|
196
|
+
req.once("readable", ready);
|
|
197
|
+
req.once("end", ready);
|
|
198
|
+
req.once("error", fail);
|
|
199
|
+
req.once("aborted", disconnected);
|
|
200
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
201
|
+
if (signal.aborted) aborted();
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
partBytes += bytes.byteLength;
|
|
206
|
+
if (partBytes > maxPart || u.bytes + bytes.byteLength > this.limits.maxBytes)
|
|
207
|
+
throw new UploadError(`media upload exceeds the ${this.limits.maxBytes} byte limit`, 413);
|
|
208
|
+
this.touch(u);
|
|
209
|
+
for (let offset = 0; offset < bytes.byteLength; ) {
|
|
210
|
+
signal.throwIfAborted();
|
|
211
|
+
const { bytesWritten } = await u.file.write(bytes, offset, bytes.byteLength - offset);
|
|
212
|
+
if (!bytesWritten) throw new Error("could not write media upload");
|
|
213
|
+
offset += bytesWritten;
|
|
214
|
+
}
|
|
215
|
+
u.bytes += bytes.byteLength;
|
|
216
|
+
this.touch(u);
|
|
217
|
+
}
|
|
218
|
+
signal.throwIfAborted();
|
|
219
|
+
}
|
|
220
|
+
async publish(u) {
|
|
221
|
+
await u.file.sync();
|
|
222
|
+
u.abort.signal.throwIfAborted();
|
|
223
|
+
const written = await u.file.stat();
|
|
224
|
+
u.abort.signal.throwIfAborted();
|
|
225
|
+
const media = this.checkMedia();
|
|
226
|
+
const directory = lstatSync(media);
|
|
227
|
+
const staging = lstatSync(u.temp);
|
|
228
|
+
const current = lstatSync(join(u.temp, "body"));
|
|
229
|
+
if (directory.dev !== u.directory.dev || directory.ino !== u.directory.ino || staging.isSymbolicLink() || staging.dev !== u.staging.dev || staging.ino !== u.staging.ino || !current.isFile() || current.isSymbolicLink() || current.dev !== written.dev || current.ino !== written.ino || current.size !== u.bytes)
|
|
230
|
+
throw new UploadError("media upload destination changed", 409);
|
|
231
|
+
const ext = extname(u.name);
|
|
232
|
+
const stem = u.name.slice(0, u.name.length - ext.length);
|
|
233
|
+
for (let suffix = 1; suffix <= 1e4; suffix++) {
|
|
234
|
+
const name = suffix === 1 ? u.name : `${stem}-${suffix}${ext}`;
|
|
235
|
+
try {
|
|
236
|
+
linkSync(join(u.temp, "body"), join(media, name));
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const code = error.code;
|
|
239
|
+
if (code === "EEXIST") continue;
|
|
240
|
+
if (!["ENOTSUP", "EOPNOTSUPP", "EPERM", "EXDEV", "ENOSYS"].includes(code ?? ""))
|
|
241
|
+
throw error;
|
|
242
|
+
const reserved = mkdtempSync(join(media, "import-"));
|
|
243
|
+
try {
|
|
244
|
+
renameSync(join(u.temp, "body"), join(reserved, u.name));
|
|
245
|
+
} catch (failure) {
|
|
246
|
+
try {
|
|
247
|
+
rmdirSync(reserved);
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
throw failure;
|
|
251
|
+
}
|
|
252
|
+
return { src: `./media/${reserved.slice(media.length + 1)}/${u.name}`, byteSize: u.bytes };
|
|
253
|
+
}
|
|
254
|
+
return { src: `./media/${name}`, byteSize: u.bytes };
|
|
255
|
+
}
|
|
256
|
+
throw new UploadError("too many media files with this name", 409);
|
|
257
|
+
}
|
|
258
|
+
async handle(req, res, url) {
|
|
259
|
+
if (isPrivateMediaUploadPath(decodeURIComponent(url.pathname))) {
|
|
260
|
+
json(res, 403, { error: "private upload" });
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
if (url.pathname === "/api/media/capabilities" && req.method === "GET") {
|
|
264
|
+
json(res, 200, {
|
|
265
|
+
streaming: true,
|
|
266
|
+
version: 1,
|
|
267
|
+
maxBytes: this.limits.maxBytes,
|
|
268
|
+
partBytes: MEDIA_UPLOAD_PART_BYTES,
|
|
269
|
+
maxConcurrent: this.limits.maxConcurrent
|
|
270
|
+
});
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
const direct = (url.pathname === "/api/media" || url.pathname === "/api/media/stream") && req.method === "POST";
|
|
274
|
+
const begin = url.pathname === "/api/media/uploads" && req.method === "POST";
|
|
275
|
+
const part = /^\/api\/media\/uploads\/([a-f0-9-]{36})$/.exec(url.pathname);
|
|
276
|
+
if (!direct && !begin && !part) return false;
|
|
277
|
+
let u;
|
|
278
|
+
let owns = false;
|
|
279
|
+
const disconnected = () => {
|
|
280
|
+
if (u && !res.writableFinished)
|
|
281
|
+
void this.dispose(u, new UploadError("media upload disconnected", 499));
|
|
282
|
+
};
|
|
283
|
+
res.once("close", disconnected);
|
|
284
|
+
try {
|
|
285
|
+
if (direct || begin) {
|
|
286
|
+
this.declared(req, direct ? this.limits.maxBytes : 0);
|
|
287
|
+
if (begin && req.headers["transfer-encoding"])
|
|
288
|
+
throw new UploadError("upload session creation takes no body", 400);
|
|
289
|
+
u = await this.begin(url.searchParams.get("name") ?? "");
|
|
290
|
+
if (res.destroyed) throw new UploadError("media upload disconnected", 499);
|
|
291
|
+
if (begin) {
|
|
292
|
+
json(res, 201, {
|
|
293
|
+
id: u.id,
|
|
294
|
+
maxBytes: this.limits.maxBytes,
|
|
295
|
+
partBytes: MEDIA_UPLOAD_PART_BYTES
|
|
296
|
+
});
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
u = this.active.get(part[1]);
|
|
301
|
+
if (req.method !== "PUT") {
|
|
302
|
+
this.declared(req, 0);
|
|
303
|
+
if (req.headers["transfer-encoding"])
|
|
304
|
+
throw new UploadError("upload finalization and cancellation take no body", 400);
|
|
305
|
+
}
|
|
306
|
+
if (!u) throw new UploadError("media upload not found or expired", 404);
|
|
307
|
+
if (req.method === "DELETE") {
|
|
308
|
+
await this.dispose(u);
|
|
309
|
+
json(res, 200, { ok: true });
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
if (u.busy) throw new UploadError("media upload already has a request in progress", 409);
|
|
313
|
+
if (req.method !== "PUT" && req.method !== "POST")
|
|
314
|
+
throw new UploadError("unsupported upload method", 405);
|
|
315
|
+
if (req.method === "PUT" && (!/^\d+$/.test(url.searchParams.get("offset") ?? "") || Number(url.searchParams.get("offset")) !== u.bytes))
|
|
316
|
+
throw new UploadError("media upload offset does not match", 409);
|
|
317
|
+
}
|
|
318
|
+
u.busy = true;
|
|
319
|
+
owns = true;
|
|
320
|
+
if (direct || req.method === "PUT")
|
|
321
|
+
await this.append(u, req, direct ? this.limits.maxBytes : MEDIA_UPLOAD_PART_BYTES);
|
|
322
|
+
if (direct || req.method === "POST") {
|
|
323
|
+
const result = await this.publish(u);
|
|
324
|
+
await this.dispose(u);
|
|
325
|
+
json(res, 200, result);
|
|
326
|
+
} else {
|
|
327
|
+
u.busy = false;
|
|
328
|
+
this.touch(u);
|
|
329
|
+
json(res, 200, { offset: u.bytes });
|
|
330
|
+
}
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (u && (owns || direct || begin)) await this.dispose(u, error);
|
|
333
|
+
if (!res.destroyed) {
|
|
334
|
+
if (!req.complete) {
|
|
335
|
+
res.setHeader("connection", "close");
|
|
336
|
+
res.once("finish", () => req.destroy());
|
|
337
|
+
}
|
|
338
|
+
json(res, error instanceof UploadError ? error.status : 500, {
|
|
339
|
+
error: error instanceof Error ? error.message : "media upload failed"
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
} finally {
|
|
343
|
+
res.off("close", disconnected);
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function listMediaFiles(media) {
|
|
349
|
+
let entries;
|
|
350
|
+
try {
|
|
351
|
+
entries = readdirSync(media, { withFileTypes: true });
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (error.code === "ENOENT") return [];
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
const files = [];
|
|
357
|
+
for (const entry of entries) {
|
|
358
|
+
if (entry.isFile() && !entry.name.startsWith("."))
|
|
359
|
+
files.push({
|
|
360
|
+
src: `./media/${entry.name}`,
|
|
361
|
+
byteSize: statSync(join(media, entry.name)).size
|
|
362
|
+
});
|
|
363
|
+
else if (entry.isDirectory() && /^import-[a-zA-Z0-9]{6}$/.test(entry.name)) {
|
|
364
|
+
for (const child of readdirSync(join(media, entry.name), { withFileTypes: true })) {
|
|
365
|
+
if (child.isFile() && !child.name.startsWith("."))
|
|
366
|
+
files.push({
|
|
367
|
+
src: `./media/${entry.name}/${child.name}`,
|
|
368
|
+
byteSize: statSync(join(media, entry.name, child.name)).size
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return files.toSorted((a, b) => a.src.localeCompare(b.src));
|
|
374
|
+
}
|
|
375
|
+
export {
|
|
376
|
+
MEDIA_UPLOAD_PART_BYTES,
|
|
377
|
+
MEDIA_UPLOAD_TEMP_PREFIX,
|
|
378
|
+
MediaUploads,
|
|
379
|
+
isPrivateMediaUploadPath,
|
|
380
|
+
listMediaFiles
|
|
381
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { ffmpeg, requireTool } from "./chunk-EKFC4S4M.mjs";
|
|
7
|
+
const run = promisify(execFile);
|
|
8
|
+
const VIDEO = /\.(mp4|mov|m4v|mkv|webm)$/i;
|
|
9
|
+
const SKIP = /(^|\/)(node_modules|\.git|\.higgsedit-cache|\.higgsedit|renders|dist)(\/|$)/;
|
|
10
|
+
const LONG_SIDE = 1920;
|
|
11
|
+
const DECODABLE = /* @__PURE__ */ new Set(["h264", "hevc", "vp9", "av1"]);
|
|
12
|
+
const needsProxy = (p) => p.width * p.height > LONG_SIDE * 1088 || !DECODABLE.has(p.codec);
|
|
13
|
+
const even = (n) => Math.max(2, Math.round(n / 2) * 2);
|
|
14
|
+
const QUIET_MS = 400;
|
|
15
|
+
const TRACKED_LIMIT = 1024;
|
|
16
|
+
class Proxies {
|
|
17
|
+
constructor(dir, onReady, log = () => {
|
|
18
|
+
}) {
|
|
19
|
+
this.dir = dir;
|
|
20
|
+
this.onReady = onReady;
|
|
21
|
+
this.log = log;
|
|
22
|
+
this.root = join(dir, ".higgsedit-cache", "proxies");
|
|
23
|
+
}
|
|
24
|
+
dir;
|
|
25
|
+
onReady;
|
|
26
|
+
log;
|
|
27
|
+
root;
|
|
28
|
+
// Recent actual preview requests bound both pending work and cached no-proxy decisions.
|
|
29
|
+
candidates = /* @__PURE__ */ new Map();
|
|
30
|
+
active;
|
|
31
|
+
timer;
|
|
32
|
+
abort = new AbortController();
|
|
33
|
+
current;
|
|
34
|
+
stopped = false;
|
|
35
|
+
version(rel) {
|
|
36
|
+
try {
|
|
37
|
+
const st = statSync(join(this.dir, rel));
|
|
38
|
+
if (!st.isFile()) return void 0;
|
|
39
|
+
return { key: createHash("sha1").update(`${rel}\0${st.size}\0${st.mtimeMs}\0${st.ctimeMs}\0${st.ino}`).digest("hex").slice(0, 20), mtime: st.mtimeMs };
|
|
40
|
+
} catch {
|
|
41
|
+
return void 0;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The finished proxy for a project-relative video path, if there is one. */
|
|
45
|
+
lookup(rel) {
|
|
46
|
+
const key = this.version(rel)?.key;
|
|
47
|
+
const file = key && join(this.root, `${key}.mp4`);
|
|
48
|
+
return file && existsSync(file) ? file : void 0;
|
|
49
|
+
}
|
|
50
|
+
/** Only /preview/ demand starts work; watcher events refresh previously requested footage. */
|
|
51
|
+
consider(rel, requested = false) {
|
|
52
|
+
if (this.stopped || !VIDEO.test(rel) || SKIP.test(rel)) return;
|
|
53
|
+
const prior = this.candidates.get(rel);
|
|
54
|
+
if (!requested && !prior) return;
|
|
55
|
+
const version = this.version(rel);
|
|
56
|
+
if (!version) {
|
|
57
|
+
this.candidates.delete(rel);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
const candidate = prior?.key === version.key ? prior : { ...version, readyAt: now + Math.min(QUIET_MS, Math.max(0, QUIET_MS - (now - version.mtime))), done: existsSync(join(this.root, `${version.key}.mp4`)) };
|
|
62
|
+
if (requested && candidate.retryAt !== void 0 && now >= candidate.retryAt) {
|
|
63
|
+
candidate.done = false;
|
|
64
|
+
candidate.retryAt = void 0;
|
|
65
|
+
}
|
|
66
|
+
if (requested) this.candidates.delete(rel);
|
|
67
|
+
this.candidates.set(rel, candidate);
|
|
68
|
+
while (this.candidates.size > TRACKED_LIMIT) {
|
|
69
|
+
for (const path of this.candidates.keys()) {
|
|
70
|
+
if (path !== this.active) {
|
|
71
|
+
this.candidates.delete(path);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.schedule();
|
|
77
|
+
}
|
|
78
|
+
schedule() {
|
|
79
|
+
clearTimeout(this.timer);
|
|
80
|
+
this.timer = void 0;
|
|
81
|
+
if (this.stopped || this.active) return;
|
|
82
|
+
let next = Infinity;
|
|
83
|
+
for (const [rel, candidate] of this.candidates) {
|
|
84
|
+
if (candidate.done) continue;
|
|
85
|
+
if (candidate.readyAt > Date.now()) {
|
|
86
|
+
next = Math.min(next, candidate.readyAt);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const version = this.version(rel);
|
|
90
|
+
if (!version) {
|
|
91
|
+
this.candidates.delete(rel);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (version.key !== candidate.key) {
|
|
95
|
+
Object.assign(candidate, version, { readyAt: Date.now() + QUIET_MS });
|
|
96
|
+
next = Math.min(next, candidate.readyAt);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
this.active = rel;
|
|
100
|
+
void this.make(rel, candidate.key).then(() => {
|
|
101
|
+
if (this.candidates.get(rel)?.key === candidate.key) candidate.done = true;
|
|
102
|
+
}, (error) => {
|
|
103
|
+
if (this.stopped) return;
|
|
104
|
+
candidate.done = true;
|
|
105
|
+
candidate.retryAt = Date.now() + 5e3;
|
|
106
|
+
this.log(`proxy for ${rel} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
107
|
+
}).finally(() => {
|
|
108
|
+
this.active = void 0;
|
|
109
|
+
this.consider(rel);
|
|
110
|
+
this.schedule();
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (Number.isFinite(next)) this.timer = setTimeout(() => this.schedule(), Math.max(0, next - Date.now()));
|
|
115
|
+
}
|
|
116
|
+
async make(rel, key) {
|
|
117
|
+
const file = join(this.dir, rel);
|
|
118
|
+
if (this.lookup(rel)) return;
|
|
119
|
+
const { stdout } = await run(requireTool("ffprobe"), ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,codec_name,time_base", "-of", "json", file], { maxBuffer: 1 << 20, timeout: 3e4, signal: this.abort.signal });
|
|
120
|
+
if (this.stopped || this.version(rel)?.key !== key) return;
|
|
121
|
+
const data = JSON.parse(stdout);
|
|
122
|
+
const stream = data?.streams?.[0];
|
|
123
|
+
if (!stream || typeof stream.width !== "number" || stream.width <= 0 || typeof stream.height !== "number" || stream.height <= 0 || typeof stream.codec_name !== "string") throw new Error("invalid video stream metadata");
|
|
124
|
+
const facts = { width: stream.width, height: stream.height, codec: stream.codec_name };
|
|
125
|
+
if (!needsProxy(facts)) return;
|
|
126
|
+
const timescale = Number(String(stream.time_base).split("/")[1]) || 9e4;
|
|
127
|
+
const scale = LONG_SIDE / Math.max(facts.width, facts.height);
|
|
128
|
+
const [w, h] = scale < 1 ? [even(facts.width * scale), even(facts.height * scale)] : [even(facts.width), even(facts.height)];
|
|
129
|
+
mkdirSync(this.root, { recursive: true });
|
|
130
|
+
const out = join(this.root, `${key}.mp4`);
|
|
131
|
+
const part = join(this.root, `${key}.${randomUUID()}.part.mp4`);
|
|
132
|
+
const t0 = performance.now();
|
|
133
|
+
this.log(`making a preview proxy for ${rel} (${facts.width}\xD7${facts.height} \u2192 ${w}\xD7${h})\u2026`);
|
|
134
|
+
const args = ["-y", "-i", file, "-map", "0:v:0", "-map", "0:a:0?", "-vf", `scale=${w}:${h}`, "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-bf", "0", "-g", "12", "-pix_fmt", "yuv420p"];
|
|
135
|
+
args.push("-fps_mode", "passthrough", "-video_track_timescale", String(timescale), "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", part);
|
|
136
|
+
try {
|
|
137
|
+
await new Promise((resolve, reject) => {
|
|
138
|
+
const p = ffmpeg(args);
|
|
139
|
+
this.current = p;
|
|
140
|
+
let err = "";
|
|
141
|
+
p.stderr.on("data", (d) => {
|
|
142
|
+
err = (err + d.toString()).slice(-16384);
|
|
143
|
+
});
|
|
144
|
+
p.on("error", reject);
|
|
145
|
+
p.on("close", (code) => {
|
|
146
|
+
if (this.current === p) this.current = void 0;
|
|
147
|
+
if (code === 0) resolve();
|
|
148
|
+
else reject(new Error(err.trim() || `ffmpeg exited with ${code}`));
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
if (this.stopped || this.version(rel)?.key !== key) return;
|
|
152
|
+
renameSync(part, out);
|
|
153
|
+
this.log(`proxy for ${rel} ready in ${((performance.now() - t0) / 1e3).toFixed(1)} s`);
|
|
154
|
+
this.onReady(`./${rel}`);
|
|
155
|
+
} finally {
|
|
156
|
+
rmSync(part, { force: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
stop() {
|
|
160
|
+
this.stopped = true;
|
|
161
|
+
clearTimeout(this.timer);
|
|
162
|
+
this.candidates.clear();
|
|
163
|
+
this.abort.abort();
|
|
164
|
+
this.current?.kill("SIGKILL");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export {
|
|
168
|
+
Proxies,
|
|
169
|
+
needsProxy
|
|
170
|
+
};
|
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.4",
|
|
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.4",
|
|
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
|
@@ -6,7 +6,7 @@ Requires Node.js 22.12 or later.
|
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
dir="$HOME/.higgsfield/video-editor-mcp"
|
|
9
|
-
npm install --prefix "$dir" --registry=https://registry.npmjs.org/ --ignore-scripts --no-audit --no-fund @higgsfield/video-editor-mcp@
|
|
9
|
+
npm install --prefix "$dir" --registry=https://registry.npmjs.org/ --ignore-scripts --no-audit --no-fund @higgsfield/video-editor-mcp@latest
|
|
10
10
|
cli="$dir/node_modules/@higgsfield/video-editor-mcp/dist/cli.mjs"
|
|
11
11
|
node "$cli" doctor
|
|
12
12
|
node "$cli" install-plugin
|