@officexapp/vidfarm-devcli 0.21.31 → 0.21.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -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;
@@ -2330,10 +2387,11 @@ Rules:
2330
2387
  - When swapping visuals, match both the literal scene DNA and the narrative purpose of the beat.
2331
2388
  - For replacement graphics, screenshots, or still-like scenes, prefer AI image generation plus Ken Burns before paying for AI video unless static_vs_pivot says motion footage is load-bearing.
2332
2389
  - If narration must be customized, default to premium ElevenLabs first, then the user's own ElevenLabs path, then BYOK OpenAI/Gemini/OpenRouter. If captions or scenes were timed to the old VO, retime them to the new narration.
2333
- - NO HTML SLOP. You are editing HTML, but the output is a social video, not a web page. Never author landing-page furniture: CTA "buttons" (a filled/gradient rounded capsule with action copy like "Sign Up for a Free Trial →"), benefit chip/badge rows ("✓ No Credit Card Needed"), bordered/shadowed/frosted cards holding a headline + URL, gradient text fills, feature grids, bulleted lists, or web-default fonts (Inter/Roboto/Arial/system-ui). None of that appears in a real TikTok, and nothing in a video is clickable — say it as timed text on the footage instead. Arrows, scribble/underline marks, italics, ALL-CAPS, single-word color pops, emoji, transparent cut-out stickers, and mock social UI (iMessage bubbles, comment cards) are all fine. Captions use an imported family (Montserrat default / TikTok Sans / Abel / Source Code Pro / Yesteryear) at weight 700-900, ~36-64px on a 1080-wide frame, inside the 8%-85% safe zone, with exactly one of four backgrounds: outline, plain, an active-word spotlight/karaoke pill, or a tight-hugging solid band (radius <=8px, no border/shadow/gradient/blur).
2390
+ - NO HTML SLOP. You are editing HTML, but the output is a social video, not a web page. THE TEST IS THE NATIVE-EDITOR TEST: could you have made this element with the tools inside TikTok's own editor? That toolset is a font, a color, a stroke/outline, a soft shadow, a tight text box, alignment, opacity, rotation, animation presets — plus stickers, emoji, drawn marks and clips. It has NO padded capsule, NO border, NO gradient fill, NO blur panel, NO card. If you reached past it, cut it. Never author landing-page furniture: CTA "buttons" (a filled/gradient rounded capsule with action copy like "Sign Up for a Free Trial →"), benefit chip/badge rows ("✓ No Credit Card Needed"), bordered/shadowed/frosted cards holding a headline + URL, gradient text fills, feature grids, bulleted lists, or web-default fonts (Inter/Roboto/Arial/system-ui). AND NOT A SINGLE PILL EITHER: one lonely rounded, padded, filled capsule around a static stat or label — "10 hrs / week", "STEP 2", "EP.01", "+40%" — is a web badge, and being the only one on screen does not make it native. The ONLY legitimate capsule in a video is the active-word spotlight/karaoke caption highlight, because it moves with the spoken word. Emphasize a stat the way the editor would: bigger, heavier, ALL-CAPS, an accent color, a hand-drawn circle or underline, or its own beat on screen. Rule of thumb on anything holding words: border-radius over ~8px PLUS a background fill PLUS padding = a badge; drop the fill or drop the radius until the band hugs the glyphs. None of this appears in a real TikTok, and nothing in a video is clickable — say it as timed text on the footage instead. Arrows, scribble/underline marks, italics, ALL-CAPS, single-word color pops, emoji, transparent cut-out stickers, and mock social UI (iMessage bubbles, comment cards) are all fine. Captions use an imported family (Montserrat default / TikTok Sans / Abel / Source Code Pro / Yesteryear) at weight 700-900, ~36-64px on a 1080-wide frame, inside the 8%-85% safe zone, with exactly one of four backgrounds: outline, plain, an active-word spotlight/karaoke pill, or a tight-hugging solid band (radius <=8px, no border/shadow/gradient/blur).
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 <destination_id> (a FlockPoster channel or email).");
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];