@camstack/addon-pipeline 1.2.45 → 1.2.47
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 +43 -13
- package/dist/detection-pipeline/index.mjs +43 -13
- package/dist/{dist-QyZ7QSRP.js → dist-C9Q1t0nT.js} +514 -3
- package/dist/{dist-BDkEhx4Z.mjs → dist-CfxC7XoI.mjs} +514 -3
- package/dist/{event-loop-stall-monitor-IAuNEdmG.mjs → event-loop-stall-monitor-B_XUPTwp.mjs} +7 -1
- package/dist/{event-loop-stall-monitor-COEKVBQK.js → event-loop-stall-monitor-Luq9AX4t.js} +7 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +15 -6
- package/dist/pipeline-runner/index.mjs +15 -6
- 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-C68YfSUH.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-SKj0Zdz2.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-KpixNEma.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-rd7UaqfV.mjs +26 -0
- package/dist/stream-broker/{hostInit-BMCrULpc.mjs → hostInit-D8gyxL0j.mjs} +2 -2
- package/dist/stream-broker/index.js +864 -106
- package/dist/stream-broker/index.mjs +864 -106
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-le27Mw_8.js → worker-protocol-BUJpm7l3.js} +1 -1
- package/dist/{worker-protocol-DFH_z2-q.mjs → worker-protocol-rqbyKlm9.mjs} +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C7bsz4cP.js → MaskShapeCanvas-DI4BY7W2-DwXIyaHw.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BmhLinUd.js → MotionZonesSettings-NcxxQN8r-Cf8EtWEG.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BJjyGWFy.js → PrivacyMaskSettings-APgPLF7p-BvsVXya9.js} +1 -1
- package/embed-dist/assets/{index-a4CMt-Uc.js → index-BuAeIGv8.js} +12 -12
- package/embed-dist/assets/index-ICnBEqOy.css +2 -0
- package/embed-dist/index.html +2 -2
- 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-CT2bGWqM.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-B3GxBnXM.mjs +0 -26
- package/embed-dist/assets/index-DC63og2c.css +0 -2
|
@@ -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-CfxC7XoI.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,220 @@ 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
|
+
}
|
|
3081
|
+
//#endregion
|
|
3082
|
+
//#region src/stream-broker/stream-broker/dead-decode-backends.ts
|
|
3083
|
+
/**
|
|
3084
|
+
* "This decode backend cannot create a device in THIS process" — remembered
|
|
3085
|
+
* across children, not per child.
|
|
3086
|
+
*
|
|
3087
|
+
* ## The thing this stops
|
|
3088
|
+
*
|
|
3089
|
+
* `-hwaccel vaapi` works for the decoder addon on this hub (ten concurrent 4K
|
|
3090
|
+
* decodes) and returns ENOMEM inside the stream-broker runner: `code=244`,
|
|
3091
|
+
* "Device creation failed: -12 … Cannot allocate memory". Every egress child
|
|
3092
|
+
* therefore spends ~3 s dying before its own software retry rescues it.
|
|
3093
|
+
*
|
|
3094
|
+
* That was already reported — `EgressTranscodeManager.onDecodeHwAccelFailed`
|
|
3095
|
+
* poisoned the broker's 60 s `egressHwAccelCache`. The memo is the wrong shape
|
|
3096
|
+
* for the verdict: it exists to avoid a per-session cross-process
|
|
3097
|
+
* `decoder.getInfo`, so it expires on a **read** cadence, and one minute later
|
|
3098
|
+
* the decoder answers `vaapi` again and the next cold child re-pays the death.
|
|
3099
|
+
* Measured on camera 615 on 2026-08-07: the hardware failure recurs at 22:17:07,
|
|
3100
|
+
* 22:18:42, 22:28:48, 22:30:52, 22:32:39, 22:34:07, 22:52:07 — once per memo
|
|
3101
|
+
* window, forever, on a hub where it has never once succeeded.
|
|
3102
|
+
*
|
|
3103
|
+
* ## Why this is a quarantine and not a tombstone
|
|
3104
|
+
*
|
|
3105
|
+
* A permanent verdict is wrong for the failure that is actually observed:
|
|
3106
|
+
* ENOMEM on device creation is a RESOURCE condition, and a resource condition
|
|
3107
|
+
* can clear (the operator gives the container more of the device, another
|
|
3108
|
+
* process releases it). A process that decided "never again" at 03:00 would
|
|
3109
|
+
* still be decoding in software at noon with a working accelerator, and nothing
|
|
3110
|
+
* would ever say so.
|
|
3111
|
+
*
|
|
3112
|
+
* So the verdict LASTS and escalates: each consecutive failure of the same
|
|
3113
|
+
* backend widens its quarantine — 30 min, 2 h, then 6 h — so a hub where the
|
|
3114
|
+
* backend is genuinely dead pays one probe per six hours instead of one per
|
|
3115
|
+
* minute, while a hub where it recovers still finds out.
|
|
3116
|
+
*
|
|
3117
|
+
* The FIRST rung is deliberately not a minute. One report is already a strong
|
|
3118
|
+
* signal: the manager only reports a backend after its child produced ZERO
|
|
3119
|
+
* packets *and* the software retry proved the same argv otherwise works, which
|
|
3120
|
+
* is a statement about the backend and not about the source. And the cost of
|
|
3121
|
+
* being wrong is bounded and already being paid — the egress encodes with
|
|
3122
|
+
* `libx264` either way, so an over-long quarantine costs a software DECODE on a
|
|
3123
|
+
* path that is decoding in software today, 100 % of the time.
|
|
3124
|
+
*
|
|
3125
|
+
* The invalidations are motivated, and there are exactly three:
|
|
3126
|
+
*
|
|
3127
|
+
* 1. **The window elapses.** The next acquire probes hardware once. If it
|
|
3128
|
+
* works, {@link DeadDecodeBackends.markAlive} clears the record and the
|
|
3129
|
+
* escalation starts from zero again.
|
|
3130
|
+
* 2. **The backend changes.** The record is keyed by backend name, so an
|
|
3131
|
+
* operator switching the decoder from vaapi to qsv is obeyed immediately —
|
|
3132
|
+
* qsv has no record.
|
|
3133
|
+
* 3. **The runner respawns.** This is instance state on the broker manager,
|
|
3134
|
+
* never a module global, for the same reason `HwAccelCache` is: a module
|
|
3135
|
+
* global outlives an addon respawn, and an addon respawn is exactly the
|
|
3136
|
+
* event after which a stale verdict must not survive.
|
|
3137
|
+
*
|
|
3138
|
+
* ## Why it lives here and not in `@camstack/types`
|
|
3139
|
+
*
|
|
3140
|
+
* `addon-export-hap` has the identical gap (its `failedBackend` memo forgets on
|
|
3141
|
+
* the same 60 s cadence) and this is deliberately NOT shared with it yet: a
|
|
3142
|
+
* framework package is only live once a `@camstack/server` containing it is
|
|
3143
|
+
* installed, whereas this ships with `camstack deploy packages/addon-pipeline`.
|
|
3144
|
+
* The cross-PROCESS gap D67 names — addons may not import each other and no cap
|
|
3145
|
+
* carries "this backend is dead on this node right now" — is unchanged either
|
|
3146
|
+
* way; what is closed here is the broker's own repetition of the cost.
|
|
3147
|
+
*/
|
|
3148
|
+
/**
|
|
3149
|
+
* How long a backend stays quarantined after its 1st, 2nd, 3rd+ consecutive
|
|
3150
|
+
* failure. Escalating rather than fixed, and it starts at HALF AN HOUR: a
|
|
3151
|
+
* client watchdog cycles in seconds, so any first rung measured in minutes is
|
|
3152
|
+
* re-paid by the very churn this exists to stop. The last rung makes a
|
|
3153
|
+
* permanently-broken backend cost ~nothing while still being re-probed.
|
|
3154
|
+
*/
|
|
3155
|
+
var DEAD_BACKEND_QUARANTINE_MS = [
|
|
3156
|
+
18e5,
|
|
3157
|
+
72e5,
|
|
3158
|
+
216e5
|
|
3159
|
+
];
|
|
3160
|
+
/**
|
|
3161
|
+
* The decoder's answer, overruled while the backend it names is quarantined.
|
|
3162
|
+
*
|
|
3163
|
+
* Composed rather than folded into `resolveEgressDecodeHwAccel`: that function
|
|
3164
|
+
* answers "what SHOULD decode this", which is a per-node property of the
|
|
3165
|
+
* hardware; this answers "what has been observed to work in THIS process",
|
|
3166
|
+
* which is a property of the runner. Keeping them apart is what stops a
|
|
3167
|
+
* runner-local ENOMEM from being mistaken for a node that has no accelerator —
|
|
3168
|
+
* the first is a flow bug to fix, the second a capability limit to accept.
|
|
3169
|
+
*/
|
|
3170
|
+
async function resolveWithQuarantine(input) {
|
|
3171
|
+
const backend = await input.resolve();
|
|
3172
|
+
if (backend === null || !input.dead.isQuarantined(backend)) return {
|
|
3173
|
+
backend,
|
|
3174
|
+
veto: null
|
|
3175
|
+
};
|
|
3176
|
+
return {
|
|
3177
|
+
backend: null,
|
|
3178
|
+
veto: {
|
|
3179
|
+
backend,
|
|
3180
|
+
failures: input.dead.failuresOf(backend),
|
|
3181
|
+
remainingMs: input.dead.remainingMs(backend)
|
|
3182
|
+
}
|
|
3183
|
+
};
|
|
3184
|
+
}
|
|
3185
|
+
function createDeadDecodeBackends(options = {}) {
|
|
3186
|
+
const now = options.now ?? (() => Date.now());
|
|
3187
|
+
const records = /* @__PURE__ */ new Map();
|
|
3188
|
+
const quarantineFor = (failures) => {
|
|
3189
|
+
return DEAD_BACKEND_QUARANTINE_MS[Math.min(failures, DEAD_BACKEND_QUARANTINE_MS.length) - 1] ?? DEAD_BACKEND_QUARANTINE_MS[0] ?? 0;
|
|
3190
|
+
};
|
|
3191
|
+
return {
|
|
3192
|
+
markDead(backend) {
|
|
3193
|
+
const failures = (records.get(backend)?.failures ?? 0) + 1;
|
|
3194
|
+
const quarantineMs = quarantineFor(failures);
|
|
3195
|
+
records.set(backend, {
|
|
3196
|
+
failures,
|
|
3197
|
+
until: now() + quarantineMs
|
|
3198
|
+
});
|
|
3199
|
+
return {
|
|
3200
|
+
backend,
|
|
3201
|
+
failures,
|
|
3202
|
+
quarantineMs
|
|
3203
|
+
};
|
|
3204
|
+
},
|
|
3205
|
+
markAlive(backend) {
|
|
3206
|
+
records.delete(backend);
|
|
3207
|
+
},
|
|
3208
|
+
isQuarantined(backend) {
|
|
3209
|
+
const record = records.get(backend);
|
|
3210
|
+
return record !== void 0 && now() < record.until;
|
|
3211
|
+
},
|
|
3212
|
+
remainingMs(backend) {
|
|
3213
|
+
const record = records.get(backend);
|
|
3214
|
+
if (record === void 0) return 0;
|
|
3215
|
+
return Math.max(0, record.until - now());
|
|
3216
|
+
},
|
|
3217
|
+
failuresOf(backend) {
|
|
3218
|
+
return records.get(backend)?.failures ?? 0;
|
|
3219
|
+
}
|
|
3220
|
+
};
|
|
3221
|
+
}
|
|
3008
3222
|
//#endregion
|
|
3009
3223
|
//#region src/stream-broker/stream-broker/ffmpeg-invocation.ts
|
|
3010
3224
|
/** The full set of hwaccel settings (for validation + option surfaces). */
|
|
@@ -4354,6 +4568,7 @@ var EgressTranscodeManager = class {
|
|
|
4354
4568
|
}
|
|
4355
4569
|
const actualHwAccel = child.decodeHwAccel();
|
|
4356
4570
|
if (decodeHwAccel !== null && !isSoftwareDecode(decodeHwAccel) && actualHwAccel === null) this.opts.onDecodeHwAccelFailed?.(decodeHwAccel);
|
|
4571
|
+
else if (actualHwAccel !== null && !isSoftwareDecode(actualHwAccel)) this.opts.onDecodeHwAccelSucceeded?.(actualHwAccel);
|
|
4357
4572
|
const entry = {
|
|
4358
4573
|
key,
|
|
4359
4574
|
deviceId,
|
|
@@ -4883,6 +5098,190 @@ var EgressTranscodeManager = class {
|
|
|
4883
5098
|
};
|
|
4884
5099
|
}
|
|
4885
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
|
+
}
|
|
5164
|
+
/** A derive that has not finished by now will not finish inside the drain. */
|
|
5165
|
+
var GIF_RENDER_TIMEOUT_MS = 2e4;
|
|
5166
|
+
/**
|
|
5167
|
+
* The filter chain. Extracted so a test can assert the palette pair without
|
|
5168
|
+
* spawning ffmpeg — an argv test cannot see quality, but it can see a filter
|
|
5169
|
+
* that somebody deleted.
|
|
5170
|
+
*/
|
|
5171
|
+
function gifFilterComplex(input) {
|
|
5172
|
+
const setpts = input.speed !== 1 ? `,setpts=PTS/${input.speed}` : "";
|
|
5173
|
+
return `[0:v]fps=${input.fps}${setpts},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`;
|
|
5174
|
+
}
|
|
5175
|
+
/** The full argv, for the same reason. */
|
|
5176
|
+
function gifFromMp4Args(input) {
|
|
5177
|
+
return [
|
|
5178
|
+
"-y",
|
|
5179
|
+
"-v",
|
|
5180
|
+
"error",
|
|
5181
|
+
"-f",
|
|
5182
|
+
"mp4",
|
|
5183
|
+
"-i",
|
|
5184
|
+
"pipe:0",
|
|
5185
|
+
"-filter_complex",
|
|
5186
|
+
gifFilterComplex(input),
|
|
5187
|
+
"-loop",
|
|
5188
|
+
"0",
|
|
5189
|
+
"-f",
|
|
5190
|
+
"gif",
|
|
5191
|
+
"pipe:1"
|
|
5192
|
+
];
|
|
5193
|
+
}
|
|
5194
|
+
/**
|
|
5195
|
+
* Derive the gif. `null` on every failure — a notification that lost its gif is
|
|
5196
|
+
* a notification, and one that failed is not.
|
|
5197
|
+
*
|
|
5198
|
+
* The child is guaranteed dead before this settles: these run on every
|
|
5199
|
+
* notification that asks for a gif, and a leaked ffmpeg accumulates on the node
|
|
5200
|
+
* until somebody notices the host is slow.
|
|
5201
|
+
*/
|
|
5202
|
+
async function gifFromMp4(input) {
|
|
5203
|
+
const args = gifFromMp4Args({
|
|
5204
|
+
maxWidth: input.maxWidth ?? 640,
|
|
5205
|
+
fps: input.fps ?? 8,
|
|
5206
|
+
speed: input.speed ?? 1
|
|
5207
|
+
});
|
|
5208
|
+
const startedAt = Date.now();
|
|
5209
|
+
try {
|
|
5210
|
+
const bytes = await runGif(input, args);
|
|
5211
|
+
input.logger?.info("nc gif: derived from the notification video", {
|
|
5212
|
+
tags: { deviceId: input.deviceId },
|
|
5213
|
+
meta: {
|
|
5214
|
+
sourceBytes: input.mp4.length,
|
|
5215
|
+
gifBytes: bytes.length,
|
|
5216
|
+
width: input.maxWidth ?? 640,
|
|
5217
|
+
fps: input.fps ?? 8,
|
|
5218
|
+
encodeMs: Date.now() - startedAt
|
|
5219
|
+
}
|
|
5220
|
+
});
|
|
5221
|
+
return bytes;
|
|
5222
|
+
} catch (err) {
|
|
5223
|
+
input.logger?.warn("nc gif: the derive failed — this notification ships without a gif", {
|
|
5224
|
+
tags: { deviceId: input.deviceId },
|
|
5225
|
+
meta: {
|
|
5226
|
+
sourceBytes: input.mp4.length,
|
|
5227
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5228
|
+
}
|
|
5229
|
+
});
|
|
5230
|
+
return null;
|
|
5231
|
+
}
|
|
5232
|
+
}
|
|
5233
|
+
function runGif(input, args) {
|
|
5234
|
+
return new Promise((resolve, reject) => {
|
|
5235
|
+
const proc = input.spawnFn(input.ffmpegPath ?? "ffmpeg", [...args], { stdio: [
|
|
5236
|
+
"pipe",
|
|
5237
|
+
"pipe",
|
|
5238
|
+
"pipe"
|
|
5239
|
+
] });
|
|
5240
|
+
const chunks = [];
|
|
5241
|
+
let stderrTail = "";
|
|
5242
|
+
let done = false;
|
|
5243
|
+
let timer = null;
|
|
5244
|
+
const settle = (err, out) => {
|
|
5245
|
+
if (done) return;
|
|
5246
|
+
done = true;
|
|
5247
|
+
if (timer !== null) clearTimeout(timer);
|
|
5248
|
+
if (proc.exitCode === null && proc.signalCode === null) try {
|
|
5249
|
+
proc.kill("SIGKILL");
|
|
5250
|
+
} catch {}
|
|
5251
|
+
try {
|
|
5252
|
+
proc.stdout?.removeAllListeners();
|
|
5253
|
+
proc.stderr?.removeAllListeners();
|
|
5254
|
+
proc.stdin?.destroy();
|
|
5255
|
+
} catch {}
|
|
5256
|
+
if (err !== null) reject(err);
|
|
5257
|
+
else resolve(out ?? new Uint8Array(0));
|
|
5258
|
+
};
|
|
5259
|
+
timer = setTimeout(() => settle(/* @__PURE__ */ new Error("gif derive timed out")), GIF_RENDER_TIMEOUT_MS);
|
|
5260
|
+
timer.unref?.();
|
|
5261
|
+
proc.stdout?.on("data", (c) => chunks.push(c));
|
|
5262
|
+
proc.stderr?.on("data", (c) => {
|
|
5263
|
+
stderrTail = (stderrTail + c.toString()).slice(-400);
|
|
5264
|
+
});
|
|
5265
|
+
proc.on("error", (err) => settle(err instanceof Error ? err : new Error(String(err))));
|
|
5266
|
+
proc.on("close", (code) => {
|
|
5267
|
+
if (code !== 0) {
|
|
5268
|
+
settle(/* @__PURE__ */ new Error(`ffmpeg gif derive exited ${code}: ${stderrTail}`));
|
|
5269
|
+
return;
|
|
5270
|
+
}
|
|
5271
|
+
const out = Buffer.concat(chunks);
|
|
5272
|
+
if (out.byteLength === 0) {
|
|
5273
|
+
settle(/* @__PURE__ */ new Error("gif derive produced no bytes"));
|
|
5274
|
+
return;
|
|
5275
|
+
}
|
|
5276
|
+
const bytes = new Uint8Array(out.byteLength);
|
|
5277
|
+
bytes.set(out);
|
|
5278
|
+
settle(null, bytes);
|
|
5279
|
+
});
|
|
5280
|
+
proc.stdin?.on("error", () => {});
|
|
5281
|
+
proc.stdin?.write(Buffer.from(input.mp4));
|
|
5282
|
+
proc.stdin?.end();
|
|
5283
|
+
});
|
|
5284
|
+
}
|
|
4886
5285
|
//#endregion
|
|
4887
5286
|
//#region src/stream-broker/webrtc/nal-utils.ts
|
|
4888
5287
|
var NAL_START_CODE_4B = Buffer.from([
|
|
@@ -5407,6 +5806,46 @@ function pickClipProfile(assigned, requested) {
|
|
|
5407
5806
|
return null;
|
|
5408
5807
|
}
|
|
5409
5808
|
/**
|
|
5809
|
+
* The tallest rendition an event clip is cut from.
|
|
5810
|
+
*
|
|
5811
|
+
* 1080 and not 720: the attachment is native-resolution H.264 that nothing
|
|
5812
|
+
* re-encodes, and the operator asked for a clip they could actually watch. A
|
|
5813
|
+
* few seconds of 4K copy is megabytes, which is the difference between "every
|
|
5814
|
+
* notifier takes it" and "pushover drops it at 5 MiB".
|
|
5815
|
+
*/
|
|
5816
|
+
var EVENT_CLIP_MAX_HEIGHT = 1080;
|
|
5817
|
+
/**
|
|
5818
|
+
* The rendition an EVENT production should cut from — the opposite default to
|
|
5819
|
+
* {@link pickClipProfile}.
|
|
5820
|
+
*
|
|
5821
|
+
* `pickClipProfile` takes the cheapest assigned slot, which was right when a
|
|
5822
|
+
* notification attachment was a 480 px thumbnail with motion. It is wrong now:
|
|
5823
|
+
* on this fleet the cheapest slot is a 640×360 substream, and a gif derived
|
|
5824
|
+
* from it is the "de-pixellated" attachment the operator reported. So an event
|
|
5825
|
+
* production prefers the LARGEST H.264 rendition at or below
|
|
5826
|
+
* {@link EVENT_CLIP_MAX_HEIGHT}:
|
|
5827
|
+
*
|
|
5828
|
+
* - **H.264, because that is what can be COPIED.** An H.265 slot forces a
|
|
5829
|
+
* re-encode, which is both expensive and not universally playable on the
|
|
5830
|
+
* phones notifications land on.
|
|
5831
|
+
* - **At or below 1080p**, falling back to the smallest slot above it when a
|
|
5832
|
+
* camera offers nothing else — a 4K-only camera still gets a clip, it just
|
|
5833
|
+
* pays for it.
|
|
5834
|
+
*
|
|
5835
|
+
* `null` when no rendition qualifies; the caller then falls back to
|
|
5836
|
+
* `pickClipProfile` and the answer reports which was used.
|
|
5837
|
+
*/
|
|
5838
|
+
function pickBestClipProfile(candidates) {
|
|
5839
|
+
const usable = candidates.filter((c) => normaliseCodec$1(c.codec) === "h264" && (c.height ?? 0) > 0 && (c.width ?? 0) > 0);
|
|
5840
|
+
if (usable.length === 0) return null;
|
|
5841
|
+
const within = usable.filter((c) => (c.height ?? 0) <= EVENT_CLIP_MAX_HEIGHT);
|
|
5842
|
+
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;
|
|
5843
|
+
}
|
|
5844
|
+
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
5845
|
+
function normaliseCodec$1(codec) {
|
|
5846
|
+
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
5847
|
+
}
|
|
5848
|
+
/**
|
|
5410
5849
|
* Placeholder packets ("RECONNECTING"/"SLEEPING" keyframes the broker pumps at
|
|
5411
5850
|
* 1 Hz while no RTP flows) are a DIFFERENT resolution and parameter set —
|
|
5412
5851
|
* muxing them into a clip corrupts the decode and produces a file that freezes
|
|
@@ -5463,6 +5902,33 @@ function windowAround(input) {
|
|
|
5463
5902
|
if (momentPts > newest.pts || momentPts < oldest.pts) return [];
|
|
5464
5903
|
return sliceFromKeyframe(video, startPts, endPts);
|
|
5465
5904
|
}
|
|
5905
|
+
/**
|
|
5906
|
+
* What the selected window covers, in WALL CLOCK.
|
|
5907
|
+
*
|
|
5908
|
+
* The same anchoring {@link windowAround} selects by, and for the same reason:
|
|
5909
|
+
* `pts` is a stream clock with an arbitrary origin, so the only fixed point is
|
|
5910
|
+
* that the newest retained packet arrived (near enough) at `nowMs`. Every
|
|
5911
|
+
* earlier packet is `newest.pts − p.pts` milliseconds before it.
|
|
5912
|
+
*
|
|
5913
|
+
* This exists so a caller can say "the clip starts 4.1 s BEFORE the event"
|
|
5914
|
+
* instead of inferring it from a duration and a hope. A production whose
|
|
5915
|
+
* `fromTs` is after the event has no pre-roll, which is precisely the defect
|
|
5916
|
+
* that has to be visible rather than plausible.
|
|
5917
|
+
*/
|
|
5918
|
+
function windowCoverage(packets, window, nowMs) {
|
|
5919
|
+
const video = decodableVideo(packets);
|
|
5920
|
+
const newest = video[video.length - 1];
|
|
5921
|
+
const first = window[0];
|
|
5922
|
+
const last = window[window.length - 1];
|
|
5923
|
+
if (newest === void 0 || first === void 0 || last === void 0) return {
|
|
5924
|
+
fromTs: nowMs,
|
|
5925
|
+
toTs: nowMs
|
|
5926
|
+
};
|
|
5927
|
+
return {
|
|
5928
|
+
fromTs: nowMs - (newest.pts - first.pts),
|
|
5929
|
+
toTs: nowMs - (newest.pts - last.pts)
|
|
5930
|
+
};
|
|
5931
|
+
}
|
|
5466
5932
|
/** True for an HEVC packet — the container flag and the demuxer both differ. */
|
|
5467
5933
|
function isHevcPacket(packet) {
|
|
5468
5934
|
const codec = (packet?.codec ?? "h264").toLowerCase();
|
|
@@ -5495,6 +5961,36 @@ function toElementaryStream(window, sdpParameterSets) {
|
|
|
5495
5961
|
return Buffer.concat(parts);
|
|
5496
5962
|
}
|
|
5497
5963
|
var RENDER_TIMEOUT_MS = 2e4;
|
|
5964
|
+
/**
|
|
5965
|
+
* Whether this render can COPY the camera's own H.264 instead of re-encoding it.
|
|
5966
|
+
*
|
|
5967
|
+
* The ring already holds the camera's elementary stream. Re-encoding it is only
|
|
5968
|
+
* ever necessary to change something, so the question is entirely "is anything
|
|
5969
|
+
* being changed":
|
|
5970
|
+
*
|
|
5971
|
+
* - **A scale is a change.** Only when the caller asks for at least the
|
|
5972
|
+
* source's own width is the `scale` filter an identity. `sourceWidth === 0`
|
|
5973
|
+
* means nobody knows the source's width, and a copy would then be a guess —
|
|
5974
|
+
* so it re-encodes, which is what it always did.
|
|
5975
|
+
* - **A speed is a change.** `setpts` rewrites the timeline.
|
|
5976
|
+
* - **H.265 is a change we WANT.** The mp4 lands on phones, and HEVC in an mp4
|
|
5977
|
+
* is not universally playable there; transcoding an H.265 camera's clip to
|
|
5978
|
+
* H.264 is the one case where the re-encode is the feature. (It is also why
|
|
5979
|
+
* the clip ring remains the fallback for an H.265-only camera rather than
|
|
5980
|
+
* something to delete.)
|
|
5981
|
+
* - **A GIF is a re-encode by definition.**
|
|
5982
|
+
*
|
|
5983
|
+
* Copying is what makes a notification clip the camera's own 1280×720 picture
|
|
5984
|
+
* rather than `libx264 -crf 28` at 480 px — measured on 615, the same eleven
|
|
5985
|
+
* seconds are 224 KB copied against a visibly softer re-encode, and the gif
|
|
5986
|
+
* derived from it stops looking pixellated for the same reason.
|
|
5987
|
+
*/
|
|
5988
|
+
function clipCanCopy(input) {
|
|
5989
|
+
if (input.format !== "mp4") return false;
|
|
5990
|
+
if (input.hevc === true) return false;
|
|
5991
|
+
if ((input.speed ?? 2) !== 1) return false;
|
|
5992
|
+
return input.sourceWidth > 0 && input.maxWidth >= input.sourceWidth;
|
|
5993
|
+
}
|
|
5498
5994
|
/** ffmpeg args for the chosen container, reading an annex-B stream from stdin. */
|
|
5499
5995
|
function clipFfmpegArgs(input) {
|
|
5500
5996
|
const scale = `scale=${input.maxWidth}:-2:flags=lanczos`;
|
|
@@ -5515,6 +6011,21 @@ function clipFfmpegArgs(input) {
|
|
|
5515
6011
|
"gif",
|
|
5516
6012
|
"pipe:1"
|
|
5517
6013
|
];
|
|
6014
|
+
if (input.copyVideo === true) return [
|
|
6015
|
+
"-y",
|
|
6016
|
+
"-f",
|
|
6017
|
+
demuxer,
|
|
6018
|
+
"-i",
|
|
6019
|
+
"pipe:0",
|
|
6020
|
+
"-c:v",
|
|
6021
|
+
"copy",
|
|
6022
|
+
"-movflags",
|
|
6023
|
+
"frag_keyframe+empty_moov",
|
|
6024
|
+
"-an",
|
|
6025
|
+
"-f",
|
|
6026
|
+
"mp4",
|
|
6027
|
+
"pipe:1"
|
|
6028
|
+
];
|
|
5518
6029
|
return [
|
|
5519
6030
|
"-y",
|
|
5520
6031
|
"-f",
|
|
@@ -5551,9 +6062,17 @@ async function renderPreBufferClip(input) {
|
|
|
5551
6062
|
if (window.length === 0) throw new Error("pre-buffer holds no decodable video for this window");
|
|
5552
6063
|
return new Promise((resolve, reject) => {
|
|
5553
6064
|
const hevc = isHevcPacket(window[0]);
|
|
6065
|
+
const copyVideo = clipCanCopy({
|
|
6066
|
+
format: input.format,
|
|
6067
|
+
maxWidth: input.maxWidth,
|
|
6068
|
+
...input.speed !== void 0 ? { speed: input.speed } : {},
|
|
6069
|
+
hevc,
|
|
6070
|
+
sourceWidth: input.sourceWidth ?? 0
|
|
6071
|
+
});
|
|
5554
6072
|
const proc = input.spawn(input.ffmpegPath ?? "ffmpeg", clipFfmpegArgs({
|
|
5555
6073
|
...input,
|
|
5556
|
-
hevc
|
|
6074
|
+
hevc,
|
|
6075
|
+
copyVideo
|
|
5557
6076
|
}));
|
|
5558
6077
|
const chunks = [];
|
|
5559
6078
|
let stderrTail = "";
|
|
@@ -12393,79 +12912,6 @@ function hexToBytes(hex) {
|
|
|
12393
12912
|
}
|
|
12394
12913
|
return out;
|
|
12395
12914
|
}
|
|
12396
|
-
/**
|
|
12397
|
-
* Minimum gap between two re-dials of the same device. A re-dial costs a
|
|
12398
|
-
* ~2 s video interruption on every source of the camera; at the 30 s
|
|
12399
|
-
* reconcile cadence, an un-cooled loop against a camera whose audio genuinely
|
|
12400
|
-
* cannot come back would be a permanent stutter.
|
|
12401
|
-
*/
|
|
12402
|
-
var AUDIO_PLANE_REDIAL_COOLDOWN_MS = 5 * 6e4;
|
|
12403
|
-
var EMPTY_AUDIO_PLANE_STATE = {
|
|
12404
|
-
disagreements: 0,
|
|
12405
|
-
redials: 0,
|
|
12406
|
-
lastRedialMs: 0,
|
|
12407
|
-
gaveUp: false
|
|
12408
|
-
};
|
|
12409
|
-
/**
|
|
12410
|
-
* Decide what one pass should do. Pure: no clock, no I/O, no broker.
|
|
12411
|
-
*
|
|
12412
|
-
* `nowMs` is passed in rather than read so the cooldown is testable without
|
|
12413
|
-
* waiting five minutes for it.
|
|
12414
|
-
*/
|
|
12415
|
-
function decideAudioPlaneAction(observation, state, nowMs) {
|
|
12416
|
-
switch (observation.kind) {
|
|
12417
|
-
case "broker-muted":
|
|
12418
|
-
case "no-live-source":
|
|
12419
|
-
case "audio-present":
|
|
12420
|
-
case "declared-off": return {
|
|
12421
|
-
action: { kind: "none" },
|
|
12422
|
-
nextState: EMPTY_AUDIO_PLANE_STATE
|
|
12423
|
-
};
|
|
12424
|
-
case "read-failed":
|
|
12425
|
-
case "audio-unknown": return {
|
|
12426
|
-
action: { kind: "none" },
|
|
12427
|
-
nextState: state
|
|
12428
|
-
};
|
|
12429
|
-
case "declared-on": {
|
|
12430
|
-
if (state.gaveUp) return {
|
|
12431
|
-
action: { kind: "none" },
|
|
12432
|
-
nextState: state
|
|
12433
|
-
};
|
|
12434
|
-
const disagreements = state.disagreements + 1;
|
|
12435
|
-
if (disagreements < 2) return {
|
|
12436
|
-
action: { kind: "await-second-pass" },
|
|
12437
|
-
nextState: {
|
|
12438
|
-
...state,
|
|
12439
|
-
disagreements
|
|
12440
|
-
}
|
|
12441
|
-
};
|
|
12442
|
-
if (state.redials >= 3) return {
|
|
12443
|
-
action: { kind: "give-up" },
|
|
12444
|
-
nextState: {
|
|
12445
|
-
...state,
|
|
12446
|
-
disagreements,
|
|
12447
|
-
gaveUp: true
|
|
12448
|
-
}
|
|
12449
|
-
};
|
|
12450
|
-
if (state.lastRedialMs > 0 && nowMs - state.lastRedialMs < 3e5) return {
|
|
12451
|
-
action: { kind: "cooldown" },
|
|
12452
|
-
nextState: {
|
|
12453
|
-
...state,
|
|
12454
|
-
disagreements: 2
|
|
12455
|
-
}
|
|
12456
|
-
};
|
|
12457
|
-
return {
|
|
12458
|
-
action: { kind: "redial" },
|
|
12459
|
-
nextState: {
|
|
12460
|
-
disagreements: 0,
|
|
12461
|
-
redials: state.redials + 1,
|
|
12462
|
-
lastRedialMs: nowMs,
|
|
12463
|
-
gaveUp: false
|
|
12464
|
-
}
|
|
12465
|
-
};
|
|
12466
|
-
}
|
|
12467
|
-
}
|
|
12468
|
-
}
|
|
12469
12915
|
//#endregion
|
|
12470
12916
|
//#region src/stream-broker/stream-broker/transcode-pipeline.ts
|
|
12471
12917
|
/**
|
|
@@ -13028,13 +13474,6 @@ function formatClockHHMM(at) {
|
|
|
13028
13474
|
* reach `streaming` with audio detected, and the stats poll cadence. */
|
|
13029
13475
|
var PROBE_STREAM_TIMEOUT_MS = 6e3;
|
|
13030
13476
|
var PROBE_STREAM_POLL_MS = 250;
|
|
13031
|
-
/**
|
|
13032
|
-
* Seconds of encoded video kept for notification clips, on the cheapest
|
|
13033
|
-
* assigned profile only. Sized to cover the longest allowed pre-roll (20 s)
|
|
13034
|
-
* plus the seconds a rule takes to reach the dispatcher; the low-profile
|
|
13035
|
-
* rendition costs a few MB for that window, and nothing at all while the
|
|
13036
|
-
* camera is idle.
|
|
13037
|
-
*/
|
|
13038
13477
|
var CLIP_RETENTION_SEC = 25;
|
|
13039
13478
|
/** Cheapest-first — the profile a clip is cut from when the rule says nothing. */
|
|
13040
13479
|
var CLIP_PROFILE_BY_COST = [
|
|
@@ -13310,6 +13749,15 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
13310
13749
|
*/
|
|
13311
13750
|
egressHwAccelCache = createHwAccelCache({ ttlMs: 6e4 });
|
|
13312
13751
|
/**
|
|
13752
|
+
* Backends that could not create a device in THIS process. Separate from the
|
|
13753
|
+
* memo above on purpose: the memo answers "what should decode this" on a read
|
|
13754
|
+
* cadence and expires in a minute, so folding the failure into it meant the
|
|
13755
|
+
* next cold child one minute later re-paid the same ~3 s death — measured
|
|
13756
|
+
* seven times on 615 between 22:17 and 22:52 on 2026-08-07. See
|
|
13757
|
+
* `dead-decode-backends.ts`.
|
|
13758
|
+
*/
|
|
13759
|
+
deadDecodeBackends = createDeadDecodeBackends();
|
|
13760
|
+
/**
|
|
13313
13761
|
* Snapshot of the system `ffmpeg` config section (binaryPath / hwAccel /
|
|
13314
13762
|
* threadCount). Refreshed by the host addon via `setFfmpegConfig`; the
|
|
13315
13763
|
* transcode manager reads it live per spawn. Defaults are safe (PATH ffmpeg,
|
|
@@ -13509,8 +13957,17 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
13509
13957
|
};
|
|
13510
13958
|
},
|
|
13511
13959
|
onDecodeHwAccelFailed: (backend) => {
|
|
13512
|
-
this.
|
|
13513
|
-
this.logger.warn("egress transcode: decode backend FAILED in this process — software until
|
|
13960
|
+
const verdict = this.deadDecodeBackends.markDead(backend);
|
|
13961
|
+
this.logger.warn("egress transcode: decode backend FAILED in this process — QUARANTINED, software until it is re-probed", { meta: {
|
|
13962
|
+
backend,
|
|
13963
|
+
failures: verdict.failures,
|
|
13964
|
+
quarantineMs: verdict.quarantineMs
|
|
13965
|
+
} });
|
|
13966
|
+
},
|
|
13967
|
+
onDecodeHwAccelSucceeded: (backend) => {
|
|
13968
|
+
if (this.deadDecodeBackends.failuresOf(backend) === 0) return;
|
|
13969
|
+
this.deadDecodeBackends.markAlive(backend);
|
|
13970
|
+
this.logger.info("egress transcode: decode backend RECOVERED in this process — the quarantine is lifted", { meta: { backend } });
|
|
13514
13971
|
}
|
|
13515
13972
|
});
|
|
13516
13973
|
}
|
|
@@ -13533,6 +13990,22 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
13533
13990
|
* hub).
|
|
13534
13991
|
*/
|
|
13535
13992
|
async resolveEgressHwAccel(deviceId, override) {
|
|
13993
|
+
const resolved = await resolveWithQuarantine({
|
|
13994
|
+
resolve: () => this.readEgressHwAccel(deviceId, override),
|
|
13995
|
+
dead: this.deadDecodeBackends
|
|
13996
|
+
});
|
|
13997
|
+
if (resolved.veto !== null) this.logger.warn("egress transcode: decoding in SOFTWARE — the backend is QUARANTINED after failing in this process", {
|
|
13998
|
+
tags: { deviceId },
|
|
13999
|
+
meta: {
|
|
14000
|
+
backend: resolved.veto.backend,
|
|
14001
|
+
failures: resolved.veto.failures,
|
|
14002
|
+
remainingMs: resolved.veto.remainingMs
|
|
14003
|
+
}
|
|
14004
|
+
});
|
|
14005
|
+
return resolved.backend;
|
|
14006
|
+
}
|
|
14007
|
+
/** The decoder addon's answer for this node — before the process quarantine. */
|
|
14008
|
+
async readEgressHwAccel(deviceId, override) {
|
|
13536
14009
|
const decoderApi = this.buildDecoderCapApi(deviceId);
|
|
13537
14010
|
return resolveEgressDecodeHwAccel({
|
|
13538
14011
|
readDecoderBackend: async () => {
|
|
@@ -14723,6 +15196,156 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
14723
15196
|
* of the event, not of the present.
|
|
14724
15197
|
*/
|
|
14725
15198
|
async renderPreBufferClip(input) {
|
|
15199
|
+
const cut = await this.selectClipWindow({
|
|
15200
|
+
deviceId: input.deviceId,
|
|
15201
|
+
aroundMs: input.aroundMs,
|
|
15202
|
+
preRollSec: input.preRollSec,
|
|
15203
|
+
postRollSec: input.postRollSec,
|
|
15204
|
+
...input.profile !== void 0 ? { profile: input.profile } : {}
|
|
15205
|
+
});
|
|
15206
|
+
const bytes = await this.muxClipWindow(cut, {
|
|
15207
|
+
format: input.format,
|
|
15208
|
+
maxWidth: input.maxWidth,
|
|
15209
|
+
fps: input.fps,
|
|
15210
|
+
...input.speed !== void 0 ? { speed: input.speed } : {}
|
|
15211
|
+
});
|
|
15212
|
+
const first = cut.window[0];
|
|
15213
|
+
const last = cut.window[cut.window.length - 1];
|
|
15214
|
+
return {
|
|
15215
|
+
base64: Buffer.from(bytes).toString("base64"),
|
|
15216
|
+
mime: input.format === "gif" ? "image/gif" : "video/mp4",
|
|
15217
|
+
bytes: bytes.byteLength,
|
|
15218
|
+
profile: cut.profile,
|
|
15219
|
+
durationMs: first && last ? Math.max(0, last.pts - first.pts) : 0
|
|
15220
|
+
};
|
|
15221
|
+
}
|
|
15222
|
+
/**
|
|
15223
|
+
* ONE cut of an event, in every container the caller asked for — the
|
|
15224
|
+
* production point every consumer of event media goes through.
|
|
15225
|
+
*
|
|
15226
|
+
* See the cap docblock for why this is one method rather than one call per
|
|
15227
|
+
* format. The short version is that `renderPreBufferClip` renders one format
|
|
15228
|
+
* per call, so a caller wanting a gif AND a video made two calls, got two
|
|
15229
|
+
* windows, and shipped a notification whose two attachments showed different
|
|
15230
|
+
* moments.
|
|
15231
|
+
*
|
|
15232
|
+
* The window comes out of the broker's own AnnexB retention ring — packets
|
|
15233
|
+
* that were already in RAM — so a production reaches BACKWARDS from the event
|
|
15234
|
+
* with nothing spawned and nothing kept warm. The mp4 is muxed once, COPYING
|
|
15235
|
+
* the camera's own H.264 whenever nothing is being changed, and any gif is
|
|
15236
|
+
* derived from that same mp4 rather than rendered a second time from the
|
|
15237
|
+
* packets: same window, same pixels, by construction.
|
|
15238
|
+
*/
|
|
15239
|
+
async produceEventMedia(input) {
|
|
15240
|
+
const preferred = input.profile ?? this.bestClipProfile(input.deviceId) ?? void 0;
|
|
15241
|
+
const cut = await this.selectClipWindow({
|
|
15242
|
+
deviceId: input.deviceId,
|
|
15243
|
+
aroundMs: input.aroundMs,
|
|
15244
|
+
preRollSec: input.preSeconds,
|
|
15245
|
+
postRollSec: input.postSeconds,
|
|
15246
|
+
...preferred !== void 0 ? { profile: preferred } : {}
|
|
15247
|
+
});
|
|
15248
|
+
const mp4 = await this.muxClipWindow(cut, {
|
|
15249
|
+
format: "mp4",
|
|
15250
|
+
maxWidth: Math.max(1, cut.sourceWidth),
|
|
15251
|
+
fps: 0,
|
|
15252
|
+
speed: input.speed
|
|
15253
|
+
});
|
|
15254
|
+
const media = [];
|
|
15255
|
+
if (input.kinds.includes("mp4")) media.push({
|
|
15256
|
+
kind: "mp4",
|
|
15257
|
+
handle: this.eventMedia.put({
|
|
15258
|
+
deviceId: input.deviceId,
|
|
15259
|
+
kind: "mp4",
|
|
15260
|
+
mime: "video/mp4",
|
|
15261
|
+
bytes: mp4
|
|
15262
|
+
}),
|
|
15263
|
+
nodeId: this.localNodeId ?? "",
|
|
15264
|
+
mime: "video/mp4",
|
|
15265
|
+
bytes: mp4.byteLength,
|
|
15266
|
+
width: cut.sourceWidth,
|
|
15267
|
+
height: cut.sourceHeight
|
|
15268
|
+
});
|
|
15269
|
+
if (input.kinds.includes("gif")) {
|
|
15270
|
+
const gif = await gifFromMp4({
|
|
15271
|
+
mp4,
|
|
15272
|
+
deviceId: input.deviceId,
|
|
15273
|
+
maxWidth: input.gifMaxWidth,
|
|
15274
|
+
fps: input.gifFps,
|
|
15275
|
+
speed: 1,
|
|
15276
|
+
...this.logger !== void 0 ? { logger: this.logger } : {},
|
|
15277
|
+
spawnFn: spawn,
|
|
15278
|
+
...this.ffmpegConfig.binaryPath !== void 0 ? { ffmpegPath: this.ffmpegConfig.binaryPath } : {}
|
|
15279
|
+
});
|
|
15280
|
+
if (gif !== null) {
|
|
15281
|
+
const height = cut.sourceWidth > 0 ? Math.round(input.gifMaxWidth / cut.sourceWidth * cut.sourceHeight) : 0;
|
|
15282
|
+
media.push({
|
|
15283
|
+
kind: "gif",
|
|
15284
|
+
handle: this.eventMedia.put({
|
|
15285
|
+
deviceId: input.deviceId,
|
|
15286
|
+
kind: "gif",
|
|
15287
|
+
mime: "image/gif",
|
|
15288
|
+
bytes: gif
|
|
15289
|
+
}),
|
|
15290
|
+
nodeId: this.localNodeId ?? "",
|
|
15291
|
+
mime: "image/gif",
|
|
15292
|
+
bytes: gif.byteLength,
|
|
15293
|
+
width: input.gifMaxWidth,
|
|
15294
|
+
height
|
|
15295
|
+
});
|
|
15296
|
+
}
|
|
15297
|
+
}
|
|
15298
|
+
const video = this.clipVideoMode(cut, {
|
|
15299
|
+
format: "mp4",
|
|
15300
|
+
maxWidth: Math.max(1, cut.sourceWidth),
|
|
15301
|
+
speed: input.speed
|
|
15302
|
+
});
|
|
15303
|
+
this.logger?.info("event media produced — one window, every container", {
|
|
15304
|
+
tags: { deviceId: input.deviceId },
|
|
15305
|
+
meta: {
|
|
15306
|
+
profile: cut.profile,
|
|
15307
|
+
video,
|
|
15308
|
+
resolution: `${cut.sourceWidth}x${cut.sourceHeight}`,
|
|
15309
|
+
packets: cut.window.length,
|
|
15310
|
+
kinds: [...input.kinds].join(","),
|
|
15311
|
+
artifacts: media.map((m) => `${m.kind}:${m.bytes}`).join(" "),
|
|
15312
|
+
startOffsetMs: cut.coverage.fromTs - input.aroundMs,
|
|
15313
|
+
endOffsetMs: cut.coverage.toTs - input.aroundMs
|
|
15314
|
+
}
|
|
15315
|
+
});
|
|
15316
|
+
return {
|
|
15317
|
+
media,
|
|
15318
|
+
coverage: {
|
|
15319
|
+
fromTs: cut.coverage.fromTs,
|
|
15320
|
+
toTs: cut.coverage.toTs,
|
|
15321
|
+
packets: cut.window.length
|
|
15322
|
+
},
|
|
15323
|
+
profile: cut.profile,
|
|
15324
|
+
video
|
|
15325
|
+
};
|
|
15326
|
+
}
|
|
15327
|
+
/** Redeem an artifact handle. `null` once it has expired (see
|
|
15328
|
+
* {@link EventMediaStore}). */
|
|
15329
|
+
async fetchEventMedia(input) {
|
|
15330
|
+
const item = this.eventMedia.get(input.handle);
|
|
15331
|
+
if (item === null) {
|
|
15332
|
+
this.logger?.warn("event media: handle expired or unknown — nothing to fetch", { meta: { handle: input.handle } });
|
|
15333
|
+
return null;
|
|
15334
|
+
}
|
|
15335
|
+
return {
|
|
15336
|
+
base64: Buffer.from(item.bytes).toString("base64"),
|
|
15337
|
+
mime: item.mime,
|
|
15338
|
+
bytes: item.bytes.length
|
|
15339
|
+
};
|
|
15340
|
+
}
|
|
15341
|
+
/**
|
|
15342
|
+
* Wait out the post-roll, then select the retained window around the moment.
|
|
15343
|
+
*
|
|
15344
|
+
* Shared by `renderPreBufferClip` and `produceEventMedia` deliberately: two
|
|
15345
|
+
* copies of "which profile, which packets, which resolution" is exactly how
|
|
15346
|
+
* the two paths would start disagreeing again.
|
|
15347
|
+
*/
|
|
15348
|
+
async selectClipWindow(input) {
|
|
14726
15349
|
const map = this.assignments.get(input.deviceId)?.map ?? {};
|
|
14727
15350
|
const assigned = CLIP_PROFILE_BY_COST.filter((p) => map[p] !== void 0);
|
|
14728
15351
|
const wanted = pickClipProfile(assigned, input.profile);
|
|
@@ -14733,19 +15356,26 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
14733
15356
|
const candidates = [wanted, ...assigned.filter((p) => p !== wanted)];
|
|
14734
15357
|
let profile = null;
|
|
14735
15358
|
let window = [];
|
|
15359
|
+
let coverage = {
|
|
15360
|
+
fromTs: input.aroundMs,
|
|
15361
|
+
toTs: input.aroundMs
|
|
15362
|
+
};
|
|
14736
15363
|
for (const candidate of candidates) {
|
|
14737
15364
|
const broker = this.brokerFor(input.deviceId, map[candidate]);
|
|
14738
15365
|
if (!broker) continue;
|
|
15366
|
+
const packets = broker.getClipBuffer();
|
|
15367
|
+
const nowMs = Date.now();
|
|
14739
15368
|
const found = windowAround({
|
|
14740
|
-
packets
|
|
15369
|
+
packets,
|
|
14741
15370
|
aroundMs: input.aroundMs,
|
|
14742
15371
|
preRollSec: input.preRollSec,
|
|
14743
15372
|
postRollSec: input.postRollSec,
|
|
14744
|
-
nowMs
|
|
15373
|
+
nowMs
|
|
14745
15374
|
});
|
|
14746
15375
|
if (found.length > 0) {
|
|
14747
15376
|
profile = candidate;
|
|
14748
15377
|
window = found;
|
|
15378
|
+
coverage = windowCoverage(packets, found, nowMs);
|
|
14749
15379
|
break;
|
|
14750
15380
|
}
|
|
14751
15381
|
}
|
|
@@ -14759,29 +15389,77 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
14759
15389
|
});
|
|
14760
15390
|
const broker = this.brokerFor(input.deviceId, map[profile]);
|
|
14761
15391
|
if (!broker) throw new Error(`No live broker for device ${input.deviceId} (${profile})`);
|
|
14762
|
-
const
|
|
15392
|
+
const cam = this.cameraStreams.get(input.deviceId)?.get(map[profile] ?? "");
|
|
15393
|
+
return {
|
|
15394
|
+
deviceId: input.deviceId,
|
|
15395
|
+
profile,
|
|
14763
15396
|
window,
|
|
14764
|
-
|
|
14765
|
-
|
|
14766
|
-
|
|
14767
|
-
|
|
15397
|
+
coverage,
|
|
15398
|
+
broker,
|
|
15399
|
+
sourceWidth: cam?.resolution?.width ?? 0,
|
|
15400
|
+
sourceHeight: cam?.resolution?.height ?? 0
|
|
15401
|
+
};
|
|
15402
|
+
}
|
|
15403
|
+
/** The rendition an event production prefers for this camera, or null when
|
|
15404
|
+
* nothing qualifies (every slot H.265, or none publishes a resolution). */
|
|
15405
|
+
bestClipProfile(deviceId) {
|
|
15406
|
+
const map = this.assignments.get(deviceId)?.map ?? {};
|
|
15407
|
+
const streams = this.cameraStreams.get(deviceId);
|
|
15408
|
+
const candidates = [];
|
|
15409
|
+
for (const profile of CLIP_PROFILE_BY_COST) {
|
|
15410
|
+
const camStreamId = map[profile];
|
|
15411
|
+
if (camStreamId === void 0) continue;
|
|
15412
|
+
const cam = streams?.get(camStreamId);
|
|
15413
|
+
candidates.push({
|
|
15414
|
+
profile,
|
|
15415
|
+
...cam?.codec !== void 0 ? { codec: cam.codec } : {},
|
|
15416
|
+
...cam?.resolution?.width !== void 0 ? { width: cam.resolution.width } : {},
|
|
15417
|
+
...cam?.resolution?.height !== void 0 ? { height: cam.resolution.height } : {}
|
|
15418
|
+
});
|
|
15419
|
+
}
|
|
15420
|
+
return pickBestClipProfile(candidates);
|
|
15421
|
+
}
|
|
15422
|
+
/** `copy` or `encode` for this cut — reported, never inferred from a size. */
|
|
15423
|
+
clipVideoMode(cut, render) {
|
|
15424
|
+
return clipCanCopy({
|
|
15425
|
+
format: render.format,
|
|
15426
|
+
maxWidth: render.maxWidth,
|
|
15427
|
+
...render.speed !== void 0 ? { speed: render.speed } : {},
|
|
15428
|
+
hevc: isHevcPacket(cut.window[0]),
|
|
15429
|
+
sourceWidth: cut.sourceWidth
|
|
15430
|
+
}) ? "copy" : "encode";
|
|
15431
|
+
}
|
|
15432
|
+
async muxClipWindow(cut, render) {
|
|
15433
|
+
const bytes = await renderPreBufferClip({
|
|
15434
|
+
window: cut.window,
|
|
15435
|
+
format: render.format,
|
|
15436
|
+
maxWidth: render.maxWidth,
|
|
15437
|
+
fps: render.fps,
|
|
15438
|
+
sourceWidth: cut.sourceWidth,
|
|
15439
|
+
...render.speed !== void 0 ? { speed: render.speed } : {},
|
|
14768
15440
|
spawn: (cmd, args) => spawn(cmd, [...args], { stdio: [
|
|
14769
15441
|
"pipe",
|
|
14770
15442
|
"pipe",
|
|
14771
15443
|
"pipe"
|
|
14772
15444
|
] }),
|
|
14773
15445
|
ffmpegPath: this.ffmpegConfig.binaryPath,
|
|
14774
|
-
sdpParameterSets: broker.getSdpParameterSets()
|
|
15446
|
+
sdpParameterSets: cut.broker.getSdpParameterSets()
|
|
14775
15447
|
});
|
|
14776
|
-
|
|
14777
|
-
|
|
14778
|
-
|
|
14779
|
-
|
|
14780
|
-
|
|
14781
|
-
|
|
14782
|
-
|
|
14783
|
-
|
|
14784
|
-
|
|
15448
|
+
this.logger?.info("pre-buffer clip rendered", {
|
|
15449
|
+
tags: { deviceId: cut.deviceId },
|
|
15450
|
+
meta: {
|
|
15451
|
+
profile: cut.profile,
|
|
15452
|
+
format: render.format,
|
|
15453
|
+
video: this.clipVideoMode(cut, render),
|
|
15454
|
+
sourceWidth: cut.sourceWidth,
|
|
15455
|
+
maxWidth: render.maxWidth,
|
|
15456
|
+
speed: render.speed ?? null,
|
|
15457
|
+
packets: cut.window.length,
|
|
15458
|
+
bytes: bytes.byteLength,
|
|
15459
|
+
coverageMs: cut.coverage.toTs - cut.coverage.fromTs
|
|
15460
|
+
}
|
|
15461
|
+
});
|
|
15462
|
+
return bytes;
|
|
14785
15463
|
}
|
|
14786
15464
|
getCameraStreamsForDevice(deviceId) {
|
|
14787
15465
|
const streamMap = this.cameraStreams.get(deviceId);
|
|
@@ -16221,6 +16899,8 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16221
16899
|
* within the minute — a rule asking for `high` fell back to `low` forever
|
|
16222
16900
|
* and looked like the choice did nothing.
|
|
16223
16901
|
*/
|
|
16902
|
+
/** Produced event artifacts waiting to be fetched by handle (D9/D18). */
|
|
16903
|
+
eventMedia = new EventMediaStore();
|
|
16224
16904
|
clipRetentionArmed = /* @__PURE__ */ new Map();
|
|
16225
16905
|
armClipRetention(deviceId, profile) {
|
|
16226
16906
|
const armed = this.clipRetentionArmed.get(deviceId) ?? /* @__PURE__ */ new Set();
|
|
@@ -18241,6 +18921,17 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
18241
18921
|
acquireTranscodeEgress;
|
|
18242
18922
|
/** The acquired egress, while this session holds a reference to it. */
|
|
18243
18923
|
transcodeEgressLeg = null;
|
|
18924
|
+
/**
|
|
18925
|
+
* An acquire started at the ANSWER and not yet consumed by the feed.
|
|
18926
|
+
*
|
|
18927
|
+
* Read-and-null-out is the whole ownership protocol: whichever of
|
|
18928
|
+
* `startTranscodeFeed` and `releaseTranscodeEgress` reads a non-null value
|
|
18929
|
+
* owns the release, and since both read it synchronously exactly one can.
|
|
18930
|
+
* Without that, a session closing while its prewarm is in flight either
|
|
18931
|
+
* double-releases (the manager logs it as a consumer bookkeeping bug) or
|
|
18932
|
+
* leaks a child the primitive has no TTL to reclaim.
|
|
18933
|
+
*/
|
|
18934
|
+
transcodeEgressPending = null;
|
|
18244
18935
|
/** Unsubscribe from the acquired egress's video plane. */
|
|
18245
18936
|
detachTranscodeEgress = null;
|
|
18246
18937
|
/** Codec actually negotiated with the browser after SDP answer. */
|
|
@@ -18880,6 +19571,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
18880
19571
|
negotiated: this.negotiatedCodec,
|
|
18881
19572
|
needsTranscode: this.needsTranscode
|
|
18882
19573
|
} });
|
|
19574
|
+
this.prewarmTranscodeEgress();
|
|
18883
19575
|
if (this.debug) this.logger.info("Codec negotiation detail", { meta: {
|
|
18884
19576
|
phase: "session",
|
|
18885
19577
|
sessionId: this.sessionId,
|
|
@@ -19078,6 +19770,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
19078
19770
|
setRemoteMs,
|
|
19079
19771
|
createAnswerMs
|
|
19080
19772
|
} });
|
|
19773
|
+
this.prewarmTranscodeEgress();
|
|
19081
19774
|
if (this.debug) this.logger.info("Codec negotiation detail (client-offer)", { meta: {
|
|
19082
19775
|
phase: "session",
|
|
19083
19776
|
sessionId: this.sessionId,
|
|
@@ -19798,6 +20491,51 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
19798
20491
|
* unchanged — the request asks for `audio: 'passthrough'` precisely so the
|
|
19799
20492
|
* child does not encode a plane this session never reads.
|
|
19800
20493
|
*/
|
|
20494
|
+
/**
|
|
20495
|
+
* Start the egress acquire NOW, in parallel with ICE and DTLS.
|
|
20496
|
+
*
|
|
20497
|
+
* `needsTranscode` is final the moment the answer's codec is known — the
|
|
20498
|
+
* "Codec negotiated" line already prints it — but the acquire used to happen
|
|
20499
|
+
* inside `startFeedingFrames`, which runs only after the DTLS wait. On camera
|
|
20500
|
+
* 615 that put the leg ~2 s after session creation and the first packet ~5 s
|
|
20501
|
+
* after that, against a browser watchdog that re-offers every ~5 s: the viewer
|
|
20502
|
+
* was always gone before the media existed, and the next session paid for a
|
|
20503
|
+
* cold child again. Three minutes of that on 2026-08-07 22:17–22:20.
|
|
20504
|
+
*
|
|
20505
|
+
* Only the ffmpeg moves. The ATTACH stays behind DTLS, because `sendRtp`
|
|
20506
|
+
* silently drops before the transport is connected.
|
|
20507
|
+
*
|
|
20508
|
+
* Idempotent and safe to call from any negotiation path; a session that needs
|
|
20509
|
+
* no transcode does nothing. Public for the same reason
|
|
20510
|
+
* {@link armSourceSilenceWatchdog} is — the acquire/release contract is
|
|
20511
|
+
* testable without standing up werift.
|
|
20512
|
+
*/
|
|
20513
|
+
prewarmTranscodeEgress() {
|
|
20514
|
+
if (this.closed) return;
|
|
20515
|
+
if (!this.needsTranscode) return;
|
|
20516
|
+
if (this.transcodeEgressPending !== null || this.transcodeEgressLeg !== null) return;
|
|
20517
|
+
const acquire = this.acquireTranscodeEgress;
|
|
20518
|
+
if (acquire === null) return;
|
|
20519
|
+
this.logger.info("Transcode leg: prewarming the egress while ICE/DTLS negotiate", { meta: {
|
|
20520
|
+
phase: "session",
|
|
20521
|
+
sessionId: this.sessionId,
|
|
20522
|
+
deviceId: this.deviceId,
|
|
20523
|
+
sourceCodec: this.sourceCodec
|
|
20524
|
+
} });
|
|
20525
|
+
this.transcodeEgressPending = acquire({
|
|
20526
|
+
profile: this.transcodeProfile,
|
|
20527
|
+
sessionId: this.sessionId
|
|
20528
|
+
});
|
|
20529
|
+
}
|
|
20530
|
+
/**
|
|
20531
|
+
* Take ownership of the in-flight prewarm, if there is one. Nulls the field
|
|
20532
|
+
* synchronously so the release path can never also claim it — see the field.
|
|
20533
|
+
*/
|
|
20534
|
+
claimPrewarmedEgress() {
|
|
20535
|
+
const pending = this.transcodeEgressPending;
|
|
20536
|
+
this.transcodeEgressPending = null;
|
|
20537
|
+
return pending;
|
|
20538
|
+
}
|
|
19801
20539
|
startTranscodeFeed(signal) {
|
|
19802
20540
|
const acquire = this.acquireTranscodeEgress;
|
|
19803
20541
|
if (acquire === null) {
|
|
@@ -19809,20 +20547,32 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
19809
20547
|
if (!this.closed) this.close();
|
|
19810
20548
|
return;
|
|
19811
20549
|
}
|
|
20550
|
+
const prewarmed = this.claimPrewarmedEgress();
|
|
19812
20551
|
this.logger.info(`Starting ${this.sourceCodec}→H.264 Baseline transcode leg`, { meta: {
|
|
19813
20552
|
phase: "session",
|
|
19814
20553
|
sessionId: this.sessionId,
|
|
19815
20554
|
deviceId: this.deviceId,
|
|
19816
20555
|
sourceCodec: this.sourceCodec,
|
|
19817
|
-
mode: "egress-primitive"
|
|
20556
|
+
mode: "egress-primitive",
|
|
20557
|
+
prewarmed: prewarmed !== null
|
|
19818
20558
|
} });
|
|
20559
|
+
const acquireNow = () => acquire({
|
|
20560
|
+
profile: this.transcodeProfile,
|
|
20561
|
+
sessionId: this.sessionId
|
|
20562
|
+
});
|
|
19819
20563
|
(async () => {
|
|
19820
20564
|
let leg;
|
|
19821
20565
|
try {
|
|
19822
|
-
leg = await
|
|
19823
|
-
|
|
19824
|
-
|
|
19825
|
-
|
|
20566
|
+
leg = await (prewarmed === null ? acquireNow() : prewarmed.catch((err) => {
|
|
20567
|
+
if (signal.aborted || this.closed) throw err;
|
|
20568
|
+
this.logger.warn("Transcode leg: the prewarmed acquire failed — retrying now that the feed has started", { meta: {
|
|
20569
|
+
phase: "session",
|
|
20570
|
+
sessionId: this.sessionId,
|
|
20571
|
+
deviceId: this.deviceId,
|
|
20572
|
+
error: errMsg(err)
|
|
20573
|
+
} });
|
|
20574
|
+
return acquireNow();
|
|
20575
|
+
}));
|
|
19826
20576
|
} catch (err) {
|
|
19827
20577
|
this.logger.error("Transcode feed: acquiring the egress transcode FAILED", { meta: {
|
|
19828
20578
|
phase: "session",
|
|
@@ -19890,6 +20640,14 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
19890
20640
|
try {
|
|
19891
20641
|
detach?.();
|
|
19892
20642
|
} catch {}
|
|
20643
|
+
const orphan = this.claimPrewarmedEgress();
|
|
20644
|
+
if (orphan !== null) orphan.then((prewarmedLeg) => prewarmedLeg.release()).catch((err) => {
|
|
20645
|
+
this.logger.debug("Transcode leg: the unclaimed prewarm ended without a handle", { meta: {
|
|
20646
|
+
phase: "session",
|
|
20647
|
+
sessionId: this.sessionId,
|
|
20648
|
+
error: errMsg(err)
|
|
20649
|
+
} });
|
|
20650
|
+
});
|
|
19893
20651
|
const leg = this.transcodeEgressLeg;
|
|
19894
20652
|
this.transcodeEgressLeg = null;
|
|
19895
20653
|
if (!leg) return;
|