@camstack/addon-pipeline 1.2.98 → 1.2.100

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.
@@ -1374,6 +1374,17 @@ function encodeDirectory(payload) {
1374
1374
  }
1375
1375
  //#endregion
1376
1376
  //#region src/recorder/segment-store.ts
1377
+ /**
1378
+ * Below this a file cannot be a media segment: one fMP4 `moof` + `mdat`
1379
+ * carrying a single sample already exceeds this by an order of magnitude.
1380
+ * What it actually catches is the structural stub — ffmpeg opens the next
1381
+ * segment the instant it closes the previous one, so a killed writer always
1382
+ * leaves one just-opened file (observed: 28-byte bare `styp` boxes). The boot
1383
+ * recovery plans by NAME and would otherwise index it with a nominal duration,
1384
+ * painting the timeline over footage that decodes to nothing — the same
1385
+ * failure `MAX_GAP_FACTOR` in staging-reconcile exists to prevent via gaps.
1386
+ */
1387
+ var MIN_INDEXABLE_SEGMENT_BYTES = 64;
1377
1388
  var SegmentStore = class SegmentStore {
1378
1389
  deps;
1379
1390
  constructor(deps) {
@@ -1397,7 +1408,16 @@ var SegmentStore = class SegmentStore {
1397
1408
  });
1398
1409
  return null;
1399
1410
  }
1400
- if (bytes <= 0) return null;
1411
+ if (bytes < MIN_INDEXABLE_SEGMENT_BYTES) {
1412
+ this.deps.logger.warn("segment too small to index — left in staging", {
1413
+ tags: { deviceId: input.deviceId },
1414
+ meta: {
1415
+ bytes,
1416
+ path: input.flatAbsPath
1417
+ }
1418
+ });
1419
+ return null;
1420
+ }
1401
1421
  const relPath = segmentRelPath(input.deviceId, input.profile, input.startMs, input.durMs, bytes);
1402
1422
  const toAbs = `${input.locationRoot}/${relPath}`;
1403
1423
  try {
@@ -7946,12 +7966,22 @@ var RecordingController = class {
7946
7966
  }
7947
7967
  /** Stop one profile's writer + watcher and release its broker lease. */
7948
7968
  async teardownProfile(deviceId, r) {
7969
+ const teardownStartMs = Date.now();
7949
7970
  try {
7950
7971
  await r.writer.stopAndWait();
7951
7972
  } catch {}
7973
+ const writerStopMs = Date.now() - teardownStartMs;
7952
7974
  try {
7953
7975
  await r.watcher.flush?.();
7954
7976
  } catch {}
7977
+ this.deps.logger.info("recorder: profile teardown drained", {
7978
+ tags: { deviceId },
7979
+ meta: {
7980
+ profile: r.profile,
7981
+ writerStopMs,
7982
+ flushMs: Date.now() - teardownStartMs - writerStopMs
7983
+ }
7984
+ });
7955
7985
  try {
7956
7986
  r.watcher.stop();
7957
7987
  } catch {}
@@ -9306,6 +9336,7 @@ async function recoverAllStagedOrphans(deps) {
9306
9336
  return entries;
9307
9337
  };
9308
9338
  const walkStartedMs = Date.now();
9339
+ const segmentSecondsMap = await deps.segmentSecondsByDevice();
9309
9340
  const work = [];
9310
9341
  for (const location of locations) {
9311
9342
  const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
@@ -9316,7 +9347,7 @@ async function recoverAllStagedOrphans(deps) {
9316
9347
  if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
9317
9348
  const profileDirs = await listPaced(`${stagingRoot}/${deviceDir}`);
9318
9349
  if (profileDirs === null) continue;
9319
- const segmentSeconds = await deps.segmentSecondsFor(deviceId);
9350
+ const segmentSeconds = segmentSecondsMap.get(deviceId) ?? deps.defaultSegmentSeconds;
9320
9351
  for (const profile of profileDirs) {
9321
9352
  if (!KNOWN_PROFILES.has(profile)) continue;
9322
9353
  work.push({
@@ -10359,13 +10390,14 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
10359
10390
  },
10360
10391
  logger: this.ctx.logger,
10361
10392
  bootMs,
10362
- segmentSecondsFor: async (deviceId) => {
10393
+ segmentSecondsByDevice: async () => {
10394
+ const out = /* @__PURE__ */ new Map();
10363
10395
  try {
10364
- return (await loadDeviceConfig(this.configStore(), deviceId)).segmentSeconds ?? this.config.segmentSeconds;
10365
- } catch {
10366
- return this.config.segmentSeconds;
10367
- }
10396
+ for (const [id, config] of await readDeviceConfigs(this.configStore())) if (config.segmentSeconds !== void 0) out.set(id, config.segmentSeconds);
10397
+ } catch {}
10398
+ return out;
10368
10399
  },
10400
+ defaultSegmentSeconds: this.config.segmentSeconds,
10369
10401
  shouldStop: () => this.segmentStore === null
10370
10402
  });
10371
10403
  } catch (err) {
@@ -1373,6 +1373,17 @@ function encodeDirectory(payload) {
1373
1373
  }
1374
1374
  //#endregion
1375
1375
  //#region src/recorder/segment-store.ts
1376
+ /**
1377
+ * Below this a file cannot be a media segment: one fMP4 `moof` + `mdat`
1378
+ * carrying a single sample already exceeds this by an order of magnitude.
1379
+ * What it actually catches is the structural stub — ffmpeg opens the next
1380
+ * segment the instant it closes the previous one, so a killed writer always
1381
+ * leaves one just-opened file (observed: 28-byte bare `styp` boxes). The boot
1382
+ * recovery plans by NAME and would otherwise index it with a nominal duration,
1383
+ * painting the timeline over footage that decodes to nothing — the same
1384
+ * failure `MAX_GAP_FACTOR` in staging-reconcile exists to prevent via gaps.
1385
+ */
1386
+ var MIN_INDEXABLE_SEGMENT_BYTES = 64;
1376
1387
  var SegmentStore = class SegmentStore {
1377
1388
  deps;
1378
1389
  constructor(deps) {
@@ -1396,7 +1407,16 @@ var SegmentStore = class SegmentStore {
1396
1407
  });
1397
1408
  return null;
1398
1409
  }
1399
- if (bytes <= 0) return null;
1410
+ if (bytes < MIN_INDEXABLE_SEGMENT_BYTES) {
1411
+ this.deps.logger.warn("segment too small to index — left in staging", {
1412
+ tags: { deviceId: input.deviceId },
1413
+ meta: {
1414
+ bytes,
1415
+ path: input.flatAbsPath
1416
+ }
1417
+ });
1418
+ return null;
1419
+ }
1400
1420
  const relPath = segmentRelPath(input.deviceId, input.profile, input.startMs, input.durMs, bytes);
1401
1421
  const toAbs = `${input.locationRoot}/${relPath}`;
1402
1422
  try {
@@ -7945,12 +7965,22 @@ var RecordingController = class {
7945
7965
  }
7946
7966
  /** Stop one profile's writer + watcher and release its broker lease. */
7947
7967
  async teardownProfile(deviceId, r) {
7968
+ const teardownStartMs = Date.now();
7948
7969
  try {
7949
7970
  await r.writer.stopAndWait();
7950
7971
  } catch {}
7972
+ const writerStopMs = Date.now() - teardownStartMs;
7951
7973
  try {
7952
7974
  await r.watcher.flush?.();
7953
7975
  } catch {}
7976
+ this.deps.logger.info("recorder: profile teardown drained", {
7977
+ tags: { deviceId },
7978
+ meta: {
7979
+ profile: r.profile,
7980
+ writerStopMs,
7981
+ flushMs: Date.now() - teardownStartMs - writerStopMs
7982
+ }
7983
+ });
7954
7984
  try {
7955
7985
  r.watcher.stop();
7956
7986
  } catch {}
@@ -9305,6 +9335,7 @@ async function recoverAllStagedOrphans(deps) {
9305
9335
  return entries;
9306
9336
  };
9307
9337
  const walkStartedMs = Date.now();
9338
+ const segmentSecondsMap = await deps.segmentSecondsByDevice();
9308
9339
  const work = [];
9309
9340
  for (const location of locations) {
9310
9341
  const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
@@ -9315,7 +9346,7 @@ async function recoverAllStagedOrphans(deps) {
9315
9346
  if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
9316
9347
  const profileDirs = await listPaced(`${stagingRoot}/${deviceDir}`);
9317
9348
  if (profileDirs === null) continue;
9318
- const segmentSeconds = await deps.segmentSecondsFor(deviceId);
9349
+ const segmentSeconds = segmentSecondsMap.get(deviceId) ?? deps.defaultSegmentSeconds;
9319
9350
  for (const profile of profileDirs) {
9320
9351
  if (!KNOWN_PROFILES.has(profile)) continue;
9321
9352
  work.push({
@@ -10358,13 +10389,14 @@ var RecorderV2Addon = class extends BaseAddon {
10358
10389
  },
10359
10390
  logger: this.ctx.logger,
10360
10391
  bootMs,
10361
- segmentSecondsFor: async (deviceId) => {
10392
+ segmentSecondsByDevice: async () => {
10393
+ const out = /* @__PURE__ */ new Map();
10362
10394
  try {
10363
- return (await loadDeviceConfig(this.configStore(), deviceId)).segmentSeconds ?? this.config.segmentSeconds;
10364
- } catch {
10365
- return this.config.segmentSeconds;
10366
- }
10395
+ for (const [id, config] of await readDeviceConfigs(this.configStore())) if (config.segmentSeconds !== void 0) out.set(id, config.segmentSeconds);
10396
+ } catch {}
10397
+ return out;
10367
10398
  },
10399
+ defaultSegmentSeconds: this.config.segmentSeconds,
10368
10400
  shouldStop: () => this.segmentStore === null
10369
10401
  });
10370
10402
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.98",
3
+ "version": "1.2.100",
4
4
  "description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",