@camstack/addon-pipeline 1.2.73 → 1.2.75

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.
Files changed (36) hide show
  1. package/dist/{addon-utils-CQs-AjTJ.js → addon-utils-a_36HH_N.js} +4 -4
  2. package/dist/audio-analyzer/index.js +5 -6
  3. package/dist/audio-analyzer/index.mjs +3 -3
  4. package/dist/detection-pipeline/index.js +9 -10
  5. package/dist/detection-pipeline/index.mjs +5 -5
  6. package/dist/{dist-BAbvf81B.js → dist-6ZJCV0jY.js} +1901 -28
  7. package/dist/{dist-Dpr92hOt.mjs → dist-T_8jMw7d.mjs} +1866 -28
  8. package/dist/{event-loop-stall-monitor-BhKoybot.js → event-loop-stall-monitor-Cjof8vA1.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-B7Wd2jo-.mjs → event-loop-stall-monitor-XeGmL4xr.mjs} +1 -1
  10. package/dist/lazy-sharp-6oymT_yf.mjs +341 -0
  11. package/dist/lazy-sharp-DvwPafeP.js +360 -0
  12. package/dist/motion-wasm/index.js +3 -3
  13. package/dist/motion-wasm/index.mjs +3 -3
  14. package/dist/pipeline-runner/index.js +109 -21
  15. package/dist/pipeline-runner/index.mjs +107 -18
  16. package/dist/recorder/index.js +138 -32
  17. package/dist/recorder/index.mjs +137 -30
  18. package/dist/session-decode/decode-worker-child.js +311 -126
  19. package/dist/session-decode/decode-worker-child.mjs +311 -123
  20. package/dist/stream-broker/_stub.js +94 -94
  21. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cvh5La_s.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-H2OTmgR6.mjs} +2 -2
  22. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-RYTOS6B-.mjs +26 -0
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DfsLsn2e.mjs +26 -0
  24. package/dist/stream-broker/{hostInit-Ci7epN77.mjs → hostInit-4XDN_Nt0.mjs} +2 -2
  25. package/dist/stream-broker/index.js +12 -13
  26. package/dist/stream-broker/index.mjs +3 -3
  27. package/dist/stream-broker/remoteEntry.js +1 -1
  28. package/dist/{worker-protocol-BFq9AIDa.js → worker-protocol-B0cbTXyw.js} +1 -1
  29. package/dist/{worker-protocol-C1FtUWvH.mjs → worker-protocol-SqED7Y9i.mjs} +1 -1
  30. package/package.json +1 -1
  31. package/dist/chunk-emK7D4bc.js +0 -35
  32. package/dist/lazy-sharp-B-mb8uWx.mjs +0 -20
  33. package/dist/lazy-sharp-CcYuXtsa.js +0 -25
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C_plRUtD.mjs +0 -26
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CExO0piw.mjs +0 -26
  36. package/dist/{addon-utils-CZ2xo67g.mjs → addon-utils-A2S9D7pu.mjs} +2 -2
@@ -1,8 +1,5 @@
1
- import { i as formatNativeLeaseKnobs, n as isWorkerRequest, o as resolveNativeLeaseKnobs, r as logLevelForLine } from "../worker-protocol-C1FtUWvH.mjs";
2
- import { t as getSharp } from "../lazy-sharp-B-mb8uWx.mjs";
3
- import { pathToFileURL } from "node:url";
4
- import * as fs from "node:fs";
5
- import * as path$1 from "node:path";
1
+ import { i as formatNativeLeaseKnobs, n as isWorkerRequest, o as resolveNativeLeaseKnobs, r as logLevelForLine } from "../worker-protocol-SqED7Y9i.mjs";
2
+ import { n as setSharpWarnSink, r as hostExternalEntryUrls, t as getSharp } from "../lazy-sharp-6oymT_yf.mjs";
6
3
  //#region src/session-decode/color-conversion.ts
7
4
  /**
8
5
  * Explicit YUV→RGB colorspace/range resolution for the session-decode worker.
@@ -875,6 +872,82 @@ var NativeTileStore = class {
875
872
  }
876
873
  };
877
874
  //#endregion
875
+ //#region src/session-decode/planar-crop-source.ts
876
+ /** How many planes each layout must present before the offsets mean anything. */
877
+ var PLANE_COUNT = {
878
+ yuv420p: 3,
879
+ nv12: 2
880
+ };
881
+ /**
882
+ * Cache tag for a native-crop scaler's SOURCE format. `yuv420p` is the empty
883
+ * string on purpose: it keeps the historical, unprefixed cache key byte-for-byte
884
+ * (`320x240->160x120`), so adding formats cannot silently move an existing
885
+ * session onto a different scaler.
886
+ */
887
+ function nativeCropSourceTag(format) {
888
+ switch (format) {
889
+ case "yuv420p": return "";
890
+ case "nv12": return "nv12:";
891
+ case "rgb24": return "rgb:";
892
+ }
893
+ }
894
+ /** A plane view starting at `offset`, or `null` when the plane is too short. */
895
+ function viewAt(plane, offset, requiredSpan) {
896
+ if (!plane) return null;
897
+ if (offset < 0 || plane.length - offset < requiredSpan) return null;
898
+ return plane.subarray(offset);
899
+ }
900
+ /**
901
+ * Resolve the plane views a crop of `region` reads, for `kind`.
902
+ *
903
+ * Returns `null` — never a partial or clamped view — when the frame does not
904
+ * present the planes the layout requires, or when the region would run past a
905
+ * plane's end. The caller counts that as a crop miss and says so ONCE; a short
906
+ * view would hand libswscale a buffer to read past, which is how this path
907
+ * previously produced heap corruption rather than an error.
908
+ *
909
+ * An odd origin is floored onto the chroma grid rather than rejected: the
910
+ * geometry helper already aligns to even, and flooring reads the chroma sample
911
+ * that CONTAINS the origin instead of inventing a half sample.
912
+ */
913
+ function resolvePlanarCropSource(kind, planes, strides, region) {
914
+ if (!planes || planes.length < PLANE_COUNT[kind]) return null;
915
+ const yStride = strides?.[0] ?? 0;
916
+ const cStride = strides?.[1] ?? 0;
917
+ if (yStride <= 0 || cStride <= 0) return null;
918
+ const top = Math.max(0, region.top);
919
+ const left = Math.max(0, region.left);
920
+ const chromaRow = Math.floor(top / 2);
921
+ const chromaHeight = Math.ceil(region.height / 2);
922
+ const y = viewAt(planes[0], top * yStride + left, yStride * region.height);
923
+ if (kind === "nv12") {
924
+ const uvOffset = chromaRow * cStride + Math.floor(left / 2) * 2;
925
+ const uv = viewAt(planes[1], uvOffset, cStride * chromaHeight);
926
+ if (!y || !uv) return null;
927
+ return {
928
+ planes: [y, uv],
929
+ strides: [yStride, cStride]
930
+ };
931
+ }
932
+ const vStride = strides?.[2] ?? cStride;
933
+ const chromaOffset = chromaRow * cStride + Math.floor(left / 2);
934
+ const u = viewAt(planes[1], chromaOffset, cStride * chromaHeight);
935
+ const v = viewAt(planes[2], chromaRow * vStride + Math.floor(left / 2), vStride * chromaHeight);
936
+ if (!y || !u || !v) return null;
937
+ return {
938
+ planes: [
939
+ y,
940
+ u,
941
+ v
942
+ ],
943
+ strides: [
944
+ yStride,
945
+ cStride,
946
+ vStride
947
+ ]
948
+ };
949
+ }
950
+ //#endregion
878
951
  //#region src/session-decode/pinned-rgb-crop.ts
879
952
  /**
880
953
  * Pure decision/plumbing helpers for the `pinnedRgbCrop` native-crop color fix
@@ -930,12 +1003,13 @@ function leaseDownloadFilterKey(srcW, srcH, color) {
930
1003
  /**
931
1004
  * Cache key for the dedicated native-crop software scaler. YUV420P (flag OFF /
932
1005
  * degrade) returns the historical `${srcW}x${srcH}->${dstW}x${dstH}` key
933
- * byte-for-byte; the RGB24 (flag ON) scaler carries an `rgb:` prefix so an RGB
934
- * scaler and a YUV scaler of identical geometry never alias the same cache slot.
1006
+ * byte-for-byte; every other source layout carries its own prefix
1007
+ * ({@link nativeCropSourceTag}) so two scalers of identical geometry but
1008
+ * different SOURCE format never alias the same cache slot — a scaler built for
1009
+ * three planes handed two reads garbage, silently.
935
1010
  */
936
- function nativeCropScalerKey(srcW, srcH, dstW, dstH, rgb24) {
937
- const base = `${srcW}x${srcH}->${dstW}x${dstH}`;
938
- return rgb24 ? `rgb:${base}` : base;
1011
+ function nativeCropScalerKey(srcW, srcH, dstW, dstH, srcFormat) {
1012
+ return `${nativeCropSourceTag(srcFormat)}${srcW}x${srcH}->${dstW}x${dstH}`;
939
1013
  }
940
1014
  /**
941
1015
  * Approximate resident bytes of a retained software frame, for the lease
@@ -994,31 +1068,60 @@ function resolveRgbCropSource(plane, planeStride, region) {
994
1068
  };
995
1069
  }
996
1070
  //#endregion
997
- //#region src/session-decode/decode-worker-child.ts
1071
+ //#region src/session-decode/subject-tile-cut.ts
1072
+ function failureReason(err) {
1073
+ return err instanceof Error ? err.message : String(err);
1074
+ }
998
1075
  /**
999
- * Decode-worker CHILD entry point Epic C P1, Task 2.
1076
+ * Copy every subject's pixels out of `frame`. SYNCHRONOUS on purpose — see the
1077
+ * module docblock; adding an `await` here reopens the race this closes.
1000
1078
  *
1001
- * This file is the whole program run by a per-session `child_process.fork`
1002
- * that the pipeline-runner's session-decode coordinator (Task 3) spawns and
1003
- * tears down on session end, so the OS reclaims the VAAPI/GPU decode pool by
1004
- * process exit (Task 0 live-validated that reclaim-by-exit works — node-av
1005
- * does not release the hw_frames_ctx pool on `dispose()` alone). It decodes
1006
- * with node-av in-process and hands decoded frames to its parent as a
1007
- * request/response PULL over its own dedicated fork IPC channel. Frames
1008
- * NEVER touch `/dev/shm` no shm sink is ever constructed here.
1079
+ * Liveness is re-checked before EVERY read and the loop STOPS at the first
1080
+ * failed check: once a frame has been freed, the remaining subjects are lost
1081
+ * regardless, and there is nothing to gain from asking again per subject.
1082
+ * `onFrameLost` therefore fires at most once.
1083
+ */
1084
+ function extractSubjectTiles(frame, bboxes, ports) {
1085
+ const raws = [];
1086
+ for (const bbox of bboxes) {
1087
+ if (!ports.isLive(frame)) {
1088
+ ports.onFrameLost();
1089
+ return raws;
1090
+ }
1091
+ try {
1092
+ raws.push(ports.cutRaw(frame, bbox));
1093
+ } catch (err) {
1094
+ ports.onCutFailed(failureReason(err));
1095
+ }
1096
+ }
1097
+ return raws;
1098
+ }
1099
+ /**
1100
+ * Cut and encode one frame's subject tiles.
1101
+ *
1102
+ * Two phases, and the boundary between them is the whole point: extraction
1103
+ * (synchronous, touches the frame) then encoding (async, touches only copies).
1009
1104
  *
1010
- * SANCTIONED RAW IPC READ BEFORE "FIXING" THIS FILE
1011
- * CLAUDE.md rule #8 forbids `process.send`/`process.on('message')` for
1012
- * BUSINESS LOGIC that rule governs cross-ADDON traffic, which must travel
1013
- * over the UDS cap/event channel (`LocalChildRegistry`/`LocalChildClient`)
1014
- * instead of raw IPC. This file is NOT a second addon: it is a private
1015
- * worker directly forked by the pipeline-runner addon, exactly like the
1016
- * Python inference pool's own subprocess. The Epic C P1 plan's Global
1017
- * Constraints explicitly carve this out — "a directly-forked worker child
1018
- * talks to its parent over its own IPC channel (this is NOT a cap call and
1019
- * NOT `$process`)". Raw `process.on('message')` / `process.send` are the
1020
- * correct, sanctioned mechanism for this specific file only.
1105
+ * The hold is deliberately NOT ended at that boundary, tempting as it looks: the
1106
+ * tiles are not stored until the encodes finish, so releasing the raster early
1107
+ * would open a window in which a crop request for this frame can be served by
1108
+ * neither the lease nor a tile. That window is small and the RAM it would buy
1109
+ * back is one frame for a few milliseconds a bad trade against a miss, which
1110
+ * is the failure this whole tier exists to remove. The caller releases once,
1111
+ * after the tiles are in the store.
1021
1112
  */
1113
+ async function cutSubjectTiles(frame, bboxes, ports) {
1114
+ const raws = extractSubjectTiles(frame, bboxes, ports);
1115
+ const tiles = [];
1116
+ for (const raw of raws) try {
1117
+ tiles.push(await ports.encode(raw));
1118
+ } catch (err) {
1119
+ ports.onCutFailed(failureReason(err));
1120
+ }
1121
+ return tiles;
1122
+ }
1123
+ //#endregion
1124
+ //#region src/session-decode/decode-worker-child.ts
1022
1125
  /** Wrap a software YUV420P frame as a lease entry (byte size ≈ w×h×1.5). */
1023
1126
  function toLeasedNavFrame(frame) {
1024
1127
  return {
@@ -1210,68 +1313,64 @@ function gpuScaleFilterForBackend(backend) {
1210
1313
  function pixelFilterName(format) {
1211
1314
  return format === "gray" ? "gray" : "rgb24";
1212
1315
  }
1213
- var _nav = null;
1214
- var _consts = null;
1215
1316
  /**
1216
- * Candidate file URLs for a node-av entry (`.` or `./constants`) in the
1217
- * module locations an AGENT lays node-av out in.
1317
+ * The crop layout a retained frame's `AV_PIX_FMT_*` maps to, or `null` when this
1318
+ * path cannot read it.
1218
1319
  *
1219
- * WHY: this worker runs as a plain `child_process.fork` (see `fork-decode-
1220
- * worker.ts`) which does NOT inherit the addon-runner's module resolution
1221
- * (NODE_PATH / the framework ESM hook). On the HUB the fork inherits that hook,
1222
- * so the bare `import('node-av')` resolves. On an AGENT it does not, so the
1223
- * bare import throws `Cannot find package 'node-av'` and we resolve node-av
1224
- * explicitly from the agent's known layout:
1225
- * • DOCKER agent — the framework overlay `CAMSTACK_FRAMEWORK_DIR`
1226
- * (`/data/framework/node_modules/node-av`).
1227
- * • ELECTRON agent — the app bundle beside `CAMSTACK_BUNDLED_ADDONS_DIR`
1228
- * (`<resources>/addons`): `<resources>/node_modules/node-av` (+ the
1229
- * `app.asar.unpacked` mirror).
1230
- * Both env vars point at real dirs only on the respective platform; a missing
1231
- * dir is skipped, and `[]` (dev / hub with the bare import already working)
1232
- * keeps the bare specifier the only path.
1320
+ * Only the formats the lease can actually hold are listed, and deliberately so:
1321
+ * a format nobody has seen must surface as a NAMED failure the next session can
1322
+ * act on, not as a guess that silently mis-reads chroma. `AV_PIX_FMT_NV12` is
1323
+ * here because a videotoolbox-era retained frame arrives as two planes the
1324
+ * shape that made every subject tile on dev:617 fail.
1233
1325
  */
1234
- function fallbackNodeAvUrls(subpath) {
1235
- const rel = subpath === "." ? "dist/index.js" : "dist/constants/index.js";
1236
- const dirs = [];
1237
- const frameworkDir = process.env["CAMSTACK_FRAMEWORK_DIR"];
1238
- if (frameworkDir !== void 0 && frameworkDir.length > 0) dirs.push(path$1.join(frameworkDir, "node_modules", "node-av"));
1239
- const bundledAddons = process.env["CAMSTACK_BUNDLED_ADDONS_DIR"];
1240
- if (bundledAddons !== void 0 && bundledAddons.length > 0) {
1241
- const resourcesRoot = path$1.dirname(bundledAddons);
1242
- dirs.push(path$1.join(resourcesRoot, "node_modules", "node-av"));
1243
- dirs.push(path$1.join(resourcesRoot, "app.asar.unpacked", "node_modules", "node-av"));
1244
- }
1245
- const urls = [];
1246
- for (const dir of dirs) {
1247
- const entry = path$1.join(dir, rel);
1248
- if (fs.existsSync(entry)) urls.push(pathToFileURL(entry).href);
1249
- }
1250
- return urls;
1326
+ function planarCropKindFor(format, C) {
1327
+ if (format === C.AV_PIX_FMT_YUV420P) return "yuv420p";
1328
+ if (format === C.AV_PIX_FMT_NV12) return "nv12";
1329
+ return null;
1330
+ }
1331
+ /** The `AV_PIX_FMT_*` a native-crop scaler is built with for a source layout. */
1332
+ function navPixelFormatFor(format, C) {
1333
+ switch (format) {
1334
+ case "yuv420p": return C.AV_PIX_FMT_YUV420P;
1335
+ case "nv12": return C.AV_PIX_FMT_NV12;
1336
+ case "rgb24": return C.AV_PIX_FMT_RGB24;
1337
+ }
1251
1338
  }
1339
+ var _nav = null;
1340
+ var _consts = null;
1252
1341
  /**
1253
1342
  * Import a node-av entry, falling back to the agent's explicit node-av path
1254
- * when the bare specifier can't be resolved (see {@link fallbackNodeAvUrls}).
1343
+ * when the bare specifier can't be resolved.
1344
+ *
1345
+ * WHY: this worker runs as a plain `child_process.fork` (see
1346
+ * `fork-decode-worker.ts`) which does NOT inherit the addon-runner's module
1347
+ * resolution (the framework ESM hook). On the HUB the fork inherits that hook,
1348
+ * so the bare `import('node-av')` resolves; on an AGENT it does not and the bare
1349
+ * import throws `Cannot find package 'node-av'`. The candidate layouts, and why
1350
+ * the resolver hook is not simply registered in the child instead, live in
1351
+ * `shared/host-external-import.ts`. `sharp` needs the same treatment and gets it
1352
+ * through `shared/lazy-sharp.ts` — one mechanism, two callers.
1353
+ *
1255
1354
  * Each candidate is tried in turn so a copy whose native binary fails to load
1256
1355
  * doesn't mask a working one. A dynamic `import(url)` is typed `any`, so the
1257
1356
  * caller's declared module type applies without a cast.
1258
1357
  */
1259
- async function importNodeAv(bareSpecifier, subpath) {
1358
+ async function importNodeAv(bareSpecifier) {
1260
1359
  try {
1261
1360
  return await import(bareSpecifier);
1262
1361
  } catch (bareErr) {
1263
- for (const url of fallbackNodeAvUrls(subpath)) try {
1362
+ for (const url of hostExternalEntryUrls(bareSpecifier)) try {
1264
1363
  return await import(url);
1265
1364
  } catch {}
1266
1365
  throw bareErr;
1267
1366
  }
1268
1367
  }
1269
1368
  async function getNodeAv() {
1270
- if (!_nav) _nav = await importNodeAv("node-av", ".");
1369
+ if (!_nav) _nav = await importNodeAv("node-av");
1271
1370
  return _nav;
1272
1371
  }
1273
1372
  async function getConstants() {
1274
- if (!_consts) _consts = await importNodeAv("node-av/constants", "./constants");
1373
+ if (!_consts) _consts = await importNodeAv("node-av/constants");
1275
1374
  return _consts;
1276
1375
  }
1277
1376
  function errMessage(err) {
@@ -1538,6 +1637,18 @@ var DecodeWorkerChild = class {
1538
1637
  /** One-shot, so a legitimately long session does not repeat the warning
1539
1638
  * every ten seconds. */
1540
1639
  warnedLarge = false;
1640
+ /**
1641
+ * One-shot for "there is no JPEG encoder here" — see {@link tileEncoder}. The
1642
+ * branch it guards runs once per subject per frame, which is what made the
1643
+ * un-guarded version a log flood rather than a diagnosis.
1644
+ */
1645
+ warnedTileEncoderUnavailable = false;
1646
+ /**
1647
+ * The last subject-tile-cut failure REASON logged — see
1648
+ * {@link reportTileCutFailure}. Compared, not counted: the count lives on the
1649
+ * metrics line where it can be read per device.
1650
+ */
1651
+ lastTileCutFailureReason = "";
1541
1652
  lastDeliveredSnapshot = 0;
1542
1653
  metricsTimer = null;
1543
1654
  demuxer = null;
@@ -1823,41 +1934,60 @@ var DecodeWorkerChild = class {
1823
1934
  async handleCutTiles(frameId, bboxes) {
1824
1935
  try {
1825
1936
  if (!this.tileStore.enabled || bboxes.length === 0) return;
1937
+ const wanted = bboxes.slice(0, NATIVE_TILE_MAX_PER_FRAME);
1938
+ if (bboxes.length > wanted.length) this.emitStderr(`decode-worker-child: tile cut capped at ${NATIVE_TILE_MAX_PER_FRAME} of ${bboxes.length} subjects on frame ${frameId} — the rest fall back to the ≤640 tier\n`);
1939
+ const sharp = await this.tileEncoder();
1826
1940
  const frame = this.leaseStore.get(frameId)?.frame ?? this.resolveRetainedFrame(frameId);
1827
1941
  if (!frame) {
1828
1942
  this.tileCutFailures += 1;
1829
1943
  return;
1830
1944
  }
1945
+ if (sharp === null) {
1946
+ this.tileCutFailures += wanted.length;
1947
+ return;
1948
+ }
1949
+ if (!this.isRetainedFrameLive(frame)) {
1950
+ this.tileCutFailures += wanted.length;
1951
+ this.reportTileCutFailure(frameId, "the retained frame was released before the cut");
1952
+ return;
1953
+ }
1831
1954
  this.tileFrameWidth = frame.width;
1832
1955
  this.tileFrameHeight = frame.height;
1833
- const wanted = bboxes.slice(0, NATIVE_TILE_MAX_PER_FRAME);
1834
- if (bboxes.length > wanted.length) this.emitStderr(`decode-worker-child: tile cut capped at ${NATIVE_TILE_MAX_PER_FRAME} of ${bboxes.length} subjects on frame ${frameId} — the rest fall back to the ≤640 tier\n`);
1835
- const tiles = [];
1836
- for (const bbox of wanted) {
1837
- const { region } = resolveNativeTileRegion(frame.width, frame.height, bbox, NATIVE_TILE_PADDING_RATIO);
1838
- const resolved = {
1839
- region,
1840
- target: {
1841
- width: region.width,
1842
- height: region.height
1843
- }
1844
- };
1845
- try {
1846
- const raw = frame.isHwFrame() ? this.nativeHwCropToBuffer(frame, resolved) : this.nativeScaleCropToBuffer(frame, resolved);
1847
- const jpeg = await (await getSharp())(raw, { raw: {
1848
- width: region.width,
1849
- height: region.height,
1956
+ const tiles = await cutSubjectTiles(frame, wanted, {
1957
+ isLive: (f) => this.isRetainedFrameLive(f),
1958
+ cutRaw: (f, bbox) => {
1959
+ const { region } = resolveNativeTileRegion(f.width, f.height, bbox, NATIVE_TILE_PADDING_RATIO);
1960
+ const resolved = {
1961
+ region,
1962
+ target: {
1963
+ width: region.width,
1964
+ height: region.height
1965
+ }
1966
+ };
1967
+ return {
1968
+ region,
1969
+ raw: f.isHwFrame() ? this.nativeHwCropToBuffer(f, resolved) : this.nativeScaleCropToBuffer(f, resolved)
1970
+ };
1971
+ },
1972
+ encode: async (tile) => {
1973
+ const jpeg = await sharp(tile.raw, { raw: {
1974
+ width: tile.region.width,
1975
+ height: tile.region.height,
1850
1976
  channels: 3
1851
1977
  } }).jpeg({ quality: NATIVE_TILE_JPEG_QUALITY }).toBuffer();
1852
- tiles.push({
1853
- region,
1978
+ return {
1979
+ region: tile.region,
1854
1980
  bytes: new Uint8Array(jpeg)
1855
- });
1856
- } catch (err) {
1857
- this.tileCutFailures += 1;
1858
- this.emitStderr(`decode-worker-child: subject tile cut FAILED on frame ${frameId} ${errMessage(err)}\n`);
1981
+ };
1982
+ },
1983
+ onFrameLost: () => {
1984
+ this.reportTileCutFailure(frameId, "the retained frame was released mid-cut");
1985
+ },
1986
+ onCutFailed: (reason) => {
1987
+ this.reportTileCutFailure(frameId, reason);
1859
1988
  }
1860
- }
1989
+ });
1990
+ this.tileCutFailures += wanted.length - tiles.length;
1861
1991
  if (tiles.length > 0) {
1862
1992
  this.tilesCut += tiles.length;
1863
1993
  this.tileStore.put(frameId, tiles);
@@ -1867,6 +1997,73 @@ var DecodeWorkerChild = class {
1867
1997
  }
1868
1998
  }
1869
1999
  /**
2000
+ * Is this retained frame still backed by pixels?
2001
+ *
2002
+ * A node-av frame whose `free()` has run reports `AV_PIX_FMT_NONE` and no
2003
+ * planes — that is what dev:1443 logged (`pixel format -1 (planes=0,
2004
+ * hw=false)`) when an eviction landed mid-cut. A HW frame legitimately has no
2005
+ * `data` planes, so residency is judged there by format and dimensions.
2006
+ *
2007
+ * This is an ASSERTION of the invariant `subject-tile-cut.ts` establishes by
2008
+ * ORDERING, not the mechanism that enforces it: within one synchronous turn
2009
+ * nothing can free the frame, so the check can only fail for a frame that was
2010
+ * already dead when the cut arrived. It is kept, and consulted before every
2011
+ * read, so that an edit which reintroduces an await inside the loop degrades
2012
+ * to a counted miss instead of a read of recycled memory.
2013
+ */
2014
+ isRetainedFrameLive(frame) {
2015
+ const C = this.consts;
2016
+ if (!C) return false;
2017
+ if (frame.format === C.AV_PIX_FMT_NONE) return false;
2018
+ if (frame.width <= 0 || frame.height <= 0) return false;
2019
+ if (frame.isHwFrame()) return true;
2020
+ return (frame.data?.length ?? 0) > 0;
2021
+ }
2022
+ /**
2023
+ * Report a failed subject-tile cut — on TRANSITION, not per subject.
2024
+ *
2025
+ * Every failure is COUNTED (`tileCutFailures`, on the periodic metrics line
2026
+ * per device), so the volume is never hidden; what is suppressed is only the
2027
+ * repetition of a cause already stated. A tile cut fails once per subject per
2028
+ * frame, so a standing cause — a retained format this path cannot read, which
2029
+ * is precisely what dev:617 had — writes one line per subject per frame: 533
2030
+ * identical lines that said the same thing 533 times.
2031
+ *
2032
+ * Transition rather than one-shot, mirroring {@link logColorProvenance}: a
2033
+ * SECOND, different cause is worth a line, and a cause that comes back after
2034
+ * another one intervened is a real signal. The frame id is in the message but
2035
+ * deliberately not in the comparison key — otherwise every frame is a
2036
+ * "transition" and nothing is suppressed.
2037
+ */
2038
+ reportTileCutFailure(frameId, reason) {
2039
+ if (reason === this.lastTileCutFailureReason) return;
2040
+ this.lastTileCutFailureReason = reason;
2041
+ this.emitStderr(`decode-worker-child: subject tile cut FAILED on frame ${frameId} — ${reason} (further identical failures are counted in tileCutFailures, not logged)\n`);
2042
+ }
2043
+ /**
2044
+ * The JPEG encoder for subject tiles, or `null` when `sharp` cannot be
2045
+ * resolved in this process at all.
2046
+ *
2047
+ * `getSharp()` memoizes an unresolvable `sharp` (see `shared/lazy-sharp.ts`),
2048
+ * so this costs one module-loader round trip for the worker's whole life and a
2049
+ * rejected promise thereafter. The diagnostic is one-shot for the same reason
2050
+ * the memo is: the cause cannot change under a running worker, and the failing
2051
+ * branch is per subject per frame. The loader emits its own line naming every
2052
+ * path it tried; this one names the CONSEQUENCE, which is what an operator
2053
+ * looking at a camera with no detail crops needs to read.
2054
+ */
2055
+ async tileEncoder() {
2056
+ try {
2057
+ return await getSharp();
2058
+ } catch (err) {
2059
+ if (!this.warnedTileEncoderUnavailable) {
2060
+ this.warnedTileEncoderUnavailable = true;
2061
+ this.emitStderr(`decode-worker-child: subject tile tier DISABLED — no JPEG encoder: ${errMessage(err)}\n`);
2062
+ }
2063
+ return null;
2064
+ }
2065
+ }
2066
+ /**
1870
2067
  * Serve a native crop from a retained subject TILE — the rung that covers the
1871
2068
  * ordinary case, where the request arrives seconds after the frame is gone.
1872
2069
  *
@@ -2069,30 +2266,16 @@ var DecodeWorkerChild = class {
2069
2266
  if (frame.format === C.AV_PIX_FMT_RGB24) return this.nativeRgbCropToBuffer(nav, C, frame, resolved);
2070
2267
  this.logColorProvenance("sw-crop", this.resolveFrameColor(frame));
2071
2268
  const { region, target } = resolved;
2072
- const planes = frame.data;
2073
- const strides = frame.linesize;
2074
- const yPlane = planes?.[0];
2075
- const uPlane = planes?.[1];
2076
- const vPlane = planes?.[2];
2077
- if (!yPlane || !uPlane || !vPlane) throw new Error("decode-worker-child: retained frame missing planar YUV420P data");
2078
- const yStride = strides[0] ?? 0;
2079
- const cStride = strides[1] ?? 0;
2080
- const ySlice = yPlane.subarray(region.top * yStride + region.left);
2081
- const uSlice = uPlane.subarray(region.top / 2 * cStride + region.left / 2);
2082
- const vSlice = vPlane.subarray(region.top / 2 * cStride + region.left / 2);
2083
- const scaler = this.ensureNativeCropScaler(nav, C, region.width, region.height, target.width, target.height, C.AV_PIX_FMT_YUV420P);
2269
+ const kind = planarCropKindFor(frame.format, C);
2270
+ if (kind === null) throw new Error(`decode-worker-child: retained frame in unsupported pixel format ${frame.format} (planes=${frame.data?.length ?? 0}, hw=${frame.isHwFrame()}) — cannot crop`);
2271
+ const source = resolvePlanarCropSource(kind, frame.data, frame.linesize, region);
2272
+ if (!source) throw new Error(`decode-worker-child: retained ${kind} frame missing plane data for ${region.width}x${region.height}+${region.left}+${region.top} (planes=${frame.data?.length ?? 0})`);
2273
+ const scaler = this.ensureNativeCropScaler(nav, C, region.width, region.height, target.width, target.height, kind);
2084
2274
  const tightStride = target.width * 3;
2085
2275
  const dstStride = tightStride + 63 & -64;
2086
2276
  const dstBuffer = Buffer.allocUnsafe(dstStride * target.height + 64);
2087
- const scaledHeight = scaler.scaleSync([
2088
- ySlice,
2089
- uSlice,
2090
- vSlice
2091
- ], [
2092
- yStride,
2093
- cStride,
2094
- cStride
2095
- ], 0, region.height, [dstBuffer], [dstStride]);
2277
+ const srcPlanes = source.planes.map((plane) => Buffer.from(plane.buffer, plane.byteOffset, plane.byteLength));
2278
+ const scaledHeight = scaler.scaleSync(srcPlanes, [...source.strides], 0, region.height, [dstBuffer], [dstStride]);
2096
2279
  if (scaledHeight < 0) throw new Error(`native crop sws_scale failed: ${scaledHeight}`);
2097
2280
  if (scaledHeight !== target.height) throw new Error(`native crop scaler produced ${scaledHeight} of ${target.height} rows`);
2098
2281
  if (dstStride === tightStride) return dstBuffer.subarray(0, tightStride * target.height);
@@ -2113,7 +2296,7 @@ var DecodeWorkerChild = class {
2113
2296
  if (!plane) throw new Error("decode-worker-child: retained RGB frame missing packed plane data");
2114
2297
  const { src: srcRgb, stride: srcSliceStride } = resolveRgbCropSource(plane, frame.linesize[0] ?? 0, region);
2115
2298
  const srcSlice = Buffer.from(srcRgb.buffer, srcRgb.byteOffset, srcRgb.byteLength);
2116
- const scaler = this.ensureNativeCropScaler(nav, C, region.width, region.height, target.width, target.height, C.AV_PIX_FMT_RGB24);
2299
+ const scaler = this.ensureNativeCropScaler(nav, C, region.width, region.height, target.width, target.height, "rgb24");
2117
2300
  const tightStride = target.width * 3;
2118
2301
  const dstStride = tightStride + 63 & -64;
2119
2302
  const dstBuffer = Buffer.allocUnsafe(dstStride * target.height + 64);
@@ -2149,14 +2332,16 @@ var DecodeWorkerChild = class {
2149
2332
  }
2150
2333
  /**
2151
2334
  * Build-once / reuse the dedicated native-crop software scaler (rgb out).
2152
- * `srcFmt` parameterizes the SOURCE pixel format so the scaler matches the
2153
- * retained frame: `AV_PIX_FMT_YUV420P` (flag OFF / degrade — YUV→RGB convert)
2154
- * or `AV_PIX_FMT_RGB24` (`pinnedRgbCrop`a pure RGB→RGB spatial resize). The
2155
- * cache key folds the format in so an RGB and a YUV scaler of identical
2335
+ * `srcFormat` names the SOURCE layout so the scaler matches the retained
2336
+ * frame: `yuv420p` (flag OFF / degrade — YUV→RGB convert), `nv12` (a
2337
+ * two-plane retained framelibswscale reads it natively, so this is the same
2338
+ * single pass, not a conversion) or `rgb24` (`pinnedRgbCrop` a pure RGB→RGB
2339
+ * spatial resize). The cache key folds the format in so scalers of identical
2156
2340
  * geometry never alias one slot (see {@link nativeCropScalerKey}).
2157
2341
  */
2158
- ensureNativeCropScaler(nav, C, srcW, srcH, dstW, dstH, srcFmt) {
2159
- const key = nativeCropScalerKey(srcW, srcH, dstW, dstH, srcFmt === C.AV_PIX_FMT_RGB24);
2342
+ ensureNativeCropScaler(nav, C, srcW, srcH, dstW, dstH, srcFormat) {
2343
+ const srcFmt = navPixelFormatFor(srcFormat, C);
2344
+ const key = nativeCropScalerKey(srcW, srcH, dstW, dstH, srcFormat);
2160
2345
  const cached = this.nativeCropScaler;
2161
2346
  if (cached && key === this.nativeCropScalerKey) return cached;
2162
2347
  this.nativeCropScaler?.[Symbol.dispose]?.();
@@ -2608,6 +2793,9 @@ var DecodeWorkerChild = class {
2608
2793
  }
2609
2794
  };
2610
2795
  var worker = new DecodeWorkerChild();
2796
+ setSharpWarnSink((message) => {
2797
+ worker.emitStderr(`decode-worker-child: ${message}\n`, "warn");
2798
+ });
2611
2799
  process.on("message", (message) => {
2612
2800
  worker.handleMessage(message);
2613
2801
  });