@officexapp/vidfarm-devcli 0.21.31 → 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 +1 -0
- package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-regime.md +12 -0
- package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +2 -1
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +13 -1
- package/.agents/skills/vidfarm/references/core-workflows.md +62 -5
- package/.agents/skills/vidfarm/references/primitives.md +75 -32
- package/SKILL.director.md +165 -39
- package/SKILL.md +2 -0
- package/dist/src/cli.js +486 -3
- package/dist/src/devcli/dedupe-local.js +209 -0
- package/dist/src/lib/dedupe-recipe.js +420 -0
- package/package.json +3 -1
package/dist/src/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ 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 { dedupeMediaLocal, localDedupeAvailable, defaultDedupeOutPath, describeLocalDedupe, DEDUPE_PRESETS, DEDUPE_DEFAULT_PRESET, isDedupePresetName } from "./devcli/dedupe-local.js";
|
|
25
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";
|
|
@@ -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.
|
|
@@ -740,8 +773,16 @@ Approve & schedule (publish a finished MP4 as a shareable post):
|
|
|
740
773
|
(add --temp only for a disposable 30-day preview.)
|
|
741
774
|
posts List your approved posts → GET /api/v1/approved/posts
|
|
742
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
|
|
743
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)
|
|
744
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.
|
|
745
786
|
|
|
746
787
|
Account:
|
|
747
788
|
login <email> Send an OTP code to email → POST /api/v1/user/request-otp
|
|
@@ -876,7 +917,8 @@ Cost spectrum (default to the cheapest approach that works; see SKILL.director.m
|
|
|
876
917
|
command (generate, music, decompose, cloud render/TTS/STT/greenscreen, create, replicate)
|
|
877
918
|
respects it: minimize refuses billed spend without --yes and points you at the free
|
|
878
919
|
local path; the others run but print each op's cost. FREE local engines never gate
|
|
879
|
-
(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).
|
|
880
922
|
In minimize AND hybrid, 'vidfarm tts' DEFAULTS to the free local Kokoro voice — ask for
|
|
881
923
|
a premium voice (--style/--voice/--provider/--own-key/--cloud), or be in rich-ai /
|
|
882
924
|
pure-videogen, to opt out.
|
|
@@ -1052,6 +1094,12 @@ async function main() {
|
|
|
1052
1094
|
case "greenscreen":
|
|
1053
1095
|
await runRemoveGreenscreenCommand(rest);
|
|
1054
1096
|
return;
|
|
1097
|
+
case "dedupe":
|
|
1098
|
+
case "dedup":
|
|
1099
|
+
case "deduplicate":
|
|
1100
|
+
case "uniquify":
|
|
1101
|
+
await runDedupeCommand(rest);
|
|
1102
|
+
return;
|
|
1055
1103
|
case "cutout":
|
|
1056
1104
|
case "sticker":
|
|
1057
1105
|
await runCutoutCommand(rest);
|
|
@@ -1134,12 +1182,21 @@ async function main() {
|
|
|
1134
1182
|
case "post":
|
|
1135
1183
|
await runPostCommand(rest);
|
|
1136
1184
|
return;
|
|
1185
|
+
case "channels":
|
|
1186
|
+
await runChannelsCommand(rest);
|
|
1187
|
+
return;
|
|
1137
1188
|
case "schedule":
|
|
1138
1189
|
await runScheduleCommand(rest);
|
|
1139
1190
|
return;
|
|
1140
1191
|
case "schedules":
|
|
1141
1192
|
await runSchedulesCommand(rest);
|
|
1142
1193
|
return;
|
|
1194
|
+
case "reschedule":
|
|
1195
|
+
await runRescheduleCommand(rest);
|
|
1196
|
+
return;
|
|
1197
|
+
case "unschedule":
|
|
1198
|
+
await runUnscheduleCommand(rest);
|
|
1199
|
+
return;
|
|
1143
1200
|
case "login":
|
|
1144
1201
|
await runLoginCommand(rest);
|
|
1145
1202
|
return;
|
|
@@ -2334,6 +2391,7 @@ Rules:
|
|
|
2334
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\`.
|
|
2335
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\`.
|
|
2336
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\`.
|
|
2337
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.
|
|
2338
2396
|
|
|
2339
2397
|
The three paintbrushes (Vidfarm is thrift-first — do NOT spend AI credits on every scene):
|
|
@@ -5305,6 +5363,354 @@ async function runLocalGreenscreen(ctx, values, sourceArg, presetRaw) {
|
|
|
5305
5363
|
rmSync(downloadDir, { recursive: true, force: true });
|
|
5306
5364
|
}
|
|
5307
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
|
+
}
|
|
5308
5714
|
// ── cutout: generate → key greenscreen → alpha-trim to min bounding box ───────
|
|
5309
5715
|
// The one-shot "make a transparent explainer sticker" verb. Three mechanical
|
|
5310
5716
|
// steps that used to be run by hand (generate an AI graphic on a green plate →
|
|
@@ -7687,6 +8093,28 @@ async function runPostCommand(argv) {
|
|
|
7687
8093
|
assertApiOk(result, "post");
|
|
7688
8094
|
emitResult(result, ctx.json, [["Preview post ", result.json?.post?.share_url]]);
|
|
7689
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
|
+
}
|
|
7690
8118
|
async function runScheduleCommand(argv) {
|
|
7691
8119
|
const parsed = parseArgs({
|
|
7692
8120
|
args: argv,
|
|
@@ -7699,7 +8127,7 @@ async function runScheduleCommand(argv) {
|
|
|
7699
8127
|
if (!parsed.values.at)
|
|
7700
8128
|
throw new Error("schedule requires --at <ISO 8601 timestamp>.");
|
|
7701
8129
|
if (!parsed.values.to)
|
|
7702
|
-
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).");
|
|
7703
8131
|
const ctx = commonContext(parsed.values);
|
|
7704
8132
|
const result = await apiRequest({
|
|
7705
8133
|
method: "POST",
|
|
@@ -7707,7 +8135,7 @@ async function runScheduleCommand(argv) {
|
|
|
7707
8135
|
path: `/api/v1/approved/posts/${encodeURIComponent(postId)}/schedules`,
|
|
7708
8136
|
auth: ctx.auth,
|
|
7709
8137
|
body: {
|
|
7710
|
-
destination_type: parsed.values.type,
|
|
8138
|
+
destination_type: inferDestinationType(argv, parsed.values.type, parsed.values.to),
|
|
7711
8139
|
destination_id: parsed.values.to,
|
|
7712
8140
|
scheduled_at: parsed.values.at,
|
|
7713
8141
|
timezone: parsed.values.timezone,
|
|
@@ -7717,6 +8145,61 @@ async function runScheduleCommand(argv) {
|
|
|
7717
8145
|
assertApiOk(result, "schedule");
|
|
7718
8146
|
emitResult(result, ctx.json);
|
|
7719
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
|
+
}
|
|
7720
8203
|
async function runSchedulesCommand(argv) {
|
|
7721
8204
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: commonOptions() });
|
|
7722
8205
|
const postId = parsed.positionals[0];
|