@camstack/shm-ring 0.1.5 → 0.1.7

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.
@@ -1 +1 @@
1
- {"version":3,"file":"frame-ring-reader-cache.d.ts","sourceRoot":"","sources":["../src/frame-ring-reader-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAiB/E;;;GAGG;AACH,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA8B;IACpD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA2B;IAClD,OAAO,CAAC,MAAM,CAAQ;gBAEV,MAAM,CAAC,EAAE,aAAa;IAIlC;;;;OAIG;IACH,IAAI,CAAC,MAAM,EAAE,WAAW,GAAG,YAAY,GAAG,IAAI;IAyB9C,8CAA8C;IAC9C,KAAK,IAAI,IAAI;IAiBb,6EAA6E;IAC7E,OAAO,CAAC,OAAO;CAkChB"}
1
+ {"version":3,"file":"frame-ring-reader-cache.d.ts","sourceRoot":"","sources":["../src/frame-ring-reader-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAiB/E;;;GAGG;AACH,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA8B;IACpD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA2B;IAClD,OAAO,CAAC,MAAM,CAAQ;gBAEV,MAAM,CAAC,EAAE,aAAa;IAIlC;;;;OAIG;IACH,IAAI,CAAC,MAAM,EAAE,WAAW,GAAG,YAAY,GAAG,IAAI;IA0B9C,8CAA8C;IAC9C,KAAK,IAAI,IAAI;IAiBb,6EAA6E;IAC7E,OAAO,CAAC,OAAO;CAkChB"}
@@ -7,6 +7,9 @@ export interface FrameMeta {
7
7
  readonly pts: number;
8
8
  /** Valid pixel bytes — must be `<= slotByteLength`. */
9
9
  readonly byteLength: number;
10
+ /** Wall-clock ms (Date.now) stamped at commit; `0` when unset (older writer).
11
+ * Optional on write (commit stamps it); always present on read. */
12
+ readonly capturedAt?: number;
10
13
  }
11
14
  /**
12
15
  * A scatter-write slot reservation returned by {@link FrameRingWriter.beginFrame}.
@@ -1 +1 @@
1
- {"version":3,"file":"frame-ring.d.ts","sourceRoot":"","sources":["../src/frame-ring.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAmD/D,mEAAmE;AACnE,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,uDAAuD;IACvD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAC5B;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,WAAW,cAAc;IAC7B,8EAA8E;IAC9E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,WAAW,SAAS;IACxB;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,kDAAkD;IAClD,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,WAAW,SAAS;IACxB;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;IAC5B;;;;;OAKG;IACH,QAAQ,IAAI,OAAO,CAAA;CACpB;AAED,4CAA4C;AAC5C,UAAU,YAAY;IACpB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,8CAA8C;IAC9C,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,0CAA0C;IAC1C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,uCAAuC;IACvC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,iCAAiC;IACjC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAC5B;AA2BD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM,CAEpF;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,IAAI,CAAA;AAC/B,eAAO,MAAM,cAAc,KAAK,CAAA;AAEhC;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM,CAGnF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAgBzD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,WAAW,GAClB,MAAM,CAKR;AAED;;;;GAIG;AACH,uBAAe,aAAa;IAOxB,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM;IAClC,SAAS,CAAC,QAAQ,CAAC,KAAK,EAAE,MAAM;IAPlC,SAAS,CAAC,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAA;IACzC,mFAAmF;IACnF,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAA;IACrC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IAEjC,SAAS,aACY,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EAChC,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM;IAkBxB,mCAAmC;IACnC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAIxC,oDAAoD;IACpD,SAAS,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAI5C,iDAAiD;IACjD,SAAS,CAAC,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;CAG9C;AAED;;;;GAIG;AACH,qBAAa,eAAgB,SAAQ,aAAa;IAChD,sEAAsE;IACtE,OAAO,CAAC,WAAW,CAAsB;IAEzC,6EAA6E;IAC7E,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;gBAG7B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,EACtB,MAAM,EAAE,MAAM;IAUhB,iFAAiF;IACjF,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,iEAAiE;IACjE,OAAO,CAAC,UAAU;IAKlB;;;;;;;;;;;;OAYG;IACH,UAAU,IAAI,cAAc;IAgB5B;;;;;;;;OAQG;IACH,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,WAAW;IAgDvD;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAiB9B;;;;;;;;;;OAUG;IACH,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,WAAW;CAYzD;AAED;;;;GAIG;AACH,qBAAa,eAAgB,SAAQ,aAAa;IAChD;;;;;;;;;;OAUG;IACH,OAAO,CAAC,OAAO,CAAQ;IAEvB;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;gBAG7B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM;IACtB;;;OAGG;IACH,MAAM,EAAE,MAAM;IAOhB;;;;OAIG;IACH,OAAO,CAAC,UAAU;IAOlB;;;;;;;;OAQG;IACH,UAAU,IAAI,SAAS,GAAG,IAAI;IAU9B;;;;;;;;OAQG;IACH,UAAU,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,IAAI;IAajD;;;;;OAKG;IACH,OAAO,CAAC,WAAW;IAiEnB;;;;;OAKG;IACH,cAAc,IAAI,SAAS,GAAG,IAAI;IASlC;;;;;OAKG;IACH,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,IAAI;IAarD;;;;OAIG;IACH,OAAO,CAAC,eAAe;CAyDxB"}
1
+ {"version":3,"file":"frame-ring.d.ts","sourceRoot":"","sources":["../src/frame-ring.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAuD/D,mEAAmE;AACnE,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,uDAAuD;IACvD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B;wEACoE;IACpE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,WAAW,cAAc;IAC7B,8EAA8E;IAC9E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,WAAW,SAAS;IACxB;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,kDAAkD;IAClD,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,WAAW,SAAS;IACxB;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;IAC5B;;;;;OAKG;IACH,QAAQ,IAAI,OAAO,CAAA;CACpB;AAED,4CAA4C;AAC5C,UAAU,YAAY;IACpB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,8CAA8C;IAC9C,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,0CAA0C;IAC1C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,uCAAuC;IACvC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,iCAAiC;IACjC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAC5B;AA2BD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM,CAEpF;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,IAAI,CAAA;AAC/B,eAAO,MAAM,cAAc,KAAK,CAAA;AAEhC;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,MAAM,CAGnF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAgBzD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,WAAW,GAClB,MAAM,CAKR;AAED;;;;GAIG;AACH,uBAAe,aAAa;IAOxB,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM;IAClC,SAAS,CAAC,QAAQ,CAAC,KAAK,EAAE,MAAM;IAPlC,SAAS,CAAC,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAA;IACzC,mFAAmF;IACnF,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAA;IACrC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IAEjC,SAAS,aACY,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EAChC,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM;IAkBxB,mCAAmC;IACnC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAIxC,oDAAoD;IACpD,SAAS,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAI5C,iDAAiD;IACjD,SAAS,CAAC,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;CAG9C;AAED;;;;GAIG;AACH,qBAAa,eAAgB,SAAQ,aAAa;IAChD,sEAAsE;IACtE,OAAO,CAAC,WAAW,CAAsB;IAEzC,6EAA6E;IAC7E,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;gBAG7B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,EACtB,MAAM,EAAE,MAAM;IAUhB,iFAAiF;IACjF,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,iEAAiE;IACjE,OAAO,CAAC,UAAU;IAKlB;;;;;;;;;;;;OAYG;IACH,UAAU,IAAI,cAAc;IAgB5B;;;;;;;;OAQG;IACH,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,WAAW;IAiDvD;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAiB9B;;;;;;;;;;OAUG;IACH,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,WAAW;CAYzD;AAED;;;;GAIG;AACH,qBAAa,eAAgB,SAAQ,aAAa;IAChD;;;;;;;;;;OAUG;IACH,OAAO,CAAC,OAAO,CAAQ;IAEvB;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;gBAG7B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM;IACtB;;;OAGG;IACH,MAAM,EAAE,MAAM;IAOhB;;;;OAIG;IACH,OAAO,CAAC,UAAU;IAOlB;;;;;;;;OAQG;IACH,UAAU,IAAI,SAAS,GAAG,IAAI;IAU9B;;;;;;;;OAQG;IACH,UAAU,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,IAAI;IAajD;;;;;OAKG;IACH,OAAO,CAAC,WAAW;IAkEnB;;;;;OAKG;IACH,cAAc,IAAI,SAAS,GAAG,IAAI;IASlC;;;;;OAKG;IACH,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,IAAI;IAarD;;;;OAIG;IACH,OAAO,CAAC,eAAe;CA0DxB"}
package/dist/index.js CHANGED
@@ -85,6 +85,10 @@ var META_OFF_HEIGHT = 4;
85
85
  var META_OFF_FORMAT = 8;
86
86
  var META_OFF_BYTE_LENGTH = 12;
87
87
  var META_OFF_PTS = 16;
88
+ /** Wall-clock ms (Date.now) stamped at commit — uses the 8 reserved padding
89
+ * bytes at +24, so slot size is unchanged. Lets readers measure frame age
90
+ * (a PTS can't be diffed against Date.now). 0 when an older writer didn't set it. */
91
+ var META_OFF_CAPTURED_AT = 24;
88
92
  /**
89
93
  * Stable integer codes for `FrameFormat` — metadata stores the index, not the
90
94
  * string, so a slot's metadata is a fixed-width struct. Append-only: never
@@ -298,6 +302,7 @@ var FrameRingWriter = class extends FrameRingBase {
298
302
  this.view.setInt32(metaOffset + META_OFF_FORMAT, encodeFormat(meta.format), true);
299
303
  this.view.setInt32(metaOffset + META_OFF_BYTE_LENGTH, meta.byteLength, true);
300
304
  this.view.setFloat64(metaOffset + META_OFF_PTS, meta.pts, true);
305
+ this.view.setFloat64(metaOffset + META_OFF_CAPTURED_AT, meta.capturedAt ?? Date.now(), true);
301
306
  const committedSeq = Atomics.add(this.header, this.seqIndex(slot), 1) + 1;
302
307
  Atomics.add(this.header, HDR_WRITE_INDEX, 1);
303
308
  return {
@@ -451,6 +456,7 @@ var FrameRingReader = class extends FrameRingBase {
451
456
  const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true);
452
457
  const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true);
453
458
  const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true);
459
+ const capturedAt = this.view.getFloat64(metaOffset + META_OFF_CAPTURED_AT, true);
454
460
  if (byteLength < 0 || byteLength > this.geometry.slotByteLength) return null;
455
461
  const pixelOffset = this.pixelOffsetOf(slot);
456
462
  const pixels = this.scratchFor(byteLength);
@@ -469,7 +475,8 @@ var FrameRingReader = class extends FrameRingBase {
469
475
  height,
470
476
  format,
471
477
  pts,
472
- byteLength
478
+ byteLength,
479
+ capturedAt
473
480
  },
474
481
  handle: {
475
482
  shmId: this.shmId,
@@ -524,6 +531,7 @@ var FrameRingReader = class extends FrameRingBase {
524
531
  const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true);
525
532
  const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true);
526
533
  const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true);
534
+ const capturedAt = this.view.getFloat64(metaOffset + META_OFF_CAPTURED_AT, true);
527
535
  if (byteLength < 0 || byteLength > this.geometry.slotByteLength) return null;
528
536
  let format;
529
537
  try {
@@ -538,7 +546,8 @@ var FrameRingReader = class extends FrameRingBase {
538
546
  height,
539
547
  format,
540
548
  pts,
541
- byteLength
549
+ byteLength,
550
+ capturedAt
542
551
  };
543
552
  const handle = {
544
553
  shmId: this.shmId,
@@ -599,7 +608,8 @@ var FrameRingReaderCache = class {
599
608
  width: frame.meta.width,
600
609
  height: frame.meta.height,
601
610
  format: frame.meta.format,
602
- timestamp: frame.meta.pts
611
+ timestamp: frame.meta.pts,
612
+ capturedAt: frame.meta.capturedAt
603
613
  };
604
614
  }
605
615
  /** Close every cached segment. Idempotent. */
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/native.ts","../src/frame-ring.ts","../src/frame-ring-reader-cache.ts"],"sourcesContent":["/**\n * Typed wrapper over the `shm_ring` N-API addon.\n *\n * The addon maps/unmaps a named OS shared-memory segment and hands JS a\n * zero-copy `Buffer` over the mapping. The ring/seqlock logic lives in\n * `frame-ring.ts` (Task 2) — this module is purely the segment plumbing.\n */\nimport { createRequire } from 'node:module'\nimport { fileURLToPath } from 'node:url'\nimport { dirname } from 'node:path'\n\nconst require = createRequire(import.meta.url)\nconst here = dirname(fileURLToPath(import.meta.url))\n\n/** Opaque per-segment handle the native side uses to reach the mapping on close. */\nexport interface NativeSegmentHandle {\n readonly __shmHandle: unique symbol\n}\n\n/** Raw result handed back by the native `create` / `open` calls. */\ninterface NativeSegment {\n readonly buffer: Buffer\n readonly handle: NativeSegmentHandle\n}\n\n/** The native addon surface. */\ninterface ShmAddon {\n create(name: string, byteLength: number): NativeSegment\n open(name: string, byteLength: number): NativeSegment\n close(handle: NativeSegmentHandle): void\n unlink(name: string): void\n}\n\n/**\n * `node-gyp-build` resolves a prebuilt binary if one exists, otherwise the\n * locally compiled `build/Release/shm_ring.node`. Pointed at the package root\n * (one level above `dist/` / `src/`) so both layouts resolve.\n */\nfunction loadAddon(): ShmAddon {\n const nodeGypBuild: (root: string) => unknown = require('node-gyp-build')\n const packageRoot = dirname(here)\n const addon = nodeGypBuild(packageRoot)\n if (!isShmAddon(addon)) {\n throw new Error('@camstack/shm-ring: native addon did not expose the expected surface')\n }\n return addon\n}\n\nfunction hasFunction(value: object, key: string): boolean {\n return typeof Reflect.get(value, key) === 'function'\n}\n\nfunction isShmAddon(value: unknown): value is ShmAddon {\n if (typeof value !== 'object' || value === null) return false\n return (\n hasFunction(value, 'create') &&\n hasFunction(value, 'open') &&\n hasFunction(value, 'close') &&\n hasFunction(value, 'unlink')\n )\n}\n\nconst addon: ShmAddon = loadAddon()\n\n/** A mapped shared-memory segment with a zero-copy view and lifecycle controls. */\nexport interface ShmSegment {\n /** Zero-copy `Buffer` over the mapped region. Valid until `close()`. */\n readonly buffer: Buffer\n /** Unmap this process's view of the segment. Does not unlink the name. */\n close(): void\n /**\n * Remove the segment name from the OS namespace. The backing memory is\n * reclaimed once every process has also `close()`d its view. POSIX:\n * `shm_unlink`. Windows: a no-op — the OS reclaims on last-handle-close.\n */\n unlink(): void\n}\n\nfunction wrap(name: string, native: NativeSegment): ShmSegment {\n let closed = false\n let unlinked = false\n return {\n buffer: native.buffer,\n close(): void {\n if (closed) return\n closed = true\n addon.close(native.handle)\n },\n unlink(): void {\n // Idempotent: native `Unlink` throws `ENOENT` on a second `shm_unlink`\n // of the same name, so a double `unlink()` would throw. Guard it here —\n // mirrors `close()`'s `closed` flag.\n if (unlinked) return\n unlinked = true\n addon.unlink(name)\n },\n }\n}\n\n/**\n * Create a new named shared-memory segment of `byteLength` bytes and map it.\n * The segment is zero-filled. Fails if a segment with this name already exists.\n */\nexport function createSegment(name: string, byteLength: number): ShmSegment {\n return wrap(name, addon.create(name, byteLength))\n}\n\n/**\n * Map an existing named shared-memory segment. `byteLength` must match (or be\n * smaller than) the size the segment was created with. Throws if the segment\n * does not exist.\n */\nexport function openSegment(name: string, byteLength: number): ShmSegment {\n return wrap(name, addon.open(name, byteLength))\n}\n\n/**\n * Remove a segment name from the OS namespace without holding a mapping.\n * Convenience for cleanup paths that never mapped the segment themselves.\n */\nexport function unlinkSegment(name: string): void {\n addon.unlink(name)\n}\n","/**\n * `FrameRing` — a lock-free seqlock ring-buffer over a shared-memory segment.\n *\n * One writer (the decoder) publishes decoded frames into `slotCount` slots;\n * any number of readers (motion, detection, the WebRTC encoder, …) consume\n * them latest-wins. There are no JS-level locks: correctness rests entirely\n * on a per-slot **seqlock** sequence number manipulated with `Atomics`.\n *\n * The backing memory is a genuine `mmap` (see `native.ts`), so the `Atomics`\n * loads/stores have cross-process effect. The ring logic here is pure\n * TypeScript and treats the segment as an opaque `Buffer`.\n *\n * ## Segment layout (all offsets in bytes, little-endian)\n *\n * ```\n * ┌──────────────────────────────────────────────────────────────┐\n * │ HEADER — Int32Array view, (3 + slotCount) * 4 bytes │\n * │ [0] slotCount │\n * │ [1] slotByteLength (capacity of one pixel slot) │\n * │ [2] writeIndex (monotone publish counter) │\n * │ [3 .. 3+N) seq[slot] — per-slot seqlock counter │\n * ├──────────────────────────────────────────────────────────────┤\n * │ METADATA — slotCount * SLOT_META_BYTES (32) bytes, 8-aligned │\n * │ per slot: width (Int32 @ +0) │\n * │ height (Int32 @ +4) │\n * │ format (Int32 @ +8, FRAME_FORMAT_CODES index) │\n * │ byteLen (Int32 @ +12, valid pixel bytes) │\n * │ pts (Float64@ +16, presentation timestamp) │\n * │ (8 bytes padding @ +24 — keeps slots 8-aligned) │\n * ├──────────────────────────────────────────────────────────────┤\n * │ PIXELS — slotCount * slotByteLength bytes │\n * │ slot K pixel region @ pixelsOffset + K * slotByteLength │\n * └──────────────────────────────────────────────────────────────┘\n * ```\n *\n * ## Seqlock protocol\n *\n * `seq[slot]` starts even (committed / readable). A write does:\n * 1. `Atomics.add(seq, slot, 1)` → seq becomes **odd** (write in progress);\n * 2. copy pixels + metadata into the slot;\n * 3. `Atomics.add(seq, slot, 1)` → seq becomes **even** (committed);\n * 4. `Atomics.store(writeIndex)` → publish the new latest slot.\n *\n * A read does:\n * 1. `s1 = Atomics.load(seq, slot)`; if `s1` is odd → skip (write in flight);\n * 2. copy pixels + metadata out;\n * 3. `s2 = Atomics.load(seq, slot)`; if `s1 !== s2` → skip (recycled / torn).\n *\n * An odd `s1` means a write is mid-flight; an `s1 !== s2` means the slot was\n * overwritten between the two loads — in both cases the reader drops the\n * frame rather than returning torn pixels.\n */\nimport type { FrameFormat, FrameHandle } from '@camstack/types'\n\n/** Header slot indices in the `Int32Array` header view. */\nconst HDR_SLOT_COUNT = 0\nconst HDR_SLOT_BYTE_LENGTH = 1\nconst HDR_WRITE_INDEX = 2\n/** Fixed header fields before the per-slot `seq[]` array begins. */\nconst HDR_FIXED_FIELDS = 3\n\n/** Bytes of metadata reserved per slot (24 used + 8 padding → 8-byte aligned). */\nconst SLOT_META_BYTES = 32\nconst META_OFF_WIDTH = 0\nconst META_OFF_HEIGHT = 4\nconst META_OFF_FORMAT = 8\nconst META_OFF_BYTE_LENGTH = 12\nconst META_OFF_PTS = 16\n\n/**\n * Stable integer codes for `FrameFormat` — metadata stores the index, not the\n * string, so a slot's metadata is a fixed-width struct. Append-only: never\n * reorder or remove an entry, or existing segments would mis-decode.\n */\nconst FRAME_FORMAT_CODES: readonly FrameFormat[] = [\n 'jpeg',\n 'rgb',\n 'bgr',\n 'yuv420',\n 'gray',\n]\n\nfunction encodeFormat(format: FrameFormat): number {\n const code = FRAME_FORMAT_CODES.indexOf(format)\n if (code < 0) {\n throw new Error(`FrameRing: unknown frame format \"${format}\"`)\n }\n return code\n}\n\nfunction decodeFormat(code: number): FrameFormat {\n const format = FRAME_FORMAT_CODES[code]\n if (format === undefined) {\n throw new Error(`FrameRing: unknown frame format code ${code}`)\n }\n return format\n}\n\n/** Round `value` up to the next multiple of `align` (a power of two). */\nfunction alignUp(value: number, align: number): number {\n return Math.ceil(value / align) * align\n}\n\n/** Per-frame metadata published alongside the pixels of a slot. */\nexport interface FrameMeta {\n readonly width: number\n readonly height: number\n readonly format: FrameFormat\n readonly pts: number\n /** Valid pixel bytes — must be `<= slotByteLength`. */\n readonly byteLength: number\n}\n\n/**\n * A scatter-write slot reservation returned by {@link FrameRingWriter.beginFrame}.\n *\n * ## Scatter-write seqlock contract (Phase 5 / D9 Task 7c — READ THIS)\n *\n * `beginFrame()` has already bumped this slot's `seq` to **odd** — a write is in\n * progress, so any reader hitting this slot skips it (returns `null` / a failed\n * `validate()`). The caller now owns an exclusive write window over `buffer`:\n *\n * 1. `buffer` is a writable `subarray` **directly over the slot's pixel\n * region in the mapped segment** — there is no intermediate copy. A\n * producer (e.g. the node-av scaler) fills it in place.\n * 2. The slot stays odd for the **entire fill duration** — not just a memcpy.\n * A scatter producer that takes longer to fill the slot (a whole scale\n * pass) holds the slot odd for that whole pass; this is correct, readers\n * simply latest-wins-drop that slot until {@link FrameRingWriter.commitFrame}.\n * 3. The caller MUST call {@link FrameRingWriter.commitFrame} with this exact\n * `slot` once the fill is done — that writes the metadata, bumps `seq` back\n * to **even** (committed) and advances `writeIndex` so readers can see it.\n *\n * Exactly one `beginFrame` may be open at a time per writer (there is a single\n * writer per ring). `buffer.byteLength` is the slot's full capacity\n * (`slotByteLength`); the caller writes at most `commitFrame`'s `meta.byteLength`\n * valid bytes into it.\n */\nexport interface FrameSlotWrite {\n /** The ring slot index this write targets — pass it back to `commitFrame`. */\n readonly slot: number\n /**\n * A writable view **directly over the slot's pixel region** in the shared\n * mapping. Fill it in place, then `commitFrame(slot, meta)`. Valid only\n * between the `beginFrame` that produced it and its matching `commitFrame`.\n */\n readonly buffer: Buffer\n}\n\n/**\n * The result of a successful seqlock read.\n *\n * ## Buffer-lifetime contract (Phase 5 / D9 Task 7b — READ THIS)\n *\n * `pixels` is a view onto the **reader's reusable scratch buffer**, NOT a\n * freshly allocated copy. The seqlock-validated pixel bytes are memcpy'd into\n * that scratch buffer (the copy is the seqlock safety copy — the bytes survive\n * a post-read slot recycle), but the buffer itself is reused on this reader's\n * **next** `readHandle` / `readLatest` call.\n *\n * Therefore `pixels` is **valid only until this same reader's next read**:\n *\n * - A consumer that processes the frame **synchronously**, fully, before it\n * issues another read on this reader may use `pixels` directly (borrow).\n * - A consumer that **retains** the frame past its next read — queues it for\n * async work, stores it, hands it to another tick — MUST `Buffer.from(...)`\n * it into its own storage at the point of retention. Holding the borrowed\n * `pixels` past the next read is silent corruption: the next read overwrites\n * the bytes in place.\n *\n * `meta` and `handle` are plain immutable values — safe to retain freely.\n */\nexport interface FrameRead {\n /**\n * The slot's pixel bytes copied into the reader's reusable scratch buffer.\n *\n * **Borrowed, not owned** — valid only until this reader's next\n * `readHandle` / `readLatest`. Retain past that point ⇒ copy first\n * (`Buffer.from(pixels)`). See the `FrameRead` doc comment.\n */\n readonly pixels: Buffer\n readonly meta: FrameMeta\n /** A serialisable handle for this exact frame. */\n readonly handle: FrameHandle\n}\n\n/**\n * A **zero-copy** read: `pixels` is a `subarray` directly over the shared\n * mapping — no memcpy at all (Phase 5 / D9 Task 7b Step 3).\n *\n * ## Validation contract (READ THIS — concurrency-sensitive)\n *\n * Because `pixels` aliases the live ring slot, a concurrent writer can recycle\n * that slot at any moment, tearing the bytes a consumer is reading. A\n * `FrameView` is therefore an **optimistic** read: the seqlock's *opening*\n * check already passed (the slot was committed and, for a handle read, the seq\n * matched), but the closing re-check is deferred to the consumer.\n *\n * A consumer MUST:\n * 1. read the slot **synchronously and fast** — no `await`, no yielding;\n * 2. call `validate()` **immediately after** finishing with `pixels`;\n * 3. if `validate()` returns `false`, **discard** whatever it computed from\n * `pixels` — the bytes were (or may have been) overwritten mid-read, i.e.\n * a torn frame, the latest-wins drop.\n *\n * `validate()` returning `true` means the slot's `seq` is unchanged since the\n * read opened — the bytes the consumer just processed were a single committed\n * frame, not torn. This is the same seqlock guarantee `readHandle` gives, but\n * the closing half is the consumer's responsibility instead of being baked\n * into a memcpy + recheck. Use this ONLY for tight synchronous consumers\n * (e.g. motion's frame scan, the perf bench's compute stage); any consumer\n * that retains or processes asynchronously MUST use the copying `readHandle` /\n * `readLatest` instead.\n */\nexport interface FrameView {\n /**\n * A `subarray` view directly over the shared-memory slot — **zero-copy**.\n * Valid for a synchronous read only; `validate()` confirms it was not torn.\n */\n readonly pixels: Buffer\n readonly meta: FrameMeta\n readonly handle: FrameHandle\n /**\n * Re-check the slot's seqlock. `true` ⇒ the slot was not recycled while the\n * consumer read `pixels` — the frame is intact. `false` ⇒ a torn read,\n * discard everything derived from `pixels`. Call once, right after the\n * synchronous read of `pixels` completes.\n */\n validate(): boolean\n}\n\n/** Internal geometry of a sized segment. */\ninterface RingGeometry {\n readonly slotCount: number\n readonly slotByteLength: number\n /** Byte length of the `Int32Array` header. */\n readonly headerBytes: number\n /** Byte offset of the metadata region. */\n readonly metaOffset: number\n /** Byte offset of the pixel region. */\n readonly pixelsOffset: number\n /** Total segment byte length. */\n readonly totalBytes: number\n}\n\n/** Compute the geometry of a ring with the given slot count and slot size. */\nfunction computeGeometry(slotCount: number, slotByteLength: number): RingGeometry {\n if (!Number.isInteger(slotCount) || slotCount <= 0) {\n throw new Error(`FrameRing: slotCount must be a positive integer, got ${slotCount}`)\n }\n if (!Number.isInteger(slotByteLength) || slotByteLength <= 0) {\n throw new Error(\n `FrameRing: slotByteLength must be a positive integer, got ${slotByteLength}`,\n )\n }\n const headerBytes = alignUp((HDR_FIXED_FIELDS + slotCount) * 4, 8)\n const metaOffset = headerBytes\n const metaBytes = slotCount * SLOT_META_BYTES\n const pixelsOffset = alignUp(metaOffset + metaBytes, 8)\n const totalBytes = pixelsOffset + slotCount * slotByteLength\n return {\n slotCount,\n slotByteLength,\n headerBytes,\n metaOffset,\n pixelsOffset,\n totalBytes,\n }\n}\n\n/**\n * Total byte length a segment must have to hold a `slotCount`-slot ring with\n * `slotByteLength`-byte pixel slots. The decoder uses this to size the\n * shared-memory segment before constructing a `FrameRingWriter` over it.\n */\nexport function computeSegmentSize(slotCount: number, slotByteLength: number): number {\n return computeGeometry(slotCount, slotByteLength).totalBytes\n}\n\n/**\n * Slot-count bounds for a per-resolution ring. {@link deriveSlotCount} clamps\n * the budget-derived count into `[MIN_RING_SLOTS, MAX_RING_SLOTS]`:\n * - `MIN` keeps a tiny floor even when one frame nearly exhausts the budget\n * (a 4K RGB frame is ~24.9 MB — a 128 MB budget yields only ~5 raw slots);\n * - `MAX` caps the ring for small frames so a 360p stream does not allocate\n * hundreds of slots' worth of shared memory it will never use latest-wins.\n */\nexport const MIN_RING_SLOTS = 6\nexport const MAX_RING_SLOTS = 64\n\n/**\n * Slot count for a ring, derived from a per-ring shared-memory budget.\n *\n * `budgetBytes` is the shared memory a single stream's ring may consume; the\n * raw count is `floor(budgetBytes / slotByteLength)`, clamped into\n * `[MIN_RING_SLOTS, MAX_RING_SLOTS]`. Live video is latest-wins, so the slot\n * count only needs to absorb the writer-commit / reader-read window — the\n * budget trades shared-memory footprint against that window per resolution.\n */\nexport function deriveSlotCount(budgetBytes: number, slotByteLength: number): number {\n const raw = Math.floor(budgetBytes / slotByteLength)\n return Math.min(MAX_RING_SLOTS, Math.max(MIN_RING_SLOTS, raw))\n}\n\n/**\n * Bytes per pixel for each packed decode format a frame ring slot can hold.\n *\n * This is the single source of truth shared by the decoder write side\n * (`DecoderFrameRingSink`) and every consumer read side\n * (`FrameRingReaderCache`): if the two computed different values, a reader\n * would mis-map the shared-memory segment and read corrupt pixels.\n *\n * `jpeg` has no fixed bytes-per-pixel — `computeSlotByteLength` short-circuits\n * it to the equivalent RGB24 raster before this is ever consulted, so the `3`\n * here is only a safe placeholder for exhaustiveness over `FrameFormat`.\n */\nexport function bytesPerPixel(format: FrameFormat): number {\n switch (format) {\n case 'gray':\n return 1\n case 'rgb':\n case 'bgr':\n return 3\n case 'yuv420':\n // 4:2:0 planar — 1 byte luma + ¼+¼ chroma per pixel = 1.5 bytes/px.\n // Rounded up to 2 so the slot is never undersized for a planar frame.\n return 2\n case 'jpeg':\n // Variable-length; see the doc comment above. `computeSlotByteLength`\n // sizes a jpeg slot from the RGB24 raster, never via this branch.\n return 3\n }\n}\n\n/**\n * Per-slot byte capacity for a frame of the given geometry — the single source\n * of truth for both the decoder write side and the consumer read side.\n *\n * For raw formats this is exactly `width × height × bpp`. For `jpeg` (variable\n * length) the slot is sized to the equivalent RGB24 raster — a JPEG is always\n * far smaller than its source raster, so an RGB24-sized slot is a safe upper\n * bound. Note: this upper bound holds only while the JPEG encoder is pinned to\n * its current quality (~80). A near-lossless quality setting can produce a\n * JPEG that exceeds the RGB24 byte count; if quality is raised significantly,\n * this slot size must be revisited.\n */\nexport function computeSlotByteLength(\n width: number,\n height: number,\n format: FrameFormat,\n): number {\n if (format === 'jpeg') {\n return width * height * 3\n }\n return width * height * bytesPerPixel(format)\n}\n\n/**\n * Shared geometry + typed views over a segment buffer. The header is an\n * `Int32Array` so `Atomics` ops apply directly; metadata and pixels are read\n * and written through `DataView` / `Buffer` slices.\n */\nabstract class FrameRingBase {\n protected readonly geometry: RingGeometry\n /** `Int32Array` over the header — slotCount, slotByteLength, writeIndex, seq[]. */\n protected readonly header: Int32Array\n protected readonly view: DataView\n\n protected constructor(\n protected readonly segment: Buffer,\n protected readonly shmId: string,\n slotCount: number,\n slotByteLength: number,\n ) {\n this.geometry = computeGeometry(slotCount, slotByteLength)\n if (segment.byteLength < this.geometry.totalBytes) {\n throw new Error(\n `FrameRing: segment is ${segment.byteLength} bytes, ` +\n `need ${this.geometry.totalBytes} for ${slotCount}×${slotByteLength}`,\n )\n }\n const headerInts = HDR_FIXED_FIELDS + slotCount\n this.header = new Int32Array(\n segment.buffer,\n segment.byteOffset,\n headerInts,\n )\n this.view = new DataView(segment.buffer, segment.byteOffset, segment.byteLength)\n }\n\n /** Header index of `seq[slot]`. */\n protected seqIndex(slot: number): number {\n return HDR_FIXED_FIELDS + slot\n }\n\n /** Byte offset of slot `slot`'s metadata struct. */\n protected metaOffsetOf(slot: number): number {\n return this.geometry.metaOffset + slot * SLOT_META_BYTES\n }\n\n /** Byte offset of slot `slot`'s pixel region. */\n protected pixelOffsetOf(slot: number): number {\n return this.geometry.pixelsOffset + slot * this.geometry.slotByteLength\n }\n}\n\n/**\n * The single writer for a ring. Constructed by whoever owns the segment (the\n * decoder). On construction it stamps `slotCount` / `slotByteLength` into the\n * header so a reader opening the same segment can self-describe.\n */\nexport class FrameRingWriter extends FrameRingBase {\n /** Slot index of an in-flight `beginWrite()`, or `null` when idle. */\n private pendingSlot: number | null = null\n\n /** Cluster node id stamped into every `FrameHandle` this writer produces. */\n private readonly nodeId: string\n\n constructor(\n segment: Buffer,\n shmId: string,\n slotCount: number,\n slotByteLength: number,\n nodeId: string,\n ) {\n super(segment, shmId, slotCount, slotByteLength)\n this.nodeId = nodeId\n // Stamp the self-describing header. `writeIndex` starts at 0; before the\n // first commit there is no readable frame (seq[0] is even == 0).\n Atomics.store(this.header, HDR_SLOT_COUNT, slotCount)\n Atomics.store(this.header, HDR_SLOT_BYTE_LENGTH, slotByteLength)\n }\n\n /** The number of slots in this ring — derived per-resolution from the budget. */\n get slotCount(): number {\n return this.geometry.slotCount\n }\n\n /** The slot the next `writeFrame` / `beginFrame` will target. */\n private targetSlot(): number {\n const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX)\n return writeIndex % this.geometry.slotCount\n }\n\n /**\n * Open the seqlock on the next slot and hand the caller a writable view\n * directly over that slot's pixel region — the **scatter-write** entry point.\n *\n * The seqlock contract (see {@link FrameSlotWrite}): this bumps the slot's\n * `seq` to **odd** (write in progress), so a concurrent reader skips the slot\n * for the entire fill window. The caller fills `buffer` in place — there is\n * NO intermediate copy: a producer (the node-av scaler) writes its packed\n * output straight into the mapped segment — then MUST call\n * {@link commitFrame} with the returned `slot` to publish the frame.\n *\n * Exactly one `beginFrame` may be open per writer at a time.\n */\n beginFrame(): FrameSlotWrite {\n if (this.pendingSlot !== null) {\n throw new Error('FrameRingWriter: a frame write is already in progress')\n }\n const slot = this.targetSlot()\n // seq → odd: a write is now in progress on this slot.\n Atomics.add(this.header, this.seqIndex(slot), 1)\n this.pendingSlot = slot\n const pixelOffset = this.pixelOffsetOf(slot)\n const buffer = this.segment.subarray(\n pixelOffset,\n pixelOffset + this.geometry.slotByteLength,\n )\n return { slot, buffer }\n }\n\n /**\n * Close the seqlock opened by {@link beginFrame}: write the metadata, bump\n * `seq` back to **even** (committed) and advance `writeIndex` so readers see\n * this slot as the latest. Returns the published frame's `FrameHandle`.\n *\n * `slot` must be the value from the matching `beginFrame`'s\n * {@link FrameSlotWrite}. The pixels are assumed already filled in place by\n * the caller (the scatter-write contract) — `commitFrame` copies nothing.\n */\n commitFrame(slot: number, meta: FrameMeta): FrameHandle {\n if (this.pendingSlot === null) {\n throw new Error('FrameRingWriter: commitFrame called without beginFrame')\n }\n if (slot !== this.pendingSlot) {\n throw new Error(\n `FrameRingWriter: commitFrame slot ${slot} does not match the ` +\n `in-progress beginFrame slot ${this.pendingSlot}`,\n )\n }\n const { slotByteLength } = this.geometry\n if (meta.byteLength > slotByteLength) {\n throw new Error(\n `FrameRingWriter: frame is ${meta.byteLength} bytes, ` +\n `slot capacity is ${slotByteLength}`,\n )\n }\n this.pendingSlot = null\n\n // --- write metadata (inside the still-open odd-seq window) ---\n const metaOffset = this.metaOffsetOf(slot)\n this.view.setInt32(metaOffset + META_OFF_WIDTH, meta.width, true)\n this.view.setInt32(metaOffset + META_OFF_HEIGHT, meta.height, true)\n this.view.setInt32(metaOffset + META_OFF_FORMAT, encodeFormat(meta.format), true)\n this.view.setInt32(metaOffset + META_OFF_BYTE_LENGTH, meta.byteLength, true)\n this.view.setFloat64(metaOffset + META_OFF_PTS, meta.pts, true)\n\n // --- close the seqlock: seq → even (committed) ---\n // `Atomics.add` returns the previous (odd) value; +1 gives the new even seq.\n const committedSeq = Atomics.add(this.header, this.seqIndex(slot), 1) + 1\n\n // --- publish: advance writeIndex so readers see this as the latest ---\n Atomics.add(this.header, HDR_WRITE_INDEX, 1)\n\n return {\n shmId: this.shmId,\n slot,\n seq: committedSeq,\n width: meta.width,\n height: meta.height,\n format: meta.format,\n pts: meta.pts,\n byteLength: meta.byteLength,\n nodeId: this.nodeId,\n slotCount: this.geometry.slotCount,\n }\n }\n\n /**\n * Abandon the seqlock opened by {@link beginFrame} **without publishing the\n * slot** — the degenerate-path counterpart of {@link commitFrame}.\n *\n * `beginFrame` has bumped the slot's `seq` to **odd**. A caller that opened a\n * slot but then could not fill it with valid pixels (the producer failed, or\n * there were no source planes) MUST NOT `commitFrame` — that would close the\n * seqlock over uninitialised / stale shared-memory bytes and advance\n * `writeIndex`, so a reader would see those garbage bytes as a real, latest\n * frame. `abortFrame` instead:\n *\n * 1. bumps `seq` from odd back to **even** — the slot is not left stuck\n * odd, so the writer can reuse it on a later cycle;\n * 2. does **NOT** advance `writeIndex` — `readLatest` therefore never\n * surfaces this slot as the latest (the prior latest stays latest);\n * 3. returns no `FrameHandle` — nothing is handed downstream.\n *\n * The slot's pixel bytes after `abortFrame` are **undefined** (whatever was\n * there before, or a half-written producer output) — but that is harmless\n * because no consumer ever sees the slot as committed: `readLatest` skips it\n * (writeIndex did not move) and any handle that happened to point at this\n * slot from an earlier commit sees the changed `seq` and is correctly\n * rejected by `readHandle`'s seq check.\n *\n * `slot` must be the value from the matching `beginFrame` — validated\n * exactly as {@link commitFrame} does.\n */\n abortFrame(slot: number): void {\n if (this.pendingSlot === null) {\n throw new Error('FrameRingWriter: abortFrame called without beginFrame')\n }\n if (slot !== this.pendingSlot) {\n throw new Error(\n `FrameRingWriter: abortFrame slot ${slot} does not match the ` +\n `in-progress beginFrame slot ${this.pendingSlot}`,\n )\n }\n this.pendingSlot = null\n // Close the seqlock: seq odd → even. The slot is committed-shape again so\n // it can be reused, but writeIndex is NOT advanced — the slot is never the\n // latest, and its pixel content is intentionally undefined.\n Atomics.add(this.header, this.seqIndex(slot), 1)\n }\n\n /**\n * Publish one frame from a caller-provided pixel `Buffer` — the convenience\n * wrapper over the scatter-write {@link beginFrame} / {@link commitFrame}\n * pair: it opens the slot, copies `pixels` into it, then commits.\n *\n * Prefer the `beginFrame` / `commitFrame` pair when the pixels can be\n * produced directly into the slot (the node-av scaler scattering its packed\n * output into the mapped segment) — that path has zero write-side copy.\n * `writeFrame` keeps the single-call form for callers that already hold a\n * detached pixel `Buffer`.\n */\n writeFrame(pixels: Buffer, meta: FrameMeta): FrameHandle {\n if (pixels.byteLength < meta.byteLength) {\n throw new Error(\n `FrameRingWriter: pixels buffer (${pixels.byteLength} bytes) ` +\n `shorter than meta.byteLength (${meta.byteLength})`,\n )\n }\n const { slot, buffer } = this.beginFrame()\n // Copy the pixels into the slot, inside the open odd-seq window.\n buffer.set(pixels.subarray(0, meta.byteLength))\n return this.commitFrame(slot, meta)\n }\n}\n\n/**\n * A reader for a ring. Many readers may share a segment concurrently with the\n * single writer. Every read is a seqlock read — a torn or recycled slot is\n * dropped (returns `null`), never returned as garbage pixels.\n */\nexport class FrameRingReader extends FrameRingBase {\n /**\n * The reusable per-reader scratch buffer the seqlock-validated pixels are\n * copied into — see the `FrameRead` doc comment for the borrow contract.\n *\n * Sized to `slotByteLength` on construction (the segment's stamped slot\n * capacity) and lazily grown if a slot ever reports a larger `byteLength`.\n * Reusing one buffer per reader replaces the per-read `Buffer.allocUnsafe` —\n * the D9 perf gate's regression (Task 7b): a fresh ~5.93 MB allocation per\n * 1080p frame churned ~1.39 GB/s and stalled on GC. The single memcpy stays\n * (it IS the seqlock safety copy); only the allocation is removed.\n */\n private scratch: Buffer\n\n /**\n * Cluster node id of the node that OWNS this segment (i.e. the writer's\n * node — the node whose shared memory physically holds the ring), stamped\n * into every `FrameHandle` this reader produces. `FrameHandle.nodeId` always\n * identifies the segment-owning node, never the reading node, so callers\n * pass the writer's node id here (e.g. `FrameRingReaderCache` passes\n * `handle.nodeId` straight through).\n */\n private readonly nodeId: string\n\n constructor(\n segment: Buffer,\n shmId: string,\n slotCount: number,\n slotByteLength: number,\n /**\n * Node id of the segment-owning (writer's) node — see the `nodeId` field\n * doc. NOT this reader's own node.\n */\n nodeId: string,\n ) {\n super(segment, shmId, slotCount, slotByteLength)\n this.nodeId = nodeId\n this.scratch = Buffer.allocUnsafe(slotByteLength)\n }\n\n /**\n * Return the reader's scratch buffer as a view of exactly `byteLength` bytes,\n * growing the backing buffer first if a slot ever exceeds the stamped slot\n * capacity (defensive — `slotByteLength` should always bound `byteLength`).\n */\n private scratchFor(byteLength: number): Buffer {\n if (byteLength > this.scratch.byteLength) {\n this.scratch = Buffer.allocUnsafe(byteLength)\n }\n return this.scratch.subarray(0, byteLength)\n }\n\n /**\n * Read the latest committed frame, latest-wins. Returns `null` when the ring\n * is empty (no frame ever published) or the latest slot is mid-write /\n * recycled (a slow reader simply drops that frame and retries next tick).\n *\n * The returned `FrameRead.pixels` is **borrowed** from this reader's reusable\n * scratch buffer — valid only until this reader's next read. A consumer that\n * retains it past the next read MUST copy. See the `FrameRead` doc comment.\n */\n readLatest(): FrameRead | null {\n const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX)\n if (writeIndex === 0) {\n return null\n }\n // The most recently published slot is writeIndex-1 modulo slotCount.\n const slot = (writeIndex - 1) % this.geometry.slotCount\n return this.seqlockRead(slot, null)\n }\n\n /**\n * Read the exact frame a `FrameHandle` refers to. Returns `null` when the\n * slot has been recycled (its committed `seq` no longer matches `handle.seq`)\n * or is mid-write — i.e. the frame is gone, not torn.\n *\n * The returned `FrameRead.pixels` is **borrowed** from this reader's reusable\n * scratch buffer — valid only until this reader's next read. A consumer that\n * retains it past the next read MUST copy. See the `FrameRead` doc comment.\n */\n readHandle(handle: FrameHandle): FrameRead | null {\n if (handle.shmId !== this.shmId) {\n throw new Error(\n `FrameRingReader: handle is for segment \"${handle.shmId}\", ` +\n `this reader is on \"${this.shmId}\"`,\n )\n }\n if (handle.slot < 0 || handle.slot >= this.geometry.slotCount) {\n return null\n }\n return this.seqlockRead(handle.slot, handle.seq)\n }\n\n /**\n * The seqlock read of a single slot.\n *\n * `expectedSeq` is the committed seq a `readHandle` caller demands; pass\n * `null` for `readLatest`, which accepts whatever even seq it finds.\n */\n private seqlockRead(slot: number, expectedSeq: number | null): FrameRead | null {\n const seqIdx = this.seqIndex(slot)\n\n // (1) read the sequence; an odd value means a write is in progress.\n const s1 = Atomics.load(this.header, seqIdx)\n if ((s1 & 1) !== 0) {\n return null\n }\n if (expectedSeq !== null && s1 !== expectedSeq) {\n // The slot has moved on (recycled) — the requested frame is gone.\n return null\n }\n\n // (2) read metadata + pixels into a private copy.\n const metaOffset = this.metaOffsetOf(slot)\n const width = this.view.getInt32(metaOffset + META_OFF_WIDTH, true)\n const height = this.view.getInt32(metaOffset + META_OFF_HEIGHT, true)\n const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true)\n const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true)\n const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true)\n\n if (byteLength < 0 || byteLength > this.geometry.slotByteLength) {\n // Metadata is being rewritten — treat as a torn read, skip.\n return null\n }\n const pixelOffset = this.pixelOffsetOf(slot)\n // Copy the pixels out into this reader's REUSABLE scratch buffer (not a\n // fresh allocation — see `scratch`). The copy is still the seqlock safety\n // copy: the bytes must survive a post-read slot recycle, and the recheck\n // below validates they were not torn mid-copy. The returned buffer is\n // borrowed — valid only until this reader's next read (see `FrameRead`).\n const pixels = this.scratchFor(byteLength)\n this.segment.copy(pixels, 0, pixelOffset, pixelOffset + byteLength)\n\n // (3) re-read the sequence; any change means the slot was overwritten\n // between (1) and (2) — the copy is torn, drop it.\n const s2 = Atomics.load(this.header, seqIdx)\n if (s1 !== s2) {\n return null\n }\n\n let format: FrameFormat\n try {\n format = decodeFormat(formatCode)\n } catch {\n // A torn metadata read can yield an out-of-range code; drop the frame.\n return null\n }\n\n const meta: FrameMeta = { width, height, format, pts, byteLength }\n const handle: FrameHandle = {\n shmId: this.shmId,\n slot,\n seq: s1,\n width,\n height,\n format,\n pts,\n byteLength,\n nodeId: this.nodeId,\n slotCount: this.geometry.slotCount,\n }\n return { pixels, meta, handle }\n }\n\n /**\n * Read the latest committed frame **zero-copy** — `FrameView.pixels` is a\n * `subarray` over the shared mapping, no memcpy. The caller MUST process it\n * synchronously and then call `validate()`; see the `FrameView` contract.\n * Returns `null` when the ring is empty or the latest slot is mid-write.\n */\n readLatestView(): FrameView | null {\n const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX)\n if (writeIndex === 0) {\n return null\n }\n const slot = (writeIndex - 1) % this.geometry.slotCount\n return this.seqlockReadView(slot, null)\n }\n\n /**\n * Read the frame a `FrameHandle` refers to **zero-copy** — `FrameView.pixels`\n * is a `subarray` over the shared mapping, no memcpy. The caller MUST process\n * it synchronously and then call `validate()`; see the `FrameView` contract.\n * Returns `null` when the slot is recycled (seq mismatch) or mid-write.\n */\n readHandleView(handle: FrameHandle): FrameView | null {\n if (handle.shmId !== this.shmId) {\n throw new Error(\n `FrameRingReader: handle is for segment \"${handle.shmId}\", ` +\n `this reader is on \"${this.shmId}\"`,\n )\n }\n if (handle.slot < 0 || handle.slot >= this.geometry.slotCount) {\n return null\n }\n return this.seqlockReadView(handle.slot, handle.seq)\n }\n\n /**\n * The zero-copy half of the seqlock read: validate the opening seqlock, then\n * return a `subarray` over the slot plus a `validate()` closure that re-reads\n * the seqlock. No pixel copy — the consumer owns the closing recheck.\n */\n private seqlockReadView(\n slot: number,\n expectedSeq: number | null,\n ): FrameView | null {\n const seqIdx = this.seqIndex(slot)\n\n // (1) opening seqlock check — odd ⇒ write in progress; mismatch ⇒ recycled.\n const s1 = Atomics.load(this.header, seqIdx)\n if ((s1 & 1) !== 0) {\n return null\n }\n if (expectedSeq !== null && s1 !== expectedSeq) {\n return null\n }\n\n // (2) read metadata — a torn metadata read is caught by the byteLength\n // bound and the closing `validate()` the consumer must call.\n const metaOffset = this.metaOffsetOf(slot)\n const width = this.view.getInt32(metaOffset + META_OFF_WIDTH, true)\n const height = this.view.getInt32(metaOffset + META_OFF_HEIGHT, true)\n const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true)\n const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true)\n const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true)\n\n if (byteLength < 0 || byteLength > this.geometry.slotByteLength) {\n return null\n }\n\n let format: FrameFormat\n try {\n format = decodeFormat(formatCode)\n } catch {\n return null\n }\n\n // (3) zero-copy view directly over the mapped slot — NO memcpy.\n const pixelOffset = this.pixelOffsetOf(slot)\n const pixels = this.segment.subarray(pixelOffset, pixelOffset + byteLength)\n\n const meta: FrameMeta = { width, height, format, pts, byteLength }\n const handle: FrameHandle = {\n shmId: this.shmId,\n slot,\n seq: s1,\n width,\n height,\n format,\n pts,\n byteLength,\n nodeId: this.nodeId,\n slotCount: this.geometry.slotCount,\n }\n // `validate()` is the closing seqlock half — the consumer calls it after a\n // synchronous read of `pixels` to confirm the slot was not recycled.\n const validate = (): boolean => Atomics.load(this.header, seqIdx) === s1\n return { pixels, meta, handle, validate }\n }\n}\n","/**\n * `FrameRingReaderCache` — the consumer side of the shared-memory frame plane\n * (Phase 5 / D9).\n *\n * A frame consumer (motion, detection, post-analysis) drains zero-pixel\n * `FrameHandle`s from the broker via `pullFrameHandles` and must read the\n * actual pixels back from the shared-memory ring the decoder wrote them into.\n * Each `FrameHandle` names its segment (`shmId`) and carries its ring depth\n * (`slotCount`) — the cache opens each segment **once** (a syscall) and reuses\n * the `FrameRingReader` for every later handle on the same ring.\n *\n * The decoder re-creates its segment under a fresh generation-tagged name on a\n * resolution change (`makeSegmentName`), so a stale `shmId` simply stops\n * appearing in new handles — its reader is closed lazily by `close()` at\n * teardown. (Resolution changes on a live camera are rare; carrying a few idle\n * mappings until teardown is cheaper than reference-counting handles.)\n *\n * A `null` from `readHandle` means the ring slot was recycled before the\n * consumer read it — a dropped frame, which is the correct latest-wins\n * behaviour for live video. The caller skips that frame.\n *\n * This is a pure consumer of the ring API and lives in `@camstack/shm-ring` so\n * any addon can read frames without importing from a sibling addon.\n */\nimport type { DecodedFrame, FrameHandle, IScopedLogger } from '@camstack/types'\nimport { errMsg } from '@camstack/types'\n\nimport {\n FrameRingReader,\n computeSegmentSize,\n computeSlotByteLength,\n} from './frame-ring.js'\nimport { openSegment } from './native.js'\nimport type { ShmSegment } from './native.js'\n\n/** One opened shm segment plus the reader mapped over it. */\ninterface OpenRing {\n readonly segment: ShmSegment\n readonly reader: FrameRingReader\n}\n\n/**\n * Opens (and caches) a `FrameRingReader` per `shmId` and reads the pixels a\n * `FrameHandle` refers to. Single-consumer — one cache per frame subscription.\n */\nexport class FrameRingReaderCache {\n private readonly rings = new Map<string, OpenRing>()\n private readonly logger: IScopedLogger | undefined\n private closed = false\n\n constructor(logger?: IScopedLogger) {\n this.logger = logger\n }\n\n /**\n * Read the pixels a `FrameHandle` refers to and return them as a\n * `DecodedFrame`. Returns `null` when the ring slot was recycled before the\n * read (a dropped frame) or the segment could not be opened.\n */\n read(handle: FrameHandle): DecodedFrame | null {\n if (this.closed) return null\n const ring = this.ringFor(handle)\n if (!ring) return null\n let frame: ReturnType<FrameRingReader['readHandle']>\n try {\n frame = ring.reader.readHandle(handle)\n } catch (err) {\n // A mismatched shmId would throw — defensive only; `ringFor` keys the\n // reader by the handle's own shmId so this should never fire.\n this.logger?.warn('frame-ring reader: readHandle failed', {\n meta: { shmId: handle.shmId, error: errMsg(err) },\n })\n return null\n }\n if (!frame) return null\n return {\n data: frame.pixels,\n width: frame.meta.width,\n height: frame.meta.height,\n format: frame.meta.format,\n timestamp: frame.meta.pts,\n }\n }\n\n /** Close every cached segment. Idempotent. */\n close(): void {\n if (this.closed) return\n this.closed = true\n for (const [shmId, ring] of this.rings) {\n try {\n ring.segment.close()\n } catch (err) {\n this.logger?.warn('frame-ring reader: segment close failed', {\n meta: { shmId, error: errMsg(err) },\n })\n }\n }\n this.rings.clear()\n }\n\n // ── Internal ─────────────────────────────────────────────────────────\n\n /** Get the cached reader for a handle's segment, opening it on first use. */\n private ringFor(handle: FrameHandle): OpenRing | null {\n const cached = this.rings.get(handle.shmId)\n if (cached) return cached\n\n const slotByteLength = computeSlotByteLength(\n handle.width,\n handle.height,\n handle.format,\n )\n try {\n // The ring depth is per-resolution (Task 2): a small frame gets many\n // slots, a 4K frame few. The handle carries its own `slotCount` so the\n // reader sizes the segment view exactly — no hardcoded constant.\n const segment = openSegment(\n handle.shmId,\n computeSegmentSize(handle.slotCount, slotByteLength),\n )\n const reader = new FrameRingReader(\n segment.buffer,\n handle.shmId,\n handle.slotCount,\n slotByteLength,\n handle.nodeId,\n )\n const ring: OpenRing = { segment, reader }\n this.rings.set(handle.shmId, ring)\n return ring\n } catch (err) {\n this.logger?.warn('frame-ring reader: openSegment failed', {\n meta: { shmId: handle.shmId, error: errMsg(err) },\n })\n return null\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/native.ts","../src/frame-ring.ts","../src/frame-ring-reader-cache.ts"],"sourcesContent":["/**\n * Typed wrapper over the `shm_ring` N-API addon.\n *\n * The addon maps/unmaps a named OS shared-memory segment and hands JS a\n * zero-copy `Buffer` over the mapping. The ring/seqlock logic lives in\n * `frame-ring.ts` (Task 2) — this module is purely the segment plumbing.\n */\nimport { createRequire } from 'node:module'\nimport { fileURLToPath } from 'node:url'\nimport { dirname } from 'node:path'\n\nconst require = createRequire(import.meta.url)\nconst here = dirname(fileURLToPath(import.meta.url))\n\n/** Opaque per-segment handle the native side uses to reach the mapping on close. */\nexport interface NativeSegmentHandle {\n readonly __shmHandle: unique symbol\n}\n\n/** Raw result handed back by the native `create` / `open` calls. */\ninterface NativeSegment {\n readonly buffer: Buffer\n readonly handle: NativeSegmentHandle\n}\n\n/** The native addon surface. */\ninterface ShmAddon {\n create(name: string, byteLength: number): NativeSegment\n open(name: string, byteLength: number): NativeSegment\n close(handle: NativeSegmentHandle): void\n unlink(name: string): void\n}\n\n/**\n * `node-gyp-build` resolves a prebuilt binary if one exists, otherwise the\n * locally compiled `build/Release/shm_ring.node`. Pointed at the package root\n * (one level above `dist/` / `src/`) so both layouts resolve.\n */\nfunction loadAddon(): ShmAddon {\n const nodeGypBuild: (root: string) => unknown = require('node-gyp-build')\n const packageRoot = dirname(here)\n const addon = nodeGypBuild(packageRoot)\n if (!isShmAddon(addon)) {\n throw new Error('@camstack/shm-ring: native addon did not expose the expected surface')\n }\n return addon\n}\n\nfunction hasFunction(value: object, key: string): boolean {\n return typeof Reflect.get(value, key) === 'function'\n}\n\nfunction isShmAddon(value: unknown): value is ShmAddon {\n if (typeof value !== 'object' || value === null) return false\n return (\n hasFunction(value, 'create') &&\n hasFunction(value, 'open') &&\n hasFunction(value, 'close') &&\n hasFunction(value, 'unlink')\n )\n}\n\nconst addon: ShmAddon = loadAddon()\n\n/** A mapped shared-memory segment with a zero-copy view and lifecycle controls. */\nexport interface ShmSegment {\n /** Zero-copy `Buffer` over the mapped region. Valid until `close()`. */\n readonly buffer: Buffer\n /** Unmap this process's view of the segment. Does not unlink the name. */\n close(): void\n /**\n * Remove the segment name from the OS namespace. The backing memory is\n * reclaimed once every process has also `close()`d its view. POSIX:\n * `shm_unlink`. Windows: a no-op — the OS reclaims on last-handle-close.\n */\n unlink(): void\n}\n\nfunction wrap(name: string, native: NativeSegment): ShmSegment {\n let closed = false\n let unlinked = false\n return {\n buffer: native.buffer,\n close(): void {\n if (closed) return\n closed = true\n addon.close(native.handle)\n },\n unlink(): void {\n // Idempotent: native `Unlink` throws `ENOENT` on a second `shm_unlink`\n // of the same name, so a double `unlink()` would throw. Guard it here —\n // mirrors `close()`'s `closed` flag.\n if (unlinked) return\n unlinked = true\n addon.unlink(name)\n },\n }\n}\n\n/**\n * Create a new named shared-memory segment of `byteLength` bytes and map it.\n * The segment is zero-filled. Fails if a segment with this name already exists.\n */\nexport function createSegment(name: string, byteLength: number): ShmSegment {\n return wrap(name, addon.create(name, byteLength))\n}\n\n/**\n * Map an existing named shared-memory segment. `byteLength` must match (or be\n * smaller than) the size the segment was created with. Throws if the segment\n * does not exist.\n */\nexport function openSegment(name: string, byteLength: number): ShmSegment {\n return wrap(name, addon.open(name, byteLength))\n}\n\n/**\n * Remove a segment name from the OS namespace without holding a mapping.\n * Convenience for cleanup paths that never mapped the segment themselves.\n */\nexport function unlinkSegment(name: string): void {\n addon.unlink(name)\n}\n","/**\n * `FrameRing` — a lock-free seqlock ring-buffer over a shared-memory segment.\n *\n * One writer (the decoder) publishes decoded frames into `slotCount` slots;\n * any number of readers (motion, detection, the WebRTC encoder, …) consume\n * them latest-wins. There are no JS-level locks: correctness rests entirely\n * on a per-slot **seqlock** sequence number manipulated with `Atomics`.\n *\n * The backing memory is a genuine `mmap` (see `native.ts`), so the `Atomics`\n * loads/stores have cross-process effect. The ring logic here is pure\n * TypeScript and treats the segment as an opaque `Buffer`.\n *\n * ## Segment layout (all offsets in bytes, little-endian)\n *\n * ```\n * ┌──────────────────────────────────────────────────────────────┐\n * │ HEADER — Int32Array view, (3 + slotCount) * 4 bytes │\n * │ [0] slotCount │\n * │ [1] slotByteLength (capacity of one pixel slot) │\n * │ [2] writeIndex (monotone publish counter) │\n * │ [3 .. 3+N) seq[slot] — per-slot seqlock counter │\n * ├──────────────────────────────────────────────────────────────┤\n * │ METADATA — slotCount * SLOT_META_BYTES (32) bytes, 8-aligned │\n * │ per slot: width (Int32 @ +0) │\n * │ height (Int32 @ +4) │\n * │ format (Int32 @ +8, FRAME_FORMAT_CODES index) │\n * │ byteLen (Int32 @ +12, valid pixel bytes) │\n * │ pts (Float64@ +16, presentation timestamp) │\n * │ (8 bytes padding @ +24 — keeps slots 8-aligned) │\n * ├──────────────────────────────────────────────────────────────┤\n * │ PIXELS — slotCount * slotByteLength bytes │\n * │ slot K pixel region @ pixelsOffset + K * slotByteLength │\n * └──────────────────────────────────────────────────────────────┘\n * ```\n *\n * ## Seqlock protocol\n *\n * `seq[slot]` starts even (committed / readable). A write does:\n * 1. `Atomics.add(seq, slot, 1)` → seq becomes **odd** (write in progress);\n * 2. copy pixels + metadata into the slot;\n * 3. `Atomics.add(seq, slot, 1)` → seq becomes **even** (committed);\n * 4. `Atomics.store(writeIndex)` → publish the new latest slot.\n *\n * A read does:\n * 1. `s1 = Atomics.load(seq, slot)`; if `s1` is odd → skip (write in flight);\n * 2. copy pixels + metadata out;\n * 3. `s2 = Atomics.load(seq, slot)`; if `s1 !== s2` → skip (recycled / torn).\n *\n * An odd `s1` means a write is mid-flight; an `s1 !== s2` means the slot was\n * overwritten between the two loads — in both cases the reader drops the\n * frame rather than returning torn pixels.\n */\nimport type { FrameFormat, FrameHandle } from '@camstack/types'\n\n/** Header slot indices in the `Int32Array` header view. */\nconst HDR_SLOT_COUNT = 0\nconst HDR_SLOT_BYTE_LENGTH = 1\nconst HDR_WRITE_INDEX = 2\n/** Fixed header fields before the per-slot `seq[]` array begins. */\nconst HDR_FIXED_FIELDS = 3\n\n/** Bytes of metadata reserved per slot (24 used + 8 padding → 8-byte aligned). */\nconst SLOT_META_BYTES = 32\nconst META_OFF_WIDTH = 0\nconst META_OFF_HEIGHT = 4\nconst META_OFF_FORMAT = 8\nconst META_OFF_BYTE_LENGTH = 12\nconst META_OFF_PTS = 16\n/** Wall-clock ms (Date.now) stamped at commit — uses the 8 reserved padding\n * bytes at +24, so slot size is unchanged. Lets readers measure frame age\n * (a PTS can't be diffed against Date.now). 0 when an older writer didn't set it. */\nconst META_OFF_CAPTURED_AT = 24\n\n/**\n * Stable integer codes for `FrameFormat` — metadata stores the index, not the\n * string, so a slot's metadata is a fixed-width struct. Append-only: never\n * reorder or remove an entry, or existing segments would mis-decode.\n */\nconst FRAME_FORMAT_CODES: readonly FrameFormat[] = [\n 'jpeg',\n 'rgb',\n 'bgr',\n 'yuv420',\n 'gray',\n]\n\nfunction encodeFormat(format: FrameFormat): number {\n const code = FRAME_FORMAT_CODES.indexOf(format)\n if (code < 0) {\n throw new Error(`FrameRing: unknown frame format \"${format}\"`)\n }\n return code\n}\n\nfunction decodeFormat(code: number): FrameFormat {\n const format = FRAME_FORMAT_CODES[code]\n if (format === undefined) {\n throw new Error(`FrameRing: unknown frame format code ${code}`)\n }\n return format\n}\n\n/** Round `value` up to the next multiple of `align` (a power of two). */\nfunction alignUp(value: number, align: number): number {\n return Math.ceil(value / align) * align\n}\n\n/** Per-frame metadata published alongside the pixels of a slot. */\nexport interface FrameMeta {\n readonly width: number\n readonly height: number\n readonly format: FrameFormat\n readonly pts: number\n /** Valid pixel bytes — must be `<= slotByteLength`. */\n readonly byteLength: number\n /** Wall-clock ms (Date.now) stamped at commit; `0` when unset (older writer).\n * Optional on write (commit stamps it); always present on read. */\n readonly capturedAt?: number\n}\n\n/**\n * A scatter-write slot reservation returned by {@link FrameRingWriter.beginFrame}.\n *\n * ## Scatter-write seqlock contract (Phase 5 / D9 Task 7c — READ THIS)\n *\n * `beginFrame()` has already bumped this slot's `seq` to **odd** — a write is in\n * progress, so any reader hitting this slot skips it (returns `null` / a failed\n * `validate()`). The caller now owns an exclusive write window over `buffer`:\n *\n * 1. `buffer` is a writable `subarray` **directly over the slot's pixel\n * region in the mapped segment** — there is no intermediate copy. A\n * producer (e.g. the node-av scaler) fills it in place.\n * 2. The slot stays odd for the **entire fill duration** — not just a memcpy.\n * A scatter producer that takes longer to fill the slot (a whole scale\n * pass) holds the slot odd for that whole pass; this is correct, readers\n * simply latest-wins-drop that slot until {@link FrameRingWriter.commitFrame}.\n * 3. The caller MUST call {@link FrameRingWriter.commitFrame} with this exact\n * `slot` once the fill is done — that writes the metadata, bumps `seq` back\n * to **even** (committed) and advances `writeIndex` so readers can see it.\n *\n * Exactly one `beginFrame` may be open at a time per writer (there is a single\n * writer per ring). `buffer.byteLength` is the slot's full capacity\n * (`slotByteLength`); the caller writes at most `commitFrame`'s `meta.byteLength`\n * valid bytes into it.\n */\nexport interface FrameSlotWrite {\n /** The ring slot index this write targets — pass it back to `commitFrame`. */\n readonly slot: number\n /**\n * A writable view **directly over the slot's pixel region** in the shared\n * mapping. Fill it in place, then `commitFrame(slot, meta)`. Valid only\n * between the `beginFrame` that produced it and its matching `commitFrame`.\n */\n readonly buffer: Buffer\n}\n\n/**\n * The result of a successful seqlock read.\n *\n * ## Buffer-lifetime contract (Phase 5 / D9 Task 7b — READ THIS)\n *\n * `pixels` is a view onto the **reader's reusable scratch buffer**, NOT a\n * freshly allocated copy. The seqlock-validated pixel bytes are memcpy'd into\n * that scratch buffer (the copy is the seqlock safety copy — the bytes survive\n * a post-read slot recycle), but the buffer itself is reused on this reader's\n * **next** `readHandle` / `readLatest` call.\n *\n * Therefore `pixels` is **valid only until this same reader's next read**:\n *\n * - A consumer that processes the frame **synchronously**, fully, before it\n * issues another read on this reader may use `pixels` directly (borrow).\n * - A consumer that **retains** the frame past its next read — queues it for\n * async work, stores it, hands it to another tick — MUST `Buffer.from(...)`\n * it into its own storage at the point of retention. Holding the borrowed\n * `pixels` past the next read is silent corruption: the next read overwrites\n * the bytes in place.\n *\n * `meta` and `handle` are plain immutable values — safe to retain freely.\n */\nexport interface FrameRead {\n /**\n * The slot's pixel bytes copied into the reader's reusable scratch buffer.\n *\n * **Borrowed, not owned** — valid only until this reader's next\n * `readHandle` / `readLatest`. Retain past that point ⇒ copy first\n * (`Buffer.from(pixels)`). See the `FrameRead` doc comment.\n */\n readonly pixels: Buffer\n readonly meta: FrameMeta\n /** A serialisable handle for this exact frame. */\n readonly handle: FrameHandle\n}\n\n/**\n * A **zero-copy** read: `pixels` is a `subarray` directly over the shared\n * mapping — no memcpy at all (Phase 5 / D9 Task 7b Step 3).\n *\n * ## Validation contract (READ THIS — concurrency-sensitive)\n *\n * Because `pixels` aliases the live ring slot, a concurrent writer can recycle\n * that slot at any moment, tearing the bytes a consumer is reading. A\n * `FrameView` is therefore an **optimistic** read: the seqlock's *opening*\n * check already passed (the slot was committed and, for a handle read, the seq\n * matched), but the closing re-check is deferred to the consumer.\n *\n * A consumer MUST:\n * 1. read the slot **synchronously and fast** — no `await`, no yielding;\n * 2. call `validate()` **immediately after** finishing with `pixels`;\n * 3. if `validate()` returns `false`, **discard** whatever it computed from\n * `pixels` — the bytes were (or may have been) overwritten mid-read, i.e.\n * a torn frame, the latest-wins drop.\n *\n * `validate()` returning `true` means the slot's `seq` is unchanged since the\n * read opened — the bytes the consumer just processed were a single committed\n * frame, not torn. This is the same seqlock guarantee `readHandle` gives, but\n * the closing half is the consumer's responsibility instead of being baked\n * into a memcpy + recheck. Use this ONLY for tight synchronous consumers\n * (e.g. motion's frame scan, the perf bench's compute stage); any consumer\n * that retains or processes asynchronously MUST use the copying `readHandle` /\n * `readLatest` instead.\n */\nexport interface FrameView {\n /**\n * A `subarray` view directly over the shared-memory slot — **zero-copy**.\n * Valid for a synchronous read only; `validate()` confirms it was not torn.\n */\n readonly pixels: Buffer\n readonly meta: FrameMeta\n readonly handle: FrameHandle\n /**\n * Re-check the slot's seqlock. `true` ⇒ the slot was not recycled while the\n * consumer read `pixels` — the frame is intact. `false` ⇒ a torn read,\n * discard everything derived from `pixels`. Call once, right after the\n * synchronous read of `pixels` completes.\n */\n validate(): boolean\n}\n\n/** Internal geometry of a sized segment. */\ninterface RingGeometry {\n readonly slotCount: number\n readonly slotByteLength: number\n /** Byte length of the `Int32Array` header. */\n readonly headerBytes: number\n /** Byte offset of the metadata region. */\n readonly metaOffset: number\n /** Byte offset of the pixel region. */\n readonly pixelsOffset: number\n /** Total segment byte length. */\n readonly totalBytes: number\n}\n\n/** Compute the geometry of a ring with the given slot count and slot size. */\nfunction computeGeometry(slotCount: number, slotByteLength: number): RingGeometry {\n if (!Number.isInteger(slotCount) || slotCount <= 0) {\n throw new Error(`FrameRing: slotCount must be a positive integer, got ${slotCount}`)\n }\n if (!Number.isInteger(slotByteLength) || slotByteLength <= 0) {\n throw new Error(\n `FrameRing: slotByteLength must be a positive integer, got ${slotByteLength}`,\n )\n }\n const headerBytes = alignUp((HDR_FIXED_FIELDS + slotCount) * 4, 8)\n const metaOffset = headerBytes\n const metaBytes = slotCount * SLOT_META_BYTES\n const pixelsOffset = alignUp(metaOffset + metaBytes, 8)\n const totalBytes = pixelsOffset + slotCount * slotByteLength\n return {\n slotCount,\n slotByteLength,\n headerBytes,\n metaOffset,\n pixelsOffset,\n totalBytes,\n }\n}\n\n/**\n * Total byte length a segment must have to hold a `slotCount`-slot ring with\n * `slotByteLength`-byte pixel slots. The decoder uses this to size the\n * shared-memory segment before constructing a `FrameRingWriter` over it.\n */\nexport function computeSegmentSize(slotCount: number, slotByteLength: number): number {\n return computeGeometry(slotCount, slotByteLength).totalBytes\n}\n\n/**\n * Slot-count bounds for a per-resolution ring. {@link deriveSlotCount} clamps\n * the budget-derived count into `[MIN_RING_SLOTS, MAX_RING_SLOTS]`:\n * - `MIN` keeps a tiny floor even when one frame nearly exhausts the budget\n * (a 4K RGB frame is ~24.9 MB — a 128 MB budget yields only ~5 raw slots);\n * - `MAX` caps the ring for small frames so a 360p stream does not allocate\n * hundreds of slots' worth of shared memory it will never use latest-wins.\n */\nexport const MIN_RING_SLOTS = 6\nexport const MAX_RING_SLOTS = 64\n\n/**\n * Slot count for a ring, derived from a per-ring shared-memory budget.\n *\n * `budgetBytes` is the shared memory a single stream's ring may consume; the\n * raw count is `floor(budgetBytes / slotByteLength)`, clamped into\n * `[MIN_RING_SLOTS, MAX_RING_SLOTS]`. Live video is latest-wins, so the slot\n * count only needs to absorb the writer-commit / reader-read window — the\n * budget trades shared-memory footprint against that window per resolution.\n */\nexport function deriveSlotCount(budgetBytes: number, slotByteLength: number): number {\n const raw = Math.floor(budgetBytes / slotByteLength)\n return Math.min(MAX_RING_SLOTS, Math.max(MIN_RING_SLOTS, raw))\n}\n\n/**\n * Bytes per pixel for each packed decode format a frame ring slot can hold.\n *\n * This is the single source of truth shared by the decoder write side\n * (`DecoderFrameRingSink`) and every consumer read side\n * (`FrameRingReaderCache`): if the two computed different values, a reader\n * would mis-map the shared-memory segment and read corrupt pixels.\n *\n * `jpeg` has no fixed bytes-per-pixel — `computeSlotByteLength` short-circuits\n * it to the equivalent RGB24 raster before this is ever consulted, so the `3`\n * here is only a safe placeholder for exhaustiveness over `FrameFormat`.\n */\nexport function bytesPerPixel(format: FrameFormat): number {\n switch (format) {\n case 'gray':\n return 1\n case 'rgb':\n case 'bgr':\n return 3\n case 'yuv420':\n // 4:2:0 planar — 1 byte luma + ¼+¼ chroma per pixel = 1.5 bytes/px.\n // Rounded up to 2 so the slot is never undersized for a planar frame.\n return 2\n case 'jpeg':\n // Variable-length; see the doc comment above. `computeSlotByteLength`\n // sizes a jpeg slot from the RGB24 raster, never via this branch.\n return 3\n }\n}\n\n/**\n * Per-slot byte capacity for a frame of the given geometry — the single source\n * of truth for both the decoder write side and the consumer read side.\n *\n * For raw formats this is exactly `width × height × bpp`. For `jpeg` (variable\n * length) the slot is sized to the equivalent RGB24 raster — a JPEG is always\n * far smaller than its source raster, so an RGB24-sized slot is a safe upper\n * bound. Note: this upper bound holds only while the JPEG encoder is pinned to\n * its current quality (~80). A near-lossless quality setting can produce a\n * JPEG that exceeds the RGB24 byte count; if quality is raised significantly,\n * this slot size must be revisited.\n */\nexport function computeSlotByteLength(\n width: number,\n height: number,\n format: FrameFormat,\n): number {\n if (format === 'jpeg') {\n return width * height * 3\n }\n return width * height * bytesPerPixel(format)\n}\n\n/**\n * Shared geometry + typed views over a segment buffer. The header is an\n * `Int32Array` so `Atomics` ops apply directly; metadata and pixels are read\n * and written through `DataView` / `Buffer` slices.\n */\nabstract class FrameRingBase {\n protected readonly geometry: RingGeometry\n /** `Int32Array` over the header — slotCount, slotByteLength, writeIndex, seq[]. */\n protected readonly header: Int32Array\n protected readonly view: DataView\n\n protected constructor(\n protected readonly segment: Buffer,\n protected readonly shmId: string,\n slotCount: number,\n slotByteLength: number,\n ) {\n this.geometry = computeGeometry(slotCount, slotByteLength)\n if (segment.byteLength < this.geometry.totalBytes) {\n throw new Error(\n `FrameRing: segment is ${segment.byteLength} bytes, ` +\n `need ${this.geometry.totalBytes} for ${slotCount}×${slotByteLength}`,\n )\n }\n const headerInts = HDR_FIXED_FIELDS + slotCount\n this.header = new Int32Array(\n segment.buffer,\n segment.byteOffset,\n headerInts,\n )\n this.view = new DataView(segment.buffer, segment.byteOffset, segment.byteLength)\n }\n\n /** Header index of `seq[slot]`. */\n protected seqIndex(slot: number): number {\n return HDR_FIXED_FIELDS + slot\n }\n\n /** Byte offset of slot `slot`'s metadata struct. */\n protected metaOffsetOf(slot: number): number {\n return this.geometry.metaOffset + slot * SLOT_META_BYTES\n }\n\n /** Byte offset of slot `slot`'s pixel region. */\n protected pixelOffsetOf(slot: number): number {\n return this.geometry.pixelsOffset + slot * this.geometry.slotByteLength\n }\n}\n\n/**\n * The single writer for a ring. Constructed by whoever owns the segment (the\n * decoder). On construction it stamps `slotCount` / `slotByteLength` into the\n * header so a reader opening the same segment can self-describe.\n */\nexport class FrameRingWriter extends FrameRingBase {\n /** Slot index of an in-flight `beginWrite()`, or `null` when idle. */\n private pendingSlot: number | null = null\n\n /** Cluster node id stamped into every `FrameHandle` this writer produces. */\n private readonly nodeId: string\n\n constructor(\n segment: Buffer,\n shmId: string,\n slotCount: number,\n slotByteLength: number,\n nodeId: string,\n ) {\n super(segment, shmId, slotCount, slotByteLength)\n this.nodeId = nodeId\n // Stamp the self-describing header. `writeIndex` starts at 0; before the\n // first commit there is no readable frame (seq[0] is even == 0).\n Atomics.store(this.header, HDR_SLOT_COUNT, slotCount)\n Atomics.store(this.header, HDR_SLOT_BYTE_LENGTH, slotByteLength)\n }\n\n /** The number of slots in this ring — derived per-resolution from the budget. */\n get slotCount(): number {\n return this.geometry.slotCount\n }\n\n /** The slot the next `writeFrame` / `beginFrame` will target. */\n private targetSlot(): number {\n const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX)\n return writeIndex % this.geometry.slotCount\n }\n\n /**\n * Open the seqlock on the next slot and hand the caller a writable view\n * directly over that slot's pixel region — the **scatter-write** entry point.\n *\n * The seqlock contract (see {@link FrameSlotWrite}): this bumps the slot's\n * `seq` to **odd** (write in progress), so a concurrent reader skips the slot\n * for the entire fill window. The caller fills `buffer` in place — there is\n * NO intermediate copy: a producer (the node-av scaler) writes its packed\n * output straight into the mapped segment — then MUST call\n * {@link commitFrame} with the returned `slot` to publish the frame.\n *\n * Exactly one `beginFrame` may be open per writer at a time.\n */\n beginFrame(): FrameSlotWrite {\n if (this.pendingSlot !== null) {\n throw new Error('FrameRingWriter: a frame write is already in progress')\n }\n const slot = this.targetSlot()\n // seq → odd: a write is now in progress on this slot.\n Atomics.add(this.header, this.seqIndex(slot), 1)\n this.pendingSlot = slot\n const pixelOffset = this.pixelOffsetOf(slot)\n const buffer = this.segment.subarray(\n pixelOffset,\n pixelOffset + this.geometry.slotByteLength,\n )\n return { slot, buffer }\n }\n\n /**\n * Close the seqlock opened by {@link beginFrame}: write the metadata, bump\n * `seq` back to **even** (committed) and advance `writeIndex` so readers see\n * this slot as the latest. Returns the published frame's `FrameHandle`.\n *\n * `slot` must be the value from the matching `beginFrame`'s\n * {@link FrameSlotWrite}. The pixels are assumed already filled in place by\n * the caller (the scatter-write contract) — `commitFrame` copies nothing.\n */\n commitFrame(slot: number, meta: FrameMeta): FrameHandle {\n if (this.pendingSlot === null) {\n throw new Error('FrameRingWriter: commitFrame called without beginFrame')\n }\n if (slot !== this.pendingSlot) {\n throw new Error(\n `FrameRingWriter: commitFrame slot ${slot} does not match the ` +\n `in-progress beginFrame slot ${this.pendingSlot}`,\n )\n }\n const { slotByteLength } = this.geometry\n if (meta.byteLength > slotByteLength) {\n throw new Error(\n `FrameRingWriter: frame is ${meta.byteLength} bytes, ` +\n `slot capacity is ${slotByteLength}`,\n )\n }\n this.pendingSlot = null\n\n // --- write metadata (inside the still-open odd-seq window) ---\n const metaOffset = this.metaOffsetOf(slot)\n this.view.setInt32(metaOffset + META_OFF_WIDTH, meta.width, true)\n this.view.setInt32(metaOffset + META_OFF_HEIGHT, meta.height, true)\n this.view.setInt32(metaOffset + META_OFF_FORMAT, encodeFormat(meta.format), true)\n this.view.setInt32(metaOffset + META_OFF_BYTE_LENGTH, meta.byteLength, true)\n this.view.setFloat64(metaOffset + META_OFF_PTS, meta.pts, true)\n this.view.setFloat64(metaOffset + META_OFF_CAPTURED_AT, meta.capturedAt ?? Date.now(), true)\n\n // --- close the seqlock: seq → even (committed) ---\n // `Atomics.add` returns the previous (odd) value; +1 gives the new even seq.\n const committedSeq = Atomics.add(this.header, this.seqIndex(slot), 1) + 1\n\n // --- publish: advance writeIndex so readers see this as the latest ---\n Atomics.add(this.header, HDR_WRITE_INDEX, 1)\n\n return {\n shmId: this.shmId,\n slot,\n seq: committedSeq,\n width: meta.width,\n height: meta.height,\n format: meta.format,\n pts: meta.pts,\n byteLength: meta.byteLength,\n nodeId: this.nodeId,\n slotCount: this.geometry.slotCount,\n }\n }\n\n /**\n * Abandon the seqlock opened by {@link beginFrame} **without publishing the\n * slot** — the degenerate-path counterpart of {@link commitFrame}.\n *\n * `beginFrame` has bumped the slot's `seq` to **odd**. A caller that opened a\n * slot but then could not fill it with valid pixels (the producer failed, or\n * there were no source planes) MUST NOT `commitFrame` — that would close the\n * seqlock over uninitialised / stale shared-memory bytes and advance\n * `writeIndex`, so a reader would see those garbage bytes as a real, latest\n * frame. `abortFrame` instead:\n *\n * 1. bumps `seq` from odd back to **even** — the slot is not left stuck\n * odd, so the writer can reuse it on a later cycle;\n * 2. does **NOT** advance `writeIndex` — `readLatest` therefore never\n * surfaces this slot as the latest (the prior latest stays latest);\n * 3. returns no `FrameHandle` — nothing is handed downstream.\n *\n * The slot's pixel bytes after `abortFrame` are **undefined** (whatever was\n * there before, or a half-written producer output) — but that is harmless\n * because no consumer ever sees the slot as committed: `readLatest` skips it\n * (writeIndex did not move) and any handle that happened to point at this\n * slot from an earlier commit sees the changed `seq` and is correctly\n * rejected by `readHandle`'s seq check.\n *\n * `slot` must be the value from the matching `beginFrame` — validated\n * exactly as {@link commitFrame} does.\n */\n abortFrame(slot: number): void {\n if (this.pendingSlot === null) {\n throw new Error('FrameRingWriter: abortFrame called without beginFrame')\n }\n if (slot !== this.pendingSlot) {\n throw new Error(\n `FrameRingWriter: abortFrame slot ${slot} does not match the ` +\n `in-progress beginFrame slot ${this.pendingSlot}`,\n )\n }\n this.pendingSlot = null\n // Close the seqlock: seq odd → even. The slot is committed-shape again so\n // it can be reused, but writeIndex is NOT advanced — the slot is never the\n // latest, and its pixel content is intentionally undefined.\n Atomics.add(this.header, this.seqIndex(slot), 1)\n }\n\n /**\n * Publish one frame from a caller-provided pixel `Buffer` — the convenience\n * wrapper over the scatter-write {@link beginFrame} / {@link commitFrame}\n * pair: it opens the slot, copies `pixels` into it, then commits.\n *\n * Prefer the `beginFrame` / `commitFrame` pair when the pixels can be\n * produced directly into the slot (the node-av scaler scattering its packed\n * output into the mapped segment) — that path has zero write-side copy.\n * `writeFrame` keeps the single-call form for callers that already hold a\n * detached pixel `Buffer`.\n */\n writeFrame(pixels: Buffer, meta: FrameMeta): FrameHandle {\n if (pixels.byteLength < meta.byteLength) {\n throw new Error(\n `FrameRingWriter: pixels buffer (${pixels.byteLength} bytes) ` +\n `shorter than meta.byteLength (${meta.byteLength})`,\n )\n }\n const { slot, buffer } = this.beginFrame()\n // Copy the pixels into the slot, inside the open odd-seq window.\n buffer.set(pixels.subarray(0, meta.byteLength))\n return this.commitFrame(slot, meta)\n }\n}\n\n/**\n * A reader for a ring. Many readers may share a segment concurrently with the\n * single writer. Every read is a seqlock read — a torn or recycled slot is\n * dropped (returns `null`), never returned as garbage pixels.\n */\nexport class FrameRingReader extends FrameRingBase {\n /**\n * The reusable per-reader scratch buffer the seqlock-validated pixels are\n * copied into — see the `FrameRead` doc comment for the borrow contract.\n *\n * Sized to `slotByteLength` on construction (the segment's stamped slot\n * capacity) and lazily grown if a slot ever reports a larger `byteLength`.\n * Reusing one buffer per reader replaces the per-read `Buffer.allocUnsafe` —\n * the D9 perf gate's regression (Task 7b): a fresh ~5.93 MB allocation per\n * 1080p frame churned ~1.39 GB/s and stalled on GC. The single memcpy stays\n * (it IS the seqlock safety copy); only the allocation is removed.\n */\n private scratch: Buffer\n\n /**\n * Cluster node id of the node that OWNS this segment (i.e. the writer's\n * node — the node whose shared memory physically holds the ring), stamped\n * into every `FrameHandle` this reader produces. `FrameHandle.nodeId` always\n * identifies the segment-owning node, never the reading node, so callers\n * pass the writer's node id here (e.g. `FrameRingReaderCache` passes\n * `handle.nodeId` straight through).\n */\n private readonly nodeId: string\n\n constructor(\n segment: Buffer,\n shmId: string,\n slotCount: number,\n slotByteLength: number,\n /**\n * Node id of the segment-owning (writer's) node — see the `nodeId` field\n * doc. NOT this reader's own node.\n */\n nodeId: string,\n ) {\n super(segment, shmId, slotCount, slotByteLength)\n this.nodeId = nodeId\n this.scratch = Buffer.allocUnsafe(slotByteLength)\n }\n\n /**\n * Return the reader's scratch buffer as a view of exactly `byteLength` bytes,\n * growing the backing buffer first if a slot ever exceeds the stamped slot\n * capacity (defensive — `slotByteLength` should always bound `byteLength`).\n */\n private scratchFor(byteLength: number): Buffer {\n if (byteLength > this.scratch.byteLength) {\n this.scratch = Buffer.allocUnsafe(byteLength)\n }\n return this.scratch.subarray(0, byteLength)\n }\n\n /**\n * Read the latest committed frame, latest-wins. Returns `null` when the ring\n * is empty (no frame ever published) or the latest slot is mid-write /\n * recycled (a slow reader simply drops that frame and retries next tick).\n *\n * The returned `FrameRead.pixels` is **borrowed** from this reader's reusable\n * scratch buffer — valid only until this reader's next read. A consumer that\n * retains it past the next read MUST copy. See the `FrameRead` doc comment.\n */\n readLatest(): FrameRead | null {\n const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX)\n if (writeIndex === 0) {\n return null\n }\n // The most recently published slot is writeIndex-1 modulo slotCount.\n const slot = (writeIndex - 1) % this.geometry.slotCount\n return this.seqlockRead(slot, null)\n }\n\n /**\n * Read the exact frame a `FrameHandle` refers to. Returns `null` when the\n * slot has been recycled (its committed `seq` no longer matches `handle.seq`)\n * or is mid-write — i.e. the frame is gone, not torn.\n *\n * The returned `FrameRead.pixels` is **borrowed** from this reader's reusable\n * scratch buffer — valid only until this reader's next read. A consumer that\n * retains it past the next read MUST copy. See the `FrameRead` doc comment.\n */\n readHandle(handle: FrameHandle): FrameRead | null {\n if (handle.shmId !== this.shmId) {\n throw new Error(\n `FrameRingReader: handle is for segment \"${handle.shmId}\", ` +\n `this reader is on \"${this.shmId}\"`,\n )\n }\n if (handle.slot < 0 || handle.slot >= this.geometry.slotCount) {\n return null\n }\n return this.seqlockRead(handle.slot, handle.seq)\n }\n\n /**\n * The seqlock read of a single slot.\n *\n * `expectedSeq` is the committed seq a `readHandle` caller demands; pass\n * `null` for `readLatest`, which accepts whatever even seq it finds.\n */\n private seqlockRead(slot: number, expectedSeq: number | null): FrameRead | null {\n const seqIdx = this.seqIndex(slot)\n\n // (1) read the sequence; an odd value means a write is in progress.\n const s1 = Atomics.load(this.header, seqIdx)\n if ((s1 & 1) !== 0) {\n return null\n }\n if (expectedSeq !== null && s1 !== expectedSeq) {\n // The slot has moved on (recycled) — the requested frame is gone.\n return null\n }\n\n // (2) read metadata + pixels into a private copy.\n const metaOffset = this.metaOffsetOf(slot)\n const width = this.view.getInt32(metaOffset + META_OFF_WIDTH, true)\n const height = this.view.getInt32(metaOffset + META_OFF_HEIGHT, true)\n const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true)\n const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true)\n const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true)\n const capturedAt = this.view.getFloat64(metaOffset + META_OFF_CAPTURED_AT, true)\n\n if (byteLength < 0 || byteLength > this.geometry.slotByteLength) {\n // Metadata is being rewritten — treat as a torn read, skip.\n return null\n }\n const pixelOffset = this.pixelOffsetOf(slot)\n // Copy the pixels out into this reader's REUSABLE scratch buffer (not a\n // fresh allocation — see `scratch`). The copy is still the seqlock safety\n // copy: the bytes must survive a post-read slot recycle, and the recheck\n // below validates they were not torn mid-copy. The returned buffer is\n // borrowed — valid only until this reader's next read (see `FrameRead`).\n const pixels = this.scratchFor(byteLength)\n this.segment.copy(pixels, 0, pixelOffset, pixelOffset + byteLength)\n\n // (3) re-read the sequence; any change means the slot was overwritten\n // between (1) and (2) — the copy is torn, drop it.\n const s2 = Atomics.load(this.header, seqIdx)\n if (s1 !== s2) {\n return null\n }\n\n let format: FrameFormat\n try {\n format = decodeFormat(formatCode)\n } catch {\n // A torn metadata read can yield an out-of-range code; drop the frame.\n return null\n }\n\n const meta: FrameMeta = { width, height, format, pts, byteLength, capturedAt }\n const handle: FrameHandle = {\n shmId: this.shmId,\n slot,\n seq: s1,\n width,\n height,\n format,\n pts,\n byteLength,\n nodeId: this.nodeId,\n slotCount: this.geometry.slotCount,\n }\n return { pixels, meta, handle }\n }\n\n /**\n * Read the latest committed frame **zero-copy** — `FrameView.pixels` is a\n * `subarray` over the shared mapping, no memcpy. The caller MUST process it\n * synchronously and then call `validate()`; see the `FrameView` contract.\n * Returns `null` when the ring is empty or the latest slot is mid-write.\n */\n readLatestView(): FrameView | null {\n const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX)\n if (writeIndex === 0) {\n return null\n }\n const slot = (writeIndex - 1) % this.geometry.slotCount\n return this.seqlockReadView(slot, null)\n }\n\n /**\n * Read the frame a `FrameHandle` refers to **zero-copy** — `FrameView.pixels`\n * is a `subarray` over the shared mapping, no memcpy. The caller MUST process\n * it synchronously and then call `validate()`; see the `FrameView` contract.\n * Returns `null` when the slot is recycled (seq mismatch) or mid-write.\n */\n readHandleView(handle: FrameHandle): FrameView | null {\n if (handle.shmId !== this.shmId) {\n throw new Error(\n `FrameRingReader: handle is for segment \"${handle.shmId}\", ` +\n `this reader is on \"${this.shmId}\"`,\n )\n }\n if (handle.slot < 0 || handle.slot >= this.geometry.slotCount) {\n return null\n }\n return this.seqlockReadView(handle.slot, handle.seq)\n }\n\n /**\n * The zero-copy half of the seqlock read: validate the opening seqlock, then\n * return a `subarray` over the slot plus a `validate()` closure that re-reads\n * the seqlock. No pixel copy — the consumer owns the closing recheck.\n */\n private seqlockReadView(\n slot: number,\n expectedSeq: number | null,\n ): FrameView | null {\n const seqIdx = this.seqIndex(slot)\n\n // (1) opening seqlock check — odd ⇒ write in progress; mismatch ⇒ recycled.\n const s1 = Atomics.load(this.header, seqIdx)\n if ((s1 & 1) !== 0) {\n return null\n }\n if (expectedSeq !== null && s1 !== expectedSeq) {\n return null\n }\n\n // (2) read metadata — a torn metadata read is caught by the byteLength\n // bound and the closing `validate()` the consumer must call.\n const metaOffset = this.metaOffsetOf(slot)\n const width = this.view.getInt32(metaOffset + META_OFF_WIDTH, true)\n const height = this.view.getInt32(metaOffset + META_OFF_HEIGHT, true)\n const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true)\n const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true)\n const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true)\n const capturedAt = this.view.getFloat64(metaOffset + META_OFF_CAPTURED_AT, true)\n\n if (byteLength < 0 || byteLength > this.geometry.slotByteLength) {\n return null\n }\n\n let format: FrameFormat\n try {\n format = decodeFormat(formatCode)\n } catch {\n return null\n }\n\n // (3) zero-copy view directly over the mapped slot — NO memcpy.\n const pixelOffset = this.pixelOffsetOf(slot)\n const pixels = this.segment.subarray(pixelOffset, pixelOffset + byteLength)\n\n const meta: FrameMeta = { width, height, format, pts, byteLength, capturedAt }\n const handle: FrameHandle = {\n shmId: this.shmId,\n slot,\n seq: s1,\n width,\n height,\n format,\n pts,\n byteLength,\n nodeId: this.nodeId,\n slotCount: this.geometry.slotCount,\n }\n // `validate()` is the closing seqlock half — the consumer calls it after a\n // synchronous read of `pixels` to confirm the slot was not recycled.\n const validate = (): boolean => Atomics.load(this.header, seqIdx) === s1\n return { pixels, meta, handle, validate }\n }\n}\n","/**\n * `FrameRingReaderCache` — the consumer side of the shared-memory frame plane\n * (Phase 5 / D9).\n *\n * A frame consumer (motion, detection, post-analysis) drains zero-pixel\n * `FrameHandle`s from the broker via `pullFrameHandles` and must read the\n * actual pixels back from the shared-memory ring the decoder wrote them into.\n * Each `FrameHandle` names its segment (`shmId`) and carries its ring depth\n * (`slotCount`) — the cache opens each segment **once** (a syscall) and reuses\n * the `FrameRingReader` for every later handle on the same ring.\n *\n * The decoder re-creates its segment under a fresh generation-tagged name on a\n * resolution change (`makeSegmentName`), so a stale `shmId` simply stops\n * appearing in new handles — its reader is closed lazily by `close()` at\n * teardown. (Resolution changes on a live camera are rare; carrying a few idle\n * mappings until teardown is cheaper than reference-counting handles.)\n *\n * A `null` from `readHandle` means the ring slot was recycled before the\n * consumer read it — a dropped frame, which is the correct latest-wins\n * behaviour for live video. The caller skips that frame.\n *\n * This is a pure consumer of the ring API and lives in `@camstack/shm-ring` so\n * any addon can read frames without importing from a sibling addon.\n */\nimport type { DecodedFrame, FrameHandle, IScopedLogger } from '@camstack/types'\nimport { errMsg } from '@camstack/types'\n\nimport {\n FrameRingReader,\n computeSegmentSize,\n computeSlotByteLength,\n} from './frame-ring.js'\nimport { openSegment } from './native.js'\nimport type { ShmSegment } from './native.js'\n\n/** One opened shm segment plus the reader mapped over it. */\ninterface OpenRing {\n readonly segment: ShmSegment\n readonly reader: FrameRingReader\n}\n\n/**\n * Opens (and caches) a `FrameRingReader` per `shmId` and reads the pixels a\n * `FrameHandle` refers to. Single-consumer — one cache per frame subscription.\n */\nexport class FrameRingReaderCache {\n private readonly rings = new Map<string, OpenRing>()\n private readonly logger: IScopedLogger | undefined\n private closed = false\n\n constructor(logger?: IScopedLogger) {\n this.logger = logger\n }\n\n /**\n * Read the pixels a `FrameHandle` refers to and return them as a\n * `DecodedFrame`. Returns `null` when the ring slot was recycled before the\n * read (a dropped frame) or the segment could not be opened.\n */\n read(handle: FrameHandle): DecodedFrame | null {\n if (this.closed) return null\n const ring = this.ringFor(handle)\n if (!ring) return null\n let frame: ReturnType<FrameRingReader['readHandle']>\n try {\n frame = ring.reader.readHandle(handle)\n } catch (err) {\n // A mismatched shmId would throw — defensive only; `ringFor` keys the\n // reader by the handle's own shmId so this should never fire.\n this.logger?.warn('frame-ring reader: readHandle failed', {\n meta: { shmId: handle.shmId, error: errMsg(err) },\n })\n return null\n }\n if (!frame) return null\n return {\n data: frame.pixels,\n width: frame.meta.width,\n height: frame.meta.height,\n format: frame.meta.format,\n timestamp: frame.meta.pts,\n capturedAt: frame.meta.capturedAt,\n }\n }\n\n /** Close every cached segment. Idempotent. */\n close(): void {\n if (this.closed) return\n this.closed = true\n for (const [shmId, ring] of this.rings) {\n try {\n ring.segment.close()\n } catch (err) {\n this.logger?.warn('frame-ring reader: segment close failed', {\n meta: { shmId, error: errMsg(err) },\n })\n }\n }\n this.rings.clear()\n }\n\n // ── Internal ─────────────────────────────────────────────────────────\n\n /** Get the cached reader for a handle's segment, opening it on first use. */\n private ringFor(handle: FrameHandle): OpenRing | null {\n const cached = this.rings.get(handle.shmId)\n if (cached) return cached\n\n const slotByteLength = computeSlotByteLength(\n handle.width,\n handle.height,\n handle.format,\n )\n try {\n // The ring depth is per-resolution (Task 2): a small frame gets many\n // slots, a 4K frame few. The handle carries its own `slotCount` so the\n // reader sizes the segment view exactly — no hardcoded constant.\n const segment = openSegment(\n handle.shmId,\n computeSegmentSize(handle.slotCount, slotByteLength),\n )\n const reader = new FrameRingReader(\n segment.buffer,\n handle.shmId,\n handle.slotCount,\n slotByteLength,\n handle.nodeId,\n )\n const ring: OpenRing = { segment, reader }\n this.rings.set(handle.shmId, ring)\n return ring\n } catch (err) {\n this.logger?.warn('frame-ring reader: openSegment failed', {\n meta: { shmId: handle.shmId, error: errMsg(err) },\n })\n return null\n }\n }\n}\n"],"mappings":"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"}
package/dist/index.mjs CHANGED
@@ -84,6 +84,10 @@ var META_OFF_HEIGHT = 4;
84
84
  var META_OFF_FORMAT = 8;
85
85
  var META_OFF_BYTE_LENGTH = 12;
86
86
  var META_OFF_PTS = 16;
87
+ /** Wall-clock ms (Date.now) stamped at commit — uses the 8 reserved padding
88
+ * bytes at +24, so slot size is unchanged. Lets readers measure frame age
89
+ * (a PTS can't be diffed against Date.now). 0 when an older writer didn't set it. */
90
+ var META_OFF_CAPTURED_AT = 24;
87
91
  /**
88
92
  * Stable integer codes for `FrameFormat` — metadata stores the index, not the
89
93
  * string, so a slot's metadata is a fixed-width struct. Append-only: never
@@ -297,6 +301,7 @@ var FrameRingWriter = class extends FrameRingBase {
297
301
  this.view.setInt32(metaOffset + META_OFF_FORMAT, encodeFormat(meta.format), true);
298
302
  this.view.setInt32(metaOffset + META_OFF_BYTE_LENGTH, meta.byteLength, true);
299
303
  this.view.setFloat64(metaOffset + META_OFF_PTS, meta.pts, true);
304
+ this.view.setFloat64(metaOffset + META_OFF_CAPTURED_AT, meta.capturedAt ?? Date.now(), true);
300
305
  const committedSeq = Atomics.add(this.header, this.seqIndex(slot), 1) + 1;
301
306
  Atomics.add(this.header, HDR_WRITE_INDEX, 1);
302
307
  return {
@@ -450,6 +455,7 @@ var FrameRingReader = class extends FrameRingBase {
450
455
  const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true);
451
456
  const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true);
452
457
  const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true);
458
+ const capturedAt = this.view.getFloat64(metaOffset + META_OFF_CAPTURED_AT, true);
453
459
  if (byteLength < 0 || byteLength > this.geometry.slotByteLength) return null;
454
460
  const pixelOffset = this.pixelOffsetOf(slot);
455
461
  const pixels = this.scratchFor(byteLength);
@@ -468,7 +474,8 @@ var FrameRingReader = class extends FrameRingBase {
468
474
  height,
469
475
  format,
470
476
  pts,
471
- byteLength
477
+ byteLength,
478
+ capturedAt
472
479
  },
473
480
  handle: {
474
481
  shmId: this.shmId,
@@ -523,6 +530,7 @@ var FrameRingReader = class extends FrameRingBase {
523
530
  const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true);
524
531
  const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true);
525
532
  const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true);
533
+ const capturedAt = this.view.getFloat64(metaOffset + META_OFF_CAPTURED_AT, true);
526
534
  if (byteLength < 0 || byteLength > this.geometry.slotByteLength) return null;
527
535
  let format;
528
536
  try {
@@ -537,7 +545,8 @@ var FrameRingReader = class extends FrameRingBase {
537
545
  height,
538
546
  format,
539
547
  pts,
540
- byteLength
548
+ byteLength,
549
+ capturedAt
541
550
  };
542
551
  const handle = {
543
552
  shmId: this.shmId,
@@ -598,7 +607,8 @@ var FrameRingReaderCache = class {
598
607
  width: frame.meta.width,
599
608
  height: frame.meta.height,
600
609
  format: frame.meta.format,
601
- timestamp: frame.meta.pts
610
+ timestamp: frame.meta.pts,
611
+ capturedAt: frame.meta.capturedAt
602
612
  };
603
613
  }
604
614
  /** Close every cached segment. Idempotent. */
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/native.ts","../src/frame-ring.ts","../src/frame-ring-reader-cache.ts"],"sourcesContent":["/**\n * Typed wrapper over the `shm_ring` N-API addon.\n *\n * The addon maps/unmaps a named OS shared-memory segment and hands JS a\n * zero-copy `Buffer` over the mapping. The ring/seqlock logic lives in\n * `frame-ring.ts` (Task 2) — this module is purely the segment plumbing.\n */\nimport { createRequire } from 'node:module'\nimport { fileURLToPath } from 'node:url'\nimport { dirname } from 'node:path'\n\nconst require = createRequire(import.meta.url)\nconst here = dirname(fileURLToPath(import.meta.url))\n\n/** Opaque per-segment handle the native side uses to reach the mapping on close. */\nexport interface NativeSegmentHandle {\n readonly __shmHandle: unique symbol\n}\n\n/** Raw result handed back by the native `create` / `open` calls. */\ninterface NativeSegment {\n readonly buffer: Buffer\n readonly handle: NativeSegmentHandle\n}\n\n/** The native addon surface. */\ninterface ShmAddon {\n create(name: string, byteLength: number): NativeSegment\n open(name: string, byteLength: number): NativeSegment\n close(handle: NativeSegmentHandle): void\n unlink(name: string): void\n}\n\n/**\n * `node-gyp-build` resolves a prebuilt binary if one exists, otherwise the\n * locally compiled `build/Release/shm_ring.node`. Pointed at the package root\n * (one level above `dist/` / `src/`) so both layouts resolve.\n */\nfunction loadAddon(): ShmAddon {\n const nodeGypBuild: (root: string) => unknown = require('node-gyp-build')\n const packageRoot = dirname(here)\n const addon = nodeGypBuild(packageRoot)\n if (!isShmAddon(addon)) {\n throw new Error('@camstack/shm-ring: native addon did not expose the expected surface')\n }\n return addon\n}\n\nfunction hasFunction(value: object, key: string): boolean {\n return typeof Reflect.get(value, key) === 'function'\n}\n\nfunction isShmAddon(value: unknown): value is ShmAddon {\n if (typeof value !== 'object' || value === null) return false\n return (\n hasFunction(value, 'create') &&\n hasFunction(value, 'open') &&\n hasFunction(value, 'close') &&\n hasFunction(value, 'unlink')\n )\n}\n\nconst addon: ShmAddon = loadAddon()\n\n/** A mapped shared-memory segment with a zero-copy view and lifecycle controls. */\nexport interface ShmSegment {\n /** Zero-copy `Buffer` over the mapped region. Valid until `close()`. */\n readonly buffer: Buffer\n /** Unmap this process's view of the segment. Does not unlink the name. */\n close(): void\n /**\n * Remove the segment name from the OS namespace. The backing memory is\n * reclaimed once every process has also `close()`d its view. POSIX:\n * `shm_unlink`. Windows: a no-op — the OS reclaims on last-handle-close.\n */\n unlink(): void\n}\n\nfunction wrap(name: string, native: NativeSegment): ShmSegment {\n let closed = false\n let unlinked = false\n return {\n buffer: native.buffer,\n close(): void {\n if (closed) return\n closed = true\n addon.close(native.handle)\n },\n unlink(): void {\n // Idempotent: native `Unlink` throws `ENOENT` on a second `shm_unlink`\n // of the same name, so a double `unlink()` would throw. Guard it here —\n // mirrors `close()`'s `closed` flag.\n if (unlinked) return\n unlinked = true\n addon.unlink(name)\n },\n }\n}\n\n/**\n * Create a new named shared-memory segment of `byteLength` bytes and map it.\n * The segment is zero-filled. Fails if a segment with this name already exists.\n */\nexport function createSegment(name: string, byteLength: number): ShmSegment {\n return wrap(name, addon.create(name, byteLength))\n}\n\n/**\n * Map an existing named shared-memory segment. `byteLength` must match (or be\n * smaller than) the size the segment was created with. Throws if the segment\n * does not exist.\n */\nexport function openSegment(name: string, byteLength: number): ShmSegment {\n return wrap(name, addon.open(name, byteLength))\n}\n\n/**\n * Remove a segment name from the OS namespace without holding a mapping.\n * Convenience for cleanup paths that never mapped the segment themselves.\n */\nexport function unlinkSegment(name: string): void {\n addon.unlink(name)\n}\n","/**\n * `FrameRing` — a lock-free seqlock ring-buffer over a shared-memory segment.\n *\n * One writer (the decoder) publishes decoded frames into `slotCount` slots;\n * any number of readers (motion, detection, the WebRTC encoder, …) consume\n * them latest-wins. There are no JS-level locks: correctness rests entirely\n * on a per-slot **seqlock** sequence number manipulated with `Atomics`.\n *\n * The backing memory is a genuine `mmap` (see `native.ts`), so the `Atomics`\n * loads/stores have cross-process effect. The ring logic here is pure\n * TypeScript and treats the segment as an opaque `Buffer`.\n *\n * ## Segment layout (all offsets in bytes, little-endian)\n *\n * ```\n * ┌──────────────────────────────────────────────────────────────┐\n * │ HEADER — Int32Array view, (3 + slotCount) * 4 bytes │\n * │ [0] slotCount │\n * │ [1] slotByteLength (capacity of one pixel slot) │\n * │ [2] writeIndex (monotone publish counter) │\n * │ [3 .. 3+N) seq[slot] — per-slot seqlock counter │\n * ├──────────────────────────────────────────────────────────────┤\n * │ METADATA — slotCount * SLOT_META_BYTES (32) bytes, 8-aligned │\n * │ per slot: width (Int32 @ +0) │\n * │ height (Int32 @ +4) │\n * │ format (Int32 @ +8, FRAME_FORMAT_CODES index) │\n * │ byteLen (Int32 @ +12, valid pixel bytes) │\n * │ pts (Float64@ +16, presentation timestamp) │\n * │ (8 bytes padding @ +24 — keeps slots 8-aligned) │\n * ├──────────────────────────────────────────────────────────────┤\n * │ PIXELS — slotCount * slotByteLength bytes │\n * │ slot K pixel region @ pixelsOffset + K * slotByteLength │\n * └──────────────────────────────────────────────────────────────┘\n * ```\n *\n * ## Seqlock protocol\n *\n * `seq[slot]` starts even (committed / readable). A write does:\n * 1. `Atomics.add(seq, slot, 1)` → seq becomes **odd** (write in progress);\n * 2. copy pixels + metadata into the slot;\n * 3. `Atomics.add(seq, slot, 1)` → seq becomes **even** (committed);\n * 4. `Atomics.store(writeIndex)` → publish the new latest slot.\n *\n * A read does:\n * 1. `s1 = Atomics.load(seq, slot)`; if `s1` is odd → skip (write in flight);\n * 2. copy pixels + metadata out;\n * 3. `s2 = Atomics.load(seq, slot)`; if `s1 !== s2` → skip (recycled / torn).\n *\n * An odd `s1` means a write is mid-flight; an `s1 !== s2` means the slot was\n * overwritten between the two loads — in both cases the reader drops the\n * frame rather than returning torn pixels.\n */\nimport type { FrameFormat, FrameHandle } from '@camstack/types'\n\n/** Header slot indices in the `Int32Array` header view. */\nconst HDR_SLOT_COUNT = 0\nconst HDR_SLOT_BYTE_LENGTH = 1\nconst HDR_WRITE_INDEX = 2\n/** Fixed header fields before the per-slot `seq[]` array begins. */\nconst HDR_FIXED_FIELDS = 3\n\n/** Bytes of metadata reserved per slot (24 used + 8 padding → 8-byte aligned). */\nconst SLOT_META_BYTES = 32\nconst META_OFF_WIDTH = 0\nconst META_OFF_HEIGHT = 4\nconst META_OFF_FORMAT = 8\nconst META_OFF_BYTE_LENGTH = 12\nconst META_OFF_PTS = 16\n\n/**\n * Stable integer codes for `FrameFormat` — metadata stores the index, not the\n * string, so a slot's metadata is a fixed-width struct. Append-only: never\n * reorder or remove an entry, or existing segments would mis-decode.\n */\nconst FRAME_FORMAT_CODES: readonly FrameFormat[] = [\n 'jpeg',\n 'rgb',\n 'bgr',\n 'yuv420',\n 'gray',\n]\n\nfunction encodeFormat(format: FrameFormat): number {\n const code = FRAME_FORMAT_CODES.indexOf(format)\n if (code < 0) {\n throw new Error(`FrameRing: unknown frame format \"${format}\"`)\n }\n return code\n}\n\nfunction decodeFormat(code: number): FrameFormat {\n const format = FRAME_FORMAT_CODES[code]\n if (format === undefined) {\n throw new Error(`FrameRing: unknown frame format code ${code}`)\n }\n return format\n}\n\n/** Round `value` up to the next multiple of `align` (a power of two). */\nfunction alignUp(value: number, align: number): number {\n return Math.ceil(value / align) * align\n}\n\n/** Per-frame metadata published alongside the pixels of a slot. */\nexport interface FrameMeta {\n readonly width: number\n readonly height: number\n readonly format: FrameFormat\n readonly pts: number\n /** Valid pixel bytes — must be `<= slotByteLength`. */\n readonly byteLength: number\n}\n\n/**\n * A scatter-write slot reservation returned by {@link FrameRingWriter.beginFrame}.\n *\n * ## Scatter-write seqlock contract (Phase 5 / D9 Task 7c — READ THIS)\n *\n * `beginFrame()` has already bumped this slot's `seq` to **odd** — a write is in\n * progress, so any reader hitting this slot skips it (returns `null` / a failed\n * `validate()`). The caller now owns an exclusive write window over `buffer`:\n *\n * 1. `buffer` is a writable `subarray` **directly over the slot's pixel\n * region in the mapped segment** — there is no intermediate copy. A\n * producer (e.g. the node-av scaler) fills it in place.\n * 2. The slot stays odd for the **entire fill duration** — not just a memcpy.\n * A scatter producer that takes longer to fill the slot (a whole scale\n * pass) holds the slot odd for that whole pass; this is correct, readers\n * simply latest-wins-drop that slot until {@link FrameRingWriter.commitFrame}.\n * 3. The caller MUST call {@link FrameRingWriter.commitFrame} with this exact\n * `slot` once the fill is done — that writes the metadata, bumps `seq` back\n * to **even** (committed) and advances `writeIndex` so readers can see it.\n *\n * Exactly one `beginFrame` may be open at a time per writer (there is a single\n * writer per ring). `buffer.byteLength` is the slot's full capacity\n * (`slotByteLength`); the caller writes at most `commitFrame`'s `meta.byteLength`\n * valid bytes into it.\n */\nexport interface FrameSlotWrite {\n /** The ring slot index this write targets — pass it back to `commitFrame`. */\n readonly slot: number\n /**\n * A writable view **directly over the slot's pixel region** in the shared\n * mapping. Fill it in place, then `commitFrame(slot, meta)`. Valid only\n * between the `beginFrame` that produced it and its matching `commitFrame`.\n */\n readonly buffer: Buffer\n}\n\n/**\n * The result of a successful seqlock read.\n *\n * ## Buffer-lifetime contract (Phase 5 / D9 Task 7b — READ THIS)\n *\n * `pixels` is a view onto the **reader's reusable scratch buffer**, NOT a\n * freshly allocated copy. The seqlock-validated pixel bytes are memcpy'd into\n * that scratch buffer (the copy is the seqlock safety copy — the bytes survive\n * a post-read slot recycle), but the buffer itself is reused on this reader's\n * **next** `readHandle` / `readLatest` call.\n *\n * Therefore `pixels` is **valid only until this same reader's next read**:\n *\n * - A consumer that processes the frame **synchronously**, fully, before it\n * issues another read on this reader may use `pixels` directly (borrow).\n * - A consumer that **retains** the frame past its next read — queues it for\n * async work, stores it, hands it to another tick — MUST `Buffer.from(...)`\n * it into its own storage at the point of retention. Holding the borrowed\n * `pixels` past the next read is silent corruption: the next read overwrites\n * the bytes in place.\n *\n * `meta` and `handle` are plain immutable values — safe to retain freely.\n */\nexport interface FrameRead {\n /**\n * The slot's pixel bytes copied into the reader's reusable scratch buffer.\n *\n * **Borrowed, not owned** — valid only until this reader's next\n * `readHandle` / `readLatest`. Retain past that point ⇒ copy first\n * (`Buffer.from(pixels)`). See the `FrameRead` doc comment.\n */\n readonly pixels: Buffer\n readonly meta: FrameMeta\n /** A serialisable handle for this exact frame. */\n readonly handle: FrameHandle\n}\n\n/**\n * A **zero-copy** read: `pixels` is a `subarray` directly over the shared\n * mapping — no memcpy at all (Phase 5 / D9 Task 7b Step 3).\n *\n * ## Validation contract (READ THIS — concurrency-sensitive)\n *\n * Because `pixels` aliases the live ring slot, a concurrent writer can recycle\n * that slot at any moment, tearing the bytes a consumer is reading. A\n * `FrameView` is therefore an **optimistic** read: the seqlock's *opening*\n * check already passed (the slot was committed and, for a handle read, the seq\n * matched), but the closing re-check is deferred to the consumer.\n *\n * A consumer MUST:\n * 1. read the slot **synchronously and fast** — no `await`, no yielding;\n * 2. call `validate()` **immediately after** finishing with `pixels`;\n * 3. if `validate()` returns `false`, **discard** whatever it computed from\n * `pixels` — the bytes were (or may have been) overwritten mid-read, i.e.\n * a torn frame, the latest-wins drop.\n *\n * `validate()` returning `true` means the slot's `seq` is unchanged since the\n * read opened — the bytes the consumer just processed were a single committed\n * frame, not torn. This is the same seqlock guarantee `readHandle` gives, but\n * the closing half is the consumer's responsibility instead of being baked\n * into a memcpy + recheck. Use this ONLY for tight synchronous consumers\n * (e.g. motion's frame scan, the perf bench's compute stage); any consumer\n * that retains or processes asynchronously MUST use the copying `readHandle` /\n * `readLatest` instead.\n */\nexport interface FrameView {\n /**\n * A `subarray` view directly over the shared-memory slot — **zero-copy**.\n * Valid for a synchronous read only; `validate()` confirms it was not torn.\n */\n readonly pixels: Buffer\n readonly meta: FrameMeta\n readonly handle: FrameHandle\n /**\n * Re-check the slot's seqlock. `true` ⇒ the slot was not recycled while the\n * consumer read `pixels` — the frame is intact. `false` ⇒ a torn read,\n * discard everything derived from `pixels`. Call once, right after the\n * synchronous read of `pixels` completes.\n */\n validate(): boolean\n}\n\n/** Internal geometry of a sized segment. */\ninterface RingGeometry {\n readonly slotCount: number\n readonly slotByteLength: number\n /** Byte length of the `Int32Array` header. */\n readonly headerBytes: number\n /** Byte offset of the metadata region. */\n readonly metaOffset: number\n /** Byte offset of the pixel region. */\n readonly pixelsOffset: number\n /** Total segment byte length. */\n readonly totalBytes: number\n}\n\n/** Compute the geometry of a ring with the given slot count and slot size. */\nfunction computeGeometry(slotCount: number, slotByteLength: number): RingGeometry {\n if (!Number.isInteger(slotCount) || slotCount <= 0) {\n throw new Error(`FrameRing: slotCount must be a positive integer, got ${slotCount}`)\n }\n if (!Number.isInteger(slotByteLength) || slotByteLength <= 0) {\n throw new Error(\n `FrameRing: slotByteLength must be a positive integer, got ${slotByteLength}`,\n )\n }\n const headerBytes = alignUp((HDR_FIXED_FIELDS + slotCount) * 4, 8)\n const metaOffset = headerBytes\n const metaBytes = slotCount * SLOT_META_BYTES\n const pixelsOffset = alignUp(metaOffset + metaBytes, 8)\n const totalBytes = pixelsOffset + slotCount * slotByteLength\n return {\n slotCount,\n slotByteLength,\n headerBytes,\n metaOffset,\n pixelsOffset,\n totalBytes,\n }\n}\n\n/**\n * Total byte length a segment must have to hold a `slotCount`-slot ring with\n * `slotByteLength`-byte pixel slots. The decoder uses this to size the\n * shared-memory segment before constructing a `FrameRingWriter` over it.\n */\nexport function computeSegmentSize(slotCount: number, slotByteLength: number): number {\n return computeGeometry(slotCount, slotByteLength).totalBytes\n}\n\n/**\n * Slot-count bounds for a per-resolution ring. {@link deriveSlotCount} clamps\n * the budget-derived count into `[MIN_RING_SLOTS, MAX_RING_SLOTS]`:\n * - `MIN` keeps a tiny floor even when one frame nearly exhausts the budget\n * (a 4K RGB frame is ~24.9 MB — a 128 MB budget yields only ~5 raw slots);\n * - `MAX` caps the ring for small frames so a 360p stream does not allocate\n * hundreds of slots' worth of shared memory it will never use latest-wins.\n */\nexport const MIN_RING_SLOTS = 6\nexport const MAX_RING_SLOTS = 64\n\n/**\n * Slot count for a ring, derived from a per-ring shared-memory budget.\n *\n * `budgetBytes` is the shared memory a single stream's ring may consume; the\n * raw count is `floor(budgetBytes / slotByteLength)`, clamped into\n * `[MIN_RING_SLOTS, MAX_RING_SLOTS]`. Live video is latest-wins, so the slot\n * count only needs to absorb the writer-commit / reader-read window — the\n * budget trades shared-memory footprint against that window per resolution.\n */\nexport function deriveSlotCount(budgetBytes: number, slotByteLength: number): number {\n const raw = Math.floor(budgetBytes / slotByteLength)\n return Math.min(MAX_RING_SLOTS, Math.max(MIN_RING_SLOTS, raw))\n}\n\n/**\n * Bytes per pixel for each packed decode format a frame ring slot can hold.\n *\n * This is the single source of truth shared by the decoder write side\n * (`DecoderFrameRingSink`) and every consumer read side\n * (`FrameRingReaderCache`): if the two computed different values, a reader\n * would mis-map the shared-memory segment and read corrupt pixels.\n *\n * `jpeg` has no fixed bytes-per-pixel — `computeSlotByteLength` short-circuits\n * it to the equivalent RGB24 raster before this is ever consulted, so the `3`\n * here is only a safe placeholder for exhaustiveness over `FrameFormat`.\n */\nexport function bytesPerPixel(format: FrameFormat): number {\n switch (format) {\n case 'gray':\n return 1\n case 'rgb':\n case 'bgr':\n return 3\n case 'yuv420':\n // 4:2:0 planar — 1 byte luma + ¼+¼ chroma per pixel = 1.5 bytes/px.\n // Rounded up to 2 so the slot is never undersized for a planar frame.\n return 2\n case 'jpeg':\n // Variable-length; see the doc comment above. `computeSlotByteLength`\n // sizes a jpeg slot from the RGB24 raster, never via this branch.\n return 3\n }\n}\n\n/**\n * Per-slot byte capacity for a frame of the given geometry — the single source\n * of truth for both the decoder write side and the consumer read side.\n *\n * For raw formats this is exactly `width × height × bpp`. For `jpeg` (variable\n * length) the slot is sized to the equivalent RGB24 raster — a JPEG is always\n * far smaller than its source raster, so an RGB24-sized slot is a safe upper\n * bound. Note: this upper bound holds only while the JPEG encoder is pinned to\n * its current quality (~80). A near-lossless quality setting can produce a\n * JPEG that exceeds the RGB24 byte count; if quality is raised significantly,\n * this slot size must be revisited.\n */\nexport function computeSlotByteLength(\n width: number,\n height: number,\n format: FrameFormat,\n): number {\n if (format === 'jpeg') {\n return width * height * 3\n }\n return width * height * bytesPerPixel(format)\n}\n\n/**\n * Shared geometry + typed views over a segment buffer. The header is an\n * `Int32Array` so `Atomics` ops apply directly; metadata and pixels are read\n * and written through `DataView` / `Buffer` slices.\n */\nabstract class FrameRingBase {\n protected readonly geometry: RingGeometry\n /** `Int32Array` over the header — slotCount, slotByteLength, writeIndex, seq[]. */\n protected readonly header: Int32Array\n protected readonly view: DataView\n\n protected constructor(\n protected readonly segment: Buffer,\n protected readonly shmId: string,\n slotCount: number,\n slotByteLength: number,\n ) {\n this.geometry = computeGeometry(slotCount, slotByteLength)\n if (segment.byteLength < this.geometry.totalBytes) {\n throw new Error(\n `FrameRing: segment is ${segment.byteLength} bytes, ` +\n `need ${this.geometry.totalBytes} for ${slotCount}×${slotByteLength}`,\n )\n }\n const headerInts = HDR_FIXED_FIELDS + slotCount\n this.header = new Int32Array(\n segment.buffer,\n segment.byteOffset,\n headerInts,\n )\n this.view = new DataView(segment.buffer, segment.byteOffset, segment.byteLength)\n }\n\n /** Header index of `seq[slot]`. */\n protected seqIndex(slot: number): number {\n return HDR_FIXED_FIELDS + slot\n }\n\n /** Byte offset of slot `slot`'s metadata struct. */\n protected metaOffsetOf(slot: number): number {\n return this.geometry.metaOffset + slot * SLOT_META_BYTES\n }\n\n /** Byte offset of slot `slot`'s pixel region. */\n protected pixelOffsetOf(slot: number): number {\n return this.geometry.pixelsOffset + slot * this.geometry.slotByteLength\n }\n}\n\n/**\n * The single writer for a ring. Constructed by whoever owns the segment (the\n * decoder). On construction it stamps `slotCount` / `slotByteLength` into the\n * header so a reader opening the same segment can self-describe.\n */\nexport class FrameRingWriter extends FrameRingBase {\n /** Slot index of an in-flight `beginWrite()`, or `null` when idle. */\n private pendingSlot: number | null = null\n\n /** Cluster node id stamped into every `FrameHandle` this writer produces. */\n private readonly nodeId: string\n\n constructor(\n segment: Buffer,\n shmId: string,\n slotCount: number,\n slotByteLength: number,\n nodeId: string,\n ) {\n super(segment, shmId, slotCount, slotByteLength)\n this.nodeId = nodeId\n // Stamp the self-describing header. `writeIndex` starts at 0; before the\n // first commit there is no readable frame (seq[0] is even == 0).\n Atomics.store(this.header, HDR_SLOT_COUNT, slotCount)\n Atomics.store(this.header, HDR_SLOT_BYTE_LENGTH, slotByteLength)\n }\n\n /** The number of slots in this ring — derived per-resolution from the budget. */\n get slotCount(): number {\n return this.geometry.slotCount\n }\n\n /** The slot the next `writeFrame` / `beginFrame` will target. */\n private targetSlot(): number {\n const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX)\n return writeIndex % this.geometry.slotCount\n }\n\n /**\n * Open the seqlock on the next slot and hand the caller a writable view\n * directly over that slot's pixel region — the **scatter-write** entry point.\n *\n * The seqlock contract (see {@link FrameSlotWrite}): this bumps the slot's\n * `seq` to **odd** (write in progress), so a concurrent reader skips the slot\n * for the entire fill window. The caller fills `buffer` in place — there is\n * NO intermediate copy: a producer (the node-av scaler) writes its packed\n * output straight into the mapped segment — then MUST call\n * {@link commitFrame} with the returned `slot` to publish the frame.\n *\n * Exactly one `beginFrame` may be open per writer at a time.\n */\n beginFrame(): FrameSlotWrite {\n if (this.pendingSlot !== null) {\n throw new Error('FrameRingWriter: a frame write is already in progress')\n }\n const slot = this.targetSlot()\n // seq → odd: a write is now in progress on this slot.\n Atomics.add(this.header, this.seqIndex(slot), 1)\n this.pendingSlot = slot\n const pixelOffset = this.pixelOffsetOf(slot)\n const buffer = this.segment.subarray(\n pixelOffset,\n pixelOffset + this.geometry.slotByteLength,\n )\n return { slot, buffer }\n }\n\n /**\n * Close the seqlock opened by {@link beginFrame}: write the metadata, bump\n * `seq` back to **even** (committed) and advance `writeIndex` so readers see\n * this slot as the latest. Returns the published frame's `FrameHandle`.\n *\n * `slot` must be the value from the matching `beginFrame`'s\n * {@link FrameSlotWrite}. The pixels are assumed already filled in place by\n * the caller (the scatter-write contract) — `commitFrame` copies nothing.\n */\n commitFrame(slot: number, meta: FrameMeta): FrameHandle {\n if (this.pendingSlot === null) {\n throw new Error('FrameRingWriter: commitFrame called without beginFrame')\n }\n if (slot !== this.pendingSlot) {\n throw new Error(\n `FrameRingWriter: commitFrame slot ${slot} does not match the ` +\n `in-progress beginFrame slot ${this.pendingSlot}`,\n )\n }\n const { slotByteLength } = this.geometry\n if (meta.byteLength > slotByteLength) {\n throw new Error(\n `FrameRingWriter: frame is ${meta.byteLength} bytes, ` +\n `slot capacity is ${slotByteLength}`,\n )\n }\n this.pendingSlot = null\n\n // --- write metadata (inside the still-open odd-seq window) ---\n const metaOffset = this.metaOffsetOf(slot)\n this.view.setInt32(metaOffset + META_OFF_WIDTH, meta.width, true)\n this.view.setInt32(metaOffset + META_OFF_HEIGHT, meta.height, true)\n this.view.setInt32(metaOffset + META_OFF_FORMAT, encodeFormat(meta.format), true)\n this.view.setInt32(metaOffset + META_OFF_BYTE_LENGTH, meta.byteLength, true)\n this.view.setFloat64(metaOffset + META_OFF_PTS, meta.pts, true)\n\n // --- close the seqlock: seq → even (committed) ---\n // `Atomics.add` returns the previous (odd) value; +1 gives the new even seq.\n const committedSeq = Atomics.add(this.header, this.seqIndex(slot), 1) + 1\n\n // --- publish: advance writeIndex so readers see this as the latest ---\n Atomics.add(this.header, HDR_WRITE_INDEX, 1)\n\n return {\n shmId: this.shmId,\n slot,\n seq: committedSeq,\n width: meta.width,\n height: meta.height,\n format: meta.format,\n pts: meta.pts,\n byteLength: meta.byteLength,\n nodeId: this.nodeId,\n slotCount: this.geometry.slotCount,\n }\n }\n\n /**\n * Abandon the seqlock opened by {@link beginFrame} **without publishing the\n * slot** — the degenerate-path counterpart of {@link commitFrame}.\n *\n * `beginFrame` has bumped the slot's `seq` to **odd**. A caller that opened a\n * slot but then could not fill it with valid pixels (the producer failed, or\n * there were no source planes) MUST NOT `commitFrame` — that would close the\n * seqlock over uninitialised / stale shared-memory bytes and advance\n * `writeIndex`, so a reader would see those garbage bytes as a real, latest\n * frame. `abortFrame` instead:\n *\n * 1. bumps `seq` from odd back to **even** — the slot is not left stuck\n * odd, so the writer can reuse it on a later cycle;\n * 2. does **NOT** advance `writeIndex` — `readLatest` therefore never\n * surfaces this slot as the latest (the prior latest stays latest);\n * 3. returns no `FrameHandle` — nothing is handed downstream.\n *\n * The slot's pixel bytes after `abortFrame` are **undefined** (whatever was\n * there before, or a half-written producer output) — but that is harmless\n * because no consumer ever sees the slot as committed: `readLatest` skips it\n * (writeIndex did not move) and any handle that happened to point at this\n * slot from an earlier commit sees the changed `seq` and is correctly\n * rejected by `readHandle`'s seq check.\n *\n * `slot` must be the value from the matching `beginFrame` — validated\n * exactly as {@link commitFrame} does.\n */\n abortFrame(slot: number): void {\n if (this.pendingSlot === null) {\n throw new Error('FrameRingWriter: abortFrame called without beginFrame')\n }\n if (slot !== this.pendingSlot) {\n throw new Error(\n `FrameRingWriter: abortFrame slot ${slot} does not match the ` +\n `in-progress beginFrame slot ${this.pendingSlot}`,\n )\n }\n this.pendingSlot = null\n // Close the seqlock: seq odd → even. The slot is committed-shape again so\n // it can be reused, but writeIndex is NOT advanced — the slot is never the\n // latest, and its pixel content is intentionally undefined.\n Atomics.add(this.header, this.seqIndex(slot), 1)\n }\n\n /**\n * Publish one frame from a caller-provided pixel `Buffer` — the convenience\n * wrapper over the scatter-write {@link beginFrame} / {@link commitFrame}\n * pair: it opens the slot, copies `pixels` into it, then commits.\n *\n * Prefer the `beginFrame` / `commitFrame` pair when the pixels can be\n * produced directly into the slot (the node-av scaler scattering its packed\n * output into the mapped segment) — that path has zero write-side copy.\n * `writeFrame` keeps the single-call form for callers that already hold a\n * detached pixel `Buffer`.\n */\n writeFrame(pixels: Buffer, meta: FrameMeta): FrameHandle {\n if (pixels.byteLength < meta.byteLength) {\n throw new Error(\n `FrameRingWriter: pixels buffer (${pixels.byteLength} bytes) ` +\n `shorter than meta.byteLength (${meta.byteLength})`,\n )\n }\n const { slot, buffer } = this.beginFrame()\n // Copy the pixels into the slot, inside the open odd-seq window.\n buffer.set(pixels.subarray(0, meta.byteLength))\n return this.commitFrame(slot, meta)\n }\n}\n\n/**\n * A reader for a ring. Many readers may share a segment concurrently with the\n * single writer. Every read is a seqlock read — a torn or recycled slot is\n * dropped (returns `null`), never returned as garbage pixels.\n */\nexport class FrameRingReader extends FrameRingBase {\n /**\n * The reusable per-reader scratch buffer the seqlock-validated pixels are\n * copied into — see the `FrameRead` doc comment for the borrow contract.\n *\n * Sized to `slotByteLength` on construction (the segment's stamped slot\n * capacity) and lazily grown if a slot ever reports a larger `byteLength`.\n * Reusing one buffer per reader replaces the per-read `Buffer.allocUnsafe` —\n * the D9 perf gate's regression (Task 7b): a fresh ~5.93 MB allocation per\n * 1080p frame churned ~1.39 GB/s and stalled on GC. The single memcpy stays\n * (it IS the seqlock safety copy); only the allocation is removed.\n */\n private scratch: Buffer\n\n /**\n * Cluster node id of the node that OWNS this segment (i.e. the writer's\n * node — the node whose shared memory physically holds the ring), stamped\n * into every `FrameHandle` this reader produces. `FrameHandle.nodeId` always\n * identifies the segment-owning node, never the reading node, so callers\n * pass the writer's node id here (e.g. `FrameRingReaderCache` passes\n * `handle.nodeId` straight through).\n */\n private readonly nodeId: string\n\n constructor(\n segment: Buffer,\n shmId: string,\n slotCount: number,\n slotByteLength: number,\n /**\n * Node id of the segment-owning (writer's) node — see the `nodeId` field\n * doc. NOT this reader's own node.\n */\n nodeId: string,\n ) {\n super(segment, shmId, slotCount, slotByteLength)\n this.nodeId = nodeId\n this.scratch = Buffer.allocUnsafe(slotByteLength)\n }\n\n /**\n * Return the reader's scratch buffer as a view of exactly `byteLength` bytes,\n * growing the backing buffer first if a slot ever exceeds the stamped slot\n * capacity (defensive — `slotByteLength` should always bound `byteLength`).\n */\n private scratchFor(byteLength: number): Buffer {\n if (byteLength > this.scratch.byteLength) {\n this.scratch = Buffer.allocUnsafe(byteLength)\n }\n return this.scratch.subarray(0, byteLength)\n }\n\n /**\n * Read the latest committed frame, latest-wins. Returns `null` when the ring\n * is empty (no frame ever published) or the latest slot is mid-write /\n * recycled (a slow reader simply drops that frame and retries next tick).\n *\n * The returned `FrameRead.pixels` is **borrowed** from this reader's reusable\n * scratch buffer — valid only until this reader's next read. A consumer that\n * retains it past the next read MUST copy. See the `FrameRead` doc comment.\n */\n readLatest(): FrameRead | null {\n const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX)\n if (writeIndex === 0) {\n return null\n }\n // The most recently published slot is writeIndex-1 modulo slotCount.\n const slot = (writeIndex - 1) % this.geometry.slotCount\n return this.seqlockRead(slot, null)\n }\n\n /**\n * Read the exact frame a `FrameHandle` refers to. Returns `null` when the\n * slot has been recycled (its committed `seq` no longer matches `handle.seq`)\n * or is mid-write — i.e. the frame is gone, not torn.\n *\n * The returned `FrameRead.pixels` is **borrowed** from this reader's reusable\n * scratch buffer — valid only until this reader's next read. A consumer that\n * retains it past the next read MUST copy. See the `FrameRead` doc comment.\n */\n readHandle(handle: FrameHandle): FrameRead | null {\n if (handle.shmId !== this.shmId) {\n throw new Error(\n `FrameRingReader: handle is for segment \"${handle.shmId}\", ` +\n `this reader is on \"${this.shmId}\"`,\n )\n }\n if (handle.slot < 0 || handle.slot >= this.geometry.slotCount) {\n return null\n }\n return this.seqlockRead(handle.slot, handle.seq)\n }\n\n /**\n * The seqlock read of a single slot.\n *\n * `expectedSeq` is the committed seq a `readHandle` caller demands; pass\n * `null` for `readLatest`, which accepts whatever even seq it finds.\n */\n private seqlockRead(slot: number, expectedSeq: number | null): FrameRead | null {\n const seqIdx = this.seqIndex(slot)\n\n // (1) read the sequence; an odd value means a write is in progress.\n const s1 = Atomics.load(this.header, seqIdx)\n if ((s1 & 1) !== 0) {\n return null\n }\n if (expectedSeq !== null && s1 !== expectedSeq) {\n // The slot has moved on (recycled) — the requested frame is gone.\n return null\n }\n\n // (2) read metadata + pixels into a private copy.\n const metaOffset = this.metaOffsetOf(slot)\n const width = this.view.getInt32(metaOffset + META_OFF_WIDTH, true)\n const height = this.view.getInt32(metaOffset + META_OFF_HEIGHT, true)\n const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true)\n const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true)\n const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true)\n\n if (byteLength < 0 || byteLength > this.geometry.slotByteLength) {\n // Metadata is being rewritten — treat as a torn read, skip.\n return null\n }\n const pixelOffset = this.pixelOffsetOf(slot)\n // Copy the pixels out into this reader's REUSABLE scratch buffer (not a\n // fresh allocation — see `scratch`). The copy is still the seqlock safety\n // copy: the bytes must survive a post-read slot recycle, and the recheck\n // below validates they were not torn mid-copy. The returned buffer is\n // borrowed — valid only until this reader's next read (see `FrameRead`).\n const pixels = this.scratchFor(byteLength)\n this.segment.copy(pixels, 0, pixelOffset, pixelOffset + byteLength)\n\n // (3) re-read the sequence; any change means the slot was overwritten\n // between (1) and (2) — the copy is torn, drop it.\n const s2 = Atomics.load(this.header, seqIdx)\n if (s1 !== s2) {\n return null\n }\n\n let format: FrameFormat\n try {\n format = decodeFormat(formatCode)\n } catch {\n // A torn metadata read can yield an out-of-range code; drop the frame.\n return null\n }\n\n const meta: FrameMeta = { width, height, format, pts, byteLength }\n const handle: FrameHandle = {\n shmId: this.shmId,\n slot,\n seq: s1,\n width,\n height,\n format,\n pts,\n byteLength,\n nodeId: this.nodeId,\n slotCount: this.geometry.slotCount,\n }\n return { pixels, meta, handle }\n }\n\n /**\n * Read the latest committed frame **zero-copy** — `FrameView.pixels` is a\n * `subarray` over the shared mapping, no memcpy. The caller MUST process it\n * synchronously and then call `validate()`; see the `FrameView` contract.\n * Returns `null` when the ring is empty or the latest slot is mid-write.\n */\n readLatestView(): FrameView | null {\n const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX)\n if (writeIndex === 0) {\n return null\n }\n const slot = (writeIndex - 1) % this.geometry.slotCount\n return this.seqlockReadView(slot, null)\n }\n\n /**\n * Read the frame a `FrameHandle` refers to **zero-copy** — `FrameView.pixels`\n * is a `subarray` over the shared mapping, no memcpy. The caller MUST process\n * it synchronously and then call `validate()`; see the `FrameView` contract.\n * Returns `null` when the slot is recycled (seq mismatch) or mid-write.\n */\n readHandleView(handle: FrameHandle): FrameView | null {\n if (handle.shmId !== this.shmId) {\n throw new Error(\n `FrameRingReader: handle is for segment \"${handle.shmId}\", ` +\n `this reader is on \"${this.shmId}\"`,\n )\n }\n if (handle.slot < 0 || handle.slot >= this.geometry.slotCount) {\n return null\n }\n return this.seqlockReadView(handle.slot, handle.seq)\n }\n\n /**\n * The zero-copy half of the seqlock read: validate the opening seqlock, then\n * return a `subarray` over the slot plus a `validate()` closure that re-reads\n * the seqlock. No pixel copy — the consumer owns the closing recheck.\n */\n private seqlockReadView(\n slot: number,\n expectedSeq: number | null,\n ): FrameView | null {\n const seqIdx = this.seqIndex(slot)\n\n // (1) opening seqlock check — odd ⇒ write in progress; mismatch ⇒ recycled.\n const s1 = Atomics.load(this.header, seqIdx)\n if ((s1 & 1) !== 0) {\n return null\n }\n if (expectedSeq !== null && s1 !== expectedSeq) {\n return null\n }\n\n // (2) read metadata — a torn metadata read is caught by the byteLength\n // bound and the closing `validate()` the consumer must call.\n const metaOffset = this.metaOffsetOf(slot)\n const width = this.view.getInt32(metaOffset + META_OFF_WIDTH, true)\n const height = this.view.getInt32(metaOffset + META_OFF_HEIGHT, true)\n const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true)\n const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true)\n const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true)\n\n if (byteLength < 0 || byteLength > this.geometry.slotByteLength) {\n return null\n }\n\n let format: FrameFormat\n try {\n format = decodeFormat(formatCode)\n } catch {\n return null\n }\n\n // (3) zero-copy view directly over the mapped slot — NO memcpy.\n const pixelOffset = this.pixelOffsetOf(slot)\n const pixels = this.segment.subarray(pixelOffset, pixelOffset + byteLength)\n\n const meta: FrameMeta = { width, height, format, pts, byteLength }\n const handle: FrameHandle = {\n shmId: this.shmId,\n slot,\n seq: s1,\n width,\n height,\n format,\n pts,\n byteLength,\n nodeId: this.nodeId,\n slotCount: this.geometry.slotCount,\n }\n // `validate()` is the closing seqlock half — the consumer calls it after a\n // synchronous read of `pixels` to confirm the slot was not recycled.\n const validate = (): boolean => Atomics.load(this.header, seqIdx) === s1\n return { pixels, meta, handle, validate }\n }\n}\n","/**\n * `FrameRingReaderCache` — the consumer side of the shared-memory frame plane\n * (Phase 5 / D9).\n *\n * A frame consumer (motion, detection, post-analysis) drains zero-pixel\n * `FrameHandle`s from the broker via `pullFrameHandles` and must read the\n * actual pixels back from the shared-memory ring the decoder wrote them into.\n * Each `FrameHandle` names its segment (`shmId`) and carries its ring depth\n * (`slotCount`) — the cache opens each segment **once** (a syscall) and reuses\n * the `FrameRingReader` for every later handle on the same ring.\n *\n * The decoder re-creates its segment under a fresh generation-tagged name on a\n * resolution change (`makeSegmentName`), so a stale `shmId` simply stops\n * appearing in new handles — its reader is closed lazily by `close()` at\n * teardown. (Resolution changes on a live camera are rare; carrying a few idle\n * mappings until teardown is cheaper than reference-counting handles.)\n *\n * A `null` from `readHandle` means the ring slot was recycled before the\n * consumer read it — a dropped frame, which is the correct latest-wins\n * behaviour for live video. The caller skips that frame.\n *\n * This is a pure consumer of the ring API and lives in `@camstack/shm-ring` so\n * any addon can read frames without importing from a sibling addon.\n */\nimport type { DecodedFrame, FrameHandle, IScopedLogger } from '@camstack/types'\nimport { errMsg } from '@camstack/types'\n\nimport {\n FrameRingReader,\n computeSegmentSize,\n computeSlotByteLength,\n} from './frame-ring.js'\nimport { openSegment } from './native.js'\nimport type { ShmSegment } from './native.js'\n\n/** One opened shm segment plus the reader mapped over it. */\ninterface OpenRing {\n readonly segment: ShmSegment\n readonly reader: FrameRingReader\n}\n\n/**\n * Opens (and caches) a `FrameRingReader` per `shmId` and reads the pixels a\n * `FrameHandle` refers to. Single-consumer — one cache per frame subscription.\n */\nexport class FrameRingReaderCache {\n private readonly rings = new Map<string, OpenRing>()\n private readonly logger: IScopedLogger | undefined\n private closed = false\n\n constructor(logger?: IScopedLogger) {\n this.logger = logger\n }\n\n /**\n * Read the pixels a `FrameHandle` refers to and return them as a\n * `DecodedFrame`. Returns `null` when the ring slot was recycled before the\n * read (a dropped frame) or the segment could not be opened.\n */\n read(handle: FrameHandle): DecodedFrame | null {\n if (this.closed) return null\n const ring = this.ringFor(handle)\n if (!ring) return null\n let frame: ReturnType<FrameRingReader['readHandle']>\n try {\n frame = ring.reader.readHandle(handle)\n } catch (err) {\n // A mismatched shmId would throw — defensive only; `ringFor` keys the\n // reader by the handle's own shmId so this should never fire.\n this.logger?.warn('frame-ring reader: readHandle failed', {\n meta: { shmId: handle.shmId, error: errMsg(err) },\n })\n return null\n }\n if (!frame) return null\n return {\n data: frame.pixels,\n width: frame.meta.width,\n height: frame.meta.height,\n format: frame.meta.format,\n timestamp: frame.meta.pts,\n }\n }\n\n /** Close every cached segment. Idempotent. */\n close(): void {\n if (this.closed) return\n this.closed = true\n for (const [shmId, ring] of this.rings) {\n try {\n ring.segment.close()\n } catch (err) {\n this.logger?.warn('frame-ring reader: segment close failed', {\n meta: { shmId, error: errMsg(err) },\n })\n }\n }\n this.rings.clear()\n }\n\n // ── Internal ─────────────────────────────────────────────────────────\n\n /** Get the cached reader for a handle's segment, opening it on first use. */\n private ringFor(handle: FrameHandle): OpenRing | null {\n const cached = this.rings.get(handle.shmId)\n if (cached) return cached\n\n const slotByteLength = computeSlotByteLength(\n handle.width,\n handle.height,\n handle.format,\n )\n try {\n // The ring depth is per-resolution (Task 2): a small frame gets many\n // slots, a 4K frame few. The handle carries its own `slotCount` so the\n // reader sizes the segment view exactly — no hardcoded constant.\n const segment = openSegment(\n handle.shmId,\n computeSegmentSize(handle.slotCount, slotByteLength),\n )\n const reader = new FrameRingReader(\n segment.buffer,\n handle.shmId,\n handle.slotCount,\n slotByteLength,\n handle.nodeId,\n )\n const ring: OpenRing = { segment, reader }\n this.rings.set(handle.shmId, ring)\n return ring\n } catch (err) {\n this.logger?.warn('frame-ring reader: openSegment failed', {\n meta: { shmId: handle.shmId, error: errMsg(err) },\n })\n return null\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/native.ts","../src/frame-ring.ts","../src/frame-ring-reader-cache.ts"],"sourcesContent":["/**\n * Typed wrapper over the `shm_ring` N-API addon.\n *\n * The addon maps/unmaps a named OS shared-memory segment and hands JS a\n * zero-copy `Buffer` over the mapping. The ring/seqlock logic lives in\n * `frame-ring.ts` (Task 2) — this module is purely the segment plumbing.\n */\nimport { createRequire } from 'node:module'\nimport { fileURLToPath } from 'node:url'\nimport { dirname } from 'node:path'\n\nconst require = createRequire(import.meta.url)\nconst here = dirname(fileURLToPath(import.meta.url))\n\n/** Opaque per-segment handle the native side uses to reach the mapping on close. */\nexport interface NativeSegmentHandle {\n readonly __shmHandle: unique symbol\n}\n\n/** Raw result handed back by the native `create` / `open` calls. */\ninterface NativeSegment {\n readonly buffer: Buffer\n readonly handle: NativeSegmentHandle\n}\n\n/** The native addon surface. */\ninterface ShmAddon {\n create(name: string, byteLength: number): NativeSegment\n open(name: string, byteLength: number): NativeSegment\n close(handle: NativeSegmentHandle): void\n unlink(name: string): void\n}\n\n/**\n * `node-gyp-build` resolves a prebuilt binary if one exists, otherwise the\n * locally compiled `build/Release/shm_ring.node`. Pointed at the package root\n * (one level above `dist/` / `src/`) so both layouts resolve.\n */\nfunction loadAddon(): ShmAddon {\n const nodeGypBuild: (root: string) => unknown = require('node-gyp-build')\n const packageRoot = dirname(here)\n const addon = nodeGypBuild(packageRoot)\n if (!isShmAddon(addon)) {\n throw new Error('@camstack/shm-ring: native addon did not expose the expected surface')\n }\n return addon\n}\n\nfunction hasFunction(value: object, key: string): boolean {\n return typeof Reflect.get(value, key) === 'function'\n}\n\nfunction isShmAddon(value: unknown): value is ShmAddon {\n if (typeof value !== 'object' || value === null) return false\n return (\n hasFunction(value, 'create') &&\n hasFunction(value, 'open') &&\n hasFunction(value, 'close') &&\n hasFunction(value, 'unlink')\n )\n}\n\nconst addon: ShmAddon = loadAddon()\n\n/** A mapped shared-memory segment with a zero-copy view and lifecycle controls. */\nexport interface ShmSegment {\n /** Zero-copy `Buffer` over the mapped region. Valid until `close()`. */\n readonly buffer: Buffer\n /** Unmap this process's view of the segment. Does not unlink the name. */\n close(): void\n /**\n * Remove the segment name from the OS namespace. The backing memory is\n * reclaimed once every process has also `close()`d its view. POSIX:\n * `shm_unlink`. Windows: a no-op — the OS reclaims on last-handle-close.\n */\n unlink(): void\n}\n\nfunction wrap(name: string, native: NativeSegment): ShmSegment {\n let closed = false\n let unlinked = false\n return {\n buffer: native.buffer,\n close(): void {\n if (closed) return\n closed = true\n addon.close(native.handle)\n },\n unlink(): void {\n // Idempotent: native `Unlink` throws `ENOENT` on a second `shm_unlink`\n // of the same name, so a double `unlink()` would throw. Guard it here —\n // mirrors `close()`'s `closed` flag.\n if (unlinked) return\n unlinked = true\n addon.unlink(name)\n },\n }\n}\n\n/**\n * Create a new named shared-memory segment of `byteLength` bytes and map it.\n * The segment is zero-filled. Fails if a segment with this name already exists.\n */\nexport function createSegment(name: string, byteLength: number): ShmSegment {\n return wrap(name, addon.create(name, byteLength))\n}\n\n/**\n * Map an existing named shared-memory segment. `byteLength` must match (or be\n * smaller than) the size the segment was created with. Throws if the segment\n * does not exist.\n */\nexport function openSegment(name: string, byteLength: number): ShmSegment {\n return wrap(name, addon.open(name, byteLength))\n}\n\n/**\n * Remove a segment name from the OS namespace without holding a mapping.\n * Convenience for cleanup paths that never mapped the segment themselves.\n */\nexport function unlinkSegment(name: string): void {\n addon.unlink(name)\n}\n","/**\n * `FrameRing` — a lock-free seqlock ring-buffer over a shared-memory segment.\n *\n * One writer (the decoder) publishes decoded frames into `slotCount` slots;\n * any number of readers (motion, detection, the WebRTC encoder, …) consume\n * them latest-wins. There are no JS-level locks: correctness rests entirely\n * on a per-slot **seqlock** sequence number manipulated with `Atomics`.\n *\n * The backing memory is a genuine `mmap` (see `native.ts`), so the `Atomics`\n * loads/stores have cross-process effect. The ring logic here is pure\n * TypeScript and treats the segment as an opaque `Buffer`.\n *\n * ## Segment layout (all offsets in bytes, little-endian)\n *\n * ```\n * ┌──────────────────────────────────────────────────────────────┐\n * │ HEADER — Int32Array view, (3 + slotCount) * 4 bytes │\n * │ [0] slotCount │\n * │ [1] slotByteLength (capacity of one pixel slot) │\n * │ [2] writeIndex (monotone publish counter) │\n * │ [3 .. 3+N) seq[slot] — per-slot seqlock counter │\n * ├──────────────────────────────────────────────────────────────┤\n * │ METADATA — slotCount * SLOT_META_BYTES (32) bytes, 8-aligned │\n * │ per slot: width (Int32 @ +0) │\n * │ height (Int32 @ +4) │\n * │ format (Int32 @ +8, FRAME_FORMAT_CODES index) │\n * │ byteLen (Int32 @ +12, valid pixel bytes) │\n * │ pts (Float64@ +16, presentation timestamp) │\n * │ (8 bytes padding @ +24 — keeps slots 8-aligned) │\n * ├──────────────────────────────────────────────────────────────┤\n * │ PIXELS — slotCount * slotByteLength bytes │\n * │ slot K pixel region @ pixelsOffset + K * slotByteLength │\n * └──────────────────────────────────────────────────────────────┘\n * ```\n *\n * ## Seqlock protocol\n *\n * `seq[slot]` starts even (committed / readable). A write does:\n * 1. `Atomics.add(seq, slot, 1)` → seq becomes **odd** (write in progress);\n * 2. copy pixels + metadata into the slot;\n * 3. `Atomics.add(seq, slot, 1)` → seq becomes **even** (committed);\n * 4. `Atomics.store(writeIndex)` → publish the new latest slot.\n *\n * A read does:\n * 1. `s1 = Atomics.load(seq, slot)`; if `s1` is odd → skip (write in flight);\n * 2. copy pixels + metadata out;\n * 3. `s2 = Atomics.load(seq, slot)`; if `s1 !== s2` → skip (recycled / torn).\n *\n * An odd `s1` means a write is mid-flight; an `s1 !== s2` means the slot was\n * overwritten between the two loads — in both cases the reader drops the\n * frame rather than returning torn pixels.\n */\nimport type { FrameFormat, FrameHandle } from '@camstack/types'\n\n/** Header slot indices in the `Int32Array` header view. */\nconst HDR_SLOT_COUNT = 0\nconst HDR_SLOT_BYTE_LENGTH = 1\nconst HDR_WRITE_INDEX = 2\n/** Fixed header fields before the per-slot `seq[]` array begins. */\nconst HDR_FIXED_FIELDS = 3\n\n/** Bytes of metadata reserved per slot (24 used + 8 padding → 8-byte aligned). */\nconst SLOT_META_BYTES = 32\nconst META_OFF_WIDTH = 0\nconst META_OFF_HEIGHT = 4\nconst META_OFF_FORMAT = 8\nconst META_OFF_BYTE_LENGTH = 12\nconst META_OFF_PTS = 16\n/** Wall-clock ms (Date.now) stamped at commit — uses the 8 reserved padding\n * bytes at +24, so slot size is unchanged. Lets readers measure frame age\n * (a PTS can't be diffed against Date.now). 0 when an older writer didn't set it. */\nconst META_OFF_CAPTURED_AT = 24\n\n/**\n * Stable integer codes for `FrameFormat` — metadata stores the index, not the\n * string, so a slot's metadata is a fixed-width struct. Append-only: never\n * reorder or remove an entry, or existing segments would mis-decode.\n */\nconst FRAME_FORMAT_CODES: readonly FrameFormat[] = [\n 'jpeg',\n 'rgb',\n 'bgr',\n 'yuv420',\n 'gray',\n]\n\nfunction encodeFormat(format: FrameFormat): number {\n const code = FRAME_FORMAT_CODES.indexOf(format)\n if (code < 0) {\n throw new Error(`FrameRing: unknown frame format \"${format}\"`)\n }\n return code\n}\n\nfunction decodeFormat(code: number): FrameFormat {\n const format = FRAME_FORMAT_CODES[code]\n if (format === undefined) {\n throw new Error(`FrameRing: unknown frame format code ${code}`)\n }\n return format\n}\n\n/** Round `value` up to the next multiple of `align` (a power of two). */\nfunction alignUp(value: number, align: number): number {\n return Math.ceil(value / align) * align\n}\n\n/** Per-frame metadata published alongside the pixels of a slot. */\nexport interface FrameMeta {\n readonly width: number\n readonly height: number\n readonly format: FrameFormat\n readonly pts: number\n /** Valid pixel bytes — must be `<= slotByteLength`. */\n readonly byteLength: number\n /** Wall-clock ms (Date.now) stamped at commit; `0` when unset (older writer).\n * Optional on write (commit stamps it); always present on read. */\n readonly capturedAt?: number\n}\n\n/**\n * A scatter-write slot reservation returned by {@link FrameRingWriter.beginFrame}.\n *\n * ## Scatter-write seqlock contract (Phase 5 / D9 Task 7c — READ THIS)\n *\n * `beginFrame()` has already bumped this slot's `seq` to **odd** — a write is in\n * progress, so any reader hitting this slot skips it (returns `null` / a failed\n * `validate()`). The caller now owns an exclusive write window over `buffer`:\n *\n * 1. `buffer` is a writable `subarray` **directly over the slot's pixel\n * region in the mapped segment** — there is no intermediate copy. A\n * producer (e.g. the node-av scaler) fills it in place.\n * 2. The slot stays odd for the **entire fill duration** — not just a memcpy.\n * A scatter producer that takes longer to fill the slot (a whole scale\n * pass) holds the slot odd for that whole pass; this is correct, readers\n * simply latest-wins-drop that slot until {@link FrameRingWriter.commitFrame}.\n * 3. The caller MUST call {@link FrameRingWriter.commitFrame} with this exact\n * `slot` once the fill is done — that writes the metadata, bumps `seq` back\n * to **even** (committed) and advances `writeIndex` so readers can see it.\n *\n * Exactly one `beginFrame` may be open at a time per writer (there is a single\n * writer per ring). `buffer.byteLength` is the slot's full capacity\n * (`slotByteLength`); the caller writes at most `commitFrame`'s `meta.byteLength`\n * valid bytes into it.\n */\nexport interface FrameSlotWrite {\n /** The ring slot index this write targets — pass it back to `commitFrame`. */\n readonly slot: number\n /**\n * A writable view **directly over the slot's pixel region** in the shared\n * mapping. Fill it in place, then `commitFrame(slot, meta)`. Valid only\n * between the `beginFrame` that produced it and its matching `commitFrame`.\n */\n readonly buffer: Buffer\n}\n\n/**\n * The result of a successful seqlock read.\n *\n * ## Buffer-lifetime contract (Phase 5 / D9 Task 7b — READ THIS)\n *\n * `pixels` is a view onto the **reader's reusable scratch buffer**, NOT a\n * freshly allocated copy. The seqlock-validated pixel bytes are memcpy'd into\n * that scratch buffer (the copy is the seqlock safety copy — the bytes survive\n * a post-read slot recycle), but the buffer itself is reused on this reader's\n * **next** `readHandle` / `readLatest` call.\n *\n * Therefore `pixels` is **valid only until this same reader's next read**:\n *\n * - A consumer that processes the frame **synchronously**, fully, before it\n * issues another read on this reader may use `pixels` directly (borrow).\n * - A consumer that **retains** the frame past its next read — queues it for\n * async work, stores it, hands it to another tick — MUST `Buffer.from(...)`\n * it into its own storage at the point of retention. Holding the borrowed\n * `pixels` past the next read is silent corruption: the next read overwrites\n * the bytes in place.\n *\n * `meta` and `handle` are plain immutable values — safe to retain freely.\n */\nexport interface FrameRead {\n /**\n * The slot's pixel bytes copied into the reader's reusable scratch buffer.\n *\n * **Borrowed, not owned** — valid only until this reader's next\n * `readHandle` / `readLatest`. Retain past that point ⇒ copy first\n * (`Buffer.from(pixels)`). See the `FrameRead` doc comment.\n */\n readonly pixels: Buffer\n readonly meta: FrameMeta\n /** A serialisable handle for this exact frame. */\n readonly handle: FrameHandle\n}\n\n/**\n * A **zero-copy** read: `pixels` is a `subarray` directly over the shared\n * mapping — no memcpy at all (Phase 5 / D9 Task 7b Step 3).\n *\n * ## Validation contract (READ THIS — concurrency-sensitive)\n *\n * Because `pixels` aliases the live ring slot, a concurrent writer can recycle\n * that slot at any moment, tearing the bytes a consumer is reading. A\n * `FrameView` is therefore an **optimistic** read: the seqlock's *opening*\n * check already passed (the slot was committed and, for a handle read, the seq\n * matched), but the closing re-check is deferred to the consumer.\n *\n * A consumer MUST:\n * 1. read the slot **synchronously and fast** — no `await`, no yielding;\n * 2. call `validate()` **immediately after** finishing with `pixels`;\n * 3. if `validate()` returns `false`, **discard** whatever it computed from\n * `pixels` — the bytes were (or may have been) overwritten mid-read, i.e.\n * a torn frame, the latest-wins drop.\n *\n * `validate()` returning `true` means the slot's `seq` is unchanged since the\n * read opened — the bytes the consumer just processed were a single committed\n * frame, not torn. This is the same seqlock guarantee `readHandle` gives, but\n * the closing half is the consumer's responsibility instead of being baked\n * into a memcpy + recheck. Use this ONLY for tight synchronous consumers\n * (e.g. motion's frame scan, the perf bench's compute stage); any consumer\n * that retains or processes asynchronously MUST use the copying `readHandle` /\n * `readLatest` instead.\n */\nexport interface FrameView {\n /**\n * A `subarray` view directly over the shared-memory slot — **zero-copy**.\n * Valid for a synchronous read only; `validate()` confirms it was not torn.\n */\n readonly pixels: Buffer\n readonly meta: FrameMeta\n readonly handle: FrameHandle\n /**\n * Re-check the slot's seqlock. `true` ⇒ the slot was not recycled while the\n * consumer read `pixels` — the frame is intact. `false` ⇒ a torn read,\n * discard everything derived from `pixels`. Call once, right after the\n * synchronous read of `pixels` completes.\n */\n validate(): boolean\n}\n\n/** Internal geometry of a sized segment. */\ninterface RingGeometry {\n readonly slotCount: number\n readonly slotByteLength: number\n /** Byte length of the `Int32Array` header. */\n readonly headerBytes: number\n /** Byte offset of the metadata region. */\n readonly metaOffset: number\n /** Byte offset of the pixel region. */\n readonly pixelsOffset: number\n /** Total segment byte length. */\n readonly totalBytes: number\n}\n\n/** Compute the geometry of a ring with the given slot count and slot size. */\nfunction computeGeometry(slotCount: number, slotByteLength: number): RingGeometry {\n if (!Number.isInteger(slotCount) || slotCount <= 0) {\n throw new Error(`FrameRing: slotCount must be a positive integer, got ${slotCount}`)\n }\n if (!Number.isInteger(slotByteLength) || slotByteLength <= 0) {\n throw new Error(\n `FrameRing: slotByteLength must be a positive integer, got ${slotByteLength}`,\n )\n }\n const headerBytes = alignUp((HDR_FIXED_FIELDS + slotCount) * 4, 8)\n const metaOffset = headerBytes\n const metaBytes = slotCount * SLOT_META_BYTES\n const pixelsOffset = alignUp(metaOffset + metaBytes, 8)\n const totalBytes = pixelsOffset + slotCount * slotByteLength\n return {\n slotCount,\n slotByteLength,\n headerBytes,\n metaOffset,\n pixelsOffset,\n totalBytes,\n }\n}\n\n/**\n * Total byte length a segment must have to hold a `slotCount`-slot ring with\n * `slotByteLength`-byte pixel slots. The decoder uses this to size the\n * shared-memory segment before constructing a `FrameRingWriter` over it.\n */\nexport function computeSegmentSize(slotCount: number, slotByteLength: number): number {\n return computeGeometry(slotCount, slotByteLength).totalBytes\n}\n\n/**\n * Slot-count bounds for a per-resolution ring. {@link deriveSlotCount} clamps\n * the budget-derived count into `[MIN_RING_SLOTS, MAX_RING_SLOTS]`:\n * - `MIN` keeps a tiny floor even when one frame nearly exhausts the budget\n * (a 4K RGB frame is ~24.9 MB — a 128 MB budget yields only ~5 raw slots);\n * - `MAX` caps the ring for small frames so a 360p stream does not allocate\n * hundreds of slots' worth of shared memory it will never use latest-wins.\n */\nexport const MIN_RING_SLOTS = 6\nexport const MAX_RING_SLOTS = 64\n\n/**\n * Slot count for a ring, derived from a per-ring shared-memory budget.\n *\n * `budgetBytes` is the shared memory a single stream's ring may consume; the\n * raw count is `floor(budgetBytes / slotByteLength)`, clamped into\n * `[MIN_RING_SLOTS, MAX_RING_SLOTS]`. Live video is latest-wins, so the slot\n * count only needs to absorb the writer-commit / reader-read window — the\n * budget trades shared-memory footprint against that window per resolution.\n */\nexport function deriveSlotCount(budgetBytes: number, slotByteLength: number): number {\n const raw = Math.floor(budgetBytes / slotByteLength)\n return Math.min(MAX_RING_SLOTS, Math.max(MIN_RING_SLOTS, raw))\n}\n\n/**\n * Bytes per pixel for each packed decode format a frame ring slot can hold.\n *\n * This is the single source of truth shared by the decoder write side\n * (`DecoderFrameRingSink`) and every consumer read side\n * (`FrameRingReaderCache`): if the two computed different values, a reader\n * would mis-map the shared-memory segment and read corrupt pixels.\n *\n * `jpeg` has no fixed bytes-per-pixel — `computeSlotByteLength` short-circuits\n * it to the equivalent RGB24 raster before this is ever consulted, so the `3`\n * here is only a safe placeholder for exhaustiveness over `FrameFormat`.\n */\nexport function bytesPerPixel(format: FrameFormat): number {\n switch (format) {\n case 'gray':\n return 1\n case 'rgb':\n case 'bgr':\n return 3\n case 'yuv420':\n // 4:2:0 planar — 1 byte luma + ¼+¼ chroma per pixel = 1.5 bytes/px.\n // Rounded up to 2 so the slot is never undersized for a planar frame.\n return 2\n case 'jpeg':\n // Variable-length; see the doc comment above. `computeSlotByteLength`\n // sizes a jpeg slot from the RGB24 raster, never via this branch.\n return 3\n }\n}\n\n/**\n * Per-slot byte capacity for a frame of the given geometry — the single source\n * of truth for both the decoder write side and the consumer read side.\n *\n * For raw formats this is exactly `width × height × bpp`. For `jpeg` (variable\n * length) the slot is sized to the equivalent RGB24 raster — a JPEG is always\n * far smaller than its source raster, so an RGB24-sized slot is a safe upper\n * bound. Note: this upper bound holds only while the JPEG encoder is pinned to\n * its current quality (~80). A near-lossless quality setting can produce a\n * JPEG that exceeds the RGB24 byte count; if quality is raised significantly,\n * this slot size must be revisited.\n */\nexport function computeSlotByteLength(\n width: number,\n height: number,\n format: FrameFormat,\n): number {\n if (format === 'jpeg') {\n return width * height * 3\n }\n return width * height * bytesPerPixel(format)\n}\n\n/**\n * Shared geometry + typed views over a segment buffer. The header is an\n * `Int32Array` so `Atomics` ops apply directly; metadata and pixels are read\n * and written through `DataView` / `Buffer` slices.\n */\nabstract class FrameRingBase {\n protected readonly geometry: RingGeometry\n /** `Int32Array` over the header — slotCount, slotByteLength, writeIndex, seq[]. */\n protected readonly header: Int32Array\n protected readonly view: DataView\n\n protected constructor(\n protected readonly segment: Buffer,\n protected readonly shmId: string,\n slotCount: number,\n slotByteLength: number,\n ) {\n this.geometry = computeGeometry(slotCount, slotByteLength)\n if (segment.byteLength < this.geometry.totalBytes) {\n throw new Error(\n `FrameRing: segment is ${segment.byteLength} bytes, ` +\n `need ${this.geometry.totalBytes} for ${slotCount}×${slotByteLength}`,\n )\n }\n const headerInts = HDR_FIXED_FIELDS + slotCount\n this.header = new Int32Array(\n segment.buffer,\n segment.byteOffset,\n headerInts,\n )\n this.view = new DataView(segment.buffer, segment.byteOffset, segment.byteLength)\n }\n\n /** Header index of `seq[slot]`. */\n protected seqIndex(slot: number): number {\n return HDR_FIXED_FIELDS + slot\n }\n\n /** Byte offset of slot `slot`'s metadata struct. */\n protected metaOffsetOf(slot: number): number {\n return this.geometry.metaOffset + slot * SLOT_META_BYTES\n }\n\n /** Byte offset of slot `slot`'s pixel region. */\n protected pixelOffsetOf(slot: number): number {\n return this.geometry.pixelsOffset + slot * this.geometry.slotByteLength\n }\n}\n\n/**\n * The single writer for a ring. Constructed by whoever owns the segment (the\n * decoder). On construction it stamps `slotCount` / `slotByteLength` into the\n * header so a reader opening the same segment can self-describe.\n */\nexport class FrameRingWriter extends FrameRingBase {\n /** Slot index of an in-flight `beginWrite()`, or `null` when idle. */\n private pendingSlot: number | null = null\n\n /** Cluster node id stamped into every `FrameHandle` this writer produces. */\n private readonly nodeId: string\n\n constructor(\n segment: Buffer,\n shmId: string,\n slotCount: number,\n slotByteLength: number,\n nodeId: string,\n ) {\n super(segment, shmId, slotCount, slotByteLength)\n this.nodeId = nodeId\n // Stamp the self-describing header. `writeIndex` starts at 0; before the\n // first commit there is no readable frame (seq[0] is even == 0).\n Atomics.store(this.header, HDR_SLOT_COUNT, slotCount)\n Atomics.store(this.header, HDR_SLOT_BYTE_LENGTH, slotByteLength)\n }\n\n /** The number of slots in this ring — derived per-resolution from the budget. */\n get slotCount(): number {\n return this.geometry.slotCount\n }\n\n /** The slot the next `writeFrame` / `beginFrame` will target. */\n private targetSlot(): number {\n const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX)\n return writeIndex % this.geometry.slotCount\n }\n\n /**\n * Open the seqlock on the next slot and hand the caller a writable view\n * directly over that slot's pixel region — the **scatter-write** entry point.\n *\n * The seqlock contract (see {@link FrameSlotWrite}): this bumps the slot's\n * `seq` to **odd** (write in progress), so a concurrent reader skips the slot\n * for the entire fill window. The caller fills `buffer` in place — there is\n * NO intermediate copy: a producer (the node-av scaler) writes its packed\n * output straight into the mapped segment — then MUST call\n * {@link commitFrame} with the returned `slot` to publish the frame.\n *\n * Exactly one `beginFrame` may be open per writer at a time.\n */\n beginFrame(): FrameSlotWrite {\n if (this.pendingSlot !== null) {\n throw new Error('FrameRingWriter: a frame write is already in progress')\n }\n const slot = this.targetSlot()\n // seq → odd: a write is now in progress on this slot.\n Atomics.add(this.header, this.seqIndex(slot), 1)\n this.pendingSlot = slot\n const pixelOffset = this.pixelOffsetOf(slot)\n const buffer = this.segment.subarray(\n pixelOffset,\n pixelOffset + this.geometry.slotByteLength,\n )\n return { slot, buffer }\n }\n\n /**\n * Close the seqlock opened by {@link beginFrame}: write the metadata, bump\n * `seq` back to **even** (committed) and advance `writeIndex` so readers see\n * this slot as the latest. Returns the published frame's `FrameHandle`.\n *\n * `slot` must be the value from the matching `beginFrame`'s\n * {@link FrameSlotWrite}. The pixels are assumed already filled in place by\n * the caller (the scatter-write contract) — `commitFrame` copies nothing.\n */\n commitFrame(slot: number, meta: FrameMeta): FrameHandle {\n if (this.pendingSlot === null) {\n throw new Error('FrameRingWriter: commitFrame called without beginFrame')\n }\n if (slot !== this.pendingSlot) {\n throw new Error(\n `FrameRingWriter: commitFrame slot ${slot} does not match the ` +\n `in-progress beginFrame slot ${this.pendingSlot}`,\n )\n }\n const { slotByteLength } = this.geometry\n if (meta.byteLength > slotByteLength) {\n throw new Error(\n `FrameRingWriter: frame is ${meta.byteLength} bytes, ` +\n `slot capacity is ${slotByteLength}`,\n )\n }\n this.pendingSlot = null\n\n // --- write metadata (inside the still-open odd-seq window) ---\n const metaOffset = this.metaOffsetOf(slot)\n this.view.setInt32(metaOffset + META_OFF_WIDTH, meta.width, true)\n this.view.setInt32(metaOffset + META_OFF_HEIGHT, meta.height, true)\n this.view.setInt32(metaOffset + META_OFF_FORMAT, encodeFormat(meta.format), true)\n this.view.setInt32(metaOffset + META_OFF_BYTE_LENGTH, meta.byteLength, true)\n this.view.setFloat64(metaOffset + META_OFF_PTS, meta.pts, true)\n this.view.setFloat64(metaOffset + META_OFF_CAPTURED_AT, meta.capturedAt ?? Date.now(), true)\n\n // --- close the seqlock: seq → even (committed) ---\n // `Atomics.add` returns the previous (odd) value; +1 gives the new even seq.\n const committedSeq = Atomics.add(this.header, this.seqIndex(slot), 1) + 1\n\n // --- publish: advance writeIndex so readers see this as the latest ---\n Atomics.add(this.header, HDR_WRITE_INDEX, 1)\n\n return {\n shmId: this.shmId,\n slot,\n seq: committedSeq,\n width: meta.width,\n height: meta.height,\n format: meta.format,\n pts: meta.pts,\n byteLength: meta.byteLength,\n nodeId: this.nodeId,\n slotCount: this.geometry.slotCount,\n }\n }\n\n /**\n * Abandon the seqlock opened by {@link beginFrame} **without publishing the\n * slot** — the degenerate-path counterpart of {@link commitFrame}.\n *\n * `beginFrame` has bumped the slot's `seq` to **odd**. A caller that opened a\n * slot but then could not fill it with valid pixels (the producer failed, or\n * there were no source planes) MUST NOT `commitFrame` — that would close the\n * seqlock over uninitialised / stale shared-memory bytes and advance\n * `writeIndex`, so a reader would see those garbage bytes as a real, latest\n * frame. `abortFrame` instead:\n *\n * 1. bumps `seq` from odd back to **even** — the slot is not left stuck\n * odd, so the writer can reuse it on a later cycle;\n * 2. does **NOT** advance `writeIndex` — `readLatest` therefore never\n * surfaces this slot as the latest (the prior latest stays latest);\n * 3. returns no `FrameHandle` — nothing is handed downstream.\n *\n * The slot's pixel bytes after `abortFrame` are **undefined** (whatever was\n * there before, or a half-written producer output) — but that is harmless\n * because no consumer ever sees the slot as committed: `readLatest` skips it\n * (writeIndex did not move) and any handle that happened to point at this\n * slot from an earlier commit sees the changed `seq` and is correctly\n * rejected by `readHandle`'s seq check.\n *\n * `slot` must be the value from the matching `beginFrame` — validated\n * exactly as {@link commitFrame} does.\n */\n abortFrame(slot: number): void {\n if (this.pendingSlot === null) {\n throw new Error('FrameRingWriter: abortFrame called without beginFrame')\n }\n if (slot !== this.pendingSlot) {\n throw new Error(\n `FrameRingWriter: abortFrame slot ${slot} does not match the ` +\n `in-progress beginFrame slot ${this.pendingSlot}`,\n )\n }\n this.pendingSlot = null\n // Close the seqlock: seq odd → even. The slot is committed-shape again so\n // it can be reused, but writeIndex is NOT advanced — the slot is never the\n // latest, and its pixel content is intentionally undefined.\n Atomics.add(this.header, this.seqIndex(slot), 1)\n }\n\n /**\n * Publish one frame from a caller-provided pixel `Buffer` — the convenience\n * wrapper over the scatter-write {@link beginFrame} / {@link commitFrame}\n * pair: it opens the slot, copies `pixels` into it, then commits.\n *\n * Prefer the `beginFrame` / `commitFrame` pair when the pixels can be\n * produced directly into the slot (the node-av scaler scattering its packed\n * output into the mapped segment) — that path has zero write-side copy.\n * `writeFrame` keeps the single-call form for callers that already hold a\n * detached pixel `Buffer`.\n */\n writeFrame(pixels: Buffer, meta: FrameMeta): FrameHandle {\n if (pixels.byteLength < meta.byteLength) {\n throw new Error(\n `FrameRingWriter: pixels buffer (${pixels.byteLength} bytes) ` +\n `shorter than meta.byteLength (${meta.byteLength})`,\n )\n }\n const { slot, buffer } = this.beginFrame()\n // Copy the pixels into the slot, inside the open odd-seq window.\n buffer.set(pixels.subarray(0, meta.byteLength))\n return this.commitFrame(slot, meta)\n }\n}\n\n/**\n * A reader for a ring. Many readers may share a segment concurrently with the\n * single writer. Every read is a seqlock read — a torn or recycled slot is\n * dropped (returns `null`), never returned as garbage pixels.\n */\nexport class FrameRingReader extends FrameRingBase {\n /**\n * The reusable per-reader scratch buffer the seqlock-validated pixels are\n * copied into — see the `FrameRead` doc comment for the borrow contract.\n *\n * Sized to `slotByteLength` on construction (the segment's stamped slot\n * capacity) and lazily grown if a slot ever reports a larger `byteLength`.\n * Reusing one buffer per reader replaces the per-read `Buffer.allocUnsafe` —\n * the D9 perf gate's regression (Task 7b): a fresh ~5.93 MB allocation per\n * 1080p frame churned ~1.39 GB/s and stalled on GC. The single memcpy stays\n * (it IS the seqlock safety copy); only the allocation is removed.\n */\n private scratch: Buffer\n\n /**\n * Cluster node id of the node that OWNS this segment (i.e. the writer's\n * node — the node whose shared memory physically holds the ring), stamped\n * into every `FrameHandle` this reader produces. `FrameHandle.nodeId` always\n * identifies the segment-owning node, never the reading node, so callers\n * pass the writer's node id here (e.g. `FrameRingReaderCache` passes\n * `handle.nodeId` straight through).\n */\n private readonly nodeId: string\n\n constructor(\n segment: Buffer,\n shmId: string,\n slotCount: number,\n slotByteLength: number,\n /**\n * Node id of the segment-owning (writer's) node — see the `nodeId` field\n * doc. NOT this reader's own node.\n */\n nodeId: string,\n ) {\n super(segment, shmId, slotCount, slotByteLength)\n this.nodeId = nodeId\n this.scratch = Buffer.allocUnsafe(slotByteLength)\n }\n\n /**\n * Return the reader's scratch buffer as a view of exactly `byteLength` bytes,\n * growing the backing buffer first if a slot ever exceeds the stamped slot\n * capacity (defensive — `slotByteLength` should always bound `byteLength`).\n */\n private scratchFor(byteLength: number): Buffer {\n if (byteLength > this.scratch.byteLength) {\n this.scratch = Buffer.allocUnsafe(byteLength)\n }\n return this.scratch.subarray(0, byteLength)\n }\n\n /**\n * Read the latest committed frame, latest-wins. Returns `null` when the ring\n * is empty (no frame ever published) or the latest slot is mid-write /\n * recycled (a slow reader simply drops that frame and retries next tick).\n *\n * The returned `FrameRead.pixels` is **borrowed** from this reader's reusable\n * scratch buffer — valid only until this reader's next read. A consumer that\n * retains it past the next read MUST copy. See the `FrameRead` doc comment.\n */\n readLatest(): FrameRead | null {\n const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX)\n if (writeIndex === 0) {\n return null\n }\n // The most recently published slot is writeIndex-1 modulo slotCount.\n const slot = (writeIndex - 1) % this.geometry.slotCount\n return this.seqlockRead(slot, null)\n }\n\n /**\n * Read the exact frame a `FrameHandle` refers to. Returns `null` when the\n * slot has been recycled (its committed `seq` no longer matches `handle.seq`)\n * or is mid-write — i.e. the frame is gone, not torn.\n *\n * The returned `FrameRead.pixels` is **borrowed** from this reader's reusable\n * scratch buffer — valid only until this reader's next read. A consumer that\n * retains it past the next read MUST copy. See the `FrameRead` doc comment.\n */\n readHandle(handle: FrameHandle): FrameRead | null {\n if (handle.shmId !== this.shmId) {\n throw new Error(\n `FrameRingReader: handle is for segment \"${handle.shmId}\", ` +\n `this reader is on \"${this.shmId}\"`,\n )\n }\n if (handle.slot < 0 || handle.slot >= this.geometry.slotCount) {\n return null\n }\n return this.seqlockRead(handle.slot, handle.seq)\n }\n\n /**\n * The seqlock read of a single slot.\n *\n * `expectedSeq` is the committed seq a `readHandle` caller demands; pass\n * `null` for `readLatest`, which accepts whatever even seq it finds.\n */\n private seqlockRead(slot: number, expectedSeq: number | null): FrameRead | null {\n const seqIdx = this.seqIndex(slot)\n\n // (1) read the sequence; an odd value means a write is in progress.\n const s1 = Atomics.load(this.header, seqIdx)\n if ((s1 & 1) !== 0) {\n return null\n }\n if (expectedSeq !== null && s1 !== expectedSeq) {\n // The slot has moved on (recycled) — the requested frame is gone.\n return null\n }\n\n // (2) read metadata + pixels into a private copy.\n const metaOffset = this.metaOffsetOf(slot)\n const width = this.view.getInt32(metaOffset + META_OFF_WIDTH, true)\n const height = this.view.getInt32(metaOffset + META_OFF_HEIGHT, true)\n const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true)\n const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true)\n const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true)\n const capturedAt = this.view.getFloat64(metaOffset + META_OFF_CAPTURED_AT, true)\n\n if (byteLength < 0 || byteLength > this.geometry.slotByteLength) {\n // Metadata is being rewritten — treat as a torn read, skip.\n return null\n }\n const pixelOffset = this.pixelOffsetOf(slot)\n // Copy the pixels out into this reader's REUSABLE scratch buffer (not a\n // fresh allocation — see `scratch`). The copy is still the seqlock safety\n // copy: the bytes must survive a post-read slot recycle, and the recheck\n // below validates they were not torn mid-copy. The returned buffer is\n // borrowed — valid only until this reader's next read (see `FrameRead`).\n const pixels = this.scratchFor(byteLength)\n this.segment.copy(pixels, 0, pixelOffset, pixelOffset + byteLength)\n\n // (3) re-read the sequence; any change means the slot was overwritten\n // between (1) and (2) — the copy is torn, drop it.\n const s2 = Atomics.load(this.header, seqIdx)\n if (s1 !== s2) {\n return null\n }\n\n let format: FrameFormat\n try {\n format = decodeFormat(formatCode)\n } catch {\n // A torn metadata read can yield an out-of-range code; drop the frame.\n return null\n }\n\n const meta: FrameMeta = { width, height, format, pts, byteLength, capturedAt }\n const handle: FrameHandle = {\n shmId: this.shmId,\n slot,\n seq: s1,\n width,\n height,\n format,\n pts,\n byteLength,\n nodeId: this.nodeId,\n slotCount: this.geometry.slotCount,\n }\n return { pixels, meta, handle }\n }\n\n /**\n * Read the latest committed frame **zero-copy** — `FrameView.pixels` is a\n * `subarray` over the shared mapping, no memcpy. The caller MUST process it\n * synchronously and then call `validate()`; see the `FrameView` contract.\n * Returns `null` when the ring is empty or the latest slot is mid-write.\n */\n readLatestView(): FrameView | null {\n const writeIndex = Atomics.load(this.header, HDR_WRITE_INDEX)\n if (writeIndex === 0) {\n return null\n }\n const slot = (writeIndex - 1) % this.geometry.slotCount\n return this.seqlockReadView(slot, null)\n }\n\n /**\n * Read the frame a `FrameHandle` refers to **zero-copy** — `FrameView.pixels`\n * is a `subarray` over the shared mapping, no memcpy. The caller MUST process\n * it synchronously and then call `validate()`; see the `FrameView` contract.\n * Returns `null` when the slot is recycled (seq mismatch) or mid-write.\n */\n readHandleView(handle: FrameHandle): FrameView | null {\n if (handle.shmId !== this.shmId) {\n throw new Error(\n `FrameRingReader: handle is for segment \"${handle.shmId}\", ` +\n `this reader is on \"${this.shmId}\"`,\n )\n }\n if (handle.slot < 0 || handle.slot >= this.geometry.slotCount) {\n return null\n }\n return this.seqlockReadView(handle.slot, handle.seq)\n }\n\n /**\n * The zero-copy half of the seqlock read: validate the opening seqlock, then\n * return a `subarray` over the slot plus a `validate()` closure that re-reads\n * the seqlock. No pixel copy — the consumer owns the closing recheck.\n */\n private seqlockReadView(\n slot: number,\n expectedSeq: number | null,\n ): FrameView | null {\n const seqIdx = this.seqIndex(slot)\n\n // (1) opening seqlock check — odd ⇒ write in progress; mismatch ⇒ recycled.\n const s1 = Atomics.load(this.header, seqIdx)\n if ((s1 & 1) !== 0) {\n return null\n }\n if (expectedSeq !== null && s1 !== expectedSeq) {\n return null\n }\n\n // (2) read metadata — a torn metadata read is caught by the byteLength\n // bound and the closing `validate()` the consumer must call.\n const metaOffset = this.metaOffsetOf(slot)\n const width = this.view.getInt32(metaOffset + META_OFF_WIDTH, true)\n const height = this.view.getInt32(metaOffset + META_OFF_HEIGHT, true)\n const formatCode = this.view.getInt32(metaOffset + META_OFF_FORMAT, true)\n const byteLength = this.view.getInt32(metaOffset + META_OFF_BYTE_LENGTH, true)\n const pts = this.view.getFloat64(metaOffset + META_OFF_PTS, true)\n const capturedAt = this.view.getFloat64(metaOffset + META_OFF_CAPTURED_AT, true)\n\n if (byteLength < 0 || byteLength > this.geometry.slotByteLength) {\n return null\n }\n\n let format: FrameFormat\n try {\n format = decodeFormat(formatCode)\n } catch {\n return null\n }\n\n // (3) zero-copy view directly over the mapped slot — NO memcpy.\n const pixelOffset = this.pixelOffsetOf(slot)\n const pixels = this.segment.subarray(pixelOffset, pixelOffset + byteLength)\n\n const meta: FrameMeta = { width, height, format, pts, byteLength, capturedAt }\n const handle: FrameHandle = {\n shmId: this.shmId,\n slot,\n seq: s1,\n width,\n height,\n format,\n pts,\n byteLength,\n nodeId: this.nodeId,\n slotCount: this.geometry.slotCount,\n }\n // `validate()` is the closing seqlock half — the consumer calls it after a\n // synchronous read of `pixels` to confirm the slot was not recycled.\n const validate = (): boolean => Atomics.load(this.header, seqIdx) === s1\n return { pixels, meta, handle, validate }\n }\n}\n","/**\n * `FrameRingReaderCache` — the consumer side of the shared-memory frame plane\n * (Phase 5 / D9).\n *\n * A frame consumer (motion, detection, post-analysis) drains zero-pixel\n * `FrameHandle`s from the broker via `pullFrameHandles` and must read the\n * actual pixels back from the shared-memory ring the decoder wrote them into.\n * Each `FrameHandle` names its segment (`shmId`) and carries its ring depth\n * (`slotCount`) — the cache opens each segment **once** (a syscall) and reuses\n * the `FrameRingReader` for every later handle on the same ring.\n *\n * The decoder re-creates its segment under a fresh generation-tagged name on a\n * resolution change (`makeSegmentName`), so a stale `shmId` simply stops\n * appearing in new handles — its reader is closed lazily by `close()` at\n * teardown. (Resolution changes on a live camera are rare; carrying a few idle\n * mappings until teardown is cheaper than reference-counting handles.)\n *\n * A `null` from `readHandle` means the ring slot was recycled before the\n * consumer read it — a dropped frame, which is the correct latest-wins\n * behaviour for live video. The caller skips that frame.\n *\n * This is a pure consumer of the ring API and lives in `@camstack/shm-ring` so\n * any addon can read frames without importing from a sibling addon.\n */\nimport type { DecodedFrame, FrameHandle, IScopedLogger } from '@camstack/types'\nimport { errMsg } from '@camstack/types'\n\nimport {\n FrameRingReader,\n computeSegmentSize,\n computeSlotByteLength,\n} from './frame-ring.js'\nimport { openSegment } from './native.js'\nimport type { ShmSegment } from './native.js'\n\n/** One opened shm segment plus the reader mapped over it. */\ninterface OpenRing {\n readonly segment: ShmSegment\n readonly reader: FrameRingReader\n}\n\n/**\n * Opens (and caches) a `FrameRingReader` per `shmId` and reads the pixels a\n * `FrameHandle` refers to. Single-consumer — one cache per frame subscription.\n */\nexport class FrameRingReaderCache {\n private readonly rings = new Map<string, OpenRing>()\n private readonly logger: IScopedLogger | undefined\n private closed = false\n\n constructor(logger?: IScopedLogger) {\n this.logger = logger\n }\n\n /**\n * Read the pixels a `FrameHandle` refers to and return them as a\n * `DecodedFrame`. Returns `null` when the ring slot was recycled before the\n * read (a dropped frame) or the segment could not be opened.\n */\n read(handle: FrameHandle): DecodedFrame | null {\n if (this.closed) return null\n const ring = this.ringFor(handle)\n if (!ring) return null\n let frame: ReturnType<FrameRingReader['readHandle']>\n try {\n frame = ring.reader.readHandle(handle)\n } catch (err) {\n // A mismatched shmId would throw — defensive only; `ringFor` keys the\n // reader by the handle's own shmId so this should never fire.\n this.logger?.warn('frame-ring reader: readHandle failed', {\n meta: { shmId: handle.shmId, error: errMsg(err) },\n })\n return null\n }\n if (!frame) return null\n return {\n data: frame.pixels,\n width: frame.meta.width,\n height: frame.meta.height,\n format: frame.meta.format,\n timestamp: frame.meta.pts,\n capturedAt: frame.meta.capturedAt,\n }\n }\n\n /** Close every cached segment. Idempotent. */\n close(): void {\n if (this.closed) return\n this.closed = true\n for (const [shmId, ring] of this.rings) {\n try {\n ring.segment.close()\n } catch (err) {\n this.logger?.warn('frame-ring reader: segment close failed', {\n meta: { shmId, error: errMsg(err) },\n })\n }\n }\n this.rings.clear()\n }\n\n // ── Internal ─────────────────────────────────────────────────────────\n\n /** Get the cached reader for a handle's segment, opening it on first use. */\n private ringFor(handle: FrameHandle): OpenRing | null {\n const cached = this.rings.get(handle.shmId)\n if (cached) return cached\n\n const slotByteLength = computeSlotByteLength(\n handle.width,\n handle.height,\n handle.format,\n )\n try {\n // The ring depth is per-resolution (Task 2): a small frame gets many\n // slots, a 4K frame few. The handle carries its own `slotCount` so the\n // reader sizes the segment view exactly — no hardcoded constant.\n const segment = openSegment(\n handle.shmId,\n computeSegmentSize(handle.slotCount, slotByteLength),\n )\n const reader = new FrameRingReader(\n segment.buffer,\n handle.shmId,\n handle.slotCount,\n slotByteLength,\n handle.nodeId,\n )\n const ring: OpenRing = { segment, reader }\n this.rings.set(handle.shmId, ring)\n return ring\n } catch (err) {\n this.logger?.warn('frame-ring reader: openSegment failed', {\n meta: { shmId: handle.shmId, error: errMsg(err) },\n })\n return null\n }\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/shm-ring",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "CamStack shared-memory frame ring — cross-platform N-API segment mapping + seqlock ring",
5
5
  "keywords": [
6
6
  "camstack",
@@ -49,7 +49,7 @@
49
49
  "node-gyp-build": "^4.8.4"
50
50
  },
51
51
  "devDependencies": {
52
- "node-av": "^5.2.0",
52
+ "node-av": "^5.2.4",
53
53
  "prebuildify": "^6.0.1",
54
54
  "typescript": "~5.9.0",
55
55
  "vite": "^8.0.11",