homebridge-hikvision-dvr 1.2.2 → 1.4.0
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/CHANGELOG.md +18 -0
- package/README.md +31 -4
- package/config.schema.json +1 -0
- package/lib/camera.js +116 -9
- package/lib/ffmpeg.js +58 -1
- package/lib/platform.js +12 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0
|
|
4
|
+
|
|
5
|
+
Better live video on a PC or server (anything that isn't a Raspberry Pi), including encoding on an Intel/AMD GPU. Raspberry Pi behaviour is unchanged.
|
|
6
|
+
|
|
7
|
+
- **GPU encoding (VAAPI / Intel Quick Sync).** With *Video processing* on Automatic, a PC or server with a GPU (`/dev/dri/renderD*`) encodes H.264 on the GPU when an ffmpeg with `h264_vaapi` is available and a real one-second test encode succeeds. Streams are decoded in software, scaled and encoded on the GPU, so a 1080p stream costs about 0.3 of a core where libx264 at 720p costs 0.6-0.8 and at 1080p more than one. A new *Intel/AMD GPU encoder (VAAPI)* choice under *Video processing* asks for it explicitly. GPU *decoding* was tested and is not used: it was only slightly cheaper and the picture was blockier.
|
|
8
|
+
- **How ffmpeg is chosen.** *Custom ffmpeg path* always wins. Otherwise the plugin uses its bundled ffmpeg, except that on a non-Pi machine with a GPU it first looks for a system ffmpeg (`ffmpeg` on the PATH, `/usr/bin/ffmpeg`, `/usr/local/bin/ffmpeg`) that has both `h264_vaapi` and `libx264` and passes the test encode, and uses that one. The bundled ffmpeg has no VAAPI. The log says which encoder and which ffmpeg are in use.
|
|
9
|
+
- **Fallback.** If a GPU encode produces no video within 8 s or ffmpeg exits, that stream switches to libx264 (at most 1280x720). After three failures in a row with no GPU stream working, the GPU is left alone for 10 minutes.
|
|
10
|
+
- **Higher quality on a PC/server.** The viewer gets the size it asks for (up to 1920x1080 on the GPU, 1280x720 with libx264, since the DVR's 960x1080 main stream has no more detail) from the main stream for up to 4 viewers at once; a fifth viewer gets the sub stream. Minimum bitrates by size: GPU 5000 kbps for 1080p, 3000 kbps for 720p; libx264 2000 kbps for 720p (the GPU encoder needs roughly twice the bitrate of libx264 `veryfast` for the same sharpness). *Minimum bitrate* still overrides. 1080p streams are labelled H.264 level 4.0.
|
|
11
|
+
- **Even frame timing (all fast hosts).** libx264 and GPU streams use a two-stage pipeline: the encoder outputs a constant 25 fps and a copy-only second ffmpeg stamps the frames exactly 40 ms apart for SRTP. DVRs deliver about 20 fps with irregular gaps, and passing that timing through showed as judder.
|
|
12
|
+
- **Abandoned streams stop sooner.** A stream whose viewer stops sending RTCP (for example an Apple TV tile it gave up on without sending a stop) is now ended after 15-30 s (it was at least 30 s and up to ten report intervals), so HomeKit can start a new one. The log says how many reports had arrived.
|
|
13
|
+
|
|
14
|
+
## 1.3.0
|
|
15
|
+
|
|
16
|
+
Smoother live video when Homebridge runs on a PC or server (anything that isn't a Raspberry Pi).
|
|
17
|
+
|
|
18
|
+
- On a machine with 4 or more cores and no Pi hardware encoder, software-encoded streams use libx264 with the `veryfast` preset (a cleaner picture than `ultrafast`) in a single ffmpeg process, passing the DVR's frame timestamps through to the viewer (`-fps_mode passthrough`) instead of forcing a constant frame rate.
|
|
19
|
+
- Raspberry Pi behaviour is unchanged.
|
|
20
|
+
|
|
3
21
|
## 1.2.2
|
|
4
22
|
|
|
5
23
|
Fixes video freezing and high load on a Raspberry Pi when several cameras are opened at once (for example the 4-camera grid on an Apple TV).
|
package/README.md
CHANGED
|
@@ -31,9 +31,9 @@ Only the IP address, username and password are needed. Everything else is option
|
|
|
31
31
|
| Motion sensors | on | A motion sensor per camera, from the DVR's own motion detection (see below) |
|
|
32
32
|
| Motion reset | 30 s | How long motion stays on after the DVR's last motion event |
|
|
33
33
|
| Verification code | read from DVR | Only needed if the plugin can't read it. Use the 6-letter code on the DVR's sticker, or the one set in Hik-Connect / HiLook |
|
|
34
|
-
| Video processing | Automatic | Uses the Raspberry Pi hardware encoder when available (for views wider than 640 px; smaller views use libx264, which starts faster), otherwise libx264. With several viewers at once (e.g. an Apple TV camera grid) it automatically uses smaller output and the DVR's sub stream so a Pi keeps up (see below). *No transcoding* passes the DVR's stream through untouched (lowest CPU, experimental) |
|
|
34
|
+
| Video processing | Automatic | Uses the Raspberry Pi hardware encoder when available (for views wider than 640 px; smaller views use libx264, which starts faster); on a PC/server with an Intel/AMD GPU and a VAAPI ffmpeg, the GPU (see [PC / server](#pc--server-with-an-intelamd-gpu)); otherwise libx264. With several viewers at once (e.g. an Apple TV camera grid) it automatically uses smaller output and the DVR's sub stream so a Pi keeps up (see below). *No transcoding* passes the DVR's stream through untouched (lowest CPU, experimental) |
|
|
35
35
|
| Use sub stream up to width | 0 (never) | e.g. `320` serves Apple Watch views from the low-res sub stream |
|
|
36
|
-
| Minimum bitrate | automatic | iOS often asks for only ~300 kbps; the plugin uses at least 1200 kbps for 720p
|
|
36
|
+
| Minimum bitrate | automatic | iOS often asks for only ~300 kbps; the plugin uses at least 1200 kbps for 720p+ (2000-3000 kbps for 720p and 5000 kbps for 1080p on a PC/server). `0` = exactly what iOS asks |
|
|
37
37
|
| Max viewers per camera | 2 | |
|
|
38
38
|
| Web console / port | off / 8100 | Serves the DVR's web page with live view, playback and motion areas working (see below) |
|
|
39
39
|
| Web console: log in automatically | off | Skips the console's login page by logging in with the plugin's DVR account. Anyone on your network can then use the console (see below) |
|
|
@@ -91,8 +91,35 @@ picks a path for every stream as it starts, depending on how many are being view
|
|
|
91
91
|
| 3 or more | sub stream | 640x360, 800 kbps, hardware encoder; a fifth and later stream is passed through unchanged |
|
|
92
92
|
|
|
93
93
|
If the hardware encoder fails for one stream, only that stream falls back to libx264. If it is wedged (nothing is encoding in hardware),
|
|
94
|
-
it is avoided for 10 minutes and then tried again. The
|
|
95
|
-
|
|
94
|
+
it is avoided for 10 minutes and then tried again. The Pi's own network and CPU limit it to a few Mbit/s of DVR video in total, so several main streams at once are slow there. (A PC or server has no such limit; see below.) Hardware video *decoding* on the Pi was tried and made things slower.
|
|
95
|
+
|
|
96
|
+
## PC / server with an Intel/AMD GPU
|
|
97
|
+
|
|
98
|
+
On anything that isn't a Raspberry Pi the plugin serves up to 4 viewers from the main stream at the size they ask for (up to 1920x1080
|
|
99
|
+
on the GPU, 1280x720 with libx264), with the frames stamped evenly at 25 fps. With *Video processing* on Automatic it encodes on the
|
|
100
|
+
GPU (VAAPI, Intel Quick Sync) when all of these hold: there is a render node (`/dev/dri/renderD128`), an ffmpeg with `h264_vaapi` and
|
|
101
|
+
`libx264` can be found, and a real one-second test encode on the GPU works. Otherwise it uses libx264. The log tells you which:
|
|
102
|
+
`Video: transcoding on the GPU (h264_vaapi, /dev/dri/renderD128) with /usr/bin/ffmpeg`. If a GPU encode ever fails to produce video,
|
|
103
|
+
that stream continues with libx264.
|
|
104
|
+
|
|
105
|
+
The ffmpeg bundled with Homebridge has no VAAPI, so a system ffmpeg is needed; the plugin looks for `ffmpeg` on the PATH, then
|
|
106
|
+
`/usr/bin/ffmpeg` and `/usr/local/bin/ffmpeg` (or set *Custom ffmpeg path*, which is used as is). Typical setup:
|
|
107
|
+
|
|
108
|
+
- **Bare metal / VM:** `sudo apt install ffmpeg intel-media-va-driver-non-free vainfo` (on Debian/Ubuntu the driver is in `non-free`/`multiverse`) and make sure the Homebridge user can read `/dev/dri/renderD128` (group `render`). Check with `vainfo`.
|
|
109
|
+
- **Docker:** pass `/dev/dri` into the container (`devices: ["/dev/dri:/dev/dri"]`; add the `render` group unless the container runs as root, as the official Homebridge image does) and build a small image on top of the official one, for example
|
|
110
|
+
```dockerfile
|
|
111
|
+
FROM homebridge/homebridge:2026-09-25
|
|
112
|
+
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver-non-free vainfo libva-drm2 \
|
|
113
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
114
|
+
ENV LIBVA_DRIVER_NAME=iHD LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri
|
|
115
|
+
```
|
|
116
|
+
The image keeps its own static ffmpeg in `/usr/local/bin`; the distro one in `/usr/bin/ffmpeg` is the one the plugin picks.
|
|
117
|
+
|
|
118
|
+
Measured on a Core i5-9500T (UHD 630) with a 4-channel DVR (960x1080 main streams, ~2 Mbit/s each), replaying the DVR's real stream:
|
|
119
|
+
one 1080p stream uses about 30% of a core on the GPU path, four use about 115%; libx264 at 720p uses 60-80% of a core per stream
|
|
120
|
+
(four: about 240%) and about 115% at 1080p. Start time (first output frame) is 50-100 ms on both. The GPU encoder needs roughly twice the
|
|
121
|
+
bitrate of libx264 for the same sharpness, which is why the minimum bitrates are higher on the GPU path (5000 kbps at 1080p).
|
|
122
|
+
Four main streams at once were delivered by the DVR at full frame rate (about 9 Mbit/s in total).
|
|
96
123
|
|
|
97
124
|
## How it works
|
|
98
125
|
|
package/config.schema.json
CHANGED
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"oneOf": [
|
|
73
73
|
{ "title": "Automatic (recommended)", "enum": ["auto"] },
|
|
74
74
|
{ "title": "Raspberry Pi hardware encoder", "enum": ["h264_v4l2m2m"] },
|
|
75
|
+
{ "title": "Intel/AMD GPU encoder (VAAPI)", "enum": ["h264_vaapi"] },
|
|
75
76
|
{ "title": "Software encoder (libx264)", "enum": ["libx264"] },
|
|
76
77
|
{ "title": "No transcoding (lowest CPU, may look stretched)", "enum": ["copy"] }
|
|
77
78
|
]
|
package/lib/camera.js
CHANGED
|
@@ -6,6 +6,8 @@ const { RtspClient } = require('./rtsp');
|
|
|
6
6
|
const { HikDecryptor } = require('./hikcrypt');
|
|
7
7
|
const { FlvWriter } = require('./flv');
|
|
8
8
|
const { run, spawn, AnnexBSplitter } = require('./ffmpeg');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
9
11
|
|
|
10
12
|
const START_CODE = Buffer.from([0, 0, 0, 1]);
|
|
11
13
|
const PROFILES = ['baseline', 'main', 'high'];
|
|
@@ -20,6 +22,12 @@ const PREPARE_TTL = 10000; // a prepared-but-not-started session counts towards
|
|
|
20
22
|
const MAIN_MAX_VIEWERS = 2; // up to this many concurrent viewers get the main stream
|
|
21
23
|
const SMALL = { width: 640, height: 360, bitrate: 800 }; // light path: sub stream scaled up, hardware encoded
|
|
22
24
|
const MAX_HW_SESSIONS = 4; // hardware encodes at once; further viewers get the sub stream passed through
|
|
25
|
+
// A PC or server (not a Pi: no V4L2 M2M encoder node, 4+ cores) has CPU to spare for software encoding.
|
|
26
|
+
const FAST_HOST = os.cpus().length >= 4 && !fs.existsSync('/dev/video11');
|
|
27
|
+
const GPU_MAIN_MAX_VIEWERS = 4; // PC/server: up to this many concurrent viewers get the main stream (the DVR delivered 4 main streams at full frame rate)
|
|
28
|
+
const GPU_FAIL_LIMIT = 3; // consecutive GPU-encode failures (with no GPU stream working) before the GPU is left alone for HW_RETRY
|
|
29
|
+
const SW_FAST_MAX = [1280, 720]; // libx264 on a PC/server: biggest picture (the DVR's 960x1080 main stream has no more detail; 1080p x264 costs ~1.6 cores a stream)
|
|
30
|
+
const GPU_MAX = [1920, 1080]; // GPU encode: whatever the viewer asks for, up to this
|
|
23
31
|
const MAX_SW_SESSIONS = 2; // libx264 encodes at once while the hardware encoder is marked broken or one hardware encode failed
|
|
24
32
|
// Hardware DECODE (-c:v h264_v4l2m2m before -i) was measured on the Pi 3 A+ and is deliberately not used: with the DVR's FLV pipe
|
|
25
33
|
// input it was slower to start (2-7 s vs 1.5 s), used 2.5-3.3 cores instead of ~1 (ffmpeg copies every frame out of the codec's
|
|
@@ -28,7 +36,7 @@ const MAX_SW_SESSIONS = 2; // libx264 encodes at once while the hardware encoder
|
|
|
28
36
|
|
|
29
37
|
/** Per-platform streaming state shared by all cameras (kept on the platform object). */
|
|
30
38
|
function streamState(platform) {
|
|
31
|
-
if (!platform.streamState) platform.streamState = { active: new Set(), prepared: new Map(), hwBrokenUntil: 0 };
|
|
39
|
+
if (!platform.streamState) platform.streamState = { active: new Set(), prepared: new Map(), hwBrokenUntil: 0, gpuBrokenUntil: 0, gpuFails: 0 };
|
|
32
40
|
return platform.streamState;
|
|
33
41
|
}
|
|
34
42
|
|
|
@@ -198,7 +206,21 @@ class HikCamera {
|
|
|
198
206
|
let cap = null; // [maxWidth, maxHeight, maxBitrate] of the output
|
|
199
207
|
let why = '';
|
|
200
208
|
const count = (m) => [...st.active].filter((x) => x.mode === m).length;
|
|
201
|
-
if (mode === '
|
|
209
|
+
if (mode === 'h264_vaapi') {
|
|
210
|
+
// PC/server with a GPU encoder: full-size main streams for several viewers, the GPU does the work.
|
|
211
|
+
if (now < st.gpuBrokenUntil) {
|
|
212
|
+
mode = 'libx264';
|
|
213
|
+
if (viewers > GPU_MAIN_MAX_VIEWERS) { sub = true; cap = [SMALL.width, SMALL.height, SMALL.bitrate]; } else cap = [SW_FAST_MAX[0], SW_FAST_MAX[1], Infinity];
|
|
214
|
+
why = 'GPU encoder marked broken, libx264';
|
|
215
|
+
} else if (viewers <= GPU_MAIN_MAX_VIEWERS) {
|
|
216
|
+
cap = [GPU_MAX[0], GPU_MAX[1], Infinity];
|
|
217
|
+
why = `${viewers} viewer${viewers > 1 ? 's' : ''}, main stream on the GPU`;
|
|
218
|
+
} else {
|
|
219
|
+
sub = true;
|
|
220
|
+
cap = [1280, 720, Infinity];
|
|
221
|
+
why = `${viewers} viewers, sub stream on the GPU`;
|
|
222
|
+
}
|
|
223
|
+
} else if (mode === 'h264_v4l2m2m' && p.encoderAuto) {
|
|
202
224
|
const hwOk = now >= st.hwBrokenUntil;
|
|
203
225
|
if (!hwOk) {
|
|
204
226
|
sub = true;
|
|
@@ -221,6 +243,16 @@ class HikCamera {
|
|
|
221
243
|
why = `${viewers} viewers, sub stream passed through`;
|
|
222
244
|
}
|
|
223
245
|
}
|
|
246
|
+
} else if (mode === 'libx264' && p.encoderAuto && FAST_HOST) {
|
|
247
|
+
// PC/server without a (working) GPU encoder: CPU to spare, so main streams for several viewers at up to 720p.
|
|
248
|
+
if (viewers <= GPU_MAIN_MAX_VIEWERS) {
|
|
249
|
+
cap = [SW_FAST_MAX[0], SW_FAST_MAX[1], Infinity];
|
|
250
|
+
why = `${viewers} viewer${viewers > 1 ? 's' : ''}, main stream`;
|
|
251
|
+
} else {
|
|
252
|
+
sub = true;
|
|
253
|
+
cap = [SMALL.width, SMALL.height, SMALL.bitrate];
|
|
254
|
+
why = `${viewers} viewers, light sub-stream path`;
|
|
255
|
+
}
|
|
224
256
|
} else if (mode === 'libx264' && p.encoderAuto && viewers > MAIN_MAX_VIEWERS) {
|
|
225
257
|
// No hardware encoder at all (e.g. a Mac or a PC): keep CPU use bounded the same way.
|
|
226
258
|
sub = true;
|
|
@@ -250,8 +282,15 @@ class HikCamera {
|
|
|
250
282
|
let outH = plan.height;
|
|
251
283
|
const fps = Math.min(video.fps, 25);
|
|
252
284
|
// iOS often asks for ~300 kbps even on Wi-Fi, which looks poor; apply a resolution-based floor.
|
|
253
|
-
|
|
254
|
-
|
|
285
|
+
// PC/server (GPU or fast CPU): sensible floors by picture size so a larger view is not starved; the Pi keeps the old ones.
|
|
286
|
+
// The GPU encoder needs ~1.5x the bitrate of libx264 veryfast for the same sharpness (measured SSIM).
|
|
287
|
+
let floor;
|
|
288
|
+
if (p.minBitrate !== undefined) floor = p.minBitrate;
|
|
289
|
+
else if (mode === 'h264_vaapi') floor = outW >= 1600 ? 5000 : outW >= 1280 ? 3000 : outW >= 640 ? 1000 : 400;
|
|
290
|
+
else if (FAST_HOST && mode === 'libx264') floor = outW >= 1280 ? 2000 : outW >= 900 ? 1400 : outW >= 640 ? 800 : 400;
|
|
291
|
+
else floor = outW >= 1280 ? 1200 : outW >= 640 ? 600 : 300;
|
|
292
|
+
let bitrate = Math.min(Math.max(video.max_bit_rate, floor, 100), Math.max(plan.maxBitrate, floor));
|
|
293
|
+
const levelStr = () => (outW > 1280 ? '4.0' : LEVELS[video.level] || '3.1'); // 1080p is only legal at level 4.0
|
|
255
294
|
const log = this.log;
|
|
256
295
|
const tag = `[${this.name}]`;
|
|
257
296
|
|
|
@@ -280,9 +319,74 @@ class HikCamera {
|
|
|
280
319
|
if (mode === 'copy') {
|
|
281
320
|
// DVR main streams are often anamorphic (e.g. 960x1080 "1080P Lite"); tell the player the display aspect is 16:9.
|
|
282
321
|
first = this.spawn(session, [...input, '-c:v', 'copy', '-bsf:v', `h264_metadata=sample_aspect_ratio=${useSub ? '16/11' : '2/1'}`, ...srtpOut]);
|
|
322
|
+
} else if (mode === 'h264_vaapi') {
|
|
323
|
+
// GPU path: software decode (a few % of a core), GPU scale and GPU encode: ~30% of a core per 1080p stream versus 60-80% for libx264 720p.
|
|
324
|
+
// GPU decoding (-hwaccel vaapi) was measured too: ~10% per stream, same start time, but visibly blockier output (SSIM 0.894 vs 0.918 at 1080p/4 Mbit/s).
|
|
325
|
+
// Same two-stage timing model as libx264 above: constant 25 fps out of the encoder, copy-only sender stamps frames evenly.
|
|
326
|
+
// VAAPI encodes put SPS/PPS before every IDR themselves (checked), so no re-insertion is needed.
|
|
327
|
+
const device = (p.vaapi && p.vaapi.device) || '/dev/dri/renderD128';
|
|
328
|
+
const sender = this.spawn(session, [...logLevel, '-fflags', '+nobuffer', '-probesize', '32', '-analyzeduration', '0', '-max_probe_packets', '1',
|
|
329
|
+
'-f', 'h264', '-framerate', String(fps), '-i', 'pipe:0', '-c:v', 'copy', '-copyinkf', ...srtpOut]);
|
|
330
|
+
const vaProfile = ['constrained_baseline', 'main', 'high'][video.profile] || 'main';
|
|
331
|
+
const vaLevel = levelStr().replace('.', '');
|
|
332
|
+
first = this.spawn(session, [...input, '-vaapi_device', device, '-vf', `format=nv12,hwupload,scale_vaapi=w=${outW}:h=${outH}:format=nv12`, '-r', String(fps), '-c:v', 'h264_vaapi',
|
|
333
|
+
'-profile:v', vaProfile, '-level', vaLevel, '-bf', '0', '-g', String(fps * 2),
|
|
334
|
+
'-b:v', `${bitrate}k`, '-maxrate', `${bitrate}k`, '-bufsize', `${bitrate * 2}k`, '-f', 'h264', 'pipe:1']);
|
|
335
|
+
first.stdout.on('data', (chunk) => {
|
|
336
|
+
write(sender, chunk);
|
|
337
|
+
clearTimeout(session.hwTimer);
|
|
338
|
+
session.hwFallback = null;
|
|
339
|
+
if (!session.hwOutput) st.gpuFails = 0;
|
|
340
|
+
session.hwOutput = true;
|
|
341
|
+
});
|
|
342
|
+
// No output in HW_TIMEOUT, or ffmpeg exited first: this session falls back to libx264 (smaller picture, lower bitrate).
|
|
343
|
+
// Only repeated failures with no GPU stream working make the GPU off limits for everyone, and then only for HW_RETRY.
|
|
344
|
+
session.hwFirst = first;
|
|
345
|
+
session.hwFallback = (why) => {
|
|
346
|
+
session.hwFallback = null;
|
|
347
|
+
clearTimeout(session.hwTimer);
|
|
348
|
+
if (session.stopped) return;
|
|
349
|
+
const othersWorking = [...st.active].some((x) => x !== session && x.mode === 'h264_vaapi' && x.hwOutput);
|
|
350
|
+
if (!othersWorking) st.gpuFails++;
|
|
351
|
+
const broken = !othersWorking && st.gpuFails >= GPU_FAIL_LIMIT;
|
|
352
|
+
if (broken) st.gpuBrokenUntil = Date.now() + HW_RETRY;
|
|
353
|
+
log.warn(`${tag} GPU encoder ${why}${session.lastErr ? ` (${session.lastErr.split('\n').pop().slice(0, 160)})` : ''}; this stream switches to libx264${broken ? ` and the GPU is not used for ${HW_RETRY / 60000} min` : ''}`);
|
|
354
|
+
for (const proc of session.procs.splice(0)) {
|
|
355
|
+
proc.replaced = true;
|
|
356
|
+
proc.kill('SIGTERM');
|
|
357
|
+
setTimeout(() => proc.exitCode === null && proc.kill('SIGKILL'), 5000).unref();
|
|
358
|
+
}
|
|
359
|
+
started = false;
|
|
360
|
+
keyRequested = false;
|
|
361
|
+
requestKeyFrame();
|
|
362
|
+
session.mode = 'libx264';
|
|
363
|
+
session.hwOutput = false;
|
|
364
|
+
if (outW > SW_FAST_MAX[0]) {
|
|
365
|
+
outH = Math.round((outH * SW_FAST_MAX[0]) / outW / 2) * 2;
|
|
366
|
+
outW = SW_FAST_MAX[0];
|
|
367
|
+
scale = `scale=${outW}:${outH}:flags=fast_bilinear,format=yuv420p`;
|
|
368
|
+
}
|
|
369
|
+
bitrate = Math.min(bitrate, 2500);
|
|
370
|
+
launch('libx264');
|
|
371
|
+
};
|
|
372
|
+
session.armHw = () => {
|
|
373
|
+
session.hwTimer = setTimeout(() => session.hwFallback && session.hwFallback(`produced no video in ${HW_TIMEOUT / 1000}s`), HW_TIMEOUT);
|
|
374
|
+
};
|
|
375
|
+
} else if (mode === 'libx264' && FAST_HOST) {
|
|
376
|
+
// PC or server: the same two-stage path the Pi's hardware encoder uses, with libx264 (a better preset; CPU to spare).
|
|
377
|
+
// The encoder outputs constant 25 fps and the copy-only sender stamps frames evenly by count, which smooths out the
|
|
378
|
+
// DVR's uneven frame delivery (it sends ~20 fps with irregular gaps). Sending the encoder's timestamps directly
|
|
379
|
+
// (single process) passes that unevenness to the viewer as judder. repeat-headers puts SPS/PPS before every IDR.
|
|
380
|
+
const sender = this.spawn(session, [...logLevel, '-fflags', '+nobuffer', '-probesize', '32', '-analyzeduration', '0', '-max_probe_packets', '1',
|
|
381
|
+
'-f', 'h264', '-framerate', String(fps), '-i', 'pipe:0', '-c:v', 'copy', '-copyinkf', ...srtpOut]);
|
|
382
|
+
first = this.spawn(session, [...input, '-vf', scale, '-r', String(fps), '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
|
|
383
|
+
'-profile:v', PROFILES[video.profile] || 'main', '-level:v', levelStr(), '-bf', '0',
|
|
384
|
+
'-g', String(fps * 2), '-b:v', `${bitrate}k`, '-maxrate', `${bitrate}k`, '-bufsize', `${bitrate * 2}k`,
|
|
385
|
+
'-x264-params', 'repeat-headers=1', '-f', 'h264', 'pipe:1']);
|
|
386
|
+
first.stdout.on('data', (chunk) => write(sender, chunk));
|
|
283
387
|
} else if (mode === 'libx264') {
|
|
284
388
|
first = this.spawn(session, [...input, '-vf', scale, '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
|
|
285
|
-
'-profile:v', PROFILES[video.profile] || 'main', '-level:v',
|
|
389
|
+
'-profile:v', PROFILES[video.profile] || 'main', '-level:v', levelStr(), '-bf', '0',
|
|
286
390
|
'-r', String(fps), '-g', String(fps * 2), '-b:v', `${bitrate}k`, '-maxrate', `${bitrate}k`, '-bufsize', `${bitrate * 2}k`,
|
|
287
391
|
...srtpOut]);
|
|
288
392
|
} else {
|
|
@@ -421,16 +525,19 @@ class HikCamera {
|
|
|
421
525
|
callback(e);
|
|
422
526
|
});
|
|
423
527
|
|
|
424
|
-
// Stop if the
|
|
425
|
-
|
|
528
|
+
// Stop if the viewer stops sending RTCP receiver reports (app closed, tile abandoned, network gone) so HomeKit can start a fresh
|
|
529
|
+
// session. A live viewer reports every rtcp_interval seconds (usually 0.5-5), so 15 s of silence (at most 30) means it has gone.
|
|
530
|
+
// (This used to be rtcp_interval x 10, at least 30 s, which left an abandoned Apple TV tile streaming for a minute or more.)
|
|
531
|
+
const timeoutMs = Math.min(Math.max((Number(video.rtcp_interval) || 1) * 5, 15), 30) * 1000;
|
|
532
|
+
let reports = 0;
|
|
426
533
|
const kick = () => {
|
|
427
534
|
clearTimeout(session.timer);
|
|
428
535
|
session.timer = setTimeout(() => {
|
|
429
|
-
log.info(`${tag} No RTCP from viewer for ${timeoutMs / 1000}s, stopping stream`);
|
|
536
|
+
log.info(`${tag} No RTCP from viewer for ${timeoutMs / 1000}s (${reports} reports received), stopping stream`);
|
|
430
537
|
this.forceStop(sessionID);
|
|
431
538
|
}, timeoutMs);
|
|
432
539
|
};
|
|
433
|
-
info.socket.on('message', kick);
|
|
540
|
+
info.socket.on('message', () => { reports++; kick(); });
|
|
434
541
|
kick();
|
|
435
542
|
}
|
|
436
543
|
|
package/lib/ffmpeg.js
CHANGED
|
@@ -31,6 +31,63 @@ function run(ffmpeg, args, { input, timeout = 15000 } = {}) {
|
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const VAAPI_CANDIDATES = ['ffmpeg', '/usr/bin/ffmpeg', '/usr/local/bin/ffmpeg'];
|
|
35
|
+
|
|
36
|
+
function isPi() {
|
|
37
|
+
return fs.existsSync('/dev/video11'); // the Pi's V4L2 M2M encoder node
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The first DRM render node (normally /dev/dri/renderD128), or null when there is no usable GPU. */
|
|
41
|
+
function findRenderNode() {
|
|
42
|
+
try {
|
|
43
|
+
const nodes = fs.readdirSync('/dev/dri').filter((n) => /^renderD\d+$/.test(n)).sort();
|
|
44
|
+
return nodes.length ? `/dev/dri/${nodes[0]}` : null;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function listEncoders(ffmpeg) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
execFile(ffmpeg, ['-hide_banner', '-encoders'], { timeout: 10000 }, (err, stdout) => resolve(err ? '' : stdout));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Real 1-second GPU encode, exactly the way a stream uses it (software frames uploaded to the GPU). */
|
|
57
|
+
async function testVaapi(ffmpeg, device) {
|
|
58
|
+
await run(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-init_hw_device', `vaapi=va:${device}`, '-filter_hw_device', 'va',
|
|
59
|
+
'-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=25', '-t', '1', '-vf', 'format=nv12,hwupload', '-c:v', 'h264_vaapi',
|
|
60
|
+
'-b:v', '500k', '-f', 'null', '-']);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Looks for an ffmpeg that can encode H.264 on the Intel/AMD GPU (VAAPI) and proves it with a real test encode.
|
|
65
|
+
* Never on a Raspberry Pi. When the user set videoProcessor only that ffmpeg is tried; otherwise the bundled ffmpeg
|
|
66
|
+
* (which has no VAAPI) is skipped in favour of a system ffmpeg that has it. The ffmpeg must also have libx264 (the fallback).
|
|
67
|
+
* Resolves to { ffmpeg, device } or null.
|
|
68
|
+
*/
|
|
69
|
+
async function findVaapi(configured, log) {
|
|
70
|
+
if (isPi()) return null;
|
|
71
|
+
const device = findRenderNode();
|
|
72
|
+
if (!device) return null;
|
|
73
|
+
const candidates = configured ? [configured] : VAAPI_CANDIDATES.filter((c, i) => !c.startsWith('/') || fs.existsSync(c) || i === 0);
|
|
74
|
+
for (const ffmpeg of candidates) {
|
|
75
|
+
const encoders = await listEncoders(ffmpeg);
|
|
76
|
+
if (!/h264_vaapi/.test(encoders)) continue;
|
|
77
|
+
if (!/libx264/.test(encoders)) {
|
|
78
|
+
log.debug(`${ffmpeg} has h264_vaapi but no libx264 (needed as the fallback), skipping it`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await testVaapi(ffmpeg, device);
|
|
83
|
+
return { ffmpeg, device };
|
|
84
|
+
} catch (e) {
|
|
85
|
+
log.debug(`GPU encode test with ${ffmpeg} on ${device} failed: ${e.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
34
91
|
/** Pick the best working H.264 encoder: Raspberry Pi hardware encoder if it actually works, else libx264. */
|
|
35
92
|
async function detectEncoder(ffmpeg, log) {
|
|
36
93
|
const encoders = await new Promise((resolve) => {
|
|
@@ -81,4 +138,4 @@ function findStart(buf, from) {
|
|
|
81
138
|
return { begin, end: i + 3 };
|
|
82
139
|
}
|
|
83
140
|
|
|
84
|
-
module.exports = { resolveFfmpeg, run, detectEncoder, AnnexBSplitter, spawn };
|
|
141
|
+
module.exports = { resolveFfmpeg, run, detectEncoder, findVaapi, isPi, AnnexBSplitter, spawn };
|
package/lib/platform.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { Isapi } = require('./isapi');
|
|
4
4
|
const { HikCamera } = require('./camera');
|
|
5
|
-
const { resolveFfmpeg, detectEncoder } = require('./ffmpeg');
|
|
5
|
+
const { resolveFfmpeg, detectEncoder, findVaapi } = require('./ffmpeg');
|
|
6
6
|
const { AlertStream } = require('./events');
|
|
7
7
|
const { WebConsole } = require('./webconsole');
|
|
8
8
|
|
|
@@ -95,9 +95,18 @@ class HikvisionPlatform {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
const mode = this.config.encoder || 'auto';
|
|
98
|
-
this.
|
|
98
|
+
this.vaapi = null;
|
|
99
|
+
if (mode === 'auto' || mode === 'h264_vaapi') {
|
|
100
|
+
// PC/server with an Intel/AMD GPU: encode there when an ffmpeg with VAAPI exists and a real test encode works.
|
|
101
|
+
this.vaapi = await findVaapi(this.config.videoProcessor, this.log);
|
|
102
|
+
if (this.vaapi) this.ffmpeg = this.vaapi.ffmpeg;
|
|
103
|
+
else if (mode === 'h264_vaapi') this.log.warn('Encoder "h264_vaapi" was chosen but no working GPU encode was found (needs /dev/dri and an ffmpeg with VAAPI); using libx264');
|
|
104
|
+
}
|
|
105
|
+
if (this.vaapi) this.encoder = 'h264_vaapi';
|
|
106
|
+
else if (mode === 'auto' || mode === 'h264_vaapi') this.encoder = await detectEncoder(this.ffmpeg, this.log);
|
|
107
|
+
else this.encoder = mode;
|
|
99
108
|
this.encoderAuto = mode === 'auto';
|
|
100
|
-
this.log.info(`Video: ${this.encoder === 'copy' ? 'passing DVR stream through (no transcoding)' : `transcoding with ${this.encoder}`}`);
|
|
109
|
+
this.log.info(`Video: ${this.encoder === 'copy' ? 'passing DVR stream through (no transcoding)' : this.encoder === 'h264_vaapi' ? `transcoding on the GPU (h264_vaapi, ${this.vaapi.device}) with ${this.ffmpeg}` : `transcoding with ${this.encoder} (${this.ffmpeg})`}`);
|
|
101
110
|
|
|
102
111
|
const overrides = new Map((this.config.cameras || []).filter((x) => x && x.channel).map((x) => [Number(x.channel), x]));
|
|
103
112
|
channels = channels
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-hikvision-dvr",
|
|
3
3
|
"displayName": "Hikvision DVR / NVR Cameras",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.4.0",
|
|
5
5
|
"description": "Hikvision, HiLook and other Hik-based DVR/NVR cameras in HomeKit, with automatic camera discovery and support for encrypted (Hik-Connect / HiLook) streams.",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"license": "MIT",
|