@melite/models-mask 0.0.0-stage → 0.1.5-pre-alpha

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/lib/actions.js ADDED
@@ -0,0 +1,502 @@
1
+ import { impactDetector } from '@melite/comfyui-card-kit/impact';
2
+ const isSpell = (s) => s.startsWith('$');
3
+ const personDetector = impactDetector('person');
4
+ const faceDetector = impactDetector('face');
5
+ const handDetector = impactDetector('hand');
6
+ /** The family's three weight names — the lane's own constants (the
7
+ * LANE_FILES pattern, the h3 precedent: clip/vae_video/vae_audio
8
+ * never ride a row either). No variant of this family swaps a
9
+ * weight and no user picks one, so they live beside the build that
10
+ * wires them; the card's provisions declare the downloads, and
11
+ * image/mask/tests/card.spec.ts pins the agreement (a weight the
12
+ * lane loads that no provision downloads is a silent
13
+ * wrong-weights run — the same law the variant cells carry). */
14
+ export const MASK_WEIGHTS = {
15
+ checkpoint: 'sam3.1_multiplex_fp16.safetensors',
16
+ unet: 'rt_detr_v4-x-hgnet_fp32.safetensors',
17
+ sam2: 'sam2.1_hiera_large.pt',
18
+ };
19
+ /** The family's facts door: the lane reads NOTHING off a row (the
20
+ * three weights are the family's constants above), so the door
21
+ * returns them closed — an empty loader name is unrepresentable,
22
+ * which is the old refusal law (a pin that is not a non-empty
23
+ * string refused here) held by construction. The registry still
24
+ * exercises this door at registration over the empty family row.
25
+ * @param _raw the resolved lane_inputs (empty on this family)
26
+ * @returns the three weights the lane wires into its loaders */
27
+ export function parseMaskLaneFacts(_raw) {
28
+ return { checkpoint: MASK_WEIGHTS.checkpoint, unet: MASK_WEIGHTS.unet, sam2: MASK_WEIGHTS.sam2 };
29
+ }
30
+ /** THE TUNE VOCABULARY IS DERIVED FROM ITS OWN TYPE (the census's
31
+ * L1): the runtime list and the compile-time type were written side
32
+ * by side, so adding a key to one and not the other was silent — the
33
+ * gate accepted a key the type did not carry (or refused one it
34
+ * did). The keys are the type's own members now; the assertion
35
+ * below fails if the two ever part company. */
36
+ export const TUNE_KEYS = [
37
+ 'threshold', 'refine', 'dilation', 'detection_threshold', 'box_expansion',
38
+ 'hint_threshold', 'detection_dilation', 'crop_factor', 'drop_size',
39
+ ];
40
+ /** THE MASK SELECTION ACTION (the family's one declared lane): the
41
+ * input_image admission stays on the card; this is the lane alone.
42
+ * TWO selection families: the SAM3 CLIP/RT-DETR leg (checkpoint +
43
+ * unet weights) and the SAM2 impact-pack arm (sam2 + the pack's own
44
+ * specialized detectors — person on segm, face/hand on their bbox
45
+ * models — the detector proposes, SAM2 refines). */
46
+ export const select = {
47
+ id: "select",
48
+ inputs: [],
49
+ outputs: [{ name: "default", kind: "MASK" }],
50
+ params: [],
51
+ facts: parseMaskLaneFacts,
52
+ build({ p, facts, g, runId }) {
53
+ const li = { checkpoint: facts.checkpoint, unet: facts.unet, sam2: facts.sam2 }; // the family's weight constants (MASK_WEIGHTS above)
54
+ const prefix = `${runId}/mask`; // the arm's "%s/mask" % run_id
55
+ const samThreshold = p.sam_threshold;
56
+ // undeclared legacy renderer keys ride the door's explicit-params
57
+ // overlay (see schema header) — schema-required paint_mask renders
58
+ // ''-absent at compose (image-gen precedent), refine_iterations
59
+ // falls back to its old schema pin 2 when no explicit names it.
60
+ const refineIterations = (p.refine_iterations ?? 2);
61
+ const dilation = p.sam_dilation;
62
+
63
+ // ImpactSimpleDetectorSEGS: sub_bbox_expansion +
64
+ // sam_mask_hint_threshold were hard-pinned and unexposed): the
65
+ // lane-flat seat is the PRIMARY sam2's fold; absent (a sam3
66
+ // primary, pre-fold benches) = the pack's own defaults.
67
+ const bboxExpansion = p.box_expansion ?? 0;
68
+ const hintThreshold = p.hint_threshold ?? 0.7;
69
+
70
+ // three hard-pinned knobs): node-faithful defaults (10 / 3 / 10 —
71
+ // what the lane always shipped), now per-entry overridable.
72
+ const detectionDilation = p.detection_dilation ?? 10;
73
+ const cropFactor = p.crop_factor ?? 3.0;
74
+ const dropSize = p.drop_size ?? 10;
75
+ const exportBoxes = p.export_boxes;
76
+ const viaRtdetr = p.primary_via_rtdetr;
77
+ // the lazy loader trio (see the header note — created on first use)
78
+ // THE SPELLED ANCHOR (the mask-as-consumer half of the gap
79
+ // audit: a producer-wired input_image rides every tensor slot
80
+ // verbatim, never a LoadImage — ROW-4 refuses producer links in
81
+ // file slots, so the loader builds for paths only).
82
+ let imgH = null;
83
+ let ckpt = null;
84
+ let rtdetrModel = null;
85
+ const img = () => (imgH ??= isSpell(p.input_image)
86
+ ? p.input_image
87
+ : g.node('LoadImage', { image: p.input_image }).out(0));
88
+ const ckptNode = () => (ckpt ??= g.node('CheckpointLoaderSimple', { ckpt_name: li.checkpoint }));
89
+ const rtdetrLoader = () => (rtdetrModel ??= g.node('UNETLoader', { unet_name: li.unet, weight_dtype: 'default' }).out(0));
90
+ const clipCond = (text) => g.node('CLIPTextEncode', { text, clip: ckptNode().out(1) }).out(0);
91
+ const detect = (conditioning, bboxes, tune) => {
92
+ const inputs = {
93
+ model: ckptNode().out(0),
94
+ image: img(),
95
+ threshold: tune?.threshold ?? samThreshold,
96
+ refine_iterations: tune?.refine ?? refineIterations,
97
+ individual_masks: false,
98
+ };
99
+ if (conditioning)
100
+ inputs.conditioning = conditioning;
101
+
102
+ // the old DEFAULT_BBOXES autofilled {0,0,512,512} on every
103
+ // unscoped detection — a top-left QUARTER "hint" on any image
104
+ // larger than 512, silently blinding the selection everywhere
105
+ // else (an empty mask on a 1024px input, proven live).
106
+ // Unscoped = bboxes rides ABSENT = search the WHOLE image; the
107
+ // drawn Region scope (and RT-DETR's own boxes) still thread as
108
+ // the explicit hint when they exist.
109
+ if (bboxes !== undefined)
110
+ inputs.bboxes = bboxes;
111
+ return g.node('SAM3_Detect', inputs).out(0); // (masks, bboxes) → masks
112
+ };
113
+ const dilate = (mask, d) => (d ?? dilation) ? g.node('GrowMask', { mask, expand: d ?? dilation, tapered_corners: true }).out(0) : mask;
114
+ // the lane's declared output is the mask it saved last (the card's
115
+ // output binding reads that SaveImage); the handle rides the same
116
+ // node, so nothing is created, moved or renumbered by declaring it
117
+ let saved = null;
118
+ const save = (mask, name, d) => {
119
+ const m2i = g.node('MaskToImage', { mask: dilate(mask, d) });
120
+ saved = g.node('SaveImage', { images: m2i.out(0), filename_prefix: `${prefix}/${name}` }).out(0);
121
+ };
122
+ // THE RECT SCOPE (the drawn box — the bbox-canvas leaf's
123
+ // [x, y, w, h] pixels): a zeros W×H mask plus a ones w×h box at
124
+ // (x, y) (MaskComposite add), multiplied over the selection. CORE
125
+ // nodes only (SolidMask + MaskComposite + GetImageSizeAndCount —
126
+ // all live on every engine; the scope never depends on the
127
+ // impact arm that made the mask).
128
+ const rectMask = (scope) => {
129
+ const size = g.node('GetImageSizeAndCount', { image: img() });
130
+ const zeros = g.node('SolidMask', { value: 0, width: size.out(1), height: size.out(2) }).out(0);
131
+ const ones = g.node('SolidMask', { value: 1, width: scope.width, height: scope.height }).out(0);
132
+ return g.node('MaskComposite', { destination: zeros, source: ones, x: scope.x, y: scope.y, operation: 'add' }).out(0);
133
+ };
134
+ const scopeMask = (mask, scope) => g.node('MaskComposite', { destination: mask, source: rectMask(scope), x: 0, y: 0, operation: 'multiply' }).out(0);
135
+ // THE SAM2 IMPACT-PACK ARM (the operator's SAM2: the detector
136
+ // proposes + SAM2 refines; the mask card declares the packs +
137
+ // weights, the compose door refuses without them — never a
138
+ // silent CLIP leg). UltralyticsDetectorProvider (subpack, the
139
+ // card's PINNED detector model: segm/person for person,
140
+ // bbox/face + bbox/hand for face/hand) → SegmDetectorSEGS or
141
+ // BboxDetectorSEGS (image + the detector boxes + the card's
142
+ // labels) → SEGS → SAMDetectorCombined (PINNED sam2 model,
143
+ // center-1 — the pack's own best-practice default) → MASK. The
144
+ // drawn box scopes after (the rect scope above).
145
+ let samH = null;
146
+ let segmH = null;
147
+ let faceH = null;
148
+ let handH = null;
149
+ const samLoader = () => (samH ??= g.node('SAMLoader', { model_name: li.sam2, device_mode: 'AUTO' }).out(0));
150
+
151
+ // pack's own detailer-pipe-test wires SegmDetectorSEGS — a
152
+ // segmentation detector whose boxes come from CLASS-PIXEL masks
153
+ // (tighter crops, no center-point guess). The detector model
154
+ // comes from the card kit's detector catalog
155
+
156
+ // downloaded + provision-declared that night).
157
+ const segmProvider = () =>
158
+
159
+ // wiring it into SegmDetectorSEGS.segm_detector 400s the run
160
+ // with return_type_mismatch; the provider's own output list
161
+ // is ["BBOX_DETECTOR", "SEGM_DETECTOR"]).
162
+ (segmH ??= g.node(personDetector.providerNode, { model_name: personDetector.graphPath }).out(personDetector.providerOutput));
163
+ const faceProvider = () => (faceH ??= g.node(faceDetector.providerNode, { model_name: faceDetector.graphPath }).out(faceDetector.providerOutput));
164
+ const handProvider = () => (handH ??= g.node(handDetector.providerNode, { model_name: handDetector.graphPath }).out(handDetector.providerOutput));
165
+
166
+ // "ONLY the ones shown in the Impact Pack examples" — person,
167
+ // face, hand, each on the pack's OWN specialized detector, exactly
168
+ // as detailer-pipe-test.json wires them). A mixed set (person +
169
+ // face) cannot share one detector call — split it across two sam2
170
+ // entries, each with its own export. Refuses LOUD, never a
171
+ // half-detected entry.
172
+ const sam2detect = (labels, scope, tune) => {
173
+ if (labels === '') {
174
+ throw new Error('maskT1: a sam2 selection needs labels (a detector class word — the card refuses empty)');
175
+ }
176
+ const words = labels.split(',').map((w) => w.trim()).filter((w) => w !== '');
177
+ const fams = new Set(words.map((w) => w === 'person' ? 'segm' : 'bbox'));
178
+ if (fams.size > 1) {
179
+ throw new Error(`maskT1: one detector per sam2 entry — '${labels}' mixes detector families (person rides segm, face/hand ride bbox). Split it across two sam2 entries.`);
180
+ }
181
+ const segmLane = words[0] === 'person';
182
+ // (ImpactSimpleDetectorSEGS was AUDITED and REJECTED for this
183
+ // lane: it carries NO labels input — the class filter, the
184
+ // operator's core demand, cannot ride it. The pack's own
185
+ // labels-filtering test workflow (detailer-pipe-test.json)
186
+ // wires BboxDetectorSEGS + SAMDetectorCombined exactly as
187
+ // this lane does.)
188
+ // THE WORD→FACTS MAP (the stroke's pin law, verbatim shape):
189
+ // person rides the segm facts, face its bbox facts, EVERYTHING
190
+ // else (hand, the 'all' wildcard, unstated words) the hand bbox
191
+ // facts with labels verbatim — the pre-stroke ternary's exact
192
+ // graph, only the pin source moved (caller strings → pack facts).
193
+ const detector = segmLane ? personDetector
194
+ : words[0] === 'face' ? faceDetector : handDetector;
195
+ const provider = segmLane ? segmProvider()
196
+ : words[0] === 'face' ? faceProvider() : handProvider();
197
+ const segs = g.node(detector.detectorNode, {
198
+ [detector.detectorInput]: provider,
199
+ image: img(),
200
+ threshold: (tune?.detection_threshold ?? p.detection_threshold ?? 0.5),
201
+ dilation: tune?.detection_dilation ?? detectionDilation,
202
+ crop_factor: tune?.crop_factor ?? cropFactor,
203
+ drop_size: tune?.drop_size ?? dropSize,
204
+ labels,
205
+ }).out(0);
206
+ // THE PER-INSTANCE CROPS (the gap audit's #3/#4 export half):
207
+ // SEGSToImageList unwraps one crop per detected instance —
208
+ // PreviewImage saves each — the per-instance readback the
209
+ // pack's own examples ride (SEGSToImageList in
210
+ // detailer-pipe-test + 5-prompt-per-tile). Automatic: the
211
+ // crops ARE the manifest's granular sibling.
212
+ g.node('SaveImage', {
213
+ images: g.node('SEGSToImageList', { segs }).out(0),
214
+ filename_prefix: `${runId}/instance`,
215
+ });
216
+ const refined = g.node('SAMDetectorCombined', {
217
+ sam_model: samLoader(),
218
+ segs,
219
+ image: img(),
220
+ detection_hint: 'center-1',
221
+ dilation: tune?.dilation ?? dilation,
222
+ threshold: tune?.threshold ?? samThreshold,
223
+ bbox_expansion: tune?.box_expansion ?? bboxExpansion,
224
+ mask_hint_threshold: tune?.hint_threshold ?? hintThreshold,
225
+ mask_hint_use_negative: 'False',
226
+ }).out(0);
227
+ const mask = scope ? scopeMask(refined, scope) : refined;
228
+ return { mask, segs };
229
+ };
230
+ let primaryMask = null;
231
+ let primarySegs = null;
232
+ // THE PRIMARY SCOPE (the first standalone entry's drawn box —
233
+ // the card threads it as scope_bbox; absent = unscoped). Every
234
+ // SELECTION primary honors it; the paint primary never does (the
235
+ // strokes ARE the mask — a scope would cut them).
236
+ const scopeQuad = p.scope_bbox;
237
+ const primaryScope = scopeQuad && scopeQuad.length === 4
238
+ ? { x: scopeQuad[0], y: scopeQuad[1], width: scopeQuad[2], height: scopeQuad[3] }
239
+ : undefined;
240
+ // ── the RT-DETR proposal lane (primary body + box manifest) ──
241
+ let rtdetrBoxes = null;
242
+ if (exportBoxes || viaRtdetr) {
243
+
244
+ // NO CAP ("0 = no cap" on the card's schema) and `|| 100` both
245
+ // capped it and coerced every falsy value — so a default-omitted
246
+ // request silently dropped detections past 100.
247
+ const stated = p.max_instances;
248
+ const cap = stated === undefined || stated === 0 ? 0 : stated;
249
+ rtdetrBoxes = g.node('RTDETR_detect', {
250
+ model: rtdetrLoader(),
251
+ image: img(),
252
+ threshold: p.detection_threshold,
253
+ class_name: 'person',
254
+ max_detections: cap,
255
+ }).out(0);
256
+ }
257
+ const primaryClass = p.primary_class;
258
+ const primaryPrompt = p.primary_prompt;
259
+ const viaSam2 = p.primary_via_sam2;
260
+ const primarySam2Labels = p.primary_sam2_labels;
261
+ if (viaRtdetr && rtdetrBoxes) {
262
+ primaryMask = detect(undefined, rtdetrBoxes);
263
+ if (primaryScope)
264
+ primaryMask = scopeMask(primaryMask, primaryScope);
265
+ save(primaryMask, 'mask_combined');
266
+ }
267
+ else if (viaSam2) {
268
+ const det = sam2detect(primarySam2Labels ?? 'all', primaryScope);
269
+ primaryMask = det.mask;
270
+ primarySegs = det.segs;
271
+ save(primaryMask, 'mask_combined');
272
+
273
+ // going to select figure one or figure two"): the pack's own
274
+ // ImpactSEGSPicker takes the primary detection's SEGS + a
275
+ // 1-based pick ("2" = the second detected instance) and each
276
+ // figure saves its own refined mask (mask_figure_<n>) — the
277
+ // agent selects a figure by composing with figures: [2], no
278
+ // manual box anywhere. The per-instance image crops
279
+ // (run/instance) are the visual index into the same numbering.
280
+ const figs = p.sam2_figures;
281
+ if (figs !== undefined) {
282
+ if (!Array.isArray(figs) || figs.some((n) => !Number.isInteger(n) || n < 1)) {
283
+ throw new Error(`maskT1: sam2_figures must be 1-based instance numbers — got ${JSON.stringify(figs).slice(0, 60)}`);
284
+ }
285
+ for (const n of [...new Set(figs)]) {
286
+ const picked = g.node('ImpactSEGSPicker', { picks: String(n), segs: primarySegs }).out(0);
287
+ // THE FIGURES RIDE THE SAME TWO-STEP (the operator's
288
+
289
+ // YOLO/Segm, THEN extract the mask off the filtered area"):
290
+ // the picker NARROWS the SEGS to figure <n>, then
291
+ // SAMDetectorCombined refines THAT subset exactly as the
292
+ // combined arm does — per-figure SAM masks for EVERY class
293
+ // (person's YOLO segm OR face/hand's bare bbox crops alike;
294
+ // SegsToCombinedMask alone would have rendered step-one
295
+ // masks — rectangles on the bbox classes). Same loader
296
+ // node, same pack-test scalars, the primary's own
297
+ // dilation/threshold.
298
+ const figMask = g.node('SAMDetectorCombined', {
299
+ sam_model: samLoader(),
300
+ segs: picked,
301
+ image: img(),
302
+ detection_hint: 'center-1',
303
+ dilation,
304
+ threshold: samThreshold,
305
+ bbox_expansion: bboxExpansion,
306
+ mask_hint_threshold: hintThreshold,
307
+ mask_hint_use_negative: 'False',
308
+ }).out(0);
309
+ save(primaryScope ? scopeMask(figMask, primaryScope) : figMask, `mask_figure_${n}`);
310
+ }
311
+ }
312
+ }
313
+ else if (primaryClass) {
314
+ // the drawn Region threads as the detect HINT too (the wall
315
+ // caught it: exports threaded theirs, the primary did not —
316
+ // the hint narrows the search, the multiply guarantees bounds)
317
+ primaryMask = detect(clipCond(primaryClass), primaryScope);
318
+ if (primaryScope)
319
+ primaryMask = scopeMask(primaryMask, primaryScope);
320
+ save(primaryMask, 'mask_combined');
321
+ }
322
+ else if (primaryPrompt) {
323
+ primaryMask = detect(clipCond(primaryPrompt), primaryScope);
324
+ if (primaryScope)
325
+ primaryMask = scopeMask(primaryMask, primaryScope);
326
+ save(primaryMask, 'mask_combined');
327
+ }
328
+ else if (p.primary_is_paint) {
329
+ // NOTE: the engine primary `painted` and the exports' `paint_masked`
330
+ // are DISTINCT locals — a paint primary plus a paint export renders
331
+ // TWO LoadImageMask nodes (byte-identical inputs, two nodes). The
332
+ // probe pins this.
333
+ const painted = g.node('LoadImageMask', { image: (p.paint_mask ?? ''), channel: 'alpha' }).out(0);
334
+ primaryMask = painted;
335
+ save(painted, 'mask_combined');
336
+ }
337
+ // ── per-name exports ──
338
+ // the third arm (optional) = the per-entry bbox [x, y, w, h];
339
+ // the fourth arm (optional) = the per-entry TUNE (stroke 95 —
340
+ // every knob a mask source owns rides its bag entry): threshold
341
+ // / refine / dilation / detection_threshold overrides for THIS
342
+ // export's selection. Malformed refuses LOUD (a drifted bag is
343
+ // drift, never a skip); absent arms ride the lane-flat values
344
+ // (the primary's tune — the card threads it flat).
345
+ const exports = p.exports ?? [];
346
+ const kinds = p.export_kind ?? [];
347
+ const entryTune = (raw) => {
348
+ if (raw === undefined || raw === null)
349
+ return undefined;
350
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
351
+ throw new Error(`maskT1: an export tune must be an object (threshold/refine/dilation/detection_threshold) — got ${JSON.stringify(raw).slice(0, 60)}`);
352
+ }
353
+ const t = raw;
354
+
355
+ // once covered only 4 of 9 keys — the 5 newer knobs flowed
356
+ // unvalidated to node inputs, an engine 400 instead of a loud
357
+ // lane refusal). Every value in a tune is a finite number.
358
+ // the key set is the type's own members (TUNE_KEYS is derived),
359
+ // so the membership test narrows the runtime string
360
+ const known = new Set(TUNE_KEYS);
361
+ const unknown = Object.keys(t).filter((k) => !known.has(k));
362
+ if (unknown.length > 0) {
363
+ throw new Error(`maskT1: an export tune carries unknown key(s) ${unknown.join(', ')} — the vocabulary is ${TUNE_KEYS.join('/')}`);
364
+ }
365
+ for (const key of Object.keys(t)) {
366
+ const v = t[key];
367
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
368
+ throw new Error(`maskT1: an export tune's '${key}' must be a finite number — got ${JSON.stringify(v).slice(0, 40)}`);
369
+ }
370
+ }
371
+ return t;
372
+ };
373
+ // SCOPE VALIDATION (the lane's own gate — the card threads the
374
+ // primary entry's box here; a malformed quad refuses LOUD, never
375
+ // a silently unscoped primary).
376
+ if (scopeQuad && scopeQuad.length !== 4) {
377
+ throw new Error(`scope_bbox refused on the mask-studio card: needs exactly [x, y, w, h] pixels (got ${scopeQuad.length} values)`);
378
+ }
379
+ // PER-ENTRY PAINT PAYLOADS (the ordered-draw law, stroke 78): each
380
+ // draw owns its PNG — cached by path (two draws on one payload
381
+ // render ONE LoadImageMask; two payloads render two).
382
+ const paintByPath = new Map();
383
+ const loadPaint = (path) => {
384
+ let m = paintByPath.get(path);
385
+ if (m === undefined) {
386
+ m = g.node('LoadImageMask', { image: path, channel: 'alpha' }).out(0);
387
+ paintByPath.set(path, m);
388
+ }
389
+ return m;
390
+ };
391
+ // THE PER-ENTRY BBOX (stroke 78): the export's THIRD arm —
392
+ // [x, y, w, h] pixels, the bbox-canvas leaf's drawn rectangle.
393
+ // Malformed refuses LOUD (a drifted bag is drift, never a skip);
394
+ // absent falls to the scope, then the renderer default.
395
+ const entryBbox = (raw) => {
396
+ if (raw === undefined || raw === null)
397
+ return undefined;
398
+ if (!Array.isArray(raw) || raw.length !== 4) {
399
+ throw new Error(`maskT1: an export bbox must be [x, y, w, h] pixels — got ${JSON.stringify(raw).slice(0, 60)}`);
400
+ }
401
+ const quad = raw.map(Number);
402
+ if (quad.some((v) => !Number.isFinite(v))) {
403
+ throw new Error(`maskT1: an export bbox needs four finite numbers — got ${JSON.stringify(raw).slice(0, 60)}`);
404
+ }
405
+ return { x: quad[0], y: quad[1], width: quad[2], height: quad[3] };
406
+ };
407
+ const n = Math.min(exports.length, kinds.length); // zip: the shorter arm wins
408
+ for (let i = 0; i < n; i++) {
409
+ const trip = exports[i];
410
+ const name = trip[0];
411
+ const payload = trip[1];
412
+ const kind = kinds[i];
413
+ if (kind === 'paint') {
414
+ // per-entry first (the draw's own PNG), the lane-global paint
415
+ // second (the studio flat-mode contract stays)
416
+ const path = typeof payload === 'string' && payload !== '' ? payload : (p.paint_mask ?? '');
417
+ if (path === '') {
418
+ throw new Error(`maskT1: paint export '${name}' needs a payload — the drawn PNG is the mask`);
419
+ }
420
+ save(loadPaint(path), `mask_${name}`);
421
+ continue;
422
+ }
423
+ let conditioning;
424
+ if (kind !== 'sam2' && payload) {
425
+ // the text lane rides EVEN under scope (boxes to segment within
426
+ // + the vocabulary) — a scope never silently drops the prompt.
427
+ // sam2 exports skip it (their payload is the detector labels
428
+ // word — the detector proposes, SAM2 refines, never CLIP).
429
+ conditioning = clipCond(payload);
430
+ }
431
+ // THE PER-ENTRY BOX (stroke 78): the export's OWN third arm —
432
+ // the card-wide scope_bbox is the PRIMARY's box and never leaks
433
+ // into an export (an export without its own box runs unscoped).
434
+ const bboxes = entryBbox(trip[2]);
435
+ // THE PER-ENTRY TUNE (stroke 95): the export's OWN fourth arm.
436
+ const tune = entryTune(trip[3]);
437
+ if (kind === 'sam2') {
438
+ if (typeof payload !== 'string' || payload === '') {
439
+ throw new Error(`maskT1: sam2 export '${name}' needs labels (a detector class word — the card refuses empty)`);
440
+ }
441
+ const sel = sam2detect(payload, bboxes ? { x: bboxes.x, y: bboxes.y, width: bboxes.width, height: bboxes.height } : undefined, tune);
442
+ save(sel.mask, `mask_${name}`, tune?.dilation);
443
+ continue;
444
+ }
445
+ const sel = detect(conditioning, bboxes, tune);
446
+ save(sel, `mask_${name}`, tune?.dilation);
447
+ }
448
+ // THE ORDERED UNION CHAIN (stroke 78 — the ordered-draw law): a
449
+ // draw STACKS — each payload unions onto the running mask (the
450
+ // previous outputs), in array order, one composite per hop; the
451
+ // terminal mask saves as combined_union. Replaces nothing — the
452
+ // single-payload boolean arm below keeps the flat-mode contract.
453
+ const paintChain = p.paint_masks;
454
+ if (paintChain !== undefined) {
455
+ if (!Array.isArray(paintChain) || paintChain.some((e) => typeof e !== 'string')) {
456
+ throw new Error('maskT1: paint_masks must be an array of payload paths (the ordered draw stack)');
457
+ }
458
+ if (primaryMask === null && paintChain.length > 0) {
459
+ throw new Error('maskT1: paint_masks stacks ONTO a mask — add a standalone source (sam2/sam3) first');
460
+ }
461
+ if (paintChain.length > 0 && primaryMask !== null) {
462
+ let acc = dilate(primaryMask);
463
+ for (const path of paintChain) {
464
+ if (path === '')
465
+ continue;
466
+ acc = g.node('MaskComposite', {
467
+ destination: acc,
468
+ source: loadPaint(path),
469
+ x: 0,
470
+ y: 0,
471
+ operation: 'add',
472
+ }).out(0);
473
+ }
474
+ save(acc, 'mask_combined_union');
475
+ }
476
+ }
477
+ // paint unions into combined (flat-mode export_paint contract —
478
+ // the global payload's mask, ONE hop)
479
+ if (p.union_paint_into_combined && primaryMask) {
480
+ const globalPath = (p.paint_mask ?? '');
481
+ if (globalPath !== '') {
482
+ const combined = g.node('MaskComposite', {
483
+ destination: dilate(primaryMask), // a SECOND GrowMask on the primary under union
484
+ source: loadPaint(globalPath),
485
+ x: 0,
486
+ y: 0,
487
+ operation: 'add',
488
+ }).out(0);
489
+ save(combined, 'mask_combined_union');
490
+ }
491
+ }
492
+ // ── the box manifest (drawn) — the instance readback ──
493
+ if (exportBoxes && rtdetrBoxes) {
494
+ const drawn = g.node('DrawBBoxes', { bboxes: rtdetrBoxes, image: img() });
495
+ return g.node('SaveImage', { images: drawn.out(0), filename_prefix: `${prefix}/detect_boxes` }).out(0);
496
+ }
497
+ // no primary and no export is refused upstream (the admission law),
498
+ // so a composed lane always has a mask handle to declare
499
+ return saved ?? primaryMask ?? img();
500
+ }
501
+ };
502
+ //# sourceMappingURL=actions.js.map
package/lib/index.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { Plugin } from "@deepseek-ai/cordis";
2
+ import type { ModelDecl } from '@melite/comfyui-card-kit/model-contract';
3
+ import type { ProvisionRow } from '@melite/comfyui-card-kit/model-contract';
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+ export declare const PROVISIONS: ProvisionRow[];
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+ export declare const DETECTOR_PROVISIONS: ProvisionRow[];
22
+ export declare const DECL: ModelDecl;
23
+ export declare const family: Plugin;
24
+ export { select, parseMaskLaneFacts, MASK_WEIGHTS, type MaskLaneFacts, type MaskLaneParams } from "./actions.js";
25
+ export { family as default };
26
+ //# sourceMappingURL=index.d.ts.map
package/lib/index.js ADDED
@@ -0,0 +1,109 @@
1
+ import { IMPACT_DETECTOR_PROVISIONS } from "@melite/comfyui-card-kit";
2
+ import { select } from "./actions.js";
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+ export const PROVISIONS = [
13
+ {
14
+
15
+ // existed since stroke ~40 but the weight was NEVER fetched —
16
+ // the engine silently dropped the whole mask arm). The official
17
+ // ComfyUI conversion.
18
+ name: "sam3.1_multiplex_fp16.safetensors",
19
+ url: "https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors",
20
+ folder: "checkpoints",
21
+ sha256: "9ba99c92703c2e8b4f47de2d34a539bb8e18923049e238b780d70dbe6368eb03",
22
+ size_bytes: 1745546848,
23
+ },
24
+ {
25
+ name: "rt_detr_v4-x-hgnet_fp32.safetensors",
26
+ url: "https://huggingface.co/Comfy-Org/SDPose/resolve/main/diffusion_models/rt_detr_v4-x-hgnet_fp32.safetensors",
27
+ folder: "diffusion_models",
28
+ sha256: "1ba4a7794c2f8b176327c887ba38fbe0f977049cbef149960288aa923d0239f3",
29
+ size_bytes: 247790660,
30
+ },
31
+ {
32
+ name: "sam2.1_hiera_large.pt",
33
+ url: "https://huggingface.co/facebook/sam2.1-hiera-large/resolve/main/sam2.1_hiera_large.pt",
34
+ folder: "sams",
35
+ sha256: "2647878d5dfa5098f2f8649825738a9345572bae2d4350a2468587ece47dd318",
36
+ size_bytes: 898083611,
37
+ },
38
+ ...IMPACT_DETECTOR_PROVISIONS,
39
+ ];
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+ export const DETECTOR_PROVISIONS = [...IMPACT_DETECTOR_PROVISIONS];
48
+ // THE TOOL FAMILY (the F1 shape): a selection tool, not a generative
49
+ // model — `unoffered: true` registers the atom (the actions admission
50
+ // gate runs at boot; the card's declaredAction binding resolves
51
+ // against it) while serving no catalog row, so no model dropdown
52
+ // gains a selection tool as a pickable base. The family id is
53
+ // mask-select, NOT the card's id — the h3 pattern (family h3, card
54
+ // h3-timeline): the card's id must keep resolving to the CARD (the
55
+ // form-spec door asks the models seat first, and a family key
56
+ // shadows the card arm — discovery.spec's mask envelope wall catches
57
+ // exactly that), and the bench plane keeps the self-naming strip for
58
+ // era rows. The three weights are the family's constants (actions.ts
59
+ // MASK_WEIGHTS — the LANE_FILES pattern), so the row carries no
60
+ // lane_inputs. ONE variant, ONE address, no cells: the family's id
61
+ // is its own route — and its six weights DOWNLOAD through the family
62
+ // provisions above, like every other model (no separate
63
+ // card-provision behavior).
64
+ export const DECL = {
65
+ "id": "mask-select",
66
+ "label": "Mask",
67
+ "description": "The mask studio's selection lane — SAM3 CLIP/RT-DETR detection, the SAM2 impact-pack arm, and paint exports, as one declared action the mask card binds.",
68
+ "genre": "image",
69
+ "unoffered": true,
70
+ "provisions": PROVISIONS,
71
+ "variants": [
72
+ {
73
+ "id": "select",
74
+ "label": "Select",
75
+ "group": "",
76
+ "route": "mask-select",
77
+ "cells": [],
78
+ },
79
+ ],
80
+
81
+ // axes — the anny shape. The undeclared 'Variant' literal the
82
+
83
+ // solo category the canon gives the one-row family.
84
+ "variant_groups": [],
85
+ // THE FAMILY DOOR (the atom vocabulary, stroke 0): the select
86
+ // action's facts parse runs over the family row and every variant
87
+ // merge AT REGISTRATION — here it closes over the family's own
88
+ // weight constants (an empty row is the family's whole truth).
89
+ "actions": [select],
90
+ };
91
+ export const family = {
92
+ name: "family-mask",
93
+ inject: ["models"],
94
+ apply(ctx) {
95
+ ctx.effect(() => ctx.models.registerFamily(DECL));
96
+ },
97
+ };
98
+ // The family's offered actions (the atom vocabulary): the select
99
+ // segment the mask card binds. The card imports this beside the DECL
100
+ // — the family's whole offer, one face. PROVISIONS rides too: the
101
+ // card serves the family's rows to its arm-weight gate (the
102
+ // provisions-identity pin, image/mask card.spec).
103
+ export { select, parseMaskLaneFacts, MASK_WEIGHTS } from "./actions.js";
104
+ // The Loader-row face: DSH loads plugin rows by bare specifier and
105
+ // unwraps `exports.default ?? exports` (vendor/cordis_plugins/loader
106
+ // unwrapExports) — the default export IS the row-loadable plugin. The
107
+ // named `family` stays the library face (engine loadFamilies).
108
+ export { family as default };
109
+ //# sourceMappingURL=index.js.map