@framefields/node-vision 2.0.2

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,997 @@
1
+ /// <reference types="webgpu" />
2
+ import type { WebGPUNodeRenderer } from "@framefields/node-sdk";
3
+ import {
4
+ type InstanceMask,
5
+ maskBounds,
6
+ matchPoseToTracks,
7
+ mergeSubjectMask,
8
+ type PersonMatte,
9
+ type PoseResult,
10
+ PoseSkeletonRenderer,
11
+ TemporalObjectTracker,
12
+ type TrackedObject,
13
+ type VisionBundle,
14
+ VisionRunner,
15
+ } from "@framefields/vision";
16
+ import type { VisionOperation } from "../shared/config.js";
17
+ import {
18
+ fromCachedMask,
19
+ toCachedMask,
20
+ VisionFrameCache,
21
+ } from "./frame-cache.js";
22
+ import { MatteCompositor } from "./matte-compositor.js";
23
+
24
+ let sharedRunner: { key: string; runner: VisionRunner } | null = null;
25
+ let sharedSkeletonRenderer: PoseSkeletonRenderer | null = null;
26
+ let sharedCompositor: MatteCompositor | null = null;
27
+ let sharedCompositorFormat: GPUTextureFormat | null = null;
28
+ /** Analysed frames, shared by every vision node in the process. */
29
+ const frameCache = new VisionFrameCache();
30
+ /** The frame each node's child texture holds (drawn on its last call). */
31
+ const heldFrameByNode = new Map<string, number>();
32
+ /** Per node: a copy of the held frame, and the composited matte. */
33
+ const heldTextures = new Map<string, GPUTexture>();
34
+ const matteTextures = new Map<string, GPUTexture>();
35
+ /** One tracker per vision node — track ids must not mix across nodes/sources. */
36
+ const objectTrackers = new Map<string, TemporalObjectTracker>();
37
+
38
+ /**
39
+ * Persistent per-node child textures. The shared frame encoder is submitted at
40
+ * the END of the frame, so a node renderer cannot read back the child it just
41
+ * drew. We keep the texture around and read back the PREVIOUS frame's pixels at
42
+ * the start of the next call — a stable one-frame delay instead of the
43
+ * unpredictable multi-frame lag you get from pooled textures.
44
+ *
45
+ * Keyed on the stable Vision `Effect` instance (render ids change every frame),
46
+ * with a size-keyed fallback for raw operation nodes.
47
+ */
48
+ const childTexturesByEffect = new WeakMap<object, GPUTexture>();
49
+ const childTexturesByKey = new Map<string, GPUTexture>();
50
+
51
+ /**
52
+ * Last successful subject mask, reused for a few frames when the model misses —
53
+ * a transient miss then holds the silhouette instead of flashing the raw plate.
54
+ * Keyed per node/effect so multiple vision nodes do not collide.
55
+ */
56
+ const lastSubjectByNode = new Map<
57
+ string,
58
+ { mask: InstanceMask; frame: number }
59
+ >();
60
+ const SUBJECT_HOLD_FRAMES = 3;
61
+
62
+ /** A per-node texture shaped like `like`, recreated when the size changes. */
63
+ function nodeTexture(
64
+ textures: Map<string, GPUTexture>,
65
+ device: GPUDevice,
66
+ key: string,
67
+ like: GPUTexture,
68
+ usage: number,
69
+ label: string,
70
+ ): GPUTexture {
71
+ let tex = textures.get(key);
72
+ if (
73
+ !tex ||
74
+ tex.width !== like.width ||
75
+ tex.height !== like.height ||
76
+ tex.format !== like.format
77
+ ) {
78
+ tex?.destroy();
79
+ tex = device.createTexture({
80
+ size: [like.width, like.height],
81
+ format: like.format,
82
+ usage,
83
+ label,
84
+ });
85
+ textures.set(key, tex);
86
+ }
87
+ return tex;
88
+ }
89
+
90
+ const heldTexture = (device: GPUDevice, key: string, like: GPUTexture) =>
91
+ nodeTexture(
92
+ heldTextures,
93
+ device,
94
+ key,
95
+ like,
96
+ GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
97
+ "vision_held_frame",
98
+ );
99
+
100
+ const matteTexture = (device: GPUDevice, key: string, like: GPUTexture) =>
101
+ nodeTexture(
102
+ matteTextures,
103
+ device,
104
+ key,
105
+ like,
106
+ GPUTextureUsage.RENDER_ATTACHMENT |
107
+ GPUTextureUsage.TEXTURE_BINDING |
108
+ GPUTextureUsage.COPY_SRC,
109
+ "vision_matte",
110
+ );
111
+
112
+ /** Everything that decides a frame's results besides the frame itself. */
113
+ function cacheSource(
114
+ nodeKey: string,
115
+ child: unknown,
116
+ op: VisionOperation,
117
+ width: number,
118
+ height: number,
119
+ ): string {
120
+ const c = child as { id?: unknown; operation?: { id?: unknown } } | undefined;
121
+ return JSON.stringify([
122
+ nodeKey,
123
+ c?.operation?.id ?? c?.id ?? null,
124
+ width,
125
+ height,
126
+ op.mode ?? "passthrough",
127
+ op.variant,
128
+ op.confidence,
129
+ op.classes,
130
+ op.maskThreshold,
131
+ op.featherRadius,
132
+ op.matteSource,
133
+ op.keyBackground === true,
134
+ op.maxMissedFrames,
135
+ ]);
136
+ }
137
+
138
+ /** The background-key threshold at `frame` (it may be animated). */
139
+ function backgroundKeyThreshold(
140
+ op: VisionOperation,
141
+ frame: number,
142
+ fps: number,
143
+ ): number {
144
+ const raw = (op as Record<string, unknown>).backgroundKeyThreshold;
145
+ if (typeof raw === "number") return raw;
146
+ if (typeof (raw as { get?: unknown })?.get === "function") {
147
+ return Number(
148
+ (raw as { get: (ctx?: unknown) => unknown }).get({ frame, fps }),
149
+ );
150
+ }
151
+ if (typeof (raw as { _value?: unknown })?._value === "number") {
152
+ return Number((raw as { _value: number })._value);
153
+ }
154
+ return 70;
155
+ }
156
+
157
+ /**
158
+ * The model's subject, or the last one through brief misses: a transient miss
159
+ * then holds the silhouette instead of flashing the raw plate.
160
+ */
161
+ function selectSubject(
162
+ selected: InstanceMask | undefined,
163
+ nodeKey: string,
164
+ frame: number,
165
+ ): InstanceMask | undefined {
166
+ if (selected) {
167
+ lastSubjectByNode.set(nodeKey, { mask: selected, frame });
168
+ return selected;
169
+ }
170
+ const last = lastSubjectByNode.get(nodeKey);
171
+ if (last && frame - last.frame <= SUBJECT_HOLD_FRAMES) return last.mask;
172
+ return undefined;
173
+ }
174
+
175
+ /**
176
+ * Lazy shared runner — `create()` is a pure constructor (zero I/O); the first frame that
177
+ * requests a task triggers that task's model download at inference time (never at init).
178
+ * Recreated only when an option that changes inference changes.
179
+ */
180
+ function getSharedRunner(op: VisionOperation): VisionRunner {
181
+ const options = {
182
+ variant: op.variant,
183
+ confidence: op.confidence,
184
+ classes: op.classes,
185
+ maskThreshold: op.maskThreshold,
186
+ featherRadius: op.featherRadius,
187
+ modelsDir: op.modelsDir,
188
+ baseUrl: op.baseUrl,
189
+ };
190
+ const key = JSON.stringify(options);
191
+ if (sharedRunner?.key !== key) {
192
+ sharedRunner?.runner.close();
193
+ sharedRunner = { key, runner: VisionRunner.create(options) };
194
+ }
195
+ return sharedRunner.runner;
196
+ }
197
+
198
+ function getObjectTracker(
199
+ nodeKey: string,
200
+ op: VisionOperation,
201
+ ): TemporalObjectTracker {
202
+ let tracker = objectTrackers.get(nodeKey);
203
+ if (!tracker) {
204
+ tracker = new TemporalObjectTracker({
205
+ maxMissedFrames: op.maxMissedFrames ?? 15,
206
+ });
207
+ objectTrackers.set(nodeKey, tracker);
208
+ }
209
+ return tracker;
210
+ }
211
+
212
+ /**
213
+ * Normalizes the runner's pose keypoints (plate pixel space) to [0, 1] landmarks, which is
214
+ * the contract the signal bundle and the skeleton pipeline expect.
215
+ */
216
+ function normalizePoseKeypoints(
217
+ result: PoseResult,
218
+ width: number,
219
+ height: number,
220
+ ): PoseResult {
221
+ if (width <= 0 || height <= 0) return result;
222
+ return {
223
+ people: result.people.map((person) => ({
224
+ ...person,
225
+ keypoints: person.keypoints.map((k) => ({
226
+ x: k.x / width,
227
+ y: k.y / height,
228
+ visibility: k.visibility,
229
+ })),
230
+ })),
231
+ };
232
+ }
233
+
234
+ /** Assigns each decoded instance mask to the temporal track whose box center is nearest the mask's centroid. */
235
+ function assignMasksToTracks(
236
+ masks: readonly InstanceMask[],
237
+ tracked: readonly { trackId: number; centerX: number; centerY: number }[],
238
+ ): InstanceMask[] {
239
+ return masks.map((mask) => {
240
+ const { cx, cy } = maskCentroid(mask);
241
+ let best: { trackId: number; distance: number } | null = null;
242
+ for (const obj of tracked) {
243
+ const dx = obj.centerX - cx;
244
+ const dy = obj.centerY - cy;
245
+ const distance = dx * dx + dy * dy;
246
+ if (!best || distance < best.distance) {
247
+ best = { trackId: obj.trackId, distance };
248
+ }
249
+ }
250
+ return best ? { ...mask, trackId: best.trackId } : mask;
251
+ });
252
+ }
253
+
254
+ /**
255
+ * Grows a subject mask into connected foreground pixels — seeding a flood fill
256
+ * from the subject through pixels that differ from the sampled backdrop recovers
257
+ * thin or fast-moving edges (hair, fabric) without adding a near-uniform studio
258
+ * wall or its soft grey shadow.
259
+ */
260
+ function fillInternalHoles(
261
+ mask: Uint8Array,
262
+ width: number,
263
+ height: number,
264
+ ): void {
265
+ // Flood the background in from the border; whatever it cannot reach is a hole.
266
+ const n = width * height;
267
+ const exterior = new Uint8Array(n);
268
+ const queue = new Int32Array(n);
269
+ let tail = 0;
270
+ const seed = (i: number) => {
271
+ if (exterior[i] || mask[i] > 0) return;
272
+ exterior[i] = 1;
273
+ queue[tail++] = i;
274
+ };
275
+ for (let x = 0; x < width; x++) {
276
+ seed(x);
277
+ seed((height - 1) * width + x);
278
+ }
279
+ for (let y = 0; y < height; y++) {
280
+ seed(y * width);
281
+ seed(y * width + width - 1);
282
+ }
283
+
284
+ let head = 0;
285
+ while (head < tail) {
286
+ const i = queue[head++];
287
+ const x = i % width;
288
+ let j = i + 1;
289
+ if (x + 1 < width && !exterior[j] && mask[j] === 0) {
290
+ exterior[j] = 1;
291
+ queue[tail++] = j;
292
+ }
293
+ j = i - 1;
294
+ if (x > 0 && !exterior[j] && mask[j] === 0) {
295
+ exterior[j] = 1;
296
+ queue[tail++] = j;
297
+ }
298
+ j = i + width;
299
+ if (j < n && !exterior[j] && mask[j] === 0) {
300
+ exterior[j] = 1;
301
+ queue[tail++] = j;
302
+ }
303
+ j = i - width;
304
+ if (j >= 0 && !exterior[j] && mask[j] === 0) {
305
+ exterior[j] = 1;
306
+ queue[tail++] = j;
307
+ }
308
+ }
309
+
310
+ for (let i = 0; i < n; i++) {
311
+ if (mask[i] === 0 && exterior[i] === 0) {
312
+ mask[i] = 255;
313
+ }
314
+ }
315
+ }
316
+
317
+ function smoothMaskBoundary(
318
+ mask: Uint8Array,
319
+ width: number,
320
+ height: number,
321
+ radius = 2,
322
+ ): Uint8Array {
323
+ if (radius <= 0) return mask;
324
+ const temp = new Uint8Array(width * height);
325
+ const out = new Uint8Array(width * height);
326
+ const div = 2 * radius + 1;
327
+
328
+ // Horizontal box filter
329
+ for (let y = 0; y < height; y++) {
330
+ const row = y * width;
331
+ let sum = 0;
332
+ for (let k = -radius; k <= radius; k++) {
333
+ const x = Math.max(0, Math.min(width - 1, k));
334
+ sum += mask[row + x];
335
+ }
336
+ for (let x = 0; x < width; x++) {
337
+ temp[row + x] = Math.round(sum / div);
338
+ const xOut = Math.max(0, x - radius);
339
+ const xIn = Math.min(width - 1, x + radius + 1);
340
+ sum += mask[row + xIn] - mask[row + xOut];
341
+ }
342
+ }
343
+
344
+ // Vertical box filter, a running sum per column walked row by row
345
+ // (in memory order).
346
+ const sums = new Float64Array(width);
347
+ for (let k = -radius; k <= radius; k++) {
348
+ const row = Math.max(0, Math.min(height - 1, k)) * width;
349
+ for (let x = 0; x < width; x++) sums[x] += temp[row + x];
350
+ }
351
+ for (let y = 0; y < height; y++) {
352
+ const row = y * width;
353
+ const rowOut = Math.max(0, y - radius) * width;
354
+ const rowIn = Math.min(height - 1, y + radius + 1) * width;
355
+ for (let x = 0; x < width; x++) {
356
+ out[row + x] = Math.round(sums[x] / div);
357
+ sums[x] += temp[rowIn + x] - temp[rowOut + x];
358
+ }
359
+ }
360
+
361
+ return out;
362
+ }
363
+
364
+ function growMaskIntoForeground(
365
+ mask: Uint8Array,
366
+ width: number,
367
+ height: number,
368
+ pixels: Uint8ClampedArray,
369
+ threshold: number,
370
+ featherRadius?: number,
371
+ ): Uint8Array {
372
+ // Backdrop estimate from the frame border, ignoring already-masked pixels.
373
+ let br = 0;
374
+ let bg = 0;
375
+ let bb = 0;
376
+ let count = 0;
377
+ const sample = (x: number, y: number) => {
378
+ const i = y * width + x;
379
+ if (mask[i]) return;
380
+ const p = i * 4;
381
+ br += pixels[p];
382
+ bg += pixels[p + 1];
383
+ bb += pixels[p + 2];
384
+ count++;
385
+ };
386
+ for (let x = 0; x < width; x += 4) {
387
+ sample(x, 0);
388
+ sample(x, height - 1);
389
+ }
390
+ for (let y = 0; y < height; y += 4) {
391
+ sample(0, y);
392
+ sample(width - 1, y);
393
+ }
394
+ if (count === 0) return mask;
395
+ br /= count;
396
+ bg /= count;
397
+ bb /= count;
398
+
399
+ // Pixels that stand out from the backdrop, decided once per pixel.
400
+ const n = width * height;
401
+ const fg = new Uint8Array(n);
402
+ for (let i = 0, p = 0; i < n; i++, p += 4) {
403
+ fg[i] =
404
+ Math.max(
405
+ Math.abs(pixels[p] - br),
406
+ Math.abs(pixels[p + 1] - bg),
407
+ Math.abs(pixels[p + 2] - bb),
408
+ ) > threshold
409
+ ? 1
410
+ : 0;
411
+ }
412
+
413
+ // Each pixel enters the queue at most once: seeds are the masked pixels,
414
+ // later pushes are pixels just raised to 255.
415
+ const queue = new Int32Array(n);
416
+ let tail = 0;
417
+ for (let i = 0; i < n; i++) {
418
+ if (mask[i] > 0) {
419
+ if (fg[i]) mask[i] = 255;
420
+ queue[tail++] = i;
421
+ }
422
+ }
423
+
424
+ let head = 0;
425
+ while (head < tail) {
426
+ const i = queue[head++];
427
+ const x = i % width;
428
+ let j = i + 1;
429
+ if (x + 1 < width && mask[j] !== 255 && fg[j]) {
430
+ mask[j] = 255;
431
+ queue[tail++] = j;
432
+ }
433
+ j = i - 1;
434
+ if (x > 0 && mask[j] !== 255 && fg[j]) {
435
+ mask[j] = 255;
436
+ queue[tail++] = j;
437
+ }
438
+ j = i + width;
439
+ if (j < n && mask[j] !== 255 && fg[j]) {
440
+ mask[j] = 255;
441
+ queue[tail++] = j;
442
+ }
443
+ j = i - width;
444
+ if (j >= 0 && mask[j] !== 255 && fg[j]) {
445
+ mask[j] = 255;
446
+ queue[tail++] = j;
447
+ }
448
+ }
449
+
450
+ fillInternalHoles(mask, width, height);
451
+
452
+ const blurRadius =
453
+ featherRadius !== undefined && featherRadius > 0
454
+ ? Math.max(1, Math.min(8, Math.round(featherRadius * 50)))
455
+ : 2;
456
+
457
+ return smoothMaskBoundary(mask, width, height, blurRadius);
458
+ }
459
+
460
+ function maskCentroid(mask: InstanceMask): { cx: number; cy: number } {
461
+ // First moment of the frame-aligned mask — cheap, deterministic association.
462
+ const { mask: data, width, height } = mask;
463
+ let sumX = 0;
464
+ let sumY = 0;
465
+ let count = 0;
466
+ for (let y = 0; y < height; y++) {
467
+ const row = y * width;
468
+ for (let x = 0; x < width; x++) {
469
+ if (data[row + x] > 0) {
470
+ sumX += x;
471
+ sumY += y;
472
+ count++;
473
+ }
474
+ }
475
+ }
476
+ if (count === 0) return { cx: 0, cy: 0 };
477
+ return { cx: sumX / count, cy: sumY / count };
478
+ }
479
+
480
+ export const VisionWebGPURenderer: WebGPUNodeRenderer = async (args) => {
481
+ const {
482
+ ctx,
483
+ encoder,
484
+ pass,
485
+ targetView,
486
+ targetWidth,
487
+ targetHeight,
488
+ props,
489
+ drawChild,
490
+ } = args;
491
+
492
+ const { virtualMedia } = props;
493
+ const rawOp = virtualMedia?.operation as
494
+ | VisionOperation
495
+ | Record<string, unknown>
496
+ | undefined;
497
+ if (rawOp?.op !== "Vision") return;
498
+ const op = rawOp as VisionOperation;
499
+
500
+ // Close the incoming render pass to allow custom render passes
501
+ pass.end();
502
+
503
+ const childMedia = virtualMedia?.children?.[0];
504
+ if (!childMedia) return;
505
+
506
+ // 1. Persistent per-node child texture (see childTextures*). Reading the
507
+ // texture we just drew would race the end-of-frame submit; reading last
508
+ // frame's already-submitted pixels at the start of this call is correct.
509
+ const effectKey = (op as { effect?: object }).effect;
510
+ const hasEffectKey = effectKey !== undefined && effectKey !== null;
511
+ const nodeKeyStr =
512
+ (virtualMedia as any)?.id ??
513
+ (rawOp as any)?.id ??
514
+ `${targetWidth}x${targetHeight}_${op.mode}_${op.variant}_${op.keyBackground}_${op.backgroundKeyThreshold}`;
515
+ const fallbackKey = `vision-${nodeKeyStr}`;
516
+ let childTex = hasEffectKey
517
+ ? childTexturesByEffect.get(effectKey as object)
518
+ : childTexturesByKey.get(fallbackKey);
519
+ if (
520
+ childTex &&
521
+ (childTex.width !== targetWidth || childTex.height !== targetHeight)
522
+ ) {
523
+ childTex.destroy();
524
+ childTex = undefined;
525
+ if (hasEffectKey) childTexturesByEffect.delete(effectKey as object);
526
+ else childTexturesByKey.delete(fallbackKey);
527
+ }
528
+ const hasPreviousFrame = childTex !== undefined;
529
+ if (!childTex) {
530
+ childTex = ctx.device.createTexture({
531
+ size: [targetWidth, targetHeight],
532
+ format: ctx.renderer.format,
533
+ usage:
534
+ GPUTextureUsage.RENDER_ATTACHMENT |
535
+ GPUTextureUsage.TEXTURE_BINDING |
536
+ GPUTextureUsage.COPY_SRC,
537
+ label: "vision_child_persistent",
538
+ });
539
+ if (hasEffectKey) childTexturesByEffect.set(effectKey as object, childTex);
540
+ else childTexturesByKey.set(fallbackKey, childTex);
541
+ // keep the fallback cache bounded across many renders in one process
542
+ if (childTexturesByKey.size > 4) {
543
+ const oldest = childTexturesByKey.keys().next().value;
544
+ if (oldest && oldest !== fallbackKey) {
545
+ childTexturesByKey.get(oldest)?.destroy();
546
+ childTexturesByKey.delete(oldest);
547
+ }
548
+ }
549
+ }
550
+
551
+ const frameIdx = props.frame ?? 0;
552
+ const fps = props.fps ?? 24;
553
+ const mode = op.mode ?? "passthrough";
554
+ const isMatteMode = mode === "mask" || mode === "matte" || mode === "crop";
555
+ const visionBundle =
556
+ (op.visionBundle as VisionBundle | undefined) ??
557
+ ((virtualMedia as Record<string, unknown>).visionBundle as
558
+ | VisionBundle
559
+ | undefined);
560
+ const wantsDetection =
561
+ op.enableDetection !== false || mode === "boxes" || mode === "tracking";
562
+ const wantsSegmentation =
563
+ op.enableSegmentation === true ||
564
+ (isMatteMode && op.matteSource !== "selfie");
565
+ const wantsSelfie =
566
+ op.enableMatte === true || (isMatteMode && op.matteSource === "selfie");
567
+ const wantsPose = op.enablePose === true || mode === "skeleton";
568
+
569
+ // 2. Results are cached per source frame (selfie mattes and poses are not,
570
+ // so nodes that need them always run inference). A frame analysed before
571
+ // is composited with its own pixels; otherwise inference runs on the
572
+ // frame the child texture still holds, one frame behind.
573
+ const cacheable =
574
+ !wantsSelfie && !wantsPose && (wantsDetection || wantsSegmentation);
575
+ const source = cacheable
576
+ ? cacheSource(nodeKeyStr, childMedia, op, targetWidth, targetHeight)
577
+ : "";
578
+ const keyThresholdAt = (frame: number) =>
579
+ op.keyBackground === true
580
+ ? backgroundKeyThreshold(op, frame, fps)
581
+ : undefined;
582
+ const lookup = (frame: number | undefined) => {
583
+ if (!cacheable || frame === undefined) return undefined;
584
+ const hit = frameCache.get(source, frame);
585
+ if (!hit || hit.keyThreshold !== keyThresholdAt(frame)) return undefined;
586
+ // Results cached without instance masks can't feed a bundle that wants them.
587
+ if (wantsSegmentation && visionBundle && !hit.masks) return undefined;
588
+ return hit;
589
+ };
590
+ const heldFrame = hasPreviousFrame
591
+ ? heldFrameByNode.get(nodeKeyStr)
592
+ : undefined;
593
+ let cached = lookup(frameIdx);
594
+ const exact = cached !== undefined;
595
+ if (!cached) cached = lookup(heldFrame);
596
+
597
+ // Keep the held frame's picture on the GPU: the matte of that frame is
598
+ // composited from it after the child texture is redrawn below.
599
+ let heldTex: GPUTexture | undefined;
600
+ if (isMatteMode && hasPreviousFrame && !exact) {
601
+ heldTex = heldTexture(ctx.device, nodeKeyStr, childTex);
602
+ encoder.copyTextureToTexture({ texture: childTex }, { texture: heldTex }, [
603
+ targetWidth,
604
+ targetHeight,
605
+ 1,
606
+ ]);
607
+ }
608
+
609
+ // 3. Read back the held frame's pixels, only when it still needs inference.
610
+ let framePixels: Uint8ClampedArray | null = null;
611
+ if (hasPreviousFrame && !cached) {
612
+ const bytesPerPixel = 4;
613
+ const unalignedBytesPerRow = targetWidth * bytesPerPixel;
614
+ const bytesPerRow = Math.ceil(unalignedBytesPerRow / 256) * 256;
615
+ const bufferSize = bytesPerRow * targetHeight;
616
+
617
+ const stagingBuffer = ctx.device.createBuffer({
618
+ size: bufferSize,
619
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
620
+ label: "vision_frame_staging",
621
+ });
622
+
623
+ const readbackEncoder = ctx.device.createCommandEncoder({
624
+ label: "vision_readback_encoder",
625
+ });
626
+ readbackEncoder.copyTextureToBuffer(
627
+ { texture: childTex },
628
+ { buffer: stagingBuffer, bytesPerRow, rowsPerImage: targetHeight },
629
+ [targetWidth, targetHeight, 1],
630
+ );
631
+ ctx.device.queue.submit([readbackEncoder.finish()]);
632
+
633
+ await stagingBuffer.mapAsync(GPUMapMode.READ);
634
+ const mappedBytes = new Uint8Array(stagingBuffer.getMappedRange());
635
+ framePixels = new Uint8ClampedArray(targetWidth * targetHeight * 4);
636
+ if (bytesPerRow === unalignedBytesPerRow) {
637
+ framePixels.set(mappedBytes.subarray(0, framePixels.length));
638
+ } else {
639
+ for (let y = 0; y < targetHeight; y++) {
640
+ framePixels.set(
641
+ mappedBytes.subarray(
642
+ y * bytesPerRow,
643
+ y * bytesPerRow + unalignedBytesPerRow,
644
+ ),
645
+ y * unalignedBytesPerRow,
646
+ );
647
+ }
648
+ }
649
+ stagingBuffer.unmap();
650
+ stagingBuffer.destroy();
651
+ }
652
+
653
+ // 4. Draw THIS frame's child into the persistent texture (recorded into the
654
+ // shared encoder, submitted at end of frame, read back next call).
655
+ const childView = childTex.createView();
656
+ const clearPass = ctx.renderer.beginFrame(
657
+ encoder,
658
+ childView,
659
+ { r: 0, g: 0, b: 0, a: 0 },
660
+ targetWidth,
661
+ targetHeight,
662
+ "clear",
663
+ );
664
+ clearPass.end();
665
+
666
+ await drawChild(
667
+ childMedia,
668
+ { ...props },
669
+ childView,
670
+ childTex,
671
+ targetWidth,
672
+ targetHeight,
673
+ );
674
+ heldFrameByNode.set(nodeKeyStr, frameIdx);
675
+
676
+ // 5. Results: from the cache, or inference on the held frame's pixels
677
+ // (models download lazily on first use of each task).
678
+ let frameObjects: readonly TrackedObject[] = [];
679
+ let frameMasks: readonly InstanceMask[] = [];
680
+ let personMatte: PersonMatte | undefined;
681
+ let currentPoseRes: PoseResult | undefined;
682
+ let subject: InstanceMask | undefined;
683
+
684
+ if (cached) {
685
+ frameObjects = cached.objects;
686
+ if (wantsDetection) {
687
+ visionBundle?.setObjectResult(frameIdx, {
688
+ objects: cached.objects,
689
+ rawDetections: cached.detections,
690
+ });
691
+ }
692
+ if (wantsSegmentation && visionBundle && cached.masks) {
693
+ frameMasks = cached.masks.map(fromCachedMask);
694
+ visionBundle.setMaskResult(frameIdx, frameMasks);
695
+ }
696
+ if (cached.subject) {
697
+ subject = fromCachedMask(cached.subject.mask);
698
+ lastSubjectByNode.set(nodeKeyStr, { mask: subject, frame: frameIdx });
699
+ }
700
+ } else if (framePixels) {
701
+ const runner = getSharedRunner(op);
702
+ const image = {
703
+ data: framePixels,
704
+ width: targetWidth,
705
+ height: targetHeight,
706
+ };
707
+
708
+ // detect + segment share one RTMDet-Ins pass per frame inside the runner.
709
+ // Tracked objects are kept locally (like masks below) so `boxes` /
710
+ // `tracking` draw even without an attached visionBundle.
711
+ let detections: Awaited<ReturnType<VisionRunner["detect"]>> = [];
712
+ if (wantsDetection) {
713
+ detections = await runner.detect(image);
714
+ const tracker = getObjectTracker(nodeKeyStr, op);
715
+ if (frameIdx === 0) {
716
+ tracker.reset();
717
+ }
718
+ frameObjects = tracker.update(detections, frameIdx, fps);
719
+ visionBundle?.setObjectResult(frameIdx, {
720
+ objects: frameObjects,
721
+ rawDetections: detections,
722
+ });
723
+ }
724
+
725
+ // Decoded masks are kept locally so mask-family modes render even without
726
+ // an attached visionBundle (the round-trip through the bundle is for signal DX).
727
+ if (wantsSegmentation) {
728
+ const segRes = await runner.segment(image);
729
+ frameMasks = assignMasksToTracks(segRes.masks, frameObjects);
730
+ visionBundle?.setMaskResult(frameIdx, frameMasks);
731
+ }
732
+
733
+ if (wantsSelfie) {
734
+ personMatte = await runner.matte(image);
735
+ visionBundle?.setMatteResult(frameIdx, personMatte);
736
+ }
737
+
738
+ if (wantsPose) {
739
+ const poseRes = await runner.pose(image);
740
+ const normalized = normalizePoseKeypoints(
741
+ poseRes,
742
+ image.width,
743
+ image.height,
744
+ );
745
+ currentPoseRes = {
746
+ people: matchPoseToTracks(normalized.people, frameObjects),
747
+ };
748
+ visionBundle?.setPoseResult(frameIdx, currentPoseRes);
749
+ }
750
+
751
+ if (isMatteMode) {
752
+ subject = selectSubject(
753
+ op.matteSource === "selfie"
754
+ ? personMatteAsMask(personMatte)
755
+ : mergeSubjectMask(
756
+ frameMasks.length > 0
757
+ ? frameMasks
758
+ : (visionBundle?.getMaskResult(frameIdx) ?? []),
759
+ ),
760
+ nodeKeyStr,
761
+ frameIdx,
762
+ );
763
+ // Opt-in: grow the subject into connected pixels that stand out from
764
+ // the backdrop (catches edges the model's soft mask leaves behind).
765
+ const threshold = keyThresholdAt(heldFrame ?? frameIdx);
766
+ if (subject && threshold !== undefined) {
767
+ subject = {
768
+ ...subject,
769
+ mask: growMaskIntoForeground(
770
+ subject.mask,
771
+ subject.width,
772
+ subject.height,
773
+ framePixels,
774
+ threshold,
775
+ op.featherRadius,
776
+ ),
777
+ };
778
+ }
779
+ }
780
+
781
+ if (cacheable && heldFrame !== undefined) {
782
+ frameCache.set(source, heldFrame, {
783
+ detections,
784
+ objects: frameObjects,
785
+ masks: visionBundle ? frameMasks.map(toCachedMask) : null,
786
+ subject: subject ? { mask: toCachedMask(subject) } : null,
787
+ keyThreshold: keyThresholdAt(heldFrame),
788
+ });
789
+ }
790
+ }
791
+
792
+ // 6. Output to destination texture based on mode
793
+
794
+ // Mask-family modes: the picture the results belong to, cut out on the GPU.
795
+ if (isMatteMode) {
796
+ const picture = exact ? childTex : heldTex;
797
+ if (!subject || !picture) {
798
+ const outPass = ctx.renderer.beginFrame(
799
+ encoder,
800
+ targetView,
801
+ { r: 0, g: 0, b: 0, a: 0 },
802
+ targetWidth,
803
+ targetHeight,
804
+ "clear",
805
+ );
806
+ outPass.end();
807
+ return;
808
+ }
809
+
810
+ if (!sharedCompositor || sharedCompositorFormat !== ctx.renderer.format) {
811
+ sharedCompositor = new MatteCompositor(ctx.device, ctx.renderer.format);
812
+ sharedCompositorFormat = ctx.renderer.format;
813
+ }
814
+ const maskTex = sharedCompositor.uploadMask(
815
+ nodeKeyStr,
816
+ subject.mask,
817
+ subject.width,
818
+ subject.height,
819
+ );
820
+ const matteTex = matteTexture(ctx.device, nodeKeyStr, childTex);
821
+ sharedCompositor.draw(
822
+ encoder,
823
+ matteTex.createView(),
824
+ picture,
825
+ maskTex,
826
+ mode,
827
+ mode === "crop" ? (maskBounds(subject) ?? undefined) : undefined,
828
+ );
829
+ visionBundle?.setStencilTexture(matteTex);
830
+
831
+ const outPass = ctx.renderer.beginFrame(
832
+ encoder,
833
+ targetView,
834
+ { r: 0, g: 0, b: 0, a: 0 },
835
+ targetWidth,
836
+ targetHeight,
837
+ "clear",
838
+ );
839
+ ctx.renderer.drawTexture(outPass, matteTex, {
840
+ x: 0,
841
+ y: 0,
842
+ width: targetWidth,
843
+ height: targetHeight,
844
+ });
845
+ outPass.end();
846
+ return;
847
+ }
848
+
849
+ if (mode === "skeleton") {
850
+ if (!sharedSkeletonRenderer) {
851
+ sharedSkeletonRenderer = new PoseSkeletonRenderer(ctx.device);
852
+ }
853
+ const poseRes = visionBundle?.getPoseResult(frameIdx) ?? currentPoseRes;
854
+ const person = poseRes?.people[0];
855
+ if (person && person.keypoints.length > 0) {
856
+ const skelTex = sharedSkeletonRenderer.renderToTexture(person.keypoints, {
857
+ width: targetWidth,
858
+ height: targetHeight,
859
+ });
860
+ const skelPass = ctx.renderer.beginFrame(
861
+ encoder,
862
+ targetView,
863
+ { r: 0, g: 0, b: 0, a: 0 },
864
+ targetWidth,
865
+ targetHeight,
866
+ "clear",
867
+ );
868
+ ctx.renderer.drawTexture(skelPass, skelTex, {
869
+ x: 0,
870
+ y: 0,
871
+ width: targetWidth,
872
+ height: targetHeight,
873
+ });
874
+ skelPass.end();
875
+ return;
876
+ }
877
+ }
878
+
879
+ if (mode === "boxes" || mode === "tracking") {
880
+ const outPass = ctx.renderer.beginFrame(
881
+ encoder,
882
+ targetView,
883
+ { r: 0, g: 0, b: 0, a: 0 },
884
+ targetWidth,
885
+ targetHeight,
886
+ "clear",
887
+ );
888
+ ctx.renderer.drawTexture(outPass, childTex, {
889
+ x: 0,
890
+ y: 0,
891
+ width: targetWidth,
892
+ height: targetHeight,
893
+ });
894
+
895
+ const objects =
896
+ frameObjects.length > 0
897
+ ? frameObjects
898
+ : (visionBundle?.getObjectResult(frameIdx).objects ?? []);
899
+ if (objects.length > 0) {
900
+ const boxColor = "#38bdf8";
901
+ const stroke = 2;
902
+
903
+ for (const obj of objects) {
904
+ if (!obj.active) continue;
905
+ const b = obj.boundingBox;
906
+
907
+ ctx.renderer.drawRect(
908
+ outPass,
909
+ { x: b.originX, y: b.originY, width: b.width, height: stroke },
910
+ boxColor,
911
+ );
912
+ ctx.renderer.drawRect(
913
+ outPass,
914
+ {
915
+ x: b.originX,
916
+ y: b.originY + b.height - stroke,
917
+ width: b.width,
918
+ height: stroke,
919
+ },
920
+ boxColor,
921
+ );
922
+ ctx.renderer.drawRect(
923
+ outPass,
924
+ { x: b.originX, y: b.originY, width: stroke, height: b.height },
925
+ boxColor,
926
+ );
927
+ ctx.renderer.drawRect(
928
+ outPass,
929
+ {
930
+ x: b.originX + b.width - stroke,
931
+ y: b.originY,
932
+ width: stroke,
933
+ height: b.height,
934
+ },
935
+ boxColor,
936
+ );
937
+
938
+ const cornerLen = Math.min(16, b.width / 4, b.height / 4);
939
+ ctx.renderer.drawRect(
940
+ outPass,
941
+ { x: b.originX, y: b.originY, width: cornerLen, height: stroke * 2 },
942
+ "#ffffff",
943
+ );
944
+ ctx.renderer.drawRect(
945
+ outPass,
946
+ { x: b.originX, y: b.originY, width: stroke * 2, height: cornerLen },
947
+ "#ffffff",
948
+ );
949
+
950
+ if (mode === "tracking") {
951
+ ctx.renderer.drawRect(
952
+ outPass,
953
+ { x: obj.centerX - 3, y: obj.centerY - 3, width: 6, height: 6 },
954
+ "#ef4444",
955
+ 3,
956
+ );
957
+ }
958
+ }
959
+ }
960
+ outPass.end();
961
+ return;
962
+ }
963
+
964
+ // Default passthrough: draw child texture to target
965
+ const outPass = ctx.renderer.beginFrame(
966
+ encoder,
967
+ targetView,
968
+ { r: 0, g: 0, b: 0, a: 0 },
969
+ targetWidth,
970
+ targetHeight,
971
+ "clear",
972
+ );
973
+ ctx.renderer.drawTexture(outPass, childTex, {
974
+ x: 0,
975
+ y: 0,
976
+ width: targetWidth,
977
+ height: targetHeight,
978
+ });
979
+ outPass.end();
980
+ };
981
+
982
+ /** Adapts a Selfie Segmenter matte to the instance-mask shape the composite path takes. */
983
+ function personMatteAsMask(
984
+ matte: PersonMatte | undefined,
985
+ ): InstanceMask | undefined {
986
+ if (!matte || matte.coverage <= 0) return undefined;
987
+ const area = Math.round(matte.coverage * matte.width * matte.height);
988
+ return {
989
+ category: "person",
990
+ mask: matte.mask,
991
+ width: matte.width,
992
+ height: matte.height,
993
+ area,
994
+ coverage: matte.coverage,
995
+ detectionIndex: -1,
996
+ };
997
+ }