@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.
@@ -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.2",
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.2",
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."
@@ -37,5 +37,5 @@
37
37
  }
38
38
  }
39
39
  },
40
- "version": "1.0.2"
40
+ "version": "1.0.4"
41
41
  }
@@ -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@next
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