@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.
@@ -0,0 +1,209 @@
1
+ // Bundle-safe LOCAL deduplication for the published devcli.
2
+ //
3
+ // Deduplication = nudging a finished render's geometry, color, timing and grain
4
+ // by a couple of percent so a platform's duplicate-content fingerprint reads it
5
+ // as a NEW upload, while a human sees no difference. It is what lets one good
6
+ // video be posted across several accounts/slots without the second and third
7
+ // posts being suppressed as reused content.
8
+ //
9
+ // This is a pure ffmpeg op with zero server dependency (exactly like
10
+ // greenscreen-local.ts), so it runs FREE and offline in the shipped CLI. Every
11
+ // transform comes from lib/dedupe-recipe.ts — the SAME module the cloud
12
+ // primitive (services/media-processing.dedupeMediaAsset) uses — so local output
13
+ // matches cloud output knob for knob. No backend module is imported here, which
14
+ // keeps the pack guard happy.
15
+ import { spawn } from "node:child_process";
16
+ import { existsSync, statSync } from "node:fs";
17
+ import path from "node:path";
18
+ import { resolveFfmpeg, resolveFfprobe, hasFfmpeg } from "../services/clip-curation/ffmpeg.js";
19
+ import { buildDedupeFfmpegPlan, dedupeCrfForVariant, describeDedupeEffects, resolveDedupeEffects, DEDUPE_DEFAULT_PRESET } from "../lib/dedupe-recipe.js";
20
+ export { DEDUPE_PRESETS, DEDUPE_DEFAULT_PRESET, describeDedupeEffects, isDedupePresetName, resolveDedupeEffects } from "../lib/dedupe-recipe.js";
21
+ /** True when a usable ffmpeg is available (bundled ffmpeg-static or on PATH). */
22
+ export async function localDedupeAvailable() {
23
+ return hasFfmpeg();
24
+ }
25
+ async function runFfmpeg(bin, args) {
26
+ return new Promise((resolve, reject) => {
27
+ const child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
28
+ let stderr = "";
29
+ child.stderr.on("data", (d) => (stderr += d.toString()));
30
+ child.on("error", reject);
31
+ child.on("close", (code) => resolve({ code: code ?? 0, stderr }));
32
+ });
33
+ }
34
+ async function probe(filePath) {
35
+ const ffprobe = await resolveFfprobe();
36
+ const args = ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", filePath];
37
+ const raw = await new Promise((resolve) => {
38
+ const child = spawn(ffprobe, args, { stdio: ["ignore", "pipe", "ignore"] });
39
+ let out = "";
40
+ child.stdout.on("data", (d) => (out += d.toString()));
41
+ child.on("error", () => resolve(""));
42
+ child.on("close", () => resolve(out));
43
+ });
44
+ let parsed = {};
45
+ try {
46
+ parsed = JSON.parse(raw || "{}");
47
+ }
48
+ catch {
49
+ parsed = {};
50
+ }
51
+ const streams = (Array.isArray(parsed.streams) ? parsed.streams : []);
52
+ const video = streams.find((stream) => stream.codec_type === "video");
53
+ const format = (parsed.format ?? {});
54
+ let fps = null;
55
+ if (typeof video?.avg_frame_rate === "string") {
56
+ const [numerator, denominator] = video.avg_frame_rate.split("/").map(Number);
57
+ if (numerator && denominator)
58
+ fps = numerator / denominator;
59
+ }
60
+ return {
61
+ width: typeof video?.width === "number" ? video.width : null,
62
+ height: typeof video?.height === "number" ? video.height : null,
63
+ durationSec: Number(format.duration ?? video?.duration ?? 0) || 0,
64
+ fps,
65
+ hasAudio: streams.some((stream) => stream.codec_type === "audio"),
66
+ hasVideo: Boolean(video)
67
+ };
68
+ }
69
+ /**
70
+ * Deduplicate an image or video LOCALLY with ffmpeg — free, offline, no wallet.
71
+ * Cloud-parity: identical filter graph to `primitive:media_dedupe` (engine
72
+ * `ffmpeg`).
73
+ *
74
+ * Two things happen beyond the visible transforms:
75
+ * • `-map_metadata -1` strips creation time / encoder / source handler, which
76
+ * some platforms compare BEFORE they compare pixels.
77
+ * • The CRF is walked ±1 per variant, so the coded bitstream differs even
78
+ * where the decoded frames are near-identical.
79
+ *
80
+ * Retries without the `perspective` (skew) stage on ffmpeg builds compiled
81
+ * without vf_perspective rather than failing the whole pass.
82
+ */
83
+ export async function dedupeMediaLocal(input) {
84
+ if (!existsSync(input.sourcePath))
85
+ throw new Error(`No such source file: ${input.sourcePath}`);
86
+ const info = await probe(input.sourcePath);
87
+ const mediaType = input.mediaType
88
+ ?? (info.hasVideo && info.durationSec > 0.25 ? "video" : "image");
89
+ const variant = Math.max(1, Math.round(input.variant ?? 1) || 1);
90
+ const preset = input.preset ?? DEDUPE_DEFAULT_PRESET;
91
+ const effects = resolveDedupeEffects({
92
+ preset,
93
+ effects: input.effects,
94
+ variant,
95
+ seed: input.seed,
96
+ jitter: input.jitter
97
+ });
98
+ const plan = buildDedupeFfmpegPlan({
99
+ effects,
100
+ mediaType,
101
+ width: info.width ?? input.width ?? 1080,
102
+ height: info.height ?? input.height ?? 1920,
103
+ outWidth: input.width ?? info.width ?? null,
104
+ outHeight: input.height ?? info.height ?? null,
105
+ sourceFps: info.fps,
106
+ tintColor: input.tintColor ?? null,
107
+ tintOpacity: input.tintOpacity ?? null
108
+ });
109
+ const crf = dedupeCrfForVariant(input.crf ?? 21, variant, input.seed ?? "");
110
+ const stripMetadata = input.stripMetadata ?? true;
111
+ const isWebp = /\.webp$/i.test(input.outputPath);
112
+ const isJpeg = /\.jpe?g$/i.test(input.outputPath);
113
+ const ffmpeg = await resolveFfmpeg();
114
+ const buildArgs = (videoFilter) => mediaType === "video"
115
+ ? [
116
+ "-hide_banner", "-y",
117
+ "-i", input.sourcePath,
118
+ "-map", "0:v:0",
119
+ "-map", "0:a:0?",
120
+ "-vf", videoFilter,
121
+ ...(plan.audioFilter && info.hasAudio ? ["-af", plan.audioFilter] : []),
122
+ ...(stripMetadata ? ["-map_metadata", "-1"] : []),
123
+ "-c:v", "libx264",
124
+ "-preset", input.x264Preset ?? "veryfast",
125
+ "-crf", String(crf),
126
+ "-pix_fmt", "yuv420p",
127
+ "-movflags", "+faststart",
128
+ "-c:a", "aac",
129
+ "-b:a", `${input.audioBitrateKbps ?? 128}k`,
130
+ "-ar", "48000",
131
+ input.outputPath
132
+ ]
133
+ : [
134
+ "-hide_banner", "-y",
135
+ "-i", input.sourcePath,
136
+ "-frames:v", "1",
137
+ "-vf", videoFilter,
138
+ ...(stripMetadata ? ["-map_metadata", "-1"] : []),
139
+ ...(isWebp ? ["-c:v", "libwebp", "-quality", "92"] : []),
140
+ ...(isJpeg ? ["-q:v", "3"] : []),
141
+ input.outputPath
142
+ ];
143
+ // Distinguish "this recipe has no shear" from "the shear was dropped": only
144
+ // the latter is worth warning about.
145
+ const skewRequested = plan.videoFilter !== plan.videoFilterWithoutSkew;
146
+ let skewApplied = skewRequested;
147
+ let run = await runFfmpeg(ffmpeg, buildArgs(plan.videoFilter));
148
+ if ((run.code !== 0 || !existsSync(input.outputPath)) && skewRequested) {
149
+ // Minimal ffmpeg builds ship without vf_perspective. Losing the shear beats
150
+ // losing the whole dedupe pass.
151
+ skewApplied = false;
152
+ run = await runFfmpeg(ffmpeg, buildArgs(plan.videoFilterWithoutSkew));
153
+ }
154
+ if (run.code !== 0 || !existsSync(input.outputPath)) {
155
+ throw new Error(`Local dedupe failed (ffmpeg exit ${run.code})${tail(run.stderr)}.`);
156
+ }
157
+ const skewDropped = skewRequested && !skewApplied;
158
+ return {
159
+ outputPath: input.outputPath,
160
+ mediaType,
161
+ effects,
162
+ preset,
163
+ variant,
164
+ notes: skewDropped ? [...plan.notes, "skew skipped — this ffmpeg has no vf_perspective"] : plan.notes,
165
+ effectiveZoom: plan.effectiveZoom,
166
+ speed: plan.speed,
167
+ crf,
168
+ skewApplied,
169
+ skewDropped,
170
+ sourceWidth: info.width,
171
+ sourceHeight: info.height,
172
+ bytes: safeSize(input.outputPath)
173
+ };
174
+ }
175
+ /** One-line summary of what a resolved recipe does, for CLI output. */
176
+ export function describeLocalDedupe(result) {
177
+ const summary = describeDedupeEffects(result.effects, result.preset);
178
+ // A rotate forces a bigger centre-crop than asked for (the zoom that hides the
179
+ // black corners). Say so — on a tall frame it is a real framing change.
180
+ if (result.effectiveZoom > result.effects.zoom + 0.0005) {
181
+ const applied = `${((result.effectiveZoom - 1) * 100).toFixed(1)}%`;
182
+ return `${summary} → crops ${applied} to hide the rotate corners (--rotate 0 keeps your framing)`;
183
+ }
184
+ return summary;
185
+ }
186
+ /**
187
+ * Default output path next to a source: `<stem>.dedupe.<ext>` for a one-off,
188
+ * `<stem>.dedupe-02.<ext>` and up for batch variants (variant 1 keeps the plain
189
+ * name so a single-copy run reads cleanly).
190
+ */
191
+ export function defaultDedupeOutPath(sourcePath, variant = 1) {
192
+ const ext = path.extname(sourcePath) || ".mp4";
193
+ const stem = path.basename(sourcePath, ext);
194
+ const suffix = variant > 1 ? `.dedupe-${String(variant).padStart(2, "0")}` : ".dedupe";
195
+ return path.resolve(path.dirname(path.resolve(sourcePath)), `${stem}${suffix}${ext}`);
196
+ }
197
+ function safeSize(filePath) {
198
+ try {
199
+ return statSync(filePath).size;
200
+ }
201
+ catch {
202
+ return 0;
203
+ }
204
+ }
205
+ function tail(stderr) {
206
+ const trimmed = stderr.trim().split("\n").slice(-3).join("\n");
207
+ return trimmed ? `:\n${trimmed}` : "";
208
+ }
209
+ //# sourceMappingURL=dedupe-local.js.map
@@ -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
  ]
@@ -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