@camstack/addon-pipeline 1.1.17 → 1.1.19
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/audio-codec-nodeav/index.js +1 -1
- package/dist/audio-codec-nodeav/index.mjs +1 -1
- package/dist/decoder-ffmpeg/index.js +1179 -0
- package/dist/decoder-ffmpeg/index.mjs +1162 -0
- package/dist/detection-pipeline/index.js +1 -1
- package/dist/detection-pipeline/index.mjs +1 -1
- package/dist/{dist-CBcVKDyT.mjs → dist-7Yx2dmuV.mjs} +1 -0
- package/dist/{dist-CRzS7bK1.js → dist-BgoBMCez.js} +1 -0
- package/dist/ffmpeg-args-common-CASoNt42.js +68 -0
- package/dist/ffmpeg-args-common-c3p-nWtU.mjs +51 -0
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +1 -1
- package/dist/pipeline-runner/index.mjs +1 -1
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/index.mjs +1 -1
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cu2RfLyo.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-w2pP0MYO.mjs} +1 -1
- package/dist/stream-broker/{hostInit-D2VENktw.mjs → hostInit-BUEbBinp.mjs} +1 -1
- package/dist/stream-broker/index.js +68 -73
- package/dist/stream-broker/index.mjs +58 -63
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-ohKbGSOY.js → MaskShapeCanvas-DI4BY7W2-CYmHXacX.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-m6kOetLh.js → MotionZonesSettings-NcxxQN8r-B-BkPQvX.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BNbgX3su.js → PrivacyMaskSettings-APgPLF7p-DC3Jw85p.js} +1 -1
- package/embed-dist/assets/{index-CqqHOFtM.js → index-B0IQgci0.js} +4 -4
- package/embed-dist/index.html +1 -1
- package/package.json +8 -8
- package/dist/decoder-nodeav/index.js +0 -1393
- package/dist/decoder-nodeav/index.mjs +0 -1385
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
Object.defineProperties(exports, {
|
|
2
|
+
__esModule: { value: true },
|
|
3
|
+
[Symbol.toStringTag]: { value: "Module" }
|
|
4
|
+
});
|
|
5
|
+
const require_dist = require("../dist-BgoBMCez.js");
|
|
6
|
+
const require_ffmpeg_args_common = require("../ffmpeg-args-common-CASoNt42.js");
|
|
7
|
+
let _camstack_shm_ring = require("@camstack/shm-ring");
|
|
8
|
+
let node_crypto = require("node:crypto");
|
|
9
|
+
let node_child_process = require("node:child_process");
|
|
10
|
+
//#region src/decoder-nodeav/frame-ring-sink.ts
|
|
11
|
+
/**
|
|
12
|
+
* `DecoderFrameRingSink` — the decoder's shared-memory write side (Phase 5 / D9).
|
|
13
|
+
*
|
|
14
|
+
* When a decoder session is configured with `frameSink: 'shm'`, the decoder
|
|
15
|
+
* **owns** the shared-memory ring segment for that stream: it creates the
|
|
16
|
+
* segment on the first decoded frame (when the output geometry is known),
|
|
17
|
+
* writes every subsequent decoded frame into the ring via a `FrameRingWriter`,
|
|
18
|
+
* and closes + unlinks the segment when the session is destroyed.
|
|
19
|
+
*
|
|
20
|
+
* What leaves the decoder is no longer the pixel `Buffer` — it is a tiny,
|
|
21
|
+
* serialisable `FrameHandle` (`FrameRingWriter.writeFrame`'s return value).
|
|
22
|
+
* Same-host consumers (motion, detection, the WebRTC encoder) open the same
|
|
23
|
+
* segment with a `FrameRingReader` and read the pixels zero-copy.
|
|
24
|
+
*
|
|
25
|
+
* ## Lazy segment creation
|
|
26
|
+
*
|
|
27
|
+
* The segment cannot be sized until the first frame: `slotByteLength` is
|
|
28
|
+
* `width × height × bytesPerPixel`, and the output dimensions are only known
|
|
29
|
+
* once the scaler has produced its first `dstFrame`. So `writeFrame` is a
|
|
30
|
+
* no-op-until-armed: the first call sizes + creates the segment, every later
|
|
31
|
+
* call writes into it.
|
|
32
|
+
*
|
|
33
|
+
* ## Resolution-change decision
|
|
34
|
+
*
|
|
35
|
+
* A live camera stream can change resolution mid-stream (the decoder's scaler
|
|
36
|
+
* is rebuilt on a config toggle, or the source renegotiates). The slot is
|
|
37
|
+
* sized for the **first** frame's geometry. A later frame that no longer fits
|
|
38
|
+
* the slot triggers a **segment re-create**: the old segment is closed +
|
|
39
|
+
* unlinked and a fresh, larger segment is created under a new generation-tagged
|
|
40
|
+
* name. This is simpler and leak-free versus over-allocating slots for a
|
|
41
|
+
* worst-case 4K frame on every stream; resolution changes on a live camera are
|
|
42
|
+
* rare, and a brief gap while consumers re-open the segment is acceptable
|
|
43
|
+
* (latest-wins — a missed frame is correct behaviour).
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* Per-ring shared-memory budget (MB) — the slot count is derived per-resolution
|
|
47
|
+
* from this budget via {@link deriveSlotCount}, so a 360p stream gets many
|
|
48
|
+
* slots and a 4K stream a few, both inside the same memory footprint.
|
|
49
|
+
*
|
|
50
|
+
* Read ONCE at module load from `CAMSTACK_SHM_RING_BUDGET_MB`; a non-finite or
|
|
51
|
+
* non-positive value falls back to the 16 MB default.
|
|
52
|
+
*
|
|
53
|
+
* The default is deliberately small (16 MB) so many concurrent per-camera rings
|
|
54
|
+
* fit inside a bounded `/dev/shm`. The decoder output is capped at 640px wide, so
|
|
55
|
+
* a slot is ≤ ~920 KB and 16 MB still yields ~17–70 latest-wins slots. A ring
|
|
56
|
+
* segment larger than the container's `/dev/shm` tmpfs backing (Docker default is
|
|
57
|
+
* only 64 MB) faults an **uncatchable SIGBUS** on write past `st_size` — the old
|
|
58
|
+
* 128 MB default overflowed a 64 MB `/dev/shm` and crashed the decoder on 8MP
|
|
59
|
+
* streams. Pair this with a `--shm-size` that scales with concurrent-decode load.
|
|
60
|
+
*/
|
|
61
|
+
var RING_BUDGET_MB = (() => {
|
|
62
|
+
const raw = Number(process.env["CAMSTACK_SHM_RING_BUDGET_MB"]);
|
|
63
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 16;
|
|
64
|
+
})();
|
|
65
|
+
/** {@link RING_BUDGET_MB} in bytes — the budget passed to `deriveSlotCount`. */
|
|
66
|
+
var RING_BUDGET_BYTES = RING_BUDGET_MB * 1024 * 1024;
|
|
67
|
+
/** A unique, stable shared-memory segment name for a decoder stream.
|
|
68
|
+
*
|
|
69
|
+
* macOS POSIX shm names are capped at ~31 characters (`PSHMNAMLEN`). A
|
|
70
|
+
* `camstack.frames.<deviceId>.<streamId>` scheme overflows that for realistic
|
|
71
|
+
* ids, so the sink uses a short, collision-resistant scheme instead:
|
|
72
|
+
* `csf.<base36 hash>.<gen>`. The hash folds the device id, the session tag
|
|
73
|
+
* and a per-process random salt; the generation suffix makes a re-created
|
|
74
|
+
* segment (resolution change) a distinct name so a stale consumer mapping is
|
|
75
|
+
* never silently reused.
|
|
76
|
+
*/
|
|
77
|
+
function makeSegmentName(seed, generation) {
|
|
78
|
+
let hash = 5381;
|
|
79
|
+
for (let i = 0; i < seed.length; i += 1) hash = (hash << 5) + hash + seed.charCodeAt(i) | 0;
|
|
80
|
+
return `csf.${(hash >>> 0).toString(36)}.${generation}`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The decoder-side owner of one stream's shared-memory frame ring.
|
|
84
|
+
*
|
|
85
|
+
* Not constructed until a session actually uses the shm sink; the segment
|
|
86
|
+
* itself is created lazily on the first `writeFrame`.
|
|
87
|
+
*/
|
|
88
|
+
var DecoderFrameRingSink = class {
|
|
89
|
+
seed;
|
|
90
|
+
logger;
|
|
91
|
+
nodeId;
|
|
92
|
+
segment = null;
|
|
93
|
+
writer = null;
|
|
94
|
+
segmentName = null;
|
|
95
|
+
slotByteLength = 0;
|
|
96
|
+
generation = 0;
|
|
97
|
+
destroyed = false;
|
|
98
|
+
/** Frames committed into the ring across this sink's lifetime (all generations). */
|
|
99
|
+
framesWritten = 0;
|
|
100
|
+
constructor(options) {
|
|
101
|
+
const salt = Math.random().toString(36).slice(2, 8);
|
|
102
|
+
this.seed = `${options.seed}.${salt}`;
|
|
103
|
+
this.logger = options.logger;
|
|
104
|
+
this.nodeId = options.nodeId;
|
|
105
|
+
}
|
|
106
|
+
/** Whether a segment has been created (i.e. at least one frame written). */
|
|
107
|
+
get isArmed() {
|
|
108
|
+
return this.writer !== null;
|
|
109
|
+
}
|
|
110
|
+
/** The current segment name, or `null` before the first frame. */
|
|
111
|
+
get currentSegmentName() {
|
|
112
|
+
return this.segmentName;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Write one decoded frame into the ring and return its `FrameHandle`.
|
|
116
|
+
*
|
|
117
|
+
* On the first call (or after a geometry change that overflows the current
|
|
118
|
+
* slot) the segment is created / re-created sized for this frame. Returns
|
|
119
|
+
* `null` only when the sink has been destroyed.
|
|
120
|
+
*
|
|
121
|
+
* This is the copy-in convenience form (it copies `pixels` into the slot).
|
|
122
|
+
* The decoder's hot path uses the zero-copy {@link beginFrame} /
|
|
123
|
+
* {@link commitFrame} scatter-write pair instead — the scaler produces its
|
|
124
|
+
* packed output directly into the slot, eliminating the write-side memcpy.
|
|
125
|
+
*/
|
|
126
|
+
writeFrame(pixels, meta) {
|
|
127
|
+
if (this.destroyed) return null;
|
|
128
|
+
if (this.writer === null || (0, _camstack_shm_ring.computeSlotByteLength)(meta.width, meta.height, meta.format) > this.slotByteLength) this.recreateSegment((0, _camstack_shm_ring.computeSlotByteLength)(meta.width, meta.height, meta.format));
|
|
129
|
+
const writer = this.writer;
|
|
130
|
+
if (writer === null) return null;
|
|
131
|
+
const handle = writer.writeFrame(pixels, meta);
|
|
132
|
+
this.framesWritten += 1;
|
|
133
|
+
return handle;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Reserve a ring slot for a frame of the given geometry — the **zero-copy**
|
|
137
|
+
* scatter-write entry point (Phase 5 / D9 Task 7c).
|
|
138
|
+
*
|
|
139
|
+
* The segment is created / re-created here if this is the first frame or the
|
|
140
|
+
* geometry overflows the current slot capacity, so the slot is correctly
|
|
141
|
+
* sized before the caller fills it. The returned `buffer` is a writable view
|
|
142
|
+
* **directly over the mapped segment** — the node-av scaler scatters its
|
|
143
|
+
* packed output straight into it, with no intermediate copy. The caller MUST
|
|
144
|
+
* call {@link commitFrame} with the returned `slot` once the slot is filled.
|
|
145
|
+
*
|
|
146
|
+
* Returns `null` when the sink is destroyed or the segment cannot be created.
|
|
147
|
+
*/
|
|
148
|
+
beginFrame(width, height, format) {
|
|
149
|
+
if (this.destroyed) return null;
|
|
150
|
+
const requiredSlotBytes = (0, _camstack_shm_ring.computeSlotByteLength)(width, height, format);
|
|
151
|
+
if (this.writer === null || requiredSlotBytes > this.slotByteLength) this.recreateSegment(requiredSlotBytes);
|
|
152
|
+
const writer = this.writer;
|
|
153
|
+
if (writer === null) return null;
|
|
154
|
+
const { slot, buffer } = writer.beginFrame();
|
|
155
|
+
return {
|
|
156
|
+
slot,
|
|
157
|
+
buffer
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Publish the frame whose slot was reserved by {@link beginFrame} and filled
|
|
162
|
+
* in place by the caller. `slot` MUST be the value from the matching
|
|
163
|
+
* `beginFrame`. Returns the published `FrameHandle`, or `null` if the sink
|
|
164
|
+
* was destroyed (or the segment lost) between begin and commit.
|
|
165
|
+
*/
|
|
166
|
+
commitFrame(slot, meta) {
|
|
167
|
+
if (this.destroyed) return null;
|
|
168
|
+
const writer = this.writer;
|
|
169
|
+
if (writer === null) return null;
|
|
170
|
+
const handle = writer.commitFrame(slot, meta);
|
|
171
|
+
this.framesWritten += 1;
|
|
172
|
+
return handle;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Current shm ring usage — `null` until the first frame arms the segment.
|
|
176
|
+
* Surfaced through `decoder.getShmStats` so a downstream consumer can
|
|
177
|
+
* observe ring pressure (slot depth, byte budget, frames written).
|
|
178
|
+
*/
|
|
179
|
+
getShmStats() {
|
|
180
|
+
if (this.writer === null) return null;
|
|
181
|
+
return {
|
|
182
|
+
slotCount: this.writer.slotCount,
|
|
183
|
+
slotByteLength: this.slotByteLength,
|
|
184
|
+
segmentBytes: (0, _camstack_shm_ring.computeSegmentSize)(this.writer.slotCount, this.slotByteLength),
|
|
185
|
+
framesWritten: this.framesWritten
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Abandon a slot reserved by {@link beginFrame} **without publishing it** —
|
|
190
|
+
* the degenerate-path counterpart of {@link commitFrame}.
|
|
191
|
+
*
|
|
192
|
+
* A caller that reserved a slot but then could not produce valid pixels (no
|
|
193
|
+
* decoded source planes, or the scaler threw) MUST call this instead of
|
|
194
|
+
* `commitFrame`: it closes the open seqlock without advancing `writeIndex`,
|
|
195
|
+
* so no reader ever sees the slot's uninitialised bytes as a real frame, and
|
|
196
|
+
* no `FrameHandle` is handed downstream. `slot` MUST be the value from the
|
|
197
|
+
* matching `beginFrame`. A no-op if the sink was destroyed (or the segment
|
|
198
|
+
* lost) between begin and abort.
|
|
199
|
+
*/
|
|
200
|
+
abortFrame(slot) {
|
|
201
|
+
if (this.destroyed) return;
|
|
202
|
+
const writer = this.writer;
|
|
203
|
+
if (writer === null) return;
|
|
204
|
+
writer.abortFrame(slot);
|
|
205
|
+
}
|
|
206
|
+
/** Close + unlink the segment. Idempotent. */
|
|
207
|
+
destroy() {
|
|
208
|
+
if (this.destroyed) return;
|
|
209
|
+
this.destroyed = true;
|
|
210
|
+
this.releaseSegment();
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Create a fresh segment sized for at least `slotByteLength` bytes per slot,
|
|
214
|
+
* replacing any prior one. A re-create bumps the generation so the new
|
|
215
|
+
* segment has a distinct name — a consumer holding the old mapping is never
|
|
216
|
+
* silently handed a resized segment.
|
|
217
|
+
*/
|
|
218
|
+
recreateSegment(slotByteLength) {
|
|
219
|
+
this.releaseSegment();
|
|
220
|
+
this.generation += 1;
|
|
221
|
+
const name = makeSegmentName(this.seed, this.generation);
|
|
222
|
+
const slotCount = (0, _camstack_shm_ring.deriveSlotCount)(RING_BUDGET_BYTES, slotByteLength);
|
|
223
|
+
if (slotCount === _camstack_shm_ring.MIN_RING_SLOTS && _camstack_shm_ring.MIN_RING_SLOTS * slotByteLength > RING_BUDGET_BYTES) this.logger.warn("decoder shm ring: budget too small for resolution — using MIN slots", { meta: {
|
|
224
|
+
slotByteLength,
|
|
225
|
+
budgetMb: RING_BUDGET_MB
|
|
226
|
+
} });
|
|
227
|
+
const totalBytes = (0, _camstack_shm_ring.computeSegmentSize)(slotCount, slotByteLength);
|
|
228
|
+
try {
|
|
229
|
+
const segment = (0, _camstack_shm_ring.createSegment)(name, totalBytes);
|
|
230
|
+
this.segment = segment;
|
|
231
|
+
this.segmentName = name;
|
|
232
|
+
this.slotByteLength = slotByteLength;
|
|
233
|
+
this.writer = new _camstack_shm_ring.FrameRingWriter(segment.buffer, name, slotCount, slotByteLength, this.nodeId);
|
|
234
|
+
this.logger.info("decoder shm ring: segment created", { meta: {
|
|
235
|
+
segment: name,
|
|
236
|
+
slotCount,
|
|
237
|
+
slotByteLength,
|
|
238
|
+
totalBytes,
|
|
239
|
+
generation: this.generation
|
|
240
|
+
} });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
this.segment = null;
|
|
243
|
+
this.writer = null;
|
|
244
|
+
this.segmentName = null;
|
|
245
|
+
this.slotByteLength = 0;
|
|
246
|
+
this.logger.error("decoder shm ring: segment create failed", { meta: {
|
|
247
|
+
segment: name,
|
|
248
|
+
slotByteLength,
|
|
249
|
+
error: err instanceof Error ? err.message : String(err)
|
|
250
|
+
} });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Unmap + unlink the current segment, if any. */
|
|
254
|
+
releaseSegment() {
|
|
255
|
+
const segment = this.segment;
|
|
256
|
+
if (segment === null) return;
|
|
257
|
+
this.segment = null;
|
|
258
|
+
this.writer = null;
|
|
259
|
+
const name = this.segmentName;
|
|
260
|
+
this.segmentName = null;
|
|
261
|
+
try {
|
|
262
|
+
segment.close();
|
|
263
|
+
segment.unlink();
|
|
264
|
+
this.logger.info("decoder shm ring: segment released", { meta: { segment: name } });
|
|
265
|
+
} catch (err) {
|
|
266
|
+
this.logger.warn("decoder shm ring: segment release failed", { meta: {
|
|
267
|
+
segment: name,
|
|
268
|
+
error: err instanceof Error ? err.message : String(err)
|
|
269
|
+
} });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region src/decoder-ffmpeg/ffmpeg-args.ts
|
|
275
|
+
/**
|
|
276
|
+
* Pure, side-effect-free ffmpeg argument construction for the
|
|
277
|
+
* `decoder-ffmpeg` addon.
|
|
278
|
+
*
|
|
279
|
+
* The addon decodes video in an **ffmpeg subprocess** (unlike `decoder-nodeav`
|
|
280
|
+
* which decodes in-process via node-av's native bindings). On the Intel hub,
|
|
281
|
+
* node-av's VA-API decode path SIGBUSes (uncatchable) on 8MP h264 and takes the
|
|
282
|
+
* whole decoder runner down; ffmpeg-as-subprocess decodes the same hardware
|
|
283
|
+
* cleanly because a crash is isolated to the child process.
|
|
284
|
+
*
|
|
285
|
+
* These helpers are extracted from the session so the argument shape is
|
|
286
|
+
* unit-testable without spawning a real ffmpeg binary.
|
|
287
|
+
*
|
|
288
|
+
* Validated working command on the Intel hub (renderD128, 8MP h264, ~50fps,
|
|
289
|
+
* exit 0):
|
|
290
|
+
*
|
|
291
|
+
* ffmpeg -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 \
|
|
292
|
+
* -hwaccel_output_format vaapi -f h264 -i <in> \
|
|
293
|
+
* -vf hwdownload,format=nv12,scale=640:360,format=gray \
|
|
294
|
+
* -f rawvideo -y /dev/null
|
|
295
|
+
*/
|
|
296
|
+
/** Default DRM render node used for the VA-API / QSV hardware decode path. */
|
|
297
|
+
var DEFAULT_VAAPI_RENDER_DEVICE = "/dev/dri/renderD128";
|
|
298
|
+
/** Whether a resolved hwaccel value means "decode in software" (no `-hwaccel`). */
|
|
299
|
+
function isSoftwareHwAccel(hwaccel) {
|
|
300
|
+
return hwaccel === null || hwaccel === "" || hwaccel === "none";
|
|
301
|
+
}
|
|
302
|
+
/** Backends whose decoded frames stay on the GPU and need an explicit
|
|
303
|
+
* `-hwaccel_output_format <m>` + `hwdownload,format=nv12` chain. */
|
|
304
|
+
function needsHwDownload(hwaccel) {
|
|
305
|
+
return hwaccel === "vaapi" || hwaccel === "qsv";
|
|
306
|
+
}
|
|
307
|
+
/** Map a cap codec string to the ffmpeg `-f` input demuxer/format. */
|
|
308
|
+
function codecToInputFormat(codec) {
|
|
309
|
+
switch (codec.toLowerCase()) {
|
|
310
|
+
case "h265":
|
|
311
|
+
case "hevc": return "hevc";
|
|
312
|
+
default: return "h264";
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Map the cap-level output format to a raw ffmpeg pixel format. */
|
|
316
|
+
function rawPixelForFormat(outputFormat) {
|
|
317
|
+
return outputFormat === "gray" ? "gray" : "rgb24";
|
|
318
|
+
}
|
|
319
|
+
/** Packed bytes-per-pixel for a raw output format (rgb24 = 3, gray = 1). */
|
|
320
|
+
function channelsForPixel(pixel) {
|
|
321
|
+
return pixel === "gray" ? 1 : 3;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The maximum output width for a given `scale` divisor — matches the node-av
|
|
325
|
+
* scaler's `maxW = floor(640 / scale)` rule so both decoders produce
|
|
326
|
+
* comparably-sized frames for the motion / detection pipeline.
|
|
327
|
+
*/
|
|
328
|
+
function maxDecodeWidth(scale) {
|
|
329
|
+
return Math.max(2, Math.floor(640 / (scale > 1 ? scale : 1)));
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Compute exact output geometry from a known source resolution — the node-av
|
|
333
|
+
* rule (`outWidth = min(srcW, floor(640/scale))`, `outHeight =
|
|
334
|
+
* round(outWidth*srcH/srcW)`). Provided for callers that DO know the source
|
|
335
|
+
* dimensions; the live broker path leaves them to ffmpeg (`-2` height).
|
|
336
|
+
*/
|
|
337
|
+
function computeOutputGeometry(srcWidth, srcHeight, scale) {
|
|
338
|
+
const width = Math.min(srcWidth, maxDecodeWidth(scale));
|
|
339
|
+
return {
|
|
340
|
+
width,
|
|
341
|
+
height: Math.round(width * srcHeight / srcWidth)
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/** Build the `-vf` filtergraph string for the requested hwaccel + pixel format. */
|
|
345
|
+
function buildVideoFilter(hwaccel, pixel, outWidth, outHeight, maxW) {
|
|
346
|
+
const scaleAndFormat = `scale=${outWidth !== void 0 && outWidth > 0 && outHeight !== void 0 && outHeight > 0 ? `${outWidth}:${outHeight}` : `min(iw\\,${maxW}):-2`},format=${pixel}`;
|
|
347
|
+
return needsHwDownload(hwaccel) ? `hwdownload,format=nv12,${scaleAndFormat}` : scaleAndFormat;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Build the full ffmpeg argv for a decode session. PURE — no spawn, no env
|
|
351
|
+
* access — so the argument shape is asserted in unit tests.
|
|
352
|
+
*
|
|
353
|
+
* Notes:
|
|
354
|
+
* - `-loglevel info -nostats`: the session parses the muxer's `Output #0`
|
|
355
|
+
* stream line from stderr to learn the exact output W×H (the broker does not
|
|
356
|
+
* pass source geometry for the shm path). `-nostats` suppresses the periodic
|
|
357
|
+
* `frame= …` counter while keeping the one-shot Output block.
|
|
358
|
+
* - The input is `pipe:0` (Annex-B packets written to stdin); the output is raw
|
|
359
|
+
* video on `pipe:1`.
|
|
360
|
+
*/
|
|
361
|
+
function buildFfmpegDecodeArgs(config, options) {
|
|
362
|
+
const inputFormat = codecToInputFormat(config.codec);
|
|
363
|
+
const pixel = rawPixelForFormat(config.outputFormat);
|
|
364
|
+
const maxW = maxDecodeWidth(config.scale);
|
|
365
|
+
const vf = buildVideoFilter(options.hwaccel, pixel, options.outWidth, options.outHeight, maxW);
|
|
366
|
+
const { hwaccel } = options;
|
|
367
|
+
let hwaccelArgs;
|
|
368
|
+
if (isSoftwareHwAccel(hwaccel) || hwaccel === null) hwaccelArgs = [];
|
|
369
|
+
else if (needsHwDownload(hwaccel)) hwaccelArgs = [
|
|
370
|
+
"-hwaccel",
|
|
371
|
+
hwaccel,
|
|
372
|
+
"-hwaccel_device",
|
|
373
|
+
options.renderDevice ?? "/dev/dri/renderD128",
|
|
374
|
+
"-hwaccel_output_format",
|
|
375
|
+
hwaccel
|
|
376
|
+
];
|
|
377
|
+
else hwaccelArgs = ["-hwaccel", hwaccel];
|
|
378
|
+
return [
|
|
379
|
+
...require_ffmpeg_args_common.logBannerArgs("info"),
|
|
380
|
+
"-nostats",
|
|
381
|
+
"-fflags",
|
|
382
|
+
"+nobuffer+flush_packets",
|
|
383
|
+
"-flags",
|
|
384
|
+
"low_delay",
|
|
385
|
+
"-probesize",
|
|
386
|
+
"1M",
|
|
387
|
+
"-analyzeduration",
|
|
388
|
+
"0",
|
|
389
|
+
...hwaccelArgs,
|
|
390
|
+
"-f",
|
|
391
|
+
inputFormat,
|
|
392
|
+
"-i",
|
|
393
|
+
"pipe:0",
|
|
394
|
+
"-vf",
|
|
395
|
+
vf,
|
|
396
|
+
"-f",
|
|
397
|
+
"rawvideo",
|
|
398
|
+
"-pix_fmt",
|
|
399
|
+
pixel,
|
|
400
|
+
"pipe:1"
|
|
401
|
+
];
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Parse the exact output geometry from ffmpeg's stderr. The muxer prints an
|
|
405
|
+
* `Output #0, rawvideo …` block whose `Stream #0:0: Video: rawvideo …, <fmt>,
|
|
406
|
+
* WIDTHxHEIGHT …` line carries the scaled dimensions. Gating on the `Output #`
|
|
407
|
+
* marker avoids picking up the INPUT stream's (source) resolution, which is
|
|
408
|
+
* printed earlier.
|
|
409
|
+
*
|
|
410
|
+
* Returns `null` until the Output block with a `WxH` token has been seen.
|
|
411
|
+
*/
|
|
412
|
+
function parseFfmpegOutputDims(stderr) {
|
|
413
|
+
const outputIdx = stderr.indexOf("Output #");
|
|
414
|
+
if (outputIdx < 0) return null;
|
|
415
|
+
const match = stderr.slice(outputIdx).match(/(\d{2,5})x(\d{2,5})/);
|
|
416
|
+
if (!match) return null;
|
|
417
|
+
const width = Number(match[1]);
|
|
418
|
+
const height = Number(match[2]);
|
|
419
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) return null;
|
|
420
|
+
return {
|
|
421
|
+
width,
|
|
422
|
+
height
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Pick the first kernel-preferred `-hwaccel` method the configured ffmpeg build
|
|
427
|
+
* actually supports, or `null` (software) when it supports none. Mirrors the
|
|
428
|
+
* broker's `pickDecodeHwAccel` (`ffmpeg-invocation.ts`) so the decoder gates
|
|
429
|
+
* decode-hwaccel against the same probed evidence:
|
|
430
|
+
* - empty `preferred` → `null` (software);
|
|
431
|
+
* - empty `supportedMethods` (probe miss) → keep the top preference and let the
|
|
432
|
+
* session's per-child software fallback cover a genuine failure;
|
|
433
|
+
* - otherwise the first preference the build supports, or `null`.
|
|
434
|
+
*/
|
|
435
|
+
function pickDecodeHwAccel(preferred, supportedMethods) {
|
|
436
|
+
if (preferred.length === 0) return null;
|
|
437
|
+
if (supportedMethods.length === 0) return preferred[0] ?? null;
|
|
438
|
+
const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
|
|
439
|
+
return preferred.find((name) => supported.has(name.toLowerCase())) ?? null;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Canonical decode-hwaccel preference order for the ffmpeg-subprocess decoder.
|
|
443
|
+
*
|
|
444
|
+
* `vaapi` is ranked ABOVE `qsv` deliberately: on the Intel hub the `qsv` decode
|
|
445
|
+
* child exits early (`code=171`, no frames) while the `vaapi` path decodes 8MP
|
|
446
|
+
* h264 cleanly at ~50fps. The kernel probe (`resolveHwAccel`) tends to surface
|
|
447
|
+
* `qsv` first on Intel, so we re-rank its result through this table before
|
|
448
|
+
* building the attempt chain. `videotoolbox` leads for macOS; `cuda` / `nvdec`
|
|
449
|
+
* trail for NVIDIA. A method not listed here keeps its incoming relative order,
|
|
450
|
+
* placed AFTER every ranked one.
|
|
451
|
+
*/
|
|
452
|
+
var DECODE_HWACCEL_RANK = [
|
|
453
|
+
"videotoolbox",
|
|
454
|
+
"vaapi",
|
|
455
|
+
"qsv",
|
|
456
|
+
"cuda",
|
|
457
|
+
"nvdec",
|
|
458
|
+
"d3d11va",
|
|
459
|
+
"dxva2",
|
|
460
|
+
"amf",
|
|
461
|
+
"vdpau",
|
|
462
|
+
"drm"
|
|
463
|
+
];
|
|
464
|
+
/** Rank index for {@link DECODE_HWACCEL_RANK} — unranked methods sort last. */
|
|
465
|
+
function decodeHwAccelRankIndex(method) {
|
|
466
|
+
const idx = DECODE_HWACCEL_RANK.indexOf(method.toLowerCase());
|
|
467
|
+
return idx < 0 ? DECODE_HWACCEL_RANK.length : idx;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Build the ORDERED list of hardware `-hwaccel` methods to attempt for an
|
|
471
|
+
* `'auto'` decode session, best-first. The session walks this chain on an
|
|
472
|
+
* early-exit-with-no-frames and only falls to software once it is exhausted.
|
|
473
|
+
*
|
|
474
|
+
* Strategy:
|
|
475
|
+
* - Seed candidates from the kernel-preferred order UNION the canonical rank
|
|
476
|
+
* UNION the build's supported list, so a method the kernel omitted but the
|
|
477
|
+
* build supports (the Intel `vaapi` case — kernel surfaces only `qsv`) is
|
|
478
|
+
* still attempted.
|
|
479
|
+
* - When the build's supported-method list is known (non-empty), keep only
|
|
480
|
+
* methods it actually offers; on a probe miss (empty) keep just the kernel
|
|
481
|
+
* preferences (avoid blindly spawning every backend).
|
|
482
|
+
* - Re-rank the survivors by {@link DECODE_HWACCEL_RANK} so `vaapi` precedes
|
|
483
|
+
* `qsv`. A supported method not in the rank table keeps its position AFTER
|
|
484
|
+
* every ranked one (stable sort). Returns lowercased method names; empty ⇒
|
|
485
|
+
* pure software.
|
|
486
|
+
*/
|
|
487
|
+
function rankDecodeHwAccels(preferred, supportedMethods) {
|
|
488
|
+
const supported = new Set(supportedMethods.map((m) => m.toLowerCase()));
|
|
489
|
+
const hasSupportList = supported.size > 0;
|
|
490
|
+
const pool = hasSupportList ? [
|
|
491
|
+
...preferred,
|
|
492
|
+
...DECODE_HWACCEL_RANK,
|
|
493
|
+
...supportedMethods
|
|
494
|
+
] : [...preferred];
|
|
495
|
+
const seen = /* @__PURE__ */ new Set();
|
|
496
|
+
const candidates = [];
|
|
497
|
+
for (const raw of pool) {
|
|
498
|
+
const method = raw.toLowerCase();
|
|
499
|
+
if (seen.has(method)) continue;
|
|
500
|
+
seen.add(method);
|
|
501
|
+
if (hasSupportList && !supported.has(method)) continue;
|
|
502
|
+
candidates.push(method);
|
|
503
|
+
}
|
|
504
|
+
return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
|
|
505
|
+
}
|
|
506
|
+
//#endregion
|
|
507
|
+
//#region src/decoder-ffmpeg/ffmpeg-decoder-session.ts
|
|
508
|
+
/**
|
|
509
|
+
* FfmpegDecoderSession — an `IDecoderSession` that decodes video in an **ffmpeg
|
|
510
|
+
* subprocess** and feeds the shared-memory frame ring (Phase 5 / D9), the same
|
|
511
|
+
* ring `decoder-nodeav` writes into.
|
|
512
|
+
*
|
|
513
|
+
* Why a subprocess: on the Intel hub, node-av's in-process VA-API decode path
|
|
514
|
+
* SIGBUSes (uncatchable) on 8MP h264 and kills the whole decoder runner. ffmpeg
|
|
515
|
+
* decodes the identical hardware cleanly because it runs out-of-process — a
|
|
516
|
+
* crashing child just stops producing frames; the addon survives and the
|
|
517
|
+
* frame-plane rotates/retries.
|
|
518
|
+
*
|
|
519
|
+
* Data flow per session:
|
|
520
|
+
* Annex-B packets → ffmpeg stdin (pipe:0)
|
|
521
|
+
* ffmpeg → `-vf [hwdownload,format=nv12,]scale=…,format=<rgb24|gray>`
|
|
522
|
+
* raw video → ffmpeg stdout (pipe:1) → fixed-size frame slices
|
|
523
|
+
* each slice → `DecoderFrameRingSink.writeFrame` → `FrameHandle` (shm mode)
|
|
524
|
+
* or `DecodedFrame` via `onFrame` (callback mode)
|
|
525
|
+
*
|
|
526
|
+
* Output geometry: the broker does not pass source width/height for the shm
|
|
527
|
+
* path, so ffmpeg picks the height (`scale=min(iw\,maxW):-2`) and the session
|
|
528
|
+
* learns the exact output W×H by parsing the muxer's `Output #0` stderr block
|
|
529
|
+
* ({@link parseFfmpegOutputDims}). Until dims are known, stdout is buffered.
|
|
530
|
+
*/
|
|
531
|
+
var noopLogger = {
|
|
532
|
+
debug() {},
|
|
533
|
+
info() {},
|
|
534
|
+
warn() {},
|
|
535
|
+
error() {},
|
|
536
|
+
child() {
|
|
537
|
+
return noopLogger;
|
|
538
|
+
},
|
|
539
|
+
withTags() {
|
|
540
|
+
return noopLogger;
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
/** A hardware child that dies faster than this (with no frames) advances the
|
|
544
|
+
* session to the NEXT decode path (next hwaccel, else software) — a genuine
|
|
545
|
+
* hwaccel init failure (missing render node, unsupported codec on GPU, the
|
|
546
|
+
* Intel `qsv` `code=171`) surfaces this early. */
|
|
547
|
+
var HWACCEL_FALLBACK_WINDOW_MS = 4e3;
|
|
548
|
+
var FfmpegDecoderSession = class {
|
|
549
|
+
config;
|
|
550
|
+
logger;
|
|
551
|
+
frameSink;
|
|
552
|
+
nodeId;
|
|
553
|
+
renderDevice;
|
|
554
|
+
ffmpegPath;
|
|
555
|
+
/**
|
|
556
|
+
* Ordered hardware decode paths to attempt, best-first. Empty ⇒ software
|
|
557
|
+
* only. The session walks it via {@link hwaccelIndex}; an index past the end
|
|
558
|
+
* means software (`null`).
|
|
559
|
+
*/
|
|
560
|
+
hwaccelChain;
|
|
561
|
+
hwaccelIndex = 0;
|
|
562
|
+
hwaccel;
|
|
563
|
+
process = null;
|
|
564
|
+
spawnedAtMs = 0;
|
|
565
|
+
outputBuffer = Buffer.alloc(0);
|
|
566
|
+
stderrAccum = "";
|
|
567
|
+
destroyed = false;
|
|
568
|
+
pixel;
|
|
569
|
+
channels;
|
|
570
|
+
/** The cap-facing raw format label (`'rgb'` / `'gray'`) stamped into frames. */
|
|
571
|
+
frameFormat;
|
|
572
|
+
outWidth = 0;
|
|
573
|
+
outHeight = 0;
|
|
574
|
+
frameSize = 0;
|
|
575
|
+
frameRingSink = null;
|
|
576
|
+
frameCallbacks = /* @__PURE__ */ new Set();
|
|
577
|
+
handleCallbacks = /* @__PURE__ */ new Set();
|
|
578
|
+
frameDropper;
|
|
579
|
+
inputPackets = 0;
|
|
580
|
+
outputFrames = 0;
|
|
581
|
+
droppedFrames = 0;
|
|
582
|
+
startTime = Date.now();
|
|
583
|
+
constructor(config, logger = noopLogger, options) {
|
|
584
|
+
this.config = { ...config };
|
|
585
|
+
const sessionTags = {};
|
|
586
|
+
if (typeof config.deviceId === "number") sessionTags["deviceId"] = config.deviceId;
|
|
587
|
+
if (typeof config.tag === "string" && config.tag.length > 0) sessionTags["tag"] = config.tag;
|
|
588
|
+
this.logger = Object.keys(sessionTags).length > 0 ? logger.withTags(sessionTags) : logger;
|
|
589
|
+
this.frameSink = options?.frameSink ?? "callback";
|
|
590
|
+
this.nodeId = options?.nodeId ?? "local";
|
|
591
|
+
this.renderDevice = options?.renderDevice;
|
|
592
|
+
this.ffmpegPath = options?.ffmpegPath ?? "ffmpeg";
|
|
593
|
+
const seededChain = options?.hwaccelChain ?? (options?.hwaccel != null && !isSoftwareHwAccel(options.hwaccel) ? [options.hwaccel] : []);
|
|
594
|
+
this.hwaccelChain = seededChain.filter((m) => !isSoftwareHwAccel(m));
|
|
595
|
+
this.hwaccel = this.currentHwAccel();
|
|
596
|
+
this.pixel = rawPixelForFormat(config.outputFormat);
|
|
597
|
+
this.channels = channelsForPixel(this.pixel);
|
|
598
|
+
this.frameFormat = this.pixel === "gray" ? "gray" : "rgb";
|
|
599
|
+
this.frameDropper = new require_ffmpeg_args_common.FrameDropper(config.maxFps);
|
|
600
|
+
this.spawnFfmpeg();
|
|
601
|
+
}
|
|
602
|
+
/** Exposed so the owning addon can surface ring stats via `getShmStats`. */
|
|
603
|
+
get frameRingSinkOrNull() {
|
|
604
|
+
return this.frameRingSink;
|
|
605
|
+
}
|
|
606
|
+
get isPullMode() {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
ensureFrameRingSink() {
|
|
610
|
+
if (this.frameRingSink === null) {
|
|
611
|
+
const seedParts = [];
|
|
612
|
+
if (typeof this.config.deviceId === "number") seedParts.push(String(this.config.deviceId));
|
|
613
|
+
if (typeof this.config.tag === "string" && this.config.tag.length > 0) seedParts.push(this.config.tag);
|
|
614
|
+
const seed = seedParts.length > 0 ? seedParts.join(":") : "anon";
|
|
615
|
+
this.frameRingSink = new DecoderFrameRingSink({
|
|
616
|
+
seed,
|
|
617
|
+
logger: this.logger,
|
|
618
|
+
nodeId: this.nodeId
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
return this.frameRingSink;
|
|
622
|
+
}
|
|
623
|
+
spawnFfmpeg() {
|
|
624
|
+
if (this.destroyed) return;
|
|
625
|
+
this.killFfmpeg();
|
|
626
|
+
this.outputBuffer = Buffer.alloc(0);
|
|
627
|
+
this.stderrAccum = "";
|
|
628
|
+
this.outWidth = 0;
|
|
629
|
+
this.outHeight = 0;
|
|
630
|
+
this.frameSize = 0;
|
|
631
|
+
const args = buildFfmpegDecodeArgs({
|
|
632
|
+
codec: this.config.codec,
|
|
633
|
+
scale: this.config.scale,
|
|
634
|
+
outputFormat: this.config.outputFormat
|
|
635
|
+
}, {
|
|
636
|
+
hwaccel: this.hwaccel,
|
|
637
|
+
renderDevice: this.renderDevice
|
|
638
|
+
});
|
|
639
|
+
let child;
|
|
640
|
+
try {
|
|
641
|
+
child = (0, node_child_process.spawn)(this.ffmpegPath, args);
|
|
642
|
+
} catch (err) {
|
|
643
|
+
this.logger.error("ffmpeg decoder: spawn threw", { meta: { error: require_dist.errMsg(err) } });
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
this.process = child;
|
|
647
|
+
this.spawnedAtMs = Date.now();
|
|
648
|
+
child.stdin?.on("error", () => {});
|
|
649
|
+
child.stdout?.on("data", (chunk) => this.handleStdout(chunk));
|
|
650
|
+
child.stderr?.on("data", (data) => this.handleStderr(data));
|
|
651
|
+
child.on("error", (err) => {
|
|
652
|
+
this.logger.error("ffmpeg decoder: process error", { meta: { error: err.message } });
|
|
653
|
+
});
|
|
654
|
+
child.on("exit", (code, signal) => this.handleExit(code, signal));
|
|
655
|
+
this.logger.info("ffmpeg decoder: spawned", { meta: {
|
|
656
|
+
hwaccel: this.hwaccel,
|
|
657
|
+
codec: this.config.codec,
|
|
658
|
+
format: this.frameFormat,
|
|
659
|
+
sink: this.frameSink
|
|
660
|
+
} });
|
|
661
|
+
}
|
|
662
|
+
killFfmpeg() {
|
|
663
|
+
const child = this.process;
|
|
664
|
+
if (!child) return;
|
|
665
|
+
this.process = null;
|
|
666
|
+
try {
|
|
667
|
+
child.stdin?.end();
|
|
668
|
+
} catch {}
|
|
669
|
+
try {
|
|
670
|
+
child.kill("SIGTERM");
|
|
671
|
+
} catch {}
|
|
672
|
+
const pid = child.pid;
|
|
673
|
+
setTimeout(() => {
|
|
674
|
+
if (pid !== void 0 && !child.killed) try {
|
|
675
|
+
child.kill("SIGKILL");
|
|
676
|
+
} catch {}
|
|
677
|
+
}, 500).unref?.();
|
|
678
|
+
}
|
|
679
|
+
/** The decode path for the current attempt — `hwaccelChain[index]`, or `null`
|
|
680
|
+
* (software) once the chain is exhausted. */
|
|
681
|
+
currentHwAccel() {
|
|
682
|
+
return this.hwaccelChain[this.hwaccelIndex] ?? null;
|
|
683
|
+
}
|
|
684
|
+
handleExit(code, signal) {
|
|
685
|
+
if (this.destroyed) return;
|
|
686
|
+
const diedFast = Date.now() - this.spawnedAtMs < HWACCEL_FALLBACK_WINDOW_MS;
|
|
687
|
+
if (!isSoftwareHwAccel(this.hwaccel) && this.outputFrames === 0 && diedFast) {
|
|
688
|
+
const failed = this.hwaccel;
|
|
689
|
+
this.hwaccelIndex += 1;
|
|
690
|
+
this.hwaccel = this.currentHwAccel();
|
|
691
|
+
const next = isSoftwareHwAccel(this.hwaccel) ? "software" : this.hwaccel;
|
|
692
|
+
this.logger.warn("ffmpeg decoder: hwaccel child exited early with no frames — trying next decode path", { meta: {
|
|
693
|
+
failed,
|
|
694
|
+
next,
|
|
695
|
+
code,
|
|
696
|
+
signal
|
|
697
|
+
} });
|
|
698
|
+
this.process = null;
|
|
699
|
+
this.spawnFfmpeg();
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
this.process = null;
|
|
703
|
+
this.logger.warn("ffmpeg decoder: child exited", { meta: {
|
|
704
|
+
code,
|
|
705
|
+
signal,
|
|
706
|
+
outputFrames: this.outputFrames
|
|
707
|
+
} });
|
|
708
|
+
}
|
|
709
|
+
handleStderr(data) {
|
|
710
|
+
const text = data.toString();
|
|
711
|
+
if (this.frameSize === 0) {
|
|
712
|
+
this.stderrAccum = (this.stderrAccum + text).slice(-16384);
|
|
713
|
+
this.tryLearnGeometry();
|
|
714
|
+
}
|
|
715
|
+
const line = text.trim();
|
|
716
|
+
if (line) this.logger.debug("ffmpeg stderr", { meta: { line } });
|
|
717
|
+
}
|
|
718
|
+
/** Parse the muxer's Output block once to size the raw frames. */
|
|
719
|
+
tryLearnGeometry() {
|
|
720
|
+
const idx = this.stderrAccum.indexOf("Output #");
|
|
721
|
+
if (idx < 0) return;
|
|
722
|
+
const match = this.stderrAccum.slice(idx).match(/(\d{2,5})x(\d{2,5})/);
|
|
723
|
+
if (!match) return;
|
|
724
|
+
const width = Number(match[1]);
|
|
725
|
+
const height = Number(match[2]);
|
|
726
|
+
if (width <= 0 || height <= 0) return;
|
|
727
|
+
this.outWidth = width;
|
|
728
|
+
this.outHeight = height;
|
|
729
|
+
this.frameSize = width * height * this.channels;
|
|
730
|
+
this.logger.info("ffmpeg decoder: learned output geometry", { meta: {
|
|
731
|
+
width,
|
|
732
|
+
height,
|
|
733
|
+
channels: this.channels,
|
|
734
|
+
frameSize: this.frameSize
|
|
735
|
+
} });
|
|
736
|
+
this.drainFrames();
|
|
737
|
+
}
|
|
738
|
+
handleStdout(chunk) {
|
|
739
|
+
this.outputBuffer = this.outputBuffer.length === 0 ? chunk : Buffer.concat([this.outputBuffer, chunk]);
|
|
740
|
+
if (this.frameSize === 0) return;
|
|
741
|
+
this.drainFrames();
|
|
742
|
+
}
|
|
743
|
+
drainFrames() {
|
|
744
|
+
const size = this.frameSize;
|
|
745
|
+
if (size === 0) return;
|
|
746
|
+
let offset = 0;
|
|
747
|
+
while (this.outputBuffer.length - offset >= size) {
|
|
748
|
+
const slice = this.outputBuffer.subarray(offset, offset + size);
|
|
749
|
+
offset += size;
|
|
750
|
+
this.emitFrame(slice);
|
|
751
|
+
}
|
|
752
|
+
if (offset > 0) this.outputBuffer = Buffer.from(this.outputBuffer.subarray(offset));
|
|
753
|
+
}
|
|
754
|
+
emitFrame(slice) {
|
|
755
|
+
if (!this.frameDropper.shouldKeep()) {
|
|
756
|
+
this.droppedFrames++;
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const pixels = Buffer.from(slice);
|
|
760
|
+
this.outputFrames++;
|
|
761
|
+
if (this.frameSink === "shm") {
|
|
762
|
+
const handle = this.ensureFrameRingSink().writeFrame(pixels, {
|
|
763
|
+
width: this.outWidth,
|
|
764
|
+
height: this.outHeight,
|
|
765
|
+
format: this.frameFormat,
|
|
766
|
+
pts: performance.now(),
|
|
767
|
+
byteLength: pixels.byteLength
|
|
768
|
+
});
|
|
769
|
+
if (handle === null) {
|
|
770
|
+
this.droppedFrames++;
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const delivered = {
|
|
774
|
+
handle,
|
|
775
|
+
timestamp: Date.now()
|
|
776
|
+
};
|
|
777
|
+
for (const cb of this.handleCallbacks) cb(delivered);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const frame = {
|
|
781
|
+
data: pixels,
|
|
782
|
+
width: this.outWidth,
|
|
783
|
+
height: this.outHeight,
|
|
784
|
+
format: this.frameFormat,
|
|
785
|
+
timestamp: Date.now()
|
|
786
|
+
};
|
|
787
|
+
for (const cb of this.frameCallbacks) cb(frame);
|
|
788
|
+
}
|
|
789
|
+
pushPacket(packet) {
|
|
790
|
+
if (this.destroyed) return;
|
|
791
|
+
const stdin = this.process?.stdin;
|
|
792
|
+
if (!stdin) return;
|
|
793
|
+
this.inputPackets++;
|
|
794
|
+
try {
|
|
795
|
+
stdin.write(packet.data);
|
|
796
|
+
} catch {}
|
|
797
|
+
}
|
|
798
|
+
onFrame(callback) {
|
|
799
|
+
this.frameCallbacks.add(callback);
|
|
800
|
+
return () => {
|
|
801
|
+
this.frameCallbacks.delete(callback);
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
/** Subscribe to shared-memory frame handles — fires only in `'shm'` mode. */
|
|
805
|
+
onFrameHandle(callback) {
|
|
806
|
+
this.handleCallbacks.add(callback);
|
|
807
|
+
return () => {
|
|
808
|
+
this.handleCallbacks.delete(callback);
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
updateConfig(update) {
|
|
812
|
+
const needsRestart = update.scale !== void 0 && update.scale !== this.config.scale || update.outputFormat !== void 0 && update.outputFormat !== this.config.outputFormat || update.codec !== void 0 && update.codec !== this.config.codec;
|
|
813
|
+
this.config = {
|
|
814
|
+
...this.config,
|
|
815
|
+
...update
|
|
816
|
+
};
|
|
817
|
+
if (update.maxFps !== void 0) this.frameDropper.setMaxFps(update.maxFps);
|
|
818
|
+
if (needsRestart) this.spawnFfmpeg();
|
|
819
|
+
}
|
|
820
|
+
async destroy() {
|
|
821
|
+
if (this.destroyed) return;
|
|
822
|
+
this.destroyed = true;
|
|
823
|
+
this.killFfmpeg();
|
|
824
|
+
this.frameRingSink?.destroy();
|
|
825
|
+
this.frameRingSink = null;
|
|
826
|
+
this.frameCallbacks.clear();
|
|
827
|
+
this.handleCallbacks.clear();
|
|
828
|
+
}
|
|
829
|
+
getStats() {
|
|
830
|
+
const uptimeSec = Math.max((Date.now() - this.startTime) / 1e3, 1);
|
|
831
|
+
return {
|
|
832
|
+
inputFps: this.inputPackets / uptimeSec,
|
|
833
|
+
outputFps: this.outputFrames / uptimeSec,
|
|
834
|
+
avgDecodeTimeMs: 0,
|
|
835
|
+
droppedFrames: this.droppedFrames
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
//#endregion
|
|
840
|
+
//#region src/decoder-ffmpeg/addon/index.ts
|
|
841
|
+
var FRAME_BUFFER_CAPACITY = 32;
|
|
842
|
+
var DecoderFfmpegAddon = class extends require_dist.BaseAddon {
|
|
843
|
+
sessions = /* @__PURE__ */ new Map();
|
|
844
|
+
frameBuffers = /* @__PURE__ */ new Map();
|
|
845
|
+
handleBuffers = /* @__PURE__ */ new Map();
|
|
846
|
+
unsubscribers = /* @__PURE__ */ new Map();
|
|
847
|
+
sessionMeta = /* @__PURE__ */ new Map();
|
|
848
|
+
frameReaders = null;
|
|
849
|
+
getFrameHits = 0;
|
|
850
|
+
getFrameMisses = 0;
|
|
851
|
+
/**
|
|
852
|
+
* The ffmpeg binary every session spawns — resolved once at init from the
|
|
853
|
+
* cluster `ffmpeg` config section (`binaryPath`), defaulting to PATH
|
|
854
|
+
* `ffmpeg`. MUST be the same binary the broker / recorder / probe use so the
|
|
855
|
+
* decode-accel probe reports for the right build.
|
|
856
|
+
*/
|
|
857
|
+
ffmpegPath = "ffmpeg";
|
|
858
|
+
constructor() {
|
|
859
|
+
super(require_dist.DEFAULT_DECODER_HWACCEL_CONFIG);
|
|
860
|
+
}
|
|
861
|
+
globalSettingsSchema() {
|
|
862
|
+
return this.schema({ sections: [{
|
|
863
|
+
id: "hwaccel",
|
|
864
|
+
title: "Hardware acceleration",
|
|
865
|
+
tab: "decoder",
|
|
866
|
+
description: "Backend used by ffmpeg-subprocess decoder sessions. \"Auto\" defers to the probed best (VA-API on Intel); \"Off\" forces pure software. Only the VA-API hardware path is validated — other concrete backends degrade to software. Changes apply to NEW sessions.",
|
|
867
|
+
fields: [this.field({
|
|
868
|
+
type: "select",
|
|
869
|
+
key: "hwaccel",
|
|
870
|
+
label: "Preferred backend",
|
|
871
|
+
options: [...require_dist.HWACCEL_OPTIONS],
|
|
872
|
+
default: "auto",
|
|
873
|
+
immediate: true
|
|
874
|
+
}), this.field({
|
|
875
|
+
type: "text",
|
|
876
|
+
key: "probedBestHwaccel",
|
|
877
|
+
label: "Probed best",
|
|
878
|
+
description: "Auto-detected best decoder backend on this host. Click the refresh icon to re-run the probe.",
|
|
879
|
+
readonlyField: true,
|
|
880
|
+
default: "",
|
|
881
|
+
actions: [{
|
|
882
|
+
action: "reprobe-hwaccel",
|
|
883
|
+
icon: "refresh-cw",
|
|
884
|
+
tooltip: "Re-probe hwaccel"
|
|
885
|
+
}]
|
|
886
|
+
})]
|
|
887
|
+
}] });
|
|
888
|
+
}
|
|
889
|
+
async onInitialize() {
|
|
890
|
+
this.ctx.logger.info("ffmpeg decoder addon initialized");
|
|
891
|
+
this.frameReaders = new _camstack_shm_ring.FrameRingReaderCache(this.ctx.logger);
|
|
892
|
+
this.ffmpegPath = await this.resolveFfmpegBinaryPath();
|
|
893
|
+
if (!this.config.probedBestHwaccel) this.reprobeHwaccel().catch((err) => {
|
|
894
|
+
this.ctx.logger.warn("ffmpeg: auto-reprobe hwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
895
|
+
});
|
|
896
|
+
return [{
|
|
897
|
+
capability: require_dist.decoderCapability,
|
|
898
|
+
provider: this
|
|
899
|
+
}];
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Resolve the ffmpeg binary the sessions spawn.
|
|
903
|
+
*
|
|
904
|
+
* Precedence:
|
|
905
|
+
* 1. An explicit operator override in the cluster `ffmpeg` config section
|
|
906
|
+
* (`binaryPath`) — wins when set, so a hand-picked build can be forced.
|
|
907
|
+
* 2. Otherwise `ctx.deps.ensureFfmpeg()` — provisions a portable static
|
|
908
|
+
* ffmpeg into the node's deps dir when the system has none and returns its
|
|
909
|
+
* absolute path. This GUARANTEES ffmpeg on every node (hub, agent, Mac),
|
|
910
|
+
* so there is no missing-ffmpeg case and no node-av fallback.
|
|
911
|
+
*
|
|
912
|
+
* `ensureFfmpeg()` is single-flighted, so we resolve once at init and reuse
|
|
913
|
+
* the path for every session. If it throws (e.g. a download failure) we log a
|
|
914
|
+
* loud ERROR and fall back to PATH `ffmpeg`; the addon still registers the
|
|
915
|
+
* decoder cap and a later session spawn re-runs the ensure.
|
|
916
|
+
*/
|
|
917
|
+
async resolveFfmpegBinaryPath() {
|
|
918
|
+
try {
|
|
919
|
+
const bp = (await this.ctx.settings?.getSection("ffmpeg") ?? {})["binaryPath"];
|
|
920
|
+
if (typeof bp === "string" && bp.trim().length > 0) return bp;
|
|
921
|
+
} catch {}
|
|
922
|
+
try {
|
|
923
|
+
return await this.ctx.deps.ensureFfmpeg();
|
|
924
|
+
} catch (err) {
|
|
925
|
+
this.ctx.logger.error("decoder-ffmpeg: ensureFfmpeg() failed to provision ffmpeg — falling back to PATH \"ffmpeg\"; a later session will retry", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
926
|
+
return "ffmpeg";
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Resolve the ORDERED decode-hwaccel chain for a new session — NEVER
|
|
931
|
+
* hardcoded. The session tries the chain best-first and only falls to
|
|
932
|
+
* software once every hardware option is exhausted.
|
|
933
|
+
*
|
|
934
|
+
* - `'none'` → `[]` (pure software).
|
|
935
|
+
* - explicit backend → `[<backend>]` (forced; the session still falls to
|
|
936
|
+
* software if that one child fails).
|
|
937
|
+
* - `'auto'` → asks `platform-probe.resolveHwAccel` for the kernel-preferred
|
|
938
|
+
* order + the node's supported `-hwaccel` methods
|
|
939
|
+
* (`getHardwareDecodeAccels`), then ranks them via {@link rankDecodeHwAccels}
|
|
940
|
+
* (vaapi ABOVE qsv, since qsv is broken on the Intel stack while vaapi
|
|
941
|
+
* works). On a probe error → `[]` (software).
|
|
942
|
+
*/
|
|
943
|
+
async resolveDecodeHwaccelChain() {
|
|
944
|
+
const pref = this.config.hwaccel;
|
|
945
|
+
if (pref === "none") return [];
|
|
946
|
+
if (pref !== "auto") return [pref];
|
|
947
|
+
try {
|
|
948
|
+
const res = await this.ctx.api.platformProbe.resolveHwAccel.query({ prefer: null });
|
|
949
|
+
const supported = await this.ctx.api.platformProbe.getHardwareDecodeAccels.query();
|
|
950
|
+
return rankDecodeHwAccels(res.preferred, supported.methods);
|
|
951
|
+
} catch (err) {
|
|
952
|
+
this.ctx.logger.warn("ffmpeg: decode-hwaccel probe failed — software decode", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
953
|
+
return [];
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
async reprobeHwaccel() {
|
|
957
|
+
const resolver = this.ctx.kernel.hwaccel;
|
|
958
|
+
if (!resolver) {
|
|
959
|
+
this.ctx.logger.warn("reprobeHwaccel: no kernel hwaccel resolver — returning none");
|
|
960
|
+
await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: "none" });
|
|
961
|
+
return { backend: "none" };
|
|
962
|
+
}
|
|
963
|
+
try {
|
|
964
|
+
const res = await resolver.resolve();
|
|
965
|
+
const backend = res.preferred[0] ?? "none";
|
|
966
|
+
await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: backend });
|
|
967
|
+
this.ctx.logger.info("reprobeHwaccel: wrote probedBestHwaccel", { meta: {
|
|
968
|
+
backend,
|
|
969
|
+
rationale: res.rationale,
|
|
970
|
+
preferred: res.preferred
|
|
971
|
+
} });
|
|
972
|
+
return { backend };
|
|
973
|
+
} catch (err) {
|
|
974
|
+
this.ctx.logger.warn("reprobeHwaccel failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
975
|
+
await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: "none" });
|
|
976
|
+
return { backend: "none" };
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
async supportsCodec(input) {
|
|
980
|
+
return [
|
|
981
|
+
"h264",
|
|
982
|
+
"h265",
|
|
983
|
+
"hevc"
|
|
984
|
+
].includes(input.codec.toLowerCase());
|
|
985
|
+
}
|
|
986
|
+
async getInfo() {
|
|
987
|
+
return {
|
|
988
|
+
id: "decoder-ffmpeg",
|
|
989
|
+
name: "Decoder (ffmpeg)",
|
|
990
|
+
isPullMode: false,
|
|
991
|
+
priority: 20
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* The cluster node id of this decoder — stamped into session-owned
|
|
996
|
+
* `FrameHandle`s so a downstream consumer routes `getFrame` to the node that
|
|
997
|
+
* holds the shm ring. Mirrors `decoder-nodeav`.
|
|
998
|
+
*/
|
|
999
|
+
resolveLocalNodeId() {
|
|
1000
|
+
const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
|
|
1001
|
+
return raw.includes("/") ? raw.split("/")[0] : raw;
|
|
1002
|
+
}
|
|
1003
|
+
async createSession(config) {
|
|
1004
|
+
const sessionId = (0, node_crypto.randomUUID)();
|
|
1005
|
+
const { frameSink } = config;
|
|
1006
|
+
const nodeId = this.resolveLocalNodeId();
|
|
1007
|
+
const hwaccelChain = await this.resolveDecodeHwaccelChain();
|
|
1008
|
+
const session = new FfmpegDecoderSession(config, this.ctx.logger, {
|
|
1009
|
+
hwaccelChain,
|
|
1010
|
+
ffmpegPath: this.ffmpegPath,
|
|
1011
|
+
frameSink,
|
|
1012
|
+
nodeId
|
|
1013
|
+
});
|
|
1014
|
+
const unsub = frameSink === "shm" ? this.wireShmSink(sessionId, session) : this.wireCallbackSink(sessionId, session);
|
|
1015
|
+
this.sessions.set(sessionId, session);
|
|
1016
|
+
this.unsubscribers.set(sessionId, unsub);
|
|
1017
|
+
this.sessionMeta.set(sessionId, {
|
|
1018
|
+
codec: config.codec,
|
|
1019
|
+
outputFormat: config.outputFormat,
|
|
1020
|
+
createdAtMs: Date.now()
|
|
1021
|
+
});
|
|
1022
|
+
this.ctx.logger.info("ffmpeg: created session", { meta: {
|
|
1023
|
+
sessionId,
|
|
1024
|
+
codec: config.codec,
|
|
1025
|
+
hwaccelChain,
|
|
1026
|
+
frameSink,
|
|
1027
|
+
nodeId
|
|
1028
|
+
} });
|
|
1029
|
+
return {
|
|
1030
|
+
sessionId,
|
|
1031
|
+
nodeId
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
wireCallbackSink(sessionId, session) {
|
|
1035
|
+
const ringBuffer = new require_dist.RingBuffer(FRAME_BUFFER_CAPACITY);
|
|
1036
|
+
this.frameBuffers.set(sessionId, ringBuffer);
|
|
1037
|
+
return session.onFrame((frame) => {
|
|
1038
|
+
const { format } = frame;
|
|
1039
|
+
if (format !== "jpeg" && format !== "rgb" && format !== "bgr" && format !== "yuv420" && format !== "gray") return;
|
|
1040
|
+
const arrayBuf = new ArrayBuffer(frame.data.byteLength);
|
|
1041
|
+
new Uint8Array(arrayBuf).set(frame.data);
|
|
1042
|
+
const capFrame = {
|
|
1043
|
+
data: new Uint8Array(arrayBuf),
|
|
1044
|
+
width: frame.width,
|
|
1045
|
+
height: frame.height,
|
|
1046
|
+
format,
|
|
1047
|
+
timestamp: frame.timestamp
|
|
1048
|
+
};
|
|
1049
|
+
ringBuffer.push(capFrame);
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
wireShmSink(sessionId, session) {
|
|
1053
|
+
const handleRing = new require_dist.RingBuffer(FRAME_BUFFER_CAPACITY);
|
|
1054
|
+
this.handleBuffers.set(sessionId, handleRing);
|
|
1055
|
+
return session.onFrameHandle((frame) => {
|
|
1056
|
+
handleRing.push(frame.handle);
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
async destroySession(input) {
|
|
1060
|
+
const { sessionId } = input;
|
|
1061
|
+
const session = this.sessions.get(sessionId);
|
|
1062
|
+
if (!session) throw new Error(`decoder-ffmpeg: unknown sessionId ${sessionId}`);
|
|
1063
|
+
const unsub = this.unsubscribers.get(sessionId);
|
|
1064
|
+
if (unsub) unsub();
|
|
1065
|
+
await session.destroy();
|
|
1066
|
+
this.sessions.delete(sessionId);
|
|
1067
|
+
this.frameBuffers.delete(sessionId);
|
|
1068
|
+
this.handleBuffers.delete(sessionId);
|
|
1069
|
+
this.unsubscribers.delete(sessionId);
|
|
1070
|
+
this.sessionMeta.delete(sessionId);
|
|
1071
|
+
this.ctx.logger.info("ffmpeg: destroyed session", { meta: { sessionId } });
|
|
1072
|
+
}
|
|
1073
|
+
async listActiveSessions() {
|
|
1074
|
+
const out = [];
|
|
1075
|
+
for (const [sessionId, meta] of this.sessionMeta) out.push({
|
|
1076
|
+
sessionId,
|
|
1077
|
+
codec: meta.codec,
|
|
1078
|
+
outputFormat: meta.outputFormat,
|
|
1079
|
+
createdAtMs: meta.createdAtMs
|
|
1080
|
+
});
|
|
1081
|
+
return out;
|
|
1082
|
+
}
|
|
1083
|
+
async pushPacket(input) {
|
|
1084
|
+
const session = this.sessions.get(input.sessionId);
|
|
1085
|
+
if (!session) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
|
|
1086
|
+
const rawData = input.packet.data;
|
|
1087
|
+
const data = Buffer.isBuffer(rawData) ? rawData : rawData instanceof Uint8Array ? Buffer.from(rawData.buffer, rawData.byteOffset, rawData.byteLength) : Buffer.from(rawData);
|
|
1088
|
+
session.pushPacket({
|
|
1089
|
+
...input.packet,
|
|
1090
|
+
data
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
async openStream(input) {
|
|
1094
|
+
if (!this.sessions.get(input.sessionId)) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
|
|
1095
|
+
input.url;
|
|
1096
|
+
}
|
|
1097
|
+
async pullFrames(input) {
|
|
1098
|
+
if (!this.sessions.has(input.sessionId)) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
|
|
1099
|
+
const ringBuffer = this.frameBuffers.get(input.sessionId);
|
|
1100
|
+
if (!ringBuffer) return [];
|
|
1101
|
+
return ringBuffer.drain(input.maxCount);
|
|
1102
|
+
}
|
|
1103
|
+
async pullHandles(input) {
|
|
1104
|
+
if (!this.sessions.has(input.sessionId)) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
|
|
1105
|
+
const handleRing = this.handleBuffers.get(input.sessionId);
|
|
1106
|
+
if (!handleRing) return [];
|
|
1107
|
+
return handleRing.drain(input.maxCount);
|
|
1108
|
+
}
|
|
1109
|
+
async updateConfig(input) {
|
|
1110
|
+
const session = this.sessions.get(input.sessionId);
|
|
1111
|
+
if (!session) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
|
|
1112
|
+
session.updateConfig(input.config);
|
|
1113
|
+
}
|
|
1114
|
+
async getStats(input) {
|
|
1115
|
+
const session = this.sessions.get(input.sessionId);
|
|
1116
|
+
if (!session) throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
|
|
1117
|
+
return session.getStats();
|
|
1118
|
+
}
|
|
1119
|
+
async getFrame(input) {
|
|
1120
|
+
const frame = this.frameReaders?.read(input.handle) ?? null;
|
|
1121
|
+
if (!frame) {
|
|
1122
|
+
this.getFrameMisses += 1;
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
this.getFrameHits += 1;
|
|
1126
|
+
const arrayBuf = new ArrayBuffer(frame.data.byteLength);
|
|
1127
|
+
new Uint8Array(arrayBuf).set(frame.data);
|
|
1128
|
+
return {
|
|
1129
|
+
data: new Uint8Array(arrayBuf),
|
|
1130
|
+
width: frame.width,
|
|
1131
|
+
height: frame.height,
|
|
1132
|
+
format: frame.format,
|
|
1133
|
+
timestamp: frame.timestamp
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
async getShmStats(input) {
|
|
1137
|
+
const stats = this.sessions.get(input.sessionId)?.frameRingSinkOrNull?.getShmStats() ?? null;
|
|
1138
|
+
if (!stats) return null;
|
|
1139
|
+
return {
|
|
1140
|
+
sessionId: input.sessionId,
|
|
1141
|
+
...stats,
|
|
1142
|
+
budgetMb: RING_BUDGET_MB,
|
|
1143
|
+
getFrameHits: this.getFrameHits,
|
|
1144
|
+
getFrameMisses: this.getFrameMisses
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
async onShutdown() {
|
|
1148
|
+
this.ctx.logger.info("ffmpeg decoder addon shutdown — destroying all sessions");
|
|
1149
|
+
const destroyPromises = [];
|
|
1150
|
+
for (const [sessionId, session] of this.sessions) {
|
|
1151
|
+
const unsub = this.unsubscribers.get(sessionId);
|
|
1152
|
+
if (unsub) unsub();
|
|
1153
|
+
destroyPromises.push(session.destroy());
|
|
1154
|
+
}
|
|
1155
|
+
await Promise.all(destroyPromises);
|
|
1156
|
+
this.sessions.clear();
|
|
1157
|
+
this.frameBuffers.clear();
|
|
1158
|
+
this.handleBuffers.clear();
|
|
1159
|
+
this.unsubscribers.clear();
|
|
1160
|
+
this.sessionMeta.clear();
|
|
1161
|
+
this.frameReaders?.close();
|
|
1162
|
+
this.frameReaders = null;
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
//#endregion
|
|
1166
|
+
exports.DECODE_HWACCEL_RANK = DECODE_HWACCEL_RANK;
|
|
1167
|
+
exports.DEFAULT_VAAPI_RENDER_DEVICE = DEFAULT_VAAPI_RENDER_DEVICE;
|
|
1168
|
+
exports.DecoderFfmpegAddon = DecoderFfmpegAddon;
|
|
1169
|
+
exports.FfmpegDecoderSession = FfmpegDecoderSession;
|
|
1170
|
+
exports.buildFfmpegDecodeArgs = buildFfmpegDecodeArgs;
|
|
1171
|
+
exports.channelsForPixel = channelsForPixel;
|
|
1172
|
+
exports.codecToInputFormat = codecToInputFormat;
|
|
1173
|
+
exports.computeOutputGeometry = computeOutputGeometry;
|
|
1174
|
+
exports.default = DecoderFfmpegAddon;
|
|
1175
|
+
exports.maxDecodeWidth = maxDecodeWidth;
|
|
1176
|
+
exports.parseFfmpegOutputDims = parseFfmpegOutputDims;
|
|
1177
|
+
exports.pickDecodeHwAccel = pickDecodeHwAccel;
|
|
1178
|
+
exports.rankDecodeHwAccels = rankDecodeHwAccels;
|
|
1179
|
+
exports.rawPixelForFormat = rawPixelForFormat;
|