@camstack/addon-pipeline 1.2.46 → 1.2.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio-analyzer/index.js +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +2 -2
- package/dist/detection-pipeline/index.mjs +2 -2
- package/dist/{dist-CPfFUIl9.js → dist-CksKE1Sn.js} +1399 -1285
- package/dist/{dist-CUj9mdhh.mjs → dist-DoTw0NM3.mjs} +1399 -1285
- package/dist/{event-loop-stall-monitor-md__iwWh.mjs → event-loop-stall-monitor-CtktCf3u.mjs} +1 -1
- package/dist/{event-loop-stall-monitor-BGbgcrEN.js → event-loop-stall-monitor-DzdwBr4X.js} +1 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/index.mjs +1 -1
- package/dist/session-decode/decode-worker-child.js +1 -1
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-aUWVCncO.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BNISghj9.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Cj2xMupA.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Czrsozsg.mjs +26 -0
- package/dist/stream-broker/{hostInit-CRwWRwJH.mjs → hostInit-DqD1qq8w.mjs} +2 -2
- package/dist/stream-broker/index.js +671 -101
- package/dist/stream-broker/index.mjs +671 -101
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-CD0M9YD7.mjs → worker-protocol-4_4L1QOe.mjs} +1 -1
- package/dist/{worker-protocol-hdTR-F5q.js → worker-protocol-CuEcTi66.js} +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C8eGXtf-.js → MaskShapeCanvas-DI4BY7W2-EcWZ5m8e.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-vvEcAgxS.js → MotionZonesSettings-NcxxQN8r-CfiyCD3z.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BLhx7VI-.js → PrivacyMaskSettings-APgPLF7p-ESPy4XXk.js} +1 -1
- package/embed-dist/assets/index-C3FKV1x-.js +112 -0
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DN2e2GJL.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-2D_L2y3g.mjs +0 -26
- package/embed-dist/assets/index-DvOgpqcE.js +0 -112
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { r as __toESM, t as __commonJSMin } from "../chunk-DnnnRqeS.mjs";
|
|
2
|
-
import { $ as errMsg, At as EventCategory, Dt as record, E as createHwAccelCache, Et as object, N as egressTranscodeSharingKey, Ot as string, P as egressTransportFromRequest, Q as webrtcSessionCapability, S as addonWidgetsSourceCapability, St as discriminatedUnion, T as cameraStreamsCapability, Tt as number, X as streamBrokerCapability, _ as RATE_CONTROL_RELAXED, at as BaseAddon, b as RingBuffer, bt as array, ct as DeviceType, d as EncodeProfileSchema, dt as isEvent, et as AUDIO_PRESETS, ft as makeProfileBrokerId, gt as parseProfileBrokerId, it as isSoftwareDecode, kt as union, lt as createEvent, mt as nodePin, nt as buildFfmpegArgs$1, ot as CAM_PROFILE_ORDER, pt as makeSourceBrokerId, q as resolveEgressDecodeHwAccel, rt as invocationFromEncodeProfile, st as DeviceFeature, tt as Fmp4BoxSplitter, v as RATE_CONTROL_TIGHT, wt as literal, xt as boolean, yt as _enum, z as maskUrlCredentials } from "../dist-
|
|
2
|
+
import { $ as errMsg, At as EventCategory, Dt as record, E as createHwAccelCache, Et as object, N as egressTranscodeSharingKey, Ot as string, P as egressTransportFromRequest, Q as webrtcSessionCapability, S as addonWidgetsSourceCapability, St as discriminatedUnion, T as cameraStreamsCapability, Tt as number, X as streamBrokerCapability, _ as RATE_CONTROL_RELAXED, at as BaseAddon, b as RingBuffer, bt as array, ct as DeviceType, d as EncodeProfileSchema, dt as isEvent, et as AUDIO_PRESETS, ft as makeProfileBrokerId, gt as parseProfileBrokerId, it as isSoftwareDecode, kt as union, lt as createEvent, mt as nodePin, nt as buildFfmpegArgs$1, ot as CAM_PROFILE_ORDER, pt as makeSourceBrokerId, q as resolveEgressDecodeHwAccel, rt as invocationFromEncodeProfile, st as DeviceFeature, tt as Fmp4BoxSplitter, v as RATE_CONTROL_TIGHT, wt as literal, xt as boolean, yt as _enum, z as maskUrlCredentials } from "../dist-DoTw0NM3.mjs";
|
|
3
3
|
import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
|
|
4
4
|
import { n as createFileDataPlaneHandler } from "../model-download-service-D8B-4ktF-BB7oZL3y.mjs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
@@ -3005,6 +3005,79 @@ function buildAudioCodecCapApi(api) {
|
|
|
3005
3005
|
flushEncode: (input) => router.flushEncode.mutate(input)
|
|
3006
3006
|
};
|
|
3007
3007
|
}
|
|
3008
|
+
/**
|
|
3009
|
+
* Minimum gap between two re-dials of the same device. A re-dial costs a
|
|
3010
|
+
* ~2 s video interruption on every source of the camera; at the 30 s
|
|
3011
|
+
* reconcile cadence, an un-cooled loop against a camera whose audio genuinely
|
|
3012
|
+
* cannot come back would be a permanent stutter.
|
|
3013
|
+
*/
|
|
3014
|
+
var AUDIO_PLANE_REDIAL_COOLDOWN_MS = 5 * 6e4;
|
|
3015
|
+
var EMPTY_AUDIO_PLANE_STATE = {
|
|
3016
|
+
disagreements: 0,
|
|
3017
|
+
redials: 0,
|
|
3018
|
+
lastRedialMs: 0,
|
|
3019
|
+
gaveUp: false
|
|
3020
|
+
};
|
|
3021
|
+
/**
|
|
3022
|
+
* Decide what one pass should do. Pure: no clock, no I/O, no broker.
|
|
3023
|
+
*
|
|
3024
|
+
* `nowMs` is passed in rather than read so the cooldown is testable without
|
|
3025
|
+
* waiting five minutes for it.
|
|
3026
|
+
*/
|
|
3027
|
+
function decideAudioPlaneAction(observation, state, nowMs) {
|
|
3028
|
+
switch (observation.kind) {
|
|
3029
|
+
case "broker-muted":
|
|
3030
|
+
case "no-live-source":
|
|
3031
|
+
case "audio-present":
|
|
3032
|
+
case "declared-off": return {
|
|
3033
|
+
action: { kind: "none" },
|
|
3034
|
+
nextState: EMPTY_AUDIO_PLANE_STATE
|
|
3035
|
+
};
|
|
3036
|
+
case "read-failed":
|
|
3037
|
+
case "audio-unknown": return {
|
|
3038
|
+
action: { kind: "none" },
|
|
3039
|
+
nextState: state
|
|
3040
|
+
};
|
|
3041
|
+
case "declared-on": {
|
|
3042
|
+
if (state.gaveUp) return {
|
|
3043
|
+
action: { kind: "none" },
|
|
3044
|
+
nextState: state
|
|
3045
|
+
};
|
|
3046
|
+
const disagreements = state.disagreements + 1;
|
|
3047
|
+
if (disagreements < 2) return {
|
|
3048
|
+
action: { kind: "await-second-pass" },
|
|
3049
|
+
nextState: {
|
|
3050
|
+
...state,
|
|
3051
|
+
disagreements
|
|
3052
|
+
}
|
|
3053
|
+
};
|
|
3054
|
+
if (state.redials >= 3) return {
|
|
3055
|
+
action: { kind: "give-up" },
|
|
3056
|
+
nextState: {
|
|
3057
|
+
...state,
|
|
3058
|
+
disagreements,
|
|
3059
|
+
gaveUp: true
|
|
3060
|
+
}
|
|
3061
|
+
};
|
|
3062
|
+
if (state.lastRedialMs > 0 && nowMs - state.lastRedialMs < 3e5) return {
|
|
3063
|
+
action: { kind: "cooldown" },
|
|
3064
|
+
nextState: {
|
|
3065
|
+
...state,
|
|
3066
|
+
disagreements: 2
|
|
3067
|
+
}
|
|
3068
|
+
};
|
|
3069
|
+
return {
|
|
3070
|
+
action: { kind: "redial" },
|
|
3071
|
+
nextState: {
|
|
3072
|
+
disagreements: 0,
|
|
3073
|
+
redials: state.redials + 1,
|
|
3074
|
+
lastRedialMs: nowMs,
|
|
3075
|
+
gaveUp: false
|
|
3076
|
+
}
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3008
3081
|
//#endregion
|
|
3009
3082
|
//#region src/stream-broker/stream-broker/dead-decode-backends.ts
|
|
3010
3083
|
/**
|
|
@@ -5025,6 +5098,69 @@ var EgressTranscodeManager = class {
|
|
|
5025
5098
|
};
|
|
5026
5099
|
}
|
|
5027
5100
|
};
|
|
5101
|
+
var EventMediaStore = class {
|
|
5102
|
+
deps;
|
|
5103
|
+
/** Insertion-ordered — `Map` iteration order IS the eviction order. */
|
|
5104
|
+
items = /* @__PURE__ */ new Map();
|
|
5105
|
+
totalBytes = 0;
|
|
5106
|
+
constructor(deps = {}) {
|
|
5107
|
+
this.deps = deps;
|
|
5108
|
+
}
|
|
5109
|
+
get size() {
|
|
5110
|
+
return this.items.size;
|
|
5111
|
+
}
|
|
5112
|
+
get bytes() {
|
|
5113
|
+
return this.totalBytes;
|
|
5114
|
+
}
|
|
5115
|
+
/** Store one artifact and return its handle. */
|
|
5116
|
+
put(input) {
|
|
5117
|
+
const now = this.deps.now ?? Date.now;
|
|
5118
|
+
this.sweep();
|
|
5119
|
+
const handle = `evm_${input.deviceId}_${(this.deps.makeId ?? defaultId)()}`;
|
|
5120
|
+
this.items.set(handle, {
|
|
5121
|
+
handle,
|
|
5122
|
+
storedAt: now(),
|
|
5123
|
+
...input
|
|
5124
|
+
});
|
|
5125
|
+
this.totalBytes += input.bytes.length;
|
|
5126
|
+
const maxBytes = this.deps.maxBytes ?? 67108864;
|
|
5127
|
+
while (this.totalBytes > maxBytes && this.items.size > 1) {
|
|
5128
|
+
const oldest = this.items.keys().next().value;
|
|
5129
|
+
if (oldest === void 0) break;
|
|
5130
|
+
this.drop(oldest);
|
|
5131
|
+
}
|
|
5132
|
+
return handle;
|
|
5133
|
+
}
|
|
5134
|
+
/** Redeem a handle. `null` when it never existed or has expired. */
|
|
5135
|
+
get(handle) {
|
|
5136
|
+
this.sweep();
|
|
5137
|
+
return this.items.get(handle) ?? null;
|
|
5138
|
+
}
|
|
5139
|
+
/** Drop everything — shutdown. */
|
|
5140
|
+
clear() {
|
|
5141
|
+
this.items.clear();
|
|
5142
|
+
this.totalBytes = 0;
|
|
5143
|
+
}
|
|
5144
|
+
sweep() {
|
|
5145
|
+
const now = (this.deps.now ?? Date.now)();
|
|
5146
|
+
const ttl = this.deps.ttlMs ?? 3e5;
|
|
5147
|
+
for (const [handle, item] of this.items) {
|
|
5148
|
+
if (now - item.storedAt < ttl) break;
|
|
5149
|
+
this.drop(handle);
|
|
5150
|
+
}
|
|
5151
|
+
}
|
|
5152
|
+
drop(handle) {
|
|
5153
|
+
const item = this.items.get(handle);
|
|
5154
|
+
if (item === void 0) return;
|
|
5155
|
+
this.items.delete(handle);
|
|
5156
|
+
this.totalBytes -= item.bytes.length;
|
|
5157
|
+
}
|
|
5158
|
+
};
|
|
5159
|
+
var counter = 0;
|
|
5160
|
+
function defaultId() {
|
|
5161
|
+
counter += 1;
|
|
5162
|
+
return `${Date.now().toString(36)}${counter.toString(36)}`;
|
|
5163
|
+
}
|
|
5028
5164
|
//#endregion
|
|
5029
5165
|
//#region src/stream-broker/webrtc/nal-utils.ts
|
|
5030
5166
|
var NAL_START_CODE_4B = Buffer.from([
|
|
@@ -5549,6 +5685,46 @@ function pickClipProfile(assigned, requested) {
|
|
|
5549
5685
|
return null;
|
|
5550
5686
|
}
|
|
5551
5687
|
/**
|
|
5688
|
+
* The tallest rendition an event clip is cut from.
|
|
5689
|
+
*
|
|
5690
|
+
* 1080 and not 720: the attachment is native-resolution H.264 that nothing
|
|
5691
|
+
* re-encodes, and the operator asked for a clip they could actually watch. A
|
|
5692
|
+
* few seconds of 4K copy is megabytes, which is the difference between "every
|
|
5693
|
+
* notifier takes it" and "pushover drops it at 5 MiB".
|
|
5694
|
+
*/
|
|
5695
|
+
var EVENT_CLIP_MAX_HEIGHT = 1080;
|
|
5696
|
+
/**
|
|
5697
|
+
* The rendition an EVENT production should cut from — the opposite default to
|
|
5698
|
+
* {@link pickClipProfile}.
|
|
5699
|
+
*
|
|
5700
|
+
* `pickClipProfile` takes the cheapest assigned slot, which was right when a
|
|
5701
|
+
* notification attachment was a 480 px thumbnail with motion. It is wrong now:
|
|
5702
|
+
* on this fleet the cheapest slot is a 640×360 substream, and a gif derived
|
|
5703
|
+
* from it is the "de-pixellated" attachment the operator reported. So an event
|
|
5704
|
+
* production prefers the LARGEST H.264 rendition at or below
|
|
5705
|
+
* {@link EVENT_CLIP_MAX_HEIGHT}:
|
|
5706
|
+
*
|
|
5707
|
+
* - **H.264, because that is what can be COPIED.** An H.265 slot forces a
|
|
5708
|
+
* re-encode, which is both expensive and not universally playable on the
|
|
5709
|
+
* phones notifications land on.
|
|
5710
|
+
* - **At or below 1080p**, falling back to the smallest slot above it when a
|
|
5711
|
+
* camera offers nothing else — a 4K-only camera still gets a clip, it just
|
|
5712
|
+
* pays for it.
|
|
5713
|
+
*
|
|
5714
|
+
* `null` when no rendition qualifies; the caller then falls back to
|
|
5715
|
+
* `pickClipProfile` and the answer reports which was used.
|
|
5716
|
+
*/
|
|
5717
|
+
function pickBestClipProfile(candidates) {
|
|
5718
|
+
const usable = candidates.filter((c) => normaliseCodec$1(c.codec) === "h264" && (c.height ?? 0) > 0 && (c.width ?? 0) > 0);
|
|
5719
|
+
if (usable.length === 0) return null;
|
|
5720
|
+
const within = usable.filter((c) => (c.height ?? 0) <= EVENT_CLIP_MAX_HEIGHT);
|
|
5721
|
+
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;
|
|
5722
|
+
}
|
|
5723
|
+
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
5724
|
+
function normaliseCodec$1(codec) {
|
|
5725
|
+
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
5726
|
+
}
|
|
5727
|
+
/**
|
|
5552
5728
|
* Placeholder packets ("RECONNECTING"/"SLEEPING" keyframes the broker pumps at
|
|
5553
5729
|
* 1 Hz while no RTP flows) are a DIFFERENT resolution and parameter set —
|
|
5554
5730
|
* muxing them into a clip corrupts the decode and produces a file that freezes
|
|
@@ -5605,6 +5781,33 @@ function windowAround(input) {
|
|
|
5605
5781
|
if (momentPts > newest.pts || momentPts < oldest.pts) return [];
|
|
5606
5782
|
return sliceFromKeyframe(video, startPts, endPts);
|
|
5607
5783
|
}
|
|
5784
|
+
/**
|
|
5785
|
+
* What the selected window covers, in WALL CLOCK.
|
|
5786
|
+
*
|
|
5787
|
+
* The same anchoring {@link windowAround} selects by, and for the same reason:
|
|
5788
|
+
* `pts` is a stream clock with an arbitrary origin, so the only fixed point is
|
|
5789
|
+
* that the newest retained packet arrived (near enough) at `nowMs`. Every
|
|
5790
|
+
* earlier packet is `newest.pts − p.pts` milliseconds before it.
|
|
5791
|
+
*
|
|
5792
|
+
* This exists so a caller can say "the clip starts 4.1 s BEFORE the event"
|
|
5793
|
+
* instead of inferring it from a duration and a hope. A production whose
|
|
5794
|
+
* `fromTs` is after the event has no pre-roll, which is precisely the defect
|
|
5795
|
+
* that has to be visible rather than plausible.
|
|
5796
|
+
*/
|
|
5797
|
+
function windowCoverage(packets, window, nowMs) {
|
|
5798
|
+
const video = decodableVideo(packets);
|
|
5799
|
+
const newest = video[video.length - 1];
|
|
5800
|
+
const first = window[0];
|
|
5801
|
+
const last = window[window.length - 1];
|
|
5802
|
+
if (newest === void 0 || first === void 0 || last === void 0) return {
|
|
5803
|
+
fromTs: nowMs,
|
|
5804
|
+
toTs: nowMs
|
|
5805
|
+
};
|
|
5806
|
+
return {
|
|
5807
|
+
fromTs: nowMs - (newest.pts - first.pts),
|
|
5808
|
+
toTs: nowMs - (newest.pts - last.pts)
|
|
5809
|
+
};
|
|
5810
|
+
}
|
|
5608
5811
|
/** True for an HEVC packet — the container flag and the demuxer both differ. */
|
|
5609
5812
|
function isHevcPacket(packet) {
|
|
5610
5813
|
const codec = (packet?.codec ?? "h264").toLowerCase();
|
|
@@ -5637,12 +5840,78 @@ function toElementaryStream(window, sdpParameterSets) {
|
|
|
5637
5840
|
return Buffer.concat(parts);
|
|
5638
5841
|
}
|
|
5639
5842
|
var RENDER_TIMEOUT_MS = 2e4;
|
|
5843
|
+
/**
|
|
5844
|
+
* The widest an event clip is RE-ENCODED at — 720p.
|
|
5845
|
+
*
|
|
5846
|
+
* Only ever reached when the render already forced a decode (a speed other than
|
|
5847
|
+
* real time, or an H.265 source). At real time the width must stay the source's
|
|
5848
|
+
* own, because an identity scale is precisely what `clipCanCopy` looks for.
|
|
5849
|
+
*
|
|
5850
|
+
* Measured on a real 615 720p window, `-preset veryfast`: crf 23 at 1280 is
|
|
5851
|
+
* 254 KB and 0.23 s of encode, against the 922 KB the copy of the same window
|
|
5852
|
+
* carried. Capping wider renditions here costs nothing an operator can see on a
|
|
5853
|
+
* phone and keeps the burst bounded on a hub that is already busy.
|
|
5854
|
+
*/
|
|
5855
|
+
var EVENT_CLIP_ENCODE_MAX_WIDTH = 1280;
|
|
5856
|
+
/**
|
|
5857
|
+
* The width to render an event clip at, given what the source is and whether
|
|
5858
|
+
* the caller asked for real time.
|
|
5859
|
+
*
|
|
5860
|
+
* One function because two call sites have to agree: the mux and the
|
|
5861
|
+
* `copy`/`encode` the production REPORTS. They disagreed once already — a
|
|
5862
|
+
* reported mode that does not match the render is a log line that lies.
|
|
5863
|
+
*/
|
|
5864
|
+
function eventClipEncodeWidth(sourceWidth, speed) {
|
|
5865
|
+
const source = Math.max(1, Math.round(sourceWidth));
|
|
5866
|
+
if (speed === 1) return source;
|
|
5867
|
+
return Math.min(source, EVENT_CLIP_ENCODE_MAX_WIDTH);
|
|
5868
|
+
}
|
|
5869
|
+
/**
|
|
5870
|
+
* Format a frame rate for an ffmpeg filter argument.
|
|
5871
|
+
*
|
|
5872
|
+
* `12 / 4` must reach the filter as `3` and `15 / 4` as `3.75`, not as a float
|
|
5873
|
+
* tail that `av_parse_video_rate` then has to guess at.
|
|
5874
|
+
*/
|
|
5875
|
+
function ffmpegRate(rate) {
|
|
5876
|
+
return String(Math.round(rate * 1e3) / 1e3);
|
|
5877
|
+
}
|
|
5878
|
+
/**
|
|
5879
|
+
* Whether this render can COPY the camera's own H.264 instead of re-encoding it.
|
|
5880
|
+
*
|
|
5881
|
+
* The ring already holds the camera's elementary stream. Re-encoding it is only
|
|
5882
|
+
* ever necessary to change something, so the question is entirely "is anything
|
|
5883
|
+
* being changed":
|
|
5884
|
+
*
|
|
5885
|
+
* - **A scale is a change.** Only when the caller asks for at least the
|
|
5886
|
+
* source's own width is the `scale` filter an identity. `sourceWidth === 0`
|
|
5887
|
+
* means nobody knows the source's width, and a copy would then be a guess —
|
|
5888
|
+
* so it re-encodes, which is what it always did.
|
|
5889
|
+
* - **A speed is a change.** `setpts` rewrites the timeline.
|
|
5890
|
+
* - **H.265 is a change we WANT.** The mp4 lands on phones, and HEVC in an mp4
|
|
5891
|
+
* is not universally playable there; transcoding an H.265 camera's clip to
|
|
5892
|
+
* H.264 is the one case where the re-encode is the feature. (It is also why
|
|
5893
|
+
* the clip ring remains the fallback for an H.265-only camera rather than
|
|
5894
|
+
* something to delete.)
|
|
5895
|
+
* - **A GIF is a re-encode by definition.**
|
|
5896
|
+
*
|
|
5897
|
+
* Copying is what makes a notification clip the camera's own 1280×720 picture
|
|
5898
|
+
* rather than `libx264 -crf 28` at 480 px — measured on 615, the same eleven
|
|
5899
|
+
* seconds are 224 KB copied against a visibly softer re-encode, and the gif
|
|
5900
|
+
* derived from it stops looking pixellated for the same reason.
|
|
5901
|
+
*/
|
|
5902
|
+
function clipCanCopy(input) {
|
|
5903
|
+
if (input.format !== "mp4") return false;
|
|
5904
|
+
if (input.hevc === true) return false;
|
|
5905
|
+
if ((input.speed ?? 2) !== 1) return false;
|
|
5906
|
+
return input.sourceWidth > 0 && input.maxWidth >= input.sourceWidth;
|
|
5907
|
+
}
|
|
5640
5908
|
/** ffmpeg args for the chosen container, reading an annex-B stream from stdin. */
|
|
5641
5909
|
function clipFfmpegArgs(input) {
|
|
5642
5910
|
const scale = `scale=${input.maxWidth}:-2:flags=lanczos`;
|
|
5643
5911
|
const demuxer = input.hevc === true ? "hevc" : "h264";
|
|
5644
5912
|
const speed = input.speed ?? 2;
|
|
5645
5913
|
const setpts = speed !== 1 ? `,setpts=PTS/${speed}` : "";
|
|
5914
|
+
const gifSetpts = speed !== 1 ? `${setpts},fps=${ffmpegRate(input.fps * speed)}` : setpts;
|
|
5646
5915
|
if (input.format === "gif") return [
|
|
5647
5916
|
"-y",
|
|
5648
5917
|
"-f",
|
|
@@ -5650,13 +5919,28 @@ function clipFfmpegArgs(input) {
|
|
|
5650
5919
|
"-i",
|
|
5651
5920
|
"pipe:0",
|
|
5652
5921
|
"-filter_complex",
|
|
5653
|
-
`[0:v]fps=${input.fps}${
|
|
5922
|
+
`[0:v]fps=${ffmpegRate(input.fps)}${gifSetpts},${scale},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`,
|
|
5654
5923
|
"-loop",
|
|
5655
5924
|
"0",
|
|
5656
5925
|
"-f",
|
|
5657
5926
|
"gif",
|
|
5658
5927
|
"pipe:1"
|
|
5659
5928
|
];
|
|
5929
|
+
if (input.copyVideo === true) return [
|
|
5930
|
+
"-y",
|
|
5931
|
+
"-f",
|
|
5932
|
+
demuxer,
|
|
5933
|
+
"-i",
|
|
5934
|
+
"pipe:0",
|
|
5935
|
+
"-c:v",
|
|
5936
|
+
"copy",
|
|
5937
|
+
"-movflags",
|
|
5938
|
+
"frag_keyframe+empty_moov",
|
|
5939
|
+
"-an",
|
|
5940
|
+
"-f",
|
|
5941
|
+
"mp4",
|
|
5942
|
+
"pipe:1"
|
|
5943
|
+
];
|
|
5660
5944
|
return [
|
|
5661
5945
|
"-y",
|
|
5662
5946
|
"-f",
|
|
@@ -5670,7 +5954,7 @@ function clipFfmpegArgs(input) {
|
|
|
5670
5954
|
"-preset",
|
|
5671
5955
|
"veryfast",
|
|
5672
5956
|
"-crf",
|
|
5673
|
-
"
|
|
5957
|
+
"23",
|
|
5674
5958
|
"-pix_fmt",
|
|
5675
5959
|
"yuv420p",
|
|
5676
5960
|
"-movflags",
|
|
@@ -5693,9 +5977,17 @@ async function renderPreBufferClip(input) {
|
|
|
5693
5977
|
if (window.length === 0) throw new Error("pre-buffer holds no decodable video for this window");
|
|
5694
5978
|
return new Promise((resolve, reject) => {
|
|
5695
5979
|
const hevc = isHevcPacket(window[0]);
|
|
5980
|
+
const copyVideo = clipCanCopy({
|
|
5981
|
+
format: input.format,
|
|
5982
|
+
maxWidth: input.maxWidth,
|
|
5983
|
+
...input.speed !== void 0 ? { speed: input.speed } : {},
|
|
5984
|
+
hevc,
|
|
5985
|
+
sourceWidth: input.sourceWidth ?? 0
|
|
5986
|
+
});
|
|
5696
5987
|
const proc = input.spawn(input.ffmpegPath ?? "ffmpeg", clipFfmpegArgs({
|
|
5697
5988
|
...input,
|
|
5698
|
-
hevc
|
|
5989
|
+
hevc,
|
|
5990
|
+
copyVideo
|
|
5699
5991
|
}));
|
|
5700
5992
|
const chunks = [];
|
|
5701
5993
|
let stderrTail = "";
|
|
@@ -5738,6 +6030,142 @@ async function renderPreBufferClip(input) {
|
|
|
5738
6030
|
proc.stdin?.end();
|
|
5739
6031
|
});
|
|
5740
6032
|
}
|
|
6033
|
+
/**
|
|
6034
|
+
* The factor the derive must apply ON TOP of the speed the mp4 already carries.
|
|
6035
|
+
*
|
|
6036
|
+
* The gif is derived from the muxed mp4, so if that mux already ran `setpts` at
|
|
6037
|
+
* the caller's `speed`, applying the absolute gif rate again would compound
|
|
6038
|
+
* them — a 4× gif beside a 2× video would come out at 8×. Discounting here is
|
|
6039
|
+
* what keeps {@link GIF_SPEED} an ABSOLUTE rate against real time whatever the
|
|
6040
|
+
* video does.
|
|
6041
|
+
*/
|
|
6042
|
+
function gifDeriveSpeed(gifSpeed, videoSpeed) {
|
|
6043
|
+
if (!Number.isFinite(videoSpeed) || videoSpeed <= 0) return gifSpeed;
|
|
6044
|
+
return gifSpeed / videoSpeed;
|
|
6045
|
+
}
|
|
6046
|
+
/** A derive that has not finished by now will not finish inside the drain. */
|
|
6047
|
+
var GIF_RENDER_TIMEOUT_MS = 2e4;
|
|
6048
|
+
/**
|
|
6049
|
+
* The filter chain. Extracted so a test can assert the palette pair without
|
|
6050
|
+
* spawning ffmpeg — an argv test cannot see quality, but it can see a filter
|
|
6051
|
+
* that somebody deleted.
|
|
6052
|
+
*/
|
|
6053
|
+
function gifFilterComplex(input) {
|
|
6054
|
+
const speed = input.speed > 0 ? input.speed : 1;
|
|
6055
|
+
if (speed === 1) return `[0:v]fps=${ffmpegRate(input.fps)},scale=${input.maxWidth}:-2:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`;
|
|
6056
|
+
return `[0:v]fps=${ffmpegRate(input.fps / speed)},setpts=PTS/${speed},fps=${ffmpegRate(input.fps)},scale=${input.maxWidth}:-2:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`;
|
|
6057
|
+
}
|
|
6058
|
+
/** The full argv, for the same reason. */
|
|
6059
|
+
function gifFromMp4Args(input) {
|
|
6060
|
+
return [
|
|
6061
|
+
"-y",
|
|
6062
|
+
"-v",
|
|
6063
|
+
"error",
|
|
6064
|
+
"-f",
|
|
6065
|
+
"mp4",
|
|
6066
|
+
"-i",
|
|
6067
|
+
"pipe:0",
|
|
6068
|
+
"-filter_complex",
|
|
6069
|
+
gifFilterComplex(input),
|
|
6070
|
+
"-loop",
|
|
6071
|
+
"0",
|
|
6072
|
+
"-f",
|
|
6073
|
+
"gif",
|
|
6074
|
+
"pipe:1"
|
|
6075
|
+
];
|
|
6076
|
+
}
|
|
6077
|
+
/**
|
|
6078
|
+
* Derive the gif. `null` on every failure — a notification that lost its gif is
|
|
6079
|
+
* a notification, and one that failed is not.
|
|
6080
|
+
*
|
|
6081
|
+
* The child is guaranteed dead before this settles: these run on every
|
|
6082
|
+
* notification that asks for a gif, and a leaked ffmpeg accumulates on the node
|
|
6083
|
+
* until somebody notices the host is slow.
|
|
6084
|
+
*/
|
|
6085
|
+
async function gifFromMp4(input) {
|
|
6086
|
+
const args = gifFromMp4Args({
|
|
6087
|
+
maxWidth: input.maxWidth ?? 640,
|
|
6088
|
+
fps: input.fps ?? 12,
|
|
6089
|
+
speed: input.speed ?? 4
|
|
6090
|
+
});
|
|
6091
|
+
const startedAt = Date.now();
|
|
6092
|
+
try {
|
|
6093
|
+
const bytes = await runGif(input, args);
|
|
6094
|
+
input.logger?.info("nc gif: derived from the notification video", {
|
|
6095
|
+
tags: { deviceId: input.deviceId },
|
|
6096
|
+
meta: {
|
|
6097
|
+
sourceBytes: input.mp4.length,
|
|
6098
|
+
gifBytes: bytes.length,
|
|
6099
|
+
width: input.maxWidth ?? 640,
|
|
6100
|
+
fps: input.fps ?? 12,
|
|
6101
|
+
speed: input.speed ?? 4,
|
|
6102
|
+
encodeMs: Date.now() - startedAt
|
|
6103
|
+
}
|
|
6104
|
+
});
|
|
6105
|
+
return bytes;
|
|
6106
|
+
} catch (err) {
|
|
6107
|
+
input.logger?.warn("nc gif: the derive failed — this notification ships without a gif", {
|
|
6108
|
+
tags: { deviceId: input.deviceId },
|
|
6109
|
+
meta: {
|
|
6110
|
+
sourceBytes: input.mp4.length,
|
|
6111
|
+
error: err instanceof Error ? err.message : String(err)
|
|
6112
|
+
}
|
|
6113
|
+
});
|
|
6114
|
+
return null;
|
|
6115
|
+
}
|
|
6116
|
+
}
|
|
6117
|
+
function runGif(input, args) {
|
|
6118
|
+
return new Promise((resolve, reject) => {
|
|
6119
|
+
const proc = input.spawnFn(input.ffmpegPath ?? "ffmpeg", [...args], { stdio: [
|
|
6120
|
+
"pipe",
|
|
6121
|
+
"pipe",
|
|
6122
|
+
"pipe"
|
|
6123
|
+
] });
|
|
6124
|
+
const chunks = [];
|
|
6125
|
+
let stderrTail = "";
|
|
6126
|
+
let done = false;
|
|
6127
|
+
let timer = null;
|
|
6128
|
+
const settle = (err, out) => {
|
|
6129
|
+
if (done) return;
|
|
6130
|
+
done = true;
|
|
6131
|
+
if (timer !== null) clearTimeout(timer);
|
|
6132
|
+
if (proc.exitCode === null && proc.signalCode === null) try {
|
|
6133
|
+
proc.kill("SIGKILL");
|
|
6134
|
+
} catch {}
|
|
6135
|
+
try {
|
|
6136
|
+
proc.stdout?.removeAllListeners();
|
|
6137
|
+
proc.stderr?.removeAllListeners();
|
|
6138
|
+
proc.stdin?.destroy();
|
|
6139
|
+
} catch {}
|
|
6140
|
+
if (err !== null) reject(err);
|
|
6141
|
+
else resolve(out ?? new Uint8Array(0));
|
|
6142
|
+
};
|
|
6143
|
+
timer = setTimeout(() => settle(/* @__PURE__ */ new Error("gif derive timed out")), GIF_RENDER_TIMEOUT_MS);
|
|
6144
|
+
timer.unref?.();
|
|
6145
|
+
proc.stdout?.on("data", (c) => chunks.push(c));
|
|
6146
|
+
proc.stderr?.on("data", (c) => {
|
|
6147
|
+
stderrTail = (stderrTail + c.toString()).slice(-400);
|
|
6148
|
+
});
|
|
6149
|
+
proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
|
|
6150
|
+
proc.on("close", (code) => {
|
|
6151
|
+
if (code !== 0) {
|
|
6152
|
+
settle(/* @__PURE__ */ new Error(`ffmpeg gif derive exited ${code}: ${stderrTail}`));
|
|
6153
|
+
return;
|
|
6154
|
+
}
|
|
6155
|
+
const out = Buffer.concat(chunks);
|
|
6156
|
+
if (out.byteLength === 0) {
|
|
6157
|
+
settle(/* @__PURE__ */ new Error("gif derive produced no bytes"));
|
|
6158
|
+
return;
|
|
6159
|
+
}
|
|
6160
|
+
const bytes = new Uint8Array(out.byteLength);
|
|
6161
|
+
bytes.set(out);
|
|
6162
|
+
settle(null, bytes);
|
|
6163
|
+
});
|
|
6164
|
+
proc.stdin?.on("error", () => {});
|
|
6165
|
+
proc.stdin?.write(Buffer.from(input.mp4));
|
|
6166
|
+
proc.stdin?.end();
|
|
6167
|
+
});
|
|
6168
|
+
}
|
|
5741
6169
|
//#endregion
|
|
5742
6170
|
//#region src/stream-broker/stream-broker/profile-restreamers.ts
|
|
5743
6171
|
/**
|
|
@@ -12535,79 +12963,6 @@ function hexToBytes(hex) {
|
|
|
12535
12963
|
}
|
|
12536
12964
|
return out;
|
|
12537
12965
|
}
|
|
12538
|
-
/**
|
|
12539
|
-
* Minimum gap between two re-dials of the same device. A re-dial costs a
|
|
12540
|
-
* ~2 s video interruption on every source of the camera; at the 30 s
|
|
12541
|
-
* reconcile cadence, an un-cooled loop against a camera whose audio genuinely
|
|
12542
|
-
* cannot come back would be a permanent stutter.
|
|
12543
|
-
*/
|
|
12544
|
-
var AUDIO_PLANE_REDIAL_COOLDOWN_MS = 5 * 6e4;
|
|
12545
|
-
var EMPTY_AUDIO_PLANE_STATE = {
|
|
12546
|
-
disagreements: 0,
|
|
12547
|
-
redials: 0,
|
|
12548
|
-
lastRedialMs: 0,
|
|
12549
|
-
gaveUp: false
|
|
12550
|
-
};
|
|
12551
|
-
/**
|
|
12552
|
-
* Decide what one pass should do. Pure: no clock, no I/O, no broker.
|
|
12553
|
-
*
|
|
12554
|
-
* `nowMs` is passed in rather than read so the cooldown is testable without
|
|
12555
|
-
* waiting five minutes for it.
|
|
12556
|
-
*/
|
|
12557
|
-
function decideAudioPlaneAction(observation, state, nowMs) {
|
|
12558
|
-
switch (observation.kind) {
|
|
12559
|
-
case "broker-muted":
|
|
12560
|
-
case "no-live-source":
|
|
12561
|
-
case "audio-present":
|
|
12562
|
-
case "declared-off": return {
|
|
12563
|
-
action: { kind: "none" },
|
|
12564
|
-
nextState: EMPTY_AUDIO_PLANE_STATE
|
|
12565
|
-
};
|
|
12566
|
-
case "read-failed":
|
|
12567
|
-
case "audio-unknown": return {
|
|
12568
|
-
action: { kind: "none" },
|
|
12569
|
-
nextState: state
|
|
12570
|
-
};
|
|
12571
|
-
case "declared-on": {
|
|
12572
|
-
if (state.gaveUp) return {
|
|
12573
|
-
action: { kind: "none" },
|
|
12574
|
-
nextState: state
|
|
12575
|
-
};
|
|
12576
|
-
const disagreements = state.disagreements + 1;
|
|
12577
|
-
if (disagreements < 2) return {
|
|
12578
|
-
action: { kind: "await-second-pass" },
|
|
12579
|
-
nextState: {
|
|
12580
|
-
...state,
|
|
12581
|
-
disagreements
|
|
12582
|
-
}
|
|
12583
|
-
};
|
|
12584
|
-
if (state.redials >= 3) return {
|
|
12585
|
-
action: { kind: "give-up" },
|
|
12586
|
-
nextState: {
|
|
12587
|
-
...state,
|
|
12588
|
-
disagreements,
|
|
12589
|
-
gaveUp: true
|
|
12590
|
-
}
|
|
12591
|
-
};
|
|
12592
|
-
if (state.lastRedialMs > 0 && nowMs - state.lastRedialMs < 3e5) return {
|
|
12593
|
-
action: { kind: "cooldown" },
|
|
12594
|
-
nextState: {
|
|
12595
|
-
...state,
|
|
12596
|
-
disagreements: 2
|
|
12597
|
-
}
|
|
12598
|
-
};
|
|
12599
|
-
return {
|
|
12600
|
-
action: { kind: "redial" },
|
|
12601
|
-
nextState: {
|
|
12602
|
-
disagreements: 0,
|
|
12603
|
-
redials: state.redials + 1,
|
|
12604
|
-
lastRedialMs: nowMs,
|
|
12605
|
-
gaveUp: false
|
|
12606
|
-
}
|
|
12607
|
-
};
|
|
12608
|
-
}
|
|
12609
|
-
}
|
|
12610
|
-
}
|
|
12611
12966
|
//#endregion
|
|
12612
12967
|
//#region src/stream-broker/stream-broker/transcode-pipeline.ts
|
|
12613
12968
|
/**
|
|
@@ -13170,13 +13525,6 @@ function formatClockHHMM(at) {
|
|
|
13170
13525
|
* reach `streaming` with audio detected, and the stats poll cadence. */
|
|
13171
13526
|
var PROBE_STREAM_TIMEOUT_MS = 6e3;
|
|
13172
13527
|
var PROBE_STREAM_POLL_MS = 250;
|
|
13173
|
-
/**
|
|
13174
|
-
* Seconds of encoded video kept for notification clips, on the cheapest
|
|
13175
|
-
* assigned profile only. Sized to cover the longest allowed pre-roll (20 s)
|
|
13176
|
-
* plus the seconds a rule takes to reach the dispatcher; the low-profile
|
|
13177
|
-
* rendition costs a few MB for that window, and nothing at all while the
|
|
13178
|
-
* camera is idle.
|
|
13179
|
-
*/
|
|
13180
13528
|
var CLIP_RETENTION_SEC = 25;
|
|
13181
13529
|
/** Cheapest-first — the profile a clip is cut from when the rule says nothing. */
|
|
13182
13530
|
var CLIP_PROFILE_BY_COST = [
|
|
@@ -14899,6 +15247,160 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
14899
15247
|
* of the event, not of the present.
|
|
14900
15248
|
*/
|
|
14901
15249
|
async renderPreBufferClip(input) {
|
|
15250
|
+
const cut = await this.selectClipWindow({
|
|
15251
|
+
deviceId: input.deviceId,
|
|
15252
|
+
aroundMs: input.aroundMs,
|
|
15253
|
+
preRollSec: input.preRollSec,
|
|
15254
|
+
postRollSec: input.postRollSec,
|
|
15255
|
+
...input.profile !== void 0 ? { profile: input.profile } : {}
|
|
15256
|
+
});
|
|
15257
|
+
const bytes = await this.muxClipWindow(cut, {
|
|
15258
|
+
format: input.format,
|
|
15259
|
+
maxWidth: input.maxWidth,
|
|
15260
|
+
fps: input.fps,
|
|
15261
|
+
...input.speed !== void 0 ? { speed: input.speed } : {}
|
|
15262
|
+
});
|
|
15263
|
+
const first = cut.window[0];
|
|
15264
|
+
const last = cut.window[cut.window.length - 1];
|
|
15265
|
+
return {
|
|
15266
|
+
base64: Buffer.from(bytes).toString("base64"),
|
|
15267
|
+
mime: input.format === "gif" ? "image/gif" : "video/mp4",
|
|
15268
|
+
bytes: bytes.byteLength,
|
|
15269
|
+
profile: cut.profile,
|
|
15270
|
+
durationMs: first && last ? Math.max(0, last.pts - first.pts) : 0
|
|
15271
|
+
};
|
|
15272
|
+
}
|
|
15273
|
+
/**
|
|
15274
|
+
* ONE cut of an event, in every container the caller asked for — the
|
|
15275
|
+
* production point every consumer of event media goes through.
|
|
15276
|
+
*
|
|
15277
|
+
* See the cap docblock for why this is one method rather than one call per
|
|
15278
|
+
* format. The short version is that `renderPreBufferClip` renders one format
|
|
15279
|
+
* per call, so a caller wanting a gif AND a video made two calls, got two
|
|
15280
|
+
* windows, and shipped a notification whose two attachments showed different
|
|
15281
|
+
* moments.
|
|
15282
|
+
*
|
|
15283
|
+
* The window comes out of the broker's own AnnexB retention ring — packets
|
|
15284
|
+
* that were already in RAM — so a production reaches BACKWARDS from the event
|
|
15285
|
+
* with nothing spawned and nothing kept warm. The mp4 is muxed once, COPYING
|
|
15286
|
+
* the camera's own H.264 whenever nothing is being changed, and any gif is
|
|
15287
|
+
* derived from that same mp4 rather than rendered a second time from the
|
|
15288
|
+
* packets: same window, same pixels, by construction.
|
|
15289
|
+
*/
|
|
15290
|
+
async produceEventMedia(input) {
|
|
15291
|
+
const preferred = input.profile ?? this.bestClipProfile(input.deviceId) ?? void 0;
|
|
15292
|
+
const cut = await this.selectClipWindow({
|
|
15293
|
+
deviceId: input.deviceId,
|
|
15294
|
+
aroundMs: input.aroundMs,
|
|
15295
|
+
preRollSec: input.preSeconds,
|
|
15296
|
+
postRollSec: input.postSeconds,
|
|
15297
|
+
...preferred !== void 0 ? { profile: preferred } : {}
|
|
15298
|
+
});
|
|
15299
|
+
const clipWidth = eventClipEncodeWidth(cut.sourceWidth, input.speed);
|
|
15300
|
+
const mp4 = await this.muxClipWindow(cut, {
|
|
15301
|
+
format: "mp4",
|
|
15302
|
+
maxWidth: clipWidth,
|
|
15303
|
+
fps: 0,
|
|
15304
|
+
speed: input.speed
|
|
15305
|
+
});
|
|
15306
|
+
const media = [];
|
|
15307
|
+
if (input.kinds.includes("mp4")) media.push({
|
|
15308
|
+
kind: "mp4",
|
|
15309
|
+
handle: this.eventMedia.put({
|
|
15310
|
+
deviceId: input.deviceId,
|
|
15311
|
+
kind: "mp4",
|
|
15312
|
+
mime: "video/mp4",
|
|
15313
|
+
bytes: mp4
|
|
15314
|
+
}),
|
|
15315
|
+
nodeId: this.localNodeId ?? "",
|
|
15316
|
+
mime: "video/mp4",
|
|
15317
|
+
bytes: mp4.byteLength,
|
|
15318
|
+
width: clipWidth,
|
|
15319
|
+
height: scaledHeight(cut, clipWidth)
|
|
15320
|
+
});
|
|
15321
|
+
const gifSpeed = input.gifSpeed ?? 4;
|
|
15322
|
+
if (input.kinds.includes("gif")) {
|
|
15323
|
+
const gif = await gifFromMp4({
|
|
15324
|
+
mp4,
|
|
15325
|
+
deviceId: input.deviceId,
|
|
15326
|
+
maxWidth: input.gifMaxWidth,
|
|
15327
|
+
fps: input.gifFps,
|
|
15328
|
+
speed: gifDeriveSpeed(gifSpeed, input.speed),
|
|
15329
|
+
...this.logger !== void 0 ? { logger: this.logger } : {},
|
|
15330
|
+
spawnFn: spawn,
|
|
15331
|
+
...this.ffmpegConfig.binaryPath !== void 0 ? { ffmpegPath: this.ffmpegConfig.binaryPath } : {}
|
|
15332
|
+
});
|
|
15333
|
+
if (gif !== null) {
|
|
15334
|
+
const height = scaledHeight(cut, input.gifMaxWidth);
|
|
15335
|
+
media.push({
|
|
15336
|
+
kind: "gif",
|
|
15337
|
+
handle: this.eventMedia.put({
|
|
15338
|
+
deviceId: input.deviceId,
|
|
15339
|
+
kind: "gif",
|
|
15340
|
+
mime: "image/gif",
|
|
15341
|
+
bytes: gif
|
|
15342
|
+
}),
|
|
15343
|
+
nodeId: this.localNodeId ?? "",
|
|
15344
|
+
mime: "image/gif",
|
|
15345
|
+
bytes: gif.byteLength,
|
|
15346
|
+
width: input.gifMaxWidth,
|
|
15347
|
+
height
|
|
15348
|
+
});
|
|
15349
|
+
}
|
|
15350
|
+
}
|
|
15351
|
+
const video = this.clipVideoMode(cut, {
|
|
15352
|
+
format: "mp4",
|
|
15353
|
+
maxWidth: clipWidth,
|
|
15354
|
+
speed: input.speed
|
|
15355
|
+
});
|
|
15356
|
+
this.logger?.info("event media produced — one window, every container", {
|
|
15357
|
+
tags: { deviceId: input.deviceId },
|
|
15358
|
+
meta: {
|
|
15359
|
+
profile: cut.profile,
|
|
15360
|
+
video,
|
|
15361
|
+
resolution: `${cut.sourceWidth}x${cut.sourceHeight}`,
|
|
15362
|
+
packets: cut.window.length,
|
|
15363
|
+
kinds: [...input.kinds].join(","),
|
|
15364
|
+
artifacts: media.map((m) => `${m.kind}:${m.bytes}`).join(" "),
|
|
15365
|
+
speed: input.speed,
|
|
15366
|
+
gifSpeed,
|
|
15367
|
+
startOffsetMs: cut.coverage.fromTs - input.aroundMs,
|
|
15368
|
+
endOffsetMs: cut.coverage.toTs - input.aroundMs
|
|
15369
|
+
}
|
|
15370
|
+
});
|
|
15371
|
+
return {
|
|
15372
|
+
media,
|
|
15373
|
+
coverage: {
|
|
15374
|
+
fromTs: cut.coverage.fromTs,
|
|
15375
|
+
toTs: cut.coverage.toTs,
|
|
15376
|
+
packets: cut.window.length
|
|
15377
|
+
},
|
|
15378
|
+
profile: cut.profile,
|
|
15379
|
+
video
|
|
15380
|
+
};
|
|
15381
|
+
}
|
|
15382
|
+
/** Redeem an artifact handle. `null` once it has expired (see
|
|
15383
|
+
* {@link EventMediaStore}). */
|
|
15384
|
+
async fetchEventMedia(input) {
|
|
15385
|
+
const item = this.eventMedia.get(input.handle);
|
|
15386
|
+
if (item === null) {
|
|
15387
|
+
this.logger?.warn("event media: handle expired or unknown — nothing to fetch", { meta: { handle: input.handle } });
|
|
15388
|
+
return null;
|
|
15389
|
+
}
|
|
15390
|
+
return {
|
|
15391
|
+
base64: Buffer.from(item.bytes).toString("base64"),
|
|
15392
|
+
mime: item.mime,
|
|
15393
|
+
bytes: item.bytes.length
|
|
15394
|
+
};
|
|
15395
|
+
}
|
|
15396
|
+
/**
|
|
15397
|
+
* Wait out the post-roll, then select the retained window around the moment.
|
|
15398
|
+
*
|
|
15399
|
+
* Shared by `renderPreBufferClip` and `produceEventMedia` deliberately: two
|
|
15400
|
+
* copies of "which profile, which packets, which resolution" is exactly how
|
|
15401
|
+
* the two paths would start disagreeing again.
|
|
15402
|
+
*/
|
|
15403
|
+
async selectClipWindow(input) {
|
|
14902
15404
|
const map = this.assignments.get(input.deviceId)?.map ?? {};
|
|
14903
15405
|
const assigned = CLIP_PROFILE_BY_COST.filter((p) => map[p] !== void 0);
|
|
14904
15406
|
const wanted = pickClipProfile(assigned, input.profile);
|
|
@@ -14909,19 +15411,26 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
14909
15411
|
const candidates = [wanted, ...assigned.filter((p) => p !== wanted)];
|
|
14910
15412
|
let profile = null;
|
|
14911
15413
|
let window = [];
|
|
15414
|
+
let coverage = {
|
|
15415
|
+
fromTs: input.aroundMs,
|
|
15416
|
+
toTs: input.aroundMs
|
|
15417
|
+
};
|
|
14912
15418
|
for (const candidate of candidates) {
|
|
14913
15419
|
const broker = this.brokerFor(input.deviceId, map[candidate]);
|
|
14914
15420
|
if (!broker) continue;
|
|
15421
|
+
const packets = broker.getClipBuffer();
|
|
15422
|
+
const nowMs = Date.now();
|
|
14915
15423
|
const found = windowAround({
|
|
14916
|
-
packets
|
|
15424
|
+
packets,
|
|
14917
15425
|
aroundMs: input.aroundMs,
|
|
14918
15426
|
preRollSec: input.preRollSec,
|
|
14919
15427
|
postRollSec: input.postRollSec,
|
|
14920
|
-
nowMs
|
|
15428
|
+
nowMs
|
|
14921
15429
|
});
|
|
14922
15430
|
if (found.length > 0) {
|
|
14923
15431
|
profile = candidate;
|
|
14924
15432
|
window = found;
|
|
15433
|
+
coverage = windowCoverage(packets, found, nowMs);
|
|
14925
15434
|
break;
|
|
14926
15435
|
}
|
|
14927
15436
|
}
|
|
@@ -14935,29 +15444,77 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
14935
15444
|
});
|
|
14936
15445
|
const broker = this.brokerFor(input.deviceId, map[profile]);
|
|
14937
15446
|
if (!broker) throw new Error(`No live broker for device ${input.deviceId} (${profile})`);
|
|
14938
|
-
const
|
|
15447
|
+
const cam = this.cameraStreams.get(input.deviceId)?.get(map[profile] ?? "");
|
|
15448
|
+
return {
|
|
15449
|
+
deviceId: input.deviceId,
|
|
15450
|
+
profile,
|
|
14939
15451
|
window,
|
|
14940
|
-
|
|
14941
|
-
|
|
14942
|
-
|
|
14943
|
-
|
|
15452
|
+
coverage,
|
|
15453
|
+
broker,
|
|
15454
|
+
sourceWidth: cam?.resolution?.width ?? 0,
|
|
15455
|
+
sourceHeight: cam?.resolution?.height ?? 0
|
|
15456
|
+
};
|
|
15457
|
+
}
|
|
15458
|
+
/** The rendition an event production prefers for this camera, or null when
|
|
15459
|
+
* nothing qualifies (every slot H.265, or none publishes a resolution). */
|
|
15460
|
+
bestClipProfile(deviceId) {
|
|
15461
|
+
const map = this.assignments.get(deviceId)?.map ?? {};
|
|
15462
|
+
const streams = this.cameraStreams.get(deviceId);
|
|
15463
|
+
const candidates = [];
|
|
15464
|
+
for (const profile of CLIP_PROFILE_BY_COST) {
|
|
15465
|
+
const camStreamId = map[profile];
|
|
15466
|
+
if (camStreamId === void 0) continue;
|
|
15467
|
+
const cam = streams?.get(camStreamId);
|
|
15468
|
+
candidates.push({
|
|
15469
|
+
profile,
|
|
15470
|
+
...cam?.codec !== void 0 ? { codec: cam.codec } : {},
|
|
15471
|
+
...cam?.resolution?.width !== void 0 ? { width: cam.resolution.width } : {},
|
|
15472
|
+
...cam?.resolution?.height !== void 0 ? { height: cam.resolution.height } : {}
|
|
15473
|
+
});
|
|
15474
|
+
}
|
|
15475
|
+
return pickBestClipProfile(candidates);
|
|
15476
|
+
}
|
|
15477
|
+
/** `copy` or `encode` for this cut — reported, never inferred from a size. */
|
|
15478
|
+
clipVideoMode(cut, render) {
|
|
15479
|
+
return clipCanCopy({
|
|
15480
|
+
format: render.format,
|
|
15481
|
+
maxWidth: render.maxWidth,
|
|
15482
|
+
...render.speed !== void 0 ? { speed: render.speed } : {},
|
|
15483
|
+
hevc: isHevcPacket(cut.window[0]),
|
|
15484
|
+
sourceWidth: cut.sourceWidth
|
|
15485
|
+
}) ? "copy" : "encode";
|
|
15486
|
+
}
|
|
15487
|
+
async muxClipWindow(cut, render) {
|
|
15488
|
+
const bytes = await renderPreBufferClip({
|
|
15489
|
+
window: cut.window,
|
|
15490
|
+
format: render.format,
|
|
15491
|
+
maxWidth: render.maxWidth,
|
|
15492
|
+
fps: render.fps,
|
|
15493
|
+
sourceWidth: cut.sourceWidth,
|
|
15494
|
+
...render.speed !== void 0 ? { speed: render.speed } : {},
|
|
14944
15495
|
spawn: (cmd, args) => spawn(cmd, [...args], { stdio: [
|
|
14945
15496
|
"pipe",
|
|
14946
15497
|
"pipe",
|
|
14947
15498
|
"pipe"
|
|
14948
15499
|
] }),
|
|
14949
15500
|
ffmpegPath: this.ffmpegConfig.binaryPath,
|
|
14950
|
-
sdpParameterSets: broker.getSdpParameterSets()
|
|
15501
|
+
sdpParameterSets: cut.broker.getSdpParameterSets()
|
|
14951
15502
|
});
|
|
14952
|
-
|
|
14953
|
-
|
|
14954
|
-
|
|
14955
|
-
|
|
14956
|
-
|
|
14957
|
-
|
|
14958
|
-
|
|
14959
|
-
|
|
14960
|
-
|
|
15503
|
+
this.logger?.info("pre-buffer clip rendered", {
|
|
15504
|
+
tags: { deviceId: cut.deviceId },
|
|
15505
|
+
meta: {
|
|
15506
|
+
profile: cut.profile,
|
|
15507
|
+
format: render.format,
|
|
15508
|
+
video: this.clipVideoMode(cut, render),
|
|
15509
|
+
sourceWidth: cut.sourceWidth,
|
|
15510
|
+
maxWidth: render.maxWidth,
|
|
15511
|
+
speed: render.speed ?? null,
|
|
15512
|
+
packets: cut.window.length,
|
|
15513
|
+
bytes: bytes.byteLength,
|
|
15514
|
+
coverageMs: cut.coverage.toTs - cut.coverage.fromTs
|
|
15515
|
+
}
|
|
15516
|
+
});
|
|
15517
|
+
return bytes;
|
|
14961
15518
|
}
|
|
14962
15519
|
getCameraStreamsForDevice(deviceId) {
|
|
14963
15520
|
const streamMap = this.cameraStreams.get(deviceId);
|
|
@@ -16397,6 +16954,8 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16397
16954
|
* within the minute — a rule asking for `high` fell back to `low` forever
|
|
16398
16955
|
* and looked like the choice did nothing.
|
|
16399
16956
|
*/
|
|
16957
|
+
/** Produced event artifacts waiting to be fetched by handle (D9/D18). */
|
|
16958
|
+
eventMedia = new EventMediaStore();
|
|
16400
16959
|
clipRetentionArmed = /* @__PURE__ */ new Map();
|
|
16401
16960
|
armClipRetention(deviceId, profile) {
|
|
16402
16961
|
const armed = this.clipRetentionArmed.get(deviceId) ?? /* @__PURE__ */ new Set();
|
|
@@ -17062,6 +17621,17 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
17062
17621
|
}
|
|
17063
17622
|
}
|
|
17064
17623
|
};
|
|
17624
|
+
/**
|
|
17625
|
+
* The height a render lands at, once `scale=W:-2` has picked it.
|
|
17626
|
+
*
|
|
17627
|
+
* `-2` means "keep the aspect, round to an EVEN number" — libx264 rejects an
|
|
17628
|
+
* odd dimension in yuv420p. Reporting the arithmetic without the rounding is
|
|
17629
|
+
* how an artifact ends up one pixel taller than the file it describes.
|
|
17630
|
+
*/
|
|
17631
|
+
function scaledHeight(cut, width) {
|
|
17632
|
+
if (cut.sourceWidth <= 0 || cut.sourceHeight <= 0) return 0;
|
|
17633
|
+
return 2 * Math.round(width / cut.sourceWidth * cut.sourceHeight / 2);
|
|
17634
|
+
}
|
|
17065
17635
|
//#endregion
|
|
17066
17636
|
//#region src/stream-broker/encode-profile-resolver.ts
|
|
17067
17637
|
/**
|