@officexapp/vidfarm-devcli 0.21.30 → 0.21.32
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 +3 -0
- package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-regime.md +12 -0
- package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +18 -4
- package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +2 -1
- package/.agents/skills/vidfarm/references/assets-and-sourcing.md +17 -0
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +17 -4
- package/.agents/skills/vidfarm/references/core-workflows.md +62 -5
- package/.agents/skills/vidfarm/references/editor-workflows.md +1 -1
- package/.agents/skills/vidfarm/references/primitives.md +155 -33
- package/SKILL.director.md +287 -48
- package/SKILL.md +4 -1
- package/dist/src/cli.js +678 -14
- package/dist/src/devcli/dedupe-local.js +209 -0
- package/dist/src/devcli/handoff.js +6 -2
- package/dist/src/devcli/sticker-pack.js +196 -2
- package/dist/src/lib/dedupe-recipe.js +420 -0
- package/package.json +5 -1
package/dist/src/cli.js
CHANGED
|
@@ -22,7 +22,8 @@ import { renderCompositionStills } from "./devcli/stills.js";
|
|
|
22
22
|
import { extractCompositionFacts, formatQaReport, qaCompositionHtml } from "./devcli/qa-check.js";
|
|
23
23
|
import { discoverRegime, formatRegimeReport, listBuiltinRegimes, loadAndEvaluateRegime, mergeRegimeIntoReport, parseRegime, resolveRegimePath } from "./devcli/qa-regime.js";
|
|
24
24
|
import { removeGreenscreenLocal, localGreenscreenAvailable, defaultGreenscreenOutPath, GREENSCREEN_PRESETS, trimTransparentBorders, cropImageRegion } from "./devcli/greenscreen-local.js";
|
|
25
|
-
import {
|
|
25
|
+
import { dedupeMediaLocal, localDedupeAvailable, defaultDedupeOutPath, describeLocalDedupe, DEDUPE_PRESETS, DEDUPE_DEFAULT_PRESET, isDedupePresetName } from "./devcli/dedupe-local.js";
|
|
26
|
+
import { segmentAlphaComponents, encodeTransparentGif, encodeTransparentAnimatedGif, pickPlateColor, detectPlateColor, keySafeArtInstruction, analyzeKeyedArt, HOLE_WARN_PCT } from "./devcli/sticker-pack.js";
|
|
26
27
|
import { runDoctorCommand } from "./devcli/doctor.js";
|
|
27
28
|
import { findFreePort } from "./devcli/port-utils.js";
|
|
28
29
|
import { scanLocalServers } from "./devcli/process-scan.js";
|
|
@@ -329,6 +330,38 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
329
330
|
--cloud Force the billed cloud primitive
|
|
330
331
|
--out <file> Write the transparent result to this path (local writes
|
|
331
332
|
<source>.transparent.png/.webm next to it by default)
|
|
333
|
+
dedupe <video|image|url> DEDUPLICATE a finished render so a re-post reads as a
|
|
334
|
+
NEW upload instead of tripping a platform's duplicate-
|
|
335
|
+
content check. Invisible to a viewer. Runs on the
|
|
336
|
+
EXPORTED file — no second render.
|
|
337
|
+
(aliases: dedup, deduplicate, uniquify)
|
|
338
|
+
→ POST /api/v1/primitives/media/dedupe
|
|
339
|
+
--preset <name> none | light | standard | strong | legacy
|
|
340
|
+
standard (default) = skew 2%, zoom 3%, rotate 2°,
|
|
341
|
+
speed +2%, saturation +4%, +contrast/brightness/hue/grain
|
|
342
|
+
--variants <n> Mint N mutually-distinct copies in one pass (variant 1
|
|
343
|
+
is the preset as authored; later variants get jittered
|
|
344
|
+
magnitudes and flipped signs). One per account/slot.
|
|
345
|
+
--variant <n> Start numbering here (resume a batch); --seed <s> for
|
|
346
|
+
extra entropy so two batches of one source diverge
|
|
347
|
+
--no-jitter Apply the preset verbatim to every variant
|
|
348
|
+
--zoom/--rotate/--skew/--tilt/--speed/--saturation/--contrast/--brightness/
|
|
349
|
+
--hue/--blur/--noise/--volume Override any single knob (beats the preset)
|
|
350
|
+
--flip Mirror horizontally. Strongest single knob, but it
|
|
351
|
+
visibly reverses on-screen text — opt in deliberately.
|
|
352
|
+
--effects-json '{...}' Set several knobs at once
|
|
353
|
+
--tint-color/--tint-opacity Flat color wash over the frame (default off)
|
|
354
|
+
--width/--height Force an output size (default: keep the source's)
|
|
355
|
+
--crf <n> Base x264 quality; jittered ±1 per variant
|
|
356
|
+
--keep-metadata Don't strip container metadata (creation time,
|
|
357
|
+
encoder, source handler) — stripping is the default
|
|
358
|
+
--media-type <k> Force image|video instead of auto-detecting
|
|
359
|
+
--output-format <f> Image only: png|jpeg|webp
|
|
360
|
+
--local Dedupe on your machine with bundled ffmpeg for FREE
|
|
361
|
+
(no wallet, no account; cloud-parity) — DEFAULT
|
|
362
|
+
--cloud Force the billed cloud primitive
|
|
363
|
+
--out <file> Single-variant output path (default <source>.dedupe.<ext>)
|
|
364
|
+
--out-dir <dir> Where to write batch variants
|
|
332
365
|
cutout <image|url> Make a transparent explainer STICKER: key out the flat
|
|
333
366
|
plate, then trim the canvas down to the cutout's true
|
|
334
367
|
min width/height. Local, free, ffmpeg-only. Image-only.
|
|
@@ -351,7 +384,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
351
384
|
transparent file (+ stickers.json). One image job for
|
|
352
385
|
the set = one art style, ~1/N the cost of N cutouts.
|
|
353
386
|
Items may be any size, from an icon to a full-frame
|
|
354
|
-
landscape.
|
|
387
|
+
landscape. Prompts for key-safe art (solid fills, no
|
|
388
|
+
outline-only/hollow shapes, nothing in a near-plate
|
|
389
|
+
shade) and flags any item that came out hollow.
|
|
390
|
+
Local, free, ffmpeg-only. Image-only.
|
|
355
391
|
(aliases: stickers, sticker-sheet)
|
|
356
392
|
--generate "<theme>" AI-generate the sheet first (ONE billed image job for
|
|
357
393
|
the WHOLE pack) on a spaced grid over a chroma plate
|
|
@@ -737,8 +773,16 @@ Approve & schedule (publish a finished MP4 as a shareable post):
|
|
|
737
773
|
(add --temp only for a disposable 30-day preview.)
|
|
738
774
|
posts List your approved posts → GET /api/v1/approved/posts
|
|
739
775
|
post <postId> Read one approved post (prints share URL) → GET /api/v1/approved/posts/:postId
|
|
776
|
+
channels List destinations you can schedule to → GET /api/v1/user/me/channels
|
|
740
777
|
schedule <postId> Schedule an approved post to a channel → POST /api/v1/approved/posts/:postId/schedules
|
|
778
|
+
--at <iso> When to send (ISO 8601, min 10 min from now)
|
|
779
|
+
--to <destination> Channel id, email address, or @handle — all resolve
|
|
780
|
+
server-side, so 'vidfarm channels' is optional
|
|
781
|
+
--type email|flockposter Destination kind (default flockposter)
|
|
741
782
|
schedules <postId> Browse an approved post's schedules → GET /api/v1/approved/posts/:postId/schedules
|
|
783
|
+
reschedule <postId> <scheduleId> Move a queued send → PATCH /api/v1/approved/posts/:postId/schedules/:scheduleId
|
|
784
|
+
unschedule <postId> <scheduleId> Cancel a queued send → DELETE /api/v1/approved/posts/:postId/schedules/:scheduleId
|
|
785
|
+
An error here means the send was NOT stopped.
|
|
742
786
|
|
|
743
787
|
Account:
|
|
744
788
|
login <email> Send an OTP code to email → POST /api/v1/user/request-otp
|
|
@@ -808,6 +852,19 @@ Marketplace (paid, cloud-only — the bazaar never renders locally):
|
|
|
808
852
|
--content-type <a,b> With --search: filter the /raws branch by shot-KIND tag
|
|
809
853
|
(talking_head,b_roll,product_shot,screen_recording,demo,
|
|
810
854
|
reaction,interview,establishing,lifestyle,text_graphic)
|
|
855
|
+
recycle <source> PAID. RECYCLE a Reddit or X/Twitter source into reusable JSON —
|
|
856
|
+
"tweet to tiktok" / "reddit to tiktok". Accepts a Reddit thread URL
|
|
857
|
+
(post + comments), a subreddit (r/name or URL -> its threads), an X
|
|
858
|
+
thread URL (tweet + replies), or an X profile (@handle or URL -> their
|
|
859
|
+
posts). Returns items[] with text, author + avatar, stats, permalinks
|
|
860
|
+
and every image/video media URL - unranked and unsummarized, so YOU
|
|
861
|
+
pick what to remix. Brokered through the reddit-lead-gen / x-lead-gen
|
|
862
|
+
OfficeX apps, billed on the credits they consume.
|
|
863
|
+
Flags: --max-records N (spend ceiling, default 100) --cursor <token>
|
|
864
|
+
--query <text> --filter hot|new|top --mode <mode> --network <net>
|
|
865
|
+
--no-comments --no-profile --out ./recycled.json --no-wait
|
|
866
|
+
Aliases: recycle-social, tweet-to-video, reddit-to-video
|
|
867
|
+
-> POST /api/v1/primitives/social/recycle
|
|
811
868
|
get-file <id> [dest] Resolve a My Files id to its URL and download it
|
|
812
869
|
--print Print text contents (md/txt/csv/json) instead of saving
|
|
813
870
|
annotate-file <id|name> Set metadata notes on one My Files entry → PATCH /api/v1/user/me/attachments/:id
|
|
@@ -860,7 +917,8 @@ Cost spectrum (default to the cheapest approach that works; see SKILL.director.m
|
|
|
860
917
|
command (generate, music, decompose, cloud render/TTS/STT/greenscreen, create, replicate)
|
|
861
918
|
respects it: minimize refuses billed spend without --yes and points you at the free
|
|
862
919
|
local path; the others run but print each op's cost. FREE local engines never gate
|
|
863
|
-
(local render, tts --engine local, stt --engine whisper, remove-greenscreen --local
|
|
920
|
+
(local render, tts --engine local, stt --engine whisper, remove-greenscreen --local,
|
|
921
|
+
dedupe --local).
|
|
864
922
|
In minimize AND hybrid, 'vidfarm tts' DEFAULTS to the free local Kokoro voice — ask for
|
|
865
923
|
a premium voice (--style/--voice/--provider/--own-key/--cloud), or be in rich-ai /
|
|
866
924
|
pure-videogen, to opt out.
|
|
@@ -1036,6 +1094,12 @@ async function main() {
|
|
|
1036
1094
|
case "greenscreen":
|
|
1037
1095
|
await runRemoveGreenscreenCommand(rest);
|
|
1038
1096
|
return;
|
|
1097
|
+
case "dedupe":
|
|
1098
|
+
case "dedup":
|
|
1099
|
+
case "deduplicate":
|
|
1100
|
+
case "uniquify":
|
|
1101
|
+
await runDedupeCommand(rest);
|
|
1102
|
+
return;
|
|
1039
1103
|
case "cutout":
|
|
1040
1104
|
case "sticker":
|
|
1041
1105
|
await runCutoutCommand(rest);
|
|
@@ -1118,12 +1182,21 @@ async function main() {
|
|
|
1118
1182
|
case "post":
|
|
1119
1183
|
await runPostCommand(rest);
|
|
1120
1184
|
return;
|
|
1185
|
+
case "channels":
|
|
1186
|
+
await runChannelsCommand(rest);
|
|
1187
|
+
return;
|
|
1121
1188
|
case "schedule":
|
|
1122
1189
|
await runScheduleCommand(rest);
|
|
1123
1190
|
return;
|
|
1124
1191
|
case "schedules":
|
|
1125
1192
|
await runSchedulesCommand(rest);
|
|
1126
1193
|
return;
|
|
1194
|
+
case "reschedule":
|
|
1195
|
+
await runRescheduleCommand(rest);
|
|
1196
|
+
return;
|
|
1197
|
+
case "unschedule":
|
|
1198
|
+
await runUnscheduleCommand(rest);
|
|
1199
|
+
return;
|
|
1127
1200
|
case "login":
|
|
1128
1201
|
await runLoginCommand(rest);
|
|
1129
1202
|
return;
|
|
@@ -1179,6 +1252,13 @@ async function main() {
|
|
|
1179
1252
|
case "download-audio":
|
|
1180
1253
|
await runDownloadPostAudioCommand(rest);
|
|
1181
1254
|
return;
|
|
1255
|
+
// "tweet to tiktok" / "reddit to tiktok": decompose a social source to JSON.
|
|
1256
|
+
case "recycle-social":
|
|
1257
|
+
case "tweet-to-video":
|
|
1258
|
+
case "reddit-to-video":
|
|
1259
|
+
case "recycle":
|
|
1260
|
+
await runRecycleCommand(rest);
|
|
1261
|
+
return;
|
|
1182
1262
|
case "files":
|
|
1183
1263
|
await runFilesCommand(rest);
|
|
1184
1264
|
return;
|
|
@@ -2311,6 +2391,7 @@ Rules:
|
|
|
2311
2391
|
- 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; never a DM funnel, "follow for part two", or ragebait. 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 regime show hooks\`.
|
|
2312
2392
|
- THE FIRST FRAME IS THE THUMBNAIL. Frame 0 is one frame of ~30 in the first second, but every feed card, share link, and paused player freezes on it — more people see that frame than watch the video. It must never be black, empty, mid-fade, or mid-animation: a real visual at start:0 (\`vidfarm retime . --layer <key> --start 0\`), the hook words already on screen at t=0, and NO entrance transition on the FIRST clip (\`vidfarm transitions set . --layer <key> --in none\`; junction transitions between later clips are fine). Look at the actual pixels before you render: \`vidfarm stills . --at 0\`.
|
|
2313
2393
|
- ONE-TIME OR BULK? Ask before you build. If the director wants volume (daily posting, N variants, hook tests), that's SCRIPTING MODE: pin this fork as the base, vary exactly ONE thing per variant, and install a QA_REGIME.md — \`vidfarm regime init short-form --out ./QA_REGIME.md\` (bases: short-form, hooks, ugc-testimonial, explainer, product-demo), then EDIT it with them. It is their own written quality standard, and it exists because nobody watches variant #37 as carefully as #1. \`vidfarm qa .\` picks up ./QA_REGIME.md automatically; \`--regime <name|path>\` adds more (they stack, and any file of theirs anywhere is valid). Its \`checks:\` are machine-settled; its \`- [ ]\` items come back for YOU to answer honestly in your report — never claim a pass on the half the CLI can't judge. When a batch teaches you something, write it back into the regime.
|
|
2394
|
+
- DEDUPLICATE BEFORE YOU PUBLISH — AND ASK FIRST. Social platforms fingerprint every upload, so the same render posted twice (a second account, another platform, a re-post next month) gets the later copy suppressed as duplicate/reused content. BEFORE you render for publication, and before any bulk run, ASK the director: "do you want deduplicated copies for posting, and how many?" Ask THEN, not after — dedupe is a post-render ffmpeg pass, so the correct order is RENDER ONCE → DEDUPE N, and deciding late means paying for a second render. Run it on the EXPORTED file: \`vidfarm dedupe ./final.mp4\` (one copy) or \`vidfarm dedupe ./final.mp4 --variants N --seed <slug> --out-dir ./posts\` (N copies, one per account/slot). Free, offline, no wallet — it never re-renders the composition. The default \`standard\` preset is skew 2%, zoom 3%, rotate 2°, speed +2%, saturation +4%, plus contrast/brightness/hue/grain, a container-metadata strip and a per-variant CRF walk; invisible to a viewer, and each variant differs from the original AND from its siblings. Post each variant to a DIFFERENT account — two accounts posting the same variant defeats the point. A rotate forces a bigger centre-crop to hide the black corners (~6.7% on a tall frame at 2°) and the CLI says so; pass \`--rotate 0\` when framing matters more. Cloud twin: \`POST /api/v1/primitives/media/dedupe\`.
|
|
2314
2395
|
- QA EVERY VIDEO BEFORE YOU RENDER: run \`vidfarm qa .\` in this directory. It's free, instant, and local — a blocklist for the slop above plus the first frame, the font regime, and the safe zone, with a concrete fix per finding. It's feedback, not a gate (exits 0 even on findings, never runs automatically) and a blocklist, not an allowlist, so stylized or hand-made work passes untouched. Fix what's real, ignore what's a deliberate style call. \`--json\` for scripted batches.
|
|
2315
2396
|
|
|
2316
2397
|
The three paintbrushes (Vidfarm is thrift-first — do NOT spend AI credits on every scene):
|
|
@@ -5282,6 +5363,354 @@ async function runLocalGreenscreen(ctx, values, sourceArg, presetRaw) {
|
|
|
5282
5363
|
rmSync(downloadDir, { recursive: true, force: true });
|
|
5283
5364
|
}
|
|
5284
5365
|
}
|
|
5366
|
+
// ── dedupe: make a re-post read as a NEW upload ──────────────────────────────
|
|
5367
|
+
// Social platforms fingerprint every upload. Posting the same render twice —
|
|
5368
|
+
// across accounts, or again next month — gets the later copy suppressed as
|
|
5369
|
+
// duplicate/reused content. `vidfarm dedupe` applies a calibrated, invisible
|
|
5370
|
+
// perturbation (skew, zoom, rotate, speed, saturation/contrast/brightness/hue,
|
|
5371
|
+
// grain, optional tint/mirror) plus a metadata strip and a per-variant CRF walk,
|
|
5372
|
+
// so each copy carries a distinct fingerprint.
|
|
5373
|
+
//
|
|
5374
|
+
// LOCAL by default: it is a pure ffmpeg pass, so it is FREE and offline and does
|
|
5375
|
+
// NOT require re-rendering the composition — dedupe the finished MP4 you already
|
|
5376
|
+
// paid to render instead of burning a second render.
|
|
5377
|
+
//
|
|
5378
|
+
// `--variants N` mints N mutually-distinct copies in one go (variant 1 is the
|
|
5379
|
+
// preset as authored; later variants get jittered magnitudes and flipped signs),
|
|
5380
|
+
// which is the shape bulk posting actually needs.
|
|
5381
|
+
function resolveDedupeTarget(values) {
|
|
5382
|
+
if (values.local)
|
|
5383
|
+
return "local";
|
|
5384
|
+
if (values.cloud)
|
|
5385
|
+
return "cloud";
|
|
5386
|
+
const env = (process.env.VIDFARM_TARGET ?? "").trim().toLowerCase();
|
|
5387
|
+
if (env === "local" || env === "cloud")
|
|
5388
|
+
return env;
|
|
5389
|
+
// The local ffmpeg pass is cloud-parity and free — prefer it (the runner
|
|
5390
|
+
// downgrades to cloud if ffmpeg turns out to be unavailable at run time).
|
|
5391
|
+
return "local";
|
|
5392
|
+
}
|
|
5393
|
+
// Per-knob flags, so any effect is overridable without hand-writing JSON.
|
|
5394
|
+
function collectDedupeEffectOverrides(values) {
|
|
5395
|
+
const effects = {};
|
|
5396
|
+
const numeric = [
|
|
5397
|
+
["zoom", "zoom"],
|
|
5398
|
+
["rotate", "rotate"],
|
|
5399
|
+
["skew", "skew"],
|
|
5400
|
+
["tilt", "tilt"],
|
|
5401
|
+
["speed", "speed"],
|
|
5402
|
+
["saturation", "saturation"],
|
|
5403
|
+
["contrast", "contrast"],
|
|
5404
|
+
["brightness", "brightness"],
|
|
5405
|
+
["hue", "hue_rotate"],
|
|
5406
|
+
["blur", "blur"],
|
|
5407
|
+
["noise", "noise"],
|
|
5408
|
+
["volume", "volume"]
|
|
5409
|
+
];
|
|
5410
|
+
for (const [flag, key] of numeric) {
|
|
5411
|
+
const raw = values[flag];
|
|
5412
|
+
if (raw === undefined)
|
|
5413
|
+
continue;
|
|
5414
|
+
const parsed = Number(raw);
|
|
5415
|
+
if (!Number.isFinite(parsed))
|
|
5416
|
+
throw new Error(`--${flag} must be a number (got "${String(raw)}").`);
|
|
5417
|
+
effects[key] = parsed;
|
|
5418
|
+
}
|
|
5419
|
+
if (values.flip)
|
|
5420
|
+
effects.horizontal_flip = true;
|
|
5421
|
+
if (values["effects-json"]) {
|
|
5422
|
+
let parsed;
|
|
5423
|
+
try {
|
|
5424
|
+
parsed = JSON.parse(String(values["effects-json"]));
|
|
5425
|
+
}
|
|
5426
|
+
catch (error) {
|
|
5427
|
+
throw new Error(`--effects-json is not valid JSON: ${error.message}`);
|
|
5428
|
+
}
|
|
5429
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
5430
|
+
throw new Error("--effects-json must be a JSON object, e.g. '{\"zoom\":1.03,\"rotate\":-2}'.");
|
|
5431
|
+
}
|
|
5432
|
+
Object.assign(effects, parsed);
|
|
5433
|
+
}
|
|
5434
|
+
return effects;
|
|
5435
|
+
}
|
|
5436
|
+
async function runDedupeCommand(argv) {
|
|
5437
|
+
const parsed = parseArgs({
|
|
5438
|
+
args: argv,
|
|
5439
|
+
allowPositionals: true,
|
|
5440
|
+
options: {
|
|
5441
|
+
...commonOptions(),
|
|
5442
|
+
source: { type: "string" },
|
|
5443
|
+
preset: { type: "string" },
|
|
5444
|
+
"media-type": { type: "string" },
|
|
5445
|
+
variants: { type: "string" },
|
|
5446
|
+
variant: { type: "string" },
|
|
5447
|
+
seed: { type: "string" },
|
|
5448
|
+
"no-jitter": { type: "boolean", default: false },
|
|
5449
|
+
jitter: { type: "boolean", default: false },
|
|
5450
|
+
// Per-knob overrides.
|
|
5451
|
+
zoom: { type: "string" },
|
|
5452
|
+
rotate: { type: "string" },
|
|
5453
|
+
skew: { type: "string" },
|
|
5454
|
+
tilt: { type: "string" },
|
|
5455
|
+
speed: { type: "string" },
|
|
5456
|
+
saturation: { type: "string" },
|
|
5457
|
+
contrast: { type: "string" },
|
|
5458
|
+
brightness: { type: "string" },
|
|
5459
|
+
hue: { type: "string" },
|
|
5460
|
+
blur: { type: "string" },
|
|
5461
|
+
noise: { type: "string" },
|
|
5462
|
+
volume: { type: "string" },
|
|
5463
|
+
flip: { type: "boolean", default: false },
|
|
5464
|
+
"effects-json": { type: "string" },
|
|
5465
|
+
"tint-color": { type: "string" },
|
|
5466
|
+
"tint-opacity": { type: "string" },
|
|
5467
|
+
width: { type: "string" },
|
|
5468
|
+
height: { type: "string" },
|
|
5469
|
+
crf: { type: "string" },
|
|
5470
|
+
"keep-metadata": { type: "boolean", default: false },
|
|
5471
|
+
"output-format": { type: "string" },
|
|
5472
|
+
local: { type: "boolean", default: false },
|
|
5473
|
+
cloud: { type: "boolean", default: false },
|
|
5474
|
+
out: { type: "string" },
|
|
5475
|
+
"out-dir": { type: "string" },
|
|
5476
|
+
"no-wait": { type: "boolean", default: false },
|
|
5477
|
+
tracer: { type: "string" }
|
|
5478
|
+
}
|
|
5479
|
+
});
|
|
5480
|
+
const ctx = commonContext(parsed.values);
|
|
5481
|
+
const sourceArg = parsed.values.source ?? parsed.positionals[0];
|
|
5482
|
+
if (!sourceArg) {
|
|
5483
|
+
throw new Error("dedupe requires a source video or image: `vidfarm dedupe <video|image|url> [--preset light|standard|strong] [--variants 3] [--out out.mp4]`.");
|
|
5484
|
+
}
|
|
5485
|
+
const presetRaw = parsed.values.preset?.trim().toLowerCase();
|
|
5486
|
+
if (presetRaw && !isDedupePresetName(presetRaw)) {
|
|
5487
|
+
throw new Error(`Unknown --preset "${presetRaw}". Choose one of: ${Object.keys(DEDUPE_PRESETS).join(", ")}.`);
|
|
5488
|
+
}
|
|
5489
|
+
const preset = presetRaw ?? DEDUPE_DEFAULT_PRESET;
|
|
5490
|
+
const effectOverrides = collectDedupeEffectOverrides(parsed.values);
|
|
5491
|
+
const variantCount = Math.max(1, Math.round(Number(parsed.values.variants ?? 1)) || 1);
|
|
5492
|
+
if (variantCount > 100)
|
|
5493
|
+
throw new Error("--variants is capped at 100 per run.");
|
|
5494
|
+
const firstVariant = Math.max(1, Math.round(Number(parsed.values.variant ?? 1)) || 1);
|
|
5495
|
+
const seed = parsed.values.seed ?? "";
|
|
5496
|
+
const jitter = parsed.values["no-jitter"] ? false : (parsed.values.jitter ? true : undefined);
|
|
5497
|
+
const stripMetadata = !parsed.values["keep-metadata"];
|
|
5498
|
+
const mediaTypeArg = parsed.values["media-type"]?.trim().toLowerCase();
|
|
5499
|
+
const mediaTypeOverride = mediaTypeArg === "image" || mediaTypeArg === "video" ? mediaTypeArg : undefined;
|
|
5500
|
+
if (parsed.values.out && variantCount > 1) {
|
|
5501
|
+
throw new Error("--out names a single file; with --variants use --out-dir (files are named <stem>.dedupe-NN.<ext>).");
|
|
5502
|
+
}
|
|
5503
|
+
let target = resolveDedupeTarget(parsed.values);
|
|
5504
|
+
if (target === "local" && !(await localDedupeAvailable())) {
|
|
5505
|
+
const hasCloudKey = Boolean(parsed.values["api-key"] ?? process.env.VIDFARM_API_KEY);
|
|
5506
|
+
if (parsed.values.local) {
|
|
5507
|
+
throw new Error("Local dedupe needs ffmpeg, which wasn't found. Install ffmpeg (or `npm i -g ffmpeg-static`), or run without --local to use the cloud primitive.");
|
|
5508
|
+
}
|
|
5509
|
+
if (!hasCloudKey) {
|
|
5510
|
+
throw new Error("Dedupe needs either ffmpeg (for the free local pass) or a cloud API key. Install ffmpeg, or set VIDFARM_API_KEY / pass --cloud --api-key.");
|
|
5511
|
+
}
|
|
5512
|
+
target = "cloud";
|
|
5513
|
+
if (!ctx.json)
|
|
5514
|
+
console.log(`${DIM}ffmpeg not found — using the cloud dedupe primitive instead.${RESET}`);
|
|
5515
|
+
}
|
|
5516
|
+
if (target === "local") {
|
|
5517
|
+
await runLocalDedupe(ctx, {
|
|
5518
|
+
sourceArg,
|
|
5519
|
+
preset,
|
|
5520
|
+
effects: effectOverrides,
|
|
5521
|
+
variantCount,
|
|
5522
|
+
firstVariant,
|
|
5523
|
+
seed,
|
|
5524
|
+
jitter,
|
|
5525
|
+
stripMetadata,
|
|
5526
|
+
mediaType: mediaTypeOverride,
|
|
5527
|
+
values: parsed.values
|
|
5528
|
+
});
|
|
5529
|
+
return;
|
|
5530
|
+
}
|
|
5531
|
+
// ---- CLOUD: billed primitive ----------------------------------------------
|
|
5532
|
+
guardBilled(ctx, {
|
|
5533
|
+
label: `cloud dedupe (${variantCount} variant${variantCount === 1 ? "" : "s"})`,
|
|
5534
|
+
estimate: "billed at real compute ×1.2 per variant",
|
|
5535
|
+
freeAlternative: "vidfarm dedupe --local (bundled ffmpeg, $0.00, identical transforms)"
|
|
5536
|
+
});
|
|
5537
|
+
const sourceUrl = await resolveSingleMediaUrl(ctx, sourceArg);
|
|
5538
|
+
const mediaType = mediaTypeOverride
|
|
5539
|
+
?? (/\.(mp4|mov|webm|m4v|mkv)(\?|#|$)/i.test(sourceUrl) ? "video" : "image");
|
|
5540
|
+
const results = [];
|
|
5541
|
+
for (let index = 0; index < variantCount; index += 1) {
|
|
5542
|
+
const variant = firstVariant + index;
|
|
5543
|
+
const payload = {
|
|
5544
|
+
source_media_url: sourceUrl,
|
|
5545
|
+
media_type: mediaType,
|
|
5546
|
+
engine: "ffmpeg",
|
|
5547
|
+
preset,
|
|
5548
|
+
variant,
|
|
5549
|
+
strip_metadata: stripMetadata
|
|
5550
|
+
};
|
|
5551
|
+
if (Object.keys(effectOverrides).length)
|
|
5552
|
+
payload.effects = effectOverrides;
|
|
5553
|
+
if (seed)
|
|
5554
|
+
payload.seed = seed;
|
|
5555
|
+
if (jitter !== undefined)
|
|
5556
|
+
payload.jitter = jitter;
|
|
5557
|
+
if (parsed.values["tint-color"])
|
|
5558
|
+
payload.tint_color = parsed.values["tint-color"];
|
|
5559
|
+
if (parsed.values["tint-opacity"] !== undefined)
|
|
5560
|
+
payload.tint_opacity = Number(parsed.values["tint-opacity"]);
|
|
5561
|
+
if (parsed.values.width !== undefined)
|
|
5562
|
+
payload.width = Number(parsed.values.width);
|
|
5563
|
+
if (parsed.values.height !== undefined)
|
|
5564
|
+
payload.height = Number(parsed.values.height);
|
|
5565
|
+
if (parsed.values.crf !== undefined)
|
|
5566
|
+
payload.crf = Number(parsed.values.crf);
|
|
5567
|
+
if (mediaType === "image" && parsed.values["output-format"])
|
|
5568
|
+
payload.output_format = parsed.values["output-format"];
|
|
5569
|
+
const tracer = parsed.values.tracer
|
|
5570
|
+
? `${String(parsed.values.tracer)}-v${variant}`
|
|
5571
|
+
: `devcli-dedupe-${Date.now().toString(36)}-v${variant}`;
|
|
5572
|
+
const submit = await apiRequest({
|
|
5573
|
+
method: "POST",
|
|
5574
|
+
host: ctx.host,
|
|
5575
|
+
path: "/api/v1/primitives/media/dedupe",
|
|
5576
|
+
auth: ctx.auth,
|
|
5577
|
+
body: { tracer, payload }
|
|
5578
|
+
});
|
|
5579
|
+
assertApiOk(submit, "dedupe");
|
|
5580
|
+
const jobId = submit.json?.job_id;
|
|
5581
|
+
if (parsed.values["no-wait"] || !jobId) {
|
|
5582
|
+
if (!ctx.json && jobId)
|
|
5583
|
+
console.log(`${DIM}Queued ${jobId} (variant ${variant}, tracer ${tracer}). Poll: vidfarm api GET /api/v1/user/me/jobs/${jobId}${RESET}`);
|
|
5584
|
+
results.push({ variant, job_id: jobId ?? null, status: "queued" });
|
|
5585
|
+
continue;
|
|
5586
|
+
}
|
|
5587
|
+
if (!ctx.json)
|
|
5588
|
+
console.log(`${DIM}Deduplicating variant ${variant} (${jobId})… polling every 5s.${RESET}`);
|
|
5589
|
+
const job = await pollGreenscreenJob(ctx, "cloud", jobId);
|
|
5590
|
+
const mediaUrl = resolveJobMediaUrl(job);
|
|
5591
|
+
const status = String(job?.status ?? "");
|
|
5592
|
+
if (!mediaUrl) {
|
|
5593
|
+
results.push({ variant, job_id: jobId, status, media_url: null });
|
|
5594
|
+
if (!ctx.json)
|
|
5595
|
+
console.log(`${RED}Variant ${variant} ${status || "did not finish"} — no media URL.${RESET}`);
|
|
5596
|
+
process.exitCode = 1;
|
|
5597
|
+
continue;
|
|
5598
|
+
}
|
|
5599
|
+
let outPath = null;
|
|
5600
|
+
const outDir = parsed.values["out-dir"];
|
|
5601
|
+
if (parsed.values.out) {
|
|
5602
|
+
outPath = path.resolve(process.cwd(), String(parsed.values.out));
|
|
5603
|
+
}
|
|
5604
|
+
else if (outDir) {
|
|
5605
|
+
const ext = mediaType === "video" ? "mp4" : String(parsed.values["output-format"] ?? "png");
|
|
5606
|
+
outPath = path.resolve(process.cwd(), outDir, `dedupe-${String(variant).padStart(2, "0")}.${ext}`);
|
|
5607
|
+
}
|
|
5608
|
+
if (outPath)
|
|
5609
|
+
await downloadUrlToFile(mediaUrl, outPath);
|
|
5610
|
+
results.push({ variant, job_id: jobId, status, media_url: mediaUrl, out: outPath });
|
|
5611
|
+
if (!ctx.json)
|
|
5612
|
+
console.log(`${GREEN}Variant ${variant} ready:${RESET} ${mediaUrl}${outPath ? ` ${DIM}→ ${outPath}${RESET}` : ""}`);
|
|
5613
|
+
}
|
|
5614
|
+
if (ctx.json) {
|
|
5615
|
+
printJson({ ok: true, target: "cloud", preset, media_type: mediaType, variants: results });
|
|
5616
|
+
}
|
|
5617
|
+
else {
|
|
5618
|
+
console.log(`${DIM}Post each variant to a DIFFERENT account/slot — two accounts posting the same variant defeats the point.${RESET}`);
|
|
5619
|
+
}
|
|
5620
|
+
}
|
|
5621
|
+
// Run the dedupe pass LOCALLY with bundled ffmpeg — free, offline, no wallet, no
|
|
5622
|
+
// in-process backend (so it works in the published cloud-only CLI). Cloud-parity
|
|
5623
|
+
// filter graph, straight from lib/dedupe-recipe.ts.
|
|
5624
|
+
async function runLocalDedupe(ctx, input) {
|
|
5625
|
+
// A path on disk is used in place; anything else (url / raw id / raws path)
|
|
5626
|
+
// resolves to a URL and downloads first.
|
|
5627
|
+
const directPath = path.resolve(process.cwd(), input.sourceArg);
|
|
5628
|
+
const isLocalFile = !/^https?:\/\//i.test(input.sourceArg) && existsSync(directPath);
|
|
5629
|
+
let sourcePath = directPath;
|
|
5630
|
+
let downloadDir = null;
|
|
5631
|
+
if (!isLocalFile) {
|
|
5632
|
+
const sourceUrl = await resolveSingleMediaUrl(ctx, input.sourceArg);
|
|
5633
|
+
downloadDir = mkdtempSync(path.join(tmpdir(), "vidfarm-dedupe-dl-"));
|
|
5634
|
+
sourcePath = path.join(downloadDir, path.basename(new URL(sourceUrl).pathname) || "source.bin");
|
|
5635
|
+
if (!ctx.json)
|
|
5636
|
+
console.log(`${DIM}Downloading source…${RESET}`);
|
|
5637
|
+
await downloadUrlToFile(sourceUrl, sourcePath);
|
|
5638
|
+
}
|
|
5639
|
+
try {
|
|
5640
|
+
const outDir = input.values["out-dir"];
|
|
5641
|
+
const outputFormat = input.values["output-format"]?.trim().toLowerCase();
|
|
5642
|
+
const results = [];
|
|
5643
|
+
for (let index = 0; index < input.variantCount; index += 1) {
|
|
5644
|
+
const variant = input.firstVariant + index;
|
|
5645
|
+
let outPath;
|
|
5646
|
+
if (input.values.out) {
|
|
5647
|
+
outPath = path.resolve(process.cwd(), String(input.values.out));
|
|
5648
|
+
}
|
|
5649
|
+
else {
|
|
5650
|
+
const base = defaultDedupeOutPath(sourcePath, input.variantCount > 1 ? variant : 1);
|
|
5651
|
+
const named = outputFormat && /\.(png|jpe?g|webp)$/i.test(`.${outputFormat}`)
|
|
5652
|
+
? base.replace(/\.[^.]+$/, `.${outputFormat}`)
|
|
5653
|
+
: base;
|
|
5654
|
+
outPath = outDir
|
|
5655
|
+
? path.resolve(process.cwd(), outDir, path.basename(named))
|
|
5656
|
+
: named;
|
|
5657
|
+
}
|
|
5658
|
+
mkdirSync(path.dirname(outPath), { recursive: true });
|
|
5659
|
+
const result = await dedupeMediaLocal({
|
|
5660
|
+
sourcePath,
|
|
5661
|
+
outputPath: outPath,
|
|
5662
|
+
mediaType: input.mediaType,
|
|
5663
|
+
preset: input.preset,
|
|
5664
|
+
effects: input.effects,
|
|
5665
|
+
variant,
|
|
5666
|
+
seed: input.seed || undefined,
|
|
5667
|
+
jitter: input.jitter,
|
|
5668
|
+
stripMetadata: input.stripMetadata,
|
|
5669
|
+
width: input.values.width !== undefined ? Number(input.values.width) : undefined,
|
|
5670
|
+
height: input.values.height !== undefined ? Number(input.values.height) : undefined,
|
|
5671
|
+
tintColor: input.values["tint-color"],
|
|
5672
|
+
tintOpacity: input.values["tint-opacity"] !== undefined ? Number(input.values["tint-opacity"]) : undefined,
|
|
5673
|
+
crf: input.values.crf !== undefined ? Number(input.values.crf) : undefined
|
|
5674
|
+
});
|
|
5675
|
+
results.push(result);
|
|
5676
|
+
if (!ctx.json) {
|
|
5677
|
+
const label = input.variantCount > 1 ? `Variant ${variant}` : "Deduped";
|
|
5678
|
+
console.log(`${GREEN}${label}:${RESET} ${result.outputPath} ${DIM}(${formatBytes(result.bytes)})${RESET}`);
|
|
5679
|
+
console.log(`${DIM} ${describeLocalDedupe(result)}${RESET}`);
|
|
5680
|
+
if (result.skewDropped) {
|
|
5681
|
+
console.log(`${YELLOW} Note:${RESET} ${DIM}this ffmpeg has no vf_perspective, so the skew stage was skipped — every other transform still applied.${RESET}`);
|
|
5682
|
+
}
|
|
5683
|
+
}
|
|
5684
|
+
}
|
|
5685
|
+
if (ctx.json) {
|
|
5686
|
+
printJson({
|
|
5687
|
+
ok: true,
|
|
5688
|
+
target: "local",
|
|
5689
|
+
preset: input.preset,
|
|
5690
|
+
media_type: results[0]?.mediaType ?? null,
|
|
5691
|
+
variants: results.map((result) => ({
|
|
5692
|
+
variant: result.variant,
|
|
5693
|
+
out: result.outputPath,
|
|
5694
|
+
bytes: result.bytes,
|
|
5695
|
+
effects: result.effects,
|
|
5696
|
+
effective_zoom: result.effectiveZoom,
|
|
5697
|
+
speed: result.speed,
|
|
5698
|
+
crf: result.crf,
|
|
5699
|
+
skew_applied: result.skewApplied,
|
|
5700
|
+
skew_dropped: result.skewDropped,
|
|
5701
|
+
notes: result.notes
|
|
5702
|
+
}))
|
|
5703
|
+
});
|
|
5704
|
+
}
|
|
5705
|
+
else {
|
|
5706
|
+
console.log(`${DIM}Free — no wallet, no re-render. ${input.variantCount > 1 ? "Post each variant to a DIFFERENT account/slot." : "Post this instead of the original when the original has already been published."}${RESET}`);
|
|
5707
|
+
}
|
|
5708
|
+
}
|
|
5709
|
+
finally {
|
|
5710
|
+
if (downloadDir)
|
|
5711
|
+
rmSync(downloadDir, { recursive: true, force: true });
|
|
5712
|
+
}
|
|
5713
|
+
}
|
|
5285
5714
|
// ── cutout: generate → key greenscreen → alpha-trim to min bounding box ───────
|
|
5286
5715
|
// The one-shot "make a transparent explainer sticker" verb. Three mechanical
|
|
5287
5716
|
// steps that used to be run by hand (generate an AI graphic on a green plate →
|
|
@@ -5303,9 +5732,12 @@ function cutoutPlateInstruction(keyColorHex) {
|
|
|
5303
5732
|
: /^#?ff00ff$/i.test(keyColorHex) ? "solid magenta"
|
|
5304
5733
|
: `a solid flat ${keyColorHex}`;
|
|
5305
5734
|
return (`Isolated subject on a completely flat, evenly-lit ${named} (${keyColorHex}) background — ` +
|
|
5306
|
-
`like a green-screen plate.
|
|
5735
|
+
`like a green-screen plate. ` +
|
|
5307
5736
|
`Center the subject with generous empty margin on all sides, no drop shadow or reflection cast on the ` +
|
|
5308
|
-
`background, crisp clean edges, sticker/cutout style, single subject, no text
|
|
5737
|
+
`background, crisp clean edges, sticker/cutout style, single subject, no text. ` +
|
|
5738
|
+
// The plate color is only half the battle — art drawn as a hollow outline
|
|
5739
|
+
// (or in a near-plate shade) gets its middle keyed away too.
|
|
5740
|
+
keySafeArtInstruction(keyColorHex));
|
|
5309
5741
|
}
|
|
5310
5742
|
async function runCutoutCommand(argv) {
|
|
5311
5743
|
const parsed = parseArgs({
|
|
@@ -5482,6 +5914,16 @@ async function runCutoutCommand(argv) {
|
|
|
5482
5914
|
const shrinkPct = trim.origWidth && trim.origHeight && trim.width && trim.height
|
|
5483
5915
|
? Math.round((1 - (trim.width * trim.height) / (trim.origWidth * trim.origHeight)) * 100)
|
|
5484
5916
|
: null;
|
|
5917
|
+
// Did the key eat the ART as well as the plate? Outline-only art (interior
|
|
5918
|
+
// left as bare plate) or a near-plate fill comes back as a rim around a
|
|
5919
|
+
// see-through hole — invisible until it composites over a real background.
|
|
5920
|
+
const art = await analyzeKeyedArt(trim.outputPath);
|
|
5921
|
+
const hollowNote = art?.hollow
|
|
5922
|
+
? `${art.hole_pct}% of this cutout is transparent HOLES inside the art. If it's meant to be a ring/frame/donut, ignore this. ` +
|
|
5923
|
+
`Otherwise the key ate the fill: the art was drawn as an outline with a bare ${keyColor} interior, or filled in a near-${keyColor} shade. ` +
|
|
5924
|
+
`Fix it in the PROMPT — ask for "a closed, solidly filled shape, no outline-only or hollow art, nothing in ${keyColor} or any near-shade of it, ` +
|
|
5925
|
+
`fully opaque, no translucency or glow" — and re-generate. (--tolerance lower can rescue a near-shade fill; nothing rescues an empty one.)`
|
|
5926
|
+
: null;
|
|
5485
5927
|
if (ctx.json) {
|
|
5486
5928
|
printJson({
|
|
5487
5929
|
ok: true,
|
|
@@ -5495,13 +5937,18 @@ async function runCutoutCommand(argv) {
|
|
|
5495
5937
|
trimmed: trim.trimmed,
|
|
5496
5938
|
area_reduced_pct: shrinkPct,
|
|
5497
5939
|
bytes: safeSize(trim.outputPath),
|
|
5498
|
-
key_color: keyColor
|
|
5940
|
+
key_color: keyColor,
|
|
5941
|
+
hole_pct: art?.hole_pct ?? null,
|
|
5942
|
+
hollow: art?.hollow ?? false,
|
|
5943
|
+
hollow_note: hollowNote
|
|
5499
5944
|
});
|
|
5500
5945
|
}
|
|
5501
5946
|
else {
|
|
5502
5947
|
console.log(`${GREEN}Cutout ready:${RESET} ${trim.outputPath} ${DIM}(${trim.width}×${trim.height}, ${formatBytes(safeSize(trim.outputPath))}${trim.trimmed && shrinkPct !== null ? `, −${shrinkPct}% area` : ""})${RESET}`);
|
|
5503
5948
|
if (!trim.trimmed && !noTrim)
|
|
5504
5949
|
console.log(`${YELLOW}Note:${RESET} ${DIM}no transparent margin to trim — the subject already reached the edges, or the plate keyed to fully transparent (check --preset/--key-color).${RESET}`);
|
|
5950
|
+
if (hollowNote)
|
|
5951
|
+
console.log(`${YELLOW}Hollow:${RESET} ${DIM}${hollowNote}${RESET}`);
|
|
5505
5952
|
console.log(`${DIM}Drop it on a composition: vidfarm place <dir> --src "${trim.outputPath}" --kind image [--ken-burns zoom-in]. Animate it (zoom/grow/shake/move) with vidfarm keyframes — see the skill's "Cutout graphics for explainers" recipe.${RESET}`);
|
|
5506
5953
|
}
|
|
5507
5954
|
}
|
|
@@ -5722,8 +6169,9 @@ function stickerSheetInstruction(keyColorHex, count, items) {
|
|
|
5722
6169
|
`${named} (${keyColorHex}) background. ${list} ` +
|
|
5723
6170
|
`CRITICAL: every item must be fully separated from the others by a clear margin of plain ${keyColorHex} background — ` +
|
|
5724
6171
|
`nothing touching, overlapping, or connected. Keep a wide ${keyColorHex} margin around the edges of the sheet too. ` +
|
|
5725
|
-
`No
|
|
5726
|
-
`no frames or dividing lines between items. One consistent art style across all items, crisp clean edges, sticker/cutout style
|
|
6172
|
+
`No drop shadows, no reflections, no text, no labels, ` +
|
|
6173
|
+
`no frames or dividing lines between items. One consistent art style across all items, crisp clean edges, sticker/cutout style. ` +
|
|
6174
|
+
keySafeArtInstruction(keyColorHex));
|
|
5727
6175
|
}
|
|
5728
6176
|
async function runStickerPackCommand(argv) {
|
|
5729
6177
|
const parsed = parseArgs({
|
|
@@ -5930,16 +6378,37 @@ async function runStickerPackCommand(argv) {
|
|
|
5930
6378
|
if (!seg.components.length) {
|
|
5931
6379
|
throw new Error("No items found in the sheet. Either the plate didn't key (check --preset/--key-color, raise --tolerance) or every item was filtered as speckle (lower --min-area).");
|
|
5932
6380
|
}
|
|
6381
|
+
// ---- 3b. Hollow-sticker check -------------------------------------------
|
|
6382
|
+
// The other half of "did the key work": items that came back as a rim around
|
|
6383
|
+
// a transparent hole, because the art was drawn as an outline (interior left
|
|
6384
|
+
// as bare plate) or filled in a near-plate shade. It looks fine on the sheet
|
|
6385
|
+
// and only shows up once the sticker composites over a real background — so
|
|
6386
|
+
// say it here, loudly, with the fix. A ring/donut/frame reads the same way,
|
|
6387
|
+
// which is why this warns and never blocks.
|
|
6388
|
+
let hollowNote = null;
|
|
6389
|
+
if (seg.hollow.length) {
|
|
6390
|
+
const named = seg.hollow.map((i) => `${String(i).padStart(2, "0")}${itemNames[i - 1] ? ` (${itemNames[i - 1]})` : ""} — ${seg.components.find((c) => c.index === i)?.hole_pct}% see-through`);
|
|
6391
|
+
hollowNote =
|
|
6392
|
+
`${seg.hollow.length} of ${seg.components.length} sticker(s) came out mostly hollow: ${named.join(", ")}. ` +
|
|
6393
|
+
`If those are meant to be rings/frames/donuts, ignore this. Otherwise the key ate their FILL: the art was drawn ` +
|
|
6394
|
+
`as an outline with a bare ${keyColor} interior, or filled in a near-${keyColor} shade. Fix it in the PROMPT, not the keyer — ` +
|
|
6395
|
+
`ask for "closed, solidly filled shapes, no outline-only or hollow objects, nothing on the art in ${keyColor} or any ` +
|
|
6396
|
+
`near-shade of it, fully opaque, no translucency or glow" — then re-generate the sheet. ` +
|
|
6397
|
+
`(Lowering --tolerance can rescue a near-shade fill from an existing sheet, but not a genuinely empty one.)`;
|
|
6398
|
+
if (!ctx.json)
|
|
6399
|
+
console.log(`${YELLOW}Hollow:${RESET} ${DIM}${hollowNote}${RESET}`);
|
|
6400
|
+
}
|
|
5933
6401
|
if (parsed.values["dry-run"]) {
|
|
5934
6402
|
// Report the boxes without writing stickers — for eyeballing segmentation
|
|
5935
6403
|
// before spending disk, and for hand-fixing a merged item with `mask --crop`.
|
|
5936
6404
|
if (ctx.json) {
|
|
5937
|
-
printJson({ ok: true, target: "local", dry_run: true, sheet: `${seg.sourceWidth}x${seg.sourceHeight}`, found: seg.components.length, skipped_specks: seg.rejected, key_color: keyColor, key_color_auto: plateAuto, items: seg.components });
|
|
6405
|
+
printJson({ ok: true, target: "local", dry_run: true, sheet: `${seg.sourceWidth}x${seg.sourceHeight}`, found: seg.components.length, skipped_specks: seg.rejected, key_color: keyColor, key_color_auto: plateAuto, hollow: seg.hollow, hollow_note: hollowNote, items: seg.components });
|
|
5938
6406
|
}
|
|
5939
6407
|
else {
|
|
5940
6408
|
console.log(`${GREEN}Found ${seg.components.length} item${seg.components.length === 1 ? "" : "s"}${RESET} ${DIM}on the ${seg.sourceWidth}×${seg.sourceHeight} sheet${seg.rejected ? `, ${seg.rejected} speck(s) skipped` : ""} (dry run — nothing written):${RESET}`);
|
|
5941
6409
|
for (const c of seg.components) {
|
|
5942
|
-
|
|
6410
|
+
const hollowFlag = c.hole_pct >= HOLE_WARN_PCT ? ` ${YELLOW}⚠ ${c.hole_pct}% hollow${RESET}` : "";
|
|
6411
|
+
console.log(` ${DIM}${String(c.index).padStart(2, "0")} crop ${c.x},${c.y},${c.width},${c.height} (${c.width}×${c.height}, ${c.area_pct}% of sheet)${RESET}${hollowFlag}`);
|
|
5943
6412
|
}
|
|
5944
6413
|
console.log(`${DIM}Merged two items into one box? Raise the gap between them in the prompt, lower --gap, or grab that one by hand: vidfarm mask <sheet> --crop x,y,w,h --flat "${keyColor}".${RESET}`);
|
|
5945
6414
|
}
|
|
@@ -5973,10 +6442,14 @@ async function runStickerPackCommand(argv) {
|
|
|
5973
6442
|
height: trim.height,
|
|
5974
6443
|
sheet_crop: { x: c.x, y: c.y, width: c.width, height: c.height },
|
|
5975
6444
|
area_pct: c.area_pct,
|
|
6445
|
+
holes: c.holes,
|
|
6446
|
+
hole_pct: c.hole_pct,
|
|
6447
|
+
hollow: c.hole_pct >= HOLE_WARN_PCT,
|
|
5976
6448
|
bytes: safeSize(finalPath)
|
|
5977
6449
|
});
|
|
5978
6450
|
if (!ctx.json) {
|
|
5979
|
-
|
|
6451
|
+
const hollowFlag = c.hole_pct >= HOLE_WARN_PCT ? ` ${YELLOW}⚠ ${c.hole_pct}% hollow${RESET}` : "";
|
|
6452
|
+
console.log(` ${GREEN}✓${RESET} ${path.relative(process.cwd(), finalPath)} ${DIM}(${trim.width}×${trim.height}, ${formatBytes(safeSize(finalPath))})${RESET}${hollowFlag}`);
|
|
5980
6453
|
}
|
|
5981
6454
|
}
|
|
5982
6455
|
// A manifest so the next step (place/keyframes, or an agent picking props by
|
|
@@ -5986,6 +6459,8 @@ async function runStickerPackCommand(argv) {
|
|
|
5986
6459
|
generated_from: generatePrompt ? "generate" : sourceArg,
|
|
5987
6460
|
key_color: keyColor,
|
|
5988
6461
|
key_color_auto: plateAuto,
|
|
6462
|
+
hollow: seg.hollow,
|
|
6463
|
+
hollow_note: hollowNote,
|
|
5989
6464
|
sheet_width: seg.sourceWidth,
|
|
5990
6465
|
sheet_height: seg.sourceHeight,
|
|
5991
6466
|
format: wantGif ? "gif" : stillExt,
|
|
@@ -5993,7 +6468,7 @@ async function runStickerPackCommand(argv) {
|
|
|
5993
6468
|
stickers: written
|
|
5994
6469
|
}, null, 2)}\n`);
|
|
5995
6470
|
if (ctx.json) {
|
|
5996
|
-
printJson({ ok: true, target: "local", out_dir: outDir, manifest: manifestPath, count: written.length, skipped_specks: seg.rejected, key_color: keyColor, key_color_auto: plateAuto, key_color_note: plateNote, stickers: written });
|
|
6471
|
+
printJson({ ok: true, target: "local", out_dir: outDir, manifest: manifestPath, count: written.length, skipped_specks: seg.rejected, key_color: keyColor, key_color_auto: plateAuto, key_color_note: plateNote, hollow: seg.hollow, hollow_note: hollowNote, stickers: written });
|
|
5997
6472
|
}
|
|
5998
6473
|
else {
|
|
5999
6474
|
console.log(`${GREEN}Sticker pack ready:${RESET} ${written.length} transparent sticker${written.length === 1 ? "" : "s"} in ${outDir} ${DIM}(manifest: stickers.json${seg.rejected ? `, ${seg.rejected} speck(s) skipped` : ""})${RESET}`);
|
|
@@ -7618,6 +8093,28 @@ async function runPostCommand(argv) {
|
|
|
7618
8093
|
assertApiOk(result, "post");
|
|
7619
8094
|
emitResult(result, ctx.json, [["Preview post ", result.json?.post?.share_url]]);
|
|
7620
8095
|
}
|
|
8096
|
+
// Destinations are otherwise invisible to an API-only caller: before this the
|
|
8097
|
+
// only place a channel id appeared was the Settings page in a browser.
|
|
8098
|
+
async function runChannelsCommand(argv) {
|
|
8099
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
|
|
8100
|
+
const ctx = commonContext(parsed.values);
|
|
8101
|
+
const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/channels", auth: ctx.auth });
|
|
8102
|
+
assertApiOk(result, "channels");
|
|
8103
|
+
emitResult(result, ctx.json);
|
|
8104
|
+
if (ctx.json)
|
|
8105
|
+
return;
|
|
8106
|
+
const channels = (result.json?.channels ?? []);
|
|
8107
|
+
console.log("");
|
|
8108
|
+
if (channels.length === 0) {
|
|
8109
|
+
console.log(" No destination channels connected. Add an email channel in Settings → Channels, or connect FlockPoster.");
|
|
8110
|
+
return;
|
|
8111
|
+
}
|
|
8112
|
+
console.log(` ${BOLD}Pass any of these to 'vidfarm schedule --to'${RESET}`);
|
|
8113
|
+
for (const channel of channels) {
|
|
8114
|
+
const unverified = channel.schedulable === false ? " (unverified — confirm the emailed link first)" : "";
|
|
8115
|
+
console.log(` ${String(channel.destination_type).padEnd(12)} ${String(channel.handle || channel.title).padEnd(32)} ${channel.destination_id}${unverified}`);
|
|
8116
|
+
}
|
|
8117
|
+
}
|
|
7621
8118
|
async function runScheduleCommand(argv) {
|
|
7622
8119
|
const parsed = parseArgs({
|
|
7623
8120
|
args: argv,
|
|
@@ -7630,7 +8127,7 @@ async function runScheduleCommand(argv) {
|
|
|
7630
8127
|
if (!parsed.values.at)
|
|
7631
8128
|
throw new Error("schedule requires --at <ISO 8601 timestamp>.");
|
|
7632
8129
|
if (!parsed.values.to)
|
|
7633
|
-
throw new Error("schedule requires --to <
|
|
8130
|
+
throw new Error("schedule requires --to <destination> (a channel id, email address, or @handle — run 'vidfarm channels' to list them).");
|
|
7634
8131
|
const ctx = commonContext(parsed.values);
|
|
7635
8132
|
const result = await apiRequest({
|
|
7636
8133
|
method: "POST",
|
|
@@ -7638,7 +8135,7 @@ async function runScheduleCommand(argv) {
|
|
|
7638
8135
|
path: `/api/v1/approved/posts/${encodeURIComponent(postId)}/schedules`,
|
|
7639
8136
|
auth: ctx.auth,
|
|
7640
8137
|
body: {
|
|
7641
|
-
destination_type: parsed.values.type,
|
|
8138
|
+
destination_type: inferDestinationType(argv, parsed.values.type, parsed.values.to),
|
|
7642
8139
|
destination_id: parsed.values.to,
|
|
7643
8140
|
scheduled_at: parsed.values.at,
|
|
7644
8141
|
timezone: parsed.values.timezone,
|
|
@@ -7648,6 +8145,61 @@ async function runScheduleCommand(argv) {
|
|
|
7648
8145
|
assertApiOk(result, "schedule");
|
|
7649
8146
|
emitResult(result, ctx.json);
|
|
7650
8147
|
}
|
|
8148
|
+
// --type defaults to flockposter, which silently misroutes 'schedule --to
|
|
8149
|
+
// someone@example.com'. An address is unambiguous, so honour it unless the
|
|
8150
|
+
// caller said --type outright.
|
|
8151
|
+
function inferDestinationType(argv, type, to) {
|
|
8152
|
+
if (argv.includes("--type"))
|
|
8153
|
+
return type ?? "flockposter";
|
|
8154
|
+
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to.trim()) ? "email" : (type ?? "flockposter");
|
|
8155
|
+
}
|
|
8156
|
+
async function runRescheduleCommand(argv) {
|
|
8157
|
+
const parsed = parseArgs({
|
|
8158
|
+
args: argv,
|
|
8159
|
+
allowPositionals: true,
|
|
8160
|
+
options: { ...commonOptions(), at: { type: "string" }, to: { type: "string" }, type: { type: "string", default: "flockposter" }, timezone: { type: "string" }, notes: { type: "string" } }
|
|
8161
|
+
});
|
|
8162
|
+
const [postId, scheduleId] = parsed.positionals;
|
|
8163
|
+
if (!postId || !scheduleId)
|
|
8164
|
+
throw new Error("reschedule requires <postId> <scheduleId> (list them with 'vidfarm schedules <postId>').");
|
|
8165
|
+
if (!parsed.values.at)
|
|
8166
|
+
throw new Error("reschedule requires --at <ISO 8601 timestamp>.");
|
|
8167
|
+
if (!parsed.values.to)
|
|
8168
|
+
throw new Error("reschedule requires --to <destination> (a channel id, email address, or @handle).");
|
|
8169
|
+
const ctx = commonContext(parsed.values);
|
|
8170
|
+
const result = await apiRequest({
|
|
8171
|
+
method: "PATCH",
|
|
8172
|
+
host: ctx.host,
|
|
8173
|
+
path: `/api/v1/approved/posts/${encodeURIComponent(postId)}/schedules/${encodeURIComponent(scheduleId)}`,
|
|
8174
|
+
auth: ctx.auth,
|
|
8175
|
+
body: {
|
|
8176
|
+
destination_type: inferDestinationType(argv, parsed.values.type, parsed.values.to),
|
|
8177
|
+
destination_id: parsed.values.to,
|
|
8178
|
+
scheduled_at: parsed.values.at,
|
|
8179
|
+
timezone: parsed.values.timezone,
|
|
8180
|
+
additional_notes: parsed.values.notes
|
|
8181
|
+
}
|
|
8182
|
+
});
|
|
8183
|
+
assertApiOk(result, "reschedule");
|
|
8184
|
+
emitResult(result, ctx.json);
|
|
8185
|
+
}
|
|
8186
|
+
async function runUnscheduleCommand(argv) {
|
|
8187
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
|
|
8188
|
+
const [postId, scheduleId] = parsed.positionals;
|
|
8189
|
+
if (!postId || !scheduleId)
|
|
8190
|
+
throw new Error("unschedule requires <postId> <scheduleId> (list them with 'vidfarm schedules <postId>').");
|
|
8191
|
+
const ctx = commonContext(parsed.values);
|
|
8192
|
+
const result = await apiRequest({
|
|
8193
|
+
method: "DELETE",
|
|
8194
|
+
host: ctx.host,
|
|
8195
|
+
path: `/api/v1/approved/posts/${encodeURIComponent(postId)}/schedules/${encodeURIComponent(scheduleId)}`,
|
|
8196
|
+
auth: ctx.auth
|
|
8197
|
+
});
|
|
8198
|
+
// A failed cancel means the send is STILL QUEUED. assertApiOk throws, which is
|
|
8199
|
+
// the honest outcome — never let this print as if it cancelled.
|
|
8200
|
+
assertApiOk(result, "unschedule");
|
|
8201
|
+
emitResult(result, ctx.json);
|
|
8202
|
+
}
|
|
7651
8203
|
async function runSchedulesCommand(argv) {
|
|
7652
8204
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
|
|
7653
8205
|
const postId = parsed.positionals[0];
|
|
@@ -8254,6 +8806,118 @@ async function runDownloadPostCommand(argv) {
|
|
|
8254
8806
|
const final = await pollPrimitiveJob(ctx, jobId);
|
|
8255
8807
|
emitPrimitiveJobResult(ctx, final, "download-post");
|
|
8256
8808
|
}
|
|
8809
|
+
async function runRecycleCommand(argv) {
|
|
8810
|
+
const parsed = parseArgs({
|
|
8811
|
+
args: argv,
|
|
8812
|
+
allowPositionals: true,
|
|
8813
|
+
options: {
|
|
8814
|
+
...commonOptions(),
|
|
8815
|
+
"max-records": { type: "string" },
|
|
8816
|
+
cursor: { type: "string" },
|
|
8817
|
+
query: { type: "string" },
|
|
8818
|
+
filter: { type: "string" },
|
|
8819
|
+
sort: { type: "string" },
|
|
8820
|
+
mode: { type: "string" },
|
|
8821
|
+
network: { type: "string" },
|
|
8822
|
+
"no-comments": { type: "boolean", default: false },
|
|
8823
|
+
"no-profile": { type: "boolean", default: false },
|
|
8824
|
+
out: { type: "string" },
|
|
8825
|
+
"no-wait": { type: "boolean", default: false },
|
|
8826
|
+
tracer: { type: "string" }
|
|
8827
|
+
}
|
|
8828
|
+
});
|
|
8829
|
+
const source = parsed.positionals[0];
|
|
8830
|
+
if (!source) {
|
|
8831
|
+
throw new Error("recycle requires a Reddit or X/Twitter source — a thread URL, a subreddit (r/name), a profile URL, or @handle.");
|
|
8832
|
+
}
|
|
8833
|
+
const maxRecords = parsed.values["max-records"] ? Number(parsed.values["max-records"]) : undefined;
|
|
8834
|
+
if (maxRecords !== undefined && (!Number.isInteger(maxRecords) || maxRecords < 1 || maxRecords > 1000)) {
|
|
8835
|
+
throw new Error("recycle --max-records must be an integer between 1 and 1000.");
|
|
8836
|
+
}
|
|
8837
|
+
const ctx = commonContext(parsed.values);
|
|
8838
|
+
guardBilled(ctx, {
|
|
8839
|
+
label: "recycle a Reddit/X source into JSON (paid plan — brokered via the OfficeX lead-gen apps)",
|
|
8840
|
+
estimate: maxRecords && maxRecords > 100
|
|
8841
|
+
? "several cents (max-records drives upstream pagination)"
|
|
8842
|
+
: "~$0.002-$0.02 per pull",
|
|
8843
|
+
freeAlternative: "open the thread in a browser and copy the text you want by hand"
|
|
8844
|
+
});
|
|
8845
|
+
// A bare handle/subreddit is ambiguous without a network; a URL is not.
|
|
8846
|
+
const looksLikeUrl = /^https?:\/\//i.test(source) || source.includes(".com/");
|
|
8847
|
+
const payload = looksLikeUrl ? { source_url: source } : { handle: source };
|
|
8848
|
+
if (!looksLikeUrl && parsed.values.network)
|
|
8849
|
+
payload.network = String(parsed.values.network);
|
|
8850
|
+
if (parsed.values.mode)
|
|
8851
|
+
payload.mode = String(parsed.values.mode);
|
|
8852
|
+
if (maxRecords !== undefined)
|
|
8853
|
+
payload.max_records = maxRecords;
|
|
8854
|
+
if (parsed.values.cursor)
|
|
8855
|
+
payload.cursor = String(parsed.values.cursor);
|
|
8856
|
+
if (parsed.values.query)
|
|
8857
|
+
payload.query = String(parsed.values.query);
|
|
8858
|
+
if (parsed.values.filter)
|
|
8859
|
+
payload.filter = String(parsed.values.filter);
|
|
8860
|
+
if (parsed.values.sort)
|
|
8861
|
+
payload.sort = String(parsed.values.sort);
|
|
8862
|
+
if (parsed.values["no-comments"])
|
|
8863
|
+
payload.include_comments = false;
|
|
8864
|
+
if (parsed.values["no-profile"])
|
|
8865
|
+
payload.include_profile = false;
|
|
8866
|
+
const tracer = String(parsed.values.tracer ?? `devcli-recycle-${Date.now().toString(36)}`);
|
|
8867
|
+
const submit = await apiRequest({
|
|
8868
|
+
method: "POST",
|
|
8869
|
+
host: ctx.host,
|
|
8870
|
+
path: "/api/v1/primitives/social/recycle",
|
|
8871
|
+
auth: ctx.auth,
|
|
8872
|
+
body: { tracer, payload }
|
|
8873
|
+
});
|
|
8874
|
+
assertApiOk(submit, "recycle");
|
|
8875
|
+
const jobId = submit.json?.job_id;
|
|
8876
|
+
if (!jobId || parsed.values["no-wait"]) {
|
|
8877
|
+
emitResult(submit, ctx.json);
|
|
8878
|
+
return;
|
|
8879
|
+
}
|
|
8880
|
+
if (!ctx.json) {
|
|
8881
|
+
console.log(`${DIM}Recycling ${source} (${jobId})… the upstream lead-gen job is polled server-side.${RESET}`);
|
|
8882
|
+
}
|
|
8883
|
+
const final = await pollPrimitiveJob(ctx, jobId);
|
|
8884
|
+
// --out writes the FULL normalized payload to disk; the job result inlines
|
|
8885
|
+
// only a prefix when the item list is large.
|
|
8886
|
+
const outPath = parsed.values.out ? String(parsed.values.out) : null;
|
|
8887
|
+
if (outPath) {
|
|
8888
|
+
const result = (final?.result ?? {});
|
|
8889
|
+
const recycledUrl = result.recycled?.url
|
|
8890
|
+
?? result.primary_file_url;
|
|
8891
|
+
if (typeof recycledUrl === "string" && recycledUrl) {
|
|
8892
|
+
const res = await fetch(recycledUrl);
|
|
8893
|
+
if (res.ok) {
|
|
8894
|
+
mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
|
|
8895
|
+
writeFileSync(path.resolve(outPath), await res.text());
|
|
8896
|
+
if (!ctx.json)
|
|
8897
|
+
console.log(`${DIM}Wrote full recycled JSON → ${outPath}${RESET}`);
|
|
8898
|
+
}
|
|
8899
|
+
else if (!ctx.json) {
|
|
8900
|
+
console.warn(`[vidfarm] could not fetch recycled.json (${res.status}) — the job result still has it.`);
|
|
8901
|
+
}
|
|
8902
|
+
}
|
|
8903
|
+
}
|
|
8904
|
+
if (!ctx.json) {
|
|
8905
|
+
const result = (final?.result ?? {});
|
|
8906
|
+
const counts = (result.counts ?? {});
|
|
8907
|
+
console.log(`${DIM}mode=${String(result.mode ?? "?")} items=${String(counts.items ?? "?")} `
|
|
8908
|
+
+ `(posts=${String(counts.posts ?? "?")} comments=${String(counts.comments ?? "?")}) `
|
|
8909
|
+
+ `media=${String(counts.media ?? "?")} credits=${String(counts.credits_charged ?? "?")}${RESET}`);
|
|
8910
|
+
for (const job of result.upstreamJobs ?? []) {
|
|
8911
|
+
if (typeof job.view_url === "string" && job.view_url) {
|
|
8912
|
+
console.log(`${DIM} browse ${String(job.endpoint)} in a spreadsheet: ${job.view_url}${RESET}`);
|
|
8913
|
+
}
|
|
8914
|
+
}
|
|
8915
|
+
if (result.nextCursor) {
|
|
8916
|
+
console.log(`${DIM} more available — re-run with --cursor ${String(result.nextCursor).slice(0, 24)}…${RESET}`);
|
|
8917
|
+
}
|
|
8918
|
+
}
|
|
8919
|
+
emitPrimitiveJobResult(ctx, final, "recycle");
|
|
8920
|
+
}
|
|
8257
8921
|
async function runDownloadPostAudioCommand(argv) {
|
|
8258
8922
|
const parsed = parseArgs({
|
|
8259
8923
|
args: argv,
|