@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/.agents/skills/vidfarm/SKILL.md +5 -4
- package/.agents/skills/vidfarm/harnesses/hooks.HARNESS.md +10 -3
- package/.agents/skills/vidfarm/harnesses/product-explainer.HARNESS.md +1 -1
- package/.agents/skills/vidfarm/harnesses/short-form.HARNESS.md +2 -0
- package/.agents/skills/vidfarm/references/assets-and-sourcing.md +89 -5
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +2 -2
- package/.agents/skills/vidfarm/references/content-ideas.md +72 -1
- package/.agents/skills/vidfarm/references/core-workflows.md +16 -3
- package/.agents/skills/vidfarm/references/editor-workflows.md +32 -8
- package/.agents/skills/vidfarm/references/rest-api.md +1 -1
- package/SKILL.director.md +219 -32
- package/SKILL.md +6 -2
- package/clipper.md +22 -0
- package/dist/src/cli.js +185 -18
- package/dist/src/devcli/marketplace-gigs.js +55 -7
- package/dist/src/devcli/qa-check.js +1 -1
- package/dist/src/devcli/shared-folder.js +333 -44
- package/dist/src/devcli/skill-docs.js +2 -2
- package/experimental/meme-recaption.md +1756 -0
- package/marketplace.md +99 -11
- package/package.json +5 -1
- package/public/assets/file-directory-app.js +33 -33
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 ·
|
|
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
|
|
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 (
|
|
11755
|
-
form.append("folder_path",
|
|
11756
|
-
if (
|
|
11757
|
-
form.append("notes",
|
|
11758
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
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
|