@camstack/addon-pipeline 1.2.227 → 1.2.229
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 +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-C1etqCJk.js → default-detection-model-C7fsHtHQ.js} +1 -1
- package/dist/{default-detection-model-CNwum1uC.mjs → default-detection-model-DC4yVaFM.mjs} +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-d1K9t4mU.mjs → dist-DTPYOq7J.mjs} +440 -19
- package/dist/{dist-BGdDektN.js → dist-p5lZW8pr.js} +445 -18
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-CQ5yofm2.js → node-CGVUk5S9.js} +1 -1
- package/dist/{node-CpckZhmU.mjs → node-Cz92fVGW.mjs} +1 -1
- package/dist/pipeline-runner/index.js +4 -4
- package/dist/pipeline-runner/index.mjs +4 -4
- package/dist/{process-memory-iUDS4M_G.mjs → process-memory-BwijtYhM.mjs} +1 -1
- package/dist/{process-memory-D2NCFwt_.js → process-memory-COypUeEc.js} +1 -1
- package/dist/recorder/index.js +4 -3
- package/dist/recorder/index.mjs +4 -3
- package/dist/{restream-intent-B1Difcnq.js → restream-intent-C4adO0P8.js} +5 -0
- package/dist/{restream-intent-DWe2kInY.mjs → restream-intent-MeJhGCK9.mjs} +5 -0
- package/dist/{segment-demux-js-Dk_Pvocs.js → segment-demux-js-B-yzHYVc.js} +1 -1
- package/dist/{segment-demux-js-iVyZ1H9j.mjs → segment-demux-js-CyaNDtrw.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-7qCmXLC3.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B9KO-igY.mjs} +3 -3
- package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Bc-sSkWV.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-mnvO611B.mjs} +1 -1
- package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CUMGe_Dp.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-JoG-yDpV.mjs} +1 -1
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-D6wBZr7_.mjs → hostInit-CDwsQyIR.mjs} +3 -3
- package/dist/stream-broker/index.js +1357 -53
- package/dist/stream-broker/index.mjs +1357 -53
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_ov_requests.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
- package/python/inference_pool.py +39 -9
- package/python/test_inference_pool_ov_requests.py +79 -0
|
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../chunk-emK7D4bc.js");
|
|
6
|
-
const require_dist = require("../dist-
|
|
7
|
-
const require_node = require("../node-
|
|
8
|
-
const require_restream_intent = require("../restream-intent-
|
|
6
|
+
const require_dist = require("../dist-p5lZW8pr.js");
|
|
7
|
+
const require_node = require("../node-CGVUk5S9.js");
|
|
8
|
+
const require_restream_intent = require("../restream-intent-C4adO0P8.js");
|
|
9
9
|
const require_remote_restream = require("../remote-restream-CO36Sr30.js");
|
|
10
10
|
const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
|
|
11
|
-
const require_segment_demux_js = require("../segment-demux-js-
|
|
11
|
+
const require_segment_demux_js = require("../segment-demux-js-B-yzHYVc.js");
|
|
12
12
|
let node_os = require("node:os");
|
|
13
13
|
node_os = require_chunk.__toESM(node_os);
|
|
14
14
|
let node_crypto = require("node:crypto");
|
|
@@ -21,6 +21,7 @@ let node_child_process = require("node:child_process");
|
|
|
21
21
|
let node_fs_promises = require("node:fs/promises");
|
|
22
22
|
let node_url = require("node:url");
|
|
23
23
|
let node_module = require("node:module");
|
|
24
|
+
let _camstack_system_addon_utils = require("@camstack/system/addon-utils");
|
|
24
25
|
let node_net = require("node:net");
|
|
25
26
|
node_net = require_chunk.__toESM(node_net);
|
|
26
27
|
let node_dgram = require("node:dgram");
|
|
@@ -560,6 +561,201 @@ function rankDecodeHwAccels(preferred, supportedMethods) {
|
|
|
560
561
|
return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
|
|
561
562
|
}
|
|
562
563
|
//#endregion
|
|
564
|
+
//#region src/stream-broker/stream-broker/egress-source-readiness.ts
|
|
565
|
+
/**
|
|
566
|
+
* Whether waiting on this source can achieve anything — the three-way answer
|
|
567
|
+
* `waitForSourceMedia` consumes.
|
|
568
|
+
*
|
|
569
|
+
* The subtlety that cost a measured 15 s per cold Alexa open on 2026-09-13: a
|
|
570
|
+
* lazy publisher materialises because someone DIALS it. The source broker's own
|
|
571
|
+
* `redial` / `kickReconnect` both refuse without demand, by design — a broker
|
|
572
|
+
* with no consumer must not wake a camera. So for a cold, idle source the
|
|
573
|
+
* ffmpeg child IS the demand, and withholding it waits for media that only
|
|
574
|
+
* starts because of the thing being withheld. The live log of that mistake:
|
|
575
|
+
*
|
|
576
|
+
* 19:04:27 waited for the SOURCE {outcome=timeout waitedMs=15005 ready=false}
|
|
577
|
+
* 19:04:30 the child died before the decoder — retrying in SOFTWARE
|
|
578
|
+
* 19:04:32 rfc4571: publisher materialized {materializeMs=5798}
|
|
579
|
+
*
|
|
580
|
+
* Fifteen seconds of nothing, then the spawn, then the wake it had triggered.
|
|
581
|
+
*
|
|
582
|
+
* So: wait only when a materialize is ALREADY in flight — a second consumer
|
|
583
|
+
* arriving while the first is waking the camera, which is the case the wait was
|
|
584
|
+
* really for. A cold idle source answers `null`: not "no media", but "waiting
|
|
585
|
+
* here cannot help", and the caller spawns at once.
|
|
586
|
+
*/
|
|
587
|
+
function sourceWaitState(input) {
|
|
588
|
+
if (input.hasMedia) return true;
|
|
589
|
+
if (input.materializing) return false;
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
async function waitForSourceMedia(input) {
|
|
593
|
+
const started = input.now();
|
|
594
|
+
const first = input.hasMedia();
|
|
595
|
+
if (first === null) return {
|
|
596
|
+
ready: false,
|
|
597
|
+
waitedMs: 0,
|
|
598
|
+
outcome: "unknown"
|
|
599
|
+
};
|
|
600
|
+
if (first) return {
|
|
601
|
+
ready: true,
|
|
602
|
+
waitedMs: 0,
|
|
603
|
+
outcome: "live"
|
|
604
|
+
};
|
|
605
|
+
for (;;) {
|
|
606
|
+
const elapsed = input.now() - started;
|
|
607
|
+
if (elapsed >= input.timeoutMs) return {
|
|
608
|
+
ready: false,
|
|
609
|
+
waitedMs: elapsed,
|
|
610
|
+
outcome: "timeout"
|
|
611
|
+
};
|
|
612
|
+
await input.sleep(input.pollMs);
|
|
613
|
+
const state = input.hasMedia();
|
|
614
|
+
const waitedMs = input.now() - started;
|
|
615
|
+
if (state === null) return {
|
|
616
|
+
ready: false,
|
|
617
|
+
waitedMs,
|
|
618
|
+
outcome: "unknown"
|
|
619
|
+
};
|
|
620
|
+
if (state) return {
|
|
621
|
+
ready: true,
|
|
622
|
+
waitedMs,
|
|
623
|
+
outcome: "materialized"
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
//#endregion
|
|
628
|
+
//#region src/stream-broker/stream-broker/stream-health-scope.ts
|
|
629
|
+
/**
|
|
630
|
+
* WHICH streams a health transition is worth telling the operator about.
|
|
631
|
+
*
|
|
632
|
+
* `stream.online` / `stream.offline` are operator-facing: they reach the
|
|
633
|
+
* notification rules and land on a phone. So they must be facts about a
|
|
634
|
+
* CAMERA, and an egress stream is not one — it is the private pipe an exporter
|
|
635
|
+
* asks the broker to build for one consumer session and drops when that session
|
|
636
|
+
* ends. Its whole life is an implementation detail of somebody watching.
|
|
637
|
+
*
|
|
638
|
+
* Measured on this hub 2026-09-13: every Alexa session on the baby monitor
|
|
639
|
+
* produced a `stream.online` and a `stream.offline` pair against
|
|
640
|
+
* `640/egress:8e96e97a…`, and the operator read them as the battery camera
|
|
641
|
+
* flapping during its wake. Nothing was flapping — that is just what an egress
|
|
642
|
+
* pipe does, once per session, by design.
|
|
643
|
+
*
|
|
644
|
+
* The doctrine already existed one layer down: an egress is published with
|
|
645
|
+
* `autoEligible: false` so "a private egress can never become a camera's
|
|
646
|
+
* published stream". It simply had not reached the event path.
|
|
647
|
+
*
|
|
648
|
+
* A DERIVED stream is different and stays in scope: the operator configured it,
|
|
649
|
+
* it is meant to persist, and its going offline is news.
|
|
650
|
+
*/
|
|
651
|
+
/** Private, per-session egress pipes. See {@link healthEventIsOperatorFacing}. */
|
|
652
|
+
var PRIVATE_STREAM_PREFIXES = ["egress:"];
|
|
653
|
+
function healthEventIsOperatorFacing(camStreamId) {
|
|
654
|
+
return !PRIVATE_STREAM_PREFIXES.some((prefix) => camStreamId.startsWith(prefix));
|
|
655
|
+
}
|
|
656
|
+
//#endregion
|
|
657
|
+
//#region src/stream-broker/stream-broker/prestream-eligibility.ts
|
|
658
|
+
function prestreamEligible(input) {
|
|
659
|
+
if (input.batteryOperated === null || input.declaresSignals === null) return {
|
|
660
|
+
eligible: false,
|
|
661
|
+
reason: "unknown"
|
|
662
|
+
};
|
|
663
|
+
if (!input.batteryOperated) return {
|
|
664
|
+
eligible: false,
|
|
665
|
+
reason: "not-battery"
|
|
666
|
+
};
|
|
667
|
+
if (!input.declaresSignals) return {
|
|
668
|
+
eligible: false,
|
|
669
|
+
reason: "no-signals-cap"
|
|
670
|
+
};
|
|
671
|
+
return { eligible: true };
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Ceiling on ONE run, measured from its first signal.
|
|
675
|
+
*
|
|
676
|
+
* Five minutes. Long enough that a genuine sustained event — a person moving
|
|
677
|
+
* around a garden — is covered end to end; short enough that a sensor stuck
|
|
678
|
+
* ON costs one five-minute wake rather than a night.
|
|
679
|
+
*/
|
|
680
|
+
var WAKE_HOLD_MAX_RUN_MS = 5 * 6e4;
|
|
681
|
+
/**
|
|
682
|
+
* How long a camera is left alone after a run hit the ceiling.
|
|
683
|
+
*
|
|
684
|
+
* Without this the ceiling is cosmetic, and the test that found it says why: a
|
|
685
|
+
* run ends exactly AT the ceiling, so the very next signal from a sensor that
|
|
686
|
+
* never stops re-arms a fresh five minutes. Five minutes awake, an instant
|
|
687
|
+
* asleep, five minutes awake — all night, which is the precise failure the
|
|
688
|
+
* ceiling was written to prevent.
|
|
689
|
+
*
|
|
690
|
+
* So a capped run buys silence. Ten minutes: long enough that a stuck sensor
|
|
691
|
+
* costs a third of its time rather than all of it, short enough that a genuine
|
|
692
|
+
* long event — someone working in the garden — resumes within a few minutes
|
|
693
|
+
* instead of being locked out for an hour.
|
|
694
|
+
*
|
|
695
|
+
* Signals during the quarantine are REFUSED and counted, not queued: a queue
|
|
696
|
+
* would fire them all the moment it lifts, which is the same drain arriving
|
|
697
|
+
* late.
|
|
698
|
+
*/
|
|
699
|
+
var WAKE_QUARANTINE_AFTER_CAP_MS = 10 * 6e4;
|
|
700
|
+
var NO_HOLD = {
|
|
701
|
+
runStartedAt: 0,
|
|
702
|
+
until: 0,
|
|
703
|
+
signals: 0,
|
|
704
|
+
quarantinedUntil: 0
|
|
705
|
+
};
|
|
706
|
+
/**
|
|
707
|
+
* Absorb one signal.
|
|
708
|
+
*
|
|
709
|
+
* A signal arriving with the run already at its ceiling does NOT extend it —
|
|
710
|
+
* and does not start a new one either, because a new run measured from now
|
|
711
|
+
* would be the same unbounded extension wearing a different name. It is
|
|
712
|
+
* reported as `capped` so the operator can see a sensor that will not stop.
|
|
713
|
+
*/
|
|
714
|
+
function absorbSignal(input) {
|
|
715
|
+
const { current, now, holdMs } = input;
|
|
716
|
+
if (!(current.until > now && current.runStartedAt > 0)) {
|
|
717
|
+
if (current.quarantinedUntil > now) return {
|
|
718
|
+
hold: {
|
|
719
|
+
...current,
|
|
720
|
+
signals: current.signals + 1
|
|
721
|
+
},
|
|
722
|
+
outcome: "quarantined"
|
|
723
|
+
};
|
|
724
|
+
return {
|
|
725
|
+
hold: {
|
|
726
|
+
runStartedAt: now,
|
|
727
|
+
until: now + holdMs,
|
|
728
|
+
signals: 1,
|
|
729
|
+
quarantinedUntil: 0
|
|
730
|
+
},
|
|
731
|
+
outcome: "armed"
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
if (now - current.runStartedAt >= 3e5) return {
|
|
735
|
+
hold: {
|
|
736
|
+
...current,
|
|
737
|
+
signals: current.signals + 1,
|
|
738
|
+
quarantinedUntil: now + WAKE_QUARANTINE_AFTER_CAP_MS
|
|
739
|
+
},
|
|
740
|
+
outcome: "capped"
|
|
741
|
+
};
|
|
742
|
+
const ceiling = current.runStartedAt + WAKE_HOLD_MAX_RUN_MS;
|
|
743
|
+
const until = Math.min(now + holdMs, ceiling);
|
|
744
|
+
return {
|
|
745
|
+
hold: {
|
|
746
|
+
...current,
|
|
747
|
+
until,
|
|
748
|
+
signals: current.signals + 1,
|
|
749
|
+
...until === ceiling ? { quarantinedUntil: ceiling + WAKE_QUARANTINE_AFTER_CAP_MS } : {}
|
|
750
|
+
},
|
|
751
|
+
outcome: "extended"
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
/** Whether the hold still keeps the camera awake at `now`. */
|
|
755
|
+
function holdIsLive(hold, now) {
|
|
756
|
+
return hold.until > now;
|
|
757
|
+
}
|
|
758
|
+
//#endregion
|
|
563
759
|
//#region src/stream-broker/battery-wake-on-play.ts
|
|
564
760
|
/**
|
|
565
761
|
* Wake-on-play — pressing play on a sleeping battery camera wakes it, and the
|
|
@@ -1248,7 +1444,7 @@ var ADTS_SAMPLE_RATES = [
|
|
|
1248
1444
|
/** AAC-LC AudioObjectType for the 2-byte AudioSpecificConfig. */
|
|
1249
1445
|
var AAC_LC_OBJECT_TYPE = 2;
|
|
1250
1446
|
/** Grace before escalating SIGTERM → SIGKILL on a lingering child. */
|
|
1251
|
-
var KILL_GRACE_MS = 500;
|
|
1447
|
+
var KILL_GRACE_MS$1 = 500;
|
|
1252
1448
|
/**
|
|
1253
1449
|
* Parse the 7-byte ADTS header of an AAC frame into the audio-init params the
|
|
1254
1450
|
* broker's push ingress needs. Returns `null` when the buffer is too short or
|
|
@@ -1442,7 +1638,7 @@ var FfmpegPushReader = class {
|
|
|
1442
1638
|
if (pid !== void 0 && child.exitCode === null && child.signalCode === null) try {
|
|
1443
1639
|
child.kill("SIGKILL");
|
|
1444
1640
|
} catch {}
|
|
1445
|
-
}, KILL_GRACE_MS).unref?.();
|
|
1641
|
+
}, KILL_GRACE_MS$1).unref?.();
|
|
1446
1642
|
}
|
|
1447
1643
|
};
|
|
1448
1644
|
//#endregion
|
|
@@ -2894,6 +3090,26 @@ var RtspRestreamer = class {
|
|
|
2894
3090
|
getSessionCount() {
|
|
2895
3091
|
return this.sessions.size;
|
|
2896
3092
|
}
|
|
3093
|
+
/**
|
|
3094
|
+
* Sessions that count as LIVE INTENT — everything except a prestream dial.
|
|
3095
|
+
*
|
|
3096
|
+
* The split exists so a Frigate-style puller can hold a session open for ever
|
|
3097
|
+
* against a battery camera without keeping it awake (D173). For a broker with
|
|
3098
|
+
* no prestream session this equals {@link getSessionCount} exactly, which is
|
|
3099
|
+
* why nothing else changed behaviour.
|
|
3100
|
+
*/
|
|
3101
|
+
getLiveIntentSessionCount() {
|
|
3102
|
+
let n = 0;
|
|
3103
|
+
for (const session of this.sessions.values()) if (!session.isPrestreamIntent()) n += 1;
|
|
3104
|
+
return n;
|
|
3105
|
+
}
|
|
3106
|
+
/** Sessions asking only for the picture. Drives the prestream pump, nothing
|
|
3107
|
+
* else — it must never reach a dial decision. */
|
|
3108
|
+
getPrestreamSessionCount() {
|
|
3109
|
+
let n = 0;
|
|
3110
|
+
for (const session of this.sessions.values()) if (session.isPrestreamIntent()) n += 1;
|
|
3111
|
+
return n;
|
|
3112
|
+
}
|
|
2897
3113
|
/** Per-session diagnostic snapshots consumed by `StreamBroker.listClients`. */
|
|
2898
3114
|
listSessionInfos() {
|
|
2899
3115
|
return [...this.sessions.values()].map((s) => s.getInfo());
|
|
@@ -3646,6 +3862,16 @@ var RtspSession = class {
|
|
|
3646
3862
|
return this.declaration.intent === "detection";
|
|
3647
3863
|
}
|
|
3648
3864
|
/**
|
|
3865
|
+
* Whether this dial asked for the STREAM without asking for the CAMERA.
|
|
3866
|
+
*
|
|
3867
|
+
* The one question the demand split turns on: a prestream session is FED but
|
|
3868
|
+
* is not INTENT, so it never dials a sleeping camera. See
|
|
3869
|
+
* `PRESTREAM_PATH_SUFFIX`.
|
|
3870
|
+
*/
|
|
3871
|
+
isPrestreamIntent() {
|
|
3872
|
+
return this.declaration.intent === "prestream";
|
|
3873
|
+
}
|
|
3874
|
+
/**
|
|
3649
3875
|
* Ms of retained history this session ASKED for on its dial. `0` = none.
|
|
3650
3876
|
*
|
|
3651
3877
|
* Read by `RtspRestreamer.servePendingSessionsRtp`, which serves the smaller
|
|
@@ -4950,6 +5176,84 @@ function pushDimension(into, constraint, requested, known) {
|
|
|
4950
5176
|
});
|
|
4951
5177
|
}
|
|
4952
5178
|
//#endregion
|
|
5179
|
+
//#region src/stream-broker/stream-broker/decode-failure-attribution.ts
|
|
5180
|
+
/**
|
|
5181
|
+
* Was the hardware decoder actually at fault?
|
|
5182
|
+
*
|
|
5183
|
+
* The egress child is spawned with `-hwaccel <backend>`, and until now ANY
|
|
5184
|
+
* failure of that first attempt was read as "hardware produced no packets":
|
|
5185
|
+
* the child was retried in software and the backend was quarantined for thirty
|
|
5186
|
+
* minutes, process-wide, for every camera.
|
|
5187
|
+
*
|
|
5188
|
+
* Measured on the hub 2026-09-13 at 17:48, the failure that triggered a
|
|
5189
|
+
* quarantine was this:
|
|
5190
|
+
*
|
|
5191
|
+
* ```
|
|
5192
|
+
* 17:48:43 egress: spawning ffmpeg child (hwaccel vaapi)
|
|
5193
|
+
* 17:48:45 native:sub — dial rate-limited, deferring to backoff window
|
|
5194
|
+
* 17:48:46 child FAILED: Error opening input: Invalid data found …
|
|
5195
|
+
* 17:48:49 rfc4571: publisher materialized {materializeMs=5486}
|
|
5196
|
+
* ```
|
|
5197
|
+
*
|
|
5198
|
+
* The child was started three seconds before anything was publishing to the
|
|
5199
|
+
* RTSP endpoint it was told to read. It never reached a decoder at all — and
|
|
5200
|
+
* the decoder took the blame, and every other camera lost hardware decode for
|
|
5201
|
+
* half an hour.
|
|
5202
|
+
*
|
|
5203
|
+
* ## The direction of the doubt
|
|
5204
|
+
*
|
|
5205
|
+
* An UNRECOGNISED failure still blames the backend. That asymmetry is
|
|
5206
|
+
* deliberate: a wrong quarantine costs software decode, while a missed one
|
|
5207
|
+
* costs every session a three-second death before its own retry — seven times
|
|
5208
|
+
* on one camera in half an hour, which is what put the quarantine here in the
|
|
5209
|
+
* first place. Only a failure we can positively attribute elsewhere is
|
|
5210
|
+
* excused.
|
|
5211
|
+
*/
|
|
5212
|
+
/** Shapes only a decode backend produces. */
|
|
5213
|
+
var BACKEND_FAILURE = [
|
|
5214
|
+
/device creation failed/i,
|
|
5215
|
+
/failed to create .*(vaapi|qsv|cuda|videotoolbox|drm) .*device/i,
|
|
5216
|
+
/no va display/i,
|
|
5217
|
+
/hwaccel .*(not|un)(supported|available)/i,
|
|
5218
|
+
/cannot open the .*(hardware|hw) device/i,
|
|
5219
|
+
/impossible to convert between the formats/i
|
|
5220
|
+
];
|
|
5221
|
+
/**
|
|
5222
|
+
* Shapes that are NOT the decoder's — checked FIRST, because the argv always
|
|
5223
|
+
* names the backend and a substring match on its name would convict it of
|
|
5224
|
+
* every failure ever logged.
|
|
5225
|
+
*/
|
|
5226
|
+
var NOT_BACKEND = [
|
|
5227
|
+
/error opening input/i,
|
|
5228
|
+
/invalid data found when processing input/i,
|
|
5229
|
+
/connection refused/i,
|
|
5230
|
+
/connection timed out/i,
|
|
5231
|
+
/server returned \d/i,
|
|
5232
|
+
/no route to host/i,
|
|
5233
|
+
/immediate exit requested/i,
|
|
5234
|
+
/end of file/i
|
|
5235
|
+
];
|
|
5236
|
+
function isDecodeBackendFailure(error) {
|
|
5237
|
+
if (NOT_BACKEND.some((re) => re.test(error))) return false;
|
|
5238
|
+
if (BACKEND_FAILURE.some((re) => re.test(error))) return true;
|
|
5239
|
+
return true;
|
|
5240
|
+
}
|
|
5241
|
+
//#endregion
|
|
5242
|
+
//#region src/stream-broker/stream-broker/egress-geometry.ts
|
|
5243
|
+
function capAxis(requested, source) {
|
|
5244
|
+
if (requested === void 0) return void 0;
|
|
5245
|
+
if (source === void 0 || source <= 0) return requested;
|
|
5246
|
+
return Math.min(requested, source);
|
|
5247
|
+
}
|
|
5248
|
+
function deliveredGeometry(input) {
|
|
5249
|
+
const width = capAxis(input.requestedWidth, input.sourceWidth);
|
|
5250
|
+
const height = capAxis(input.requestedHeight, input.sourceHeight);
|
|
5251
|
+
return {
|
|
5252
|
+
...width === void 0 ? {} : { width },
|
|
5253
|
+
...height === void 0 ? {} : { height }
|
|
5254
|
+
};
|
|
5255
|
+
}
|
|
5256
|
+
//#endregion
|
|
4953
5257
|
//#region src/stream-broker/stream-broker/rtsp-host.ts
|
|
4954
5258
|
/**
|
|
4955
5259
|
* Rewrite the host of an `rtsp://host:port/path` URL in place, preserving the
|
|
@@ -5322,6 +5626,28 @@ var TranscodeEgress = class {
|
|
|
5322
5626
|
*/
|
|
5323
5627
|
var DEFAULT_RELEASE_GRACE_MS = 5e3;
|
|
5324
5628
|
/**
|
|
5629
|
+
* How long an egress child waits for its source to carry media before it is
|
|
5630
|
+
* spawned anyway. Sized against the measured cold open of a lazy publisher on
|
|
5631
|
+
* this hub (12.4 s on the wyze bridge, 5.5 s on the camera that produced the
|
|
5632
|
+
* mis-attributed VAAPI death) with margin — and bounded, because the wait is
|
|
5633
|
+
* never a refusal and a source that is genuinely dead must not hold a consumer
|
|
5634
|
+
* past the point where its own client has given up.
|
|
5635
|
+
*/
|
|
5636
|
+
var SOURCE_MEDIA_WAIT_MS = 15e3;
|
|
5637
|
+
/** Poll cadence for that wait. Flat, for the reason a materialize poll is. */
|
|
5638
|
+
var SOURCE_MEDIA_POLL_MS = 250;
|
|
5639
|
+
/**
|
|
5640
|
+
* How long to wait before re-dialling a source whose input was not there.
|
|
5641
|
+
*
|
|
5642
|
+
* The first spawn is what WAKES a lazy publisher, and the measured wake on this
|
|
5643
|
+
* hub lands between 2.2 s and 7.8 s (`materializeMs`). Three seconds is inside
|
|
5644
|
+
* that band: long enough that a quick wake is already serving, short enough
|
|
5645
|
+
* that the second attempt still beats the consumer's own patience.
|
|
5646
|
+
*/
|
|
5647
|
+
var INPUT_RETRY_DELAY_MS = 3e3;
|
|
5648
|
+
/** After the first three, log one keyframe in this many. */
|
|
5649
|
+
var KEYFRAME_LOG_EVERY = 25;
|
|
5650
|
+
/**
|
|
5325
5651
|
* How long a push child may produce nothing before it is declared dead. The
|
|
5326
5652
|
* observed failure EXITS in ~2 s and is caught by the error path long before
|
|
5327
5653
|
* this; the timeout only covers a child that hangs.
|
|
@@ -5487,12 +5813,18 @@ var EgressTranscodeManager = class {
|
|
|
5487
5813
|
const decodeHwAccel = await this.opts.resolveDecodeHwAccel(deviceId, request.decodeHwAccel);
|
|
5488
5814
|
const fragments = delivery.transport === "fragments";
|
|
5489
5815
|
const publishedCamStreamId = `egress:${key.replace(/^egress:/, "")}`;
|
|
5816
|
+
const delivered = deliveredGeometry({
|
|
5817
|
+
requestedWidth: request.encode.video.width,
|
|
5818
|
+
requestedHeight: request.encode.video.height,
|
|
5819
|
+
sourceWidth: source.facts.width,
|
|
5820
|
+
sourceHeight: source.facts.height
|
|
5821
|
+
});
|
|
5490
5822
|
const pushSink = fragments || request.publishLocally !== true || this.opts.publishPushStream === void 0 ? null : await this.opts.publishPushStream({
|
|
5491
5823
|
deviceId,
|
|
5492
5824
|
camStreamId: publishedCamStreamId,
|
|
5493
5825
|
codec: request.encode.video.codec,
|
|
5494
|
-
width:
|
|
5495
|
-
height:
|
|
5826
|
+
width: delivered.width ?? 0,
|
|
5827
|
+
height: delivered.height ?? 0
|
|
5496
5828
|
});
|
|
5497
5829
|
if (request.publishLocally === true && pushSink === null && !fragments) this.opts.logger.warn("egress transcode: local publication REFUSED — the consumer must dial the url", {
|
|
5498
5830
|
tags: { deviceId },
|
|
@@ -5506,7 +5838,10 @@ var EgressTranscodeManager = class {
|
|
|
5506
5838
|
const fragmentSeconds = fragments ? (delivery.fragmentMs ?? 0) / 1e3 : void 0;
|
|
5507
5839
|
const { sink: _sink, audioSidecar: _audioSidecar, ...plan } = require_dist.invocationFromEncodeProfile({
|
|
5508
5840
|
profile: {
|
|
5509
|
-
video:
|
|
5841
|
+
video: {
|
|
5842
|
+
...request.encode.video,
|
|
5843
|
+
...delivered
|
|
5844
|
+
},
|
|
5510
5845
|
audio: audio.profileAudio
|
|
5511
5846
|
},
|
|
5512
5847
|
sourceCodec: source.codec,
|
|
@@ -5530,6 +5865,7 @@ var EgressTranscodeManager = class {
|
|
|
5530
5865
|
const plane = fragments ? new require_node.Fmp4FragmentPlane(this.opts.logger.child("egress-fmp4")) : null;
|
|
5531
5866
|
const channel = pushSink === null && plane === null ? this.opts.acquireChannel(brokerId) : null;
|
|
5532
5867
|
const consumerUrl = channel !== null ? channel.consumerUrl : plane !== null ? fragmentsOnlyUrl(key) : pushOnlyUrl(publishedCamStreamId);
|
|
5868
|
+
await this.awaitSourceMedia(deviceId, key, source);
|
|
5533
5869
|
const child = this.createChild({
|
|
5534
5870
|
brokerId,
|
|
5535
5871
|
deviceId,
|
|
@@ -5575,8 +5911,9 @@ var EgressTranscodeManager = class {
|
|
|
5575
5911
|
throw err;
|
|
5576
5912
|
}
|
|
5577
5913
|
const actualHwAccel = child.decodeHwAccel();
|
|
5578
|
-
if (decodeHwAccel !== null && !require_dist.isSoftwareDecode(decodeHwAccel) && actualHwAccel === null)
|
|
5579
|
-
|
|
5914
|
+
if (decodeHwAccel !== null && !require_dist.isSoftwareDecode(decodeHwAccel) && actualHwAccel === null) {
|
|
5915
|
+
if (child.hwAccelAtFault?.() !== false) this.opts.onDecodeHwAccelFailed?.(decodeHwAccel);
|
|
5916
|
+
} else if (actualHwAccel !== null && !require_dist.isSoftwareDecode(actualHwAccel)) this.opts.onDecodeHwAccelSucceeded?.(actualHwAccel);
|
|
5580
5917
|
const entry = {
|
|
5581
5918
|
key,
|
|
5582
5919
|
deviceId,
|
|
@@ -5936,6 +6273,18 @@ var EgressTranscodeManager = class {
|
|
|
5936
6273
|
dts: packet.dts
|
|
5937
6274
|
}
|
|
5938
6275
|
});
|
|
6276
|
+
} else if (packet.keyframe === true) {
|
|
6277
|
+
keyframesOut += 1;
|
|
6278
|
+
if (keyframesOut <= 3 || keyframesOut % KEYFRAME_LOG_EVERY === 0) logger.info("egress push: keyframe out", {
|
|
6279
|
+
tags: { deviceId },
|
|
6280
|
+
meta: {
|
|
6281
|
+
brokerId: spec.brokerId,
|
|
6282
|
+
camStreamId: spec.publishedCamStreamId,
|
|
6283
|
+
keyframesOut,
|
|
6284
|
+
bytes: packet.data.length,
|
|
6285
|
+
approxRtpPackets: Math.ceil(packet.data.length / 1200)
|
|
6286
|
+
}
|
|
6287
|
+
});
|
|
5939
6288
|
} else if (packetsOut % PUSH_PACKET_LOG_EVERY === 0) logger.info("egress push: packets still flowing out of the ffmpeg child", {
|
|
5940
6289
|
tags: { deviceId },
|
|
5941
6290
|
meta: {
|
|
@@ -5993,25 +6342,66 @@ var EgressTranscodeManager = class {
|
|
|
5993
6342
|
reader.connect();
|
|
5994
6343
|
});
|
|
5995
6344
|
};
|
|
6345
|
+
let keyframesOut = 0;
|
|
6346
|
+
let hwAccelAtFault = true;
|
|
5996
6347
|
return {
|
|
6348
|
+
hwAccelAtFault: () => hwAccelAtFault,
|
|
5997
6349
|
start: async () => {
|
|
5998
6350
|
const requested = spec.invocation.decodeHwAccel;
|
|
6351
|
+
/** Whether a same-decode retry already produced a live child. */
|
|
6352
|
+
let recoveredOnRetry = false;
|
|
6353
|
+
const inputRetryDelayMs = this.opts.inputRetryDelayMs ?? INPUT_RETRY_DELAY_MS;
|
|
5999
6354
|
try {
|
|
6000
6355
|
await spawnAttempt(requested);
|
|
6001
6356
|
} catch (err) {
|
|
6002
6357
|
if (requested === null || require_dist.isSoftwareDecode(requested)) throw err;
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6358
|
+
const blameBackend = isDecodeBackendFailure(require_dist.errMsg(err));
|
|
6359
|
+
hwAccelAtFault = blameBackend;
|
|
6360
|
+
if (!blameBackend) {
|
|
6361
|
+
logger.info("egress push: the input was not there — retrying the SAME decode", {
|
|
6362
|
+
tags: { deviceId },
|
|
6363
|
+
meta: {
|
|
6364
|
+
brokerId: spec.brokerId,
|
|
6365
|
+
camStreamId: spec.publishedCamStreamId,
|
|
6366
|
+
decodeHwAccel: requested,
|
|
6367
|
+
afterMs: inputRetryDelayMs,
|
|
6368
|
+
error: require_dist.errMsg(err)
|
|
6369
|
+
}
|
|
6370
|
+
});
|
|
6371
|
+
reader?.destroy();
|
|
6372
|
+
await new Promise((resolve) => {
|
|
6373
|
+
setTimeout(resolve, inputRetryDelayMs).unref?.();
|
|
6374
|
+
});
|
|
6375
|
+
try {
|
|
6376
|
+
await spawnAttempt(requested);
|
|
6377
|
+
recoveredOnRetry = true;
|
|
6378
|
+
} catch (retryErr) {
|
|
6379
|
+
logger.warn("egress push: the input was STILL not there — falling to SOFTWARE", {
|
|
6380
|
+
tags: { deviceId },
|
|
6381
|
+
meta: {
|
|
6382
|
+
brokerId: spec.brokerId,
|
|
6383
|
+
camStreamId: spec.publishedCamStreamId,
|
|
6384
|
+
decodeHwAccel: requested,
|
|
6385
|
+
error: require_dist.errMsg(retryErr)
|
|
6386
|
+
}
|
|
6387
|
+
});
|
|
6010
6388
|
}
|
|
6011
|
-
}
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
|
|
6389
|
+
}
|
|
6390
|
+
if (!recoveredOnRetry) {
|
|
6391
|
+
logger.warn(blameBackend ? "egress push: hardware decode produced NO packets — retrying in SOFTWARE" : "egress push: the child died before the decoder — retrying in SOFTWARE, backend NOT blamed", {
|
|
6392
|
+
tags: { deviceId },
|
|
6393
|
+
meta: {
|
|
6394
|
+
brokerId: spec.brokerId,
|
|
6395
|
+
camStreamId: spec.publishedCamStreamId,
|
|
6396
|
+
decodeHwAccel: requested,
|
|
6397
|
+
blameBackend,
|
|
6398
|
+
error: require_dist.errMsg(err)
|
|
6399
|
+
}
|
|
6400
|
+
});
|
|
6401
|
+
reader?.destroy();
|
|
6402
|
+
activeHwAccel = null;
|
|
6403
|
+
await spawnAttempt(null);
|
|
6404
|
+
}
|
|
6015
6405
|
}
|
|
6016
6406
|
logger.info("egress transcode: publishing LOCALLY — no rtsp round trip", {
|
|
6017
6407
|
tags: { deviceId },
|
|
@@ -6087,6 +6477,37 @@ var EgressTranscodeManager = class {
|
|
|
6087
6477
|
* source dial. A targeted source that has never been dialed is refused rather
|
|
6088
6478
|
* than silently substituted — the consumer asked for a specific stream.
|
|
6089
6479
|
*/
|
|
6480
|
+
/**
|
|
6481
|
+
* Hold the spawn until the source is carrying media, under a bound.
|
|
6482
|
+
*
|
|
6483
|
+
* Never throws and never refuses: a source that says nothing by the deadline
|
|
6484
|
+
* is spawned against anyway, and the line says what was waited. The only
|
|
6485
|
+
* thing this changes is WHEN ffmpeg opens the loopback.
|
|
6486
|
+
*/
|
|
6487
|
+
async awaitSourceMedia(deviceId, key, source) {
|
|
6488
|
+
const hasMedia = this.opts.sourceHasMedia;
|
|
6489
|
+
if (hasMedia === void 0) return;
|
|
6490
|
+
const verdict = await waitForSourceMedia({
|
|
6491
|
+
hasMedia: () => hasMedia(source.brokerId),
|
|
6492
|
+
timeoutMs: SOURCE_MEDIA_WAIT_MS,
|
|
6493
|
+
pollMs: SOURCE_MEDIA_POLL_MS,
|
|
6494
|
+
now: () => Date.now(),
|
|
6495
|
+
sleep: (ms) => new Promise((resolve) => {
|
|
6496
|
+
setTimeout(resolve, ms).unref?.();
|
|
6497
|
+
})
|
|
6498
|
+
});
|
|
6499
|
+
if (verdict.outcome === "live") return;
|
|
6500
|
+
this.opts.logger.info("egress transcode: waited for the SOURCE before spawning", {
|
|
6501
|
+
tags: { deviceId },
|
|
6502
|
+
meta: {
|
|
6503
|
+
pipelineKey: key,
|
|
6504
|
+
sourceBrokerId: source.brokerId,
|
|
6505
|
+
outcome: verdict.outcome,
|
|
6506
|
+
waitedMs: verdict.waitedMs,
|
|
6507
|
+
ready: verdict.ready
|
|
6508
|
+
}
|
|
6509
|
+
});
|
|
6510
|
+
}
|
|
6090
6511
|
resolveSource(request) {
|
|
6091
6512
|
const deviceId = request.deviceId;
|
|
6092
6513
|
const camStreamId = this.resolvedCamStreamId(request);
|
|
@@ -6097,6 +6518,7 @@ var EgressTranscodeManager = class {
|
|
|
6097
6518
|
const stream = this.opts.cameraStreamLookup(deviceId).find((s) => s.camStreamId === camStreamId);
|
|
6098
6519
|
const declaredCodec = stream?.codec !== void 0 && stream.codec.length > 0;
|
|
6099
6520
|
return {
|
|
6521
|
+
brokerId,
|
|
6100
6522
|
url: entry.url,
|
|
6101
6523
|
codec: normaliseSourceCodec(stream?.codec),
|
|
6102
6524
|
width: stream?.resolution?.width ?? 1280,
|
|
@@ -6729,13 +7151,13 @@ var EVENT_CLIP_MAX_HEIGHT = 1080;
|
|
|
6729
7151
|
* `pickClipProfile` and the answer reports which was used.
|
|
6730
7152
|
*/
|
|
6731
7153
|
function pickBestClipProfile(candidates) {
|
|
6732
|
-
const usable = candidates.filter((c) => normaliseCodec$
|
|
7154
|
+
const usable = candidates.filter((c) => normaliseCodec$2(c.codec) === "h264" && (c.height ?? 0) > 0 && (c.width ?? 0) > 0);
|
|
6733
7155
|
if (usable.length === 0) return null;
|
|
6734
7156
|
const within = usable.filter((c) => (c.height ?? 0) <= EVENT_CLIP_MAX_HEIGHT);
|
|
6735
7157
|
return [...within.length > 0 ? within : usable].sort((a, b) => within.length > 0 ? (b.height ?? 0) - (a.height ?? 0) : (a.height ?? 0) - (b.height ?? 0))[0]?.profile ?? null;
|
|
6736
7158
|
}
|
|
6737
7159
|
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
6738
|
-
function normaliseCodec$
|
|
7160
|
+
function normaliseCodec$2(codec) {
|
|
6739
7161
|
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
6740
7162
|
}
|
|
6741
7163
|
/**
|
|
@@ -7181,6 +7603,249 @@ function runGif(input, args) {
|
|
|
7181
7603
|
});
|
|
7182
7604
|
}
|
|
7183
7605
|
//#endregion
|
|
7606
|
+
//#region src/stream-broker/stream-broker/prestream-encode.ts
|
|
7607
|
+
/**
|
|
7608
|
+
* ONE short-lived ffmpeg turns the prestream's picture into ONE Annex-B IDR.
|
|
7609
|
+
*
|
|
7610
|
+
* ── Why one encode and not a continuous child ─────────────────────────────
|
|
7611
|
+
* The obvious shape is `-loop 1 -framerate 1` and let ffmpeg emit the picture
|
|
7612
|
+
* for as long as the camera is waking. That is a resident child per sleeping
|
|
7613
|
+
* camera, for the whole wake, to redraw a picture that changes at most hourly.
|
|
7614
|
+
* So the child encodes exactly ONE frame (`-frames:v 1`) and exits, the caller
|
|
7615
|
+
* caches the bytes, and {@link PrestreamPump} re-emits them. A new encode
|
|
7616
|
+
* happens only when the picture changes.
|
|
7617
|
+
*
|
|
7618
|
+
* ── Why every frame carries its own parameter sets ────────────────────────
|
|
7619
|
+
* The pump re-emits ONE access unit at 1 fps, so a consumer that attaches on
|
|
7620
|
+
* tick 4 sees its first packet with nothing before it. `-g 1` plus
|
|
7621
|
+
* `repeat-headers=1` makes that packet a complete IDR with SPS/PPS (or
|
|
7622
|
+
* VPS/SPS/PPS) inline, so it decodes within a second of attaching instead of
|
|
7623
|
+
* waiting for a keyframe that will never come.
|
|
7624
|
+
*
|
|
7625
|
+
* ── Why the geometry comes from the plan and nowhere else ─────────────────
|
|
7626
|
+
* `planPrestream` refuses a camera whose geometry nobody has probed, precisely
|
|
7627
|
+
* so this file never has a default to fall back on. `stream-broker.ts` records
|
|
7628
|
+
* what a wrong one does: a fixed 1280x720 placeholder SPS in front of a
|
|
7629
|
+
* camera's real IDR froze Chrome's decoder on the last synthetic frame until
|
|
7630
|
+
* the viewer was recreated by hand. Hence `scale` mode `exact`, the plan's
|
|
7631
|
+
* numbers, and the plan's codec.
|
|
7632
|
+
*
|
|
7633
|
+
* ── Why no text is drawn here ─────────────────────────────────────────────
|
|
7634
|
+
* D118: the hub carries two ffmpeg builds and only one has libfreetype, so
|
|
7635
|
+
* `drawtext` is forbidden. The picture arrives already composed — sharp + an
|
|
7636
|
+
* inline SVG, the house pattern from `snapshot-courtesy.ts`.
|
|
7637
|
+
*/
|
|
7638
|
+
/**
|
|
7639
|
+
* Bound on the encode. A single 4 MP still on `ultrafast` is milliseconds; a
|
|
7640
|
+
* child that has not answered in this long is hung, and the prestream would
|
|
7641
|
+
* otherwise hold a promise open for the rest of the process's life.
|
|
7642
|
+
*/
|
|
7643
|
+
var PRESTREAM_ENCODE_TIMEOUT_MS = 1e4;
|
|
7644
|
+
/** Grace before escalating SIGTERM → SIGKILL on a child that will not go. */
|
|
7645
|
+
var KILL_GRACE_MS = 500;
|
|
7646
|
+
/** x264/x265 params shared by both codecs: one-frame GOP, headers on it. */
|
|
7647
|
+
var STILL_ENCODER_PARAMS = "keyint=1:repeat-headers=1";
|
|
7648
|
+
/**
|
|
7649
|
+
* The argv. PURE, so the two properties that matter — the geometry is the
|
|
7650
|
+
* plan's, and the frame is a self-contained IDR — are assertable without a
|
|
7651
|
+
* binary.
|
|
7652
|
+
*/
|
|
7653
|
+
function buildPrestreamEncodeArgs(plan) {
|
|
7654
|
+
const h265 = plan.codec === "h265";
|
|
7655
|
+
return require_dist.buildFfmpegArgs({
|
|
7656
|
+
logLevel: "error",
|
|
7657
|
+
decodeHwAccel: null,
|
|
7658
|
+
input: {
|
|
7659
|
+
url: "pipe:0",
|
|
7660
|
+
extraArgs: [
|
|
7661
|
+
"-f",
|
|
7662
|
+
"image2pipe",
|
|
7663
|
+
"-framerate",
|
|
7664
|
+
"1"
|
|
7665
|
+
]
|
|
7666
|
+
},
|
|
7667
|
+
video: {
|
|
7668
|
+
kind: "encode",
|
|
7669
|
+
encoder: h265 ? "libx265" : "libx264",
|
|
7670
|
+
scale: {
|
|
7671
|
+
mode: "exact",
|
|
7672
|
+
width: plan.width,
|
|
7673
|
+
height: plan.height
|
|
7674
|
+
},
|
|
7675
|
+
preset: "ultrafast",
|
|
7676
|
+
...h265 ? {} : { tune: "stillimage" },
|
|
7677
|
+
pixelFormat: "yuv420p",
|
|
7678
|
+
fps: 1,
|
|
7679
|
+
gopFrames: 1,
|
|
7680
|
+
bf: 0
|
|
7681
|
+
},
|
|
7682
|
+
audio: { kind: "none" },
|
|
7683
|
+
threadCount: 1,
|
|
7684
|
+
outputArgs: [
|
|
7685
|
+
"-frames:v",
|
|
7686
|
+
"1",
|
|
7687
|
+
h265 ? "-x265-params" : "-x264-params",
|
|
7688
|
+
STILL_ENCODER_PARAMS
|
|
7689
|
+
],
|
|
7690
|
+
sink: {
|
|
7691
|
+
kind: "stdout",
|
|
7692
|
+
container: h265 ? "hevc" : "h264"
|
|
7693
|
+
}
|
|
7694
|
+
});
|
|
7695
|
+
}
|
|
7696
|
+
/**
|
|
7697
|
+
* Run the encode. Resolves with the Annex-B bytes, rejects with a named reason
|
|
7698
|
+
* — the caller turns a rejection into a refusal log and no prestream, which is
|
|
7699
|
+
* the same outcome as a black screen and never worse than one.
|
|
7700
|
+
*/
|
|
7701
|
+
function encodePrestreamIdr(options) {
|
|
7702
|
+
const args = buildPrestreamEncodeArgs(options.plan);
|
|
7703
|
+
const spawnFn = options.spawnFn ?? node_child_process.spawn;
|
|
7704
|
+
const tags = options.deviceId === void 0 ? {} : { tags: { deviceId: options.deviceId } };
|
|
7705
|
+
return new Promise((resolve, reject) => {
|
|
7706
|
+
let child;
|
|
7707
|
+
try {
|
|
7708
|
+
child = spawnFn(options.ffmpegPath, args);
|
|
7709
|
+
} catch (err) {
|
|
7710
|
+
reject(/* @__PURE__ */ new Error(`prestream encode could not spawn ffmpeg: ${require_dist.errMsg(err)}`));
|
|
7711
|
+
return;
|
|
7712
|
+
}
|
|
7713
|
+
const chunks = [];
|
|
7714
|
+
let stderrTail = "";
|
|
7715
|
+
let settled = false;
|
|
7716
|
+
const finish = (outcome) => {
|
|
7717
|
+
if (settled) return;
|
|
7718
|
+
settled = true;
|
|
7719
|
+
clearTimeout(timer);
|
|
7720
|
+
if ("error" in outcome) reject(outcome.error);
|
|
7721
|
+
else resolve(outcome.bytes);
|
|
7722
|
+
};
|
|
7723
|
+
const timer = setTimeout(() => {
|
|
7724
|
+
killChild(child);
|
|
7725
|
+
finish({ error: /* @__PURE__ */ new Error(`prestream encode timed out after ${String(PRESTREAM_ENCODE_TIMEOUT_MS)} ms`) });
|
|
7726
|
+
}, PRESTREAM_ENCODE_TIMEOUT_MS);
|
|
7727
|
+
timer.unref?.();
|
|
7728
|
+
child.stdout?.on("data", (chunk) => chunks.push(chunk));
|
|
7729
|
+
child.stderr?.on("data", (chunk) => {
|
|
7730
|
+
stderrTail = `${stderrTail}${chunk.toString("utf8")}`.slice(-2048);
|
|
7731
|
+
});
|
|
7732
|
+
child.stdin?.on("error", () => {});
|
|
7733
|
+
child.on("error", (err) => {
|
|
7734
|
+
finish({ error: /* @__PURE__ */ new Error(`prestream encode failed: ${err.message}`) });
|
|
7735
|
+
});
|
|
7736
|
+
child.on("exit", (code, signal) => {
|
|
7737
|
+
if (code !== 0) {
|
|
7738
|
+
finish({ error: /* @__PURE__ */ new Error(`prestream encode exited ${String(code ?? signal)}: ${stderrTail.trim() || "no stderr"}`) });
|
|
7739
|
+
return;
|
|
7740
|
+
}
|
|
7741
|
+
const bytes = Buffer.concat(chunks);
|
|
7742
|
+
if (bytes.length === 0) {
|
|
7743
|
+
finish({ error: /* @__PURE__ */ new Error("prestream encode produced no bytes") });
|
|
7744
|
+
return;
|
|
7745
|
+
}
|
|
7746
|
+
options.logger?.debug("prestream: picture encoded", {
|
|
7747
|
+
...tags,
|
|
7748
|
+
meta: {
|
|
7749
|
+
codec: options.plan.codec,
|
|
7750
|
+
width: options.plan.width,
|
|
7751
|
+
height: options.plan.height,
|
|
7752
|
+
bytes: bytes.length
|
|
7753
|
+
}
|
|
7754
|
+
});
|
|
7755
|
+
finish({ bytes });
|
|
7756
|
+
});
|
|
7757
|
+
child.stdin?.end(options.jpeg);
|
|
7758
|
+
});
|
|
7759
|
+
}
|
|
7760
|
+
function killChild(child) {
|
|
7761
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
7762
|
+
child.kill("SIGTERM");
|
|
7763
|
+
setTimeout(() => {
|
|
7764
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
7765
|
+
}, KILL_GRACE_MS).unref?.();
|
|
7766
|
+
}
|
|
7767
|
+
//#endregion
|
|
7768
|
+
//#region src/stream-broker/stream-broker/prestream-picture.ts
|
|
7769
|
+
/**
|
|
7770
|
+
* The PICTURE the prestream shows: the camera's own last frame under a "Waking"
|
|
7771
|
+
* treatment, or — when there is none — a generated card carrying its name.
|
|
7772
|
+
*
|
|
7773
|
+
* ── Why a fallback exists at all ──────────────────────────────────────────
|
|
7774
|
+
* The snapshot cache is RAM-only. After a hub restart a sleeping battery camera
|
|
7775
|
+
* has no cached frame, and `getSnapshot` without `force` will not wake it to
|
|
7776
|
+
* make one (`force` is an OPERATOR signal — a background caller that sets it
|
|
7777
|
+
* wakes the camera it was trying not to disturb). So on exactly the cameras
|
|
7778
|
+
* this feature is for, "no picture" is a normal state, not an error. A card
|
|
7779
|
+
* saying WAKING with the camera's name is worse than the real frame and much
|
|
7780
|
+
* better than black — and it is what makes the broker servable inside Amazon's
|
|
7781
|
+
* six-second SDP deadline on a cold hub.
|
|
7782
|
+
*
|
|
7783
|
+
* ── Why the source is reported ────────────────────────────────────────────
|
|
7784
|
+
* The two look identical to a consumer and are very different to an operator:
|
|
7785
|
+
* one means the cache is warm, the other means it is cold. The caller logs
|
|
7786
|
+
* which was used, per camera.
|
|
7787
|
+
*
|
|
7788
|
+
* Pure input → bytes. No snapshot fetch, no device lookup, no I/O of its own —
|
|
7789
|
+
* the caller does both and hands the results in.
|
|
7790
|
+
*/
|
|
7791
|
+
/**
|
|
7792
|
+
* Compose the picture.
|
|
7793
|
+
*
|
|
7794
|
+
* An overlay that cannot be rendered (a truncated or non-decodable cached
|
|
7795
|
+
* frame) falls through to the generated card rather than leaking an unlabelled
|
|
7796
|
+
* stale frame — the same call the snapshot wrapper's `stateImage` makes, for
|
|
7797
|
+
* the same reason.
|
|
7798
|
+
*/
|
|
7799
|
+
async function composePrestreamPicture(input) {
|
|
7800
|
+
const tags = input.deviceId === void 0 ? {} : { tags: { deviceId: input.deviceId } };
|
|
7801
|
+
if (input.cachedJpeg !== null && input.cachedJpeg.length > 0) try {
|
|
7802
|
+
return {
|
|
7803
|
+
jpeg: await (0, _camstack_system_addon_utils.renderCourtesyOverlayJpeg)(input.cachedJpeg, {
|
|
7804
|
+
deviceName: input.deviceName,
|
|
7805
|
+
reason: "waking"
|
|
7806
|
+
}),
|
|
7807
|
+
source: "cached-snapshot"
|
|
7808
|
+
};
|
|
7809
|
+
} catch (err) {
|
|
7810
|
+
input.logger?.warn("prestream: cached frame could not be treated — using the card", {
|
|
7811
|
+
...tags,
|
|
7812
|
+
meta: { error: require_dist.errMsg(err) }
|
|
7813
|
+
});
|
|
7814
|
+
}
|
|
7815
|
+
return {
|
|
7816
|
+
jpeg: await (0, _camstack_system_addon_utils.renderCourtesyJpeg)({
|
|
7817
|
+
deviceName: input.deviceName,
|
|
7818
|
+
reason: "waking",
|
|
7819
|
+
width: input.width,
|
|
7820
|
+
height: input.height
|
|
7821
|
+
}),
|
|
7822
|
+
source: "generated-card"
|
|
7823
|
+
};
|
|
7824
|
+
}
|
|
7825
|
+
function planPrestream(input) {
|
|
7826
|
+
if (input.sourceUrl === void 0 || !input.sourceUrl.startsWith("lazy:rfc4571:")) return { refusal: "not-lazy" };
|
|
7827
|
+
if (input.width === void 0 || input.height === void 0 || input.width <= 0 || input.height <= 0) return { refusal: "unknown-geometry" };
|
|
7828
|
+
const codec = normaliseCodec$1(input.codec);
|
|
7829
|
+
if (codec === null) return { refusal: "unknown-codec" };
|
|
7830
|
+
return { plan: {
|
|
7831
|
+
width: input.width,
|
|
7832
|
+
height: input.height,
|
|
7833
|
+
codec
|
|
7834
|
+
} };
|
|
7835
|
+
}
|
|
7836
|
+
/**
|
|
7837
|
+
* `null` for a codec nobody has read. NOT a default of h264: a prestream in the
|
|
7838
|
+
* wrong codec is a stream the consumer cannot decode at all, which is worse
|
|
7839
|
+
* than the black screen it replaces.
|
|
7840
|
+
*/
|
|
7841
|
+
function normaliseCodec$1(codec) {
|
|
7842
|
+
if (codec === void 0) return null;
|
|
7843
|
+
const c = codec.toLowerCase().replace(/[.\s-]/g, "");
|
|
7844
|
+
if (c === "h264" || c === "avc" || c === "avc1") return "h264";
|
|
7845
|
+
if (c === "h265" || c === "hevc" || c === "hvc1" || c === "hev1") return "h265";
|
|
7846
|
+
return null;
|
|
7847
|
+
}
|
|
7848
|
+
//#endregion
|
|
7184
7849
|
//#region src/stream-broker/stream-broker/profile-restreamers.ts
|
|
7185
7850
|
/**
|
|
7186
7851
|
* Diff the desired profile→source aliasing against the current
|
|
@@ -11314,6 +11979,124 @@ function getPlaceholderFrame(kind, codec) {
|
|
|
11314
11979
|
return buf;
|
|
11315
11980
|
}
|
|
11316
11981
|
//#endregion
|
|
11982
|
+
//#region src/stream-broker/stream-broker/prestream-pump.ts
|
|
11983
|
+
/**
|
|
11984
|
+
* The prestream pump: ONE encode, then a 1 fps re-emit until real media lands.
|
|
11985
|
+
*
|
|
11986
|
+
* ── What it is for ────────────────────────────────────────────────────────
|
|
11987
|
+
* A lazy source is BUILT on demand, and on this hub that build costs 2.2 to
|
|
11988
|
+
* 12.4 s. For all of it the consumer had black — and Amazon allows SIX SECONDS
|
|
11989
|
+
* for an Alexa SDP answer, so a cold battery camera could not answer an Echo at
|
|
11990
|
+
* all. The pump makes the broker servable before the camera is: the camera's
|
|
11991
|
+
* own last picture under a "Waking" treatment, at the camera's real geometry,
|
|
11992
|
+
* replaced by real media the instant the first real packet arrives.
|
|
11993
|
+
*
|
|
11994
|
+
* ── The three rules it exists to hold ─────────────────────────────────────
|
|
11995
|
+
* 1. **Prepare ONCE.** The picture is fetched and encoded a single time per
|
|
11996
|
+
* wake and the bytes are cached here. Re-fetching per tick would put a
|
|
11997
|
+
* snapshot round-trip and a fork+exec on every second of every waking
|
|
11998
|
+
* camera, and would move frames across a process boundary at frame-rate —
|
|
11999
|
+
* D9/D18.
|
|
12000
|
+
* 2. **A late preparation lands nowhere.** The encode is async. If the camera
|
|
12001
|
+
* woke while it was running the pump has already been stopped, and starting
|
|
12002
|
+
* the timer then would paint a still picture over live media.
|
|
12003
|
+
* 3. **It never dials.** The pump has no source, no socket and no demand. It
|
|
12004
|
+
* is started alongside the publisher request, never instead of it.
|
|
12005
|
+
*
|
|
12006
|
+
* Emission is the caller's: {@link PrestreamPumpOptions.emit} is where the
|
|
12007
|
+
* `isPlaceholder: true` packet is built, because that flag belongs to the
|
|
12008
|
+
* broker's packet contract and the containment that reads it lives there.
|
|
12009
|
+
*/
|
|
12010
|
+
var DEFAULT_PRESTREAM_INTERVAL_MS = 1e3;
|
|
12011
|
+
var PrestreamPump = class {
|
|
12012
|
+
options;
|
|
12013
|
+
intervalMs;
|
|
12014
|
+
tags;
|
|
12015
|
+
timer;
|
|
12016
|
+
/** True from `start()` until `stop()`, INCLUDING while the encode runs. */
|
|
12017
|
+
active = false;
|
|
12018
|
+
frame = null;
|
|
12019
|
+
startedAt = 0;
|
|
12020
|
+
emitted = 0;
|
|
12021
|
+
constructor(options) {
|
|
12022
|
+
this.options = options;
|
|
12023
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_PRESTREAM_INTERVAL_MS;
|
|
12024
|
+
this.tags = options.deviceId === void 0 ? {} : { tags: { deviceId: options.deviceId } };
|
|
12025
|
+
}
|
|
12026
|
+
/** Whether the window is currently being filled (or about to be). */
|
|
12027
|
+
get running() {
|
|
12028
|
+
return this.active;
|
|
12029
|
+
}
|
|
12030
|
+
/**
|
|
12031
|
+
* Open the window. Idempotent: a second call while the first preparation is
|
|
12032
|
+
* still in flight is a no-op, so a reconnect tick that re-enters the lazy
|
|
12033
|
+
* branch every second does not spawn an encode every second.
|
|
12034
|
+
*/
|
|
12035
|
+
start(sourceUrl) {
|
|
12036
|
+
if (this.active) return;
|
|
12037
|
+
this.active = true;
|
|
12038
|
+
this.startedAt = Date.now();
|
|
12039
|
+
this.emitted = 0;
|
|
12040
|
+
this.options.prepare({ sourceUrl }).then((frame) => {
|
|
12041
|
+
if (!this.active) {
|
|
12042
|
+
this.options.logger?.debug("prestream: preparation landed after the window closed", { ...this.tags });
|
|
12043
|
+
return;
|
|
12044
|
+
}
|
|
12045
|
+
if (frame === null) {
|
|
12046
|
+
this.options.logger?.info("prestream: refused — the window goes unfilled", { ...this.tags });
|
|
12047
|
+
this.active = false;
|
|
12048
|
+
return;
|
|
12049
|
+
}
|
|
12050
|
+
this.frame = frame;
|
|
12051
|
+
this.options.logger?.info("prestream: filling the wake window", {
|
|
12052
|
+
...this.tags,
|
|
12053
|
+
meta: {
|
|
12054
|
+
codec: frame.codec,
|
|
12055
|
+
bytes: frame.annexB.length,
|
|
12056
|
+
intervalMs: this.intervalMs,
|
|
12057
|
+
preparedInMs: Date.now() - this.startedAt
|
|
12058
|
+
}
|
|
12059
|
+
});
|
|
12060
|
+
this.tick();
|
|
12061
|
+
this.timer = setInterval(() => this.tick(), this.intervalMs);
|
|
12062
|
+
}).catch((err) => {
|
|
12063
|
+
this.options.logger?.warn("prestream: preparation failed — the window goes unfilled", {
|
|
12064
|
+
...this.tags,
|
|
12065
|
+
meta: { error: require_dist.errMsg(err) }
|
|
12066
|
+
});
|
|
12067
|
+
this.active = false;
|
|
12068
|
+
});
|
|
12069
|
+
}
|
|
12070
|
+
/**
|
|
12071
|
+
* Close the window. Called on the first NON-placeholder packet — the one edge
|
|
12072
|
+
* the prestream is defined by — and on the broker's own teardown paths so a
|
|
12073
|
+
* stopped broker leaves no timer behind.
|
|
12074
|
+
*/
|
|
12075
|
+
stop(reason) {
|
|
12076
|
+
if (!this.active) return;
|
|
12077
|
+
this.active = false;
|
|
12078
|
+
if (this.timer) {
|
|
12079
|
+
clearInterval(this.timer);
|
|
12080
|
+
this.timer = void 0;
|
|
12081
|
+
}
|
|
12082
|
+
this.options.logger?.info("prestream: window closed", {
|
|
12083
|
+
...this.tags,
|
|
12084
|
+
meta: {
|
|
12085
|
+
reason,
|
|
12086
|
+
framesEmitted: this.emitted,
|
|
12087
|
+
heldMs: Date.now() - this.startedAt
|
|
12088
|
+
}
|
|
12089
|
+
});
|
|
12090
|
+
this.frame = null;
|
|
12091
|
+
}
|
|
12092
|
+
tick() {
|
|
12093
|
+
const frame = this.frame;
|
|
12094
|
+
if (!this.active || frame === null) return;
|
|
12095
|
+
this.emitted++;
|
|
12096
|
+
this.options.emit(frame);
|
|
12097
|
+
}
|
|
12098
|
+
};
|
|
12099
|
+
//#endregion
|
|
11317
12100
|
//#region src/stream-broker/stream-broker/stream-pipe-server.ts
|
|
11318
12101
|
/**
|
|
11319
12102
|
* A lightweight TCP server that broadcasts raw encoded video bytes
|
|
@@ -11606,6 +12389,23 @@ var StreamBroker = class StreamBroker {
|
|
|
11606
12389
|
audioMutedDropLogged = false;
|
|
11607
12390
|
reconnectTimer;
|
|
11608
12391
|
placeholderTimer;
|
|
12392
|
+
/**
|
|
12393
|
+
* The prestream pump, armed by the manager via `setPrestreamProvider`.
|
|
12394
|
+
* `undefined` on a broker whose manager supplied none — the pre-prestream
|
|
12395
|
+
* behaviour, unchanged.
|
|
12396
|
+
*/
|
|
12397
|
+
prestreamPump;
|
|
12398
|
+
/**
|
|
12399
|
+
* Latched once real media has been seen on this session.
|
|
12400
|
+
*
|
|
12401
|
+
* The lazy branch is re-entered on every materialize poll tick, so stopping
|
|
12402
|
+
* the pump is not enough on its own: the very next tick would re-open a
|
|
12403
|
+
* window that real bytes have already closed, and the consumer would get a
|
|
12404
|
+
* still picture painted over live video every second. Reset exactly where
|
|
12405
|
+
* `firstKeyframeReceived` is — broker stop and suspend — so a battery camera
|
|
12406
|
+
* that goes back to sleep gets its prestream again on the next wake.
|
|
12407
|
+
*/
|
|
12408
|
+
prestreamWindowClosed = false;
|
|
11609
12409
|
reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
|
|
11610
12410
|
/**
|
|
11611
12411
|
* Dial rate-limiter (2026-07-23 storm fix): the earliest wall-clock at
|
|
@@ -11700,6 +12500,15 @@ var StreamBroker = class StreamBroker {
|
|
|
11700
12500
|
*/
|
|
11701
12501
|
materializeRequestedAt = 0;
|
|
11702
12502
|
/**
|
|
12503
|
+
* The hold a camera's own signal bought. See `wake-hold.ts` — a term of
|
|
12504
|
+
* {@link hasLiveIntent}, so the predicate's owner owns the state: putting it
|
|
12505
|
+
* in the manager would give two places a vote on whether to dial.
|
|
12506
|
+
*/
|
|
12507
|
+
wakeHold = NO_HOLD;
|
|
12508
|
+
/** Fires when the hold lapses, so the suspend happens instead of waiting for
|
|
12509
|
+
* an unrelated edge. Re-armed on every extension. */
|
|
12510
|
+
wakeHoldTimer;
|
|
12511
|
+
/**
|
|
11703
12512
|
* Rolling source-RTP activity watchdog. Distinct from the first-video
|
|
11704
12513
|
* watchdog: this one ARMS at DIAL time (so a connection that opens the
|
|
11705
12514
|
* loopback TCP but never delivers media is still detected) and RESETS on
|
|
@@ -11999,6 +12808,18 @@ var StreamBroker = class StreamBroker {
|
|
|
11999
12808
|
getLastPacketAt() {
|
|
12000
12809
|
return this.lastPacketAt;
|
|
12001
12810
|
}
|
|
12811
|
+
/**
|
|
12812
|
+
* Whether a LAZY publisher is being built right now.
|
|
12813
|
+
*
|
|
12814
|
+
* A consumer deciding whether it is worth waiting for this source needs to
|
|
12815
|
+
* tell "cold and idle" from "waking up": the first cannot be waited for at
|
|
12816
|
+
* all (the dial is the demand that starts the wake), the second can.
|
|
12817
|
+
* `getFirstVideoWatchdogStatus` carries the same number and is a TEST SEAM —
|
|
12818
|
+
* this is the production read.
|
|
12819
|
+
*/
|
|
12820
|
+
isMaterializing() {
|
|
12821
|
+
return this.materializeRequestedAt > 0;
|
|
12822
|
+
}
|
|
12002
12823
|
/** Source type of the active stream (rtsp/push/rtmp/placeholder/error). */
|
|
12003
12824
|
getActiveSourceType() {
|
|
12004
12825
|
return this.source?.type ?? "unknown";
|
|
@@ -12087,6 +12908,38 @@ var StreamBroker = class StreamBroker {
|
|
|
12087
12908
|
this.requestSourceRefresh = notify;
|
|
12088
12909
|
}
|
|
12089
12910
|
/**
|
|
12911
|
+
* Arm the PRESTREAM — the fill for the window between "someone asked for this
|
|
12912
|
+
* camera" and "the camera is awake".
|
|
12913
|
+
*
|
|
12914
|
+
* Manager-owned, because everything the fill needs is the manager's: the
|
|
12915
|
+
* camera's probed geometry and codec (`planPrestream`), its last cached
|
|
12916
|
+
* snapshot, its name, and the ffmpeg binary. The broker contributes the one
|
|
12917
|
+
* thing it owns — WHEN the window is open — and nothing else.
|
|
12918
|
+
*
|
|
12919
|
+
* A broker with no provider behaves exactly as it did before the prestream
|
|
12920
|
+
* existed: the lazy branch asks the publisher to materialize and the consumer
|
|
12921
|
+
* sees black, which is the outcome every refusal falls back to.
|
|
12922
|
+
*/
|
|
12923
|
+
setPrestreamProvider(provider) {
|
|
12924
|
+
this.prestreamPump = new PrestreamPump({
|
|
12925
|
+
prepare: provider.prepare,
|
|
12926
|
+
emit: (frame) => {
|
|
12927
|
+
const now = Date.now();
|
|
12928
|
+
this.pushEncodedPacket({
|
|
12929
|
+
type: "video",
|
|
12930
|
+
data: frame.annexB,
|
|
12931
|
+
pts: now,
|
|
12932
|
+
dts: now,
|
|
12933
|
+
keyframe: true,
|
|
12934
|
+
codec: frame.codec,
|
|
12935
|
+
isPlaceholder: true
|
|
12936
|
+
});
|
|
12937
|
+
},
|
|
12938
|
+
...this.logger ? { logger: this.logger } : {},
|
|
12939
|
+
...this.numericDeviceId === void 0 ? {} : { deviceId: this.numericDeviceId }
|
|
12940
|
+
});
|
|
12941
|
+
}
|
|
12942
|
+
/**
|
|
12090
12943
|
* Fire `requestSourceRefresh` at most once per ~500 ms. The
|
|
12091
12944
|
* dial-failure path fans into onError + reader.connect().catch; we
|
|
12092
12945
|
* only want one round-trip to the publisher per real failure.
|
|
@@ -12136,7 +12989,7 @@ var StreamBroker = class StreamBroker {
|
|
|
12136
12989
|
*/
|
|
12137
12990
|
redial(reason) {
|
|
12138
12991
|
if (this.manualStop || this.stopping || !this.source) return;
|
|
12139
|
-
if (!this.
|
|
12992
|
+
if (!this.hasLiveIntent()) return;
|
|
12140
12993
|
this.logger?.info("source redial — the camera changed under a live reader", { meta: {
|
|
12141
12994
|
reason,
|
|
12142
12995
|
status: this._status
|
|
@@ -12152,7 +13005,7 @@ var StreamBroker = class StreamBroker {
|
|
|
12152
13005
|
}
|
|
12153
13006
|
kickReconnect() {
|
|
12154
13007
|
if (this.manualStop || this.stopping || !this.source) return;
|
|
12155
|
-
if (!this.
|
|
13008
|
+
if (!this.hasLiveIntent()) return;
|
|
12156
13009
|
if (!this.reconnectTimer) return;
|
|
12157
13010
|
const nowMs = Date.now();
|
|
12158
13011
|
if (nowMs - this.lastKickAt < INITIAL_RECONNECT_DELAY_MS) return;
|
|
@@ -12194,7 +13047,7 @@ var StreamBroker = class StreamBroker {
|
|
|
12194
13047
|
} });
|
|
12195
13048
|
await this.pipeServer.start();
|
|
12196
13049
|
this.logger?.debug("Pipe server started", { meta: { url: this.pipeServer.getUrl() } });
|
|
12197
|
-
if (isLazyDialSource(source.type) && !this.
|
|
13050
|
+
if (isLazyDialSource(source.type) && !this.hasLiveIntent()) {
|
|
12198
13051
|
this._suspended = true;
|
|
12199
13052
|
this._status = "idle";
|
|
12200
13053
|
this.logger?.info("Broker registered idle — no demand, source dial deferred");
|
|
@@ -12252,6 +13105,8 @@ var StreamBroker = class StreamBroker {
|
|
|
12252
13105
|
clearInterval(this.placeholderTimer);
|
|
12253
13106
|
this.placeholderTimer = void 0;
|
|
12254
13107
|
}
|
|
13108
|
+
this.prestreamPump?.stop("broker stopped");
|
|
13109
|
+
this.prestreamWindowClosed = false;
|
|
12255
13110
|
if (this.reconnectTimer) {
|
|
12256
13111
|
clearTimeout(this.reconnectTimer);
|
|
12257
13112
|
this.reconnectTimer = void 0;
|
|
@@ -12413,14 +13268,98 @@ var StreamBroker = class StreamBroker {
|
|
|
12413
13268
|
this.checkDemand();
|
|
12414
13269
|
}
|
|
12415
13270
|
/** True when at least one consumer needs the stream alive. */
|
|
12416
|
-
|
|
13271
|
+
/**
|
|
13272
|
+
* A signal the CAMERA reported about itself.
|
|
13273
|
+
*
|
|
13274
|
+
* Two terms must hold before anything is dialled, and they are the whole
|
|
13275
|
+
* safety of this feature:
|
|
13276
|
+
*
|
|
13277
|
+
* - the caller has already checked the signal is ENABLED for this camera
|
|
13278
|
+
* (the broker manager owns that setting), and
|
|
13279
|
+
* - somebody is actually waiting on the prestream. Waking a camera nobody is
|
|
13280
|
+
* watching is the one thing a battery camera must never do, and a signal
|
|
13281
|
+
* arriving with no consumer is exactly that.
|
|
13282
|
+
*
|
|
13283
|
+
* Returns what it did, so the caller's log line can say it. A hold that is
|
|
13284
|
+
* merely EXTENDED does not re-dial: the camera is already up.
|
|
13285
|
+
*/
|
|
13286
|
+
reportWakeSignal(code, holdMs, at) {
|
|
13287
|
+
if (this.rtspRestreamer.getPrestreamSessionCount() === 0) return "no-consumer";
|
|
13288
|
+
const wasLive = holdIsLive(this.wakeHold, at);
|
|
13289
|
+
const { hold, outcome } = absorbSignal({
|
|
13290
|
+
current: this.wakeHold,
|
|
13291
|
+
now: at,
|
|
13292
|
+
holdMs
|
|
13293
|
+
});
|
|
13294
|
+
this.wakeHold = hold;
|
|
13295
|
+
this.logger?.info("wake signal from the camera", {
|
|
13296
|
+
tags: { deviceId: this.numericDeviceId },
|
|
13297
|
+
meta: {
|
|
13298
|
+
brokerId: this.deviceId,
|
|
13299
|
+
code,
|
|
13300
|
+
outcome,
|
|
13301
|
+
signals: hold.signals,
|
|
13302
|
+
untilMs: Math.max(0, hold.until - at),
|
|
13303
|
+
runMs: hold.runStartedAt > 0 ? at - hold.runStartedAt : 0
|
|
13304
|
+
}
|
|
13305
|
+
});
|
|
13306
|
+
this.armWakeHoldTimer(at);
|
|
13307
|
+
if (outcome === "quarantined") return "no-consumer";
|
|
13308
|
+
if (wasLive) return "hold-extended";
|
|
13309
|
+
this.checkDemand();
|
|
13310
|
+
return "woke";
|
|
13311
|
+
}
|
|
13312
|
+
/**
|
|
13313
|
+
* Look at the hold when it lapses, so the camera actually goes back to sleep
|
|
13314
|
+
* instead of waiting for an unrelated edge to notice.
|
|
13315
|
+
*/
|
|
13316
|
+
armWakeHoldTimer(now) {
|
|
13317
|
+
if (this.wakeHoldTimer) clearTimeout(this.wakeHoldTimer);
|
|
13318
|
+
const delay = this.wakeHold.until - now;
|
|
13319
|
+
if (delay <= 0) return;
|
|
13320
|
+
this.wakeHoldTimer = setTimeout(() => {
|
|
13321
|
+
this.wakeHoldTimer = void 0;
|
|
13322
|
+
this.logger?.info("wake hold lapsed — back to the prestream picture", {
|
|
13323
|
+
tags: { deviceId: this.numericDeviceId },
|
|
13324
|
+
meta: {
|
|
13325
|
+
brokerId: this.deviceId,
|
|
13326
|
+
signals: this.wakeHold.signals,
|
|
13327
|
+
runMs: this.wakeHold.runStartedAt > 0 ? Date.now() - this.wakeHold.runStartedAt : 0
|
|
13328
|
+
}
|
|
13329
|
+
});
|
|
13330
|
+
this.checkDemand();
|
|
13331
|
+
}, delay);
|
|
13332
|
+
this.wakeHoldTimer.unref?.();
|
|
13333
|
+
}
|
|
13334
|
+
/**
|
|
13335
|
+
* Anything that must be FED, prestream sessions included.
|
|
13336
|
+
*
|
|
13337
|
+
* The only consumer of this is the prestream pump: it says "somebody is
|
|
13338
|
+
* watching, keep the picture going", and says nothing about whether the
|
|
13339
|
+
* camera may be dialled. {@link hasLiveIntent} answers that, and it is the
|
|
13340
|
+
* one every dial path asks.
|
|
13341
|
+
*/
|
|
13342
|
+
hasAnyConsumer() {
|
|
13343
|
+
if (this.hasLiveIntent()) return true;
|
|
13344
|
+
return this.rtspRestreamer.getPrestreamSessionCount() > 0;
|
|
13345
|
+
}
|
|
13346
|
+
/**
|
|
13347
|
+
* Consumers that may DIAL. Every term of the former `hasDemand`, with the
|
|
13348
|
+
* RTSP session count filtered to exclude prestream dials, plus the wake hold.
|
|
13349
|
+
*
|
|
13350
|
+
* For a broker with no prestream session attached this is term-for-term what
|
|
13351
|
+
* `hasDemand` was, which is why every existing camera, consumer and test
|
|
13352
|
+
* behaves identically.
|
|
13353
|
+
*/
|
|
13354
|
+
hasLiveIntent() {
|
|
12417
13355
|
if (this.encodedCallbacks.size > 0) return true;
|
|
12418
13356
|
if (this.rtpVideoCallbacks.size > 0) return true;
|
|
12419
13357
|
if ((this.frameHandlePlane?.subscriberCount ?? 0) > 0) return true;
|
|
12420
13358
|
if (this.audioChunkPlane.subscriberCount > 0) return true;
|
|
12421
|
-
if (this.rtspRestreamer.
|
|
13359
|
+
if (this.rtspRestreamer.getLiveIntentSessionCount() > 0) return true;
|
|
12422
13360
|
if (this.pipeServer.getClientCount() > 0) return true;
|
|
12423
13361
|
if (this.prebufferHold !== null) return true;
|
|
13362
|
+
if (holdIsLive(this.wakeHold, Date.now())) return true;
|
|
12424
13363
|
return false;
|
|
12425
13364
|
}
|
|
12426
13365
|
/**
|
|
@@ -12536,7 +13475,7 @@ var StreamBroker = class StreamBroker {
|
|
|
12536
13475
|
checkDemand() {
|
|
12537
13476
|
if (this.manualStop || this.stopping) return;
|
|
12538
13477
|
this.reconcileAudioConsumer();
|
|
12539
|
-
if (this.
|
|
13478
|
+
if (this.hasLiveIntent()) {
|
|
12540
13479
|
if (!this.demandActive) {
|
|
12541
13480
|
this.demandActive = true;
|
|
12542
13481
|
this.demandObserver?.(true);
|
|
@@ -12560,9 +13499,19 @@ var StreamBroker = class StreamBroker {
|
|
|
12560
13499
|
} else if (!this._suspended) {
|
|
12561
13500
|
if (!this.suspendTimer) this.suspendTimer = setTimeout(() => {
|
|
12562
13501
|
this.suspendTimer = void 0;
|
|
12563
|
-
if (!this.
|
|
13502
|
+
if (!this.hasLiveIntent() && !this.manualStop && !this.stopping) this.suspend();
|
|
12564
13503
|
}, SUSPEND_GRACE_MS);
|
|
12565
13504
|
}
|
|
13505
|
+
this.prestreamWanted = this.hasAnyConsumer() && !this.hasLiveIntent();
|
|
13506
|
+
}
|
|
13507
|
+
/**
|
|
13508
|
+
* Whether a prestream picture should be flowing right now: somebody is
|
|
13509
|
+
* watching and the camera is not up. Read by the pump.
|
|
13510
|
+
*/
|
|
13511
|
+
prestreamWanted = false;
|
|
13512
|
+
/** Whether the picture should be flowing — the pump's one question. */
|
|
13513
|
+
wantsPrestream() {
|
|
13514
|
+
return this.prestreamWanted;
|
|
12566
13515
|
}
|
|
12567
13516
|
/** Suspend the stream: kill ffmpeg/native client, clear buffers, go idle. Source is preserved for resume. */
|
|
12568
13517
|
suspend() {
|
|
@@ -12587,6 +13536,8 @@ var StreamBroker = class StreamBroker {
|
|
|
12587
13536
|
clearInterval(this.placeholderTimer);
|
|
12588
13537
|
this.placeholderTimer = void 0;
|
|
12589
13538
|
}
|
|
13539
|
+
this.prestreamPump?.stop("broker suspended");
|
|
13540
|
+
this.prestreamWindowClosed = false;
|
|
12590
13541
|
this.preBuffer.clear();
|
|
12591
13542
|
this.decoderSeedBuffer.clear();
|
|
12592
13543
|
this.clipBuffer?.clear();
|
|
@@ -12670,6 +13621,10 @@ var StreamBroker = class StreamBroker {
|
|
|
12670
13621
|
}
|
|
12671
13622
|
pushEncodedPacket(packet) {
|
|
12672
13623
|
if (this.stopping) return;
|
|
13624
|
+
if (packet.isPlaceholder !== true && !this.prestreamWindowClosed) {
|
|
13625
|
+
this.prestreamWindowClosed = true;
|
|
13626
|
+
this.prestreamPump?.stop("first real packet");
|
|
13627
|
+
}
|
|
12673
13628
|
if (this.source?.type === "push") {
|
|
12674
13629
|
this.stopErrorPlaceholder();
|
|
12675
13630
|
if (this._status === "error") this._status = "streaming";
|
|
@@ -12711,17 +13666,17 @@ var StreamBroker = class StreamBroker {
|
|
|
12711
13666
|
}
|
|
12712
13667
|
}
|
|
12713
13668
|
if (packet.type === "video" && !this.isRtpSource()) this.preBuffer.push(packet);
|
|
12714
|
-
if (packet.type === "video" && this.clipBuffer) this.clipBuffer.push(packet);
|
|
13669
|
+
if (packet.type === "video" && this.clipBuffer && packet.isPlaceholder !== true) this.clipBuffer.push(packet);
|
|
12715
13670
|
this.fanOutEncoded(packet);
|
|
12716
13671
|
if (packet.type === "video") {
|
|
12717
13672
|
this.pipeServer.broadcast(packet.data);
|
|
12718
13673
|
if (!this.rtspRestreamer.rtpPassthroughMode) this.rtspRestreamer.pushPacket(packet);
|
|
12719
|
-
if (!this.firstKeyframeReceived && packet.keyframe) {
|
|
13674
|
+
if (!this.firstKeyframeReceived && packet.keyframe && packet.isPlaceholder !== true) {
|
|
12720
13675
|
this.firstKeyframeReceived = true;
|
|
12721
13676
|
this.logger?.info("First keyframe received — decoder can start");
|
|
12722
13677
|
}
|
|
12723
13678
|
}
|
|
12724
|
-
if (packet.type === "video" && this.firstKeyframeReceived) {
|
|
13679
|
+
if (packet.type === "video" && this.firstKeyframeReceived && packet.isPlaceholder !== true) {
|
|
12725
13680
|
const plane = this.frameHandlePlane;
|
|
12726
13681
|
if (plane) {
|
|
12727
13682
|
const decoderPacket = this.decoderPacketWithParamSets(packet);
|
|
@@ -14285,7 +15240,7 @@ var StreamBroker = class StreamBroker {
|
|
|
14285
15240
|
}
|
|
14286
15241
|
scheduleReconnect() {
|
|
14287
15242
|
if (this.manualStop || !this.source) return;
|
|
14288
|
-
if (!this.
|
|
15243
|
+
if (!this.hasLiveIntent()) {
|
|
14289
15244
|
this._suspended = true;
|
|
14290
15245
|
this._status = "idle";
|
|
14291
15246
|
this.stopErrorPlaceholder();
|
|
@@ -14397,6 +15352,7 @@ var StreamBroker = class StreamBroker {
|
|
|
14397
15352
|
});
|
|
14398
15353
|
this._status = "error";
|
|
14399
15354
|
this.notifySourceRefresh();
|
|
15355
|
+
if (!this.prestreamWindowClosed) this.prestreamPump?.start(source.url);
|
|
14400
15356
|
this.scheduleReconnect();
|
|
14401
15357
|
return;
|
|
14402
15358
|
}
|
|
@@ -15126,7 +16082,23 @@ var DeviceOverrideSchema = require_dist.object({
|
|
|
15126
16082
|
derivedStreams: require_dist.record(require_dist.string(), DerivedStreamSchema).optional(),
|
|
15127
16083
|
hwaccel: require_dist.record(require_dist.string(), FfmpegHwAccelSettingSchema).optional(),
|
|
15128
16084
|
sourceParser: require_dist.record(require_dist.string(), SourceParserSettingSchema).optional(),
|
|
15129
|
-
sourceParserArgs: require_dist.record(require_dist.string(), SourceParserArgsEntrySchema).optional()
|
|
16085
|
+
sourceParserArgs: require_dist.record(require_dist.string(), SourceParserArgsEntrySchema).optional(),
|
|
16086
|
+
/**
|
|
16087
|
+
* The on-demand PRESTREAM, per camera.
|
|
16088
|
+
*
|
|
16089
|
+
* Absent means the operator has never touched it, which is NOT the same as
|
|
16090
|
+
* off: `enabledSignals` absent means "every signal this camera recommends",
|
|
16091
|
+
* so a Reolink works the moment a prestream URL is dialled without an
|
|
16092
|
+
* operator hunting for a checkbox. An empty ARRAY is a deliberate "none" and
|
|
16093
|
+
* is honoured — the two are different answers and the shape keeps them
|
|
16094
|
+
* apart.
|
|
16095
|
+
*/
|
|
16096
|
+
prestream: require_dist.object({
|
|
16097
|
+
/** Signal codes allowed to wake this camera. Absent ⇒ the recommended set. */
|
|
16098
|
+
enabledSignals: require_dist.array(require_dist.string()).optional(),
|
|
16099
|
+
/** How long the camera stays up after the LAST signal. Absent ⇒ default. */
|
|
16100
|
+
holdMs: require_dist.number().int().min(1e3).max(3e5).optional()
|
|
16101
|
+
}).optional()
|
|
15130
16102
|
});
|
|
15131
16103
|
/**
|
|
15132
16104
|
* Read `audioEnabled` off a `privacyMask.getStatus` payload (D83 reconcile).
|
|
@@ -15270,6 +16242,12 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
15270
16242
|
profileRestreamerBindings = /* @__PURE__ */ new Map();
|
|
15271
16243
|
/** Per-device published cam-stream registry. */
|
|
15272
16244
|
cameraStreams = /* @__PURE__ */ new Map();
|
|
16245
|
+
/**
|
|
16246
|
+
* The encoded prestream frame per broker, with the digest of the picture it
|
|
16247
|
+
* was made from. One entry is one still image — kilobytes — and it is
|
|
16248
|
+
* dropped with the broker in `releaseBroker`.
|
|
16249
|
+
*/
|
|
16250
|
+
prestreamFrames = /* @__PURE__ */ new Map();
|
|
15273
16251
|
/** Monotonic insertion counter per device — feeds `CameraStreamInternal.order`. */
|
|
15274
16252
|
streamOrderSeq = /* @__PURE__ */ new Map();
|
|
15275
16253
|
/** Persisted operator intent: which cam stream serves which profile. */
|
|
@@ -15618,6 +16596,14 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
15618
16596
|
const entry = this.rtspProvider.getEntry(brokerId);
|
|
15619
16597
|
return entry ? { url: entry.url } : null;
|
|
15620
16598
|
},
|
|
16599
|
+
sourceHasMedia: (brokerId) => {
|
|
16600
|
+
const broker = this.brokers.get(brokerId);
|
|
16601
|
+
if (broker === void 0) return null;
|
|
16602
|
+
return sourceWaitState({
|
|
16603
|
+
hasMedia: broker.getLastPacketAt() > 0,
|
|
16604
|
+
materializing: broker.isMaterializing()
|
|
16605
|
+
});
|
|
16606
|
+
},
|
|
15621
16607
|
ffmpegConfig: () => this.ffmpegConfig,
|
|
15622
16608
|
...this.costRegistry === null ? {} : { costRegistry: this.costRegistry },
|
|
15623
16609
|
resolveDecodeHwAccel: (deviceId, override) => this.resolveEgressHwAccel(deviceId, override),
|
|
@@ -16152,6 +17138,150 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16152
17138
|
* firehose so we don't filter the entire device-state stream
|
|
16153
17139
|
* client-side and we get a typed payload.
|
|
16154
17140
|
*/
|
|
17141
|
+
/**
|
|
17142
|
+
* Build the per-broker PRESTREAM provider — the fill for the window between
|
|
17143
|
+
* "someone asked for this camera" and "the camera is awake".
|
|
17144
|
+
*
|
|
17145
|
+
* On this hub that window is 2.2-12.4 s for a lazy source, and it is not
|
|
17146
|
+
* merely ugly: Amazon allows SIX SECONDS for an Alexa SDP answer, so a cold
|
|
17147
|
+
* battery camera could not answer an Echo at all.
|
|
17148
|
+
*
|
|
17149
|
+
* Four facts are needed and all four are the manager's, which is why the
|
|
17150
|
+
* provider is a closure here rather than anything the broker knows about:
|
|
17151
|
+
* the probed geometry and codec ({@link planPrestream}), the camera's last
|
|
17152
|
+
* cached snapshot, its name, and the ffmpeg binary path.
|
|
17153
|
+
*
|
|
17154
|
+
* `null` is a REFUSAL, named in the log. Refusing is cheap — the consumer
|
|
17155
|
+
* gets the black screen it got before the prestream existed — and the
|
|
17156
|
+
* alternative for an unknown geometry is the decoder freeze the whole
|
|
17157
|
+
* synthetic-frame path is shaped around.
|
|
17158
|
+
*/
|
|
17159
|
+
buildPrestreamProvider(deviceId, camStreamId, brokerId, logger) {
|
|
17160
|
+
return { prepare: async ({ sourceUrl }) => {
|
|
17161
|
+
const gate = prestreamEligible({
|
|
17162
|
+
batteryOperated: await this.isBatteryOperated(deviceId),
|
|
17163
|
+
declaresSignals: (await this.declaredSignalCodes(deviceId)).length > 0
|
|
17164
|
+
});
|
|
17165
|
+
if (!gate.eligible) {
|
|
17166
|
+
logger.info("prestream: not offered for this camera", {
|
|
17167
|
+
tags: { deviceId },
|
|
17168
|
+
meta: {
|
|
17169
|
+
brokerId,
|
|
17170
|
+
reason: gate.reason
|
|
17171
|
+
}
|
|
17172
|
+
});
|
|
17173
|
+
return null;
|
|
17174
|
+
}
|
|
17175
|
+
const stream = this.cameraStreams.get(deviceId)?.get(camStreamId);
|
|
17176
|
+
const codec = this.lastProbedByBroker.get(brokerId)?.codec ?? stream?.codec;
|
|
17177
|
+
const planned = planPrestream({
|
|
17178
|
+
sourceUrl,
|
|
17179
|
+
width: stream?.resolution?.width,
|
|
17180
|
+
height: stream?.resolution?.height,
|
|
17181
|
+
codec
|
|
17182
|
+
});
|
|
17183
|
+
if ("refusal" in planned) {
|
|
17184
|
+
logger.info("prestream: no fill for this camera", {
|
|
17185
|
+
tags: { deviceId },
|
|
17186
|
+
meta: {
|
|
17187
|
+
refusal: planned.refusal,
|
|
17188
|
+
camStreamId,
|
|
17189
|
+
width: stream?.resolution?.width ?? null,
|
|
17190
|
+
height: stream?.resolution?.height ?? null,
|
|
17191
|
+
codec: codec ?? null
|
|
17192
|
+
}
|
|
17193
|
+
});
|
|
17194
|
+
return null;
|
|
17195
|
+
}
|
|
17196
|
+
return await this.preparePrestreamFrame(deviceId, brokerId, planned.plan, logger);
|
|
17197
|
+
} };
|
|
17198
|
+
}
|
|
17199
|
+
/**
|
|
17200
|
+
* Fetch the picture, compose the treatment, encode ONCE and cache the bytes.
|
|
17201
|
+
*
|
|
17202
|
+
* The cache is keyed on the picture's own digest plus the geometry, so a
|
|
17203
|
+
* second wake of the same camera re-uses the frame and spawns nothing: the
|
|
17204
|
+
* picture changes at most hourly, and a fork+exec per wake per sleeping
|
|
17205
|
+
* camera is the cost this shape exists to avoid.
|
|
17206
|
+
*/
|
|
17207
|
+
async preparePrestreamFrame(deviceId, brokerId, plan, logger) {
|
|
17208
|
+
if (!this.api) {
|
|
17209
|
+
logger.info("prestream: no api access — the window goes unfilled", { tags: { deviceId } });
|
|
17210
|
+
return null;
|
|
17211
|
+
}
|
|
17212
|
+
const api = this.api;
|
|
17213
|
+
let cachedJpeg = null;
|
|
17214
|
+
try {
|
|
17215
|
+
const image = await api.snapshot.getSnapshot.query({ deviceId });
|
|
17216
|
+
if (image) cachedJpeg = Buffer.from(image.base64, "base64");
|
|
17217
|
+
} catch (err) {
|
|
17218
|
+
logger.debug("prestream: cached snapshot read failed — falling back to the card", {
|
|
17219
|
+
tags: { deviceId },
|
|
17220
|
+
meta: { error: require_dist.errMsg(err) }
|
|
17221
|
+
});
|
|
17222
|
+
}
|
|
17223
|
+
let deviceName = `#${String(deviceId)}`;
|
|
17224
|
+
try {
|
|
17225
|
+
const device = await api.deviceManager.getDevice.query({ deviceId });
|
|
17226
|
+
if (device?.name) deviceName = device.name;
|
|
17227
|
+
} catch (err) {
|
|
17228
|
+
logger.debug("prestream: device name read failed — using the numeric id", {
|
|
17229
|
+
tags: { deviceId },
|
|
17230
|
+
meta: { error: require_dist.errMsg(err) }
|
|
17231
|
+
});
|
|
17232
|
+
}
|
|
17233
|
+
const key = `${(0, node_crypto.createHash)("sha1").update(cachedJpeg ?? Buffer.alloc(0)).update(deviceName).digest("hex")}:${String(plan.width)}x${String(plan.height)}:${plan.codec}`;
|
|
17234
|
+
const cached = this.prestreamFrames.get(brokerId);
|
|
17235
|
+
if (cached && cached.key === key) {
|
|
17236
|
+
logger.debug("prestream: re-using the encoded frame", {
|
|
17237
|
+
tags: { deviceId },
|
|
17238
|
+
meta: { bytes: cached.frame.annexB.length }
|
|
17239
|
+
});
|
|
17240
|
+
return cached.frame;
|
|
17241
|
+
}
|
|
17242
|
+
const picture = await composePrestreamPicture({
|
|
17243
|
+
cachedJpeg,
|
|
17244
|
+
deviceName,
|
|
17245
|
+
width: plan.width,
|
|
17246
|
+
height: plan.height,
|
|
17247
|
+
logger,
|
|
17248
|
+
deviceId
|
|
17249
|
+
});
|
|
17250
|
+
logger.info("prestream: picture composed", {
|
|
17251
|
+
tags: { deviceId },
|
|
17252
|
+
meta: {
|
|
17253
|
+
source: picture.source,
|
|
17254
|
+
width: plan.width,
|
|
17255
|
+
height: plan.height,
|
|
17256
|
+
codec: plan.codec
|
|
17257
|
+
}
|
|
17258
|
+
});
|
|
17259
|
+
let annexB;
|
|
17260
|
+
try {
|
|
17261
|
+
annexB = await encodePrestreamIdr({
|
|
17262
|
+
ffmpegPath: this.ffmpegConfig.binaryPath,
|
|
17263
|
+
plan,
|
|
17264
|
+
jpeg: picture.jpeg,
|
|
17265
|
+
logger,
|
|
17266
|
+
deviceId
|
|
17267
|
+
});
|
|
17268
|
+
} catch (err) {
|
|
17269
|
+
logger.warn("prestream: encode failed — the window goes unfilled", {
|
|
17270
|
+
tags: { deviceId },
|
|
17271
|
+
meta: { error: require_dist.errMsg(err) }
|
|
17272
|
+
});
|
|
17273
|
+
return null;
|
|
17274
|
+
}
|
|
17275
|
+
const frame = {
|
|
17276
|
+
annexB,
|
|
17277
|
+
codec: plan.codec
|
|
17278
|
+
};
|
|
17279
|
+
this.prestreamFrames.set(brokerId, {
|
|
17280
|
+
key,
|
|
17281
|
+
frame
|
|
17282
|
+
});
|
|
17283
|
+
return frame;
|
|
17284
|
+
}
|
|
16155
17285
|
subscribePlaceholderStateSources(bus) {
|
|
16156
17286
|
bus.subscribe({ category: require_dist.EventCategory.BatteryOnStatusChanged }, (event) => {
|
|
16157
17287
|
const data = event.data;
|
|
@@ -16306,11 +17436,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16306
17436
|
if (!api) return;
|
|
16307
17437
|
await this.batteryWakeOnPlay.run(deviceId, {
|
|
16308
17438
|
logger: this.logger,
|
|
16309
|
-
isBatteryDevice:
|
|
16310
|
-
const dev = await api.deviceManager.getDevice.query({ deviceId: id }).catch(() => null);
|
|
16311
|
-
if (!dev) return null;
|
|
16312
|
-
return dev.features.includes(require_dist.DeviceFeature.BatteryOperated);
|
|
16313
|
-
},
|
|
17439
|
+
isBatteryDevice: (id) => this.isBatteryOperated(id),
|
|
16314
17440
|
readBatteryFacts: async (id) => {
|
|
16315
17441
|
const slice = await api.deviceState.getCapSlice.query({
|
|
16316
17442
|
deviceId: id,
|
|
@@ -17843,6 +18969,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
17843
18969
|
const parsed = this.parseBrokerId(brokerId);
|
|
17844
18970
|
if (!parsed) continue;
|
|
17845
18971
|
const { deviceId, camStreamId } = parsed;
|
|
18972
|
+
if (!healthEventIsOperatorFacing(camStreamId)) continue;
|
|
17846
18973
|
const lastPacketAt = broker.getLastPacketAt();
|
|
17847
18974
|
const wasHealthy = this.streamHealthByBroker.get(brokerId);
|
|
17848
18975
|
const isHealthy = lastPacketAt > 0 && now - lastPacketAt <= STREAM_STALE_TIMEOUT_MS;
|
|
@@ -18133,6 +19260,93 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
18133
19260
|
* falls back to the global `ffmpeg.hwAccel`). Read by the transcode pipeline
|
|
18134
19261
|
* per spawn; affects ONLY the transcode paths.
|
|
18135
19262
|
*/
|
|
19263
|
+
/**
|
|
19264
|
+
* A device reporting one of its own signals — the cap method the provider
|
|
19265
|
+
* calls. See `streamBroker.reportStreamSignal` for the contract.
|
|
19266
|
+
*
|
|
19267
|
+
* Everything that could refuse says WHY, because this is the path that
|
|
19268
|
+
* decides whether a battery camera wakes, and a silent refusal here is
|
|
19269
|
+
* indistinguishable from a signal that never arrived.
|
|
19270
|
+
*/
|
|
19271
|
+
async reportStreamSignal(input) {
|
|
19272
|
+
const at = input.at ?? Date.now();
|
|
19273
|
+
const declared = await this.declaredSignalCodes(input.deviceId);
|
|
19274
|
+
if (!declared.includes(input.code)) {
|
|
19275
|
+
this.logger.warn("wake signal REFUSED — this camera never declared that code", {
|
|
19276
|
+
tags: { deviceId: input.deviceId },
|
|
19277
|
+
meta: {
|
|
19278
|
+
code: input.code,
|
|
19279
|
+
declared
|
|
19280
|
+
}
|
|
19281
|
+
});
|
|
19282
|
+
return {
|
|
19283
|
+
accepted: false,
|
|
19284
|
+
reason: "unknown-code"
|
|
19285
|
+
};
|
|
19286
|
+
}
|
|
19287
|
+
const settings = this.deviceOverrides.get(input.deviceId)?.prestream;
|
|
19288
|
+
if (!(settings?.enabledSignals ?? declared).includes(input.code)) return {
|
|
19289
|
+
accepted: false,
|
|
19290
|
+
reason: "not-enabled"
|
|
19291
|
+
};
|
|
19292
|
+
const holdMs = settings?.holdMs ?? 15e3;
|
|
19293
|
+
let outcome = "no-consumer";
|
|
19294
|
+
for (const [brokerId, broker] of this.brokers) {
|
|
19295
|
+
if (this.parseBrokerId(brokerId)?.deviceId !== input.deviceId) continue;
|
|
19296
|
+
const result = broker.reportWakeSignal(input.code, holdMs, at);
|
|
19297
|
+
if (result === "woke") outcome = "woke";
|
|
19298
|
+
else if (result === "hold-extended" && outcome !== "woke") outcome = "hold-extended";
|
|
19299
|
+
}
|
|
19300
|
+
return {
|
|
19301
|
+
accepted: outcome !== "no-consumer",
|
|
19302
|
+
reason: outcome
|
|
19303
|
+
};
|
|
19304
|
+
}
|
|
19305
|
+
/**
|
|
19306
|
+
* What this camera says it can emit. `[]` when it declares the cap and lists
|
|
19307
|
+
* nothing, and `[]` when it does not declare the cap at all — both mean no
|
|
19308
|
+
* signal of that name is acceptable, which is the same refusal.
|
|
19309
|
+
*/
|
|
19310
|
+
async declaredSignalCodes(deviceId) {
|
|
19311
|
+
const read = this.deviceSignalReader;
|
|
19312
|
+
if (read === null) return [];
|
|
19313
|
+
try {
|
|
19314
|
+
return await read(deviceId);
|
|
19315
|
+
} catch {
|
|
19316
|
+
return [];
|
|
19317
|
+
}
|
|
19318
|
+
}
|
|
19319
|
+
/**
|
|
19320
|
+
* How the manager asks a DEVICE what signals it declares. Injected by the
|
|
19321
|
+
* host addon, which owns the device proxy. `null` until it is.
|
|
19322
|
+
*/
|
|
19323
|
+
deviceSignalReader = null;
|
|
19324
|
+
setDeviceSignalReader(read) {
|
|
19325
|
+
this.deviceSignalReader = read;
|
|
19326
|
+
}
|
|
19327
|
+
/**
|
|
19328
|
+
* Whether this device runs on a battery.
|
|
19329
|
+
*
|
|
19330
|
+
* The DEVICE REGISTRY, never the published cam-streams: a sleeping camera has
|
|
19331
|
+
* none — which is precisely when this question gets asked — and
|
|
19332
|
+
* `deviceHasFeature` reads the stream map, so it answers `false` for every
|
|
19333
|
+
* sleeping battery camera. That reasoning was written for the wake-on-play
|
|
19334
|
+
* path and belongs to the question, not to one caller of it.
|
|
19335
|
+
*
|
|
19336
|
+
* `null` when nobody could say, and unknown is refused by every caller rather
|
|
19337
|
+
* than guessed: guessing yes puts a mains camera in a wake-hold loop, and
|
|
19338
|
+
* guessing no silently disables the feature on the cameras it exists for.
|
|
19339
|
+
*/
|
|
19340
|
+
async isBatteryOperated(deviceId) {
|
|
19341
|
+
if (!this.api) return null;
|
|
19342
|
+
try {
|
|
19343
|
+
const features = (await this.api.deviceManager.getDevice.query({ deviceId }))?.features;
|
|
19344
|
+
if (!Array.isArray(features)) return null;
|
|
19345
|
+
return features.includes(require_dist.DeviceFeature.BatteryOperated);
|
|
19346
|
+
} catch {
|
|
19347
|
+
return null;
|
|
19348
|
+
}
|
|
19349
|
+
}
|
|
18136
19350
|
getStreamHwAccelOverride(deviceId, camStreamId) {
|
|
18137
19351
|
return this.deviceOverrides.get(deviceId)?.hwaccel?.[camStreamId] ?? null;
|
|
18138
19352
|
}
|
|
@@ -19300,6 +20514,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
19300
20514
|
broker.setRequestSourceRefresh(() => {
|
|
19301
20515
|
this.emitRequestStreamSourceRefresh(deviceId, camStreamId, brokerId);
|
|
19302
20516
|
});
|
|
20517
|
+
broker.setPrestreamProvider(this.buildPrestreamProvider(deviceId, camStreamId, brokerId, brokerLogger));
|
|
19303
20518
|
try {
|
|
19304
20519
|
await broker.start(source);
|
|
19305
20520
|
} catch (err) {
|
|
@@ -19347,6 +20562,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
19347
20562
|
this.streamOfflineReasonByBroker.delete(brokerId);
|
|
19348
20563
|
await broker.stop();
|
|
19349
20564
|
this.brokers.delete(brokerId);
|
|
20565
|
+
this.prestreamFrames.delete(brokerId);
|
|
19350
20566
|
this.sweepFrameSubscriptions(brokerId);
|
|
19351
20567
|
this.rtspProvider.persistTokens();
|
|
19352
20568
|
this.syncProfileRestreamers(deviceId);
|
|
@@ -19813,6 +21029,28 @@ var WEBRTC_FALLBACK_PROFILE = {
|
|
|
19813
21029
|
},
|
|
19814
21030
|
audio: "passthrough"
|
|
19815
21031
|
};
|
|
21032
|
+
function shouldWriteLossLine(input) {
|
|
21033
|
+
if (input.lastWritten === null) return {
|
|
21034
|
+
write: true,
|
|
21035
|
+
reason: "first"
|
|
21036
|
+
};
|
|
21037
|
+
if (input.sample.hasReceiverReport !== input.lastWritten.hasReceiverReport) return {
|
|
21038
|
+
write: true,
|
|
21039
|
+
reason: "reporting-changed"
|
|
21040
|
+
};
|
|
21041
|
+
if (Math.abs(input.sample.lossPct - input.lastWritten.lossPct) >= 2) return {
|
|
21042
|
+
write: true,
|
|
21043
|
+
reason: "loss-moved"
|
|
21044
|
+
};
|
|
21045
|
+
if (input.ticksSinceWrite >= 10) return {
|
|
21046
|
+
write: true,
|
|
21047
|
+
reason: "heartbeat"
|
|
21048
|
+
};
|
|
21049
|
+
return {
|
|
21050
|
+
write: false,
|
|
21051
|
+
reason: "quiet"
|
|
21052
|
+
};
|
|
21053
|
+
}
|
|
19816
21054
|
//#endregion
|
|
19817
21055
|
//#region src/stream-broker/webrtc/mdns-resolve.ts
|
|
19818
21056
|
/**
|
|
@@ -20788,6 +22026,14 @@ function selectAudioClock(mimeType) {
|
|
|
20788
22026
|
}
|
|
20789
22027
|
//#endregion
|
|
20790
22028
|
//#region src/stream-broker/webrtc/session.ts
|
|
22029
|
+
/**
|
|
22030
|
+
* Adaptive WebRTC session — extends the base session pattern with:
|
|
22031
|
+
* - RTCP Receiver Report monitoring (packet loss, jitter, RTT)
|
|
22032
|
+
* - Source replacement (replaceTrack for seamless quality switching)
|
|
22033
|
+
* - Stats emission for the AdaptiveController
|
|
22034
|
+
*
|
|
22035
|
+
* Uses werift (optional peer dependency) for server-side WebRTC.
|
|
22036
|
+
*/
|
|
20791
22037
|
var _werift;
|
|
20792
22038
|
async function loadWerift() {
|
|
20793
22039
|
if (_werift) return _werift;
|
|
@@ -21094,6 +22340,10 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
21094
22340
|
/** Log the camera's actual H.264 profile-level-id once per session. */
|
|
21095
22341
|
profileLogged = false;
|
|
21096
22342
|
onStats;
|
|
22343
|
+
consumerKind;
|
|
22344
|
+
/** The last loss sample actually WRITTEN, and how many ticks ago. */
|
|
22345
|
+
lastLossWritten = null;
|
|
22346
|
+
lossTicksSinceWrite = 0;
|
|
21097
22347
|
debug;
|
|
21098
22348
|
sourceCodec;
|
|
21099
22349
|
/**
|
|
@@ -21403,6 +22653,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
21403
22653
|
this.intercom = options.intercom;
|
|
21404
22654
|
this.iceConfig = options.iceConfig;
|
|
21405
22655
|
this.onStats = options.onStats;
|
|
22656
|
+
this.consumerKind = options.consumerKind ?? "unknown";
|
|
21406
22657
|
this.debug = options.debug ?? false;
|
|
21407
22658
|
this.sourceCodec = options.sourceCodec ?? "H264";
|
|
21408
22659
|
this.transcodeToBaseline = options.transcodeToBaseline ?? false;
|
|
@@ -23405,6 +24656,37 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
23405
24656
|
selectedRemoteType: nominated?.remoteType ?? "none",
|
|
23406
24657
|
selectedRemoteAddr: nominated?.remoteAddr ?? "none"
|
|
23407
24658
|
} });
|
|
24659
|
+
const rembRaw = this.videoSender?.receiverEstimatedMaxBitrate;
|
|
24660
|
+
const rembBps = typeof rembRaw === "bigint" && rembRaw > 0n ? Number(rembRaw) : -1;
|
|
24661
|
+
const sample = {
|
|
24662
|
+
hasReceiverReport: report !== void 0,
|
|
24663
|
+
lossPct: report ? Math.round(fractionLost / 256 * 100) : -1
|
|
24664
|
+
};
|
|
24665
|
+
this.lossTicksSinceWrite += 1;
|
|
24666
|
+
const verdict = shouldWriteLossLine({
|
|
24667
|
+
sample,
|
|
24668
|
+
lastWritten: this.lastLossWritten,
|
|
24669
|
+
ticksSinceWrite: this.lossTicksSinceWrite
|
|
24670
|
+
});
|
|
24671
|
+
if (verdict.write) {
|
|
24672
|
+
this.lastLossWritten = sample;
|
|
24673
|
+
this.lossTicksSinceWrite = 0;
|
|
24674
|
+
this.logger.info("webrtc loss", {
|
|
24675
|
+
tags: { deviceId: this.deviceId },
|
|
24676
|
+
meta: {
|
|
24677
|
+
sessionId: this.sessionId,
|
|
24678
|
+
consumerKind: this.consumerKind,
|
|
24679
|
+
why: verdict.reason,
|
|
24680
|
+
hasReceiverReport: sample.hasReceiverReport,
|
|
24681
|
+
rembBps,
|
|
24682
|
+
lossPct: sample.lossPct,
|
|
24683
|
+
packetsLost,
|
|
24684
|
+
jitterMs: report ? Math.round(jitter / 90) : -1,
|
|
24685
|
+
rttMs: report ? Math.round(rtt * 1e3) : -1,
|
|
24686
|
+
rtpPacketsSent: this.rtpPacketsSent
|
|
24687
|
+
}
|
|
24688
|
+
});
|
|
24689
|
+
}
|
|
23408
24690
|
if (report && this.onStats) this.onStats({
|
|
23409
24691
|
sessionId: this.sessionId,
|
|
23410
24692
|
packetLoss: fractionLost / 256,
|
|
@@ -28633,7 +29915,8 @@ var BrokerWebrtcServer = class {
|
|
|
28633
29915
|
logger: sessionLogger,
|
|
28634
29916
|
deviceId,
|
|
28635
29917
|
rtpBootstrap: useRtpRepacketizer ? () => broker.getRtpPreBuffer() : void 0,
|
|
28636
|
-
debug: opts.streamingDebug === true
|
|
29918
|
+
debug: opts.streamingDebug === true,
|
|
29919
|
+
consumerKind: baseAttribution.kind
|
|
28637
29920
|
});
|
|
28638
29921
|
const seedFromSdp = (ps) => {
|
|
28639
29922
|
if (sessionCodec === "H265") session.seedH265CodecInfoFromSdp(ps);
|
|
@@ -29016,7 +30299,11 @@ var BrokerWebrtcServer = class {
|
|
|
29016
30299
|
/** Live signaling state for a session — used by the webrtc-session
|
|
29017
30300
|
* provider's getSessionState. Returns a null pending when unknown. */
|
|
29018
30301
|
getSessionRenegotiationState(sessionId) {
|
|
29019
|
-
|
|
30302
|
+
const entry = this.sessions.get(sessionId);
|
|
30303
|
+
return {
|
|
30304
|
+
pendingRenegotiation: entry?.session.pendingRenegotiation ?? null,
|
|
30305
|
+
alive: entry !== void 0
|
|
30306
|
+
};
|
|
29020
30307
|
}
|
|
29021
30308
|
/**
|
|
29022
30309
|
* Wire an {@link AdaptiveController} to a freshly-created adaptive session.
|
|
@@ -29426,12 +30713,15 @@ var CameraStreamsProvider = class {
|
|
|
29426
30713
|
const accept = requirements.acceptCodecs ?? [];
|
|
29427
30714
|
const minHeight = requirements.minHeight ?? 0;
|
|
29428
30715
|
const minWidth = requirements.minWidth ?? 0;
|
|
30716
|
+
const maxHeight = requirements.maxHeight ?? Number.POSITIVE_INFINITY;
|
|
30717
|
+
const allowTranscode = requirements.allowTranscode === true;
|
|
29429
30718
|
const candidates = streams.filter((s) => {
|
|
29430
30719
|
if (!isSelectable(s)) return false;
|
|
29431
30720
|
if (excludeDerived && s.camStreamId.startsWith("derived:")) return false;
|
|
29432
|
-
if (accept.length > 0 && !codecMatches(s.codec, accept)) return false;
|
|
30721
|
+
if (!allowTranscode && accept.length > 0 && !codecMatches(s.codec, accept)) return false;
|
|
29433
30722
|
if ((s.resolution?.height ?? 0) < minHeight) return false;
|
|
29434
30723
|
if ((s.resolution?.width ?? 0) < minWidth) return false;
|
|
30724
|
+
if ((s.resolution?.height ?? 0) > maxHeight) return false;
|
|
29435
30725
|
return true;
|
|
29436
30726
|
});
|
|
29437
30727
|
if (candidates.length === 0) {
|
|
@@ -29476,24 +30766,35 @@ var CameraStreamsProvider = class {
|
|
|
29476
30766
|
for (let i = 0; i < preferredProviders.length; i += 1) if (camStreamId.startsWith(preferredProviders[i])) return i;
|
|
29477
30767
|
return preferredProviders.length;
|
|
29478
30768
|
};
|
|
29479
|
-
candidates.
|
|
29480
|
-
|
|
29481
|
-
|
|
29482
|
-
|
|
29483
|
-
|
|
29484
|
-
|
|
29485
|
-
|
|
29486
|
-
})
|
|
29487
|
-
|
|
30769
|
+
const ranked = require_dist.rankStreams(candidates.map((s) => ({
|
|
30770
|
+
camStreamId: s.camStreamId,
|
|
30771
|
+
...s.codec === void 0 ? {} : { codec: s.codec },
|
|
30772
|
+
...s.resolution ? {
|
|
30773
|
+
height: s.resolution.height,
|
|
30774
|
+
width: s.resolution.width
|
|
30775
|
+
} : {}
|
|
30776
|
+
})), {
|
|
30777
|
+
acceptCodecs: accept,
|
|
30778
|
+
allowTranscode,
|
|
30779
|
+
preferredProviders,
|
|
30780
|
+
resolutionPreference,
|
|
30781
|
+
...preferences?.targetHeight === void 0 ? {} : { targetHeight: preferences.targetHeight }
|
|
30782
|
+
});
|
|
30783
|
+
if (ranked === null) return null;
|
|
30784
|
+
const winner = candidates.find((s) => s.camStreamId === ranked.candidate.camStreamId);
|
|
30785
|
+
if (winner === void 0) return null;
|
|
29488
30786
|
const reasonParts = [];
|
|
29489
30787
|
reasonParts.push(`codec=${winner.codec ?? "unknown"}`);
|
|
29490
30788
|
if (winner.resolution) reasonParts.push(`res=${winner.resolution.width}x${winner.resolution.height}`);
|
|
29491
30789
|
reasonParts.push(`provider=${providerRank(winner.camStreamId)}`);
|
|
30790
|
+
reasonParts.push(ranked.transcodes ? "transcode" : "passthrough");
|
|
30791
|
+
if (preferences?.targetHeight !== void 0) reasonParts.push(`target=${preferences.targetHeight}`);
|
|
29492
30792
|
if (requireSibling.length > 0) reasonParts.push(`bypassed=${requireSibling.join("|")}`);
|
|
29493
30793
|
return {
|
|
29494
30794
|
camStreamId: winner.camStreamId,
|
|
29495
30795
|
codec: winner.codec,
|
|
29496
30796
|
resolution: winner.resolution,
|
|
30797
|
+
transcodes: ranked.transcodes,
|
|
29497
30798
|
reason: reasonParts.join(" ")
|
|
29498
30799
|
};
|
|
29499
30800
|
}
|
|
@@ -29965,6 +31266,9 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
29965
31266
|
const localNode = this.ctx.kernel.localNodeId;
|
|
29966
31267
|
const localNodeId = localNode ? localNode.includes("/") ? localNode.split("/")[0] : localNode : void 0;
|
|
29967
31268
|
this.brokerManager.setApiAccess(this.ctx.api, localNodeId, this.ctx.kernel.hwaccel);
|
|
31269
|
+
this.brokerManager.setDeviceSignalReader(async (deviceId) => {
|
|
31270
|
+
return (await (await this.ctx.fetchDevice(deviceId)).streamSignals?.listSignals({}) ?? []).map((s) => s.code);
|
|
31271
|
+
});
|
|
29968
31272
|
await this.loadFfmpegConfig();
|
|
29969
31273
|
await this.declareRowCollections();
|
|
29970
31274
|
await this.purgeFlattenedSettingsKeys();
|