@officexapp/vidfarm-devcli 0.21.29 → 0.21.31

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,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 { segmentAlphaComponents, encodeTransparentGif, encodeTransparentAnimatedGif, pickPlateColor, detectPlateColor } from "./devcli/sticker-pack.js";
25
+ import { segmentAlphaComponents, encodeTransparentGif, encodeTransparentAnimatedGif, pickPlateColor, detectPlateColor, keySafeArtInstruction, analyzeKeyedArt, HOLE_WARN_PCT } from "./devcli/sticker-pack.js";
26
26
  import { runDoctorCommand } from "./devcli/doctor.js";
27
27
  import { findFreePort } from "./devcli/port-utils.js";
28
28
  import { scanLocalServers } from "./devcli/process-scan.js";
@@ -351,7 +351,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
351
351
  transparent file (+ stickers.json). One image job for
352
352
  the set = one art style, ~1/N the cost of N cutouts.
353
353
  Items may be any size, from an icon to a full-frame
354
- landscape. Local, free, ffmpeg-only. Image-only.
354
+ landscape. Prompts for key-safe art (solid fills, no
355
+ outline-only/hollow shapes, nothing in a near-plate
356
+ shade) and flags any item that came out hollow.
357
+ Local, free, ffmpeg-only. Image-only.
355
358
  (aliases: stickers, sticker-sheet)
356
359
  --generate "<theme>" AI-generate the sheet first (ONE billed image job for
357
360
  the WHOLE pack) on a spaced grid over a chroma plate
@@ -808,6 +811,19 @@ Marketplace (paid, cloud-only — the bazaar never renders locally):
808
811
  --content-type <a,b> With --search: filter the /raws branch by shot-KIND tag
809
812
  (talking_head,b_roll,product_shot,screen_recording,demo,
810
813
  reaction,interview,establishing,lifestyle,text_graphic)
814
+ recycle <source> PAID. RECYCLE a Reddit or X/Twitter source into reusable JSON —
815
+ "tweet to tiktok" / "reddit to tiktok". Accepts a Reddit thread URL
816
+ (post + comments), a subreddit (r/name or URL -> its threads), an X
817
+ thread URL (tweet + replies), or an X profile (@handle or URL -> their
818
+ posts). Returns items[] with text, author + avatar, stats, permalinks
819
+ and every image/video media URL - unranked and unsummarized, so YOU
820
+ pick what to remix. Brokered through the reddit-lead-gen / x-lead-gen
821
+ OfficeX apps, billed on the credits they consume.
822
+ Flags: --max-records N (spend ceiling, default 100) --cursor <token>
823
+ --query <text> --filter hot|new|top --mode <mode> --network <net>
824
+ --no-comments --no-profile --out ./recycled.json --no-wait
825
+ Aliases: recycle-social, tweet-to-video, reddit-to-video
826
+ -> POST /api/v1/primitives/social/recycle
811
827
  get-file <id> [dest] Resolve a My Files id to its URL and download it
812
828
  --print Print text contents (md/txt/csv/json) instead of saving
813
829
  annotate-file <id|name> Set metadata notes on one My Files entry → PATCH /api/v1/user/me/attachments/:id
@@ -1179,6 +1195,13 @@ async function main() {
1179
1195
  case "download-audio":
1180
1196
  await runDownloadPostAudioCommand(rest);
1181
1197
  return;
1198
+ // "tweet to tiktok" / "reddit to tiktok": decompose a social source to JSON.
1199
+ case "recycle-social":
1200
+ case "tweet-to-video":
1201
+ case "reddit-to-video":
1202
+ case "recycle":
1203
+ await runRecycleCommand(rest);
1204
+ return;
1182
1205
  case "files":
1183
1206
  await runFilesCommand(rest);
1184
1207
  return;
@@ -5303,9 +5326,12 @@ function cutoutPlateInstruction(keyColorHex) {
5303
5326
  : /^#?ff00ff$/i.test(keyColorHex) ? "solid magenta"
5304
5327
  : `a solid flat ${keyColorHex}`;
5305
5328
  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. ` +
5329
+ `like a green-screen plate. ` +
5307
5330
  `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.`);
5331
+ `background, crisp clean edges, sticker/cutout style, single subject, no text. ` +
5332
+ // The plate color is only half the battle — art drawn as a hollow outline
5333
+ // (or in a near-plate shade) gets its middle keyed away too.
5334
+ keySafeArtInstruction(keyColorHex));
5309
5335
  }
5310
5336
  async function runCutoutCommand(argv) {
5311
5337
  const parsed = parseArgs({
@@ -5482,6 +5508,16 @@ async function runCutoutCommand(argv) {
5482
5508
  const shrinkPct = trim.origWidth && trim.origHeight && trim.width && trim.height
5483
5509
  ? Math.round((1 - (trim.width * trim.height) / (trim.origWidth * trim.origHeight)) * 100)
5484
5510
  : null;
5511
+ // Did the key eat the ART as well as the plate? Outline-only art (interior
5512
+ // left as bare plate) or a near-plate fill comes back as a rim around a
5513
+ // see-through hole — invisible until it composites over a real background.
5514
+ const art = await analyzeKeyedArt(trim.outputPath);
5515
+ const hollowNote = art?.hollow
5516
+ ? `${art.hole_pct}% of this cutout is transparent HOLES inside the art. If it's meant to be a ring/frame/donut, ignore this. ` +
5517
+ `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. ` +
5518
+ `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, ` +
5519
+ `fully opaque, no translucency or glow" — and re-generate. (--tolerance lower can rescue a near-shade fill; nothing rescues an empty one.)`
5520
+ : null;
5485
5521
  if (ctx.json) {
5486
5522
  printJson({
5487
5523
  ok: true,
@@ -5495,13 +5531,18 @@ async function runCutoutCommand(argv) {
5495
5531
  trimmed: trim.trimmed,
5496
5532
  area_reduced_pct: shrinkPct,
5497
5533
  bytes: safeSize(trim.outputPath),
5498
- key_color: keyColor
5534
+ key_color: keyColor,
5535
+ hole_pct: art?.hole_pct ?? null,
5536
+ hollow: art?.hollow ?? false,
5537
+ hollow_note: hollowNote
5499
5538
  });
5500
5539
  }
5501
5540
  else {
5502
5541
  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
5542
  if (!trim.trimmed && !noTrim)
5504
5543
  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}`);
5544
+ if (hollowNote)
5545
+ console.log(`${YELLOW}Hollow:${RESET} ${DIM}${hollowNote}${RESET}`);
5505
5546
  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
5547
  }
5507
5548
  }
@@ -5722,8 +5763,9 @@ function stickerSheetInstruction(keyColorHex, count, items) {
5722
5763
  `${named} (${keyColorHex}) background. ${list} ` +
5723
5764
  `CRITICAL: every item must be fully separated from the others by a clear margin of plain ${keyColorHex} background — ` +
5724
5765
  `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.`);
5766
+ `No drop shadows, no reflections, no text, no labels, ` +
5767
+ `no frames or dividing lines between items. One consistent art style across all items, crisp clean edges, sticker/cutout style. ` +
5768
+ keySafeArtInstruction(keyColorHex));
5727
5769
  }
5728
5770
  async function runStickerPackCommand(argv) {
5729
5771
  const parsed = parseArgs({
@@ -5930,16 +5972,37 @@ async function runStickerPackCommand(argv) {
5930
5972
  if (!seg.components.length) {
5931
5973
  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
5974
  }
5975
+ // ---- 3b. Hollow-sticker check -------------------------------------------
5976
+ // The other half of "did the key work": items that came back as a rim around
5977
+ // a transparent hole, because the art was drawn as an outline (interior left
5978
+ // as bare plate) or filled in a near-plate shade. It looks fine on the sheet
5979
+ // and only shows up once the sticker composites over a real background — so
5980
+ // say it here, loudly, with the fix. A ring/donut/frame reads the same way,
5981
+ // which is why this warns and never blocks.
5982
+ let hollowNote = null;
5983
+ if (seg.hollow.length) {
5984
+ 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`);
5985
+ hollowNote =
5986
+ `${seg.hollow.length} of ${seg.components.length} sticker(s) came out mostly hollow: ${named.join(", ")}. ` +
5987
+ `If those are meant to be rings/frames/donuts, ignore this. Otherwise the key ate their FILL: the art was drawn ` +
5988
+ `as an outline with a bare ${keyColor} interior, or filled in a near-${keyColor} shade. Fix it in the PROMPT, not the keyer — ` +
5989
+ `ask for "closed, solidly filled shapes, no outline-only or hollow objects, nothing on the art in ${keyColor} or any ` +
5990
+ `near-shade of it, fully opaque, no translucency or glow" — then re-generate the sheet. ` +
5991
+ `(Lowering --tolerance can rescue a near-shade fill from an existing sheet, but not a genuinely empty one.)`;
5992
+ if (!ctx.json)
5993
+ console.log(`${YELLOW}Hollow:${RESET} ${DIM}${hollowNote}${RESET}`);
5994
+ }
5933
5995
  if (parsed.values["dry-run"]) {
5934
5996
  // Report the boxes without writing stickers — for eyeballing segmentation
5935
5997
  // before spending disk, and for hand-fixing a merged item with `mask --crop`.
5936
5998
  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 });
5999
+ 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
6000
  }
5939
6001
  else {
5940
6002
  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
6003
  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}`);
6004
+ const hollowFlag = c.hole_pct >= HOLE_WARN_PCT ? ` ${YELLOW} ${c.hole_pct}% hollow${RESET}` : "";
6005
+ 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
6006
  }
5944
6007
  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
6008
  }
@@ -5973,10 +6036,14 @@ async function runStickerPackCommand(argv) {
5973
6036
  height: trim.height,
5974
6037
  sheet_crop: { x: c.x, y: c.y, width: c.width, height: c.height },
5975
6038
  area_pct: c.area_pct,
6039
+ holes: c.holes,
6040
+ hole_pct: c.hole_pct,
6041
+ hollow: c.hole_pct >= HOLE_WARN_PCT,
5976
6042
  bytes: safeSize(finalPath)
5977
6043
  });
5978
6044
  if (!ctx.json) {
5979
- console.log(` ${GREEN}✓${RESET} ${path.relative(process.cwd(), finalPath)} ${DIM}(${trim.width}×${trim.height}, ${formatBytes(safeSize(finalPath))})${RESET}`);
6045
+ const hollowFlag = c.hole_pct >= HOLE_WARN_PCT ? ` ${YELLOW} ${c.hole_pct}% hollow${RESET}` : "";
6046
+ console.log(` ${GREEN}✓${RESET} ${path.relative(process.cwd(), finalPath)} ${DIM}(${trim.width}×${trim.height}, ${formatBytes(safeSize(finalPath))})${RESET}${hollowFlag}`);
5980
6047
  }
5981
6048
  }
5982
6049
  // A manifest so the next step (place/keyframes, or an agent picking props by
@@ -5986,6 +6053,8 @@ async function runStickerPackCommand(argv) {
5986
6053
  generated_from: generatePrompt ? "generate" : sourceArg,
5987
6054
  key_color: keyColor,
5988
6055
  key_color_auto: plateAuto,
6056
+ hollow: seg.hollow,
6057
+ hollow_note: hollowNote,
5989
6058
  sheet_width: seg.sourceWidth,
5990
6059
  sheet_height: seg.sourceHeight,
5991
6060
  format: wantGif ? "gif" : stillExt,
@@ -5993,7 +6062,7 @@ async function runStickerPackCommand(argv) {
5993
6062
  stickers: written
5994
6063
  }, null, 2)}\n`);
5995
6064
  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 });
6065
+ 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
6066
  }
5998
6067
  else {
5999
6068
  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}`);
@@ -8254,6 +8323,118 @@ async function runDownloadPostCommand(argv) {
8254
8323
  const final = await pollPrimitiveJob(ctx, jobId);
8255
8324
  emitPrimitiveJobResult(ctx, final, "download-post");
8256
8325
  }
8326
+ async function runRecycleCommand(argv) {
8327
+ const parsed = parseArgs({
8328
+ args: argv,
8329
+ allowPositionals: true,
8330
+ options: {
8331
+ ...commonOptions(),
8332
+ "max-records": { type: "string" },
8333
+ cursor: { type: "string" },
8334
+ query: { type: "string" },
8335
+ filter: { type: "string" },
8336
+ sort: { type: "string" },
8337
+ mode: { type: "string" },
8338
+ network: { type: "string" },
8339
+ "no-comments": { type: "boolean", default: false },
8340
+ "no-profile": { type: "boolean", default: false },
8341
+ out: { type: "string" },
8342
+ "no-wait": { type: "boolean", default: false },
8343
+ tracer: { type: "string" }
8344
+ }
8345
+ });
8346
+ const source = parsed.positionals[0];
8347
+ if (!source) {
8348
+ throw new Error("recycle requires a Reddit or X/Twitter source — a thread URL, a subreddit (r/name), a profile URL, or @handle.");
8349
+ }
8350
+ const maxRecords = parsed.values["max-records"] ? Number(parsed.values["max-records"]) : undefined;
8351
+ if (maxRecords !== undefined && (!Number.isInteger(maxRecords) || maxRecords < 1 || maxRecords > 1000)) {
8352
+ throw new Error("recycle --max-records must be an integer between 1 and 1000.");
8353
+ }
8354
+ const ctx = commonContext(parsed.values);
8355
+ guardBilled(ctx, {
8356
+ label: "recycle a Reddit/X source into JSON (paid plan — brokered via the OfficeX lead-gen apps)",
8357
+ estimate: maxRecords && maxRecords > 100
8358
+ ? "several cents (max-records drives upstream pagination)"
8359
+ : "~$0.002-$0.02 per pull",
8360
+ freeAlternative: "open the thread in a browser and copy the text you want by hand"
8361
+ });
8362
+ // A bare handle/subreddit is ambiguous without a network; a URL is not.
8363
+ const looksLikeUrl = /^https?:\/\//i.test(source) || source.includes(".com/");
8364
+ const payload = looksLikeUrl ? { source_url: source } : { handle: source };
8365
+ if (!looksLikeUrl && parsed.values.network)
8366
+ payload.network = String(parsed.values.network);
8367
+ if (parsed.values.mode)
8368
+ payload.mode = String(parsed.values.mode);
8369
+ if (maxRecords !== undefined)
8370
+ payload.max_records = maxRecords;
8371
+ if (parsed.values.cursor)
8372
+ payload.cursor = String(parsed.values.cursor);
8373
+ if (parsed.values.query)
8374
+ payload.query = String(parsed.values.query);
8375
+ if (parsed.values.filter)
8376
+ payload.filter = String(parsed.values.filter);
8377
+ if (parsed.values.sort)
8378
+ payload.sort = String(parsed.values.sort);
8379
+ if (parsed.values["no-comments"])
8380
+ payload.include_comments = false;
8381
+ if (parsed.values["no-profile"])
8382
+ payload.include_profile = false;
8383
+ const tracer = String(parsed.values.tracer ?? `devcli-recycle-${Date.now().toString(36)}`);
8384
+ const submit = await apiRequest({
8385
+ method: "POST",
8386
+ host: ctx.host,
8387
+ path: "/api/v1/primitives/social/recycle",
8388
+ auth: ctx.auth,
8389
+ body: { tracer, payload }
8390
+ });
8391
+ assertApiOk(submit, "recycle");
8392
+ const jobId = submit.json?.job_id;
8393
+ if (!jobId || parsed.values["no-wait"]) {
8394
+ emitResult(submit, ctx.json);
8395
+ return;
8396
+ }
8397
+ if (!ctx.json) {
8398
+ console.log(`${DIM}Recycling ${source} (${jobId})… the upstream lead-gen job is polled server-side.${RESET}`);
8399
+ }
8400
+ const final = await pollPrimitiveJob(ctx, jobId);
8401
+ // --out writes the FULL normalized payload to disk; the job result inlines
8402
+ // only a prefix when the item list is large.
8403
+ const outPath = parsed.values.out ? String(parsed.values.out) : null;
8404
+ if (outPath) {
8405
+ const result = (final?.result ?? {});
8406
+ const recycledUrl = result.recycled?.url
8407
+ ?? result.primary_file_url;
8408
+ if (typeof recycledUrl === "string" && recycledUrl) {
8409
+ const res = await fetch(recycledUrl);
8410
+ if (res.ok) {
8411
+ mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
8412
+ writeFileSync(path.resolve(outPath), await res.text());
8413
+ if (!ctx.json)
8414
+ console.log(`${DIM}Wrote full recycled JSON → ${outPath}${RESET}`);
8415
+ }
8416
+ else if (!ctx.json) {
8417
+ console.warn(`[vidfarm] could not fetch recycled.json (${res.status}) — the job result still has it.`);
8418
+ }
8419
+ }
8420
+ }
8421
+ if (!ctx.json) {
8422
+ const result = (final?.result ?? {});
8423
+ const counts = (result.counts ?? {});
8424
+ console.log(`${DIM}mode=${String(result.mode ?? "?")} items=${String(counts.items ?? "?")} `
8425
+ + `(posts=${String(counts.posts ?? "?")} comments=${String(counts.comments ?? "?")}) `
8426
+ + `media=${String(counts.media ?? "?")} credits=${String(counts.credits_charged ?? "?")}${RESET}`);
8427
+ for (const job of result.upstreamJobs ?? []) {
8428
+ if (typeof job.view_url === "string" && job.view_url) {
8429
+ console.log(`${DIM} browse ${String(job.endpoint)} in a spreadsheet: ${job.view_url}${RESET}`);
8430
+ }
8431
+ }
8432
+ if (result.nextCursor) {
8433
+ console.log(`${DIM} more available — re-run with --cursor ${String(result.nextCursor).slice(0, 24)}…${RESET}`);
8434
+ }
8435
+ }
8436
+ emitPrimitiveJobResult(ctx, final, "recycle");
8437
+ }
8257
8438
  async function runDownloadPostAudioCommand(argv) {
8258
8439
  const parsed = parseArgs({
8259
8440
  args: argv,
@@ -15,6 +15,7 @@ import { parseArgs } from "node:util";
15
15
  import { detectLocalAgent } from "../services/clip-curation/index.js";
16
16
  import { hasFfmpeg, resolveFfmpeg, resolveFfprobe } from "../services/clip-curation/ffmpeg.js";
17
17
  import { resolveHyperframesCli } from "./hyperframes-cli.js";
18
+ import { applyVidfarmStudioBrand } from "./studio-brand.js";
18
19
  import { resolveSkillsRoot } from "./skills.js";
19
20
  import { readStoredAuth } from "./auth-store.js";
20
21
  import { scanLocalServers, reapProcesses } from "./process-scan.js";
@@ -138,6 +139,18 @@ export async function runDoctorCommand(argv) {
138
139
  add("hyperframes", hyperframesCli ? "ok" : "warn", hyperframesCli
139
140
  ? `${hyperframesCli} (Vidfarm's whitelabel render/animation engine — reach it via \`vidfarm hf …\`)`
140
141
  : "not installed — Vidfarm's render/animation engine (open-source whitelabel). Install for native local render/tts/stt/matting: `npm i -g hyperframes` (else falls back to slow `npx -y hyperframes`)");
142
+ // 3b. Whitelabel the LOCAL studio shell (the `hyperframes preview` editor UI),
143
+ // which otherwise renders the upstream "HeyGen · HyperFrames" header logo and
144
+ // tab title. `doctor` REPAIRS it rather than just reporting: the patch is
145
+ // idempotent and a fresh `npm install` restores the pristine shell.
146
+ if (hyperframesCli) {
147
+ const brand = applyVidfarmStudioBrand();
148
+ add("studio branding", brand === "unavailable" ? "warn" : "ok", brand === "patched"
149
+ ? "local studio shell re-branded as VidFarm (upstream logo/title replaced)"
150
+ : brand === "already-branded"
151
+ ? "local studio shell already whitelabeled as VidFarm"
152
+ : "could not patch the local studio shell — `hyperframes preview` will show upstream branding (cosmetic only)");
153
+ }
141
154
  // 4. Chrome for the in-process render (stills / serve local render).
142
155
  const chrome = detectChromeForRender();
143
156
  add("chrome", chrome.found ? "ok" : "warn", chrome.found ? chrome.detail : `${chrome.detail} — local render/stills will try to download one on first run`);
@@ -22,7 +22,7 @@
22
22
  // Both builders return plain text meant to be shown verbatim to the user, plus a
23
23
  // structured form for `--json` so an agent can render it its own way. No network,
24
24
  // no backend imports — this module is pure string assembly.
25
- import { pickPlateColor } from "./sticker-pack.js";
25
+ import { pickPlateColor, keySafeArtInstruction } from "./sticker-pack.js";
26
26
  const DEFAULT_STYLE = "simple flat vector illustration, minimal detail, 2-3 flat colors, no shadows, no text";
27
27
  /** Pick a sensible grid for N items (roughly square, wider than tall). */
28
28
  function gridFor(count) {
@@ -67,18 +67,22 @@ export function buildImageHandoff(input) {
67
67
  `CRITICAL: every object must be fully separated from the others by a clear margin of plain ${keyColor} background —`,
68
68
  `nothing touching, overlapping or connected, and nothing touching the image edge.`,
69
69
  `One consistent art style, line weight and palette across all objects. Front-facing, centered in its own cell.`,
70
+ // Load-bearing: the user is about to spend their own time on this sheet,
71
+ // and hollow/outline-only art comes back as rims around holes.
72
+ keySafeArtInstruction(keyColor),
70
73
  `Square image, high resolution.`
71
74
  ].join(" ")
72
75
  : [
73
76
  `${input.theme} — ${style} — isolated on a solid pure ${keyColor} background`,
74
77
  `(flat, evenly lit, no gradient, no shadow cast on the background, no text).`,
75
78
  `Center the subject with generous empty margin on all sides, crisp clean edges, single subject.`,
79
+ keySafeArtInstruction(keyColor),
76
80
  `Square image, high resolution.`
77
81
  ].join(" ");
78
82
  const steps = pack
79
83
  ? [
80
84
  "Open a FREE image generator you're already signed into (list below).",
81
- "Paste the prompt below and generate. If any two objects come out touching, re-generate asking for wider spacing — touching objects get cut out as one sticker.",
85
+ "Paste the prompt below and generate. Two checks before you send it back: (a) if any two objects are touching, re-generate asking for wider spacing — touching objects get cut out as ONE sticker; (b) if any object is a hollow outline with the background showing through its middle, re-generate asking for solid fills — that interior gets deleted with the background and the sticker ends up as a rim around a hole.",
82
86
  "Download the image (PNG preferred) and tell me the file path — or drop it in this project folder.",
83
87
  `I'll split it into individual transparent stickers locally for $0: \`vidfarm sticker-pack <sheet> --out-dir ${outDir}\`.`
84
88
  ]
@@ -49,6 +49,18 @@ export async function runHyperframesCommand(subcommand, args, opts = {}) {
49
49
  "Local engines and vidfarm BYOK keys are the only auth.");
50
50
  }
51
51
  const cli = resolveHyperframesCli();
52
+ // The local studio SPA shell ships upstream branding (a "HeyGen · HyperFrames"
53
+ // header logo and a "HyperFrames Studio" tab title) that `hyperframes preview`
54
+ // serves straight off disk — the one Vidfarm surface the web editor's own
55
+ // rebrand never reaches. Whitelabel the shell on EVERY hyperframes spawn, not
56
+ // just preview: the patch is a cheap idempotent file check, and doing it here
57
+ // means a director who later runs `hyperframes preview` by hand still gets the
58
+ // VidFarm studio. Best-effort — branding never fails a render.
59
+ try {
60
+ const { applyVidfarmStudioBrand } = await import("./studio-brand.js");
61
+ applyVidfarmStudioBrand();
62
+ }
63
+ catch { /* cosmetic only */ }
52
64
  // Fresh empty config dir per invocation — guarantees no ~/.heygen credential
53
65
  // (or any other persisted account state) leaks into the child process.
54
66
  const emptyConfigDir = mkdtempSync(path.join(os.tmpdir(), "vidfarm-hf-noauth-"));
@@ -132,6 +132,42 @@ export async function detectPlateColor(sourcePath, opts = {}) {
132
132
  const hex = `#${avg.map((v) => v.toString(16).padStart(2, "0")).join("").toUpperCase()}`;
133
133
  return { hex, rgb: avg };
134
134
  }
135
+ // ── Key-safe ART instruction ─────────────────────────────────────────────────
136
+ // Picking a plate the art doesn't use (above) is only HALF of surviving a chroma
137
+ // key. The other half is what the art is made of, and it's the failure we see in
138
+ // the wild: an image model hears "sticker on a green plate" and draws OUTLINE
139
+ // art — a colored stroke with the shape's interior left as bare background. On
140
+ // screen that looks fine. After the key, the interior is gone, and the sticker
141
+ // composites as a rim floating around a see-through hole.
142
+ //
143
+ // The same failure arrives three other ways: a fill that's a near-shade of the
144
+ // plate (keyed by tolerance, not by exact match), a translucent/glassy material
145
+ // that lets the plate through, and a soft glow/drop-shadow that fades INTO the
146
+ // plate at the edges.
147
+ //
148
+ // All four are prompt-preventable, so every generation path that mints art
149
+ // destined for a key (cutout, sticker-pack, the hand-off brief the user pastes
150
+ // into a free web tool) appends this clause. Detection after the fact is the net
151
+ // (`detectEnclosedHoles` below) — this is the mechanism.
152
+ /**
153
+ * The "your art has to survive the key" clause, worded for an image model.
154
+ * Append to any prompt whose output will be chroma-keyed on `keyColorHex`.
155
+ */
156
+ export function keySafeArtInstruction(keyColorHex) {
157
+ const hex = keyColorHex.toUpperCase();
158
+ return (`KEY-SAFE ARTWORK (the ${hex} background gets deleted, so anything ${hex} on the art is deleted too): ` +
159
+ `every object must be a CLOSED, SOLIDLY FILLED shape — outlines and strokes must enclose an opaque fill of a ` +
160
+ `different color. NO outline-only / hollow / line-art objects, and never leave a shape's interior as bare ` +
161
+ `background. No part of any object — fill, outline, highlight, gradient, glow, shading or detail — may be ${hex} ` +
162
+ `or any near-shade, tint or tone of ${hex}. No transparent, translucent, glassy, glowing, misty or ghosted ` +
163
+ `materials; every pixel of every object is fully opaque. No soft glows, blurs or drop shadows fading into the ` +
164
+ `background. Keep the whole palette in strong contrast to ${hex}. The background must be visible ONLY around the ` +
165
+ `outside of the objects, never showing through inside them.`);
166
+ }
167
+ /** At/above this `hole_pct` a sticker is worth warning about: past ~a fifth of
168
+ * its own box, "the key ate the fill" is far more likely than "the artist drew
169
+ * a ring". Tuned to stay quiet on letter counters, handles and small gaps. */
170
+ export const HOLE_WARN_PCT = 20;
135
171
  /** Read a still's alpha plane as raw 8-bit luma at a given size (bundle-safe:
136
172
  * ffmpeg's `alphaextract` writes alpha as luma; rawvideo skips any decoding on
137
173
  * our side). Returns exactly width*height bytes. */
@@ -199,6 +235,93 @@ function dilate(mask, w, h, radius) {
199
235
  }
200
236
  return out;
201
237
  }
238
+ /**
239
+ * Find transparent islands that are fully SURROUNDED by opaque art — the exact
240
+ * signature of the "hollow sticker" bug: art drawn as an outline (or filled in a
241
+ * near-plate shade) has its middle deleted by the key, and composites as a rim
242
+ * around a see-through hole.
243
+ *
244
+ * Mechanically it's the complement of a background flood-fill: every transparent
245
+ * pixel reachable from the mask's border is the plate doing its job; every
246
+ * transparent pixel that is NOT reachable is a hole punched inside something.
247
+ * 4-connected on purpose — an 8-connected fill leaks through a 1px diagonal
248
+ * seam in antialiased line art and would under-report every real hole.
249
+ *
250
+ * Note this cannot distinguish a bug from a deliberate ring/donut/picture-frame,
251
+ * so callers WARN on the result, never reject it.
252
+ */
253
+ export function detectEnclosedHoles(mask, w, h, opts = {}) {
254
+ const reachable = new Uint8Array(w * h);
255
+ const queue = new Int32Array(w * h);
256
+ let head = 0;
257
+ let tail = 0;
258
+ const push = (p) => {
259
+ if (!mask[p] && !reachable[p]) {
260
+ reachable[p] = 1;
261
+ queue[tail++] = p;
262
+ }
263
+ };
264
+ for (let x = 0; x < w; x++) {
265
+ push(x);
266
+ push((h - 1) * w + x);
267
+ }
268
+ for (let y = 0; y < h; y++) {
269
+ push(y * w);
270
+ push(y * w + (w - 1));
271
+ }
272
+ while (head < tail) {
273
+ const p = queue[head++];
274
+ const py = (p / w) | 0;
275
+ const px = p - py * w;
276
+ if (px > 0)
277
+ push(p - 1);
278
+ if (px < w - 1)
279
+ push(p + 1);
280
+ if (py > 0)
281
+ push(p - w);
282
+ if (py < h - 1)
283
+ push(p + w);
284
+ }
285
+ // Anything transparent and unreached is enclosed — group it into islands.
286
+ const minAreaPx = Math.max(1, Math.round(opts.minAreaPx ?? 4));
287
+ const seen = new Uint8Array(w * h);
288
+ const holes = [];
289
+ const stack = new Int32Array(w * h);
290
+ for (let start = 0; start < mask.length; start++) {
291
+ if (mask[start] || reachable[start] || seen[start])
292
+ continue;
293
+ let top = 0;
294
+ stack[top++] = start;
295
+ seen[start] = 1;
296
+ const hole = { minX: w, minY: h, maxX: -1, maxY: -1, area: 0 };
297
+ while (top > 0) {
298
+ const p = stack[--top];
299
+ const py = (p / w) | 0;
300
+ const px = p - py * w;
301
+ hole.area++;
302
+ if (px < hole.minX)
303
+ hole.minX = px;
304
+ if (px > hole.maxX)
305
+ hole.maxX = px;
306
+ if (py < hole.minY)
307
+ hole.minY = py;
308
+ if (py > hole.maxY)
309
+ hole.maxY = py;
310
+ const neighbors = [px > 0 ? p - 1 : -1, px < w - 1 ? p + 1 : -1, py > 0 ? p - w : -1, py < h - 1 ? p + w : -1];
311
+ for (const q of neighbors) {
312
+ if (q < 0 || mask[q] || reachable[q] || seen[q])
313
+ continue;
314
+ seen[q] = 1;
315
+ stack[top++] = q;
316
+ }
317
+ }
318
+ // Sub-threshold islands are antialiasing dropouts and despill speckle, not
319
+ // a missing fill — a real hollow interior is orders of magnitude bigger.
320
+ if (hole.area >= minAreaPx)
321
+ holes.push(hole);
322
+ }
323
+ return holes;
324
+ }
202
325
  /**
203
326
  * Find every item on a keyed plate by segmenting its alpha channel into
204
327
  * connected islands of opaque pixels — the automatic replacement for measuring
@@ -312,22 +435,93 @@ export async function segmentAlphaComponents(input) {
312
435
  // ---- Map sample-space boxes back to source pixels --------------------------
313
436
  // One sample pixel of slack on each side covers the downscale's rounding, so a
314
437
  // subject's outermost antialiased edge never gets clipped off.
438
+ // ---- Attribute enclosed holes to the item that surrounds them --------------
439
+ // A hole lives strictly inside the art that encloses it, so its center falls
440
+ // in that item's box. Boxes can nest (a small icon inside a big backdrop), so
441
+ // the SMALLEST containing box wins — the nearest enclosing art is the owner.
442
+ const holes = detectEnclosedHoles(mask, sw, sh, { minAreaPx: Math.max(6, Math.round(total * 0.00005)) });
443
+ const holeArea = new Array(ordered.length).fill(0);
444
+ const holeCount = new Array(ordered.length).fill(0);
445
+ for (const hole of holes) {
446
+ const cx = (hole.minX + hole.maxX) / 2;
447
+ const cy = (hole.minY + hole.maxY) / 2;
448
+ let owner = -1;
449
+ let ownerArea = Infinity;
450
+ for (let i = 0; i < ordered.length; i++) {
451
+ const b = ordered[i];
452
+ if (cx < b.minX || cx > b.maxX || cy < b.minY || cy > b.maxY)
453
+ continue;
454
+ const boxArea = (b.maxX - b.minX + 1) * (b.maxY - b.minY + 1);
455
+ if (boxArea < ownerArea) {
456
+ owner = i;
457
+ ownerArea = boxArea;
458
+ }
459
+ }
460
+ if (owner >= 0) {
461
+ holeArea[owner] += hole.area;
462
+ holeCount[owner]++;
463
+ }
464
+ }
315
465
  const inv = 1 / scale;
316
466
  const components = ordered.map((b, i) => {
317
467
  const x0 = Math.max(0, Math.floor((b.minX - 1) * inv));
318
468
  const y0 = Math.max(0, Math.floor((b.minY - 1) * inv));
319
469
  const x1 = Math.min(dims.width, Math.ceil((b.maxX + 2) * inv));
320
470
  const y1 = Math.min(dims.height, Math.ceil((b.maxY + 2) * inv));
471
+ // Measured against the item's own silhouette (fill + holes), not the plate:
472
+ // "a fifth of THIS sticker is missing" is the question that matters.
473
+ const silhouette = b.area + holeArea[i];
321
474
  return {
322
475
  index: i + 1,
323
476
  x: x0,
324
477
  y: y0,
325
478
  width: Math.max(1, x1 - x0),
326
479
  height: Math.max(1, y1 - y0),
327
- area_pct: Math.round((b.area / total) * 1000) / 10
480
+ area_pct: Math.round((b.area / total) * 1000) / 10,
481
+ holes: holeCount[i],
482
+ hole_pct: silhouette > 0 ? Math.round((holeArea[i] / silhouette) * 1000) / 10 : 0
328
483
  };
329
484
  });
330
- return { components, sourceWidth: dims.width, sourceHeight: dims.height, sampleWidth: sw, sampleHeight: sh, rejected };
485
+ const hollow = components.filter((c) => c.hole_pct >= HOLE_WARN_PCT).map((c) => c.index);
486
+ return { components, sourceWidth: dims.width, sourceHeight: dims.height, sampleWidth: sw, sampleHeight: sh, rejected, hollow };
487
+ }
488
+ /**
489
+ * The single-subject version of the hollow-sticker check: does THIS keyed still
490
+ * have transparency punched through the middle of its art? Used by `cutout`,
491
+ * which has one subject and so needs no segmentation — just the same
492
+ * background-flood-fill complement over the whole alpha plane.
493
+ */
494
+ export async function analyzeKeyedArt(sourcePath) {
495
+ if (!existsSync(sourcePath))
496
+ return null;
497
+ const dims = await probeImageDimensions(sourcePath);
498
+ if (!dims)
499
+ return null;
500
+ const longSide = Math.max(dims.width, dims.height);
501
+ const scale = longSide > 640 ? 640 / longSide : 1;
502
+ const sw = Math.max(1, Math.round(dims.width * scale));
503
+ const sh = Math.max(1, Math.round(dims.height * scale));
504
+ let alpha;
505
+ try {
506
+ alpha = await readAlphaPlane(sourcePath, sw, sh);
507
+ }
508
+ catch {
509
+ return null; // no alpha channel to read → nothing to say
510
+ }
511
+ const mask = new Uint8Array(sw * sh);
512
+ let opaque = 0;
513
+ for (let i = 0; i < mask.length; i++)
514
+ if (alpha[i] > 8) {
515
+ mask[i] = 1;
516
+ opaque++;
517
+ }
518
+ if (!opaque)
519
+ return null;
520
+ const total = sw * sh;
521
+ const holes = detectEnclosedHoles(mask, sw, sh, { minAreaPx: Math.max(6, Math.round(total * 0.00005)) });
522
+ const holeArea = holes.reduce((sum, h) => sum + h.area, 0);
523
+ const hole_pct = Math.round((holeArea / (opaque + holeArea)) * 1000) / 10;
524
+ return { holes: holes.length, hole_pct, hollow: hole_pct >= HOLE_WARN_PCT };
331
525
  }
332
526
  /**
333
527
  * Re-encode a transparent still as a transparent GIF — the format a lot of