@officexapp/vidfarm-devcli 0.21.12 → 0.21.14

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.
@@ -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 the editor's render POST and runs it ON THIS MACHINE via the
14
- // bundled hyperframes CLI (free local render no backend needed; an
15
- // explicit render_target:"cloud" still proxies to the billed renderer),
16
- // 5. serves the same live-reload SSE the full backend does, driven by
17
- // polling the cloud working copy of watched forks, so open editor tabs
18
- // live-morph when `vidfarm publish` / another tab changes the fork, and
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 out free-tier client-only editor (IndexedDB autosave, no cloud
154
- // persist/render). Logged in the cloud account is paid, so drop the gate
155
- // and let persist/chat/render proxy to the cloud host.
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 POST and drives the
160
- // bundled hyperframes CLI — free; with a login the Render button becomes
161
- // the local/cloud picker), and live-reload is on: the shell polls the
162
- // cloud working copy of watched forks and pushes the same `reload` SSE the
163
- // full-backend serve emits, so `vidfarm publish` / other tabs live-morph
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 full-backend serve watches the working composition ON DISK and pushes a
295
- // `reload` SSE so an open editor tab live-morphs when an agent edits the file.
296
- // This shell has no disk copy — the working state lives on the cloud host — so
297
- // an open tab used to go stale the moment `vidfarm publish` (or another tab)
298
- // updated the fork, and users learned to open new tabs. Same SSE contract
299
- // (`/api/v1/dev/events`, event `reload`, data `{forkId}`), different change
300
- // source: while at least one tab is subscribed, poll the cloud working copy of
301
- // every fork a tab has touched and broadcast when its content hash moves.
302
- // Self-saves are suppressed by pre-seeding the hash from proxied PUT bodies —
303
- // the shell's equivalent of the backend's compositionWatch.suppressFork.
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; an explicit hash pre-seeds/suppresses. */
315
- function noteForkSeen(forkId, hash) {
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: hash ?? existing?.hash ?? null, lastSeen: Date.now() });
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
- function broadcastReload(forkId) {
333
- const frame = `event: reload\ndata: ${JSON.stringify({ forkId })}\n\n`;
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
- const res = await fetch(`${host}/api/v1/compositions/${encodeURIComponent(forkId)}/composition.html`, {
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 hash = contentHash(await res.text());
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
- broadcastReload(forkId);
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", reject);
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
- if (pathname === "/api/v1/dev/events" && req.method === "GET") {
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, and pre-seed hashes from saves so a
565
- // tab's own PUT never bounces back as a reload.
566
- const compositionFile = pathname.match(/^\/api\/v1\/compositions\/([^/]+)\/composition\.html\/?$/);
567
- if (compositionFile && (req.method === "PUT" || req.method === "POST")) {
568
- const forkId = decodeURIComponent(compositionFile[1]);
569
- const bodyBuf = await readBody(req);
570
- noteForkSeen(forkId, contentHash(bodyBuf));
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 (compositionFile && req.method === "GET") {
575
- noteForkSeen(decodeURIComponent(compositionFile[1]));
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 — pre-seed the hash so that save doesn't echo back as a reload.
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
- noteForkSeen(renderForkId, contentHash(body.html));
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