@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
|
@@ -34,9 +34,13 @@ import {
|
|
|
34
34
|
import "./chunk-2VKQHJMM.mjs";
|
|
35
35
|
import "./chunk-75WO2NOF.mjs";
|
|
36
36
|
|
|
37
|
+
import { renderCapabilities, validateRenderRequest } from "./export-settings.mjs";
|
|
38
|
+
import { MEDIA_UPLOAD_TEMP_PREFIX, MediaUploads, listMediaFiles } from "./media-upload.mjs";
|
|
39
|
+
|
|
37
40
|
// src/dev.ts
|
|
38
41
|
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync3, statSync as statSync3, watch, writeFileSync as writeFileSync2 } from "node:fs";
|
|
39
42
|
import { extname, join as join4 } from "node:path";
|
|
43
|
+
import { linkSync, lstatSync, mkdtempSync, rmSync as removeExportStage } from "node:fs";
|
|
40
44
|
|
|
41
45
|
// src/editor-build.ts
|
|
42
46
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
@@ -158,132 +162,7 @@ var EditHistory = class {
|
|
|
158
162
|
}
|
|
159
163
|
};
|
|
160
164
|
|
|
161
|
-
|
|
162
|
-
import { execFile } from "node:child_process";
|
|
163
|
-
import { createHash } from "node:crypto";
|
|
164
|
-
import { existsSync as existsSync2, mkdirSync, readdirSync as readdirSync2, renameSync as renameSync2, rmSync, statSync as statSync2 } from "node:fs";
|
|
165
|
-
import { join as join3 } from "node:path";
|
|
166
|
-
import { promisify } from "node:util";
|
|
167
|
-
var run = promisify(execFile);
|
|
168
|
-
var VIDEO = /\.(mp4|mov|m4v|mkv|webm)$/i;
|
|
169
|
-
var SKIP = /(^|\/)(node_modules|\.git|\.higgsedit-cache|renders|dist)(\/|$)/;
|
|
170
|
-
var LONG_SIDE = 1920;
|
|
171
|
-
var DECODABLE = /* @__PURE__ */ new Set(["h264", "hevc", "vp9", "av1"]);
|
|
172
|
-
var needsProxy = (p) => p.width * p.height > LONG_SIDE * 1088 || !DECODABLE.has(p.codec);
|
|
173
|
-
var even = (n) => Math.max(2, Math.round(n / 2) * 2);
|
|
174
|
-
var Proxies = class {
|
|
175
|
-
constructor(dir, onReady, log = () => {
|
|
176
|
-
}) {
|
|
177
|
-
this.dir = dir;
|
|
178
|
-
this.onReady = onReady;
|
|
179
|
-
this.log = log;
|
|
180
|
-
this.root = join3(dir, ".higgsedit-cache", "proxies");
|
|
181
|
-
}
|
|
182
|
-
dir;
|
|
183
|
-
onReady;
|
|
184
|
-
log;
|
|
185
|
-
root;
|
|
186
|
-
queue = [];
|
|
187
|
-
pending = /* @__PURE__ */ new Set();
|
|
188
|
-
current;
|
|
189
|
-
stopped = false;
|
|
190
|
-
key(rel) {
|
|
191
|
-
try {
|
|
192
|
-
const st = statSync2(join3(this.dir, rel));
|
|
193
|
-
return createHash("sha1").update(`${rel}\0${st.size}\0${st.mtimeMs}`).digest("hex").slice(0, 20);
|
|
194
|
-
} catch {
|
|
195
|
-
return void 0;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
/** The finished proxy for a project-relative video path, if there is one. */
|
|
199
|
-
lookup(rel) {
|
|
200
|
-
const key = this.key(rel);
|
|
201
|
-
const file = key && join3(this.root, `${key}.mp4`);
|
|
202
|
-
return file && existsSync2(file) ? file : void 0;
|
|
203
|
-
}
|
|
204
|
-
/** Queues every video in the project. */
|
|
205
|
-
scan(sub = "") {
|
|
206
|
-
let entries;
|
|
207
|
-
try {
|
|
208
|
-
entries = readdirSync2(join3(this.dir, sub), { withFileTypes: true });
|
|
209
|
-
} catch {
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
for (const e of entries) {
|
|
213
|
-
const rel = sub ? `${sub}/${e.name}` : e.name;
|
|
214
|
-
if (e.name.startsWith(".") || SKIP.test(rel)) continue;
|
|
215
|
-
if (e.isDirectory()) this.scan(rel);
|
|
216
|
-
else this.consider(rel);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
/** Queues `rel` if it is a video that needs a proxy and has none yet. */
|
|
220
|
-
consider(rel) {
|
|
221
|
-
if (this.stopped || !VIDEO.test(rel) || SKIP.test(rel) || this.pending.has(rel) || this.lookup(rel)) return;
|
|
222
|
-
this.pending.add(rel);
|
|
223
|
-
this.queue.push(rel);
|
|
224
|
-
if (this.queue.length === 1 && !this.current) void this.drain();
|
|
225
|
-
}
|
|
226
|
-
async drain() {
|
|
227
|
-
while (!this.stopped && this.queue.length) {
|
|
228
|
-
const rel = this.queue[0];
|
|
229
|
-
try {
|
|
230
|
-
await this.make(rel);
|
|
231
|
-
} catch (error) {
|
|
232
|
-
this.log(`proxy for ${rel} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
233
|
-
}
|
|
234
|
-
this.queue.shift();
|
|
235
|
-
this.pending.delete(rel);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
async make(rel) {
|
|
239
|
-
const file = join3(this.dir, rel);
|
|
240
|
-
for (let size = -1; ; ) {
|
|
241
|
-
const now = statSync2(file).size;
|
|
242
|
-
if (now === size) break;
|
|
243
|
-
size = now;
|
|
244
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
245
|
-
}
|
|
246
|
-
if (this.lookup(rel)) return;
|
|
247
|
-
const facts = await probe(file);
|
|
248
|
-
if (!needsProxy(facts)) return;
|
|
249
|
-
const { stdout } = await run(requireTool("ffprobe"), ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=time_base", "-of", "csv=p=0", file]);
|
|
250
|
-
const timescale = Number(stdout.trim().split("/")[1]) || 9e4;
|
|
251
|
-
const scale = LONG_SIDE / Math.max(facts.width, facts.height);
|
|
252
|
-
const [w, h] = scale < 1 ? [even(facts.width * scale), even(facts.height * scale)] : [even(facts.width), even(facts.height)];
|
|
253
|
-
const key = this.key(rel);
|
|
254
|
-
mkdirSync(this.root, { recursive: true });
|
|
255
|
-
const out = join3(this.root, `${key}.mp4`);
|
|
256
|
-
const part = join3(this.root, `${key}.part.mp4`);
|
|
257
|
-
const t0 = performance.now();
|
|
258
|
-
this.log(`making a preview proxy for ${rel} (${facts.width}\xD7${facts.height} \u2192 ${w}\xD7${h})\u2026`);
|
|
259
|
-
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"];
|
|
260
|
-
args.push("-fps_mode", "passthrough", "-video_track_timescale", String(timescale), "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", part);
|
|
261
|
-
await new Promise((resolve, reject) => {
|
|
262
|
-
const p = ffmpeg(args);
|
|
263
|
-
this.current = p;
|
|
264
|
-
let err = "";
|
|
265
|
-
p.stderr.on("data", (d) => err += d.toString());
|
|
266
|
-
p.on("error", reject);
|
|
267
|
-
p.on("close", (code) => {
|
|
268
|
-
this.current = void 0;
|
|
269
|
-
if (code === 0) resolve();
|
|
270
|
-
else reject(new Error(err.trim() || `ffmpeg exited with ${code}`));
|
|
271
|
-
});
|
|
272
|
-
}).catch((error) => {
|
|
273
|
-
rmSync(part, { force: true });
|
|
274
|
-
throw error;
|
|
275
|
-
});
|
|
276
|
-
if (this.stopped) return rmSync(part, { force: true });
|
|
277
|
-
renameSync2(part, out);
|
|
278
|
-
this.log(`proxy for ${rel} ready in ${((performance.now() - t0) / 1e3).toFixed(1)} s`);
|
|
279
|
-
this.onReady(`./${rel}`);
|
|
280
|
-
}
|
|
281
|
-
stop() {
|
|
282
|
-
this.stopped = true;
|
|
283
|
-
this.queue.length = 0;
|
|
284
|
-
this.current?.kill("SIGKILL");
|
|
285
|
-
}
|
|
286
|
-
};
|
|
165
|
+
import { Proxies } from "./proxy.mjs";
|
|
287
166
|
|
|
288
167
|
// src/dev.ts
|
|
289
168
|
var escapeHtml = (s) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
@@ -302,7 +181,8 @@ ${entry.styles.map((s) => `<link rel="stylesheet" href="/__higgsedit/editor/${s}
|
|
|
302
181
|
<script type="module" src="/__higgsedit/editor/${entry.script}"></script>
|
|
303
182
|
</body>
|
|
304
183
|
</html>`;
|
|
305
|
-
async function startDev(dir, port = 0) {
|
|
184
|
+
async function startDev(dir, port = 0, options = {}) {
|
|
185
|
+
const mediaUploads = new MediaUploads(dir, options.mediaUploads);
|
|
306
186
|
const clients = /* @__PURE__ */ new Set();
|
|
307
187
|
const known = /* @__PURE__ */ new Map();
|
|
308
188
|
for (const f of sourceFiles(dir)) known.set(f, readFileSync3(join4(dir, f), "utf8"));
|
|
@@ -347,19 +227,45 @@ async function startDev(dir, port = 0) {
|
|
|
347
227
|
if (changed.length) broadcast({ type: "change", files: changed, origin, detectedAt: firstSeen, mtime, undo: history.state() });
|
|
348
228
|
};
|
|
349
229
|
const proxies = new Proxies(dir, (src) => broadcast({ type: "proxy", src }), (line) => console.log(line));
|
|
350
|
-
proxies.scan();
|
|
351
230
|
const watcher = watch(dir, { recursive: true }, (_event, name) => {
|
|
352
231
|
const file = (name ?? "").split("\\").join("/");
|
|
353
232
|
if (/(^|\/)(node_modules|\.git|\.higgsedit-cache|renders)(\/|$)/.test(file)) return;
|
|
233
|
+
if (file.split("/").some((part) => part.startsWith(MEDIA_UPLOAD_TEMP_PREFIX))) return;
|
|
354
234
|
proxies.consider(file);
|
|
235
|
+
if (file === "media" || file.startsWith("media/")) return;
|
|
355
236
|
if (!timer) firstSeen = Date.now();
|
|
356
237
|
if (!timer) timer = setTimeout(flush, 15);
|
|
357
238
|
});
|
|
358
239
|
const jobs = /* @__PURE__ */ new Map();
|
|
359
240
|
let jobSeq = 0;
|
|
241
|
+
let stopping = false;
|
|
242
|
+
let mutations = 0;
|
|
243
|
+
let stopped;
|
|
244
|
+
const busy = () => mediaUploads.busy || mutations > 0 || [...jobs.values()].some((job) => job.state === "running");
|
|
360
245
|
const server = await startServer(
|
|
361
246
|
dir,
|
|
362
247
|
[
|
|
248
|
+
(req, res, url) => {
|
|
249
|
+
if (stopping) { res.setHeader("retry-after", "1"); json(res, 503, { error: "editor is stopping" }); return true; }
|
|
250
|
+
if (url.pathname === "/api/idle-stop" && req.method === "POST") {
|
|
251
|
+
if (busy()) { json(res, 409, { busy: true }); return true; }
|
|
252
|
+
stopping = true;
|
|
253
|
+
res.once("finish", () => { options.onIdleStop?.(); void stop(); });
|
|
254
|
+
json(res, 200, { stopping: true }); return true;
|
|
255
|
+
}
|
|
256
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
257
|
+
mutations++;
|
|
258
|
+
let released = false;
|
|
259
|
+
const release = () => { if (!released) { released = true; mutations--; } };
|
|
260
|
+
res.once("finish", release); res.once("close", release);
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
},
|
|
264
|
+
(req, res, url) => mediaUploads.handle(req, res, url),
|
|
265
|
+
(req, res, url) => {
|
|
266
|
+
if (url.pathname !== "/api/activity" || req.method !== "GET") return false;
|
|
267
|
+
json(res, 200, { busy: busy() }); return true;
|
|
268
|
+
},
|
|
363
269
|
async (req, res, url) => {
|
|
364
270
|
if (url.pathname !== "/" && url.pathname !== "/index.html") return false;
|
|
365
271
|
const entry = await editorEntry((line) => console.log(line));
|
|
@@ -413,39 +319,93 @@ async function startDev(dir, port = 0) {
|
|
|
413
319
|
},
|
|
414
320
|
async (req, res, url) => {
|
|
415
321
|
if (!url.pathname.startsWith("/api/render")) return false;
|
|
322
|
+
if (url.pathname === "/api/render/capabilities" && req.method === "GET") {
|
|
323
|
+
json(res, 200, await renderCapabilities());
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
416
326
|
if (url.pathname === "/api/render" && req.method === "POST") {
|
|
327
|
+
let body;
|
|
328
|
+
try {
|
|
329
|
+
body = validateRenderRequest(JSON.parse((await readBody(req)).toString() || "{}"), await renderCapabilities());
|
|
330
|
+
} catch (error) {
|
|
331
|
+
json(res, 400, { error: error.message });
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
417
334
|
if ([...jobs.values()].some((j) => j.state === "running")) {
|
|
418
335
|
json(res, 409, { error: "a render is already running" });
|
|
419
336
|
return true;
|
|
420
337
|
}
|
|
421
|
-
const
|
|
422
|
-
const name = (
|
|
423
|
-
const
|
|
424
|
-
mkdirSync2(
|
|
338
|
+
const requested = (body.fileName ?? "render.mp4").replace(/[/\\]/g, "_");
|
|
339
|
+
const name = requested.endsWith(".mp4") ? requested : `${requested}.mp4`;
|
|
340
|
+
const renders = join4(dir, "renders");
|
|
341
|
+
mkdirSync2(renders, { recursive: true });
|
|
342
|
+
const directory = lstatSync(renders);
|
|
343
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) {
|
|
344
|
+
json(res, 400, { error: "renders must be a project directory, not a symbolic link" });
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
const stage = mkdtempSync(join4(renders, ".export-"));
|
|
348
|
+
const stageIdentity = lstatSync(stage);
|
|
349
|
+
const out = join4(stage, "output.mp4");
|
|
425
350
|
const id2 = String(++jobSeq);
|
|
426
|
-
const job2 = { state: "running", done: 0, total: 0,
|
|
351
|
+
const job2 = { state: "running", done: 0, total: 0, abort: new AbortController() };
|
|
427
352
|
jobs.set(id2, job2);
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
353
|
+
void (async () => {
|
|
354
|
+
try {
|
|
355
|
+
const report = await render(dir, {
|
|
356
|
+
out,
|
|
357
|
+
depth: body.depth ?? 8,
|
|
358
|
+
allowFontFallback: body.allowFontFallback === true,
|
|
359
|
+
...body.from !== undefined ? { from: body.from } : {},
|
|
360
|
+
...body.to !== undefined ? { to: body.to } : {},
|
|
361
|
+
...body.spec ? { spec: body.spec } : {},
|
|
362
|
+
...body.sceneId !== undefined ? { sceneId: body.sceneId } : {},
|
|
363
|
+
signal: job2.abort.signal,
|
|
364
|
+
onProgress: (done, total) => {
|
|
365
|
+
job2.done = done;
|
|
366
|
+
job2.total = total;
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
// Cancellation during remux/closing the capture page must not publish a result.
|
|
370
|
+
if (job2.abort.signal.aborted) throw Object.assign(new Error("render cancelled"), { name: "RenderCancelledError" });
|
|
371
|
+
const current = lstatSync(renders);
|
|
372
|
+
if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== directory.dev || current.ino !== directory.ino) throw new Error("renders directory changed during export");
|
|
373
|
+
const output = lstatSync(out);
|
|
374
|
+
if (!output.isFile() || output.isSymbolicLink() || output.size === 0) throw new Error("render did not produce a complete file");
|
|
375
|
+
const occupied = new Set(readdirSync3(renders));
|
|
376
|
+
for (let n = 1; ; n++) {
|
|
377
|
+
const candidate = n === 1 ? name : `${name.slice(0, -4)}-${n}.mp4`;
|
|
378
|
+
if (occupied.has(candidate)) continue;
|
|
379
|
+
try { linkSync(out, join4(renders, candidate)); }
|
|
380
|
+
catch (error) {
|
|
381
|
+
if (error.code === "EEXIST") continue;
|
|
382
|
+
if (!["ENOTSUP", "EOPNOTSUPP", "EPERM", "EXDEV", "ENOSYS"].includes(error.code)) throw error;
|
|
383
|
+
// Some removable/network volumes do not support hard links. A newly reserved
|
|
384
|
+
// directory gives rename an exclusively owned, nonexistent destination there too.
|
|
385
|
+
const reserved = mkdtempSync(join4(renders, `${name.slice(0, -4)}-`));
|
|
386
|
+
try { renameSync(out, join4(reserved, name)); }
|
|
387
|
+
catch (error) { removeExportStage(reserved, { recursive: true, force: true }); throw error; }
|
|
388
|
+
job2.out = `renders/${reserved.slice(renders.length + 1)}/${name}`;
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
job2.out = `renders/${candidate}`;
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
441
394
|
job2.state = "done";
|
|
442
395
|
job2.msPerFrame = report.msPerFrame;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
job2.state = error.name === "RenderCancelledError" ? "cancelled" : "failed";
|
|
396
|
+
} catch (error) {
|
|
397
|
+
job2.state = job2.abort.signal.aborted || error.name === "RenderCancelledError" ? "cancelled" : "failed";
|
|
446
398
|
job2.error = error.message;
|
|
399
|
+
} finally {
|
|
400
|
+
// Never delete a published file, or a directory substituted for this job's staging area.
|
|
401
|
+
try {
|
|
402
|
+
const current = lstatSync(stage);
|
|
403
|
+
if (current.dev === stageIdentity.dev && current.ino === stageIdentity.ino && !current.isSymbolicLink()) removeExportStage(stage, { recursive: true, force: true });
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (error.code !== "ENOENT") console.warn("Could not remove export staging directory:", error.message);
|
|
406
|
+
}
|
|
447
407
|
}
|
|
448
|
-
);
|
|
408
|
+
})();
|
|
449
409
|
json(res, 200, { id: id2 });
|
|
450
410
|
return true;
|
|
451
411
|
}
|
|
@@ -455,7 +415,7 @@ async function startDev(dir, port = 0) {
|
|
|
455
415
|
json(res, 404, { error: `no render ${id}` });
|
|
456
416
|
return true;
|
|
457
417
|
}
|
|
458
|
-
if (url.pathname === "/api/render/cancel") job.abort.abort();
|
|
418
|
+
if (url.pathname === "/api/render/cancel" && job.state === "running") job.abort.abort();
|
|
459
419
|
const { abort: _abort, ...status } = job;
|
|
460
420
|
void _abort;
|
|
461
421
|
json(res, 200, status);
|
|
@@ -470,6 +430,7 @@ async function startDev(dir, port = 0) {
|
|
|
470
430
|
res.end();
|
|
471
431
|
return true;
|
|
472
432
|
}
|
|
433
|
+
proxies.consider(rel, true);
|
|
473
434
|
const proxy = proxies.lookup(rel);
|
|
474
435
|
if (proxy) res.setHeader("x-higgsedit-proxy", "1");
|
|
475
436
|
sendFile(req, res, proxy ?? file);
|
|
@@ -484,23 +445,7 @@ async function startDev(dir, port = 0) {
|
|
|
484
445
|
async (req, res, url) => {
|
|
485
446
|
if (url.pathname !== "/api/media") return false;
|
|
486
447
|
const media = join4(dir, "media");
|
|
487
|
-
|
|
488
|
-
const name = url.searchParams.get("name") ?? "";
|
|
489
|
-
if (!/^[^/\\]+$/.test(name) || name.startsWith(".")) {
|
|
490
|
-
json(res, 400, { error: `bad media name "${name}"` });
|
|
491
|
-
return true;
|
|
492
|
-
}
|
|
493
|
-
mkdirSync2(media, { recursive: true });
|
|
494
|
-
const ext = extname(name);
|
|
495
|
-
const stem = name.slice(0, name.length - ext.length);
|
|
496
|
-
let file = name;
|
|
497
|
-
for (let n = 2; existsSync3(join4(media, file)); n++) file = `${stem}-${n}${ext}`;
|
|
498
|
-
const bytes = await readBody(req);
|
|
499
|
-
writeFileSync2(join4(media, file), bytes);
|
|
500
|
-
json(res, 200, { src: `./media/${file}`, byteSize: bytes.length });
|
|
501
|
-
return true;
|
|
502
|
-
}
|
|
503
|
-
const files = existsSync3(media) ? readdirSync3(media, { withFileTypes: true }).filter((e) => e.isFile() && !e.name.startsWith(".")).map((e) => ({ src: `./media/${e.name}`, byteSize: statSync3(join4(media, e.name)).size })).toSorted((a, b) => a.src.localeCompare(b.src)) : [];
|
|
448
|
+
const files = listMediaFiles(media);
|
|
504
449
|
json(res, 200, { files });
|
|
505
450
|
return true;
|
|
506
451
|
},
|
|
@@ -515,21 +460,30 @@ async function startDev(dir, port = 0) {
|
|
|
515
460
|
],
|
|
516
461
|
port
|
|
517
462
|
);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
async
|
|
463
|
+
function stop() {
|
|
464
|
+
stopping = true;
|
|
465
|
+
return stopped ??= (async () => {
|
|
466
|
+
clearTimeout(timer);
|
|
521
467
|
watcher.close();
|
|
468
|
+
await mediaUploads.close();
|
|
522
469
|
proxies.stop();
|
|
523
470
|
for (const c of clients) c.end();
|
|
524
471
|
await server.close();
|
|
525
|
-
}
|
|
526
|
-
}
|
|
472
|
+
})();
|
|
473
|
+
}
|
|
474
|
+
return { ...server, stop };
|
|
527
475
|
}
|
|
528
476
|
async function dev(dir, port) {
|
|
529
|
-
const server = await startDev(dir, port);
|
|
530
|
-
console.log(`higgsedit dev ${dir}
|
|
531
|
-
|
|
477
|
+
const server = await startDev(dir, port, { onIdleStop: () => quit() });
|
|
478
|
+
console.log(`higgsedit dev ${dir}\n editor ${server.origin}/`);
|
|
479
|
+
const quit = () => {
|
|
480
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
481
|
+
void server.stop().finally(() => process.exit(0));
|
|
482
|
+
};
|
|
483
|
+
process.once("SIGTERM", quit);
|
|
484
|
+
process.once("SIGINT", quit);
|
|
532
485
|
}
|
|
486
|
+
|
|
533
487
|
export {
|
|
534
488
|
dev,
|
|
535
489
|
startDev
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ffmpeg } from "./chunk-EKFC4S4M.mjs";
|
|
2
|
+
import { parseTime } from "./chunk-F7YUKEI5.mjs";
|
|
3
|
+
|
|
4
|
+
// This is the native renderer's contract; the iframe's encoders do not constrain it.
|
|
5
|
+
const sampleRates = [44100, 48000, 96000];
|
|
6
|
+
let capabilities;
|
|
7
|
+
export function renderCapabilities() {
|
|
8
|
+
capabilities ??= new Promise((resolve, reject) => {
|
|
9
|
+
const child = ffmpeg(["-encoders"]);
|
|
10
|
+
let output = "";
|
|
11
|
+
child.stdout.on("data", (chunk) => output += chunk.toString());
|
|
12
|
+
child.on("error", reject);
|
|
13
|
+
child.on("close", (code) => {
|
|
14
|
+
if (code !== 0) return reject(new Error("could not inspect native video encoders"));
|
|
15
|
+
const has = (name) => new RegExp(`^\\s*[A-Z.]{6}\\s+${name}\\s`, "m").test(output);
|
|
16
|
+
const videoCodecs = [...has("libx264") ? ["avc"] : [], ...has("libx265") ? ["hevc"] : []];
|
|
17
|
+
resolve({ containers: videoCodecs.length ? ["mp4"] : [], videoCodecs, audioCodecs: has("aac") ? ["aac"] : [], sampleRates, audioOnly: false });
|
|
18
|
+
});
|
|
19
|
+
}).catch((error) => { capabilities = undefined; throw error; });
|
|
20
|
+
return capabilities;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const object = (value, label) => {
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
25
|
+
return value;
|
|
26
|
+
};
|
|
27
|
+
const number = (value, label, min, max, integer = false) => {
|
|
28
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max || integer && !Number.isInteger(value)) throw new Error(`${label} must be ${integer ? "an integer" : "a number"} between ${min} and ${max}`);
|
|
29
|
+
return value;
|
|
30
|
+
};
|
|
31
|
+
const member = (value, allowed, label) => {
|
|
32
|
+
if (!allowed.includes(value)) throw new Error(`unsupported ${label}: ${String(value)}`);
|
|
33
|
+
return value;
|
|
34
|
+
};
|
|
35
|
+
export function validateRenderRequest(input, supported) {
|
|
36
|
+
const body = object(input, "render request");
|
|
37
|
+
const out = {};
|
|
38
|
+
if (body.fileName !== undefined) {
|
|
39
|
+
if (typeof body.fileName !== "string" || !body.fileName.trim() || body.fileName.length > 180 || /[\x00-\x1f]/.test(body.fileName)) throw new Error("fileName must be a nonempty filename of at most 180 characters");
|
|
40
|
+
out.fileName = body.fileName;
|
|
41
|
+
}
|
|
42
|
+
if (body.sceneId !== undefined) {
|
|
43
|
+
if (typeof body.sceneId !== "string" || !body.sceneId || body.sceneId.length > 1024) throw new Error("sceneId must be a nonempty scene identifier");
|
|
44
|
+
out.sceneId = body.sceneId;
|
|
45
|
+
}
|
|
46
|
+
for (const key of ["from", "to"]) {
|
|
47
|
+
if (body[key] === undefined) continue;
|
|
48
|
+
const time = parseTime(body[key], 30);
|
|
49
|
+
if (time === undefined || !Number.isFinite(time) || time < 0 || typeof body[key] === "string" && body[key].length > 64) throw new Error(`${key} must be a nonnegative time`);
|
|
50
|
+
out[key] = body[key];
|
|
51
|
+
}
|
|
52
|
+
out.depth = body.depth === undefined ? 8 : member(body.depth, [8, 10], "bit depth");
|
|
53
|
+
out.allowFontFallback = body.allowFontFallback === true;
|
|
54
|
+
if (body.spec === undefined) return out;
|
|
55
|
+
const spec = object(body.spec, "spec");
|
|
56
|
+
const video = object(spec.video, "spec.video (audio-only export is unavailable)");
|
|
57
|
+
const width = number(video.width, "video width", 2, 8192, true);
|
|
58
|
+
const height = number(video.height, "video height", 2, 8192, true);
|
|
59
|
+
if (width % 2 || height % 2 || width * height > 35389440) throw new Error("video dimensions must be even and no larger than 8K");
|
|
60
|
+
const normalized = {
|
|
61
|
+
container: member(spec.container, supported.containers, "container"),
|
|
62
|
+
video: { width, height, fps: number(video.fps, "video fps", 1, 120), codec: member(video.codec, supported.videoCodecs, "video codec") },
|
|
63
|
+
audio: null
|
|
64
|
+
};
|
|
65
|
+
if (video.bitrate !== undefined) normalized.video.bitrate = number(video.bitrate, "video bitrate", 1000, 1000000000, true);
|
|
66
|
+
if (spec.audio !== null) {
|
|
67
|
+
const audio = object(spec.audio, "spec.audio");
|
|
68
|
+
normalized.audio = { codec: member(audio.codec, supported.audioCodecs, "audio codec"), sampleRate: member(audio.sampleRate, supported.sampleRates, "audio sample rate"), channels: member(audio.channels, [1, 2], "audio channels") };
|
|
69
|
+
if (audio.bitrate !== undefined) normalized.audio.bitrate = number(audio.bitrate, "audio bitrate", 16000, 1024000, true);
|
|
70
|
+
}
|
|
71
|
+
// The explicit codec selects its native encoder. Legacy --depth still applies without a spec.
|
|
72
|
+
out.depth = 8;
|
|
73
|
+
out.spec = normalized;
|
|
74
|
+
return out;
|
|
75
|
+
}
|