@officexapp/vidfarm-devcli 0.21.43 → 0.21.46

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.
Files changed (49) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +4 -0
  2. package/.agents/skills/vidfarm/SKILL.md +95 -17
  3. package/.agents/skills/vidfarm/harnesses/explainer.HARNESS.md +1 -1
  4. package/.agents/skills/vidfarm/harnesses/product-demo.HARNESS.md +2 -0
  5. package/.agents/skills/vidfarm/harnesses/short-form.HARNESS.md +1 -0
  6. package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +1 -1
  7. package/.agents/skills/vidfarm/recipes/onboard-a-new-director.md +1 -1
  8. package/.agents/skills/vidfarm/references/agent-included-imagegen.md +75 -0
  9. package/.agents/skills/vidfarm/references/assets-and-sourcing.md +152 -2
  10. package/.agents/skills/vidfarm/references/automation-and-local-dev.md +22 -9
  11. package/.agents/skills/vidfarm/references/browser-harness.md +93 -0
  12. package/.agents/skills/vidfarm/references/content-ideas.md +232 -10
  13. package/.agents/skills/vidfarm/references/core-workflows.md +11 -1
  14. package/.agents/skills/vidfarm/references/editor-workflows.md +39 -0
  15. package/.agents/skills/vidfarm/references/onboarding.md +1 -1
  16. package/.agents/skills/vidfarm/references/primitives.md +51 -0
  17. package/.agents/skills/vidfarm-media/SKILL.md +2 -0
  18. package/SKILL.director.md +775 -42
  19. package/SKILL.md +157 -115
  20. package/crowdsourcing.md +417 -3
  21. package/dist/src/cli.js +750 -34
  22. package/dist/src/devcli/agent-imagegen.js +181 -0
  23. package/dist/src/devcli/browser-harness.js +384 -0
  24. package/dist/src/devcli/clip-store.js +41 -3
  25. package/dist/src/devcli/consult.js +14 -0
  26. package/dist/src/devcli/cost-mode.js +23 -3
  27. package/dist/src/devcli/doctor.js +52 -3
  28. package/dist/src/devcli/hyperframes-cli.js +11 -1
  29. package/dist/src/devcli/local-render.js +4 -7
  30. package/dist/src/devcli/marketplace-gigs.js +623 -0
  31. package/dist/src/devcli/qa-check.js +89 -1
  32. package/dist/src/devcli/shared-folder.js +387 -0
  33. package/dist/src/devcli/skill-docs.js +61 -7
  34. package/dist/src/devcli/stills.js +4 -8
  35. package/dist/src/lib/ffprobe-path.js +64 -0
  36. package/dist/src/lib/render-media-prep.js +2 -11
  37. package/dist/src/services/clip-curation/ffmpeg.js +4 -15
  38. package/dist/src/services/clip-curation/index.js +1 -1
  39. package/dist/src/services/clip-curation/local-agent.js +6 -2
  40. package/dist/src/services/clip-curation/media-select.js +146 -3
  41. package/experimental/google-news-to-video.md +235 -0
  42. package/package.json +8 -150
  43. package/public/assets/file-directory-app.js +35 -35
  44. package/public/assets/homepage-client-app.js +15 -15
  45. package/public/serve-shells/library-files.html +5 -1
  46. package/public/serve-shells/library-raws.html +10 -1
  47. package/public/serve-shells/tools-clipper.html +5 -1
  48. package/public/serve-shells/tools-image.html +5 -1
  49. package/public/serve-shells/tools-video.html +5 -1
@@ -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 || !ACTION_COPY.test(text))
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
@@ -108,7 +108,15 @@ export function readPackDoc(ref, name = DEFAULT_PACK) {
108
108
  }
109
109
  export const PACK_TOPICS = [
110
110
  { topic: "content-ideas", aliases: ["ideas", "idea", "angles", "what-to-post", "content"], doc: "references/content-ideas.md",
111
- blurb: "The 50-frame angle bank — answer \"what should I post?\" for a whole month" },
111
+ blurb: "50 frames x 5 awareness stages x 44 problem angles — answer \"what should I post?\" for a whole month" },
112
+ // The other two idea axes get their own spoken names. A director asks "what
113
+ // is awareness again?" far more often than they ask for the content-ideas
114
+ // reference by file name, and sending them the whole 290-line doc for a
115
+ // 40-line answer is how a topic index fails.
116
+ { topic: "awareness", aliases: ["awareness-stages", "ladder", "stages", "schwartz"], doc: "references/content-ideas.md", heading: "The awareness ladder",
117
+ blurb: "The 5-stage ladder — what the viewer knows, what the video must do, and what it may ask for" },
118
+ { topic: "problem-angles", aliases: ["angle", "lenses", "problem-angle"], doc: "references/content-ideas.md", heading: "The problem angles",
119
+ blurb: "44 angles on the problem — hold the frame, change the angle when a topic is \"already covered\"" },
112
120
  { topic: "meme-recaption", aliases: ["meme", "recaption", "meme-caption"], doc: "references/editor-workflows.md", heading: "Writing a meme recaption",
113
121
  blurb: "Recaption a meme at a pain or a win the niche knows — the cold-viewer test" },
114
122
  { topic: "product-explainer", aliases: ["product-explainers"], doc: "harnesses/product-explainer.HARNESS.md",
@@ -188,10 +196,13 @@ export function readPackTopic(topic, name = DEFAULT_PACK) {
188
196
  return { doc, contents, whole: true };
189
197
  return { doc, heading: section.heading, contents: section.body, whole: false };
190
198
  }
191
- export function loadIdeaBank(name = DEFAULT_PACK) {
192
- const { contents } = readPackDoc("references/content-ideas.md", name);
193
- const section = extractSection(contents, "The 50 frames");
194
- const lines = (section?.body ?? contents).split("\n");
199
+ /**
200
+ * Shared shape for both bullet banks: a bold family label on its own line,
201
+ * then `- item` (frames) or `- item — note` (angles) bullets under it.
202
+ */
203
+ function parseBulletBank(contents, heading, splitNote) {
204
+ const section = extractSection(contents, heading);
205
+ const lines = (section?.body ?? "").split("\n");
195
206
  const frames = [];
196
207
  const families = [];
197
208
  let family = "";
@@ -204,11 +215,54 @@ export function loadIdeaBank(name = DEFAULT_PACK) {
204
215
  continue;
205
216
  }
206
217
  const bullet = line.match(/^-\s+(.+?)\s*$/);
207
- if (bullet && family)
208
- frames.push({ frame: bullet[1].trim(), family });
218
+ if (!bullet || !family)
219
+ continue;
220
+ const text = bullet[1].trim();
221
+ if (!splitNote) {
222
+ frames.push({ frame: text, family });
223
+ continue;
224
+ }
225
+ const at = text.indexOf(" — ");
226
+ frames.push(at > 0
227
+ ? { frame: text.slice(0, at).trim(), family, note: text.slice(at + 3).trim() }
228
+ : { frame: text, family });
209
229
  }
210
230
  return { frames, families };
211
231
  }
232
+ export function loadIdeaBank(name = DEFAULT_PACK) {
233
+ const { contents } = readPackDoc("references/content-ideas.md", name);
234
+ const parsed = parseBulletBank(contents, "The 50 frames", false);
235
+ // Old packs predate the section slice; fall back to the whole file rather
236
+ // than handing the CLI an empty bank.
237
+ return parsed.frames.length ? parsed : parseBulletBank(`## all\n${contents}`, "all", false);
238
+ }
239
+ /**
240
+ * The problem-angle bank — the SECOND axis of an idea. A frame says what the
241
+ * video is the story of; an angle says which side of the problem it approaches
242
+ * from. Holding the frame and changing the angle is the cheapest way to answer
243
+ * "I already covered that topic", so this has to be as reachable as the frames.
244
+ */
245
+ export function loadAngleBank(name = DEFAULT_PACK) {
246
+ const { contents } = readPackDoc("references/content-ideas.md", name);
247
+ const { frames, families } = parseBulletBank(contents, "The problem angles", true);
248
+ return { angles: frames, families };
249
+ }
250
+ export function loadAwarenessLadder(name = DEFAULT_PACK) {
251
+ const { contents } = readPackDoc("references/content-ideas.md", name);
252
+ const section = extractSection(contents, "The awareness ladder");
253
+ const stages = [];
254
+ for (const line of (section?.body ?? "").split("\n")) {
255
+ const head = line.match(/^\*\*Stage\s+(\d+)\s*·\s*(.+?)\*\*\s*[—-]\s*(.+?)\s*$/);
256
+ if (head) {
257
+ stages.push({ index: Number(head[1]), stage: head[2].trim(), summary: head[3].trim(), fields: [] });
258
+ continue;
259
+ }
260
+ const field = line.match(/^-\s+\*\*(.+?):\*\*\s*(.+?)\s*$/);
261
+ if (field && stages.length)
262
+ stages[stages.length - 1].fields.push({ label: field[1].trim(), value: field[2].trim() });
263
+ }
264
+ return stages;
265
+ }
212
266
  /**
213
267
  * Grep the pack. This is the affordance that makes a local copy genuinely
214
268
  * better than the network one: "where does it say anything about greenscreen"
@@ -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
- try {
45
- const mod = (await import("ffprobe-static"));
46
- const resolved = (mod.path ?? mod.default?.path);
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
  /**