@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/dist/src/cli.js CHANGED
@@ -20,6 +20,8 @@ import { resolveFfmpeg } from "./services/clip-curation/ffmpeg.js";
20
20
  import { parseHyperframesJson, runHyperframesCommand } from "./devcli/hyperframes-cli.js";
21
21
  import { renderCompositionStills } from "./devcli/stills.js";
22
22
  import { runDoctorCommand } from "./devcli/doctor.js";
23
+ import { findFreePort } from "./devcli/port-utils.js";
24
+ import { scanLocalServers } from "./devcli/process-scan.js";
23
25
  import { runSkillsCommand } from "./devcli/skills.js";
24
26
  import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
25
27
  import { resolveLocalDataDir, localBackendAvailable, LocalModeUnavailableError, localApiRequest } from "./devcli/local-backend.js";
@@ -91,7 +93,8 @@ Local editor loop:
91
93
  With a template_id or --fork, pulls that composition
92
94
  (or the template's default/shared decomposition) from
93
95
  the cloud host onto local disk to edit offline.
94
- --port <n> Server port (default: 3000)
96
+ --port <n> Server port (default: 3000; auto-advances to the next
97
+ free port so concurrent local jobs never collide)
95
98
  --dir <path> Local data dir (default: ~/.vidfarm/data or VIDFARM_HOME/data)
96
99
  --key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
97
100
  --fork <id> Pull + open a specific cloud fork
@@ -489,7 +492,11 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
489
492
  doctor Health-check the local toolchain: node, (local)
490
493
  ffmpeg, hyperframes engines, Chrome, API key
491
494
  (whoami), provider keys, agent CLI, poisoned
492
- env, port 3000, installed skills [--json]
495
+ env, local serve/preview processes, installed
496
+ skills [--json] [--kill-orphans]
497
+ --kill-orphans Reap orphaned local serve/preview servers
498
+ (running from a deleted package path) so their
499
+ squatted ports are reclaimed
493
500
  skills <list|add <names…>|update> Install/refresh agent skills into → GET /skill-pack/* (or the
494
501
  <root>/.agents/skills/<name> + a relative copies bundled in this repo)
495
502
  .claude/skills symlink; skills-lock.json is
@@ -649,7 +656,7 @@ Common options (any command):
649
656
  --json Print only the raw JSON response (agent-friendly)
650
657
 
651
658
  Serve-mode options:
652
- --port <n> Local server port (default: 3000)
659
+ --port <n> Local server port (default: 3000; auto-advances to the next free port)
653
660
  --dir <path> Local data dir for disk records + storage (default: ./.vidfarm-local)
654
661
  --key <api-key> Bootstrap/browser key (default: VIDFARM_API_KEY or a dev key)
655
662
  --fork <id> Open a specific fork (multiple forks editable at once)
@@ -1330,10 +1337,29 @@ async function runFrontendServeCommand(argv) {
1330
1337
  "no-open": { type: "boolean", default: false }
1331
1338
  }
1332
1339
  });
1333
- const port = Number(parsed.values.port);
1334
- if (!Number.isFinite(port) || port <= 0) {
1340
+ const requestedPort = Number(parsed.values.port);
1341
+ if (!Number.isFinite(requestedPort) || requestedPort <= 0) {
1335
1342
  throw new Error(`Invalid --port: ${parsed.values.port}`);
1336
1343
  }
1344
+ // Multiple concurrent local serves must coexist — auto-advance to the next
1345
+ // free port instead of crashing the 2nd job with EADDRINUSE.
1346
+ const port = await findFreePort(requestedPort);
1347
+ if (port !== requestedPort) {
1348
+ let squatterNote = "";
1349
+ try {
1350
+ const holder = scanLocalServers().servers.find((s) => s.port === requestedPort && !s.isSelf);
1351
+ if (holder?.orphaned) {
1352
+ squatterNote = ` (orphaned ${holder.kind} pid ${holder.pid} squatting it — \`vidfarm doctor --kill-orphans\` reclaims it)`;
1353
+ }
1354
+ else if (holder) {
1355
+ squatterNote = ` (held by a running ${holder.kind}, pid ${holder.pid})`;
1356
+ }
1357
+ }
1358
+ catch {
1359
+ // advisory only
1360
+ }
1361
+ console.warn(`[vidfarm] serve: port ${requestedPort} busy${squatterNote}; using ${port} instead.`);
1362
+ }
1337
1363
  const stored = readStoredAuth();
1338
1364
  const host = trimTrailingSlash(String(parsed.values.host ?? stored?.host ?? DEFAULT_HOST));
1339
1365
  // Only reuse the persisted key when it was minted for this host.
@@ -1405,10 +1431,33 @@ async function runServeCommand(argv) {
1405
1431
  "no-cloud": { type: "boolean", default: false }
1406
1432
  }
1407
1433
  });
1408
- const port = Number(parsed.values.port);
1409
- if (!Number.isFinite(port) || port <= 0) {
1434
+ const requestedPort = Number(parsed.values.port);
1435
+ if (!Number.isFinite(requestedPort) || requestedPort <= 0) {
1410
1436
  throw new Error(`Invalid --port: ${parsed.values.port}`);
1411
1437
  }
1438
+ // Customers run several local video jobs at once, so never hard-fail on a
1439
+ // busy port: claim the next free one upward from the request. This makes the
1440
+ // 2nd/3rd `vidfarm serve` "just work" instead of crashing with EADDRINUSE.
1441
+ const port = await findFreePort(requestedPort);
1442
+ if (port !== requestedPort) {
1443
+ // If an ORPHANED serve/preview (running from a deleted package path) is
1444
+ // squatting the requested port, say so — that dead process will never free
1445
+ // it on its own, and `vidfarm doctor --kill-orphans` reclaims it.
1446
+ let squatterNote = "";
1447
+ try {
1448
+ const holder = scanLocalServers().servers.find((s) => s.port === requestedPort && !s.isSelf);
1449
+ if (holder?.orphaned) {
1450
+ squatterNote = ` (an orphaned ${holder.kind} pid ${holder.pid} is squatting it — reclaim with \`vidfarm doctor --kill-orphans\`)`;
1451
+ }
1452
+ else if (holder) {
1453
+ squatterNote = ` (held by a running ${holder.kind}, pid ${holder.pid})`;
1454
+ }
1455
+ }
1456
+ catch {
1457
+ // advisory only
1458
+ }
1459
+ console.warn(`[vidfarm] serve: port ${requestedPort} busy${squatterNote}; using ${port} instead.`);
1460
+ }
1412
1461
  const dataDir = parsed.values.dir
1413
1462
  ? path.resolve(process.cwd(), parsed.values.dir)
1414
1463
  : resolveLocalDataDir();
@@ -16,8 +16,35 @@ const TRANSITION_PRESET_SET = new Set(TRANSITION_PRESETS);
16
16
  const TRANSITION_OUT_PRESET_SET = new Set(TRANSITION_OUT_PRESETS);
17
17
  const round3 = (v) => Number(v.toFixed(3));
18
18
  const clampPercent = (v) => Math.min(100, Math.max(0, v));
19
+ // Matches an open tag and captures its attribute blob / self-close slash.
20
+ // KEEP IN SYNC with OPEN_TAG_RE in src/services/composition-sanitize.ts.
21
+ const OPEN_TAG_RE = /<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'`=<>]+))?)*)\s*(\/?)>/g;
22
+ // Quoted attribute values only — the capture keeps the quote character.
23
+ // KEEP IN SYNC with QUOTED_ATTR_RE in src/services/composition-sanitize.ts.
24
+ const QUOTED_ATTR_RE = /(\s+[^\s"'>/=]+\s*=\s*)("[^"]*"|'[^']*')/g;
25
+ // Re-escape raw ">" inside quoted attribute values as "&gt;". Legal HTML, but
26
+ // DOM serializers (linkedom here, browser outerHTML in the web editor) emit
27
+ // the raw form, and the sealed StudioApp's string-offset source editor treats
28
+ // the FIRST ">" as the end of an open tag — which once spliced an imported
29
+ // layer into the middle of a root's data-viral-dna JSON. Idempotent.
30
+ // KEEP IN SYNC with escapeGtInAttributeValues in
31
+ // src/services/composition-sanitize.ts (backend) and
32
+ // demo/src/vidfarm-composition-edit.ts (client twin).
33
+ function escapeGtInAttributeValues(html) {
34
+ if (!html || !html.includes(">"))
35
+ return html;
36
+ return html.replace(OPEN_TAG_RE, (match, tag, rawAttrs, selfClose) => {
37
+ if (!rawAttrs || rawAttrs.indexOf(">") < 0)
38
+ return match;
39
+ const fixed = rawAttrs.replace(QUOTED_ATTR_RE, (_m, lead, quoted) => {
40
+ const q = quoted[0];
41
+ return lead + q + quoted.slice(1, -1).replace(/>/g, "&gt;") + q;
42
+ });
43
+ return `<${tag}${fixed}${selfClose ? "/" : ""}>`;
44
+ });
45
+ }
19
46
  function serialize(document) {
20
- return `<!doctype html>\n${document.documentElement.outerHTML}`;
47
+ return escapeGtInAttributeValues(`<!doctype html>\n${document.documentElement.outerHTML}`);
21
48
  }
22
49
  function reduceAspectRatio(width, height) {
23
50
  if (!width || !height || width <= 0 || height <= 0)
@@ -148,10 +175,45 @@ function resolveLayerNode(root, key) {
148
175
  if (!trimmed)
149
176
  return null;
150
177
  const escaped = trimmed.replace(/["\\]/g, "\\$&");
151
- return root.querySelector(`[data-hf-id="${escaped}"]`)
152
- ?? root.querySelector(`[data-hf-slug="${escaped}"]`)
153
- ?? root.querySelector(`#${escaped.replace(/[^\w-]/g, "")}`)
154
- ?? null;
178
+ const byAttr = root.querySelector(`[data-hf-id="${escaped}"]`)
179
+ ?? root.querySelector(`[data-hf-slug="${escaped}"]`);
180
+ if (byAttr)
181
+ return byAttr;
182
+ // id fallback via an attribute selector — a `#…` selector would throw a
183
+ // querySelector SyntaxError when the sanitized key ends up empty/invalid
184
+ // (e.g. an all-symbol key producing bare "#").
185
+ const idSafe = trimmed.replace(/[^\w-]/g, "");
186
+ return idSafe ? (root.querySelector(`[id="${idSafe}"]`) ?? null) : null;
187
+ }
188
+ // A miss should teach, not stonewall: name the layer keys that DO exist so an
189
+ // agent can self-correct in one round trip (mirrors the web editLayerByKey).
190
+ function layerNotFoundError(root, key) {
191
+ const available = collectClips(root).map((node) => {
192
+ const id = node.getAttribute?.("data-hf-id") || node.id || "";
193
+ const slug = node.getAttribute?.("data-hf-slug");
194
+ return slug && slug !== id ? `${id || slug} (slug: ${slug})` : id;
195
+ }).filter(Boolean);
196
+ const hint = available.length
197
+ ? ` Available layer keys: ${available.slice(0, 24).join(", ")}${available.length > 24 ? ", …" : ""}.`
198
+ : " The composition has no [data-start] layers.";
199
+ return new Error(`Layer not found: ${key}.${hint}`);
200
+ }
201
+ // The web editor's appendLayerHtml grows the root's data-duration whenever a
202
+ // clip's end runs past it — mirror that after any op that can push a clip out
203
+ // (insert/duplicate/nudge/ripple/retime/trim/set-media), so devcli edits never
204
+ // leave clips dangling past the render window. Never shrinks.
205
+ function extendRootDurationToCover(root) {
206
+ let furthest = 0;
207
+ for (const node of collectClips(root)) {
208
+ const s = numAttr(node, "data-start");
209
+ const d = numAttr(node, "data-duration");
210
+ if (Number.isFinite(s) && Number.isFinite(d))
211
+ furthest = Math.max(furthest, s + d);
212
+ }
213
+ const total = numAttr(root, "data-duration");
214
+ if (furthest > (Number.isFinite(total) ? total : 0) + 0.001) {
215
+ root.setAttribute("data-duration", String(round3(furthest)));
216
+ }
155
217
  }
156
218
  function nextAutoTrack(clips) {
157
219
  return clips.reduce((max, node) => {
@@ -283,12 +345,18 @@ export function insertMediaLayer(html, opts) {
283
345
  const clips = collectClips(root);
284
346
  const totalDuration = numAttr(root, "data-duration");
285
347
  const dur = Number.isFinite(totalDuration) ? totalDuration : 0;
286
- const start = Math.max(0, dur > 0 ? Math.min(dur - 0.1, opts.start ?? 0) : (opts.start ?? 0));
287
- const remaining = dur > 0 ? Math.max(0.1, dur - start) : 4;
348
+ // An EXPLICIT start/duration may run past the current timeline end the
349
+ // root's data-duration is extended below (extendRootDurationToCover), the
350
+ // same way the web editor's appendLayerHtml grows the composition. Only the
351
+ // DEFAULTS are fitted to the existing timeline.
352
+ const start = Math.max(0, opts.start ?? 0);
353
+ const remaining = dur > start ? dur - start : 0;
288
354
  // Audio beds (narration/music/SFX) default to spanning the rest of the
289
355
  // timeline from their start; visual clips default to a 4s slot.
290
- const defaultDuration = opts.kind === "audio" ? remaining : Math.min(4, remaining);
291
- const duration = Math.max(0.1, dur > 0 ? Math.min(opts.duration ?? defaultDuration, remaining) : (opts.duration ?? 4));
356
+ const defaultDuration = opts.kind === "audio"
357
+ ? (remaining > 0.1 ? remaining : 4)
358
+ : Math.min(4, remaining > 0.1 ? remaining : 4);
359
+ const duration = Math.max(0.1, opts.duration ?? defaultDuration);
292
360
  const providedKey = opts.layerKey?.trim();
293
361
  const genKey = providedKey && /^[A-Za-z][\w-]{0,63}$/.test(providedKey)
294
362
  ? providedKey
@@ -305,6 +373,7 @@ export function insertMediaLayer(html, opts) {
305
373
  height: opts.height ?? 100
306
374
  });
307
375
  root.append(node);
376
+ extendRootDurationToCover(root);
308
377
  return { html: serialize(document), layerKey: id };
309
378
  }
310
379
  const CAPTION_DEFAULT_FRAME = { x: 10, y: 70, width: 80, height: 14 };
@@ -445,7 +514,7 @@ export function replaceLayerWithMedia(html, targetKey, opts) {
445
514
  const { document, root } = readCompositionDoc(html);
446
515
  const target = resolveLayerNode(root, targetKey);
447
516
  if (!target)
448
- throw new Error(`Layer to replace not found: ${targetKey}`);
517
+ throw layerNotFoundError(root, targetKey);
449
518
  const start = opts.start ?? numAttr(target, "data-start");
450
519
  const durationRaw = opts.duration ?? numAttr(target, "data-duration");
451
520
  const trackRaw = opts.track ?? numAttr(target, "data-track-index");
@@ -476,6 +545,7 @@ export function replaceLayerWithMedia(html, targetKey, opts) {
476
545
  ...geom
477
546
  });
478
547
  root.append(node);
548
+ extendRootDurationToCover(root);
479
549
  void targetId;
480
550
  return { html: serialize(document), layerKey: id };
481
551
  }
@@ -621,7 +691,7 @@ export function setLayerTransitions(html, layerKey, opts) {
621
691
  const { document, root } = readCompositionDoc(html);
622
692
  const node = resolveLayerNode(root, layerKey);
623
693
  if (!node)
624
- throw new Error(`Layer not found: ${layerKey}`);
694
+ throw layerNotFoundError(root, layerKey);
625
695
  if (!isSceneClipNode(node) && String(node.tagName || "").toLowerCase() === "audio") {
626
696
  throw new Error("Transitions cannot be applied to audio layers.");
627
697
  }
@@ -729,7 +799,7 @@ export function setLayerKeyframes(html, layerKey, opts) {
729
799
  const { document, root } = readCompositionDoc(html);
730
800
  const node = resolveLayerNode(root, layerKey);
731
801
  if (!node)
732
- throw new Error(`Layer not found: ${layerKey}`);
802
+ throw layerNotFoundError(root, layerKey);
733
803
  const baseId = nodeTimelineId(node) || "layer";
734
804
  const animName = `hfk-${baseId.replace(/[^A-Za-z0-9_-]/g, "-")}`;
735
805
  const clipDuration = numAttr(node, "data-duration");
@@ -788,7 +858,7 @@ export function nudgeLayers(html, keys, opts) {
788
858
  for (const key of cleanKeys) {
789
859
  const seed = resolveLayerNode(root, key);
790
860
  if (!seed)
791
- throw new Error(`Layer not found: ${key}`);
861
+ throw layerNotFoundError(root, key);
792
862
  const groupId = seed.getAttribute("data-vf-group");
793
863
  if (groupId) {
794
864
  const escaped = groupId.replace(/["\\]/g, "\\$&");
@@ -808,6 +878,7 @@ export function nudgeLayers(html, keys, opts) {
808
878
  track: Math.max(0, (Number.isFinite(track) ? track : 0) + Math.trunc(deltaTrack))
809
879
  });
810
880
  }
881
+ extendRootDurationToCover(root);
811
882
  return { html: serialize(document), moved: Array.from(moveSet.values()).map((n) => nodeTimelineId(n)) };
812
883
  }
813
884
  // ripple_edit twin: insert (delta > 0) or close (delta < 0) time at `at`,
@@ -830,6 +901,7 @@ export function rippleEdit(html, opts) {
830
901
  const s = numAttr(node, "data-start");
831
902
  applyTimingToNode(node, { start: Math.max(0, s + opts.delta) });
832
903
  }
904
+ extendRootDurationToCover(root);
833
905
  return { html: serialize(document), affected: affected.length };
834
906
  }
835
907
  // trim_layer twin: move ONE edge of a clip to a composition time. edge='start'
@@ -844,7 +916,7 @@ export function trimLayer(html, layerKey, opts) {
844
916
  const { document, root } = readCompositionDoc(html);
845
917
  const node = resolveLayerNode(root, layerKey);
846
918
  if (!node)
847
- throw new Error(`Layer not found: ${layerKey}`);
919
+ throw layerNotFoundError(root, layerKey);
848
920
  const start = numAttr(node, "data-start");
849
921
  const duration = numAttr(node, "data-duration");
850
922
  const s = Number.isFinite(start) ? start : 0;
@@ -882,6 +954,7 @@ export function trimLayer(html, layerKey, opts) {
882
954
  report.duration = round3(updates.duration);
883
955
  if (updates.playbackStart !== undefined)
884
956
  report.playback_start = round3(updates.playbackStart);
957
+ extendRootDurationToCover(root);
885
958
  return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, updates: report };
886
959
  }
887
960
  // set_layer_zindex twin: restack a layer. Stacking == data-track-index (higher
@@ -896,7 +969,7 @@ export function restackLayer(html, layerKey, opts) {
896
969
  const { document, root } = readCompositionDoc(html);
897
970
  const node = resolveLayerNode(root, layerKey);
898
971
  if (!node)
899
- throw new Error(`Layer not found: ${layerKey}`);
972
+ throw layerNotFoundError(root, layerKey);
900
973
  const clips = collectClips(root);
901
974
  const tracks = clips.map((n) => numAttr(n, "data-track-index")).filter((t) => Number.isFinite(t));
902
975
  const currentTrack = numAttr(node, "data-track-index");
@@ -938,7 +1011,7 @@ export function setLayerText(html, layerKey, text) {
938
1011
  const { document, root } = readCompositionDoc(html);
939
1012
  const node = resolveLayerNode(root, layerKey);
940
1013
  if (!node)
941
- throw new Error(`Layer not found: ${layerKey}`);
1014
+ throw layerNotFoundError(root, layerKey);
942
1015
  const inline = node.querySelector?.("[data-vf-text-inline]");
943
1016
  if (inline)
944
1017
  inline.textContent = text;
@@ -955,7 +1028,7 @@ export function setLayerStyle(html, layerKey, opts) {
955
1028
  const { document, root } = readCompositionDoc(html);
956
1029
  const node = resolveLayerNode(root, layerKey);
957
1030
  if (!node)
958
- throw new Error(`Layer not found: ${layerKey}`);
1031
+ throw layerNotFoundError(root, layerKey);
959
1032
  const changed = [];
960
1033
  if (opts.color !== undefined) {
961
1034
  upsertStyleDecl(node, "color", opts.color);
@@ -1021,7 +1094,7 @@ export function setLayerVisual(html, layerKey, opts) {
1021
1094
  const { document, root } = readCompositionDoc(html);
1022
1095
  const node = resolveLayerNode(root, layerKey);
1023
1096
  if (!node)
1024
- throw new Error(`Layer not found: ${layerKey}`);
1097
+ throw layerNotFoundError(root, layerKey);
1025
1098
  const changed = [];
1026
1099
  if (opts.x !== undefined) {
1027
1100
  upsertStyleDecl(node, "left", `${clampPercent(opts.x)}%`);
@@ -1067,7 +1140,7 @@ export function setLayerMedia(html, layerKey, opts) {
1067
1140
  const { document, root } = readCompositionDoc(html);
1068
1141
  const node = resolveLayerNode(root, layerKey);
1069
1142
  if (!node)
1070
- throw new Error(`Layer not found: ${layerKey}`);
1143
+ throw layerNotFoundError(root, layerKey);
1071
1144
  const tag = String(node.tagName || "").toLowerCase();
1072
1145
  const kind = node.getAttribute("data-layer-kind") || (tag === "img" ? "image" : tag);
1073
1146
  if (tag !== "video" && tag !== "audio" && tag !== "img") {
@@ -1158,6 +1231,7 @@ export function setLayerMedia(html, layerKey, opts) {
1158
1231
  if (changed.length === 0) {
1159
1232
  throw new Error("set-media needs at least one of --src / --volume / --muted / --loop / --playback-start / --source-out / --duration / --object-fit / --object-position / --ken-burns / --transition / --transition-out.");
1160
1233
  }
1234
+ extendRootDurationToCover(root);
1161
1235
  return { html: serialize(document), layerKey: nodeTimelineId(node) || layerKey, changed, kind: kind || tag };
1162
1236
  }
1163
1237
  /** Set a layer's stable slug + human note (the AI/viral-DNA handle). Pass an
@@ -1168,7 +1242,7 @@ export function setLayerIdentity(html, layerKey, opts) {
1168
1242
  const { document, root } = readCompositionDoc(html);
1169
1243
  const node = resolveLayerNode(root, layerKey);
1170
1244
  if (!node)
1171
- throw new Error(`Layer not found: ${layerKey}`);
1245
+ throw layerNotFoundError(root, layerKey);
1172
1246
  if (opts.slug !== undefined) {
1173
1247
  const slug = opts.slug.trim();
1174
1248
  if (slug)
@@ -1190,7 +1264,7 @@ export function duplicateLayer(html, layerKey, opts = {}) {
1190
1264
  const { document, root } = readCompositionDoc(html);
1191
1265
  const node = resolveLayerNode(root, layerKey);
1192
1266
  if (!node)
1193
- throw new Error(`Layer not found: ${layerKey}`);
1267
+ throw layerNotFoundError(root, layerKey);
1194
1268
  const clips = collectClips(root);
1195
1269
  const start = numAttr(node, "data-start");
1196
1270
  const duration = numAttr(node, "data-duration");
@@ -1208,6 +1282,7 @@ export function duplicateLayer(html, layerKey, opts = {}) {
1208
1282
  const resolvedTrack = findNonCollidingTrack(clips, { start: newStart, duration: d, track: initialTrack }, newId);
1209
1283
  applyTimingToNode(clone, { start: newStart, duration: d, track: resolvedTrack });
1210
1284
  root.append(clone);
1285
+ extendRootDurationToCover(root);
1211
1286
  return { html: serialize(document), layerKey: newId };
1212
1287
  }
1213
1288
  /** Cut a clip in two at splitTime (composition seconds). The tail inherits the
@@ -1216,7 +1291,7 @@ export function splitLayer(html, layerKey, splitTime) {
1216
1291
  const { document, root } = readCompositionDoc(html);
1217
1292
  const node = resolveLayerNode(root, layerKey);
1218
1293
  if (!node)
1219
- throw new Error(`Layer not found: ${layerKey}`);
1294
+ throw layerNotFoundError(root, layerKey);
1220
1295
  const start = numAttr(node, "data-start");
1221
1296
  const duration = numAttr(node, "data-duration");
1222
1297
  const s = Number.isFinite(start) ? start : 0;
@@ -1247,7 +1322,7 @@ export function setLayerTiming(html, layerKey, opts) {
1247
1322
  const { document, root } = readCompositionDoc(html);
1248
1323
  const node = resolveLayerNode(root, layerKey);
1249
1324
  if (!node)
1250
- throw new Error(`Layer not found: ${layerKey}`);
1325
+ throw layerNotFoundError(root, layerKey);
1251
1326
  const clips = collectClips(root);
1252
1327
  const excludeId = nodeTimelineId(node);
1253
1328
  const nextStart = opts.start !== undefined ? Math.max(0, opts.start) : numAttr(node, "data-start");
@@ -1260,6 +1335,7 @@ export function setLayerTiming(html, layerKey, opts) {
1260
1335
  track: resolvedTrack,
1261
1336
  playbackStart: opts.playbackStart
1262
1337
  });
1338
+ extendRootDurationToCover(root);
1263
1339
  return { html: serialize(document), layerKey: excludeId || layerKey, track: resolvedTrack };
1264
1340
  }
1265
1341
  /** Composition-level (canvas / theme) edit: resize the frame, retarget total
@@ -16,6 +16,8 @@ import { detectLocalAgent } from "../services/clip-curation/index.js";
16
16
  import { hasFfmpeg, resolveFfmpeg, resolveFfprobe } from "../services/clip-curation/ffmpeg.js";
17
17
  import { resolveHyperframesCli } from "./hyperframes-cli.js";
18
18
  import { resolveSkillsRoot } from "./skills.js";
19
+ import { readStoredAuth } from "./auth-store.js";
20
+ import { scanLocalServers, reapProcesses } from "./process-scan.js";
19
21
  const GREEN = "\x1b[32m";
20
22
  const RED = "\x1b[31m";
21
23
  const YELLOW = "\x1b[33m";
@@ -108,10 +110,14 @@ export async function runDoctorCommand(argv) {
108
110
  options: {
109
111
  host: { type: "string", default: "https://vidfarm.cc" },
110
112
  "api-key": { type: "string" },
111
- json: { type: "boolean", default: false }
113
+ json: { type: "boolean", default: false },
114
+ // Reap orphaned local serve/preview processes (running from a deleted
115
+ // package path) so their squatted ports are reclaimed.
116
+ "kill-orphans": { type: "boolean", default: false }
112
117
  }
113
118
  });
114
119
  const json = Boolean(parsed.values.json);
120
+ const killOrphans = Boolean(parsed.values["kill-orphans"]);
115
121
  const checks = [];
116
122
  const add = (name, level, detail) => checks.push({ name, level, detail });
117
123
  // 1. Node version.
@@ -129,14 +135,21 @@ export async function runDoctorCommand(argv) {
129
135
  // 4. Chrome for the in-process render (stills / serve local render).
130
136
  const chrome = detectChromeForRender();
131
137
  add("chrome", chrome.found ? "ok" : "warn", chrome.found ? chrome.detail : `${chrome.detail} — local render/stills will try to download one on first run`);
132
- // 5. Vidfarm cloud auth.
133
- const apiKey = parsed.values["api-key"] ?? process.env.VIDFARM_API_KEY;
138
+ // 5. Vidfarm cloud auth — --api-key / env, else the persisted `vidfarm login`
139
+ // (auth-store), which is how most users authenticate. Without the store
140
+ // lookup a logged-in user got a false "VIDFARM_API_KEY not set" warning.
141
+ const storedAuth = readStoredAuth();
142
+ const explicitKey = parsed.values["api-key"] ?? process.env.VIDFARM_API_KEY;
143
+ const apiKey = explicitKey ?? storedAuth?.apiKey;
134
144
  if (apiKey) {
135
- const who = await fetchWhoami(String(parsed.values.host).replace(/\/+$/, ""), apiKey);
136
- add("vidfarm auth", who.ok ? "ok" : "warn", who.ok ? who.detail : `${who.detail} — serve will fall back to local browse mode`);
145
+ // A stored login was minted for its own host; only an explicit key uses --host.
146
+ const host = explicitKey ? String(parsed.values.host).replace(/\/+$/, "") : (storedAuth?.host ?? String(parsed.values.host)).replace(/\/+$/, "");
147
+ const who = await fetchWhoami(host, apiKey);
148
+ const source = explicitKey ? "" : " (persisted `vidfarm login`)";
149
+ add("vidfarm auth", who.ok ? "ok" : "warn", who.ok ? `${who.detail}${source}` : `${who.detail} — serve will fall back to local browse mode`);
137
150
  }
138
151
  else {
139
- add("vidfarm auth", "warn", "VIDFARM_API_KEY not set — serve will fall back to local browse mode; cloud catalog/bulk routes need it");
152
+ add("vidfarm auth", "warn", "no login foundrun `vidfarm login` (or set VIDFARM_API_KEY); serve will fall back to local browse mode, cloud catalog/bulk routes need it");
140
153
  }
141
154
  // 6. Provider keys in env (BYOK local-first engines).
142
155
  const presentKeys = PROVIDER_KEY_ENV_VARS.filter((name) => process.env[name]?.trim());
@@ -157,9 +170,52 @@ export async function runDoctorCommand(argv) {
157
170
  add("env cloud ARNs/URLs", poisoned.length > 0 ? "warn" : "ok", poisoned.length > 0
158
171
  ? `${poisoned.join(", ")} set — outside \`vidfarm serve\` these route local work to cloud infra (serve blanks them itself)`
159
172
  : "no stray cloud ARNs/URLs in env");
160
- // 9. Port 3000 (default serve port).
161
- const portBusy = await isPortInUse(3000);
162
- add("port 3000", portBusy ? "warn" : "ok", portBusy ? "in use — a serve box may already be running (or pass --port to serve)" : "free");
173
+ // 9. Local serve/preview processes concurrency map + orphan reaping.
174
+ // Customers run several local video jobs at once; a renamed/pruned devcli can
175
+ // also leave an orphaned server squatting a port forever (the "Waiting for
176
+ // preview server…" hang). List them, flag orphans, and optionally reap.
177
+ const scan = scanLocalServers();
178
+ let reapedSummary = "";
179
+ if (killOrphans && scan.supported) {
180
+ const orphans = scan.servers.filter((s) => s.orphaned && !s.isSelf);
181
+ if (orphans.length > 0) {
182
+ const results = await reapProcesses(orphans.map((s) => s.pid));
183
+ const killedPids = new Set(results.filter((r) => r.killed).map((r) => r.pid));
184
+ const survivors = results.filter((r) => !r.killed);
185
+ reapedSummary = `reaped ${killedPids.size}/${orphans.length} orphaned server(s): ${orphans.map((s) => `pid ${s.pid}${s.port ? `:${s.port}` : ""}`).join(", ")}`;
186
+ if (survivors.length > 0) {
187
+ reapedSummary += ` (could not kill: ${survivors.map((r) => `pid ${r.pid}${r.error ? ` — ${r.error}` : ""}`).join(", ")})`;
188
+ }
189
+ // Drop the confirmed-dead rows so the report reflects the fix.
190
+ scan.servers = scan.servers.filter((s) => !killedPids.has(s.pid));
191
+ }
192
+ else {
193
+ reapedSummary = "no orphaned servers to reap";
194
+ }
195
+ }
196
+ const describeServer = (s) => `${s.kind}${s.port ? ` :${s.port}` : ""} pid ${s.pid}${s.isSelf ? " (self)" : ""}${s.orphaned ? " ⚠ORPHAN (binary deleted)" : ""}`;
197
+ if (!scan.supported) {
198
+ add("local servers", "ok", scan.note ?? "scan unavailable");
199
+ }
200
+ else {
201
+ const others = scan.servers.filter((s) => !s.isSelf);
202
+ const orphanCount = others.filter((s) => s.orphaned).length;
203
+ // 3000 is only serve's DEFAULT start port (serve auto-advances past a busy
204
+ // one) — a busy 3000 is informational, and we can't be sure whose it is.
205
+ const portBusy = await isPortInUse(3000);
206
+ const port3000Note = portBusy && !others.some((s) => s.port === 3000)
207
+ ? "; default port 3000 is in use by another process (serve auto-advances to the next free port)"
208
+ : "";
209
+ const detail = others.length === 0
210
+ ? `none running${port3000Note}`
211
+ : `${others.length} running — ${others.map(describeServer).join("; ")}${port3000Note}`;
212
+ const suffix = orphanCount > 0 && !killOrphans
213
+ ? ` — run \`vidfarm doctor --kill-orphans\` to reclaim their ports`
214
+ : reapedSummary
215
+ ? ` — ${reapedSummary}`
216
+ : "";
217
+ add("local servers", orphanCount > 0 ? "warn" : "ok", detail + suffix);
218
+ }
163
219
  // 10. HeyGen credential file — vidfarm never uses it.
164
220
  const heygenCredentials = path.join(homedir(), ".heygen", "credentials");
165
221
  if (existsSync(heygenCredentials) || existsSync(path.join(homedir(), ".heygen", "credentials.json"))) {