@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.
- 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/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
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,
|
|
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
|
|
1334
|
-
if (!Number.isFinite(
|
|
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
|
|
1409
|
-
if (!Number.isFinite(
|
|
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 ">". 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, ">") + 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
|
-
|
|
152
|
-
?? root.querySelector(`[data-hf-slug="${escaped}"]`)
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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"
|
|
291
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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", "
|
|
152
|
+
add("vidfarm auth", "warn", "no login found — run `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.
|
|
161
|
-
|
|
162
|
-
|
|
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"))) {
|