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 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+. `0` = exactly what iOS asks |
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 DVR itself delivers only a few Mbit/s in total, so several main streams at once
95
- would be slow even with a faster computer. Hardware video *decoding* on the Pi was tried and made things slower.
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
 
@@ -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 === 'h264_v4l2m2m' && p.encoderAuto) {
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
- const floor = p.minBitrate !== undefined ? p.minBitrate : outW >= 1280 ? 1200 : outW >= 640 ? 600 : 300;
254
- const bitrate = Math.min(Math.max(video.max_bit_rate, floor, 100), Math.max(plan.maxBitrate, floor));
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', LEVELS[video.level] || '3.1', '-bf', '0',
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 iPhone stops sending RTCP (app closed, network gone).
425
- const timeoutMs = Math.max(video.rtcp_interval * 10, 30) * 1000;
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.encoder = mode === 'auto' ? await detectEncoder(this.ffmpeg, this.log) : mode;
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.2.2",
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",