@camstack/addon-pipeline 1.1.65 → 1.1.67
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.
- package/dist/audio-analyzer/index.js +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +83 -22
- package/dist/detection-pipeline/index.mjs +83 -22
- package/dist/{dist-WW3gH8hF.js → dist-BxxVKcnw.js} +56 -5
- package/dist/{dist-D6tHM9GA.mjs → dist-DkGHtAaK.mjs} +56 -5
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +88 -22
- package/dist/pipeline-runner/index.mjs +88 -22
- package/dist/recorder/index.js +141 -1
- package/dist/recorder/index.mjs +141 -1
- package/dist/session-decode/decode-worker-child.js +185 -14
- package/dist/session-decode/decode-worker-child.mjs +185 -14
- package/dist/{step-definitions-CXZpwwT0.js → step-definitions-CAs0RD2N.js} +127 -8
- package/dist/{step-definitions-Dbsm64_I.mjs → step-definitions-Delhq28w.mjs} +127 -8
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CbU_HuOC.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CfFsU700.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D68Lj69v.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DlALQ_wp.mjs +26 -0
- package/dist/stream-broker/{hostInit-QaDEOY0Z.mjs → hostInit-D-S5fCDx.mjs} +2 -2
- package/dist/stream-broker/index.js +1 -1
- package/dist/stream-broker/index.mjs +1 -1
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C7H0kVAH.js → MaskShapeCanvas-DI4BY7W2-CRvzj7Of.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CHfEFlEF.js → MotionZonesSettings-NcxxQN8r-CBgvPUB-.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-Bc-trVxX.js → PrivacyMaskSettings-APgPLF7p-DH07fe1k.js} +1 -1
- package/embed-dist/assets/index-BkoPcGtQ.js +114 -0
- package/embed-dist/assets/index-D2pF9Z3W.css +2 -0
- package/embed-dist/index.html +2 -2
- package/package.json +1 -1
- package/python/inference_pool.py +392 -45
- package/python/postprocessors/softmax.py +17 -5
- package/python/postprocessors/test_softmax.py +52 -0
- package/python/test_inference_pool_ov_ppp.py +117 -0
- package/python/test_inference_pool_preprocess.py +93 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CxZvj8X1.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BzNJyeNE.mjs +0 -26
- package/embed-dist/assets/index-Bm9LaQPH.css +0 -2
- package/embed-dist/assets/index-eZGm_itM.js +0 -81
package/dist/recorder/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const require_chunk = require("../chunk-D6vf50IK.js");
|
|
2
|
-
const require_dist = require("../dist-
|
|
2
|
+
const require_dist = require("../dist-BxxVKcnw.js");
|
|
3
3
|
const require_sheet_geometry = require("../sheet-geometry-BR-ip0CP.js");
|
|
4
4
|
const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-Dv-oFwjN.js");
|
|
5
5
|
let node_child_process = require("node:child_process");
|
|
@@ -2593,6 +2593,122 @@ function makeBrokerCall(handle) {
|
|
|
2593
2593
|
return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
|
|
2594
2594
|
}
|
|
2595
2595
|
//#endregion
|
|
2596
|
+
//#region src/recorder/addon/retention-sweep.ts
|
|
2597
|
+
/**
|
|
2598
|
+
* Periodic footage-retention sweep (recording-spec §6).
|
|
2599
|
+
*
|
|
2600
|
+
* The disk-pressure eviction path only fires when a volume is TOO FULL
|
|
2601
|
+
* (`shouldSaveDiskSpace` + `minFreePercent`); it never enforces a camera's
|
|
2602
|
+
* age/size policy. This sweeper closes that gap: on an hourly cadence it prunes
|
|
2603
|
+
* footage for every device whose operator set an actionable `retention` policy,
|
|
2604
|
+
* delegating the actual selection + delete to the `recording` cap's
|
|
2605
|
+
* `pruneFootage` (which owns the retention FLOOR via `selectEvictions` — this
|
|
2606
|
+
* sweeper only decides WHICH devices to prune and WHEN).
|
|
2607
|
+
*
|
|
2608
|
+
* Owns its own timer + single-flight guard so a slow sweep is never overlapped
|
|
2609
|
+
* by the next tick — the same subsystem-owns-its-timer shape as
|
|
2610
|
+
* `ThumbnailService`. Pure over injected deps (config read + prune + logger +
|
|
2611
|
+
* clock via `setInterval`), so it is unit-testable with fake timers and a mock
|
|
2612
|
+
* `prune`. Every pass is best-effort: a per-device prune failure is logged and
|
|
2613
|
+
* never aborts the rest, and the sweep never throws.
|
|
2614
|
+
*/
|
|
2615
|
+
/**
|
|
2616
|
+
* True when the operator set an ACTIONABLE retention policy on a device — at
|
|
2617
|
+
* least one of `maxAgeDays` / `maxSizeGb` is present and positive. Mirrors the
|
|
2618
|
+
* `> 0` guards `selectEvictions` itself applies, so a `0`/absent limit (which
|
|
2619
|
+
* inherits the node default = "keep forever") is treated as NO policy and the
|
|
2620
|
+
* device is skipped. NEVER prune a device without an explicit policy — that
|
|
2621
|
+
* would delete footage the operator never asked to age out.
|
|
2622
|
+
*/
|
|
2623
|
+
function hasRetentionPolicy(config) {
|
|
2624
|
+
const retention = config.retention;
|
|
2625
|
+
if (!retention) return false;
|
|
2626
|
+
const byAge = retention.maxAgeDays != null && retention.maxAgeDays > 0;
|
|
2627
|
+
const bySize = retention.maxSizeGb != null && retention.maxSizeGb > 0;
|
|
2628
|
+
return byAge || bySize;
|
|
2629
|
+
}
|
|
2630
|
+
var RetentionSweeper = class {
|
|
2631
|
+
deps;
|
|
2632
|
+
timer = null;
|
|
2633
|
+
/** Single-flight guard: true while a sweep is in flight. */
|
|
2634
|
+
sweeping = false;
|
|
2635
|
+
constructor(deps) {
|
|
2636
|
+
this.deps = deps;
|
|
2637
|
+
}
|
|
2638
|
+
/**
|
|
2639
|
+
* Arm the sweep: run immediately, then on the interval. Idempotent — a second
|
|
2640
|
+
* `start()` while already armed is a no-op. The timer is `unref`'d so it never
|
|
2641
|
+
* keeps the process alive.
|
|
2642
|
+
*/
|
|
2643
|
+
start() {
|
|
2644
|
+
if (this.timer !== null) return;
|
|
2645
|
+
const tick = () => {
|
|
2646
|
+
this.sweep();
|
|
2647
|
+
};
|
|
2648
|
+
tick();
|
|
2649
|
+
this.timer = setInterval(tick, this.deps.intervalMs);
|
|
2650
|
+
this.timer.unref?.();
|
|
2651
|
+
}
|
|
2652
|
+
/** Stop the sweep timer. Idempotent. An in-flight sweep still completes. */
|
|
2653
|
+
stop() {
|
|
2654
|
+
if (this.timer !== null) {
|
|
2655
|
+
clearInterval(this.timer);
|
|
2656
|
+
this.timer = null;
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
/**
|
|
2660
|
+
* One retention pass. Single-flighted: a call that fires while a previous
|
|
2661
|
+
* sweep is still running is a no-op. Never throws — a config-read failure
|
|
2662
|
+
* aborts THIS pass (logged); a per-device prune failure is logged and the
|
|
2663
|
+
* sweep continues with the remaining devices.
|
|
2664
|
+
*/
|
|
2665
|
+
async sweep() {
|
|
2666
|
+
if (this.sweeping) {
|
|
2667
|
+
this.deps.logger.debug("recorder: retention sweep already running — skipping this tick");
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
this.sweeping = true;
|
|
2671
|
+
try {
|
|
2672
|
+
let configs;
|
|
2673
|
+
try {
|
|
2674
|
+
configs = await this.deps.readConfigs();
|
|
2675
|
+
} catch (err) {
|
|
2676
|
+
this.deps.logger.warn("recorder: retention sweep could not read device configs", { meta: { error: require_dist.errMsg(err) } });
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
let devicesSwept = 0;
|
|
2680
|
+
let devicesSkipped = 0;
|
|
2681
|
+
let bucketsPruned = 0;
|
|
2682
|
+
let bytesReclaimed = 0;
|
|
2683
|
+
for (const [deviceId, config] of configs) {
|
|
2684
|
+
if (!hasRetentionPolicy(config)) {
|
|
2685
|
+
devicesSkipped += 1;
|
|
2686
|
+
continue;
|
|
2687
|
+
}
|
|
2688
|
+
try {
|
|
2689
|
+
const result = await this.deps.prune(deviceId);
|
|
2690
|
+
devicesSwept += 1;
|
|
2691
|
+
bucketsPruned += result.deletedBuckets;
|
|
2692
|
+
bytesReclaimed += result.reclaimedBytes;
|
|
2693
|
+
} catch (err) {
|
|
2694
|
+
this.deps.logger.warn("recorder: retention prune failed for device", {
|
|
2695
|
+
tags: { deviceId },
|
|
2696
|
+
meta: { error: require_dist.errMsg(err) }
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
this.deps.logger.info("recorder: retention sweep complete", { meta: {
|
|
2701
|
+
devicesSwept,
|
|
2702
|
+
devicesSkipped,
|
|
2703
|
+
bucketsPruned,
|
|
2704
|
+
bytesReclaimed
|
|
2705
|
+
} });
|
|
2706
|
+
} finally {
|
|
2707
|
+
this.sweeping = false;
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
};
|
|
2711
|
+
//#endregion
|
|
2596
2712
|
//#region src/recorder/addon/recording-hub-host.ts
|
|
2597
2713
|
/**
|
|
2598
2714
|
* Resolve the host a REMOTE recording node should use to dial the hub-only
|
|
@@ -3599,6 +3715,12 @@ var PLAYBACK_PREFIX = "playback";
|
|
|
3599
3715
|
var EXPORTS_PREFIX = "exports";
|
|
3600
3716
|
/** How often the export janitor sweeps expired exports. */
|
|
3601
3717
|
var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
3718
|
+
/** How often the retention sweep prunes footage past each camera's per-device
|
|
3719
|
+
* age/size policy. Retention is NOT time-critical (footage ages out over days,
|
|
3720
|
+
* and the disk-pressure eviction path already bounds the worst case), so an
|
|
3721
|
+
* hourly cadence keeps the storage.list/evict cost negligible while honouring
|
|
3722
|
+
* the recording-spec §6 periodic retention. */
|
|
3723
|
+
var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
3602
3724
|
/** Grace after a completed download before a `deleteAfterDownload` file is removed. */
|
|
3603
3725
|
var EXPORT_DELETE_GRACE_MS = 6e4;
|
|
3604
3726
|
/** Only `.mp4` export ids matching this shape are servable (traversal guard). */
|
|
@@ -3634,6 +3756,12 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3634
3756
|
exportEngine = null;
|
|
3635
3757
|
/** Periodic export-expiry sweep timer. Cleared on shutdown. */
|
|
3636
3758
|
exportSweepTimer = null;
|
|
3759
|
+
/** The `recording` cap provider — retained so the periodic retention sweep can
|
|
3760
|
+
* call its `pruneFootage`. Null until built in `onInitialize`. */
|
|
3761
|
+
recordingProvider = null;
|
|
3762
|
+
/** Periodic footage-retention sweep (owns its timer + single-flight guard).
|
|
3763
|
+
* Constructed + started in `onHubReachable`, stopped on shutdown. */
|
|
3764
|
+
retentionSweeper = null;
|
|
3637
3765
|
constructor() {
|
|
3638
3766
|
super({ ...DEFAULT_CONFIG });
|
|
3639
3767
|
}
|
|
@@ -3742,6 +3870,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3742
3870
|
}
|
|
3743
3871
|
};
|
|
3744
3872
|
const recordingProvider = buildRecordingProvider(recordingDeps);
|
|
3873
|
+
this.recordingProvider = recordingProvider;
|
|
3745
3874
|
const evictableProvider = new StorageEvictableProvider({
|
|
3746
3875
|
index: this.index,
|
|
3747
3876
|
store: this.segmentStore
|
|
@@ -3839,7 +3968,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3839
3968
|
clearInterval(this.exportSweepTimer);
|
|
3840
3969
|
this.exportSweepTimer = null;
|
|
3841
3970
|
}
|
|
3971
|
+
this.retentionSweeper?.stop();
|
|
3972
|
+
this.retentionSweeper = null;
|
|
3842
3973
|
this.exportEngine = null;
|
|
3974
|
+
this.recordingProvider = null;
|
|
3843
3975
|
}
|
|
3844
3976
|
/**
|
|
3845
3977
|
* Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
|
|
@@ -3898,6 +4030,14 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
3898
4030
|
this.ctx.logger.warn("recorder: thumbnail service start failed", { meta: { error: require_dist.errMsg(err) } });
|
|
3899
4031
|
}
|
|
3900
4032
|
await this.bootstrapExports();
|
|
4033
|
+
const provider = this.recordingProvider;
|
|
4034
|
+
if (provider && this.retentionSweeper === null) this.retentionSweeper = new RetentionSweeper({
|
|
4035
|
+
prune: (deviceId) => provider.pruneFootage({ deviceId }),
|
|
4036
|
+
readConfigs: () => readDeviceConfigs(this.configStore()),
|
|
4037
|
+
logger: this.ctx.logger,
|
|
4038
|
+
intervalMs: RETENTION_SWEEP_INTERVAL_MS
|
|
4039
|
+
});
|
|
4040
|
+
this.retentionSweeper?.start();
|
|
3901
4041
|
}
|
|
3902
4042
|
/**
|
|
3903
4043
|
* Resume interrupted exports and arm the expiry sweep. A row left `rendering`
|
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { B as errMsg, D as migrateConfigToBands, I as storageEvictableCapability, K as hydrateSchema, M as recordingCapability, N as recordingExportCapability, P as resolveScrubThumbnailGeometry, Q as selectAssignedProfileSlots, V as BaseAddon, W as DeviceType, ct as record, d as OpsLogEntrySchema, dt as EventCategory, f as RecordingConfigSchema, k as nodePin, l as ExportRecordSchema, lt as string, q as isEvent, s as EVENT_PAD_MS, tt as array } from "../dist-
|
|
1
|
+
import { B as errMsg, D as migrateConfigToBands, I as storageEvictableCapability, K as hydrateSchema, M as recordingCapability, N as recordingExportCapability, P as resolveScrubThumbnailGeometry, Q as selectAssignedProfileSlots, V as BaseAddon, W as DeviceType, ct as record, d as OpsLogEntrySchema, dt as EventCategory, f as RecordingConfigSchema, k as nodePin, l as ExportRecordSchema, lt as string, q as isEvent, s as EVENT_PAD_MS, tt as array } from "../dist-DkGHtAaK.mjs";
|
|
2
2
|
import { n as computeSheetGeometry, r as resolveHubHostname, t as allTilePlacements } from "../sheet-geometry-DcpuGTCm.mjs";
|
|
3
3
|
import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
@@ -2590,6 +2590,122 @@ function makeBrokerCall(handle) {
|
|
|
2590
2590
|
return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
|
|
2591
2591
|
}
|
|
2592
2592
|
//#endregion
|
|
2593
|
+
//#region src/recorder/addon/retention-sweep.ts
|
|
2594
|
+
/**
|
|
2595
|
+
* Periodic footage-retention sweep (recording-spec §6).
|
|
2596
|
+
*
|
|
2597
|
+
* The disk-pressure eviction path only fires when a volume is TOO FULL
|
|
2598
|
+
* (`shouldSaveDiskSpace` + `minFreePercent`); it never enforces a camera's
|
|
2599
|
+
* age/size policy. This sweeper closes that gap: on an hourly cadence it prunes
|
|
2600
|
+
* footage for every device whose operator set an actionable `retention` policy,
|
|
2601
|
+
* delegating the actual selection + delete to the `recording` cap's
|
|
2602
|
+
* `pruneFootage` (which owns the retention FLOOR via `selectEvictions` — this
|
|
2603
|
+
* sweeper only decides WHICH devices to prune and WHEN).
|
|
2604
|
+
*
|
|
2605
|
+
* Owns its own timer + single-flight guard so a slow sweep is never overlapped
|
|
2606
|
+
* by the next tick — the same subsystem-owns-its-timer shape as
|
|
2607
|
+
* `ThumbnailService`. Pure over injected deps (config read + prune + logger +
|
|
2608
|
+
* clock via `setInterval`), so it is unit-testable with fake timers and a mock
|
|
2609
|
+
* `prune`. Every pass is best-effort: a per-device prune failure is logged and
|
|
2610
|
+
* never aborts the rest, and the sweep never throws.
|
|
2611
|
+
*/
|
|
2612
|
+
/**
|
|
2613
|
+
* True when the operator set an ACTIONABLE retention policy on a device — at
|
|
2614
|
+
* least one of `maxAgeDays` / `maxSizeGb` is present and positive. Mirrors the
|
|
2615
|
+
* `> 0` guards `selectEvictions` itself applies, so a `0`/absent limit (which
|
|
2616
|
+
* inherits the node default = "keep forever") is treated as NO policy and the
|
|
2617
|
+
* device is skipped. NEVER prune a device without an explicit policy — that
|
|
2618
|
+
* would delete footage the operator never asked to age out.
|
|
2619
|
+
*/
|
|
2620
|
+
function hasRetentionPolicy(config) {
|
|
2621
|
+
const retention = config.retention;
|
|
2622
|
+
if (!retention) return false;
|
|
2623
|
+
const byAge = retention.maxAgeDays != null && retention.maxAgeDays > 0;
|
|
2624
|
+
const bySize = retention.maxSizeGb != null && retention.maxSizeGb > 0;
|
|
2625
|
+
return byAge || bySize;
|
|
2626
|
+
}
|
|
2627
|
+
var RetentionSweeper = class {
|
|
2628
|
+
deps;
|
|
2629
|
+
timer = null;
|
|
2630
|
+
/** Single-flight guard: true while a sweep is in flight. */
|
|
2631
|
+
sweeping = false;
|
|
2632
|
+
constructor(deps) {
|
|
2633
|
+
this.deps = deps;
|
|
2634
|
+
}
|
|
2635
|
+
/**
|
|
2636
|
+
* Arm the sweep: run immediately, then on the interval. Idempotent — a second
|
|
2637
|
+
* `start()` while already armed is a no-op. The timer is `unref`'d so it never
|
|
2638
|
+
* keeps the process alive.
|
|
2639
|
+
*/
|
|
2640
|
+
start() {
|
|
2641
|
+
if (this.timer !== null) return;
|
|
2642
|
+
const tick = () => {
|
|
2643
|
+
this.sweep();
|
|
2644
|
+
};
|
|
2645
|
+
tick();
|
|
2646
|
+
this.timer = setInterval(tick, this.deps.intervalMs);
|
|
2647
|
+
this.timer.unref?.();
|
|
2648
|
+
}
|
|
2649
|
+
/** Stop the sweep timer. Idempotent. An in-flight sweep still completes. */
|
|
2650
|
+
stop() {
|
|
2651
|
+
if (this.timer !== null) {
|
|
2652
|
+
clearInterval(this.timer);
|
|
2653
|
+
this.timer = null;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
/**
|
|
2657
|
+
* One retention pass. Single-flighted: a call that fires while a previous
|
|
2658
|
+
* sweep is still running is a no-op. Never throws — a config-read failure
|
|
2659
|
+
* aborts THIS pass (logged); a per-device prune failure is logged and the
|
|
2660
|
+
* sweep continues with the remaining devices.
|
|
2661
|
+
*/
|
|
2662
|
+
async sweep() {
|
|
2663
|
+
if (this.sweeping) {
|
|
2664
|
+
this.deps.logger.debug("recorder: retention sweep already running — skipping this tick");
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
this.sweeping = true;
|
|
2668
|
+
try {
|
|
2669
|
+
let configs;
|
|
2670
|
+
try {
|
|
2671
|
+
configs = await this.deps.readConfigs();
|
|
2672
|
+
} catch (err) {
|
|
2673
|
+
this.deps.logger.warn("recorder: retention sweep could not read device configs", { meta: { error: errMsg(err) } });
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2676
|
+
let devicesSwept = 0;
|
|
2677
|
+
let devicesSkipped = 0;
|
|
2678
|
+
let bucketsPruned = 0;
|
|
2679
|
+
let bytesReclaimed = 0;
|
|
2680
|
+
for (const [deviceId, config] of configs) {
|
|
2681
|
+
if (!hasRetentionPolicy(config)) {
|
|
2682
|
+
devicesSkipped += 1;
|
|
2683
|
+
continue;
|
|
2684
|
+
}
|
|
2685
|
+
try {
|
|
2686
|
+
const result = await this.deps.prune(deviceId);
|
|
2687
|
+
devicesSwept += 1;
|
|
2688
|
+
bucketsPruned += result.deletedBuckets;
|
|
2689
|
+
bytesReclaimed += result.reclaimedBytes;
|
|
2690
|
+
} catch (err) {
|
|
2691
|
+
this.deps.logger.warn("recorder: retention prune failed for device", {
|
|
2692
|
+
tags: { deviceId },
|
|
2693
|
+
meta: { error: errMsg(err) }
|
|
2694
|
+
});
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
this.deps.logger.info("recorder: retention sweep complete", { meta: {
|
|
2698
|
+
devicesSwept,
|
|
2699
|
+
devicesSkipped,
|
|
2700
|
+
bucketsPruned,
|
|
2701
|
+
bytesReclaimed
|
|
2702
|
+
} });
|
|
2703
|
+
} finally {
|
|
2704
|
+
this.sweeping = false;
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
};
|
|
2708
|
+
//#endregion
|
|
2593
2709
|
//#region src/recorder/addon/recording-hub-host.ts
|
|
2594
2710
|
/**
|
|
2595
2711
|
* Resolve the host a REMOTE recording node should use to dial the hub-only
|
|
@@ -3596,6 +3712,12 @@ var PLAYBACK_PREFIX = "playback";
|
|
|
3596
3712
|
var EXPORTS_PREFIX = "exports";
|
|
3597
3713
|
/** How often the export janitor sweeps expired exports. */
|
|
3598
3714
|
var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
3715
|
+
/** How often the retention sweep prunes footage past each camera's per-device
|
|
3716
|
+
* age/size policy. Retention is NOT time-critical (footage ages out over days,
|
|
3717
|
+
* and the disk-pressure eviction path already bounds the worst case), so an
|
|
3718
|
+
* hourly cadence keeps the storage.list/evict cost negligible while honouring
|
|
3719
|
+
* the recording-spec §6 periodic retention. */
|
|
3720
|
+
var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
3599
3721
|
/** Grace after a completed download before a `deleteAfterDownload` file is removed. */
|
|
3600
3722
|
var EXPORT_DELETE_GRACE_MS = 6e4;
|
|
3601
3723
|
/** Only `.mp4` export ids matching this shape are servable (traversal guard). */
|
|
@@ -3631,6 +3753,12 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
3631
3753
|
exportEngine = null;
|
|
3632
3754
|
/** Periodic export-expiry sweep timer. Cleared on shutdown. */
|
|
3633
3755
|
exportSweepTimer = null;
|
|
3756
|
+
/** The `recording` cap provider — retained so the periodic retention sweep can
|
|
3757
|
+
* call its `pruneFootage`. Null until built in `onInitialize`. */
|
|
3758
|
+
recordingProvider = null;
|
|
3759
|
+
/** Periodic footage-retention sweep (owns its timer + single-flight guard).
|
|
3760
|
+
* Constructed + started in `onHubReachable`, stopped on shutdown. */
|
|
3761
|
+
retentionSweeper = null;
|
|
3634
3762
|
constructor() {
|
|
3635
3763
|
super({ ...DEFAULT_CONFIG });
|
|
3636
3764
|
}
|
|
@@ -3739,6 +3867,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
3739
3867
|
}
|
|
3740
3868
|
};
|
|
3741
3869
|
const recordingProvider = buildRecordingProvider(recordingDeps);
|
|
3870
|
+
this.recordingProvider = recordingProvider;
|
|
3742
3871
|
const evictableProvider = new StorageEvictableProvider({
|
|
3743
3872
|
index: this.index,
|
|
3744
3873
|
store: this.segmentStore
|
|
@@ -3836,7 +3965,10 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
3836
3965
|
clearInterval(this.exportSweepTimer);
|
|
3837
3966
|
this.exportSweepTimer = null;
|
|
3838
3967
|
}
|
|
3968
|
+
this.retentionSweeper?.stop();
|
|
3969
|
+
this.retentionSweeper = null;
|
|
3839
3970
|
this.exportEngine = null;
|
|
3971
|
+
this.recordingProvider = null;
|
|
3840
3972
|
}
|
|
3841
3973
|
/**
|
|
3842
3974
|
* Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
|
|
@@ -3895,6 +4027,14 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
3895
4027
|
this.ctx.logger.warn("recorder: thumbnail service start failed", { meta: { error: errMsg(err) } });
|
|
3896
4028
|
}
|
|
3897
4029
|
await this.bootstrapExports();
|
|
4030
|
+
const provider = this.recordingProvider;
|
|
4031
|
+
if (provider && this.retentionSweeper === null) this.retentionSweeper = new RetentionSweeper({
|
|
4032
|
+
prune: (deviceId) => provider.pruneFootage({ deviceId }),
|
|
4033
|
+
readConfigs: () => readDeviceConfigs(this.configStore()),
|
|
4034
|
+
logger: this.ctx.logger,
|
|
4035
|
+
intervalMs: RETENTION_SWEEP_INTERVAL_MS
|
|
4036
|
+
});
|
|
4037
|
+
this.retentionSweeper?.start();
|
|
3898
4038
|
}
|
|
3899
4039
|
/**
|
|
3900
4040
|
* Resume interrupted exports and arm the expiry sweep. A row left `rendering`
|
|
@@ -6,6 +6,133 @@ let node_fs = require("node:fs");
|
|
|
6
6
|
node_fs = require_chunk.__toESM(node_fs);
|
|
7
7
|
let node_path = require("node:path");
|
|
8
8
|
node_path = require_chunk.__toESM(node_path);
|
|
9
|
+
//#region src/session-decode/color-conversion.ts
|
|
10
|
+
/**
|
|
11
|
+
* Explicit YUV→RGB colorspace/range resolution for the session-decode worker.
|
|
12
|
+
*
|
|
13
|
+
* WHY THIS EXISTS (RC-5, `docs/superpowers/specs/2026-07-19-track-media-bugs-analysis.md`):
|
|
14
|
+
* an intermittent ~one-GOP DESATURATED stretch was measured on cam 615 — the
|
|
15
|
+
* classic "limited-range YUV interpreted as full-range" signature (lifted
|
|
16
|
+
* blacks, washed color, JPEG size collapse). Root cause: NONE of the decode
|
|
17
|
+
* worker's YUV→RGB conversion sites pinned an explicit colorspace/range, so the
|
|
18
|
+
* conversion depended on whatever `colorspace`/`color_range` metadata each
|
|
19
|
+
* decoded frame happened to carry. A GOP replayed/decoded by a DIFFERENT path
|
|
20
|
+
* (hwaccel-cascade / keyframe-seed fallback, see `docs/design/decode-path.md`)
|
|
21
|
+
* can carry UNSPECIFIED (or a different) range, which the libav auto-inserted
|
|
22
|
+
* converter then guesses differently than its neighbours → one washed-out GOP,
|
|
23
|
+
* then a snap back.
|
|
24
|
+
*
|
|
25
|
+
* The fix is to resolve every frame's source color to an EXPLICIT
|
|
26
|
+
* `(matrix, range)` — using the frame's own tags when present, and a STABLE
|
|
27
|
+
* default (bt709 / limited, the typical H.264/H.265 HD signalling) when they
|
|
28
|
+
* are unspecified — and to pin the conversion to that + full-range RGB output
|
|
29
|
+
* IDENTICALLY at every site. An unspecified/fallback GOP then converts exactly
|
|
30
|
+
* like its neighbours instead of being auto-guessed, so the desaturation cannot
|
|
31
|
+
* recur regardless of which decode path produced the GOP.
|
|
32
|
+
*
|
|
33
|
+
* These functions operate on the raw FFmpeg `AVColorSpace` / `AVColorRange`
|
|
34
|
+
* enum integers (stable ABI values) so they are unit-testable without loading
|
|
35
|
+
* node-av. node-av's branded `AVColorSpace`/`AVColorRange` are `number`
|
|
36
|
+
* subtypes and pass through directly.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* FFmpeg `AVColorSpace` enum values we map to a libswscale/`scale`-filter
|
|
40
|
+
* `in_color_matrix` token. Stable ABI integers (libavutil `pixfmt.h`).
|
|
41
|
+
*/
|
|
42
|
+
var AVCOL_SPC_BT709 = 1;
|
|
43
|
+
var AVCOL_SPC_FCC = 4;
|
|
44
|
+
var AVCOL_SPC_BT470BG = 5;
|
|
45
|
+
var AVCOL_SPC_SMPTE170M = 6;
|
|
46
|
+
var AVCOL_SPC_SMPTE240M = 7;
|
|
47
|
+
var AVCOL_SPC_BT2020_NCL = 9;
|
|
48
|
+
var AVCOL_SPC_BT2020_CL = 10;
|
|
49
|
+
/** FFmpeg `AVColorRange` enum values. Stable ABI integers. */
|
|
50
|
+
var AVCOL_RANGE_MPEG = 1;
|
|
51
|
+
var AVCOL_RANGE_JPEG = 2;
|
|
52
|
+
/**
|
|
53
|
+
* Default source matrix when the frame carries no (or an unusable) colorspace
|
|
54
|
+
* tag — bt709, the near-universal signalling for HD H.264/H.265 IP cameras.
|
|
55
|
+
*/
|
|
56
|
+
var DEFAULT_COLOR_MATRIX = "bt709";
|
|
57
|
+
/**
|
|
58
|
+
* Default source range when the frame carries no (or an unspecified) range tag
|
|
59
|
+
* — limited/mpeg, the near-universal signalling for camera video. Guessing
|
|
60
|
+
* full here is the exact mistake that produced the desaturated GOP.
|
|
61
|
+
*/
|
|
62
|
+
var DEFAULT_COLOR_RANGE = "limited";
|
|
63
|
+
/**
|
|
64
|
+
* Output RGB range — always FULL (0-255). Every RGB conversion site converges
|
|
65
|
+
* on this so downstream media/inference sees a single, path-independent RGB
|
|
66
|
+
* contract.
|
|
67
|
+
*/
|
|
68
|
+
var OUTPUT_RGB_RANGE = "full";
|
|
69
|
+
/** Map an `AVColorSpace` enum integer to a `scale`-filter matrix token. */
|
|
70
|
+
function resolveColorMatrix(colorSpace) {
|
|
71
|
+
switch (colorSpace) {
|
|
72
|
+
case AVCOL_SPC_BT709: return "bt709";
|
|
73
|
+
case AVCOL_SPC_BT470BG:
|
|
74
|
+
case AVCOL_SPC_SMPTE170M: return "bt601";
|
|
75
|
+
case AVCOL_SPC_FCC: return "fcc";
|
|
76
|
+
case AVCOL_SPC_SMPTE240M: return "smpte240m";
|
|
77
|
+
case AVCOL_SPC_BT2020_NCL:
|
|
78
|
+
case AVCOL_SPC_BT2020_CL: return "bt2020nc";
|
|
79
|
+
default: return DEFAULT_COLOR_MATRIX;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Map an `AVColorRange` enum integer to a `scale`-filter range token. */
|
|
83
|
+
function resolveColorRange(colorRange) {
|
|
84
|
+
switch (colorRange) {
|
|
85
|
+
case AVCOL_RANGE_JPEG: return "full";
|
|
86
|
+
case AVCOL_RANGE_MPEG: return "limited";
|
|
87
|
+
default: return DEFAULT_COLOR_RANGE;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a frame's `(colorSpace, colorRange)` tags to the explicit
|
|
92
|
+
* `(matrix, range)` every conversion site pins — applying the stable defaults
|
|
93
|
+
* when a tag is unspecified/unusable.
|
|
94
|
+
*/
|
|
95
|
+
function resolveSourceColor(colorSpace, colorRange) {
|
|
96
|
+
return {
|
|
97
|
+
inColorMatrix: resolveColorMatrix(colorSpace),
|
|
98
|
+
inRange: resolveColorRange(colorRange),
|
|
99
|
+
colorSpaceDefaulted: !(colorSpace === AVCOL_SPC_BT709 || colorSpace === AVCOL_SPC_BT470BG || colorSpace === AVCOL_SPC_SMPTE170M || colorSpace === AVCOL_SPC_FCC || colorSpace === AVCOL_SPC_SMPTE240M || colorSpace === AVCOL_SPC_BT2020_NCL || colorSpace === AVCOL_SPC_BT2020_CL),
|
|
100
|
+
colorRangeDefaulted: !(colorRange === AVCOL_RANGE_JPEG || colorRange === AVCOL_RANGE_MPEG),
|
|
101
|
+
rawColorSpace: colorSpace,
|
|
102
|
+
rawColorRange: colorRange
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The `scale`-filter option fragment that pins the explicit YUV→(full)RGB
|
|
107
|
+
* conversion — appended to a `scale=...` filter in the GPU/CPU filtergraph
|
|
108
|
+
* (`scale=W:H:<fragment>` or `scale=<fragment>` for an identity-size convert).
|
|
109
|
+
* Always converges output on full-range RGB; `out_color_matrix` is intentionally
|
|
110
|
+
* omitted (irrelevant for an RGB/gray packed output).
|
|
111
|
+
*/
|
|
112
|
+
function colorConvertFilterOptions(resolved) {
|
|
113
|
+
return `in_color_matrix=${resolved.inColorMatrix}:in_range=${resolved.inRange}:out_range=${OUTPUT_RGB_RANGE}`;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A stable cache key for the resolved source color — folded into the GPU
|
|
117
|
+
* filtergraph key so a mid-session colorspace/range change rebuilds the graph
|
|
118
|
+
* (and re-emits the provenance transition marker) instead of reusing a graph
|
|
119
|
+
* pinned to the old color.
|
|
120
|
+
*/
|
|
121
|
+
function sourceColorKey(resolved) {
|
|
122
|
+
return `${resolved.inColorMatrix}:${resolved.inRange}`;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* One-line provenance marker: which decode/scale path produced a frame + the
|
|
126
|
+
* (raw and resolved) source color it was converted with. Emitted on a color
|
|
127
|
+
* TRANSITION (rare, diagnostic-gold: pinpoints an intermittent recurrence) and,
|
|
128
|
+
* behind the debug flag, per frame. Includes a trailing newline for `emitStderr`.
|
|
129
|
+
*/
|
|
130
|
+
function colorProvenanceLine(path, resolved, transition) {
|
|
131
|
+
const spaceTag = resolved.colorSpaceDefaulted ? `${resolved.rawColorSpace}->${resolved.inColorMatrix}(default)` : `${resolved.rawColorSpace}->${resolved.inColorMatrix}`;
|
|
132
|
+
const rangeTag = resolved.colorRangeDefaulted ? `${resolved.rawColorRange}->${resolved.inRange}(default)` : `${resolved.rawColorRange}->${resolved.inRange}`;
|
|
133
|
+
return `session-decode color${transition ? " TRANSITION" : ""} {path:${path}, colorSpace:${spaceTag}, colorRange:${rangeTag}, out:rgb-${OUTPUT_RGB_RANGE}}\n`;
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
9
136
|
//#region src/session-decode/crop-geometry.ts
|
|
10
137
|
/** Clamp to [min,max] then floor to the nearest even number (YUV420P chroma is 2x2 subsampled). */
|
|
11
138
|
function clampEven(value, min, max) {
|
|
@@ -460,12 +587,18 @@ var NATIVE_LEASE_BUDGET_BYTES = (() => {
|
|
|
460
587
|
/**
|
|
461
588
|
* TTL (ms) after which a native-frame lease is treated as a miss and reclaimed.
|
|
462
589
|
* The backstop that bounds in-flight RAM even if the explicit `releaseNativeLease`
|
|
463
|
-
* is dropped
|
|
464
|
-
*
|
|
590
|
+
* is dropped. Must comfortably exceed the FULL late-crop horizon: detection
|
|
591
|
+
* inference + the cross-process inference-result hop to hub post-analysis +
|
|
592
|
+
* tracking + the tRPC crop round-trip back — on a busy camera with many
|
|
593
|
+
* concurrent tracks that is well past the old 500 ms (which the busiest cameras'
|
|
594
|
+
* subject crops outran, missing to the ≤640 fallback). Raised to 1200 ms; still
|
|
595
|
+
* bounded by the per-session {@link NATIVE_LEASE_BUDGET_BYTES} ceiling and the
|
|
596
|
+
* {@link NATIVE_LEASE_ACTIVITY_WINDOW_MS} demand gate, so idle cameras retain
|
|
597
|
+
* nothing. `CAMSTACK_SESSION_NATIVE_LEASE_TTL_MS` (default 1200).
|
|
465
598
|
*/
|
|
466
599
|
var NATIVE_LEASE_TTL_MS = (() => {
|
|
467
600
|
const raw = Number(process.env["CAMSTACK_SESSION_NATIVE_LEASE_TTL_MS"]);
|
|
468
|
-
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) :
|
|
601
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 1200;
|
|
469
602
|
})();
|
|
470
603
|
/**
|
|
471
604
|
* Demand window (ms) for the lease-capture {@link LeaseActivityGate}: eager
|
|
@@ -632,9 +765,10 @@ function backendToHwDeviceConst(backend, consts) {
|
|
|
632
765
|
* It is correct and never crashes; it just forgoes the GPU win for that rare
|
|
633
766
|
* call — exactly the safety/behaviour trade-off the task calls for.
|
|
634
767
|
*/
|
|
635
|
-
function buildGpuFilterDescription(scaleFilter, region, target, fullFrame, pixel) {
|
|
636
|
-
|
|
637
|
-
return
|
|
768
|
+
function buildGpuFilterDescription(scaleFilter, region, target, fullFrame, pixel, color) {
|
|
769
|
+
const convert = `scale=${colorConvertFilterOptions(color)}`;
|
|
770
|
+
if (fullFrame) return `${scaleFilter}=w=${target.width}:h=${target.height},hwdownload,format=nv12,${convert},format=${pixel}`;
|
|
771
|
+
return `hwdownload,format=nv12,crop=${region.width}:${region.height}:${region.left}:${region.top},scale=${target.width}:${target.height}:${colorConvertFilterOptions(color)},format=${pixel}`;
|
|
638
772
|
}
|
|
639
773
|
/**
|
|
640
774
|
* Copy a single-plane packed frame (rgb24 / gray8 out of the filtergraph) into
|
|
@@ -765,6 +899,15 @@ var DecodeWorkerChild = class {
|
|
|
765
899
|
/** Throttled native-crop hit/miss counters, emitted with the throughput line. */
|
|
766
900
|
nativeCropHits = 0;
|
|
767
901
|
nativeCropMisses = 0;
|
|
902
|
+
/**
|
|
903
|
+
* Last `(path, rawColorSpace, rawColorRange)` provenance key emitted — a
|
|
904
|
+
* change re-emits a one-line TRANSITION marker (RC-5 diagnostic: pinpoints an
|
|
905
|
+
* intermittent desaturation recurrence by showing the exact GOP whose color
|
|
906
|
+
* tags drifted). See {@link logColorProvenance}.
|
|
907
|
+
*/
|
|
908
|
+
lastColorProvenanceKey = "";
|
|
909
|
+
/** Per-frame color provenance logging (opt-in; the transition marker is always on). */
|
|
910
|
+
colorDebug = process.env["CAMSTACK_DECODE_COLOR_DEBUG"] === "1";
|
|
768
911
|
/** Scrypted-style throughput counters (plain integers, no per-frame allocation). */
|
|
769
912
|
framesDecoded = 0;
|
|
770
913
|
/** Child-side skips only (HW-guard drops + probe/degrade frees); slot drops add {@link FrameSlot.droppedCount}. */
|
|
@@ -965,6 +1108,28 @@ var DecodeWorkerChild = class {
|
|
|
965
1108
|
return this.nativeRing.get(frameId) ?? this.frames.toBuffer(frameId);
|
|
966
1109
|
}
|
|
967
1110
|
/**
|
|
1111
|
+
* Resolve a decoded frame's `(colorSpace, colorRange)` tags to the EXPLICIT
|
|
1112
|
+
* `(matrix, range)` every RGB conversion site pins — the RC-5 fix. `colorSpace`
|
|
1113
|
+
* / `colorRange` are node-av branded `number` subtypes, so they pass to the
|
|
1114
|
+
* plain-number helper directly.
|
|
1115
|
+
*/
|
|
1116
|
+
resolveFrameColor(frame) {
|
|
1117
|
+
return resolveSourceColor(frame.colorSpace, frame.colorRange);
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Emit the color-provenance marker: which decode/scale path produced this
|
|
1121
|
+
* frame + its raw and resolved source color. Always logs on a TRANSITION (the
|
|
1122
|
+
* path or the frame's raw color tags changed vs the last emit — rare, and the
|
|
1123
|
+
* exact signal a desaturation recurrence leaves); logs every frame only under
|
|
1124
|
+
* the `CAMSTACK_DECODE_COLOR_DEBUG` flag.
|
|
1125
|
+
*/
|
|
1126
|
+
logColorProvenance(path, color) {
|
|
1127
|
+
const key = `${path}|${color.rawColorSpace}|${color.rawColorRange}`;
|
|
1128
|
+
const transition = key !== this.lastColorProvenanceKey;
|
|
1129
|
+
if (transition) this.lastColorProvenanceKey = key;
|
|
1130
|
+
if (transition || this.colorDebug) this.emitStderr(colorProvenanceLine(path, color, transition));
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
968
1133
|
* Retain a just-superseded DELIVERED frame for later native crops. Transfers
|
|
969
1134
|
* ownership from the {@link FrameSlot} (the caller MUST NOT free when this
|
|
970
1135
|
* returns `true`).
|
|
@@ -1047,6 +1212,7 @@ var DecodeWorkerChild = class {
|
|
|
1047
1212
|
const nav = this.nav;
|
|
1048
1213
|
const C = this.consts;
|
|
1049
1214
|
if (!nav || !C) throw new Error("node-av not initialized");
|
|
1215
|
+
this.logColorProvenance("sw-crop", this.resolveFrameColor(frame));
|
|
1050
1216
|
const { region, target } = resolved;
|
|
1051
1217
|
const planes = frame.data;
|
|
1052
1218
|
const strides = frame.linesize;
|
|
@@ -1085,7 +1251,9 @@ var DecodeWorkerChild = class {
|
|
|
1085
1251
|
*/
|
|
1086
1252
|
nativeHwCropToBuffer(frame, resolved) {
|
|
1087
1253
|
const { region, target } = resolved;
|
|
1088
|
-
const
|
|
1254
|
+
const color = this.resolveFrameColor(frame);
|
|
1255
|
+
this.logColorProvenance("hw-crop", color);
|
|
1256
|
+
const outputs = this.ensureNativeCropHwFilter(region, target, color).processAllSync(frame);
|
|
1089
1257
|
const first = outputs[0];
|
|
1090
1258
|
if (!first) {
|
|
1091
1259
|
for (const out of outputs) out.free();
|
|
@@ -1119,17 +1287,17 @@ var DecodeWorkerChild = class {
|
|
|
1119
1287
|
return scaler;
|
|
1120
1288
|
}
|
|
1121
1289
|
/** Build-once / reuse the dedicated native-crop GPU filtergraph (rgb out). */
|
|
1122
|
-
ensureNativeCropHwFilter(region, target) {
|
|
1290
|
+
ensureNativeCropHwFilter(region, target, color) {
|
|
1123
1291
|
const nav = this.nav;
|
|
1124
1292
|
const scaleFilter = this.gpuScaleFilter;
|
|
1125
1293
|
if (!nav || !scaleFilter || !this.hwContext) throw new Error("decode-worker-child: native GPU crop requested without a HW context");
|
|
1126
|
-
const key = `${region.left},${region.top},${region.width},${region.height}->${target.width}x${target.height}`;
|
|
1294
|
+
const key = `${region.left},${region.top},${region.width},${region.height}->${target.width}x${target.height}:${sourceColorKey(color)}`;
|
|
1127
1295
|
const cached = this.nativeCropHwFilter;
|
|
1128
1296
|
if (cached && key === this.nativeCropHwFilterKey) return cached;
|
|
1129
1297
|
this.nativeCropHwFilter?.close();
|
|
1130
1298
|
this.nativeCropHwFilter = null;
|
|
1131
1299
|
this.nativeCropHwFilterKey = "";
|
|
1132
|
-
const description = buildGpuFilterDescription(scaleFilter, region, target, false, "rgb24");
|
|
1300
|
+
const description = buildGpuFilterDescription(scaleFilter, region, target, false, "rgb24", color);
|
|
1133
1301
|
const filter = nav.FilterAPI.create(description, { hardware: this.hwContext });
|
|
1134
1302
|
this.nativeCropHwFilter = filter;
|
|
1135
1303
|
this.nativeCropHwFilterKey = key;
|
|
@@ -1338,6 +1506,7 @@ var DecodeWorkerChild = class {
|
|
|
1338
1506
|
const format = opts?.format ?? this.sessionFormat;
|
|
1339
1507
|
const dstFmt = format === "gray" ? C.AV_PIX_FMT_GRAY8 : C.AV_PIX_FMT_RGB24;
|
|
1340
1508
|
const channels = format === "gray" ? 1 : 3;
|
|
1509
|
+
this.logColorProvenance(isFullFrameRegion(region, frame.width, frame.height) ? "sw-fullframe" : "sw-crop", this.resolveFrameColor(frame));
|
|
1341
1510
|
const planes = frame.data;
|
|
1342
1511
|
const strides = frame.linesize;
|
|
1343
1512
|
const yPlane = planes?.[0];
|
|
@@ -1409,7 +1578,9 @@ var DecodeWorkerChild = class {
|
|
|
1409
1578
|
const target = resolveResizeTarget(region.width, region.height, opts?.resize);
|
|
1410
1579
|
const format = opts?.format ?? this.sessionFormat;
|
|
1411
1580
|
const channels = format === "gray" ? 1 : 3;
|
|
1412
|
-
const
|
|
1581
|
+
const color = this.resolveFrameColor(frame);
|
|
1582
|
+
this.logColorProvenance(isFullFrameRegion(region, frame.width, frame.height) ? "hw-fullframe" : "hw-crop", color);
|
|
1583
|
+
const outputs = this.ensureHwFilter(region, target, frame.width, frame.height, format, color).processAllSync(frame);
|
|
1413
1584
|
const first = outputs[0];
|
|
1414
1585
|
if (!first) throw new Error("GPU filtergraph produced no frame");
|
|
1415
1586
|
try {
|
|
@@ -1431,19 +1602,19 @@ var DecodeWorkerChild = class {
|
|
|
1431
1602
|
* only when the key changes; detection's ROI + resize target are stable, so
|
|
1432
1603
|
* this is a per-worker singleton in the steady state.
|
|
1433
1604
|
*/
|
|
1434
|
-
ensureHwFilter(region, target, srcW, srcH, format) {
|
|
1605
|
+
ensureHwFilter(region, target, srcW, srcH, format, color) {
|
|
1435
1606
|
const nav = this.nav;
|
|
1436
1607
|
const scaleFilter = this.gpuScaleFilter;
|
|
1437
1608
|
if (!nav || !scaleFilter || !this.hwContext) throw new Error("decode-worker-child: GPU filter requested without a HW context");
|
|
1438
1609
|
const fullFrame = isFullFrameRegion(region, srcW, srcH);
|
|
1439
1610
|
const pixel = pixelFilterName(format);
|
|
1440
|
-
const key = `${region.left},${region.top},${region.width},${region.height}->${target.width}x${target.height}:${pixel}`;
|
|
1611
|
+
const key = `${region.left},${region.top},${region.width},${region.height}->${target.width}x${target.height}:${pixel}:${sourceColorKey(color)}`;
|
|
1441
1612
|
const cached = this.hwFilter;
|
|
1442
1613
|
if (cached && key === this.hwFilterKey) return cached;
|
|
1443
1614
|
this.hwFilter?.close();
|
|
1444
1615
|
this.hwFilter = null;
|
|
1445
1616
|
this.hwFilterKey = "";
|
|
1446
|
-
const description = buildGpuFilterDescription(scaleFilter, region, target, fullFrame, pixel);
|
|
1617
|
+
const description = buildGpuFilterDescription(scaleFilter, region, target, fullFrame, pixel, color);
|
|
1447
1618
|
const filter = nav.FilterAPI.create(description, { hardware: this.hwContext });
|
|
1448
1619
|
this.hwFilter = filter;
|
|
1449
1620
|
this.hwFilterKey = key;
|