@officexapp/vidfarm-devcli 0.21.30 → 0.21.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/vidfarm/SKILL.md +3 -0
- package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-regime.md +12 -0
- package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +18 -4
- package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +2 -1
- package/.agents/skills/vidfarm/references/assets-and-sourcing.md +17 -0
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +17 -4
- package/.agents/skills/vidfarm/references/core-workflows.md +62 -5
- package/.agents/skills/vidfarm/references/editor-workflows.md +1 -1
- package/.agents/skills/vidfarm/references/primitives.md +155 -33
- package/SKILL.director.md +287 -48
- package/SKILL.md +4 -1
- package/dist/src/cli.js +678 -14
- package/dist/src/devcli/dedupe-local.js +209 -0
- package/dist/src/devcli/handoff.js +6 -2
- package/dist/src/devcli/sticker-pack.js +196 -2
- package/dist/src/lib/dedupe-recipe.js +420 -0
- package/package.json +5 -1
|
@@ -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.
|
|
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
|
-
|
|
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
|