@officexapp/vidfarm-devcli 0.21.45 → 0.21.47
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/editor-capabilities/SKILL.md +2 -0
- package/.agents/skills/vidfarm/SKILL.md +60 -7
- package/.agents/skills/vidfarm/harnesses/explainer.HARNESS.md +1 -1
- package/.agents/skills/vidfarm/harnesses/product-demo.HARNESS.md +2 -0
- package/.agents/skills/vidfarm/harnesses/short-form.HARNESS.md +1 -0
- package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +1 -1
- package/.agents/skills/vidfarm/references/agent-included-imagegen.md +75 -0
- package/.agents/skills/vidfarm/references/assets-and-sourcing.md +71 -2
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +13 -6
- package/.agents/skills/vidfarm/references/browser-harness.md +93 -0
- package/.agents/skills/vidfarm/references/editor-workflows.md +22 -0
- package/SKILL.director.md +337 -16
- package/SKILL.md +45 -4
- package/dist/src/cli.js +210 -4
- package/dist/src/devcli/agent-imagegen.js +181 -0
- package/dist/src/devcli/browser-harness.js +384 -0
- package/dist/src/devcli/clip-store.js +41 -3
- package/dist/src/devcli/cost-mode.js +23 -3
- package/dist/src/devcli/doctor.js +52 -3
- package/dist/src/devcli/hyperframes-cli.js +11 -1
- package/dist/src/devcli/local-render.js +4 -7
- package/dist/src/devcli/marketplace-gigs.js +651 -0
- package/dist/src/devcli/qa-check.js +89 -1
- package/dist/src/devcli/shared-folder.js +387 -0
- package/dist/src/devcli/stills.js +4 -8
- package/dist/src/lib/ffprobe-path.js +64 -0
- package/dist/src/lib/render-media-prep.js +2 -11
- package/dist/src/services/clip-curation/ffmpeg.js +4 -15
- package/dist/src/services/clip-curation/local-agent.js +6 -2
- package/experiments.md +2 -2
- package/marketplace.md +549 -0
- package/package.json +21 -10
- package/public/assets/file-directory-app.js +34 -34
- package/public/serve-shells/library-files.html +5 -1
- package/public/serve-shells/library-raws.html +10 -1
- package/public/serve-shells/tools-clipper.html +5 -1
- package/public/serve-shells/tools-image.html +5 -1
- package/public/serve-shells/tools-video.html +5 -1
- package/crowdsourcing.md +0 -157
|
@@ -153,6 +153,15 @@ const SLOP_CLASS_PREFIXES = [
|
|
|
153
153
|
// ("buy now" is a legitimate social line) — this list only escalates a finding
|
|
154
154
|
// when the text also sits inside a button shape.
|
|
155
155
|
const ACTION_COPY = /\b(sign\s?up|get\s?started|learn\s?more|book\s+a\s+(call|demo)|start\s+(your\s+)?free\s+trial|free\s+trial|try\s+(it\s+)?free|click\s+here|subscribe\s+now|shop\s+now|order\s+now|contact\s+us|join\s+now|download\s+now|register\s+now|read\s+more|see\s+plans|view\s+pricing)\b/i;
|
|
156
|
+
// The BRANDED imperative — "TRY DISHCOVERY NOW", "GET ACME FREE", "START YOUR
|
|
157
|
+
// PLAN TODAY". The stock phrases above miss it because the product name sits in
|
|
158
|
+
// the middle, yet it is the exact copy that lands inside a web CTA capsule.
|
|
159
|
+
// Anchored to the whole run and capped in length so a narrative caption line
|
|
160
|
+
// ("we tried three of them today") can never match.
|
|
161
|
+
const BRANDED_CTA_COPY = /^\s*(try|get|start|join|claim|grab|book|order|shop|download|install|discover|explore|unlock|switch)\b[\w\s'&.-]{0,28}\b(now|today|free|here|instantly)\s*[→›»>!.]*\s*$/i;
|
|
162
|
+
function isActionCopy(text) {
|
|
163
|
+
return ACTION_COPY.test(text) || BRANDED_CTA_COPY.test(text);
|
|
164
|
+
}
|
|
156
165
|
// The stock trust-badge phrases that show up in benefit chip rows.
|
|
157
166
|
const BENEFIT_COPY = /(no\s+credit\s+card|money[-\s]?back|cancel\s+any\s?time|24\/7|free\s+shipping|verified|guarantee|no\s+commitment|risk[-\s]?free)/i;
|
|
158
167
|
// Entrance transitions that leave frame 0 as a flat solid — the worst possible
|
|
@@ -394,7 +403,7 @@ export function qaCompositionHtml(html) {
|
|
|
394
403
|
// "BUY NOW" as a bare caption is legitimate social copy and must not fire.
|
|
395
404
|
for (const node of all) {
|
|
396
405
|
const text = textOf(node);
|
|
397
|
-
if (!text || text.length > 60 || !
|
|
406
|
+
if (!text || text.length > 60 || !isActionCopy(text))
|
|
398
407
|
continue;
|
|
399
408
|
const style = styleString(node);
|
|
400
409
|
const radius = radiusPx(style);
|
|
@@ -514,6 +523,85 @@ export function qaCompositionHtml(html) {
|
|
|
514
523
|
});
|
|
515
524
|
}
|
|
516
525
|
}
|
|
526
|
+
// ── Rule: layout template — the frame composed like a PAGE ─────────────────
|
|
527
|
+
// The web hero / modal body: a headline, a support line, and a call to action
|
|
528
|
+
// stacked inside one centred well. `card-panel` needs a border or a shadow and
|
|
529
|
+
// `cta-button` needs a capsule, so the FLAT version of the same thing — drop
|
|
530
|
+
// the box, keep the stack — passed both while still reading as a screenshot of
|
|
531
|
+
// a landing page. The stack itself is the tell, with or without the box.
|
|
532
|
+
const layoutFired = new Set();
|
|
533
|
+
for (const node of all) {
|
|
534
|
+
if (isMockSocialUi(node) || isAnimatedCaptionPart(node))
|
|
535
|
+
continue;
|
|
536
|
+
// Report the OUTERMOST offender only — a hero inside a card would otherwise
|
|
537
|
+
// fire once per nesting level and bury the real finding.
|
|
538
|
+
if (selfAndAncestors(node).slice(1).some((a) => layoutFired.has(a)))
|
|
539
|
+
continue;
|
|
540
|
+
const kids = Array.from(node.children ?? []).filter((c) => textOf(c).trim().length > 0);
|
|
541
|
+
if (kids.length < 3)
|
|
542
|
+
continue;
|
|
543
|
+
// Three stacked lines are a legitimate title card. Three stacked lines whose
|
|
544
|
+
// last job is to be CLICKED are a page layout — that is the whole difference.
|
|
545
|
+
const ctaKid = kids.find((c) => {
|
|
546
|
+
const t = textOf(c).trim();
|
|
547
|
+
return t.length <= 60 && isActionCopy(t);
|
|
548
|
+
});
|
|
549
|
+
if (!ctaKid)
|
|
550
|
+
continue;
|
|
551
|
+
layoutFired.add(node);
|
|
552
|
+
push({
|
|
553
|
+
rule: "layout-template",
|
|
554
|
+
severity: "error",
|
|
555
|
+
message: `Page layout: ${kids.length} stacked text blocks ending in a call to action ("${textOf(ctaKid).trim().slice(0, 32)}") — headline + subheading + CTA is a web hero/modal, not a video frame.`,
|
|
556
|
+
where: label(node, "layout"),
|
|
557
|
+
fix: "Unstack it. Pick the ONE line that carries this beat, set it on the footage in the font regime, and let the next beat carry the next line. A CTA is spoken or a bare caption — never a block in a centred well."
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
// ── Rule: modal scrim — a dimmed AND blurred backdrop ──────────────────────
|
|
561
|
+
// The website-modal frame: the page behind goes dark and out of focus so a
|
|
562
|
+
// centred box can pop forward. Blur ALONE is legitimate (the blurred fill
|
|
563
|
+
// behind a 16:9 clip inside a 9:16 frame is a real technique), so this needs
|
|
564
|
+
// blur AND deliberate dimming — a pair that exists only to push the picture
|
|
565
|
+
// backwards and make a foreground panel read as "on top of the page".
|
|
566
|
+
const pctOf = (style, prop) => {
|
|
567
|
+
const match = style.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*(-?[\\d.]+)%`));
|
|
568
|
+
return match ? Number(match[1]) : null;
|
|
569
|
+
};
|
|
570
|
+
const isFullFrame = (node) => {
|
|
571
|
+
const style = styleString(node);
|
|
572
|
+
if (/(?:^|;)\s*inset\s*:\s*0/.test(style))
|
|
573
|
+
return true;
|
|
574
|
+
return (pctOf(style, "width") ?? 0) >= 90 && (pctOf(style, "height") ?? 0) >= 90;
|
|
575
|
+
};
|
|
576
|
+
// A dark translucent wash covering the frame — rgba with dark channels and a
|
|
577
|
+
// partial alpha. This is the "scrim" half of the modal look.
|
|
578
|
+
const DIM_FILL = /rgba\(\s*\d{1,2}\s*,\s*\d{1,2}\s*,\s*\d{1,2}\s*,\s*0?\.[1-9]/;
|
|
579
|
+
const hasDimScrim = all.some((node) => isFullFrame(node) && DIM_FILL.test(styleString(node)));
|
|
580
|
+
for (const node of all) {
|
|
581
|
+
const style = styleString(node);
|
|
582
|
+
if (!/(?:^|;)\s*filter\s*:\s*[^;]*blur\(\s*(?!0(?:px)?\s*\))/.test(style))
|
|
583
|
+
continue;
|
|
584
|
+
if (!isFullFrame(node))
|
|
585
|
+
continue;
|
|
586
|
+
const opacity = Number((style.match(/(?:^|;)\s*opacity\s*:\s*([\d.]+)/) ?? [])[1]);
|
|
587
|
+
// A backdrop faded almost to nothing is an AMBIENT TEXTURE BED, not a scrim:
|
|
588
|
+
// you can't read the picture through it, so it isn't staging anything "on
|
|
589
|
+
// top of the page" — it's just colour behind the frame. Legitimate design.
|
|
590
|
+
if (Number.isFinite(opacity) && opacity < 0.25)
|
|
591
|
+
continue;
|
|
592
|
+
const selfDimmed = (Number.isFinite(opacity) && opacity < 0.95) ||
|
|
593
|
+
/filter\s*:\s*[^;]*brightness\(\s*0?\.\d/.test(style);
|
|
594
|
+
if (!selfDimmed && !hasDimScrim)
|
|
595
|
+
continue;
|
|
596
|
+
push({
|
|
597
|
+
rule: "modal-scrim",
|
|
598
|
+
severity: "error",
|
|
599
|
+
message: "Backdrop is blurred AND dimmed — that is a web modal scrim, staged so a foreground panel pops off the page.",
|
|
600
|
+
where: label(node, "backdrop"),
|
|
601
|
+
fix: "Let the footage be the frame: full-bleed and in focus, with the words set directly on it. If one caption needs legibility, use an outline or a tight band on THAT text — never a full-frame wash to stage a floating block."
|
|
602
|
+
});
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
517
605
|
// ── Rule: gradient text fill ───────────────────────────────────────────────
|
|
518
606
|
if (/background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/i.test(html)) {
|
|
519
607
|
push({
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// `vidfarm shared` — the VISITOR side of a directory share link.
|
|
2
|
+
//
|
|
3
|
+
// A share link (`https://vidfarm.cc/directory/preview/dsh_…/files/<folder>`) is
|
|
4
|
+
// the only vidfarm surface that needs NO account and NO API key. A gigworker or
|
|
5
|
+
// their AI agent gets the link in a task brief and works the folder straight
|
|
6
|
+
// from the terminal: browse it, search it by meaning, make a subfolder for their
|
|
7
|
+
// own proof of work, upload deliverables, download reference footage.
|
|
8
|
+
//
|
|
9
|
+
// The owner mints the link with `vidfarm directory share <path> --mode …`
|
|
10
|
+
// (paid). Everything in this module is free and anonymous.
|
|
11
|
+
//
|
|
12
|
+
// Routes used (all public, token-scoped):
|
|
13
|
+
// GET /api/v1/share/:token/directory?path=… browse
|
|
14
|
+
// POST /api/v1/share/:token/directory/search vector search (read mode too)
|
|
15
|
+
// POST /api/v1/share/:token/directory/folders make a subfolder (upload/edit)
|
|
16
|
+
// POST /api/v1/share/:token/attachments/presign ─┐ big-file upload: the
|
|
17
|
+
// PUT <presigned storage url> │ multipart route below
|
|
18
|
+
// POST /api/v1/share/:token/attachments/finalize ─┘ caps at ~6 MB
|
|
19
|
+
// POST /api/v1/share/:token/attachments/upload multipart fallback
|
|
20
|
+
//
|
|
21
|
+
// Uploads ALWAYS try presign → PUT → finalize first. The multipart route goes
|
|
22
|
+
// through the API Lambda, whose request body caps at about 6 MB, so a 40 MB clip
|
|
23
|
+
// answers 413 before the handler runs.
|
|
24
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { parseArgs } from "node:util";
|
|
27
|
+
const DEFAULT_HOST = process.env.VIDFARM_HOST?.trim() || "https://vidfarm.cc";
|
|
28
|
+
const BOLD = "\x1b[1m";
|
|
29
|
+
const DIM = "\x1b[2m";
|
|
30
|
+
const GREEN = "\x1b[32m";
|
|
31
|
+
const RESET = "\x1b[0m";
|
|
32
|
+
export const SHARED_HELP = `vidfarm shared — use a folder someone shared with you (no account, no API key)
|
|
33
|
+
|
|
34
|
+
shared info <link> What the link is: folder, mode, what you may do
|
|
35
|
+
shared ls <link> [subfolder] Browse the shared folder → GET /api/v1/share/:token/directory
|
|
36
|
+
--limit <n> --offset <n> --json
|
|
37
|
+
shared search <link> "<query>" Find files by MEANING inside the share → POST /api/v1/share/:token/directory/search
|
|
38
|
+
--subfolder <name> --limit <n> --json
|
|
39
|
+
shared mkdir <link> <subfolder> Make a subfolder (upload/edit links only) → POST /api/v1/share/:token/directory/folders
|
|
40
|
+
shared put <link> <file…> Upload files into the share (upload/edit) → presign → PUT → finalize
|
|
41
|
+
--subfolder <name> Land them in a subfolder of the shared folder
|
|
42
|
+
--json
|
|
43
|
+
shared get <link> <file-name> Download one file out of the share
|
|
44
|
+
--subfolder <name> Look inside a subfolder
|
|
45
|
+
--out <dir|file> Where to write it (default: this folder)
|
|
46
|
+
--all Download every file in the folder instead
|
|
47
|
+
|
|
48
|
+
<link> is the URL you were given, or just the dsh_… token (then --host <url>).
|
|
49
|
+
|
|
50
|
+
e.g. vidfarm shared info https://vidfarm.cc/directory/preview/dsh_abc/files/project1
|
|
51
|
+
vidfarm shared mkdir <link> yvette-batch-01
|
|
52
|
+
vidfarm shared put <link> clip-01.mp4 clip-02.mp4 --subfolder yvette-batch-01
|
|
53
|
+
vidfarm shared search <link> "founder talking head, no captions"
|
|
54
|
+
vidfarm shared get <link> brief.md --out ./work
|
|
55
|
+
|
|
56
|
+
Modes: read = browse + search · upload = + add files and subfolders (never delete)
|
|
57
|
+
edit = + rename and delete. Only /files and /temp shares accept uploads.`;
|
|
58
|
+
// Accepts a preview URL, an API URL, or a bare token. The trailing path of a
|
|
59
|
+
// preview URL is kept as the default subfolder so a task brief can point at the
|
|
60
|
+
// exact folder the worker should use.
|
|
61
|
+
export function parseSharedLink(raw, hostOverride) {
|
|
62
|
+
const value = (raw || "").trim();
|
|
63
|
+
if (!value)
|
|
64
|
+
throw new Error("A share link (or dsh_… token) is required.");
|
|
65
|
+
if (!/^https?:\/\//i.test(value)) {
|
|
66
|
+
if (!/^dsh_[A-Za-z0-9_-]+$/.test(value)) {
|
|
67
|
+
throw new Error(`Not a share link or token: ${value}`);
|
|
68
|
+
}
|
|
69
|
+
return { host: (hostOverride || DEFAULT_HOST).replace(/\/+$/, ""), token: value, subPath: "" };
|
|
70
|
+
}
|
|
71
|
+
let url;
|
|
72
|
+
try {
|
|
73
|
+
url = new URL(value);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new Error(`Not a valid share link: ${value}`);
|
|
77
|
+
}
|
|
78
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
79
|
+
const tokenIndex = segments.findIndex((s) => /^dsh_[A-Za-z0-9_-]+$/.test(s));
|
|
80
|
+
if (tokenIndex < 0)
|
|
81
|
+
throw new Error(`That URL has no dsh_… share token in it: ${value}`);
|
|
82
|
+
return {
|
|
83
|
+
host: (hostOverride || url.origin).replace(/\/+$/, ""),
|
|
84
|
+
token: segments[tokenIndex],
|
|
85
|
+
// Everything after the token is /<root>/<folder…> — the share's own path.
|
|
86
|
+
subPath: segments.slice(tokenIndex + 1).join("/")
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function apiBase(link) {
|
|
90
|
+
return `${link.host}/api/v1/share/${link.token}`;
|
|
91
|
+
}
|
|
92
|
+
async function readJson(response) {
|
|
93
|
+
const text = await response.text();
|
|
94
|
+
try {
|
|
95
|
+
return text ? JSON.parse(text) : {};
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { error: text.slice(0, 300) };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function fail(action, status, body) {
|
|
102
|
+
const message = typeof body?.error === "string" ? body.error : `HTTP ${status}`;
|
|
103
|
+
throw new Error(`${action} failed: ${message}`);
|
|
104
|
+
}
|
|
105
|
+
async function shareGet(link, route, query) {
|
|
106
|
+
const url = new URL(apiBase(link) + route);
|
|
107
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
108
|
+
if (value != null && value !== "")
|
|
109
|
+
url.searchParams.set(key, value);
|
|
110
|
+
}
|
|
111
|
+
const response = await fetch(url, { headers: { accept: "application/json" } });
|
|
112
|
+
const body = await readJson(response);
|
|
113
|
+
if (!response.ok)
|
|
114
|
+
fail(`share ${route}`, response.status, body);
|
|
115
|
+
return body;
|
|
116
|
+
}
|
|
117
|
+
async function sharePost(link, route, payload) {
|
|
118
|
+
const response = await fetch(apiBase(link) + route, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
121
|
+
body: JSON.stringify(payload)
|
|
122
|
+
});
|
|
123
|
+
const body = await readJson(response);
|
|
124
|
+
if (!response.ok)
|
|
125
|
+
fail(`share ${route}`, response.status, body);
|
|
126
|
+
return body;
|
|
127
|
+
}
|
|
128
|
+
// The path a command works on: the link's own folder, plus an optional
|
|
129
|
+
// --subfolder. Never an absolute path — a token cannot leave its own subtree.
|
|
130
|
+
function targetPath(link, basePath, subfolder) {
|
|
131
|
+
const base = basePath || (link.subPath ? "/" + link.subPath : "");
|
|
132
|
+
const sub = (subfolder || "").replace(/^\/+|\/+$/g, "");
|
|
133
|
+
if (!sub)
|
|
134
|
+
return base;
|
|
135
|
+
return `${base.replace(/\/+$/, "")}/${sub}`;
|
|
136
|
+
}
|
|
137
|
+
async function loadShare(link, subfolder) {
|
|
138
|
+
// The first listing tells us the share's canonical base path and mode.
|
|
139
|
+
const probe = await shareGet(link, "/directory", { path: link.subPath ? "/" + link.subPath : undefined });
|
|
140
|
+
const basePath = probe.share?.base_path || probe.path || "";
|
|
141
|
+
const wanted = targetPath(link, basePath, subfolder);
|
|
142
|
+
if (wanted === probe.path)
|
|
143
|
+
return { listing: probe, basePath, path: probe.path };
|
|
144
|
+
const listing = await shareGet(link, "/directory", { path: wanted });
|
|
145
|
+
return { listing, basePath, path: wanted };
|
|
146
|
+
}
|
|
147
|
+
// ── commands ─────────────────────────────────────────────────────────────────
|
|
148
|
+
function options() {
|
|
149
|
+
return {
|
|
150
|
+
host: { type: "string" },
|
|
151
|
+
json: { type: "boolean" },
|
|
152
|
+
subfolder: { type: "string" },
|
|
153
|
+
folder: { type: "string" },
|
|
154
|
+
out: { type: "string" },
|
|
155
|
+
limit: { type: "string" },
|
|
156
|
+
offset: { type: "string" },
|
|
157
|
+
all: { type: "boolean" }
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function modeVerbs(mode) {
|
|
161
|
+
if (mode === "edit")
|
|
162
|
+
return "browse · search · upload · make folders · rename · delete";
|
|
163
|
+
if (mode === "upload")
|
|
164
|
+
return "browse · search · upload · make folders (no delete)";
|
|
165
|
+
return "browse · search (read only)";
|
|
166
|
+
}
|
|
167
|
+
async function runInfo(argv) {
|
|
168
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: options() });
|
|
169
|
+
const link = parseSharedLink(parsed.positionals[0], parsed.values.host);
|
|
170
|
+
const { listing, basePath } = await loadShare(link);
|
|
171
|
+
if (parsed.values.json) {
|
|
172
|
+
console.log(JSON.stringify({ token: link.token, host: link.host, base_path: basePath, share: listing.share }, null, 2));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const mode = listing.share?.mode ?? "read";
|
|
176
|
+
console.log(`${BOLD}${listing.share?.label || "Shared folder"}${RESET} ${DIM}${link.token}${RESET}`);
|
|
177
|
+
console.log(` folder : ${basePath}`);
|
|
178
|
+
console.log(` mode : ${mode} ${DIM}(${modeVerbs(mode)})${RESET}`);
|
|
179
|
+
console.log(` files : ${(listing.files ?? []).length} folders: ${(listing.folders ?? []).length}`);
|
|
180
|
+
if (mode !== "read") {
|
|
181
|
+
console.log(` ${DIM}Make your own subfolder first: vidfarm shared mkdir <link> <your-name>${RESET}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function runLs(argv) {
|
|
185
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: options() });
|
|
186
|
+
const link = parseSharedLink(parsed.positionals[0], parsed.values.host);
|
|
187
|
+
const subfolder = parsed.values.subfolder ?? parsed.positionals[1];
|
|
188
|
+
const { listing, path: shownPath } = await loadShare(link, subfolder);
|
|
189
|
+
if (parsed.values.json) {
|
|
190
|
+
console.log(JSON.stringify(listing, null, 2));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
console.log(`${BOLD}${shownPath}${RESET} ${DIM}(${listing.share?.mode ?? "read"} link)${RESET}`);
|
|
194
|
+
for (const folder of listing.folders ?? [])
|
|
195
|
+
console.log(` ${BOLD}${folder.name}/${RESET}`);
|
|
196
|
+
for (const file of listing.files ?? []) {
|
|
197
|
+
const size = typeof file.sizeBytes === "number" ? `${(file.sizeBytes / (1024 * 1024)).toFixed(1)} MB` : "";
|
|
198
|
+
console.log(` ${file.name} ${DIM}${size}${RESET}`);
|
|
199
|
+
}
|
|
200
|
+
if (!(listing.folders ?? []).length && !(listing.files ?? []).length)
|
|
201
|
+
console.log(` ${DIM}empty${RESET}`);
|
|
202
|
+
}
|
|
203
|
+
async function runSearch(argv) {
|
|
204
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: options() });
|
|
205
|
+
const link = parseSharedLink(parsed.positionals[0], parsed.values.host);
|
|
206
|
+
const query = parsed.positionals.slice(1).join(" ").trim();
|
|
207
|
+
if (!query)
|
|
208
|
+
throw new Error('shared search needs a query: vidfarm shared search <link> "founder talking head"');
|
|
209
|
+
const { basePath } = await loadShare(link);
|
|
210
|
+
const scope = targetPath(link, basePath, parsed.values.subfolder);
|
|
211
|
+
const limit = parsed.values.limit != null ? Number(parsed.values.limit) : undefined;
|
|
212
|
+
const body = await sharePost(link, "/directory/search", {
|
|
213
|
+
query,
|
|
214
|
+
path: scope,
|
|
215
|
+
...(limit != null && Number.isFinite(limit) ? { limit } : {})
|
|
216
|
+
});
|
|
217
|
+
if (parsed.values.json) {
|
|
218
|
+
console.log(JSON.stringify(body, null, 2));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const results = body.results ?? body.items ?? [];
|
|
222
|
+
if (!results.length) {
|
|
223
|
+
console.log(`${DIM}No matches in ${scope}.${RESET}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
for (const hit of results) {
|
|
227
|
+
console.log(` ${hit.name ?? hit.path} ${DIM}${hit.path ?? ""}${RESET}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function runMkdir(argv) {
|
|
231
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: options() });
|
|
232
|
+
const link = parseSharedLink(parsed.positionals[0], parsed.values.host);
|
|
233
|
+
const name = parsed.positionals[1] ?? parsed.values.subfolder;
|
|
234
|
+
if (!name)
|
|
235
|
+
throw new Error("shared mkdir needs a folder name: vidfarm shared mkdir <link> <subfolder>");
|
|
236
|
+
const { basePath } = await loadShare(link);
|
|
237
|
+
const body = await sharePost(link, "/directory/folders", { path: targetPath(link, basePath, name) });
|
|
238
|
+
if (parsed.values.json) {
|
|
239
|
+
console.log(JSON.stringify(body, null, 2));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
console.log(`${GREEN}✓${RESET} Created ${BOLD}${body.path ?? name}${RESET}`);
|
|
243
|
+
}
|
|
244
|
+
// Upload one file: presign → PUT the bytes straight to storage → finalize. Falls
|
|
245
|
+
// back to the multipart route when the server has no presigned transport.
|
|
246
|
+
async function uploadOne(link, filePath, folderPath) {
|
|
247
|
+
if (!existsSync(filePath))
|
|
248
|
+
throw new Error(`No such file: ${filePath}`);
|
|
249
|
+
const stats = statSync(filePath);
|
|
250
|
+
const fileName = path.basename(filePath);
|
|
251
|
+
// A server that predates the presign route just 404s — fall back to multipart
|
|
252
|
+
// (which still works for anything under ~6 MB). Any OTHER refusal is a real
|
|
253
|
+
// answer (unsupported type, over the size cap, read-only link) and must
|
|
254
|
+
// surface: retrying it as multipart only turns a clear 400 into a 502.
|
|
255
|
+
const presign = await sharePost(link, "/attachments/presign", {
|
|
256
|
+
file_name: fileName,
|
|
257
|
+
size_bytes: stats.size,
|
|
258
|
+
folder_path: folderPath
|
|
259
|
+
}).catch((error) => {
|
|
260
|
+
if (/HTTP 404/.test(error.message))
|
|
261
|
+
return { transport: "server" };
|
|
262
|
+
throw error;
|
|
263
|
+
});
|
|
264
|
+
const bytes = await readFileBytes(filePath);
|
|
265
|
+
// One ArrayBuffer view keeps both transports happy (fetch body / Blob part).
|
|
266
|
+
const payload = new Uint8Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
|
267
|
+
if (presign.transport !== "presigned" || !presign.upload?.url) {
|
|
268
|
+
const form = new FormData();
|
|
269
|
+
form.append("file", new Blob([payload]), fileName);
|
|
270
|
+
form.append("folder_path", folderPath);
|
|
271
|
+
const response = await fetch(apiBase(link) + "/attachments/upload", { method: "POST", body: form });
|
|
272
|
+
const multipartBody = await readJson(response);
|
|
273
|
+
if (!response.ok)
|
|
274
|
+
fail(`upload ${fileName}`, response.status, multipartBody);
|
|
275
|
+
return multipartBody.attachment ?? multipartBody.file ?? multipartBody;
|
|
276
|
+
}
|
|
277
|
+
const put = await fetch(presign.upload.url, {
|
|
278
|
+
method: presign.upload.method || "PUT",
|
|
279
|
+
headers: presign.upload.headers || {},
|
|
280
|
+
body: payload
|
|
281
|
+
});
|
|
282
|
+
if (!put.ok)
|
|
283
|
+
throw new Error(`upload ${fileName} failed: storage answered HTTP ${put.status}`);
|
|
284
|
+
const saved = await sharePost(link, "/attachments/finalize", {
|
|
285
|
+
file_id: presign.file_id,
|
|
286
|
+
file_name: presign.file_name,
|
|
287
|
+
content_type: presign.content_type,
|
|
288
|
+
size_bytes: stats.size,
|
|
289
|
+
storage_key: presign.storage_key,
|
|
290
|
+
folder_path: presign.folder_path
|
|
291
|
+
});
|
|
292
|
+
return saved.attachment ?? saved.file ?? saved;
|
|
293
|
+
}
|
|
294
|
+
async function readFileBytes(filePath) {
|
|
295
|
+
const { readFile } = await import("node:fs/promises");
|
|
296
|
+
return readFile(filePath);
|
|
297
|
+
}
|
|
298
|
+
async function runPut(argv) {
|
|
299
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: options() });
|
|
300
|
+
const link = parseSharedLink(parsed.positionals[0], parsed.values.host);
|
|
301
|
+
const files = parsed.positionals.slice(1);
|
|
302
|
+
if (!files.length)
|
|
303
|
+
throw new Error("shared put needs at least one file: vidfarm shared put <link> clip.mp4 [--subfolder batch-01]");
|
|
304
|
+
const subfolder = parsed.values.subfolder ?? parsed.values.folder;
|
|
305
|
+
const { listing, basePath } = await loadShare(link);
|
|
306
|
+
const mode = listing.share?.mode ?? "read";
|
|
307
|
+
if (mode === "read")
|
|
308
|
+
throw new Error("This share link is read-only — ask the owner for an upload link.");
|
|
309
|
+
// The API wants the folder RELATIVE to the root (no /files prefix).
|
|
310
|
+
const wanted = targetPath(link, basePath, subfolder);
|
|
311
|
+
const folderPath = wanted.replace(/^\/(files|temp|raws|approved|projects)\/?/, "");
|
|
312
|
+
const saved = [];
|
|
313
|
+
for (const file of files) {
|
|
314
|
+
const record = await uploadOne(link, file, folderPath);
|
|
315
|
+
saved.push(record);
|
|
316
|
+
if (!parsed.values.json)
|
|
317
|
+
console.log(`${GREEN}✓${RESET} ${path.basename(file)} → ${BOLD}${wanted}${RESET}`);
|
|
318
|
+
}
|
|
319
|
+
if (parsed.values.json)
|
|
320
|
+
console.log(JSON.stringify({ ok: true, uploaded: saved }, null, 2));
|
|
321
|
+
}
|
|
322
|
+
async function downloadTo(url, outPath) {
|
|
323
|
+
const response = await fetch(url);
|
|
324
|
+
if (!response.ok)
|
|
325
|
+
throw new Error(`download failed: HTTP ${response.status}`);
|
|
326
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
327
|
+
mkdirSync(path.dirname(outPath), { recursive: true });
|
|
328
|
+
writeFileSync(outPath, bytes);
|
|
329
|
+
}
|
|
330
|
+
async function runGet(argv) {
|
|
331
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: options() });
|
|
332
|
+
const link = parseSharedLink(parsed.positionals[0], parsed.values.host);
|
|
333
|
+
const wantedName = parsed.positionals[1];
|
|
334
|
+
const { listing, path: shownPath } = await loadShare(link, parsed.values.subfolder);
|
|
335
|
+
const files = listing.files ?? [];
|
|
336
|
+
const out = parsed.values.out || ".";
|
|
337
|
+
if (parsed.values.all || !wantedName) {
|
|
338
|
+
if (!parsed.values.all)
|
|
339
|
+
throw new Error("shared get needs a file name, or --all to take the whole folder.");
|
|
340
|
+
for (const file of files) {
|
|
341
|
+
if (!file.viewUrl)
|
|
342
|
+
continue;
|
|
343
|
+
const dest = path.join(out, file.name);
|
|
344
|
+
await downloadTo(file.viewUrl, dest);
|
|
345
|
+
console.log(`${GREEN}✓${RESET} ${dest}`);
|
|
346
|
+
}
|
|
347
|
+
console.log(`${DIM}${files.length} file(s) from ${shownPath}${RESET}`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const match = files.find((f) => f.name === wantedName) || files.find((f) => String(f.name).includes(wantedName));
|
|
351
|
+
if (!match)
|
|
352
|
+
throw new Error(`No file named "${wantedName}" in ${shownPath}.`);
|
|
353
|
+
if (!match.viewUrl)
|
|
354
|
+
throw new Error(`"${match.name}" has no downloadable URL.`);
|
|
355
|
+
const dest = /\.[A-Za-z0-9]+$/.test(out) ? out : path.join(out, match.name);
|
|
356
|
+
await downloadTo(match.viewUrl, dest);
|
|
357
|
+
console.log(`${GREEN}✓${RESET} ${dest}`);
|
|
358
|
+
}
|
|
359
|
+
export async function runSharedCommand(argv) {
|
|
360
|
+
const sub = argv[0];
|
|
361
|
+
const rest = argv.slice(1);
|
|
362
|
+
switch (sub) {
|
|
363
|
+
case "info":
|
|
364
|
+
case "about": return runInfo(rest);
|
|
365
|
+
case "ls":
|
|
366
|
+
case "list": return runLs(rest);
|
|
367
|
+
case "search":
|
|
368
|
+
case "find": return runSearch(rest);
|
|
369
|
+
case "mkdir":
|
|
370
|
+
case "folder": return runMkdir(rest);
|
|
371
|
+
case "put":
|
|
372
|
+
case "upload": return runPut(rest);
|
|
373
|
+
case "get":
|
|
374
|
+
case "download": return runGet(rest);
|
|
375
|
+
case undefined:
|
|
376
|
+
case "help":
|
|
377
|
+
case "--help":
|
|
378
|
+
case "-h":
|
|
379
|
+
console.log(SHARED_HELP);
|
|
380
|
+
return;
|
|
381
|
+
default:
|
|
382
|
+
console.error(`Unknown shared subcommand: ${sub}\n`);
|
|
383
|
+
console.log(SHARED_HELP);
|
|
384
|
+
process.exitCode = 1;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
//# sourceMappingURL=shared-folder.js.map
|
|
@@ -23,6 +23,7 @@ import os from "node:os";
|
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { parseHTML } from "linkedom";
|
|
25
25
|
import { resolveFfmpeg } from "../services/clip-curation/ffmpeg.js";
|
|
26
|
+
import { resolveBundledFfprobe } from "../lib/ffprobe-path.js";
|
|
26
27
|
import { inspectComposition } from "./composition-edit.js";
|
|
27
28
|
const MAX_DEFAULT_STILLS = 8;
|
|
28
29
|
const MAX_GRID_FRAMES = 600;
|
|
@@ -41,14 +42,9 @@ async function ensureProducerFfmpegEnv() {
|
|
|
41
42
|
catch { /* PATH fallback */ }
|
|
42
43
|
}
|
|
43
44
|
if (!process.env.HYPERFRAMES_FFPROBE_PATH?.trim()) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (typeof resolved === "string" && resolved && existsSync(resolved)) {
|
|
48
|
-
process.env.HYPERFRAMES_FFPROBE_PATH = resolved;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
catch { /* PATH fallback */ }
|
|
45
|
+
const resolved = resolveBundledFfprobe();
|
|
46
|
+
if (resolved)
|
|
47
|
+
process.env.HYPERFRAMES_FFPROBE_PATH = resolved;
|
|
52
48
|
}
|
|
53
49
|
}
|
|
54
50
|
/**
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Single source of truth for the bundled ffprobe binary path.
|
|
2
|
+
//
|
|
3
|
+
// Why this module exists: the previous dependency, `ffprobe-static@3`, ships
|
|
4
|
+
// binaries for EVERY platform in one tarball (335 MB unpacked — darwin 132 MB +
|
|
5
|
+
// linux 99 MB + win32 104 MB). Every `npm i -g @officexapp/vidfarm-devcli` paid
|
|
6
|
+
// for all three. On Windows that cost is multiplied again by Defender scanning
|
|
7
|
+
// each extracted file, which is the single biggest first-run setup delay users
|
|
8
|
+
// report. `@ffprobe-installer/ffprobe` declares one optional dependency per
|
|
9
|
+
// platform, so npm installs ONLY the matching binary (~17 MB).
|
|
10
|
+
//
|
|
11
|
+
// Resolution order (same shape as resolveFfmpeg in services/clip-curation):
|
|
12
|
+
// 1. HYPERFRAMES_FFPROBE_PATH / FFPROBE_PATH env override
|
|
13
|
+
// 2. @ffprobe-installer/ffprobe (per-platform, the bundled default)
|
|
14
|
+
// 3. ffprobe-static (legacy; only if something still installs it)
|
|
15
|
+
// 4. null → the caller falls back to a bare `ffprobe` on PATH
|
|
16
|
+
//
|
|
17
|
+
// Windows-on-ARM note: neither ffprobe-installer nor ffmpeg-static publishes a
|
|
18
|
+
// win32-arm64 build, so steps 2/3 return null there and PATH is the only route.
|
|
19
|
+
// That is not a regression — ffmpeg-static already had the same gap.
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
const requireFrom = createRequire(import.meta.url);
|
|
23
|
+
let cached;
|
|
24
|
+
/** Pull `.path` off whichever module shape the installer package exports. */
|
|
25
|
+
function readPath(mod) {
|
|
26
|
+
if (typeof mod === "string")
|
|
27
|
+
return mod;
|
|
28
|
+
if (mod && typeof mod === "object") {
|
|
29
|
+
const direct = mod.path;
|
|
30
|
+
if (typeof direct === "string" && direct)
|
|
31
|
+
return direct;
|
|
32
|
+
const viaDefault = mod.default?.path;
|
|
33
|
+
if (typeof viaDefault === "string" && viaDefault)
|
|
34
|
+
return viaDefault;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Absolute path to a bundled ffprobe, or null when none is installed for this
|
|
40
|
+
* platform. Cached — resolution is a require() plus a stat.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveBundledFfprobe() {
|
|
43
|
+
if (cached !== undefined)
|
|
44
|
+
return cached;
|
|
45
|
+
const envPath = process.env.HYPERFRAMES_FFPROBE_PATH?.trim() || process.env.FFPROBE_PATH?.trim();
|
|
46
|
+
if (envPath && existsSync(envPath))
|
|
47
|
+
return (cached = envPath);
|
|
48
|
+
for (const pkg of ["@ffprobe-installer/ffprobe", "ffprobe-static"]) {
|
|
49
|
+
try {
|
|
50
|
+
const resolved = readPath(requireFrom(pkg));
|
|
51
|
+
if (resolved && existsSync(resolved))
|
|
52
|
+
return (cached = resolved);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// not installed for this platform — try the next candidate
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return (cached = null);
|
|
59
|
+
}
|
|
60
|
+
/** Same as resolveBundledFfprobe, but falls back to a bare `ffprobe` on PATH. */
|
|
61
|
+
export function resolveFfprobeCommand() {
|
|
62
|
+
return resolveBundledFfprobe() ?? "ffprobe";
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=ffprobe-path.js.map
|
|
@@ -32,6 +32,7 @@ import path from "node:path";
|
|
|
32
32
|
import { Readable, Transform } from "node:stream";
|
|
33
33
|
import { pipeline } from "node:stream/promises";
|
|
34
34
|
import { parseHTML } from "linkedom";
|
|
35
|
+
import { resolveFfprobeCommand } from "./ffprobe-path.js";
|
|
35
36
|
// Trailing pad added to every cut so the renderer never runs out of media on
|
|
36
37
|
// the final frame of a clip (frame quantization + audio priming).
|
|
37
38
|
const SEGMENT_PAD_SECONDS = 0.5;
|
|
@@ -71,17 +72,7 @@ async function resolveFfmpegPath() {
|
|
|
71
72
|
return "ffmpeg";
|
|
72
73
|
}
|
|
73
74
|
async function resolveFfprobePath() {
|
|
74
|
-
|
|
75
|
-
if (env && existsSync(env))
|
|
76
|
-
return env;
|
|
77
|
-
try {
|
|
78
|
-
const mod = (await import("ffprobe-static"));
|
|
79
|
-
const resolved = (mod.path ?? mod.default?.path);
|
|
80
|
-
if (typeof resolved === "string" && resolved && existsSync(resolved))
|
|
81
|
-
return resolved;
|
|
82
|
-
}
|
|
83
|
-
catch { /* PATH fallback */ }
|
|
84
|
-
return "ffprobe";
|
|
75
|
+
return resolveFfprobeCommand();
|
|
85
76
|
}
|
|
86
77
|
function runCommand(bin, args, timeoutMs) {
|
|
87
78
|
return new Promise((resolve, reject) => {
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// thumbnail extraction, per-scene audio for ASR. Same flags on devcli (local
|
|
3
3
|
// ffmpeg-static) and cloud (bundled ffmpeg-static in the scan Lambda), so both
|
|
4
4
|
// targets cut identical clips. Resolution order matches the rest of the repo:
|
|
5
|
-
// HYPERFRAMES_FFMPEG_PATH/FFPROBE env → ffmpeg-static
|
|
5
|
+
// HYPERFRAMES_FFMPEG_PATH/FFPROBE env → ffmpeg-static/@ffprobe-installer → PATH.
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { existsSync, mkdirSync } from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
|
+
import { resolveBundledFfprobe } from "../../lib/ffprobe-path.js";
|
|
9
10
|
let cachedFfmpeg;
|
|
10
11
|
let cachedFfprobe;
|
|
11
12
|
export async function resolveFfmpeg() {
|
|
@@ -29,20 +30,8 @@ export async function resolveFfmpeg() {
|
|
|
29
30
|
export async function resolveFfprobe() {
|
|
30
31
|
if (cachedFfprobe !== undefined)
|
|
31
32
|
return cachedFfprobe ?? "ffprobe";
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return (cachedFfprobe = envPath);
|
|
35
|
-
try {
|
|
36
|
-
const mod = (await import("ffprobe-static"));
|
|
37
|
-
const p = (mod.path ?? mod.default?.path);
|
|
38
|
-
if (typeof p === "string" && p && existsSync(p))
|
|
39
|
-
return (cachedFfprobe = p);
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
/* fall through to PATH */
|
|
43
|
-
}
|
|
44
|
-
cachedFfprobe = null;
|
|
45
|
-
return "ffprobe";
|
|
33
|
+
cachedFfprobe = resolveBundledFfprobe();
|
|
34
|
+
return cachedFfprobe ?? "ffprobe";
|
|
46
35
|
}
|
|
47
36
|
/** True if a usable ffmpeg is available (bundled or on PATH). */
|
|
48
37
|
export async function hasFfmpeg() {
|
|
@@ -16,9 +16,13 @@ export function detectLocalAgent(preferred) {
|
|
|
16
16
|
const candidates = preferred === "claude" ? ["claude"]
|
|
17
17
|
: preferred === "codex" ? ["codex"]
|
|
18
18
|
: ["claude", "codex"];
|
|
19
|
+
// Windows has no `which` — the equivalent is `where`, which prints one match
|
|
20
|
+
// per line (take the first). Without this branch, agent detection always
|
|
21
|
+
// reported "none installed" on Windows even with Claude Code on PATH.
|
|
22
|
+
const lookup = process.platform === "win32" ? "where" : "which";
|
|
19
23
|
for (const kind of candidates) {
|
|
20
|
-
const found = spawnSync(
|
|
21
|
-
const bin = (found.stdout || "").trim();
|
|
24
|
+
const found = spawnSync(lookup, [kind], { encoding: "utf8", shell: process.platform === "win32" });
|
|
25
|
+
const bin = (found.stdout || "").split(/\r?\n/)[0]?.trim() ?? "";
|
|
22
26
|
if (found.status === 0 && bin)
|
|
23
27
|
return { kind, bin };
|
|
24
28
|
}
|