@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/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 { segmentAlphaComponents, encodeTransparentGif, encodeTransparentAnimatedGif, pickPlateColor, detectPlateColor } from "./devcli/sticker-pack.js";
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. Local, free, ffmpeg-only. Image-only.
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. The subject must contain NONE of that background color anywhere on it. ` +
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 item may contain that background color anywhere on it. No drop shadows, no reflections, no text, no labels, ` +
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
- 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}`);
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
- console.log(` ${GREEN}✓${RESET} ${path.relative(process.cwd(), finalPath)} ${DIM}(${trim.width}×${trim.height}, ${formatBytes(safeSize(finalPath))})${RESET}`);
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 <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).");
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,