@camstack/addon-decoder-ffmpeg 0.1.1
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/index.d.mts +195 -0
- package/dist/index.d.ts +195 -0
- package/dist/index.js +577 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +553 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +67 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// src/addon/index.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import {
|
|
4
|
+
BaseAddon,
|
|
5
|
+
DEFAULT_DECODER_HWACCEL_CONFIG,
|
|
6
|
+
HWACCEL_OPTIONS,
|
|
7
|
+
decoderCapability,
|
|
8
|
+
RingBuffer
|
|
9
|
+
} from "@camstack/types";
|
|
10
|
+
|
|
11
|
+
// src/ffmpeg-decoder-session.ts
|
|
12
|
+
import { spawn } from "child_process";
|
|
13
|
+
import { maskUrlCredentials } from "@camstack/types";
|
|
14
|
+
|
|
15
|
+
// src/frame-dropper.ts
|
|
16
|
+
var FrameDropper = class {
|
|
17
|
+
intervalMs;
|
|
18
|
+
lastPassedAt = -Infinity;
|
|
19
|
+
constructor(maxFps) {
|
|
20
|
+
this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
|
|
21
|
+
}
|
|
22
|
+
shouldKeep() {
|
|
23
|
+
if (this.intervalMs === 0) return true;
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
if (now - this.lastPassedAt >= this.intervalMs) {
|
|
26
|
+
this.lastPassedAt = now;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
setMaxFps(maxFps) {
|
|
32
|
+
this.intervalMs = maxFps > 0 ? 1e3 / maxFps : 0;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// src/ffmpeg-decoder-session.ts
|
|
37
|
+
var noopLogger = {
|
|
38
|
+
debug() {
|
|
39
|
+
},
|
|
40
|
+
info() {
|
|
41
|
+
},
|
|
42
|
+
warn() {
|
|
43
|
+
},
|
|
44
|
+
error() {
|
|
45
|
+
},
|
|
46
|
+
child() {
|
|
47
|
+
return noopLogger;
|
|
48
|
+
},
|
|
49
|
+
withTags() {
|
|
50
|
+
return noopLogger;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var SOI = Buffer.from([255, 216]);
|
|
54
|
+
var EOI = Buffer.from([255, 217]);
|
|
55
|
+
var FfmpegDecoderSession = class {
|
|
56
|
+
config;
|
|
57
|
+
frameDropper;
|
|
58
|
+
process = null;
|
|
59
|
+
frameCallbacks = /* @__PURE__ */ new Set();
|
|
60
|
+
outputBuffer = Buffer.alloc(0);
|
|
61
|
+
destroyed = false;
|
|
62
|
+
logger;
|
|
63
|
+
/** When openStream() is used, we read from RTSP directly (not push mode) */
|
|
64
|
+
pullMode = false;
|
|
65
|
+
// Cached dimensions — won't change between frames from the same FFmpeg session
|
|
66
|
+
cachedWidth = 0;
|
|
67
|
+
cachedHeight = 0;
|
|
68
|
+
// Stats tracking
|
|
69
|
+
inputPackets = 0;
|
|
70
|
+
outputFrames = 0;
|
|
71
|
+
droppedFrames = 0;
|
|
72
|
+
totalDecodeTimeMs = 0;
|
|
73
|
+
decodeCount = 0;
|
|
74
|
+
startTime = Date.now();
|
|
75
|
+
hwaccelPref;
|
|
76
|
+
hwaccelResolver;
|
|
77
|
+
/** The backend we actually passed to ffmpeg `-hwaccel` — `'none'` = software. */
|
|
78
|
+
activeHwAccel = "none";
|
|
79
|
+
/**
|
|
80
|
+
* Backend resolution is async (calls into `ctx.kernel.hwaccel`), but
|
|
81
|
+
* `pushPacket` is sync — we kick the resolve off in the constructor
|
|
82
|
+
* and cache the result. By the time the first keyframe arrives
|
|
83
|
+
* (~30ms for RTSP), the resolver has completed (it's ultimately
|
|
84
|
+
* `os.platform()` + file checks → sub-ms). If `ensurePushProcess`
|
|
85
|
+
* fires before resolve settles, it skips hwaccel for that spawn;
|
|
86
|
+
* reconnect loop gets the flag on subsequent sessions.
|
|
87
|
+
*/
|
|
88
|
+
resolvedBackend = null;
|
|
89
|
+
constructor(config, logger = noopLogger, options) {
|
|
90
|
+
this.config = { ...config };
|
|
91
|
+
this.logger = logger;
|
|
92
|
+
this.frameDropper = new FrameDropper(config.maxFps);
|
|
93
|
+
this.hwaccelPref = options?.hwaccel ?? "auto";
|
|
94
|
+
this.hwaccelResolver = options?.hwaccelResolver ?? null;
|
|
95
|
+
void this.resolveHwAccelBackend().then((b) => {
|
|
96
|
+
this.resolvedBackend = b;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the preferred backend for this host and return the first
|
|
101
|
+
* hit, or `null` when software is requested / nothing available.
|
|
102
|
+
* FFmpeg CLI accepts the `-hwaccel <name>` identifier directly.
|
|
103
|
+
*/
|
|
104
|
+
async resolveHwAccelBackend() {
|
|
105
|
+
if (this.hwaccelPref === "none") return null;
|
|
106
|
+
const explicit = this.hwaccelPref === "auto" ? null : this.hwaccelPref;
|
|
107
|
+
if (!this.hwaccelResolver) return explicit;
|
|
108
|
+
const resolution = await this.hwaccelResolver.resolve(explicit);
|
|
109
|
+
return resolution.preferred[0] ?? null;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Open an RTSP stream directly — ffmpeg reads from the URL and outputs JPEG frames.
|
|
113
|
+
* This avoids the raw h264 pipe which loses SPS/PPS metadata on some cameras.
|
|
114
|
+
*/
|
|
115
|
+
async openStream(url) {
|
|
116
|
+
if (this.destroyed) return;
|
|
117
|
+
this.pullMode = true;
|
|
118
|
+
this.killFfmpeg();
|
|
119
|
+
this.outputBuffer = Buffer.alloc(0);
|
|
120
|
+
this.cachedWidth = 0;
|
|
121
|
+
this.cachedHeight = 0;
|
|
122
|
+
const backend = await this.resolveHwAccelBackend();
|
|
123
|
+
const hwArgs = backend ? ["-hwaccel", backend] : [];
|
|
124
|
+
this.activeHwAccel = backend ?? "none";
|
|
125
|
+
const args = [
|
|
126
|
+
"-hide_banner",
|
|
127
|
+
"-loglevel",
|
|
128
|
+
"error",
|
|
129
|
+
...hwArgs,
|
|
130
|
+
"-fflags",
|
|
131
|
+
"+nobuffer+flush_packets",
|
|
132
|
+
"-flags",
|
|
133
|
+
"low_delay",
|
|
134
|
+
"-probesize",
|
|
135
|
+
"32768",
|
|
136
|
+
"-analyzeduration",
|
|
137
|
+
"500000",
|
|
138
|
+
"-rtsp_transport",
|
|
139
|
+
"tcp",
|
|
140
|
+
"-i",
|
|
141
|
+
url,
|
|
142
|
+
"-an"
|
|
143
|
+
// no audio
|
|
144
|
+
];
|
|
145
|
+
if (this.config.scale > 1) {
|
|
146
|
+
args.push("-vf", `scale=iw/${this.config.scale}:ih/${this.config.scale}`);
|
|
147
|
+
}
|
|
148
|
+
args.push("-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3", "-threads", "1", "pipe:1");
|
|
149
|
+
this.logger.info("Opening RTSP stream directly", { meta: { url: maskUrlCredentials(url), hwAccel: this.activeHwAccel } });
|
|
150
|
+
this.process = spawn("ffmpeg", args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
151
|
+
this.process.stdin?.on("error", () => {
|
|
152
|
+
});
|
|
153
|
+
this.process.stdout?.on("data", (chunk) => {
|
|
154
|
+
this.handleOutputData(chunk);
|
|
155
|
+
});
|
|
156
|
+
this.process.stderr?.on("data", (data) => {
|
|
157
|
+
const line = data.toString().trim();
|
|
158
|
+
if (line) this.logger.warn("ffmpeg decoder stderr", { meta: { line } });
|
|
159
|
+
});
|
|
160
|
+
this.process.on("error", (err) => {
|
|
161
|
+
this.logger.error("FFmpeg decoder spawn error", { meta: { error: err.message } });
|
|
162
|
+
});
|
|
163
|
+
this.process.on("close", (code, signal) => {
|
|
164
|
+
this.logger.warn("FFmpeg decoder exited", { meta: { code, signal, frames: this.outputFrames } });
|
|
165
|
+
if (!this.destroyed) {
|
|
166
|
+
this.process = null;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
ensurePushProcess() {
|
|
171
|
+
if (this.process || this.destroyed || this.pullMode) return;
|
|
172
|
+
const inputFormat = this.config.codec === "h265" || this.config.codec === "hevc" ? "hevc" : this.config.codec === "mjpeg" ? "mjpeg" : "h264";
|
|
173
|
+
const hwArgs = this.resolvedBackend ? ["-hwaccel", this.resolvedBackend] : [];
|
|
174
|
+
this.activeHwAccel = this.resolvedBackend ?? "none";
|
|
175
|
+
const args = [
|
|
176
|
+
"-hide_banner",
|
|
177
|
+
"-loglevel",
|
|
178
|
+
"error",
|
|
179
|
+
...hwArgs,
|
|
180
|
+
"-fflags",
|
|
181
|
+
"+nobuffer+flush_packets",
|
|
182
|
+
"-flags",
|
|
183
|
+
"low_delay",
|
|
184
|
+
"-probesize",
|
|
185
|
+
"32768",
|
|
186
|
+
"-analyzeduration",
|
|
187
|
+
"500000",
|
|
188
|
+
"-f",
|
|
189
|
+
inputFormat,
|
|
190
|
+
"-i",
|
|
191
|
+
"pipe:0"
|
|
192
|
+
];
|
|
193
|
+
if (this.config.scale > 1) {
|
|
194
|
+
args.push("-vf", `scale=iw/${this.config.scale}:ih/${this.config.scale}`);
|
|
195
|
+
}
|
|
196
|
+
args.push("-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3", "-threads", "1", "pipe:1");
|
|
197
|
+
this.logger.info("Spawning push-mode ffmpeg decoder", {
|
|
198
|
+
meta: { codec: this.config.codec, hwAccel: this.activeHwAccel }
|
|
199
|
+
});
|
|
200
|
+
this.process = spawn("ffmpeg", args);
|
|
201
|
+
this.process.stdin?.on("error", () => {
|
|
202
|
+
});
|
|
203
|
+
this.process.stdout?.on("data", (chunk) => {
|
|
204
|
+
this.handleOutputData(chunk);
|
|
205
|
+
});
|
|
206
|
+
this.process.stderr?.on("data", (data) => {
|
|
207
|
+
const line = data.toString().trim();
|
|
208
|
+
if (line) this.logger.warn("ffmpeg decoder stderr", { meta: { line } });
|
|
209
|
+
});
|
|
210
|
+
this.process.on("error", (err) => {
|
|
211
|
+
this.logger.error("FFmpeg decoder spawn error", { meta: { error: err.message } });
|
|
212
|
+
});
|
|
213
|
+
this.process.on("close", (code, signal) => {
|
|
214
|
+
this.logger.warn("FFmpeg decoder exited", { meta: { code, signal, packets: this.inputPackets, frames: this.outputFrames } });
|
|
215
|
+
if (!this.destroyed) {
|
|
216
|
+
this.process = null;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
killFfmpeg() {
|
|
221
|
+
if (this.process) {
|
|
222
|
+
try {
|
|
223
|
+
this.process.kill("SIGKILL");
|
|
224
|
+
} catch {
|
|
225
|
+
}
|
|
226
|
+
this.process = null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
handleOutputData(chunk) {
|
|
230
|
+
this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]);
|
|
231
|
+
let searchFrom = 0;
|
|
232
|
+
while (true) {
|
|
233
|
+
const soiIndex = this.outputBuffer.indexOf(SOI, searchFrom);
|
|
234
|
+
if (soiIndex === -1) break;
|
|
235
|
+
const eoiIndex = this.outputBuffer.indexOf(EOI, soiIndex + 2);
|
|
236
|
+
if (eoiIndex === -1) break;
|
|
237
|
+
const frameEnd = eoiIndex + 2;
|
|
238
|
+
const jpegData = this.outputBuffer.subarray(soiIndex, frameEnd);
|
|
239
|
+
searchFrom = frameEnd;
|
|
240
|
+
this.emitFrame(Buffer.from(jpegData));
|
|
241
|
+
}
|
|
242
|
+
if (searchFrom > 0) {
|
|
243
|
+
this.outputBuffer = Buffer.from(this.outputBuffer.subarray(searchFrom));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
emitFrame(data) {
|
|
247
|
+
const decodeStart = Date.now();
|
|
248
|
+
if (!this.frameDropper.shouldKeep()) {
|
|
249
|
+
this.droppedFrames++;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const decodeTime = Date.now() - decodeStart;
|
|
253
|
+
this.totalDecodeTimeMs += decodeTime;
|
|
254
|
+
this.decodeCount++;
|
|
255
|
+
this.outputFrames++;
|
|
256
|
+
if (this.cachedWidth === 0) {
|
|
257
|
+
const dims = parseJpegDimensions(data);
|
|
258
|
+
this.cachedWidth = dims.width;
|
|
259
|
+
this.cachedHeight = dims.height;
|
|
260
|
+
this.logger.info("First decoded frame", { meta: { width: dims.width, height: dims.height, format: "jpeg", bytes: data.length } });
|
|
261
|
+
}
|
|
262
|
+
const frame = {
|
|
263
|
+
data,
|
|
264
|
+
width: this.cachedWidth,
|
|
265
|
+
height: this.cachedHeight,
|
|
266
|
+
format: "jpeg",
|
|
267
|
+
timestamp: Date.now()
|
|
268
|
+
};
|
|
269
|
+
for (const cb of this.frameCallbacks) {
|
|
270
|
+
cb(frame);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
pushPacket(packet) {
|
|
274
|
+
if (this.destroyed || this.pullMode) return;
|
|
275
|
+
this.ensurePushProcess();
|
|
276
|
+
if (!this.process?.stdin) return;
|
|
277
|
+
this.inputPackets++;
|
|
278
|
+
try {
|
|
279
|
+
this.process.stdin.write(packet.data);
|
|
280
|
+
} catch {
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
onFrame(callback) {
|
|
284
|
+
this.frameCallbacks.add(callback);
|
|
285
|
+
return () => {
|
|
286
|
+
this.frameCallbacks.delete(callback);
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
updateConfig(update) {
|
|
290
|
+
this.config = { ...this.config, ...update };
|
|
291
|
+
if (update.maxFps !== void 0) {
|
|
292
|
+
this.frameDropper.setMaxFps(update.maxFps);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async destroy() {
|
|
296
|
+
if (this.destroyed) return;
|
|
297
|
+
this.destroyed = true;
|
|
298
|
+
this.killFfmpeg();
|
|
299
|
+
this.frameCallbacks.clear();
|
|
300
|
+
}
|
|
301
|
+
getStats() {
|
|
302
|
+
const uptimeSec = Math.max((Date.now() - this.startTime) / 1e3, 1);
|
|
303
|
+
return {
|
|
304
|
+
inputFps: this.inputPackets / uptimeSec,
|
|
305
|
+
outputFps: this.outputFrames / uptimeSec,
|
|
306
|
+
avgDecodeTimeMs: this.decodeCount > 0 ? this.totalDecodeTimeMs / this.decodeCount : 0,
|
|
307
|
+
droppedFrames: this.droppedFrames
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
function parseJpegDimensions(data) {
|
|
312
|
+
for (let i = 0; i < data.length - 8; i++) {
|
|
313
|
+
if (data[i] === 255 && (data[i + 1] === 192 || data[i + 1] === 194)) {
|
|
314
|
+
const height = data[i + 5] << 8 | data[i + 6];
|
|
315
|
+
const width = data[i + 7] << 8 | data[i + 8];
|
|
316
|
+
return { width, height };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { width: 0, height: 0 };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/addon/index.ts
|
|
323
|
+
var FRAME_BUFFER_CAPACITY = 32;
|
|
324
|
+
var DecoderFfmpegAddon = class extends BaseAddon {
|
|
325
|
+
sessions = /* @__PURE__ */ new Map();
|
|
326
|
+
frameBuffers = /* @__PURE__ */ new Map();
|
|
327
|
+
unsubscribers = /* @__PURE__ */ new Map();
|
|
328
|
+
sessionMeta = /* @__PURE__ */ new Map();
|
|
329
|
+
constructor() {
|
|
330
|
+
super(DEFAULT_DECODER_HWACCEL_CONFIG);
|
|
331
|
+
}
|
|
332
|
+
globalSettingsSchema() {
|
|
333
|
+
return this.schema({
|
|
334
|
+
sections: [{
|
|
335
|
+
id: "hwaccel",
|
|
336
|
+
title: "Hardware acceleration",
|
|
337
|
+
description: 'Backend appended as `-hwaccel <name>` to the ffmpeg argv. "Auto" defers to the probed best; concrete backends force it. Changes apply to NEW sessions \u2014 existing sessions keep the backend they were created with.',
|
|
338
|
+
fields: [
|
|
339
|
+
this.field({
|
|
340
|
+
type: "select",
|
|
341
|
+
key: "hwaccel",
|
|
342
|
+
label: "Preferred backend",
|
|
343
|
+
options: [...HWACCEL_OPTIONS],
|
|
344
|
+
default: "auto",
|
|
345
|
+
immediate: true
|
|
346
|
+
}),
|
|
347
|
+
this.field({
|
|
348
|
+
type: "text",
|
|
349
|
+
key: "probedBestHwaccel",
|
|
350
|
+
label: "Probed best",
|
|
351
|
+
description: "Auto-detected best backend on this host. Call `reprobeHwaccel` to refresh.",
|
|
352
|
+
disabled: true,
|
|
353
|
+
default: ""
|
|
354
|
+
})
|
|
355
|
+
]
|
|
356
|
+
}]
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
async onInitialize() {
|
|
360
|
+
this.ctx.logger.info("FFmpeg decoder addon initialized");
|
|
361
|
+
if (!this.config.probedBestHwaccel) {
|
|
362
|
+
this.reprobeHwaccel().catch((err) => {
|
|
363
|
+
this.ctx.logger.warn("ffmpeg: auto-reprobe hwaccel failed", {
|
|
364
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return [{ capability: decoderCapability, provider: this }];
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Resolve the effective hwaccel backend for a new session. Reads
|
|
372
|
+
* this addon's own `hwaccel` setting. `'auto'` defers to the
|
|
373
|
+
* session's local resolver (`ctx.kernel.hwaccel`).
|
|
374
|
+
*/
|
|
375
|
+
resolveHwAccelPref() {
|
|
376
|
+
return this.config.hwaccel;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Re-run the platform probe on this host and persist the detected
|
|
380
|
+
* backend as `probedBestHwaccel`. Operator `hwaccel` setting is not
|
|
381
|
+
* touched — only the hint.
|
|
382
|
+
*/
|
|
383
|
+
async reprobeHwaccel() {
|
|
384
|
+
const resolver = this.ctx.kernel.hwaccel;
|
|
385
|
+
if (!resolver) {
|
|
386
|
+
this.ctx.logger.warn("reprobeHwaccel: no kernel hwaccel resolver \u2014 returning none");
|
|
387
|
+
await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: "none" });
|
|
388
|
+
return { backend: "none" };
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
const res = await resolver.resolve();
|
|
392
|
+
const backend = res.preferred[0] ?? "none";
|
|
393
|
+
await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: backend });
|
|
394
|
+
this.ctx.logger.info("reprobeHwaccel: wrote probedBestHwaccel", {
|
|
395
|
+
meta: { backend, rationale: res.rationale, preferred: res.preferred }
|
|
396
|
+
});
|
|
397
|
+
return { backend };
|
|
398
|
+
} catch (err) {
|
|
399
|
+
this.ctx.logger.warn("reprobeHwaccel failed", {
|
|
400
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
401
|
+
});
|
|
402
|
+
await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: "none" });
|
|
403
|
+
return { backend: "none" };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async supportsCodec(input) {
|
|
407
|
+
return ["h264", "h265", "hevc", "mjpeg"].includes(input.codec.toLowerCase());
|
|
408
|
+
}
|
|
409
|
+
async getInfo() {
|
|
410
|
+
return {
|
|
411
|
+
id: "decoder-ffmpeg",
|
|
412
|
+
name: "Decoder (FFmpeg)",
|
|
413
|
+
isPullMode: false,
|
|
414
|
+
priority: 50
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
async createSession(config) {
|
|
418
|
+
const sessionId = randomUUID();
|
|
419
|
+
const hwaccel = this.resolveHwAccelPref();
|
|
420
|
+
const session = new FfmpegDecoderSession(config, this.ctx.logger, {
|
|
421
|
+
hwaccel,
|
|
422
|
+
hwaccelResolver: this.ctx.kernel.hwaccel
|
|
423
|
+
});
|
|
424
|
+
const ringBuffer = new RingBuffer(FRAME_BUFFER_CAPACITY);
|
|
425
|
+
const unsub = session.onFrame((frame) => {
|
|
426
|
+
const { format } = frame;
|
|
427
|
+
if (format !== "jpeg" && format !== "rgb" && format !== "bgr" && format !== "yuv420" && format !== "gray") return;
|
|
428
|
+
const arrayBuf = new ArrayBuffer(frame.data.byteLength);
|
|
429
|
+
new Uint8Array(arrayBuf).set(frame.data);
|
|
430
|
+
const capFrame = {
|
|
431
|
+
data: new Uint8Array(arrayBuf),
|
|
432
|
+
width: frame.width,
|
|
433
|
+
height: frame.height,
|
|
434
|
+
format,
|
|
435
|
+
timestamp: frame.timestamp
|
|
436
|
+
};
|
|
437
|
+
ringBuffer.push(capFrame);
|
|
438
|
+
});
|
|
439
|
+
this.sessions.set(sessionId, session);
|
|
440
|
+
this.frameBuffers.set(sessionId, ringBuffer);
|
|
441
|
+
this.unsubscribers.set(sessionId, unsub);
|
|
442
|
+
this.sessionMeta.set(sessionId, {
|
|
443
|
+
codec: config.codec,
|
|
444
|
+
outputFormat: config.outputFormat,
|
|
445
|
+
createdAtMs: Date.now()
|
|
446
|
+
});
|
|
447
|
+
this.ctx.logger.info("ffmpeg: created session", { meta: { sessionId, codec: config.codec, hwaccelPref: hwaccel } });
|
|
448
|
+
return { sessionId, nodeId: this.ctx.kernel.localNodeId ?? "local" };
|
|
449
|
+
}
|
|
450
|
+
async destroySession(input) {
|
|
451
|
+
const { sessionId } = input;
|
|
452
|
+
const session = this.sessions.get(sessionId);
|
|
453
|
+
if (!session) {
|
|
454
|
+
throw new Error(`decoder-ffmpeg: unknown sessionId ${sessionId}`);
|
|
455
|
+
}
|
|
456
|
+
const unsub = this.unsubscribers.get(sessionId);
|
|
457
|
+
if (unsub) unsub();
|
|
458
|
+
await session.destroy();
|
|
459
|
+
this.sessions.delete(sessionId);
|
|
460
|
+
this.frameBuffers.delete(sessionId);
|
|
461
|
+
this.unsubscribers.delete(sessionId);
|
|
462
|
+
this.sessionMeta.delete(sessionId);
|
|
463
|
+
this.ctx.logger.info("ffmpeg: destroyed session", { meta: { sessionId } });
|
|
464
|
+
}
|
|
465
|
+
async listActiveSessions() {
|
|
466
|
+
const out = [];
|
|
467
|
+
for (const [sessionId, meta] of this.sessionMeta) {
|
|
468
|
+
out.push({ sessionId, codec: meta.codec, outputFormat: meta.outputFormat, createdAtMs: meta.createdAtMs });
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
async pushPacket(input) {
|
|
473
|
+
const session = this.sessions.get(input.sessionId);
|
|
474
|
+
if (!session) {
|
|
475
|
+
throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
|
|
476
|
+
}
|
|
477
|
+
session.pushPacket({
|
|
478
|
+
...input.packet,
|
|
479
|
+
data: Buffer.from(input.packet.data)
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
async openStream(input) {
|
|
483
|
+
const session = this.sessions.get(input.sessionId);
|
|
484
|
+
if (!session) {
|
|
485
|
+
throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
|
|
486
|
+
}
|
|
487
|
+
if (session.openStream) {
|
|
488
|
+
await session.openStream(input.url);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async pullFrames(input) {
|
|
492
|
+
const ringBuffer = this.frameBuffers.get(input.sessionId);
|
|
493
|
+
if (!ringBuffer) {
|
|
494
|
+
throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
|
|
495
|
+
}
|
|
496
|
+
return ringBuffer.drain(input.maxCount);
|
|
497
|
+
}
|
|
498
|
+
async updateConfig(input) {
|
|
499
|
+
const session = this.sessions.get(input.sessionId);
|
|
500
|
+
if (!session) {
|
|
501
|
+
throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
|
|
502
|
+
}
|
|
503
|
+
session.updateConfig(input.config);
|
|
504
|
+
}
|
|
505
|
+
async getStats(input) {
|
|
506
|
+
const session = this.sessions.get(input.sessionId);
|
|
507
|
+
if (!session) {
|
|
508
|
+
throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`);
|
|
509
|
+
}
|
|
510
|
+
return session.getStats();
|
|
511
|
+
}
|
|
512
|
+
async onShutdown() {
|
|
513
|
+
this.ctx.logger.info("FFmpeg decoder addon shutdown \u2014 destroying all sessions");
|
|
514
|
+
const destroyPromises = [];
|
|
515
|
+
for (const [sessionId, session] of this.sessions) {
|
|
516
|
+
const unsub = this.unsubscribers.get(sessionId);
|
|
517
|
+
if (unsub) unsub();
|
|
518
|
+
destroyPromises.push(session.destroy());
|
|
519
|
+
}
|
|
520
|
+
await Promise.all(destroyPromises);
|
|
521
|
+
this.sessions.clear();
|
|
522
|
+
this.frameBuffers.clear();
|
|
523
|
+
this.sessionMeta.clear();
|
|
524
|
+
this.unsubscribers.clear();
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// src/ffmpeg-decoder-provider.ts
|
|
529
|
+
var SUPPORTED_CODECS = /* @__PURE__ */ new Set(["h264", "h265", "hevc", "mjpeg"]);
|
|
530
|
+
var FfmpegDecoderProvider = class {
|
|
531
|
+
id = "ffmpeg";
|
|
532
|
+
name = "FFmpeg Decoder";
|
|
533
|
+
isPullMode = false;
|
|
534
|
+
/** Software decoder — used as fallback when hardware decoders are unavailable. */
|
|
535
|
+
priority = 50;
|
|
536
|
+
logger = null;
|
|
537
|
+
setLogger(logger) {
|
|
538
|
+
this.logger = logger;
|
|
539
|
+
}
|
|
540
|
+
async supportsCodec(input) {
|
|
541
|
+
return SUPPORTED_CODECS.has(input.codec);
|
|
542
|
+
}
|
|
543
|
+
async createSession(config) {
|
|
544
|
+
return new FfmpegDecoderSession(config, this.logger ?? void 0);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
export {
|
|
548
|
+
DecoderFfmpegAddon,
|
|
549
|
+
FfmpegDecoderProvider,
|
|
550
|
+
FfmpegDecoderSession,
|
|
551
|
+
FrameDropper
|
|
552
|
+
};
|
|
553
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/addon/index.ts","../src/ffmpeg-decoder-session.ts","../src/frame-dropper.ts","../src/ffmpeg-decoder-provider.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto'\nimport type {\n DecoderHwAccelConfig,\n HwAccelChoice,\n ProviderRegistration,\n IDecoderSession,\n DecoderStats,\n IDecoderCapProvider,\n FrameFormat,\n} from '@camstack/types'\nimport {\n BaseAddon,\n DEFAULT_DECODER_HWACCEL_CONFIG,\n HWACCEL_OPTIONS,\n decoderCapability,\n RingBuffer,\n} from '@camstack/types'\nimport { FfmpegDecoderSession } from '../ffmpeg-decoder-session.js'\n\n/** Cap-compatible frame shape — format must match DecodedFrameSchema enum values. */\ntype CapDecodedFrame = {\n data: Uint8Array<ArrayBuffer>\n width: number\n height: number\n format: FrameFormat\n timestamp: number\n}\n\n/** Cap-compatible session config — matches DecoderSessionConfigSchema output type. */\ntype CapDecoderSessionConfig = {\n codec: string\n maxFps: number\n outputFormat: FrameFormat\n scale: number\n width?: number\n height?: number\n}\n\n/** Cap-compatible encoded packet — data is Uint8Array matching EncodedPacketSchema. */\ntype CapEncodedPacket = {\n type: 'video' | 'audio'\n data: Uint8Array<ArrayBuffer>\n pts: number\n dts: number\n keyframe: boolean\n codec: string\n}\n\nconst FRAME_BUFFER_CAPACITY = 32\n\n/** Per-session metadata recorded at creation time, surfaced via `listActiveSessions`. */\ninterface SessionMeta {\n readonly codec: string\n readonly outputFormat: string\n readonly createdAtMs: number\n}\n\n/**\n * FFmpeg decoder addon — H264/HEVC/MJPEG decode via ffmpeg child\n * process.\n *\n * Phase 2d of the pipeline-settings migration — ffmpeg decoder owns\n * its own `hwaccel` choice + `probedBestHwaccel` hint. Sessions\n * resolve the effective backend from this addon's global settings\n * instead of the orchestrator's legacy `AgentPipelineSettings.hwaccel`.\n * Session constructor still appends `-hwaccel <name>` to the ffmpeg\n * argv as before.\n *\n * Implements the sessionId-based IDecoderCapProvider cap interface.\n * Sessions are managed internally via a Map; frames are polled via\n * RingBuffer.\n */\nexport default class DecoderFfmpegAddon extends BaseAddon<DecoderHwAccelConfig> implements IDecoderCapProvider {\n private readonly sessions = new Map<string, IDecoderSession>()\n private readonly frameBuffers = new Map<string, RingBuffer<CapDecodedFrame>>()\n private readonly unsubscribers = new Map<string, () => void>()\n private readonly sessionMeta = new Map<string, SessionMeta>()\n\n constructor() { super(DEFAULT_DECODER_HWACCEL_CONFIG) }\n\n protected globalSettingsSchema() {\n return this.schema({\n sections: [{\n id: 'hwaccel',\n title: 'Hardware acceleration',\n description: 'Backend appended as `-hwaccel <name>` to the ffmpeg argv. \"Auto\" defers to the probed best; concrete backends force it. Changes apply to NEW sessions — existing sessions keep the backend they were created with.',\n fields: [\n this.field({\n type: 'select',\n key: 'hwaccel',\n label: 'Preferred backend',\n options: [...HWACCEL_OPTIONS],\n default: 'auto',\n immediate: true,\n }),\n this.field({\n type: 'text',\n key: 'probedBestHwaccel',\n label: 'Probed best',\n description: 'Auto-detected best backend on this host. Call `reprobeHwaccel` to refresh.',\n disabled: true,\n default: '',\n }),\n ],\n }],\n })\n }\n\n protected async onInitialize(): Promise<ProviderRegistration[]> {\n this.ctx.logger.info('FFmpeg decoder addon initialized')\n // Auto-seed probedBestHwaccel on first boot.\n if (!this.config.probedBestHwaccel) {\n this.reprobeHwaccel().catch((err: unknown) => {\n this.ctx.logger.warn('ffmpeg: auto-reprobe hwaccel failed', {\n meta: { error: err instanceof Error ? err.message : String(err) },\n })\n })\n }\n return [{ capability: decoderCapability, provider: this }]\n }\n\n /**\n * Resolve the effective hwaccel backend for a new session. Reads\n * this addon's own `hwaccel` setting. `'auto'` defers to the\n * session's local resolver (`ctx.kernel.hwaccel`).\n */\n private resolveHwAccelPref(): HwAccelChoice {\n return this.config.hwaccel\n }\n\n /**\n * Re-run the platform probe on this host and persist the detected\n * backend as `probedBestHwaccel`. Operator `hwaccel` setting is not\n * touched — only the hint.\n */\n async reprobeHwaccel(): Promise<{ backend: string }> {\n const resolver = this.ctx.kernel.hwaccel\n if (!resolver) {\n this.ctx.logger.warn('reprobeHwaccel: no kernel hwaccel resolver — returning none')\n await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: 'none' })\n return { backend: 'none' }\n }\n try {\n const res = await resolver.resolve()\n const backend = (res.preferred[0] ?? 'none') as string\n await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: backend })\n this.ctx.logger.info('reprobeHwaccel: wrote probedBestHwaccel', {\n meta: { backend, rationale: res.rationale, preferred: res.preferred },\n })\n return { backend }\n } catch (err) {\n this.ctx.logger.warn('reprobeHwaccel failed', {\n meta: { error: err instanceof Error ? err.message : String(err) },\n })\n await this.ctx.settings?.writeAddonStore({ probedBestHwaccel: 'none' })\n return { backend: 'none' }\n }\n }\n\n async supportsCodec(input: { codec: string }): Promise<boolean> {\n return ['h264', 'h265', 'hevc', 'mjpeg'].includes(input.codec.toLowerCase())\n }\n\n async getInfo(): Promise<{ id: string; name: string; isPullMode?: boolean; priority?: number }> {\n return {\n id: 'decoder-ffmpeg',\n name: 'Decoder (FFmpeg)',\n isPullMode: false,\n priority: 50,\n }\n }\n\n async createSession(config: CapDecoderSessionConfig): Promise<{ sessionId: string; nodeId: string }> {\n const sessionId = randomUUID()\n const hwaccel = this.resolveHwAccelPref()\n const session = new FfmpegDecoderSession(config, this.ctx.logger, {\n hwaccel,\n hwaccelResolver: this.ctx.kernel.hwaccel,\n })\n const ringBuffer = new RingBuffer<CapDecodedFrame>(FRAME_BUFFER_CAPACITY)\n\n const unsub = session.onFrame((frame) => {\n // Map internal DecodedFrame to cap-compatible shape.\n const { format } = frame\n if (format !== 'jpeg' && format !== 'rgb' && format !== 'bgr' && format !== 'yuv420' && format !== 'gray') return\n // Copy frame data into a fresh ArrayBuffer so the cap-facing Uint8Array\n // has a concrete ArrayBuffer (not a SharedArrayBuffer / Buffer backing).\n const arrayBuf = new ArrayBuffer(frame.data.byteLength)\n new Uint8Array(arrayBuf).set(frame.data)\n const capFrame: CapDecodedFrame = {\n data: new Uint8Array(arrayBuf),\n width: frame.width,\n height: frame.height,\n format,\n timestamp: frame.timestamp,\n }\n ringBuffer.push(capFrame)\n })\n\n this.sessions.set(sessionId, session)\n this.frameBuffers.set(sessionId, ringBuffer)\n this.unsubscribers.set(sessionId, unsub)\n this.sessionMeta.set(sessionId, {\n codec: config.codec,\n outputFormat: config.outputFormat,\n createdAtMs: Date.now(),\n })\n\n this.ctx.logger.info('ffmpeg: created session', { meta: { sessionId, codec: config.codec, hwaccelPref: hwaccel } })\n return { sessionId, nodeId: this.ctx.kernel.localNodeId ?? 'local' }\n }\n\n async destroySession(input: { sessionId: string }): Promise<void> {\n const { sessionId } = input\n const session = this.sessions.get(sessionId)\n if (!session) {\n throw new Error(`decoder-ffmpeg: unknown sessionId ${sessionId}`)\n }\n\n const unsub = this.unsubscribers.get(sessionId)\n if (unsub) unsub()\n\n await session.destroy()\n\n this.sessions.delete(sessionId)\n this.frameBuffers.delete(sessionId)\n this.unsubscribers.delete(sessionId)\n this.sessionMeta.delete(sessionId)\n\n this.ctx.logger.info('ffmpeg: destroyed session', { meta: { sessionId } })\n }\n\n async listActiveSessions(): Promise<readonly { sessionId: string; codec: string; outputFormat: string; createdAtMs: number }[]> {\n const out: Array<{ sessionId: string; codec: string; outputFormat: string; createdAtMs: number }> = []\n for (const [sessionId, meta] of this.sessionMeta) {\n out.push({ sessionId, codec: meta.codec, outputFormat: meta.outputFormat, createdAtMs: meta.createdAtMs })\n }\n return out\n }\n\n async pushPacket(input: { sessionId: string; packet: CapEncodedPacket }): Promise<void> {\n const session = this.sessions.get(input.sessionId)\n if (!session) {\n throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`)\n }\n // Convert Uint8Array to Buffer at the cap boundary before passing to the internal session.\n session.pushPacket({\n ...input.packet,\n data: Buffer.from(input.packet.data),\n })\n }\n\n async openStream(input: { sessionId: string; url: string }): Promise<void> {\n const session = this.sessions.get(input.sessionId)\n if (!session) {\n throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`)\n }\n if (session.openStream) {\n await session.openStream(input.url)\n }\n }\n\n async pullFrames(input: { sessionId: string; maxCount: number }): Promise<CapDecodedFrame[]> {\n const ringBuffer = this.frameBuffers.get(input.sessionId)\n if (!ringBuffer) {\n throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`)\n }\n return ringBuffer.drain(input.maxCount)\n }\n\n async updateConfig(input: { sessionId: string; config: Partial<CapDecoderSessionConfig> }): Promise<void> {\n const session = this.sessions.get(input.sessionId)\n if (!session) {\n throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`)\n }\n session.updateConfig(input.config)\n }\n\n async getStats(input: { sessionId: string }): Promise<DecoderStats> {\n const session = this.sessions.get(input.sessionId)\n if (!session) {\n throw new Error(`decoder-ffmpeg: unknown sessionId ${input.sessionId}`)\n }\n return session.getStats()\n }\n\n protected async onShutdown(): Promise<void> {\n this.ctx.logger.info('FFmpeg decoder addon shutdown — destroying all sessions')\n const destroyPromises: Promise<void>[] = []\n for (const [sessionId, session] of this.sessions) {\n const unsub = this.unsubscribers.get(sessionId)\n if (unsub) unsub()\n destroyPromises.push(session.destroy())\n }\n await Promise.all(destroyPromises)\n this.sessions.clear()\n this.frameBuffers.clear()\n this.sessionMeta.clear()\n this.unsubscribers.clear()\n }\n}\n","import { spawn, type ChildProcess } from 'node:child_process'\nimport type {\n IDecoderSession, DecoderSessionConfig, DecoderStats, EncodedPacket,\n DecodedFrame, Unsubscribe, IScopedLogger,\n HwAccelBackend, IKernelHwAccel,\n} from '@camstack/types'\nimport { maskUrlCredentials } from '@camstack/types'\nimport { FrameDropper } from './frame-dropper'\n\nexport type HwAccelPref = 'auto' | 'none' | HwAccelBackend\n\nexport interface FfmpegDecoderSessionOptions {\n /** Addon-level hwaccel preference — per-agent. Default `'auto'`. */\n readonly hwaccel?: HwAccelPref\n /** Kernel hwaccel resolver — `ctx.kernel.hwaccel` passed from the addon. */\n readonly hwaccelResolver?: IKernelHwAccel\n}\n\n/** Minimal no-op logger for default parameter */\nconst noopLogger: IScopedLogger = {\n debug() {},\n info() {},\n warn() {},\n error() {},\n child() { return noopLogger },\n withTags() { return noopLogger },\n}\n\nconst SOI = Buffer.from([0xff, 0xd8])\nconst EOI = Buffer.from([0xff, 0xd9])\n\nexport class FfmpegDecoderSession implements IDecoderSession {\n private config: DecoderSessionConfig\n private frameDropper: FrameDropper\n private process: ChildProcess | null = null\n private frameCallbacks = new Set<(frame: DecodedFrame) => void>()\n private outputBuffer = Buffer.alloc(0)\n private destroyed = false\n private readonly logger: IScopedLogger\n\n /** When openStream() is used, we read from RTSP directly (not push mode) */\n private pullMode = false\n\n // Cached dimensions — won't change between frames from the same FFmpeg session\n private cachedWidth = 0\n private cachedHeight = 0\n\n // Stats tracking\n private inputPackets = 0\n private outputFrames = 0\n private droppedFrames = 0\n private totalDecodeTimeMs = 0\n private decodeCount = 0\n private startTime = Date.now()\n\n private readonly hwaccelPref: HwAccelPref\n private readonly hwaccelResolver: IKernelHwAccel | null\n /** The backend we actually passed to ffmpeg `-hwaccel` — `'none'` = software. */\n private activeHwAccel: 'none' | HwAccelBackend = 'none'\n\n /**\n * Backend resolution is async (calls into `ctx.kernel.hwaccel`), but\n * `pushPacket` is sync — we kick the resolve off in the constructor\n * and cache the result. By the time the first keyframe arrives\n * (~30ms for RTSP), the resolver has completed (it's ultimately\n * `os.platform()` + file checks → sub-ms). If `ensurePushProcess`\n * fires before resolve settles, it skips hwaccel for that spawn;\n * reconnect loop gets the flag on subsequent sessions.\n */\n private resolvedBackend: HwAccelBackend | null = null\n\n constructor(\n config: DecoderSessionConfig,\n logger: IScopedLogger = noopLogger,\n options?: FfmpegDecoderSessionOptions,\n ) {\n this.config = { ...config }\n this.logger = logger\n this.frameDropper = new FrameDropper(config.maxFps)\n this.hwaccelPref = options?.hwaccel ?? 'auto'\n this.hwaccelResolver = options?.hwaccelResolver ?? null\n // Pre-warm backend resolution so push-mode spawn doesn't have to\n // await. openStream() calls the async path directly.\n void this.resolveHwAccelBackend().then((b) => { this.resolvedBackend = b })\n // Don't spawn push-mode ffmpeg here; wait for openStream() or first pushPacket()\n }\n\n /**\n * Resolve the preferred backend for this host and return the first\n * hit, or `null` when software is requested / nothing available.\n * FFmpeg CLI accepts the `-hwaccel <name>` identifier directly.\n */\n private async resolveHwAccelBackend(): Promise<HwAccelBackend | null> {\n if (this.hwaccelPref === 'none') return null\n const explicit: HwAccelBackend | null =\n this.hwaccelPref === 'auto' ? null : this.hwaccelPref\n if (!this.hwaccelResolver) return explicit\n const resolution = await this.hwaccelResolver.resolve(explicit)\n return resolution.preferred[0] ?? null\n }\n\n /**\n * Open an RTSP stream directly — ffmpeg reads from the URL and outputs JPEG frames.\n * This avoids the raw h264 pipe which loses SPS/PPS metadata on some cameras.\n */\n async openStream(url: string): Promise<void> {\n if (this.destroyed) return\n this.pullMode = true\n\n this.killFfmpeg()\n this.outputBuffer = Buffer.alloc(0)\n this.cachedWidth = 0\n this.cachedHeight = 0\n\n // Resolve + prepend `-hwaccel <backend>` before input when the\n // host supports any backend. `-hwaccel` must come before `-i` in\n // the argv so ffmpeg knows to pick a HW-capable decoder. If the\n // backend is unavailable or fails at spawn, ffmpeg exits with an\n // error and the session logs it; the reconnect loop at a higher\n // layer will get triggered.\n const backend = await this.resolveHwAccelBackend()\n const hwArgs: string[] = backend ? ['-hwaccel', backend] : []\n this.activeHwAccel = backend ?? 'none'\n\n const args = [\n '-hide_banner', '-loglevel', 'error',\n ...hwArgs,\n '-fflags', '+nobuffer+flush_packets',\n '-flags', 'low_delay',\n '-probesize', '32768',\n '-analyzeduration', '500000',\n '-rtsp_transport', 'tcp',\n '-i', url,\n '-an', // no audio\n ]\n\n if (this.config.scale > 1) {\n args.push('-vf', `scale=iw/${this.config.scale}:ih/${this.config.scale}`)\n }\n\n args.push('-f', 'image2pipe', '-vcodec', 'mjpeg', '-q:v', '3', '-threads', '1', 'pipe:1')\n\n // Software decode via ffmpeg CLI — no `-hwaccel` flag is passed.\n // If hwaccel gets wired (e.g. `-hwaccel videotoolbox` on macOS),\n // update `hwAccel` below to the actual backend used.\n this.logger.info('Opening RTSP stream directly', { meta: { url: maskUrlCredentials(url), hwAccel: this.activeHwAccel } })\n this.process = spawn('ffmpeg', args, { stdio: ['pipe', 'pipe', 'pipe'] })\n\n this.process.stdin?.on('error', () => {})\n\n this.process.stdout?.on('data', (chunk: Buffer) => {\n this.handleOutputData(chunk)\n })\n\n this.process.stderr?.on('data', (data: Buffer) => {\n const line = data.toString().trim()\n if (line) this.logger.warn('ffmpeg decoder stderr', { meta: { line } })\n })\n\n this.process.on('error', (err: Error) => {\n this.logger.error('FFmpeg decoder spawn error', { meta: { error: err.message } })\n })\n\n this.process.on('close', (code, signal) => {\n this.logger.warn('FFmpeg decoder exited', { meta: { code, signal, frames: this.outputFrames } })\n if (!this.destroyed) {\n this.process = null\n }\n })\n }\n\n private ensurePushProcess(): void {\n if (this.process || this.destroyed || this.pullMode) return\n\n const inputFormat = this.config.codec === 'h265' || this.config.codec === 'hevc'\n ? 'hevc'\n : this.config.codec === 'mjpeg' ? 'mjpeg' : 'h264'\n\n // Read the pre-warmed hwaccel backend set by the constructor's\n // fire-and-forget resolve. Null → software (either explicit\n // 'none', no resolver, or resolve hasn't completed yet on the\n // very first spawn after session creation).\n const hwArgs: string[] = this.resolvedBackend ? ['-hwaccel', this.resolvedBackend] : []\n this.activeHwAccel = this.resolvedBackend ?? 'none'\n\n const args = [\n '-hide_banner', '-loglevel', 'error',\n ...hwArgs,\n '-fflags', '+nobuffer+flush_packets',\n '-flags', 'low_delay',\n '-probesize', '32768',\n '-analyzeduration', '500000',\n '-f', inputFormat, '-i', 'pipe:0',\n ]\n\n if (this.config.scale > 1) {\n args.push('-vf', `scale=iw/${this.config.scale}:ih/${this.config.scale}`)\n }\n\n args.push('-f', 'image2pipe', '-vcodec', 'mjpeg', '-q:v', '3', '-threads', '1', 'pipe:1')\n\n this.logger.info('Spawning push-mode ffmpeg decoder', {\n meta: { codec: this.config.codec, hwAccel: this.activeHwAccel },\n })\n this.process = spawn('ffmpeg', args)\n this.process.stdin?.on('error', () => {})\n\n this.process.stdout?.on('data', (chunk: Buffer) => {\n this.handleOutputData(chunk)\n })\n\n this.process.stderr?.on('data', (data: Buffer) => {\n const line = data.toString().trim()\n if (line) this.logger.warn('ffmpeg decoder stderr', { meta: { line } })\n })\n\n this.process.on('error', (err: Error) => {\n this.logger.error('FFmpeg decoder spawn error', { meta: { error: err.message } })\n })\n\n this.process.on('close', (code, signal) => {\n this.logger.warn('FFmpeg decoder exited', { meta: { code, signal, packets: this.inputPackets, frames: this.outputFrames } })\n if (!this.destroyed) {\n this.process = null\n }\n })\n }\n\n private killFfmpeg(): void {\n if (this.process) {\n try {\n this.process.kill('SIGKILL')\n } catch {\n // already dead\n }\n this.process = null\n }\n }\n\n private handleOutputData(chunk: Buffer): void {\n this.outputBuffer = Buffer.concat([this.outputBuffer, chunk])\n\n // Extract complete JPEG frames from the buffer\n let searchFrom = 0\n while (true) {\n const soiIndex = this.outputBuffer.indexOf(SOI, searchFrom)\n if (soiIndex === -1) break\n\n const eoiIndex = this.outputBuffer.indexOf(EOI, soiIndex + 2)\n if (eoiIndex === -1) break\n\n const frameEnd = eoiIndex + 2\n const jpegData = this.outputBuffer.subarray(soiIndex, frameEnd)\n\n // Advance past the consumed frame\n searchFrom = frameEnd\n\n this.emitFrame(Buffer.from(jpegData))\n }\n\n // Keep only unprocessed tail\n if (searchFrom > 0) {\n this.outputBuffer = Buffer.from(this.outputBuffer.subarray(searchFrom))\n }\n }\n\n private emitFrame(data: Buffer): void {\n const decodeStart = Date.now()\n\n if (!this.frameDropper.shouldKeep()) {\n this.droppedFrames++\n return\n }\n\n const decodeTime = Date.now() - decodeStart\n this.totalDecodeTimeMs += decodeTime\n this.decodeCount++\n this.outputFrames++\n\n // Only parse dimensions on first frame or after FFmpeg restart (cached=0)\n if (this.cachedWidth === 0) {\n const dims = parseJpegDimensions(data)\n this.cachedWidth = dims.width\n this.cachedHeight = dims.height\n this.logger.info('First decoded frame', { meta: { width: dims.width, height: dims.height, format: 'jpeg', bytes: data.length } })\n }\n\n const frame: DecodedFrame = {\n data,\n width: this.cachedWidth,\n height: this.cachedHeight,\n format: 'jpeg',\n timestamp: Date.now(),\n }\n\n for (const cb of this.frameCallbacks) {\n cb(frame)\n }\n }\n\n pushPacket(packet: EncodedPacket): void {\n if (this.destroyed || this.pullMode) return\n this.ensurePushProcess()\n if (!this.process?.stdin) return\n this.inputPackets++\n try {\n this.process.stdin.write(packet.data)\n } catch {\n // stdin may be closed if ffmpeg crashed\n }\n }\n\n onFrame(callback: (frame: DecodedFrame) => void): Unsubscribe {\n this.frameCallbacks.add(callback)\n return () => {\n this.frameCallbacks.delete(callback)\n }\n }\n\n updateConfig(update: Partial<DecoderSessionConfig>): void {\n this.config = { ...this.config, ...update }\n if (update.maxFps !== undefined) {\n this.frameDropper.setMaxFps(update.maxFps)\n }\n }\n\n async destroy(): Promise<void> {\n if (this.destroyed) return\n this.destroyed = true\n this.killFfmpeg()\n this.frameCallbacks.clear()\n }\n\n getStats(): DecoderStats {\n const uptimeSec = Math.max((Date.now() - this.startTime) / 1000, 1)\n return {\n inputFps: this.inputPackets / uptimeSec,\n outputFps: this.outputFrames / uptimeSec,\n avgDecodeTimeMs: this.decodeCount > 0 ? this.totalDecodeTimeMs / this.decodeCount : 0,\n droppedFrames: this.droppedFrames,\n }\n }\n}\n\n/**\n * Parse JPEG SOF0/SOF2 marker to extract image dimensions.\n * Scans for 0xFF 0xC0 (baseline) or 0xFF 0xC2 (progressive) markers.\n * Returns {width: 0, height: 0} if not found.\n */\nfunction parseJpegDimensions(data: Buffer): { width: number; height: number } {\n for (let i = 0; i < data.length - 8; i++) {\n if (data[i] === 0xFF && (data[i + 1] === 0xC0 || data[i + 1] === 0xC2)) {\n const height = (data[i + 5]! << 8) | data[i + 6]!\n const width = (data[i + 7]! << 8) | data[i + 8]!\n return { width, height }\n }\n }\n return { width: 0, height: 0 }\n}\n","export class FrameDropper {\n private intervalMs: number\n private lastPassedAt = -Infinity\n\n constructor(maxFps: number) {\n this.intervalMs = maxFps > 0 ? 1000 / maxFps : 0\n }\n\n shouldKeep(): boolean {\n if (this.intervalMs === 0) return true\n\n const now = Date.now()\n if (now - this.lastPassedAt >= this.intervalMs) {\n this.lastPassedAt = now\n return true\n }\n return false\n }\n\n setMaxFps(maxFps: number): void {\n this.intervalMs = maxFps > 0 ? 1000 / maxFps : 0\n }\n}\n","import type { IDecoderProvider, DecoderSessionConfig, IDecoderSession, IScopedLogger } from '@camstack/types'\nimport { FfmpegDecoderSession } from './ffmpeg-decoder-session'\n\nconst SUPPORTED_CODECS = new Set(['h264', 'h265', 'hevc', 'mjpeg'])\n\nexport class FfmpegDecoderProvider implements IDecoderProvider {\n readonly id = 'ffmpeg'\n readonly name = 'FFmpeg Decoder'\n readonly isPullMode = false\n /** Software decoder — used as fallback when hardware decoders are unavailable. */\n readonly priority = 50\n private logger: IScopedLogger | null = null\n\n setLogger(logger: IScopedLogger): void {\n this.logger = logger\n }\n\n async supportsCodec(input: { codec: string }): Promise<boolean> {\n return SUPPORTED_CODECS.has(input.codec)\n }\n\n async createSession(config: DecoderSessionConfig): Promise<IDecoderSession> {\n return new FfmpegDecoderSession(config, this.logger ?? undefined)\n }\n}\n"],"mappings":"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","names":[]}
|