@meta-sam/graphics 0.1.5

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,649 @@
1
+ /*
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates. All Rights Reserved.
3
+ */
4
+ import { InvalidSegmentationMaskError, decodeMaskToRaster, } from '@meta-sam/parser';
5
+ import { InvalidMaskPayloadError, InvalidRenderOptionsError, Path2DUnavailableError, RendererDisposedError, SegmentationResourceLimitError, UnsupportedMaskEncodingError, } from './errors.js';
6
+ import { traceContour } from './contour.js';
7
+ const palette = [
8
+ '#1677ff',
9
+ '#00a870',
10
+ '#d46b08',
11
+ '#c41d7f',
12
+ '#531dab',
13
+ '#08979c',
14
+ '#cf1322',
15
+ '#5b8c00',
16
+ ];
17
+ function positiveInteger(value, fallback, name) {
18
+ if (value === undefined)
19
+ return fallback;
20
+ if (!Number.isSafeInteger(value) || value <= 0) {
21
+ throw new TypeError(`${name} must be a positive safe integer.`);
22
+ }
23
+ return value;
24
+ }
25
+ function unitInterval(value, fallback, name) {
26
+ if (value === undefined)
27
+ return fallback;
28
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
29
+ throw new TypeError(`${name} must be a finite number from zero through one.`);
30
+ }
31
+ return value;
32
+ }
33
+ function colorFor(identity) {
34
+ let hash = 2_166_136_261;
35
+ for (let index = 0; index < identity.length; index += 1) {
36
+ hash ^= identity.charCodeAt(index);
37
+ hash = Math.imul(hash, 16_777_619);
38
+ }
39
+ return palette[(hash >>> 0) % palette.length];
40
+ }
41
+ /**
42
+ * The fill and stroke color the renderer assigns to an object identifier.
43
+ * Exposed so legends and inspectors can match the composited overlay exactly.
44
+ */
45
+ export function objectColor(objectId) {
46
+ return colorFor(objectId);
47
+ }
48
+ /**
49
+ * Retained bookkeeping charged per known mask: the map entry plus the retained
50
+ * descriptor. Payload and traced characters are deliberately excluded — the
51
+ * payload belongs to the parser's record and the path is traced on demand.
52
+ */
53
+ const RETAINED_MASK_OVERHEAD = 64;
54
+ function sameBounds(left, right) {
55
+ if (left === undefined || right === undefined)
56
+ return left === right;
57
+ return (left.left === right.left &&
58
+ left.top === right.top &&
59
+ left.right === right.right &&
60
+ left.bottom === right.bottom);
61
+ }
62
+ /**
63
+ * Compares two mask records field by field, cheapest first, so that identical
64
+ * payloads are only scanned when everything else already matches.
65
+ */
66
+ function sameMaskRecord(left, right) {
67
+ return (left === right ||
68
+ (left.identity === right.identity &&
69
+ left.revision === right.revision &&
70
+ left.order === right.order &&
71
+ left.objectId === right.objectId &&
72
+ (left.frame?.frameIndex ?? null) === (right.frame?.frameIndex ?? null) &&
73
+ left.mask.encoding === right.mask.encoding &&
74
+ left.mask.width === right.mask.width &&
75
+ left.mask.height === right.mask.height &&
76
+ left.mask.payload.length === right.mask.payload.length &&
77
+ sameBounds(left.bounds, right.bounds) &&
78
+ left.mask.payload === right.mask.payload));
79
+ }
80
+ function boxIdentity(record) {
81
+ return `box:${record.frame?.frameIndex ?? '*'}:${record.objectId}`;
82
+ }
83
+ function hiddenSet(hidden) {
84
+ if (hidden === undefined)
85
+ return new Set();
86
+ return hidden instanceof Set ? hidden : new Set(hidden);
87
+ }
88
+ function validateRectangle(rectangle, name) {
89
+ if (!Number.isFinite(rectangle.x) ||
90
+ !Number.isFinite(rectangle.y) ||
91
+ !Number.isFinite(rectangle.width) ||
92
+ !Number.isFinite(rectangle.height) ||
93
+ rectangle.width <= 0 ||
94
+ rectangle.height <= 0) {
95
+ throw new InvalidRenderOptionsError(`${name} must contain finite coordinates and positive dimensions.`);
96
+ }
97
+ }
98
+ function validateFit(fit) {
99
+ const resolved = fit ?? 'contain';
100
+ if (resolved !== 'contain' && resolved !== 'cover' && resolved !== 'fill') {
101
+ throw new InvalidRenderOptionsError('fit must be contain, cover, or fill.');
102
+ }
103
+ return resolved;
104
+ }
105
+ function resolveDevicePixelRatio(option) {
106
+ const value = typeof option === 'function' ? option() : (option ?? 1);
107
+ if (!Number.isFinite(value) || value <= 0) {
108
+ throw new InvalidRenderOptionsError('devicePixelRatio must be finite and greater than zero.');
109
+ }
110
+ return value;
111
+ }
112
+ function fittedTarget(source, display, fit) {
113
+ if (fit === 'fill')
114
+ return display;
115
+ const scale = fit === 'contain'
116
+ ? Math.min(display.width / source.width, display.height / source.height)
117
+ : Math.max(display.width / source.width, display.height / source.height);
118
+ const width = source.width * scale;
119
+ const height = source.height * scale;
120
+ return {
121
+ x: display.x + (display.width - width) / 2,
122
+ y: display.y + (display.height - height) / 2,
123
+ width,
124
+ height,
125
+ };
126
+ }
127
+ function drawDecodedFrame(context, frame, source, target, display, devicePixelRatio) {
128
+ context.save();
129
+ try {
130
+ context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
131
+ context.clearRect(0, 0, display.width, display.height);
132
+ context.drawImage(frame, source.x, source.y, source.width, source.height, target.x, target.y, target.width, target.height);
133
+ }
134
+ finally {
135
+ context.restore();
136
+ }
137
+ }
138
+ function validateFrame(frameIndex) {
139
+ if (frameIndex !== undefined &&
140
+ (!Number.isSafeInteger(frameIndex) || frameIndex < 0)) {
141
+ throw new InvalidRenderOptionsError('Frame indexes must be non-negative safe integers.');
142
+ }
143
+ }
144
+ function retainMask(record) {
145
+ return {
146
+ identity: record.identity,
147
+ revision: record.revision,
148
+ record,
149
+ objectId: record.objectId,
150
+ ...(record.frame === undefined ? {} : { frameIndex: record.frame.frameIndex }),
151
+ width: record.mask.width,
152
+ height: record.mask.height,
153
+ ...(record.bounds === undefined ? {} : { bounds: record.bounds }),
154
+ };
155
+ }
156
+ function traceMask(mask, limit) {
157
+ if (mask.encoding !== 'one_bit' && mask.encoding !== 'lossless') {
158
+ throw new UnsupportedMaskEncodingError(mask.encoding);
159
+ }
160
+ let raster;
161
+ try {
162
+ raster = decodeMaskToRaster(mask);
163
+ }
164
+ catch (error) {
165
+ if (error instanceof InvalidSegmentationMaskError) {
166
+ throw new InvalidMaskPayloadError(error.message);
167
+ }
168
+ throw error;
169
+ }
170
+ return traceContour(raster, mask.width, mask.height, limit);
171
+ }
172
+ /** Default fill opacity under the contour. */
173
+ const DEFAULT_MASK_FILL_OPACITY = 0.35;
174
+ /** Default contour opacity. */
175
+ const DEFAULT_MASK_OUTLINE_OPACITY = 0.8;
176
+ /**
177
+ * Contour width as a fraction of the shorter source edge: about 2.2 source
178
+ * pixels on 720p, so the weight tracks the resolution rather than a fixed
179
+ * pixel count.
180
+ */
181
+ const MASK_OUTLINE_WIDTH_RATIO = 0.003;
182
+ /** Contour width in target CSS pixels when the source size is unusable. */
183
+ const FALLBACK_MASK_OUTLINE_WIDTH = 1.5;
184
+ function resolveMaskOutline(option) {
185
+ if (option === false) {
186
+ return { enabled: false, width: null, opacity: DEFAULT_MASK_OUTLINE_OPACITY };
187
+ }
188
+ if (option === undefined || option === true) {
189
+ return { enabled: true, width: null, opacity: DEFAULT_MASK_OUTLINE_OPACITY };
190
+ }
191
+ if (typeof option !== 'object' || option === null) {
192
+ throw new TypeError('maskOutline must be a boolean or an options object.');
193
+ }
194
+ let width = null;
195
+ if (option.width !== undefined) {
196
+ if (!Number.isFinite(option.width) || option.width <= 0) {
197
+ throw new TypeError('maskOutline.width must be a finite number greater than zero.');
198
+ }
199
+ width = option.width;
200
+ }
201
+ return {
202
+ enabled: true,
203
+ width,
204
+ opacity: unitInterval(option.opacity, DEFAULT_MASK_OUTLINE_OPACITY, 'maskOutline.opacity'),
205
+ };
206
+ }
207
+ export class SegmentationRenderer {
208
+ #limits;
209
+ #maskFillOpacity;
210
+ #outline;
211
+ #cache = new Map();
212
+ #cacheComplexity = 0;
213
+ #state;
214
+ #tail = Promise.resolve();
215
+ #epoch = 0;
216
+ #disposed = false;
217
+ constructor(options = {}) {
218
+ this.#maskFillOpacity = unitInterval(options.maskFillOpacity, DEFAULT_MASK_FILL_OPACITY, 'maskFillOpacity');
219
+ this.#outline = resolveMaskOutline(options.maskOutline);
220
+ this.#limits = {
221
+ maxCachedPaths: positiveInteger(options.maxCachedPaths, 128, 'maxCachedPaths'),
222
+ maxCachedComplexity: positiveInteger(options.maxCachedComplexity, 250_000, 'maxCachedComplexity'),
223
+ maxRecords: positiveInteger(options.maxRecords, 20_000, 'maxRecords'),
224
+ maxMasks: positiveInteger(options.maxMasks, 4_096, 'maxMasks'),
225
+ maxBoxes: positiveInteger(options.maxBoxes, 8_192, 'maxBoxes'),
226
+ maxMaskArea: positiveInteger(options.maxMaskArea, 16_777_216, 'maxMaskArea'),
227
+ maxMaskPayloadLength: positiveInteger(options.maxMaskPayloadLength, 2_000_000, 'maxMaskPayloadLength'),
228
+ maxPathComplexity: positiveInteger(options.maxPathComplexity, 250_000, 'maxPathComplexity'),
229
+ maxRetainedComplexity: positiveInteger(options.maxRetainedComplexity, 1_000_000, 'maxRetainedComplexity'),
230
+ };
231
+ }
232
+ update(result, options = {}) {
233
+ if (this.#disposed)
234
+ return Promise.reject(new RendererDisposedError());
235
+ const epoch = this.#epoch;
236
+ const task = this.#tail.then(async () => {
237
+ if (this.#disposed)
238
+ throw new RendererDisposedError();
239
+ await Promise.resolve();
240
+ if (this.#disposed)
241
+ throw new RendererDisposedError();
242
+ if (epoch !== this.#epoch)
243
+ return;
244
+ const reset = options.reset === true;
245
+ const candidate = this.#buildState(result, reset);
246
+ if (this.#disposed)
247
+ throw new RendererDisposedError();
248
+ if (epoch !== this.#epoch)
249
+ return;
250
+ if (reset)
251
+ this.#dropCache();
252
+ this.#state = candidate;
253
+ this.#pruneCache(candidate);
254
+ });
255
+ this.#tail = task.catch(() => undefined);
256
+ return task;
257
+ }
258
+ renderVideoFrame(composition, options = {}) {
259
+ if (composition.signal.aborted)
260
+ return false;
261
+ if (this.#disposed)
262
+ throw new RendererDisposedError();
263
+ const fit = validateFit(options.fit);
264
+ const devicePixelRatio = resolveDevicePixelRatio(options.devicePixelRatio);
265
+ validateFrame(composition.frameIndex);
266
+ const source = {
267
+ x: 0,
268
+ y: 0,
269
+ width: composition.frame.width,
270
+ height: composition.frame.height,
271
+ };
272
+ const display = {
273
+ x: 0,
274
+ y: 0,
275
+ width: composition.canvas.width / devicePixelRatio,
276
+ height: composition.canvas.height / devicePixelRatio,
277
+ };
278
+ validateRectangle(source, 'frame');
279
+ validateRectangle(display, 'canvas');
280
+ const target = fittedTarget(source, display, fit);
281
+ validateRectangle(target, 'target');
282
+ const fallbackCanvas = composition.fallbackCanvas;
283
+ const fallbackCtx = composition.fallbackCtx;
284
+ if ((fallbackCanvas === undefined) !== (fallbackCtx === undefined)) {
285
+ throw new InvalidRenderOptionsError('fallbackCanvas and fallbackCtx must be provided together.');
286
+ }
287
+ if (fallbackCanvas !== undefined &&
288
+ (fallbackCanvas.width !== composition.canvas.width ||
289
+ fallbackCanvas.height !== composition.canvas.height)) {
290
+ throw new InvalidRenderOptionsError('The fallback canvas dimensions must match the composition canvas.');
291
+ }
292
+ const state = this.#state;
293
+ if (state !== undefined) {
294
+ if (state.media !== 'video') {
295
+ throw new InvalidRenderOptionsError(`Cannot render ${state.media} segmentation as video.`);
296
+ }
297
+ const Constructor = globalThis
298
+ .Path2D;
299
+ if (Constructor === undefined)
300
+ throw new Path2DUnavailableError();
301
+ }
302
+ const { ctx } = composition;
303
+ if (fallbackCtx !== undefined) {
304
+ drawDecodedFrame(fallbackCtx, composition.frame, source, target, display, devicePixelRatio);
305
+ }
306
+ drawDecodedFrame(ctx, composition.frame, source, target, display, devicePixelRatio);
307
+ if (composition.signal.aborted)
308
+ return false;
309
+ ctx.save();
310
+ try {
311
+ ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
312
+ this.render(ctx, {
313
+ media: 'video',
314
+ frameIndex: composition.frameIndex,
315
+ source,
316
+ target,
317
+ ...(options.hiddenIds === undefined ? {} : { hiddenIds: options.hiddenIds }),
318
+ });
319
+ }
320
+ finally {
321
+ ctx.restore();
322
+ }
323
+ return !composition.signal.aborted;
324
+ }
325
+ render(context, options) {
326
+ if (this.#disposed)
327
+ throw new RendererDisposedError();
328
+ validateRectangle(options.source, 'source');
329
+ validateRectangle(options.target, 'target');
330
+ if (options.media === 'video')
331
+ validateFrame(options.frameIndex);
332
+ const state = this.#state;
333
+ if (state === undefined)
334
+ return;
335
+ if (state.media !== options.media) {
336
+ throw new InvalidRenderOptionsError(`Cannot render ${state.media} segmentation as ${options.media}.`);
337
+ }
338
+ const hidden = hiddenSet(options.hiddenIds);
339
+ const scaleX = options.target.width / options.source.width;
340
+ const scaleY = options.target.height / options.source.height;
341
+ const offsetX = options.target.x - options.source.x * scaleX;
342
+ const offsetY = options.target.y - options.source.y * scaleY;
343
+ if (!Number.isFinite(scaleX) ||
344
+ !Number.isFinite(scaleY) ||
345
+ !Number.isFinite(offsetX) ||
346
+ !Number.isFinite(offsetY)) {
347
+ throw new InvalidRenderOptionsError('The source-to-target transform exceeds the supported numeric range.');
348
+ }
349
+ const Constructor = globalThis
350
+ .Path2D;
351
+ if (Constructor === undefined)
352
+ throw new Path2DUnavailableError();
353
+ const clipPath = new Constructor();
354
+ clipPath.rect(options.target.x, options.target.y, options.target.width, options.target.height);
355
+ context.save();
356
+ try {
357
+ context.clip(clipPath);
358
+ context.transform(scaleX, 0, 0, scaleY, offsetX, offsetY);
359
+ const outlineWidth = this.#outline.enabled
360
+ ? this.#outlineWidth(options.source, scaleX, scaleY)
361
+ : 0;
362
+ if (this.#outline.enabled) {
363
+ context.lineJoin = 'round';
364
+ context.lineCap = 'round';
365
+ }
366
+ for (const mask of state.masks.values()) {
367
+ if (hidden.has(mask.objectId) ||
368
+ (options.media === 'video' &&
369
+ mask.frameIndex !== undefined &&
370
+ mask.frameIndex !== options.frameIndex)) {
371
+ continue;
372
+ }
373
+ const traced = this.#path(mask);
374
+ if (traced.empty)
375
+ continue;
376
+ const color = colorFor(mask.objectId);
377
+ context.fillStyle = color;
378
+ if (mask.bounds === undefined) {
379
+ this.#paintMask(context, traced.path, color, outlineWidth);
380
+ }
381
+ else {
382
+ context.save();
383
+ try {
384
+ const boundsScaleX = (mask.bounds.right - mask.bounds.left) / mask.width;
385
+ const boundsScaleY = (mask.bounds.bottom - mask.bounds.top) / mask.height;
386
+ context.translate(mask.bounds.left, mask.bounds.top);
387
+ context.scale(boundsScaleX, boundsScaleY);
388
+ this.#paintMask(context, traced.path, color, outlineWidth /
389
+ Math.max(Math.abs(boundsScaleX), Math.abs(boundsScaleY), Number.MIN_VALUE));
390
+ }
391
+ finally {
392
+ context.restore();
393
+ }
394
+ }
395
+ }
396
+ for (const box of state.boxes.values()) {
397
+ if (hidden.has(box.objectId) ||
398
+ (options.media === 'video' &&
399
+ box.frameIndex !== undefined &&
400
+ box.frameIndex !== options.frameIndex)) {
401
+ continue;
402
+ }
403
+ context.strokeStyle = colorFor(box.objectId);
404
+ context.globalAlpha = 1;
405
+ context.lineWidth =
406
+ 2 / Math.max(Math.abs(scaleX), Math.abs(scaleY), Number.MIN_VALUE);
407
+ context.strokeRect(box.left, box.top, box.right - box.left, box.bottom - box.top);
408
+ }
409
+ }
410
+ finally {
411
+ context.restore();
412
+ }
413
+ }
414
+ clear() {
415
+ if (this.#disposed)
416
+ return;
417
+ this.#epoch += 1;
418
+ this.#tail = Promise.resolve();
419
+ this.#state = undefined;
420
+ this.#dropCache();
421
+ }
422
+ dispose() {
423
+ if (this.#disposed)
424
+ return;
425
+ this.#disposed = true;
426
+ this.#epoch += 1;
427
+ this.#tail = Promise.resolve();
428
+ this.#state = undefined;
429
+ this.#dropCache();
430
+ }
431
+ #buildState(result, reset) {
432
+ const previous = !reset && this.#state?.media === result.media ? this.#state : undefined;
433
+ if (!Number.isSafeInteger(result.revision) || result.revision < 0) {
434
+ throw new InvalidRenderOptionsError('Segmentation snapshot revisions must be non-negative safe integers.');
435
+ }
436
+ if (previous !== undefined && result.revision < previous.snapshotRevision) {
437
+ return previous;
438
+ }
439
+ const latestMasks = new Map();
440
+ const boxes = new Map();
441
+ let maskRecords = 0;
442
+ let boxRecords = 0;
443
+ if (result.records.length > this.#limits.maxRecords) {
444
+ throw new SegmentationResourceLimitError('maxRecords');
445
+ }
446
+ for (const record of result.records) {
447
+ validateFrame(record.kind === 'text' ? undefined : record.frame?.frameIndex);
448
+ if (record.kind === 'mask') {
449
+ maskRecords += 1;
450
+ if (maskRecords > this.#limits.maxMasks) {
451
+ throw new SegmentationResourceLimitError('maxMasks');
452
+ }
453
+ this.#preflightMask(record);
454
+ const current = latestMasks.get(record.identity);
455
+ if (current === undefined || record.revision > current.revision) {
456
+ latestMasks.set(record.identity, record);
457
+ }
458
+ else if (record.revision === current.revision &&
459
+ !sameMaskRecord(record, current)) {
460
+ throw new InvalidMaskPayloadError(`Mask ${record.identity} has conflicting data at revision ${record.revision}.`);
461
+ }
462
+ }
463
+ else if (record.kind === 'box') {
464
+ boxRecords += 1;
465
+ if (boxRecords > this.#limits.maxBoxes) {
466
+ throw new SegmentationResourceLimitError('maxBoxes');
467
+ }
468
+ const width = record.right - record.left;
469
+ const height = record.bottom - record.top;
470
+ if (!Number.isFinite(record.left) ||
471
+ !Number.isFinite(record.top) ||
472
+ !Number.isFinite(record.right) ||
473
+ !Number.isFinite(record.bottom) ||
474
+ !Number.isFinite(width) ||
475
+ !Number.isFinite(height) ||
476
+ width < 0 ||
477
+ height < 0) {
478
+ throw new InvalidRenderOptionsError('Box coordinates are invalid.');
479
+ }
480
+ const identity = boxIdentity(record);
481
+ boxes.set(identity, {
482
+ identity,
483
+ objectId: record.objectId,
484
+ ...(record.frame === undefined
485
+ ? {}
486
+ : { frameIndex: record.frame.frameIndex }),
487
+ left: record.left,
488
+ top: record.top,
489
+ right: record.right,
490
+ bottom: record.bottom,
491
+ });
492
+ }
493
+ }
494
+ const masks = new Map();
495
+ let complexity = 0;
496
+ for (const box of boxes.values()) {
497
+ complexity += JSON.stringify([
498
+ box.identity,
499
+ box.objectId,
500
+ box.frameIndex ?? null,
501
+ box.left,
502
+ box.top,
503
+ box.right,
504
+ box.bottom,
505
+ ]).length;
506
+ if (complexity > this.#limits.maxRetainedComplexity) {
507
+ throw new SegmentationResourceLimitError('maxRetainedComplexity');
508
+ }
509
+ }
510
+ for (const record of latestMasks.values()) {
511
+ const prior = previous?.masks.get(record.identity);
512
+ let retained;
513
+ if (prior !== undefined && record.revision < prior.revision) {
514
+ retained = prior;
515
+ }
516
+ else if (prior?.revision === record.revision) {
517
+ if (!sameMaskRecord(prior.record, record)) {
518
+ throw new InvalidMaskPayloadError(`Mask ${record.identity} has conflicting data at revision ${record.revision}.`);
519
+ }
520
+ retained = prior;
521
+ }
522
+ else {
523
+ retained = retainMask(record);
524
+ }
525
+ complexity += retained.identity.length + RETAINED_MASK_OVERHEAD;
526
+ if (complexity > this.#limits.maxRetainedComplexity) {
527
+ throw new SegmentationResourceLimitError('maxRetainedComplexity');
528
+ }
529
+ masks.set(record.identity, retained);
530
+ }
531
+ return {
532
+ media: result.media,
533
+ snapshotRevision: result.revision,
534
+ masks,
535
+ boxes,
536
+ complexity,
537
+ };
538
+ }
539
+ #preflightMask(record) {
540
+ const { mask } = record;
541
+ if (mask.encoding !== 'one_bit' && mask.encoding !== 'lossless') {
542
+ throw new UnsupportedMaskEncodingError(mask.encoding);
543
+ }
544
+ if (!Number.isSafeInteger(record.order) ||
545
+ record.order < 0 ||
546
+ !Number.isSafeInteger(record.revision) ||
547
+ record.revision <= 0 ||
548
+ record.identity.length === 0 ||
549
+ record.objectId.length === 0) {
550
+ throw new InvalidMaskPayloadError('Mask record identity and revision are invalid.');
551
+ }
552
+ if (!Number.isSafeInteger(mask.width) ||
553
+ !Number.isSafeInteger(mask.height) ||
554
+ mask.width <= 0 ||
555
+ mask.height <= 0) {
556
+ throw new InvalidMaskPayloadError('Mask dimensions must be positive safe integers.');
557
+ }
558
+ if (record.bounds !== undefined) {
559
+ const { left, top, right, bottom } = record.bounds;
560
+ if (![left, top, right, bottom].every(Number.isFinite) ||
561
+ right <= left ||
562
+ bottom <= top) {
563
+ throw new InvalidMaskPayloadError('Mask bounds are invalid.');
564
+ }
565
+ }
566
+ const area = mask.width * mask.height;
567
+ if (!Number.isSafeInteger(area) || area > this.#limits.maxMaskArea) {
568
+ throw new SegmentationResourceLimitError('maxMaskArea');
569
+ }
570
+ if (mask.payload.length > this.#limits.maxMaskPayloadLength) {
571
+ throw new SegmentationResourceLimitError('maxMaskPayloadLength');
572
+ }
573
+ }
574
+ /**
575
+ * The contour width in source pixels. The default is relative to the source
576
+ * resolution, so a mask keeps the same visual weight whatever the media size
577
+ * is; the CSS-pixel fallback only applies when the source cannot supply one.
578
+ */
579
+ #outlineWidth(source, scaleX, scaleY) {
580
+ if (this.#outline.width !== null)
581
+ return this.#outline.width;
582
+ const shortest = Math.min(source.width, source.height);
583
+ if (Number.isFinite(shortest) && shortest > 0) {
584
+ return MASK_OUTLINE_WIDTH_RATIO * shortest;
585
+ }
586
+ return (FALLBACK_MASK_OUTLINE_WIDTH /
587
+ Math.max(Math.abs(scaleX), Math.abs(scaleY), Number.MIN_VALUE));
588
+ }
589
+ /**
590
+ * Fills the contour and strokes the same path, so the translucent body and
591
+ * the crisp edge always agree. `width` is in the coordinate space in force,
592
+ * which is source pixels unless a box-local mask added its own bounds scale.
593
+ */
594
+ #paintMask(context, path, color, width) {
595
+ context.globalAlpha = this.#maskFillOpacity;
596
+ context.fill(path, 'evenodd');
597
+ if (!this.#outline.enabled)
598
+ return;
599
+ context.strokeStyle = color;
600
+ context.globalAlpha = this.#outline.opacity;
601
+ context.lineWidth = width;
602
+ context.stroke(path);
603
+ }
604
+ #path(mask) {
605
+ const cached = this.#cache.get(mask);
606
+ if (cached !== undefined) {
607
+ this.#cache.delete(mask);
608
+ this.#cache.set(mask, cached);
609
+ return cached;
610
+ }
611
+ const Constructor = globalThis
612
+ .Path2D;
613
+ if (Constructor === undefined)
614
+ throw new Path2DUnavailableError();
615
+ const traced = traceMask(mask.record.mask, this.#limits.maxPathComplexity);
616
+ const entry = {
617
+ path: new Constructor(traced.d),
618
+ complexity: traced.complexity,
619
+ empty: traced.d.length === 0,
620
+ };
621
+ this.#cache.set(mask, entry);
622
+ this.#cacheComplexity += entry.complexity;
623
+ this.#evict();
624
+ return entry;
625
+ }
626
+ #evict() {
627
+ while (this.#cache.size > this.#limits.maxCachedPaths ||
628
+ this.#cacheComplexity > this.#limits.maxCachedComplexity) {
629
+ const first = this.#cache.entries().next().value;
630
+ if (first === undefined)
631
+ return;
632
+ this.#cache.delete(first[0]);
633
+ this.#cacheComplexity -= first[1].complexity;
634
+ }
635
+ }
636
+ #pruneCache(state) {
637
+ const retained = new Set(state.masks.values());
638
+ for (const [key, cached] of this.#cache) {
639
+ if (retained.has(key))
640
+ continue;
641
+ this.#cache.delete(key);
642
+ this.#cacheComplexity -= cached.complexity;
643
+ }
644
+ }
645
+ #dropCache() {
646
+ this.#cache.clear();
647
+ this.#cacheComplexity = 0;
648
+ }
649
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@meta-sam/graphics",
3
+ "version": "0.1.5",
4
+ "description": "Retained Canvas 2D renderer for SAM 3 segmentation",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "homepage": "https://github.com/meta-models/meta-sam#readme",
7
+ "bugs": {
8
+ "url": "https://github.com/meta-models/meta-sam/issues"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/meta-models/meta-sam.git",
13
+ "directory": "typescript/packages/graphics"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "sideEffects": false,
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "types": "./dist/index.d.ts",
32
+ "engines": {
33
+ "node": "^20.17.0 || >=22.9.0"
34
+ },
35
+ "dependencies": {
36
+ "@meta-sam/parser": "0.0.8"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -b",
40
+ "prepack": "tsc -b --force",
41
+ "typecheck": "tsc -p tsconfig.json --noEmit"
42
+ }
43
+ }