@officexapp/vidfarm-devcli 0.21.12 → 0.21.15
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/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +19 -2
- package/SKILL.director.md +19 -2
- package/demo/dist/app.js +247 -226
- package/dist/src/cli.js +56 -7
- package/dist/src/devcli/composition-edit.js +99 -23
- package/dist/src/devcli/doctor.js +65 -9
- package/dist/src/devcli/local-frontend-server.js +341 -49
- package/dist/src/devcli/port-utils.js +43 -0
- package/dist/src/devcli/process-scan.js +173 -0
- package/dist/src/hyperframes/composition.js +2 -2
- package/package.json +3 -1
- package/public/serve-shells/editor.html +62 -13
- package/public/serve-shells/library-files.html +62 -13
- package/public/serve-shells/library-raws.html +62 -13
- package/public/serve-shells/tools-clipper.html +62 -13
- package/public/serve-shells/tools-image.html +62 -13
- package/public/serve-shells/tools-video.html +62 -13
|
@@ -10,12 +10,16 @@
|
|
|
10
10
|
// 2. serves their prebuilt static bundles (demo/dist editor + public/assets),
|
|
11
11
|
// 3. reverse-proxies their data/API calls (/api, /raws, /clips, /composition,
|
|
12
12
|
// /auto-login, /editor/*/composition) to the logged-in cloud host,
|
|
13
|
-
// 4. intercepts
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
13
|
+
// 4. intercepts BOTH render POSTs — the VF tools panel's
|
|
14
|
+
// /api/v1/compositions/:id/render AND the sealed StudioApp Export
|
|
15
|
+
// button's studio-contract /api/projects/:id/render — and runs them ON
|
|
16
|
+
// THIS MACHINE via the bundled hyperframes CLI (free local render — no
|
|
17
|
+
// backend needed; an explicit render_target:"cloud" still proxies to the
|
|
18
|
+
// billed renderer),
|
|
19
|
+
// 5. serves the studio live-reload SSE (`/api/events`, `file-change`) the
|
|
20
|
+
// sealed editor subscribes to, driven by polling the cloud working copy
|
|
21
|
+
// of watched forks, so open editor tabs live-reload when
|
|
22
|
+
// `vidfarm publish` / another tab changes the fork, and
|
|
19
23
|
// 6. for every OTHER route, shows a small "this page lives in the cloud"
|
|
20
24
|
// interstitial with an open-in-new-tab button (browser page loads), so
|
|
21
25
|
// users aren't silently auto-forwarded off localhost; non-HTML requests
|
|
@@ -26,7 +30,7 @@
|
|
|
26
30
|
// and never drags a backend module into the tarball.
|
|
27
31
|
import { createServer } from "node:http";
|
|
28
32
|
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
|
|
29
|
-
import { mkdir } from "node:fs/promises";
|
|
33
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
30
34
|
import { createHash, randomUUID } from "node:crypto";
|
|
31
35
|
import os from "node:os";
|
|
32
36
|
import path from "node:path";
|
|
@@ -150,19 +154,18 @@ function editorBoot(pathname, query, auth) {
|
|
|
150
154
|
songUrl: null,
|
|
151
155
|
initialThreadId: query.get("thread") || null,
|
|
152
156
|
jobRunsUrl: accountId ? `/job-runs?account=${encodeURIComponent(accountId)}` : "/job-runs",
|
|
153
|
-
// Logged
|
|
154
|
-
//
|
|
155
|
-
//
|
|
157
|
+
// Logged-out /editor never reaches this boot — handleRequest serves the
|
|
158
|
+
// login interstitial instead (the sealed StudioApp reads/saves the fork
|
|
159
|
+
// through the cloud proxy, which 401s without a key). The flag stays for
|
|
160
|
+
// the non-editor shells' shared template.
|
|
156
161
|
freeTier: !loggedIn,
|
|
157
162
|
editorChatApiUrl: "/api/v1/editor-chat",
|
|
158
163
|
vidfarmApiKey: auth?.apiKey ?? null,
|
|
159
|
-
// Renders run locally (the shell intercepts the render
|
|
160
|
-
// bundled hyperframes CLI — free; with a login the Render button
|
|
161
|
-
// the local/cloud picker)
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// into an open editor instead of requiring a new tab.
|
|
165
|
-
liveReload: true,
|
|
164
|
+
// Renders run locally (the shell intercepts the render POSTs and drives
|
|
165
|
+
// the bundled hyperframes CLI — free; with a login the Render button
|
|
166
|
+
// becomes the local/cloud picker). Live-reload needs no boot flag: the
|
|
167
|
+
// sealed editor always subscribes to EventSource("/api/events"), which
|
|
168
|
+
// this shell serves locally (see the live-reload section).
|
|
166
169
|
localRender: true,
|
|
167
170
|
cloudRenderAvailable: loggedIn
|
|
168
171
|
};
|
|
@@ -291,16 +294,21 @@ async function proxyToCloud(req, res, host, pathWithSearch, auth, bodyOverride)
|
|
|
291
294
|
}
|
|
292
295
|
}
|
|
293
296
|
// ── live reload (cloud-poll edition) ────────────────────────────────────────
|
|
294
|
-
// The
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
// an open tab used to go stale the moment `vidfarm publish` (or
|
|
298
|
-
// updated the fork, and users learned to open new tabs.
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
297
|
+
// The sealed 0.7.64 StudioApp always subscribes to EventSource("/api/events")
|
|
298
|
+
// and reloads its preview on `file-change` events (data JSON `{path}` — its
|
|
299
|
+
// composition file is "index.html"). On the cloud host that endpoint is a
|
|
300
|
+
// no-op, so an open tab used to go stale the moment `vidfarm publish` (or
|
|
301
|
+
// another tab) updated the fork, and users learned to open new tabs. This
|
|
302
|
+
// shell serves `/api/events` as a REAL local SSE endpoint: while at least one
|
|
303
|
+
// tab is subscribed, poll the cloud working copy (the same studio
|
|
304
|
+
// `/api/projects/:id/files/index.html` route the editor reads) of every fork
|
|
305
|
+
// a tab has touched and broadcast `file-change` when its version moves.
|
|
306
|
+
// Self-saves are suppressed by RESETTING a fork's baseline whenever a studio
|
|
307
|
+
// file write passes through the proxy — the next poll tick re-seeds the
|
|
308
|
+
// version without broadcasting, so a tab's own save never echoes back (the
|
|
309
|
+
// sealed client additionally debounces file-change within 4s of its own
|
|
310
|
+
// saves). The event carries no fork id, so a change broadcasts to every open
|
|
311
|
+
// tab; each merely refetches its own (unchanged) file — cheap and safe.
|
|
304
312
|
const LIVE_POLL_INTERVAL_MS = 3_000;
|
|
305
313
|
const LIVE_WATCH_TTL_MS = 30 * 60_000;
|
|
306
314
|
const LIVE_WATCH_MAX_FORKS = 8;
|
|
@@ -311,10 +319,10 @@ let livePollInFlight = false;
|
|
|
311
319
|
function contentHash(body) {
|
|
312
320
|
return createHash("sha256").update(body).digest("hex");
|
|
313
321
|
}
|
|
314
|
-
/** Record that a tab uses this fork
|
|
315
|
-
function noteForkSeen(forkId
|
|
322
|
+
/** Record that a tab uses this fork (registers it with the change poller). */
|
|
323
|
+
function noteForkSeen(forkId) {
|
|
316
324
|
const existing = watchedForks.get(forkId);
|
|
317
|
-
watchedForks.set(forkId, { hash:
|
|
325
|
+
watchedForks.set(forkId, { hash: existing?.hash ?? null, lastSeen: Date.now() });
|
|
318
326
|
// Keep the watch list tiny — drop the stalest fork beyond the cap.
|
|
319
327
|
if (watchedForks.size > LIVE_WATCH_MAX_FORKS) {
|
|
320
328
|
let oldest = null;
|
|
@@ -329,8 +337,19 @@ function noteForkSeen(forkId, hash) {
|
|
|
329
337
|
watchedForks.delete(oldest);
|
|
330
338
|
}
|
|
331
339
|
}
|
|
332
|
-
|
|
333
|
-
|
|
340
|
+
/** A tab (or the render mirror) just WROTE this fork's working copy: null the
|
|
341
|
+
* baseline so the next poll re-seeds silently instead of echoing the
|
|
342
|
+
* self-save back as a file-change. */
|
|
343
|
+
function resetForkBaseline(forkId) {
|
|
344
|
+
noteForkSeen(forkId);
|
|
345
|
+
const entry = watchedForks.get(forkId);
|
|
346
|
+
if (entry)
|
|
347
|
+
entry.hash = null;
|
|
348
|
+
}
|
|
349
|
+
function broadcastFileChange() {
|
|
350
|
+
// The studio contract: event `file-change`, data JSON with the changed
|
|
351
|
+
// project-relative path. Every fork's composition file is "index.html".
|
|
352
|
+
const frame = `event: file-change\ndata: ${JSON.stringify({ path: "index.html" })}\n\n`;
|
|
334
353
|
for (const client of sseClients) {
|
|
335
354
|
try {
|
|
336
355
|
client.write(frame);
|
|
@@ -350,15 +369,20 @@ async function pollWatchedForks(host, auth) {
|
|
|
350
369
|
continue;
|
|
351
370
|
}
|
|
352
371
|
try {
|
|
353
|
-
|
|
372
|
+
// Poll the SAME studio files route the sealed editor reads/writes, so
|
|
373
|
+
// the compared bytes are exactly what a reloading tab would fetch.
|
|
374
|
+
const res = await fetch(`${host}/api/projects/${encodeURIComponent(forkId)}/files/index.html`, {
|
|
354
375
|
headers: auth?.apiKey ? { "vidfarm-api-key": auth.apiKey } : {},
|
|
355
376
|
signal: AbortSignal.timeout(10_000)
|
|
356
377
|
});
|
|
357
378
|
if (!res.ok)
|
|
358
379
|
continue;
|
|
359
|
-
const
|
|
380
|
+
const file = await res.json().catch(() => null);
|
|
381
|
+
if (typeof file?.content !== "string")
|
|
382
|
+
continue;
|
|
383
|
+
const hash = file.version || contentHash(file.content);
|
|
360
384
|
if (entry.hash !== null && entry.hash !== hash)
|
|
361
|
-
|
|
385
|
+
broadcastFileChange();
|
|
362
386
|
entry.hash = hash;
|
|
363
387
|
}
|
|
364
388
|
catch { /* transient — retry next tick */ }
|
|
@@ -423,6 +447,9 @@ function localRenderStatusJson(renderId, record) {
|
|
|
423
447
|
cost: "$0.00 (local Vidfarm render)",
|
|
424
448
|
outputUrl: record.status === "SUCCEEDED" ? `/local-renders/${encodeURIComponent(renderId)}.mp4` : undefined,
|
|
425
449
|
error: record.error,
|
|
450
|
+
// The web poller reads the CLOUD status shape `s.errors?.[0]?.error` — emit
|
|
451
|
+
// both so a local failure surfaces its real message, not "Render failed.".
|
|
452
|
+
errors: record.error ? [{ error: record.error }] : undefined,
|
|
426
453
|
fatalErrorEncountered: record.status === "FAILED" ? true : undefined
|
|
427
454
|
};
|
|
428
455
|
}
|
|
@@ -486,6 +513,126 @@ async function startLocalRender(input) {
|
|
|
486
513
|
})();
|
|
487
514
|
return { renderId, record };
|
|
488
515
|
}
|
|
516
|
+
const studioRenders = new Map();
|
|
517
|
+
const STUDIO_RENDER_MIME = {
|
|
518
|
+
".mp4": "video/mp4",
|
|
519
|
+
".webm": "video/webm",
|
|
520
|
+
".mov": "video/quicktime"
|
|
521
|
+
};
|
|
522
|
+
// Same synthetic creep as localRenderStatusJson, on the studio 0-100 scale.
|
|
523
|
+
function studioRenderProgress(job) {
|
|
524
|
+
if (job.status === "complete")
|
|
525
|
+
return 100;
|
|
526
|
+
if (job.status !== "rendering")
|
|
527
|
+
return 0;
|
|
528
|
+
return Math.round(Math.min(93, 4 + ((Date.now() - job.startedAt) / LOCAL_RENDER_ESTIMATE_MS) * 90));
|
|
529
|
+
}
|
|
530
|
+
function studioProgressPayload(job) {
|
|
531
|
+
return { progress: studioRenderProgress(job), status: job.status, stage: job.stage, error: job.error };
|
|
532
|
+
}
|
|
533
|
+
async function startStudioRender(input) {
|
|
534
|
+
// The composition source is the studio files route — exactly what the sealed
|
|
535
|
+
// editor has been editing (the working copy on the cloud host).
|
|
536
|
+
const res = await fetch(`${input.host}/api/projects/${encodeURIComponent(input.projectId)}/files/index.html`, {
|
|
537
|
+
headers: input.auth?.apiKey ? { "vidfarm-api-key": input.auth.apiKey } : {},
|
|
538
|
+
signal: AbortSignal.timeout(FORK_RESOLVE_TIMEOUT_MS)
|
|
539
|
+
});
|
|
540
|
+
if (!res.ok) {
|
|
541
|
+
throw new Error(`Could not load the composition for ${input.projectId} (${res.status}). Run \`vidfarm login\` and reload the editor.`);
|
|
542
|
+
}
|
|
543
|
+
const file = await res.json().catch(() => null);
|
|
544
|
+
const compositionHtml = typeof file?.content === "string" ? file.content : "";
|
|
545
|
+
if (!compositionHtml.includes("data-composition-id=")) {
|
|
546
|
+
throw new Error(`Composition for ${input.projectId} is missing data-composition-id — not renderable.`);
|
|
547
|
+
}
|
|
548
|
+
// Validate the studio request body the way the upstream route does; anything
|
|
549
|
+
// off-contract falls back to its default rather than erroring.
|
|
550
|
+
const format = (["mp4", "webm", "mov"].includes(String(input.body.format)) ? String(input.body.format) : "mp4");
|
|
551
|
+
const quality = (["draft", "standard", "high"].includes(String(input.body.quality)) ? String(input.body.quality) : "standard");
|
|
552
|
+
// Upstream accepts integer fps or an ffmpeg rational string; the local
|
|
553
|
+
// pipeline takes integers only, so rationals fall back to the root data-fps.
|
|
554
|
+
const fps = typeof input.body.fps === "number" && Number.isFinite(input.body.fps) && input.body.fps > 0
|
|
555
|
+
? Math.round(input.body.fps)
|
|
556
|
+
: undefined;
|
|
557
|
+
// Upstream job ids are `${projectId}_${timestamp}` and double as filenames.
|
|
558
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
|
|
559
|
+
const jobId = `${input.projectId}_${stamp}`;
|
|
560
|
+
const filename = `${jobId}.${format}`;
|
|
561
|
+
await mkdir(localRenderDir, { recursive: true });
|
|
562
|
+
const outputPath = path.join(localRenderDir, filename);
|
|
563
|
+
const job = {
|
|
564
|
+
projectId: input.projectId,
|
|
565
|
+
status: "rendering",
|
|
566
|
+
startedAt: Date.now(),
|
|
567
|
+
stage: "preparing",
|
|
568
|
+
outputPath,
|
|
569
|
+
filename
|
|
570
|
+
};
|
|
571
|
+
studioRenders.set(jobId, job);
|
|
572
|
+
void (async () => {
|
|
573
|
+
try {
|
|
574
|
+
const { renderCompositionLocally } = await import("./local-render.js");
|
|
575
|
+
await renderCompositionLocally({
|
|
576
|
+
compositionHtml,
|
|
577
|
+
outputPath,
|
|
578
|
+
fps,
|
|
579
|
+
quality,
|
|
580
|
+
format,
|
|
581
|
+
stdio: "capture",
|
|
582
|
+
log: (event) => {
|
|
583
|
+
if (event === "local_render.start" && job.status === "rendering")
|
|
584
|
+
job.stage = "rendering";
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
// A cancel marks the job terminal for the UI but cannot abort the child
|
|
588
|
+
// process — don't resurrect a cancelled job when it eventually finishes.
|
|
589
|
+
if (job.status === "rendering") {
|
|
590
|
+
job.status = "complete";
|
|
591
|
+
job.stage = undefined;
|
|
592
|
+
}
|
|
593
|
+
job.endedAt = Date.now();
|
|
594
|
+
console.log(`[vidfarm] local render ${jobId} finished (${((job.endedAt - job.startedAt) / 1000).toFixed(1)}s) → ${outputPath}`);
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
if (job.status === "rendering")
|
|
598
|
+
job.status = "failed";
|
|
599
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
600
|
+
job.endedAt = Date.now();
|
|
601
|
+
console.error(`[vidfarm] local render ${jobId} failed: ${job.error}`);
|
|
602
|
+
}
|
|
603
|
+
})();
|
|
604
|
+
return { jobId, job };
|
|
605
|
+
}
|
|
606
|
+
// The studio progress contract: an SSE stream that snapshots the job every
|
|
607
|
+
// 500ms and ends after the first terminal status (mirrors upstream streamSSE).
|
|
608
|
+
function handleStudioRenderProgressStream(req, res, jobId) {
|
|
609
|
+
res.statusCode = 200;
|
|
610
|
+
res.setHeader("content-type", "text/event-stream");
|
|
611
|
+
res.setHeader("cache-control", "no-store");
|
|
612
|
+
res.setHeader("connection", "keep-alive");
|
|
613
|
+
let closed = false;
|
|
614
|
+
req.on("close", () => { closed = true; });
|
|
615
|
+
void (async () => {
|
|
616
|
+
while (!closed) {
|
|
617
|
+
const job = studioRenders.get(jobId);
|
|
618
|
+
if (!job)
|
|
619
|
+
break;
|
|
620
|
+
try {
|
|
621
|
+
res.write(`event: progress\ndata: ${JSON.stringify(studioProgressPayload(job))}\n\n`);
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
if (job.status !== "rendering")
|
|
627
|
+
break;
|
|
628
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
res.end();
|
|
632
|
+
}
|
|
633
|
+
catch { /* already gone */ }
|
|
634
|
+
})();
|
|
635
|
+
}
|
|
489
636
|
// ── cloud-only interstitial ─────────────────────────────────────────────────
|
|
490
637
|
// Shown instead of an auto-forwarding 302 when a browser navigates to a route
|
|
491
638
|
// the local frontend doesn't serve. Keeps the user oriented on localhost and
|
|
@@ -528,6 +675,40 @@ function renderCloudOnlyPage(cloudUrl, pathname) {
|
|
|
528
675
|
</html>
|
|
529
676
|
`;
|
|
530
677
|
}
|
|
678
|
+
// Shown instead of the editor shell when serve has no persisted login. The
|
|
679
|
+
// sealed StudioApp reads and saves the fork through the cloud proxy
|
|
680
|
+
// (GET/PUT /api/projects/:id/files/*), which 401s without a key — the shell
|
|
681
|
+
// would otherwise render a blank, stuck editor. Other routes are unaffected.
|
|
682
|
+
function renderLoginRequiredPage(pathname) {
|
|
683
|
+
const safePath = escapeHtml(pathname);
|
|
684
|
+
return `<!doctype html>
|
|
685
|
+
<html lang="en">
|
|
686
|
+
<head>
|
|
687
|
+
<meta charset="utf-8">
|
|
688
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
689
|
+
<title>Log in to edit | VidFarm</title>
|
|
690
|
+
<style>
|
|
691
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fbfbfb;color:#171717;font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
692
|
+
.card{max-width:460px;margin:24px;padding:36px 32px;background:#fff;border:1px solid #ededed;border-radius:16px;box-shadow:0 4px 24px -8px #00000026,0 1px 2px #0000000d;text-align:center}
|
|
693
|
+
h1{margin:0 0 8px;font-size:19px}
|
|
694
|
+
p{margin:0 0 16px;color:#737373}
|
|
695
|
+
code{font-size:13px;background:#f5f5f5;border-radius:6px;padding:2px 6px}
|
|
696
|
+
pre{margin:0 0 20px;padding:12px 16px;background:#171717;color:#fafafa;border-radius:10px;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;text-align:left;overflow-x:auto}
|
|
697
|
+
.hint{margin-top:16px;font-size:13px;color:#a3a3a3}
|
|
698
|
+
</style>
|
|
699
|
+
</head>
|
|
700
|
+
<body>
|
|
701
|
+
<div class="card">
|
|
702
|
+
<h1>Log in to use the local editor</h1>
|
|
703
|
+
<p>The local editor at <code>${safePath}</code> edits your CLOUD fork — without a login every project read/save fails and the editor loads blank. Run:</p>
|
|
704
|
+
<pre>vidfarm login</pre>
|
|
705
|
+
<p>then restart <code>vidfarm serve</code> and reload this page.</p>
|
|
706
|
+
<div class="hint">Local pages that work without a login: /tools/*, /library, /library/raws</div>
|
|
707
|
+
</div>
|
|
708
|
+
</body>
|
|
709
|
+
</html>
|
|
710
|
+
`;
|
|
711
|
+
}
|
|
531
712
|
export function startLocalFrontendServer(opts) {
|
|
532
713
|
const host = opts.host.replace(/\/+$/, "");
|
|
533
714
|
const server = createServer((req, res) => {
|
|
@@ -540,7 +721,11 @@ export function startLocalFrontendServer(opts) {
|
|
|
540
721
|
});
|
|
541
722
|
});
|
|
542
723
|
return new Promise((resolve, reject) => {
|
|
543
|
-
server.once("error",
|
|
724
|
+
server.once("error", (error) => {
|
|
725
|
+
reject(error.code === "EADDRINUSE"
|
|
726
|
+
? new Error(`Port ${opts.port} is already in use (another local job, or an orphaned server). Pass --port <n>, or run \`vidfarm doctor --kill-orphans\` to reclaim it.`)
|
|
727
|
+
: error);
|
|
728
|
+
});
|
|
544
729
|
server.listen(opts.port, () => resolve({ port: opts.port, host }));
|
|
545
730
|
});
|
|
546
731
|
}
|
|
@@ -557,22 +742,23 @@ async function handleRequest(req, res, host, auth) {
|
|
|
557
742
|
return;
|
|
558
743
|
}
|
|
559
744
|
// 2. Live-reload SSE for open editor tabs (see the live-reload section).
|
|
560
|
-
|
|
745
|
+
// The sealed editor subscribes here; MUST be intercepted before the proxy
|
|
746
|
+
// (/api/events matches isProxyPath and would otherwise forward to the cloud).
|
|
747
|
+
if (pathname === "/api/events" && req.method === "GET") {
|
|
561
748
|
handleDevEventsStream(req, res, host, auth);
|
|
562
749
|
return;
|
|
563
750
|
}
|
|
564
|
-
// Track which forks tabs are using
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
await proxyToCloud(req, res, host, pathname + parsed.search, auth, bodyBuf);
|
|
751
|
+
// Track which forks tabs are using: the sealed editor's project traffic is
|
|
752
|
+
// the studio files route (project id == fork id). Writes reset the fork's
|
|
753
|
+
// poll baseline so a tab's own save never bounces back as a file-change.
|
|
754
|
+
const studioFile = pathname.match(/^\/api\/projects\/([^/]+)\/files\//);
|
|
755
|
+
if (studioFile && (req.method === "PUT" || req.method === "POST" || req.method === "PATCH")) {
|
|
756
|
+
resetForkBaseline(decodeURIComponent(studioFile[1]));
|
|
757
|
+
await proxyToCloud(req, res, host, pathname + parsed.search, auth);
|
|
572
758
|
return;
|
|
573
759
|
}
|
|
574
|
-
if (
|
|
575
|
-
noteForkSeen(decodeURIComponent(
|
|
760
|
+
if (studioFile && req.method === "GET") {
|
|
761
|
+
noteForkSeen(decodeURIComponent(studioFile[1]));
|
|
576
762
|
}
|
|
577
763
|
const editorForkPage = req.method === "GET" ? pathname.match(/^\/editor\/[^/]+\/fork\/([^/]+)\/?$/) : null;
|
|
578
764
|
if (editorForkPage)
|
|
@@ -595,9 +781,10 @@ async function handleRequest(req, res, host, auth) {
|
|
|
595
781
|
try {
|
|
596
782
|
const renderForkId = decodeURIComponent(renderPost[1]);
|
|
597
783
|
// The render intercept PUTs the submitted html back to the cloud working
|
|
598
|
-
// copy —
|
|
784
|
+
// copy — reset the poll baseline so that save doesn't echo back as a
|
|
785
|
+
// file-change to open editor tabs.
|
|
599
786
|
if (typeof body.html === "string" && body.html)
|
|
600
|
-
|
|
787
|
+
resetForkBaseline(renderForkId);
|
|
601
788
|
const { renderId, record } = await startLocalRender({ host, auth, forkId: renderForkId, body });
|
|
602
789
|
sendJson(res, 202, localRenderStatusJson(renderId, record));
|
|
603
790
|
}
|
|
@@ -632,6 +819,100 @@ async function handleRequest(req, res, host, auth) {
|
|
|
632
819
|
createReadStream(record.outputPath).pipe(res);
|
|
633
820
|
return;
|
|
634
821
|
}
|
|
822
|
+
// 3b. Studio-contract render endpoints (the sealed StudioApp's Export
|
|
823
|
+
// button; see the studio-contract render section). Everything here must run
|
|
824
|
+
// BEFORE the proxy — these paths all match isProxyPath, and on the cloud
|
|
825
|
+
// host the render dispatch can never finish (in-process render on Lambda).
|
|
826
|
+
const studioRenderPost = req.method === "POST" ? pathname.match(/^\/api\/projects\/([^/]+)\/render\/?$/) : null;
|
|
827
|
+
if (studioRenderPost) {
|
|
828
|
+
const bodyBuf = await readBody(req);
|
|
829
|
+
let body = {};
|
|
830
|
+
try {
|
|
831
|
+
body = JSON.parse(bodyBuf.toString("utf8") || "{}");
|
|
832
|
+
}
|
|
833
|
+
catch { /* tolerate empty body */ }
|
|
834
|
+
try {
|
|
835
|
+
const { jobId } = await startStudioRender({ host, auth, projectId: decodeURIComponent(studioRenderPost[1]), body });
|
|
836
|
+
sendJson(res, 200, { jobId, status: "rendering" });
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
840
|
+
}
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
// Progress SSE for local studio jobs; unknown job ids proxy on (404 upstream).
|
|
844
|
+
const studioProgress = req.method === "GET" ? pathname.match(/^\/api\/render\/([^/]+)\/progress\/?$/) : null;
|
|
845
|
+
if (studioProgress && studioRenders.has(decodeURIComponent(studioProgress[1]))) {
|
|
846
|
+
handleStudioRenderProgressStream(req, res, decodeURIComponent(studioProgress[1]));
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const studioCancel = req.method === "POST" ? pathname.match(/^\/api\/render\/([^/]+)\/cancel\/?$/) : null;
|
|
850
|
+
if (studioCancel) {
|
|
851
|
+
const job = studioRenders.get(decodeURIComponent(studioCancel[1]));
|
|
852
|
+
if (job) {
|
|
853
|
+
// The hyperframes child can't be aborted from here; mark the job
|
|
854
|
+
// cancelled so the SSE stream terminates — the same fallback the
|
|
855
|
+
// upstream route documents for adapters without a cancel hook.
|
|
856
|
+
if (job.status === "rendering")
|
|
857
|
+
job.status = "cancelled";
|
|
858
|
+
sendJson(res, 200, { status: job.status });
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const studioDelete = req.method === "DELETE" ? pathname.match(/^\/api\/render\/([^/]+)\/?$/) : null;
|
|
863
|
+
if (studioDelete) {
|
|
864
|
+
const jobId = decodeURIComponent(studioDelete[1]);
|
|
865
|
+
const job = studioRenders.get(jobId);
|
|
866
|
+
if (job) {
|
|
867
|
+
await rm(job.outputPath, { force: true }).catch(() => { });
|
|
868
|
+
studioRenders.delete(jobId);
|
|
869
|
+
sendJson(res, 200, { deleted: true });
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
// Render history: local jobs merged with the cloud's list (best-effort).
|
|
874
|
+
const studioRendersList = req.method === "GET" ? pathname.match(/^\/api\/projects\/([^/]+)\/renders\/?$/) : null;
|
|
875
|
+
if (studioRendersList) {
|
|
876
|
+
const projectId = decodeURIComponent(studioRendersList[1]);
|
|
877
|
+
const local = Array.from(studioRenders.entries())
|
|
878
|
+
.filter(([, job]) => job.projectId === projectId && job.status === "complete" && existsSync(job.outputPath))
|
|
879
|
+
.map(([jobId, job]) => ({
|
|
880
|
+
id: jobId,
|
|
881
|
+
filename: job.filename,
|
|
882
|
+
size: statSync(job.outputPath).size,
|
|
883
|
+
createdAt: job.endedAt ?? job.startedAt,
|
|
884
|
+
status: "complete",
|
|
885
|
+
durationMs: job.endedAt ? job.endedAt - job.startedAt : undefined
|
|
886
|
+
}));
|
|
887
|
+
let cloud = [];
|
|
888
|
+
try {
|
|
889
|
+
const upstream = await fetch(new URL(pathname, host), {
|
|
890
|
+
headers: auth?.apiKey ? { "vidfarm-api-key": auth.apiKey } : {},
|
|
891
|
+
signal: AbortSignal.timeout(10_000)
|
|
892
|
+
});
|
|
893
|
+
const json = upstream.ok ? await upstream.json().catch(() => null) : null;
|
|
894
|
+
if (Array.isArray(json?.renders))
|
|
895
|
+
cloud = json.renders;
|
|
896
|
+
}
|
|
897
|
+
catch { /* cloud history is advisory */ }
|
|
898
|
+
sendJson(res, 200, { renders: [...local, ...cloud] });
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
// Finished studio render files (the client links /renders/file/<filename>);
|
|
902
|
+
// filenames we didn't mint proxy on to the cloud's render store.
|
|
903
|
+
const studioRenderFile = req.method === "GET" ? pathname.match(/^\/api\/projects\/[^/]+\/renders\/file\/([^/]+)$/) : null;
|
|
904
|
+
if (studioRenderFile) {
|
|
905
|
+
const filename = decodeURIComponent(studioRenderFile[1]);
|
|
906
|
+
const job = Array.from(studioRenders.values()).find((j) => j.filename === filename);
|
|
907
|
+
if (job && existsSync(job.outputPath)) {
|
|
908
|
+
res.statusCode = 200;
|
|
909
|
+
res.setHeader("content-type", STUDIO_RENDER_MIME[path.extname(job.outputPath)] ?? "video/mp4");
|
|
910
|
+
res.setHeader("content-length", String(statSync(job.outputPath).size));
|
|
911
|
+
res.setHeader("content-disposition", `inline; filename="${filename}"`);
|
|
912
|
+
createReadStream(job.outputPath).pipe(res);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
635
916
|
// 4. Data plane → reverse-proxy to the cloud host.
|
|
636
917
|
if (isProxyPath(pathname)) {
|
|
637
918
|
await proxyToCloud(req, res, host, pathname + parsed.search, auth);
|
|
@@ -652,6 +933,17 @@ async function handleRequest(req, res, host, auth) {
|
|
|
652
933
|
}
|
|
653
934
|
// 6. Locally-served page shells (client-hydrated) → snapshot + boot fill.
|
|
654
935
|
const shellFile = shellFileForPath(pathname);
|
|
936
|
+
// The editor is cloud-backed even on localhost: the sealed StudioApp
|
|
937
|
+
// reads/saves the fork through the proxied studio files routes, which 401
|
|
938
|
+
// without a login and leave a blank stuck editor. Show a clear "log in
|
|
939
|
+
// first" interstitial instead of the broken shell.
|
|
940
|
+
if (shellFile === "editor.html" && !auth?.apiKey && req.method === "GET") {
|
|
941
|
+
res.statusCode = 200;
|
|
942
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
943
|
+
res.setHeader("cache-control", "no-cache");
|
|
944
|
+
res.end(renderLoginRequiredPage(pathname));
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
655
947
|
if (shellFile && req.method === "GET") {
|
|
656
948
|
const html = readRootFile(`public/serve-shells/${shellFile}`);
|
|
657
949
|
if (html) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Backend-free port helpers for the local serve / render loops.
|
|
2
|
+
//
|
|
3
|
+
// Customers routinely run several local video jobs at once (one `vidfarm serve`
|
|
4
|
+
// per fork, plus ad-hoc renders), so a hard-coded port is a foot-gun: the
|
|
5
|
+
// second job would crash with EADDRINUSE. These helpers let `serve` claim the
|
|
6
|
+
// next free port automatically and let `doctor` report contention. Only
|
|
7
|
+
// `node:net` is used, so this stays inside the CLI's backend-free closure.
|
|
8
|
+
import net from "node:net";
|
|
9
|
+
/**
|
|
10
|
+
* True when `port` can be bound on `host` right now. Resolves false on
|
|
11
|
+
* EADDRINUSE (and any other bind error — a port we cannot bind is unusable).
|
|
12
|
+
*/
|
|
13
|
+
export function isPortFree(port, host = "127.0.0.1") {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const tester = net.createServer();
|
|
16
|
+
tester.once("error", () => resolve(false));
|
|
17
|
+
tester.once("listening", () => {
|
|
18
|
+
tester.close(() => resolve(true));
|
|
19
|
+
});
|
|
20
|
+
// Bind on both the loopback (what the browser hits) and, implicitly, the
|
|
21
|
+
// wildcard the runtime listens on — 127.0.0.1 is the strict subset that
|
|
22
|
+
// matters for a same-box collision.
|
|
23
|
+
tester.listen(port, host);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Return the first free port at or above `preferred`. Throws only when an
|
|
28
|
+
* entire window of `maxTries` ports is occupied (effectively never on a dev
|
|
29
|
+
* box). Sequential probing keeps the chosen port predictable — the Nth
|
|
30
|
+
* concurrent serve tends to land on preferred+N-1.
|
|
31
|
+
*/
|
|
32
|
+
export async function findFreePort(preferred, opts = {}) {
|
|
33
|
+
const host = opts.host ?? "127.0.0.1";
|
|
34
|
+
const maxTries = Math.max(1, opts.maxTries ?? 64);
|
|
35
|
+
for (let candidate = preferred; candidate < preferred + maxTries; candidate += 1) {
|
|
36
|
+
if (candidate > 65535)
|
|
37
|
+
break;
|
|
38
|
+
if (await isPortFree(candidate, host))
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`No free TCP port found in ${preferred}..${Math.min(preferred + maxTries - 1, 65535)} — close some local jobs (see \`vidfarm doctor\`) or pass an explicit --port.`);
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=port-utils.js.map
|