@officexapp/vidfarm-devcli 0.21.51 → 0.21.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -1144,6 +1144,9 @@ Marketplace (paid, cloud-only — the bazaar never renders locally):
1144
1144
  --file-id <id> Move a FILE; omit to move the folder (same root, e.g. /raws/demos → /raws/archive)
1145
1145
  directory copy <path> [<to-folder>] Duplicate a file/folder (shared S3 obj) → POST /api/v1/user/me/directory/copy
1146
1146
  --file-id <id> --as <name> Copy a FILE; omit --file-id to copy the folder; --as renames the copy
1147
+ directory note <path> Read/write the VECTOR NOTE of a file or FOLDER → GET|PUT /api/v1/user/me/directory/note
1148
+ --file-id <id> Annotate a FILE; omit to annotate the FOLDER at <path>
1149
+ --set "<text>" What the thing IS, in plain words — embedded, so search finds it by meaning
1147
1150
  directory save-url <url> Save a durable media URL INTO My Files at a folder → POST /api/v1/user/me/attachments/from-url
1148
1151
  --folder <path> Destination folder under /files (e.g. inpaints, promos)
1149
1152
  --as <name> Name the saved file · --notes <text> vector-embedded notes
@@ -1161,7 +1164,10 @@ Marketplace (paid, cloud-only — the bazaar never renders locally):
1161
1164
  vidfarm gigs proofs --status pending · vidfarm gigs approve PRF_01H…
1162
1165
  (see: vidfarm gigs help)
1163
1166
  shared <sub> <link> USE a link someone shared with you — NO account, NO API key
1164
- info · ls · search · mkdir · put · get e.g. vidfarm shared put <link> clip.mp4 --subfolder batch-01
1167
+ info · ls --tree · search · grab · note · mkdir · put · get
1168
+ grab is the one to reach for: it finds footage by MEANING and downloads it in one step.
1169
+ e.g. vidfarm shared grab <link> "founder talking head, kitchen" --out ./assets
1170
+ vidfarm shared put <link> cut-v2.mp4 --subfolder batch-01 --note "Final cut, 9:16"
1165
1171
  (the gigworker/agent side of directory share; see: vidfarm shared help)
1166
1172
  put-file / get-file / files / annotate-file are the My Files (persistent) set;
1167
1173
  upload is the throwaway temp store for dropping media into a composition.
@@ -2741,7 +2747,7 @@ Rules:
2741
2747
  - When swapping visuals, match both the literal scene DNA and the narrative purpose of the beat.
2742
2748
  - For replacement graphics, screenshots, or still-like scenes, prefer AI image generation plus Ken Burns before paying for AI video unless static_vs_pivot says motion footage is load-bearing.
2743
2749
  - If narration must be customized, default to premium ElevenLabs first, then the user's own ElevenLabs path, then BYOK OpenAI/Gemini/OpenRouter. If captions or scenes were timed to the old VO, retime them to the new narration.
2744
- - NO HTML SLOP. You are editing HTML, but the output is a social video, not a web page. THE TEST IS THE NATIVE-EDITOR TEST: could you have made this element with the tools inside TikTok's own editor? That toolset is a font, a color, a stroke/outline, a soft shadow, a tight text box, alignment, opacity, rotation, animation presets — plus stickers, emoji, drawn marks and clips. It has NO padded capsule, NO border, NO gradient fill, NO blur panel, NO card. If you reached past it, cut it. Never author landing-page furniture: CTA "buttons" (a filled/gradient rounded capsule with action copy like "Sign Up for a Free Trial →"), benefit chip/badge rows ("✓ No Credit Card Needed"), bordered/shadowed/frosted cards holding a headline + URL, gradient text fills, feature grids, bulleted lists, or web-default fonts (Inter/Roboto/Arial/system-ui). AND NOT A SINGLE PILL EITHER: one lonely rounded, padded, filled capsule around a static stat or label — "10 hrs / week", "STEP 2", "EP.01", "+40%" — is a web badge, and being the only one on screen does not make it native. The ONLY legitimate capsule in a video is the active-word spotlight/karaoke caption highlight, because it moves with the spoken word. Emphasize a stat the way the editor would: bigger, heavier, ALL-CAPS, an accent color, a hand-drawn circle or underline, or its own beat on screen. Rule of thumb on anything holding words: border-radius over ~8px PLUS a background fill PLUS padding = a badge; drop the fill or drop the radius until the band hugs the glyphs. None of this appears in a real TikTok, and nothing in a video is clickable — say it as timed text on the footage instead. Arrows, scribble/underline marks, italics, ALL-CAPS, single-word color pops, emoji, transparent cut-out stickers, and mock social UI (iMessage bubbles, comment cards) are all fine. Captions use an imported family (Montserrat default / TikTok Sans / Abel / Source Code Pro / Yesteryear) at weight 700-900, ~36-64px on a 1080-wide frame, inside the 8%-85% safe zone, with exactly one of four backgrounds: outline, plain, an active-word spotlight/karaoke pill, or a tight-hugging solid band (radius <=8px, no border/shadow/gradient/blur).
2750
+ - NO HTML SLOP. You are editing HTML, but the output is a social video, not a web page. THE TEST IS THE NATIVE-EDITOR TEST: could you have made this element with the tools inside TikTok's own editor? That toolset is a font, a color, a stroke/outline, a soft shadow, a tight text box, alignment, opacity, rotation, animation presets — plus stickers, emoji, drawn marks and clips. It has NO padded capsule, NO border, NO gradient fill, NO blur panel, NO card. If you reached past it, cut it. Never author landing-page furniture: CTA "buttons" (a filled/gradient rounded capsule with action copy like "Sign Up for a Free Trial →"), benefit chip/badge rows ("✓ No Credit Card Needed"), bordered/shadowed/frosted cards holding a headline + URL, gradient text fills, feature grids, bulleted lists, or web-default fonts (Inter/Roboto/Arial/system-ui). AND NOT A SINGLE PILL EITHER: one lonely rounded, padded, filled capsule around a static stat or label — "10 hrs / week", "STEP 2", "EP.01", "+40%" — is a web badge, and being the only one on screen does not make it native. The ONLY legitimate capsule in a video is the active-word spotlight/karaoke caption highlight, because it moves with the spoken word. Emphasize a stat the way the editor would: bigger, heavier, ALL-CAPS, an accent color, a hand-drawn circle or underline, or its own beat on screen. Rule of thumb on anything holding words: border-radius over ~8px PLUS a background fill PLUS padding = a badge; drop the fill or drop the radius until the band hugs the glyphs. None of this appears in a real TikTok, and nothing in a video is clickable — say it as timed text on the footage instead. Arrows, scribble/underline marks, italics, ALL-CAPS, single-word color pops, emoji, transparent cut-out stickers, and mock social UI (iMessage bubbles, comment cards) are all fine. Captions use one of the FIVE imported families and nothing else (Montserrat default / TikTok Sans / Abel / Source Code Pro / Yesteryear - the full regime, with a rendered specimen of each, is at https://vidfarm.cc/fonts) at weight 700-900, ~36-64px on a 1080-wide frame, inside the 8%-85% safe zone, with exactly one of four backgrounds: outline, plain, an active-word spotlight/karaoke pill, or a tight-hugging solid band (radius <=8px, no border/shadow/gradient/blur).
2745
2751
  - NO LAYOUT TEMPLATES — JUDGE THE WHOLE FRAME, NOT JUST THE ELEMENT. Every rule above judges one element, and a frame can pass element-by-element and still be a web page. The archetype is the MODAL: the backdrop dimmed and blurred out of focus, and floating on top of it a rounded bordered box holding a big headline, a smaller support line, and a fat CTA button. THE STACK IS THE TELL, NOT THE BOX — delete the border, the fill and the capsule, keep headline then subheadline then CTA centred in a well with even margins, and it STILL reads as a landing page, because a viewer recognizes the SHAPE before reading a single word. Banned at frame level: a modal/dialog staged on top of a backdrop that has been dimmed, blurred, greyed or scaled back (nothing in a video pops "above" the video); the hero triplet and its cousins (title + kicker + logo lockup, question + answer + URL); a full-frame dark wash used to stage a floating block (a legibility band on ONE caption is legal, a page-wide wash to stage a panel is not; likewise a blurred backdrop is fine alone — a blurred fill behind a 16:9 clip in a 9:16 frame is a real technique — but blur PLUS dimming is modal staging); nav strip / hero / three-up feature row / testimonial block / footer fine print; a blurred website screenshot used as the background plate (if the backdrop is a web page, the frame is a screen recording of a web page — show the real product UI full-bleed and in focus, or don't show it); a centred content column with even gutters and document margins. THE FIX IS ALWAYS TO UNSTACK IT INTO TIME: the headline is the hook at start:0, the support line lands on the next cut, the CTA is SPOKEN or a bare caption on the final frame. You lose nothing — a viewer reads one line at a time anyway — and you gain the pacing that makes it look shot rather than designed. Self-check before you place any text group: am I arranging words relative to EACH OTHER, or relative to the PICTURE? Relative to each other is a layout, which is web. Two on-screen text runs at once is the ceiling. Verify on real pixels: \`vidfarm stills . --at <t>\` — if the still could be a screenshot of a website, rebuild the beat. \`vidfarm qa\` catches only the mechanical half (layout-template, modal-scrim); the frame-level judgement is yours.
2746
2752
  - STRUCTURE BEFORE POLISH — THE FOUR CHARGES, WRITTEN BEFORE YOU TOUCH THE TIMELINE. Most agent-made videos fail on structure, not polish, because the timeline is the fun part so it gets built first and the words get retrofitted. Invert it: (1) HOOK — write the opening line as text first: a complete clause (subject + verb), no jargon, naming a SITUATION ("I've quit six businesses") not a label ("anonymity"); it goes on screen at start:0, because caption chunk 1 is read before any audio and muted autoplay is the default. Banned openings: throat-clearing ("so I was thinking", "here's the thing"), a logo, a title card, a fade from black, context before the claim. (2) LOOP — one open question by 0:10, said ON SCREEN, closing INSIDE this video (state the timestamp it closes at; if you can't, there is no loop), and the withheld answer must be one the viewer CANNOT supply themselves — a formally-correct loop with a guessable answer passes every mechanical check and dies in the field. (3) PAYOFF — shown, not summarized, ≥5 uninterrupted seconds, landing BEFORE the final beat; the payoff is not the CTA. (4) BAIT — one ask in the final beat and in the post caption; a keyword comment ask ("comment CLIPPER and I'll send the breakdown") is standard and allowed, but never "follow for part two", ragebait, or an earnings/health claim traded for the reply. Then build the timeline. Re-theming a decomposed template: viral_dna already names the source's hook/retention/payoff — rebuild each charge for the new subject, never flatten the loop into a product statement. Full craft harness: the vidfarm skill's references/hooks-and-virality.md. Checkable form: \`vidfarm harness show hooks\`.
2747
2753
  - ORIENT THE COLD VIEWER IN THE FIRST 3 SECONDS — THE VIEWER HAS NO CONTEXT AND DID NOT CHOOSE THIS VIDEO. Distinct from the hook: the hook makes them WANT to watch, orientation makes the watching POSSIBLE. A stranger mid-scroll must be able to answer three things by ~3s — what am I looking at (the CATEGORY noun), who is it for, and why is this on my screen (the situation). The failure is not a bad first frame, it is a good video that BEGINS AT BEAT TWO, and the author cannot see it because the author already knows what the thing is. Signatures, each a rebuild not a polish: a pronoun with no referent ("it just works", "this changes everything", "here's how they do it"); starting at step three (the process already running, the dashboard already full); a metaphor whose subject only lands at 6s; insider vocabulary, a product's own feature name, or an ACRONYM in the first line; a detail crop that reads as texture until you know the whole. Instead, the opening beat is BOTH channels at once: an EASY IMAGE (one large subject, already moving, legible at a glance and at thumbnail scale — a relevant die-cut sticker names the category before a word is read) AND an EASY LINE (first spoken sentence one clause, <=12 words, everyday words, concrete noun + verb, no subordinate clause, brand name said once plainly, and the CATEGORY named: "X is a language app that…"). Give the SITUATION, not the label — "the end of the month, and your receipts are in a shoebox" orients, "expense automation" does not. THIS IS NOT AN INTRO AND COSTS NO EXTRA SECONDS: it replaces the wind-up sentence, it never precedes it, and it never licenses a logo, a title card or a fade from black. Test it on the render, not the script: play the first 3 seconds ONLY to somebody with no context and stop — they should say what kind of thing it is and roughly who it is for. "Something about audio" is a fail. Fullest form: \`vidfarm harness show product-explainer\` (Rule 0).
@@ -11104,6 +11110,17 @@ const DIRECTORY_HELP = `vidfarm directory — browse the unified file tree (/fil
11104
11110
  e.g. vidfarm directory copy /raws/demos /raws/archive
11105
11111
  vidfarm directory copy /files/brand/logo.png /files/inbox --file-id att_123 --as logo-copy.png
11106
11112
 
11113
+ directory note <path> Read the VECTOR NOTE of a file or FOLDER → GET /api/v1/user/me/directory/note
11114
+ --file-id <id> Annotate a FILE; omit to annotate the FOLDER at <path>
11115
+ --set "<text>" Write the note ("" clears it). It is embedded, so the
11116
+ thing is findable by MEANING — a file name is not.
11117
+ --json
11118
+ e.g. vidfarm directory note /raws/AboutOffer/BRoll --set "Kitchen b-roll, no faces, 9:16"
11119
+ vidfarm directory note /files/brand/logo.png --file-id att_123
11120
+ Notes live on /files entries, /raws entries and FOLDERS in any writable root.
11121
+ Uploads self-describe when the account has an AI key saved (skipped over 100 MB);
11122
+ with no key the note is yours to type, and search falls back to keyword-only.
11123
+
11107
11124
  directory save-url <url> Save a durable media URL INTO My Files at a folder → POST /api/v1/user/me/attachments/from-url
11108
11125
  --folder <path> Destination folder under /files (e.g. inpaints, promos)
11109
11126
  --as <name> Name the saved file (else derived from the URL)
@@ -11155,6 +11172,10 @@ async function runDirectoryCommand(argv) {
11155
11172
  case "copy":
11156
11173
  case "cp":
11157
11174
  return runDirectoryCopy(rest);
11175
+ case "note":
11176
+ case "notes":
11177
+ case "annotate":
11178
+ return runDirectoryNote(rest);
11158
11179
  case "save-url":
11159
11180
  case "from-url":
11160
11181
  case "import-url":
@@ -11247,6 +11268,55 @@ async function runDirectorySearch(argv) {
11247
11268
  }
11248
11269
  printDirectorySearch(merged, query, spaces.length > 1 ? "both" : spaces[0]);
11249
11270
  }
11271
+ // Read or write the VECTOR NOTE of a file or folder. The note is the plain-text
11272
+ // answer to "what is this?", embedded so search finds it from any phrasing —
11273
+ // which is the difference between a folder of IMG_4821.mp4 and a usable library.
11274
+ async function runDirectoryNote(argv) {
11275
+ const parsed = parseArgs({
11276
+ args: argv,
11277
+ allowPositionals: true,
11278
+ options: { ...commonOptions(), "file-id": { type: "string" }, set: { type: "string" }, note: { type: "string" } }
11279
+ });
11280
+ const ctx = commonContext(parsed.values);
11281
+ const targetPath = parsed.positionals[0];
11282
+ if (!targetPath) {
11283
+ throw new Error('directory note requires a path: vidfarm directory note /raws/BRoll [--file-id <id>] [--set "what this is"]');
11284
+ }
11285
+ const fileId = parsed.values["file-id"];
11286
+ const next = parsed.values.set ?? parsed.values.note;
11287
+ const space = targetSpaces(ctx.target)[0];
11288
+ if (next == null) {
11289
+ const result = await dispatch(ctx, { method: "GET", path: "/api/v1/user/me/directory/note", query: { path: targetPath, id: fileId } }, space);
11290
+ assertApiOk(result, "directory note");
11291
+ if (ctx.json) {
11292
+ printJson(result.json ?? result.text);
11293
+ return;
11294
+ }
11295
+ const note = result.json?.note;
11296
+ console.log(`${BOLD}${targetPath}${RESET}`);
11297
+ console.log(note ? ` ${note}` : ` ${DIM}(no vector note yet)${RESET}`);
11298
+ if (!note)
11299
+ console.log(` ${DIM}Write one: vidfarm directory note ${targetPath}${fileId ? ` --file-id ${fileId}` : ""} --set "what this is"${RESET}`);
11300
+ return;
11301
+ }
11302
+ const result = await dispatch(ctx, {
11303
+ method: "PUT",
11304
+ path: "/api/v1/user/me/directory/note",
11305
+ body: { path: targetPath, ...(fileId ? { id: fileId } : {}), note: next }
11306
+ }, space);
11307
+ assertApiOk(result, "directory note");
11308
+ if (ctx.json) {
11309
+ printJson(result.json ?? result.text);
11310
+ return;
11311
+ }
11312
+ const saved = result.json?.note;
11313
+ console.log(`${GREEN}✓${RESET} ${saved ? "note saved on" : "note cleared on"} ${BOLD}${targetPath}${RESET}`);
11314
+ if (saved) {
11315
+ console.log(result.json?.embedded
11316
+ ? ` ${DIM}searchable by meaning${RESET}`
11317
+ : ` ${DIM}saved, but keyword-search only — add a gemini/openai key to embed it${RESET}`);
11318
+ }
11319
+ }
11250
11320
  async function runDirectoryRename(argv) {
11251
11321
  const parsed = parseArgs({
11252
11322
  args: argv,
@@ -11568,6 +11638,20 @@ function mergeSearch(parts, limit) {
11568
11638
  };
11569
11639
  }
11570
11640
  // Compact "12.3s · 4.2 MB · video/mp4" line for a directory file item.
11641
+ // ── vector notes in list/search output ───────────────────────────────────────
11642
+ // The note is what a human (or an agent) recognises the item by, so it prints
11643
+ // under the name; the mark makes a MISSING note visible as a gap to fill.
11644
+ function noteLine(item) {
11645
+ const note = typeof item?.note === "string" ? item.note.replace(/\s+/g, " ").trim() : "";
11646
+ if (!note)
11647
+ return "";
11648
+ return note.length > 110 ? `${note.slice(0, 109)}…` : note;
11649
+ }
11650
+ function noteMark(item) {
11651
+ if (item?.note === undefined)
11652
+ return ""; // backend carries no note field
11653
+ return item.note ? `${GREEN}✓${RESET} ` : `${DIM}✎${RESET} `;
11654
+ }
11571
11655
  function directoryFileMeta(item) {
11572
11656
  const parts = [];
11573
11657
  if (typeof item?.durationSec === "number" && item.durationSec > 0)
@@ -11598,11 +11682,17 @@ function printDirectoryListing(data, requestedPath, space) {
11598
11682
  console.log(` ${DIM}(empty)${RESET}`);
11599
11683
  }
11600
11684
  for (const f of folders) {
11601
- console.log(` ${DIM}dir ${RESET} ${originBadge(f)}${f?.name ?? ""}/ ${DIM}${f?.path ?? ""}${RESET}`);
11685
+ console.log(` ${DIM}dir ${RESET} ${noteMark(f)}${originBadge(f)}${f?.name ?? ""}/ ${DIM}${f?.path ?? ""}${RESET}`);
11686
+ const note = noteLine(f);
11687
+ if (note)
11688
+ console.log(` ${DIM}${note}${RESET}`);
11602
11689
  }
11603
11690
  for (const f of files) {
11604
11691
  const meta = directoryFileMeta(f);
11605
- console.log(` file ${originBadge(f)}${f?.name ?? ""}${meta ? ` ${DIM}${meta}${RESET}` : ""} ${DIM}${f?.path ?? ""}${RESET}`);
11692
+ console.log(` file ${noteMark(f)}${originBadge(f)}${f?.name ?? ""}${meta ? ` ${DIM}${meta}${RESET}` : ""} ${DIM}${f?.path ?? ""}${RESET}`);
11693
+ const note = noteLine(f);
11694
+ if (note)
11695
+ console.log(` ${DIM}${note}${RESET}`);
11606
11696
  if (f?.viewUrl)
11607
11697
  console.log(` ${FRONTEND}${f.viewUrl}${RESET}`);
11608
11698
  }
@@ -11623,7 +11713,10 @@ function printDirectorySearch(data, query, space) {
11623
11713
  results.forEach((r, i) => {
11624
11714
  const score = typeof r?.score === "number" ? ` ${DIM}score=${r.score.toFixed(3)}${RESET}` : "";
11625
11715
  const kind = r?.kind === "folder" ? "dir " : "file";
11626
- console.log(` ${String(i + 1).padStart(2)}. ${kind} ${originBadge(r)}${r?.path ?? r?.name ?? ""}${score}`);
11716
+ console.log(` ${String(i + 1).padStart(2)}. ${kind} ${noteMark(r)}${originBadge(r)}${r?.path ?? r?.name ?? ""}${score}`);
11717
+ const note = noteLine(r);
11718
+ if (note)
11719
+ console.log(` ${DIM}${note}${RESET}`);
11627
11720
  const meta = directoryFileMeta(r);
11628
11721
  if (meta)
11629
11722
  console.log(` ${DIM}${meta}${RESET}`);
@@ -11703,6 +11796,14 @@ async function runGetFileCommand(argv) {
11703
11796
  // Content can come from a local file (positional), inline --content, or piped stdin.
11704
11797
  // It is the write counterpart to `files`/`get-file` and the same store the /editor
11705
11798
  // AI copilot writes to via the browse_files write action.
11799
+ //
11800
+ // TRANSPORT: presign → PUT direct to S3 → finalize, exactly like `upload` and
11801
+ // `approve --video`. The multipart POST to /me/attachments/upload is only a
11802
+ // FALLBACK (local-storage `vidfarm serve` boxes, or an older server with no
11803
+ // presign route) because that path proxies the bytes through the API Lambda,
11804
+ // whose request body caps near 6 MB — a 9 MB multipart POST answers 413 before
11805
+ // the handler runs. The presigned PUT never touches Lambda, so it carries the
11806
+ // full 200 MB My Files ceiling.
11706
11807
  async function runPutFileCommand(argv) {
11707
11808
  const parsed = parseArgs({
11708
11809
  args: argv,
@@ -11746,24 +11847,90 @@ async function runPutFileCommand(argv) {
11746
11847
  throw new Error("put-file could not determine a file name. Pass --as <name>.");
11747
11848
  const contentType = guessContentType(fileName);
11748
11849
  const uploadPath = "/api/v1/user/me/attachments/upload";
11850
+ const presignPath = "/api/v1/user/me/attachments/presign";
11851
+ const finalizePath = "/api/v1/user/me/attachments";
11852
+ const folder = parsed.values.folder ? String(parsed.values.folder) : undefined;
11853
+ const notes = parsed.values.notes ? String(parsed.values.notes) : undefined;
11749
11854
  for (const space of targetSpaces(ctx.target)) {
11855
+ // One request shape for both spaces: the local backend answers in-process,
11856
+ // the cloud over HTTP. The presigned PUT itself always goes over the wire.
11857
+ const call = async (apiPath, init) => {
11858
+ if (space === "local") {
11859
+ const { withLocalBackend } = await import("./devcli/local-backend.js");
11860
+ const backend = await withLocalBackend({ home: ctx.home, apiKey: ctx.auth.apiKey });
11861
+ return backend.app.request(apiPath, init);
11862
+ }
11863
+ return fetch(new URL(apiPath, ctx.host), init);
11864
+ };
11865
+ // Step 1: presign. An S3-backed box answers transport:"presigned".
11866
+ let presignJson = null;
11867
+ let presignStatus = 0;
11868
+ try {
11869
+ const presign = await call(presignPath, {
11870
+ method: "POST",
11871
+ headers: { ...buildAuthHeaders(ctx.auth), "content-type": "application/json" },
11872
+ body: JSON.stringify({ file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength, folder_path: folder })
11873
+ });
11874
+ presignStatus = presign.status;
11875
+ presignJson = await presign.json().catch(() => null);
11876
+ if (!presign.ok)
11877
+ presignJson = null;
11878
+ }
11879
+ catch {
11880
+ presignJson = null;
11881
+ }
11882
+ if (presignJson?.transport === "presigned" && presignJson?.upload?.url) {
11883
+ // Step 2a: bytes go straight to storage — no Lambda body limit.
11884
+ const put = await fetch(presignJson.upload.url, {
11885
+ method: presignJson.upload.method || "PUT",
11886
+ headers: presignJson.upload.headers || {},
11887
+ body: new Uint8Array(buffer)
11888
+ });
11889
+ if (!put.ok)
11890
+ throw new Error(`put-file (${space}): storage PUT failed with HTTP ${put.status}.`);
11891
+ // Step 3: finalize — record the attachment now that the bytes landed.
11892
+ const finalize = await call(finalizePath, {
11893
+ method: "POST",
11894
+ headers: { ...buildAuthHeaders(ctx.auth), "content-type": "application/json" },
11895
+ body: JSON.stringify({
11896
+ attachment_id: presignJson.attachment_id,
11897
+ file_name: presignJson.file_name || fileName,
11898
+ content_type: presignJson.content_type || contentType,
11899
+ size_bytes: buffer.byteLength,
11900
+ storage_key: presignJson.storage_key,
11901
+ folder_path: presignJson.folder_path,
11902
+ notes
11903
+ })
11904
+ });
11905
+ const finalizeText = await finalize.text();
11906
+ let finalizeJson = null;
11907
+ try {
11908
+ finalizeJson = finalizeText ? JSON.parse(finalizeText) : null;
11909
+ }
11910
+ catch {
11911
+ finalizeJson = null;
11912
+ }
11913
+ const finalizeResult = { status: finalize.status, ok: finalize.ok, json: finalizeJson, text: finalizeText };
11914
+ assertApiOk(finalizeResult, `put-file finalize (${space})`);
11915
+ emitResult(finalizeResult, ctx.json, [[`My Files [${space}]`, finalizeResult.json?.attachment?.viewUrl]]);
11916
+ continue;
11917
+ }
11918
+ // Step 2b (fallback): no presigned transport (local storage driver) or no
11919
+ // presign route at all. Multipart through the API — capped near 6 MB in the
11920
+ // cloud, so refuse a big file here instead of eating a confusing 413.
11921
+ if (space !== "local" && buffer.byteLength > 6 * 1024 * 1024) {
11922
+ throw new Error(`put-file (${space}): presign unavailable (HTTP ${presignStatus || 0}) and ${formatBytes(buffer.byteLength)} exceeds the ~6 MB multipart limit. ` +
11923
+ `Upgrade the server or retry when /me/attachments/presign is reachable.`);
11924
+ }
11750
11925
  // FormData is single-use (its stream is consumed), so build a fresh one per
11751
11926
  // space when writing to both.
11752
11927
  const form = new FormData();
11753
11928
  form.append("file", new Blob([new Uint8Array(buffer)], contentType ? { type: contentType } : undefined), fileName);
11754
- if (parsed.values.folder)
11755
- form.append("folder_path", String(parsed.values.folder));
11756
- if (parsed.values.notes)
11757
- form.append("notes", String(parsed.values.notes));
11758
- let res;
11759
- if (space === "local") {
11760
- const { withLocalBackend } = await import("./devcli/local-backend.js");
11761
- const backend = await withLocalBackend({ home: ctx.home, apiKey: ctx.auth.apiKey });
11762
- res = await backend.app.request(uploadPath, { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
11763
- }
11764
- else {
11765
- res = await fetch(new URL(uploadPath, ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
11766
- }
11929
+ if (folder)
11930
+ form.append("folder_path", folder);
11931
+ if (notes)
11932
+ form.append("notes", notes);
11933
+ const res = await call(uploadPath, { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
11767
11934
  const text = await res.text();
11768
11935
  let json = null;
11769
11936
  try {
@@ -215,10 +215,50 @@ async function listMachines(auth) {
215
215
  title: String(gig.title ?? gig.id ?? ""),
216
216
  gigId: String(gig.id ?? ""),
217
217
  distribution: gig.distribution ? String(gig.distribution) : undefined,
218
- inviteUrl: typeof gig.invite_url === "string" ? gig.invite_url : null
218
+ // NOT the gig record's `invite_url` that one carries no `?invite=`
219
+ // token, and an invite-only gig refuses a tokenless join. See
220
+ // resolveInviteUrl(): the real link lives on the invite itself.
221
+ inviteUrl: null
219
222
  };
220
223
  }).filter((m) => m.gigId);
221
224
  }
225
+ /** True only for a join URL that carries an `?invite=` token. */
226
+ function hasInviteToken(url) {
227
+ if (typeof url !== "string" || !url)
228
+ return false;
229
+ try {
230
+ return Boolean(new URL(url).searchParams.get("invite"));
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ }
236
+ /**
237
+ * The machine's REUSABLE, tokened join link — the only link a gigworker can
238
+ * actually join through, and therefore the only valid `destination_url` for a
239
+ * bell. Reads the gig's invites (each gig ships an unlimited "default" one) and
240
+ * mints a fresh unlimited invite when none survives.
241
+ */
242
+ async function resolveInviteUrl(auth, gigId) {
243
+ const pick = (invite) => {
244
+ if (hasInviteToken(invite?.invite_url))
245
+ return invite.invite_url;
246
+ if (invite?.token)
247
+ return `https://dollarplatoon.com/gig/${encodeURIComponent(gigId)}/join?invite=${encodeURIComponent(String(invite.token))}`;
248
+ return "";
249
+ };
250
+ const listed = await dp(auth, `/gigs/${gigId}/invites`).catch(() => ({}));
251
+ const invites = Array.isArray(listed.invites) ? listed.invites : [];
252
+ const reusable = invites.filter((i) => !i.revoked && !i.exhausted && !i.email && (i.max_uses === null || i.max_uses === undefined));
253
+ const chosen = reusable.find((i) => i.label === "default") ?? reusable[0];
254
+ if (chosen && pick(chosen))
255
+ return pick(chosen);
256
+ const minted = await dp(auth, `/gigs/${gigId}/invites`, {
257
+ method: "POST",
258
+ body: { max_uses: null, label: "vidfarm" }
259
+ }).catch(() => ({}));
260
+ return minted.invite ? pick(minted.invite) : "";
261
+ }
222
262
  /** A gig id, a machine slug, or (default) the machine this command is about. */
223
263
  async function resolveGigId(auth, raw, fallbackSlug) {
224
264
  const value = (raw ?? "").trim();
@@ -258,9 +298,14 @@ function proofLink(proof) {
258
298
  // ── client commands ──────────────────────────────────────────────────────────
259
299
  async function cmdMachines(auth, values) {
260
300
  const machines = await listMachines(auth);
301
+ // Each machine's shareable link is its reusable INVITE, not the tokenless
302
+ // join URL the gig record carries — hand this one to a gigworker.
303
+ await Promise.all(machines.map(async (machine) => {
304
+ machine.inviteUrl = (await resolveInviteUrl(auth, machine.gigId).catch(() => "")) || null;
305
+ }));
261
306
  out(Boolean(values.json), { machines }, () => {
262
307
  if (!machines.length) {
263
- console.log(`${DIM}No vending machines on this key yet. A paid vidfarm account gets two; open ${RESET}https://vidfarm.cc/marketplace/buyer${DIM} once to create them.${RESET}`);
308
+ console.log(`${DIM}No vending machines on this key yet. A paid vidfarm account gets two; open ${RESET}https://vidfarm.cc/marketplace${DIM} once to create them.${RESET}`);
264
309
  return;
265
310
  }
266
311
  for (const machine of machines) {
@@ -403,17 +448,20 @@ async function cmdRingBell(auth, values) {
403
448
  const machine = machines.find((m) => m.slug === slug);
404
449
  if (!machine)
405
450
  throw new Error(`No "${slug}" machine on this account. Run: vidfarm gigs machines`);
406
- let destination = machine.inviteUrl ?? "";
407
- if (!destination) {
408
- // Registering the gig in the feed returns its reusable join link.
451
+ // The bell is only as good as its link: an agent that taps a tokenless
452
+ // ".../join" URL is refused at the door, so the destination is always the
453
+ // machine's reusable INVITE link.
454
+ let destination = await resolveInviteUrl(auth, machine.gigId);
455
+ if (!hasInviteToken(destination)) {
456
+ // Registering the gig in the feed returns its reusable join link too.
409
457
  const registered = await dp(auth, `/feeds/${VIDFARM_FEED_ID}/registry`, {
410
458
  method: "POST",
411
459
  body: { gig_id: machine.gigId, tags: ["vidfarm", "video", slug] }
412
460
  }).catch(() => ({}));
413
- destination = registered.invite_url ?? "";
461
+ destination = hasInviteToken(registered.invite_url) ? registered.invite_url : "";
414
462
  }
415
463
  if (!destination)
416
- throw new Error("That machine has no invite link yet — open it once on vidfarm.cc/marketplace/buyer.");
464
+ throw new Error("That machine has no invite link yet — open it once on vidfarm.cc/marketplace.");
417
465
  const result = await dp(auth, `/feeds/${VIDFARM_FEED_ID}/notifications`, {
418
466
  method: "POST",
419
467
  body: {
@@ -655,7 +655,7 @@ export function qaCompositionHtml(html) {
655
655
  ? `Text layer in "${family}" — a website body font. This alone makes a frame read as a screenshot of a web page.`
656
656
  : `Text layer in "${family}", outside the composition's imported font regime — it will silently fall back at render.`,
657
657
  where: label(node, "text layer"),
658
- fix: `Use an imported display family: Montserrat (default), TikTok Sans, Abel, Source Code Pro, or Yesteryear — e.g. \`vidfarm set-style <dir> --layer <key> --font-family Montserrat\`. Local renders auto-coerce this, but the editor preview will not match until you fix it.`
658
+ fix: `Use an imported display family: Montserrat (default), TikTok Sans, Abel, Source Code Pro, or Yesteryear — e.g. \`vidfarm set-style <dir> --layer <key> --font-family Montserrat\`. Local renders auto-coerce this, but the editor preview will not match until you fix it. The five families, each rendered as a real caption: https://vidfarm.cc/fonts`
659
659
  });
660
660
  }
661
661
  // Weight: the TikTok caption look is heavy. Light weights are a legitimate