@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/.agents/skills/vidfarm/SKILL.md +2 -0
- package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +18 -4
- package/.agents/skills/vidfarm/references/assets-and-sourcing.md +17 -0
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +6 -3
- package/.agents/skills/vidfarm/references/editor-workflows.md +1 -1
- package/.agents/skills/vidfarm/references/primitives.md +80 -1
- package/SKILL.director.md +124 -9
- package/SKILL.md +2 -1
- package/dist/src/cli.js +192 -11
- package/dist/src/devcli/doctor.js +13 -0
- package/dist/src/devcli/handoff.js +6 -2
- package/dist/src/devcli/hyperframes-cli.js +12 -0
- package/dist/src/devcli/sticker-pack.js +196 -2
- package/dist/src/devcli/studio-brand.js +196 -0
- package/package.json +5 -1
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|