@apocaliss92/nodelink-js 0.2.2 → 0.2.4

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.
@@ -3,10 +3,10 @@ import {
3
3
  BaichuanRtspServer,
4
4
  ReolinkBaichuanApi,
5
5
  autoDetectDeviceType
6
- } from "../chunk-MN7GUZT7.js";
6
+ } from "../chunk-EG5IY3CM.js";
7
7
  import {
8
8
  __require
9
- } from "../chunk-NLTB7GTA.js";
9
+ } from "../chunk-APEEZ4UN.js";
10
10
 
11
11
  // src/cli/rtsp-server.ts
12
12
  function parseArgs() {
package/dist/index.cjs CHANGED
@@ -3000,14 +3000,6 @@ var init_BaichuanVideoStream = __esm({
3000
3000
  this.profile,
3001
3001
  { variant: this.variant, client: this.client }
3002
3002
  );
3003
- if (this.client.getTransport?.() === "udp") {
3004
- await startPromise;
3005
- } else {
3006
- await Promise.race([
3007
- startPromise,
3008
- new Promise((resolve) => setTimeout(resolve, 400))
3009
- ]);
3010
- }
3011
3003
  const updateActiveMsgNum = () => {
3012
3004
  try {
3013
3005
  const getMsgNum = this.api.getActiveVideoMsgNumWithVariant;
@@ -3017,6 +3009,15 @@ var init_BaichuanVideoStream = __esm({
3017
3009
  }
3018
3010
  };
3019
3011
  updateActiveMsgNum();
3012
+ if (this.client.getTransport?.() === "udp") {
3013
+ await startPromise;
3014
+ } else {
3015
+ await Promise.race([
3016
+ startPromise,
3017
+ new Promise((resolve) => setTimeout(resolve, 400))
3018
+ ]);
3019
+ }
3020
+ updateActiveMsgNum();
3020
3021
  void startPromise.then(() => updateActiveMsgNum()).catch((e) => {
3021
3022
  const err = e instanceof Error ? e : new Error(String(e));
3022
3023
  this.emitSafeError(err);
@@ -12542,8 +12543,24 @@ var BaichuanEventEmitter = class {
12542
12543
  }
12543
12544
  };
12544
12545
  async function* createNativeStream(api, channel, profile, options) {
12546
+ let client = options?.client;
12547
+ let dedicatedRelease;
12548
+ if (!client) {
12549
+ const variantSuffix = options?.variant && options.variant !== "default" ? `:${options.variant}` : "";
12550
+ const sessionKey = `live:native${variantSuffix}:ch${channel}:${profile}`;
12551
+ try {
12552
+ api.logger?.info?.(`[createNativeStream] acquiring dedicated session key=${sessionKey}`);
12553
+ const session = await api.createDedicatedSession(sessionKey);
12554
+ client = session.client;
12555
+ dedicatedRelease = session.release;
12556
+ api.logger?.info?.(`[createNativeStream] dedicated session acquired key=${sessionKey}`);
12557
+ } catch (e) {
12558
+ api.logger?.warn?.(`[createNativeStream] dedicated session failed, using shared client: ${e instanceof Error ? e.message : e}`);
12559
+ client = api.client;
12560
+ }
12561
+ }
12545
12562
  const videoStream = new BaichuanVideoStream({
12546
- client: api.client,
12563
+ client,
12547
12564
  api,
12548
12565
  channel,
12549
12566
  profile,
@@ -12557,9 +12574,15 @@ async function* createNativeStream(api, channel, profile, options) {
12557
12574
  let closed = false;
12558
12575
  const onError = (_error) => {
12559
12576
  closed = true;
12577
+ api.logger?.warn?.(
12578
+ `[createNativeStream] stream error \u2192 closed channel=${channel} profile=${profile} error=${_error?.message ?? _error}`
12579
+ );
12560
12580
  };
12561
12581
  const onClose = () => {
12562
12582
  closed = true;
12583
+ api.logger?.warn?.(
12584
+ `[createNativeStream] stream close \u2192 closed channel=${channel} profile=${profile}`
12585
+ );
12563
12586
  };
12564
12587
  try {
12565
12588
  videoStream.on("error", onError);
@@ -12670,6 +12693,10 @@ async function* createNativeStream(api, channel, profile, options) {
12670
12693
  }
12671
12694
  videoStream.removeListener("error", onError);
12672
12695
  videoStream.removeListener("close", onClose);
12696
+ if (dedicatedRelease) {
12697
+ dedicatedRelease().catch(() => {
12698
+ });
12699
+ }
12673
12700
  }
12674
12701
  }
12675
12702
 
@@ -12909,6 +12936,8 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
12909
12936
  tcpRtpFraming;
12910
12937
  active = false;
12911
12938
  flow;
12939
+ deviceId;
12940
+ dedicatedSessionRelease;
12912
12941
  // Authentication
12913
12942
  authCredentials = [];
12914
12943
  requireAuth;
@@ -12951,6 +12980,12 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
12951
12980
  // Shared native stream fan-out (single camera stream, multiple RTSP clients)
12952
12981
  nativeFanout = null;
12953
12982
  noClientAutoStopTimer;
12983
+ // Prebuffer: rolling ring of recent video frames for IDR-aligned fast startup.
12984
+ // When a new client connects while the stream is already running it does not need
12985
+ // to wait up to one full GOP interval for the next keyframe — we replay frames
12986
+ // from the last IDR in the prebuffer immediately.
12987
+ PREBUFFER_MAX_MS = 3e3;
12988
+ prebuffer = [];
12954
12989
  static isAdtsAacFrame(b) {
12955
12990
  return b.length >= 2 && b[0] === 255 && (b[1] & 240) === 240;
12956
12991
  }
@@ -12985,6 +13020,25 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
12985
13020
  );
12986
13021
  return { sampleRate, channels, configHex };
12987
13022
  }
13023
+ /** Returns true if the raw (packed/Annex B) frame is an IDR (H.264) or IRAP (H.265). */
13024
+ isRawFrameKeyframe(frame) {
13025
+ try {
13026
+ if (frame.videoType === "H264") {
13027
+ const nals = _BaichuanRtspServer.splitAnnexBNals(
13028
+ convertToAnnexB(frame.data)
13029
+ );
13030
+ return nals.some((n) => n.length >= 1 && (n[0] & 31) === 5);
13031
+ }
13032
+ if (frame.videoType === "H265") {
13033
+ const nals = splitAnnexBToNalPayloads2(convertToAnnexB2(frame.data));
13034
+ return nals.some(
13035
+ (n) => n.length >= 2 && isH265Irap(n[0] >> 1 & 63)
13036
+ );
13037
+ }
13038
+ } catch {
13039
+ }
13040
+ return false;
13041
+ }
12988
13042
  static parseInterleavedChannels(transportHeader) {
12989
13043
  const m = transportHeader.match(/interleaved\s*=\s*(\d+)\s*-\s*(\d+)/i);
12990
13044
  if (!m) return null;
@@ -13050,6 +13104,7 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
13050
13104
  this.path = options.path ?? `/stream/${this.profile}`;
13051
13105
  this.logger = options.logger ?? console;
13052
13106
  this.tcpRtpFraming = options.tcpRtpFraming ?? "rfc4571";
13107
+ this.deviceId = options.deviceId;
13053
13108
  this.authCredentials = options.credentials ?? [];
13054
13109
  this.requireAuth = options.requireAuth ?? this.authCredentials.length > 0;
13055
13110
  const transport = this.api.client.getTransport();
@@ -13379,7 +13434,11 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
13379
13434
  }
13380
13435
  const { hasParamSets: hasParamSets2 } = this.flow.getFmtp();
13381
13436
  if (!hasParamSets2) {
13382
- const primingMs = this.api.client.getTransport() === "udp" ? 4e3 : 1500;
13437
+ const primingMs = this.api.client.getTransport() === "udp" ? 4e3 : 3e3;
13438
+ const primingStart = Date.now();
13439
+ this.logger.info(
13440
+ `[rebroadcast] DESCRIBE priming: waiting up to ${primingMs}ms for SPS/PPS client=${clientId} path=${this.path}`
13441
+ );
13383
13442
  try {
13384
13443
  await Promise.race([
13385
13444
  this.firstFramePromise || Promise.resolve(),
@@ -13387,6 +13446,17 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
13387
13446
  ]);
13388
13447
  } catch {
13389
13448
  }
13449
+ const primingElapsed = Date.now() - primingStart;
13450
+ const { hasParamSets: hasParamSetsAfter } = this.flow.getFmtp();
13451
+ if (hasParamSetsAfter) {
13452
+ this.logger.info(
13453
+ `[rebroadcast] DESCRIBE priming: SPS/PPS received after ${primingElapsed}ms client=${clientId} path=${this.path}`
13454
+ );
13455
+ } else {
13456
+ this.logger.warn(
13457
+ `[rebroadcast] DESCRIBE priming: timed out after ${primingElapsed}ms without SPS/PPS \u2014 SDP will lack sprop-parameter-sets, downstream decoder may hang client=${clientId} path=${this.path}`
13458
+ );
13459
+ }
13390
13460
  }
13391
13461
  }
13392
13462
  {
@@ -13395,11 +13465,6 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
13395
13465
  this.logger.info(
13396
13466
  `[BaichuanRtspServer] DESCRIBE SDP for ${clientId} path=${this.path} codec=${this.flow.sdpCodec} hasParamSets=${hasParamSets2} fmtp=${fmtpPreview}`
13397
13467
  );
13398
- if (!hasParamSets2) {
13399
- this.rtspDebugLog(
13400
- `DESCRIBE responding without parameter sets yet (client=${clientId}, path=${this.path}, flow=${this.flow.key})`
13401
- );
13402
- }
13403
13468
  }
13404
13469
  const sdp = this.generateSdp();
13405
13470
  sendResponse(
@@ -13604,10 +13669,6 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
13604
13669
  sdp += `a=control:track1\r
13605
13670
  `;
13606
13671
  }
13607
- sdp += `a=setup:passive\r
13608
- `;
13609
- sdp += `a=connection:new\r
13610
- `;
13611
13672
  return sdp;
13612
13673
  }
13613
13674
  /**
@@ -13678,6 +13739,14 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
13678
13739
  return false;
13679
13740
  if (channel === audioRtpChannel && !resources2?.setupTrack1)
13680
13741
  return false;
13742
+ const buffered = rtspSocket.writableLength;
13743
+ if (buffered > 10 * 1024 * 1024) {
13744
+ this.logger.warn(
13745
+ `[rebroadcast] backpressure: ${Math.round(buffered / 1024)}KB buffered for client=${clientId} \u2014 disconnecting`
13746
+ );
13747
+ rtspSocket.destroy();
13748
+ return false;
13749
+ }
13681
13750
  try {
13682
13751
  return rtspSocket.write(frameRtpOverTcp(channel, msg));
13683
13752
  } catch (error) {
@@ -14107,6 +14176,24 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
14107
14176
  let frameCount = 0;
14108
14177
  let lastFrameTime = Date.now();
14109
14178
  const targetFrameInterval = streamMetadata && streamMetadata.frameRate > 0 ? 1e3 / streamMetadata.frameRate : 40;
14179
+ const prebufferSnap = this.prebuffer.slice();
14180
+ let lastIdrIdx = -1;
14181
+ for (let i = prebufferSnap.length - 1; i >= 0; i--) {
14182
+ if (prebufferSnap[i].isKeyframe) {
14183
+ lastIdrIdx = i;
14184
+ break;
14185
+ }
14186
+ }
14187
+ const prebufferFrames = lastIdrIdx >= 0 ? prebufferSnap.slice(lastIdrIdx) : [];
14188
+ if (prebufferFrames.length > 0) {
14189
+ this.logger.info(
14190
+ `[rebroadcast] prebuffer replay client=${clientId} frames=${prebufferFrames.length} starting from IDR`
14191
+ );
14192
+ }
14193
+ const combined = async function* () {
14194
+ for (const entry of prebufferFrames) yield entry.frame;
14195
+ for await (const f of clientGenerator) yield f;
14196
+ };
14110
14197
  const feedFrames = async () => {
14111
14198
  try {
14112
14199
  this.rtspDebugLog(
@@ -14118,7 +14205,7 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
14118
14205
  let firstVideoFrameSeenLogged = false;
14119
14206
  let h265WaitParamSetsLogged = false;
14120
14207
  let h265WaitIrapLogged = false;
14121
- for await (const frame of clientGenerator) {
14208
+ for await (const frame of combined()) {
14122
14209
  if (!this.connectedClients.has(clientId)) {
14123
14210
  this.rtspDebugLog(
14124
14211
  `Client ${clientId} disconnected, stopping frame feed`
@@ -14403,14 +14490,31 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
14403
14490
  this.firstAudioPromise = new Promise((resolve) => {
14404
14491
  this.firstAudioResolve = resolve;
14405
14492
  });
14493
+ let dedicatedClient;
14494
+ const variantSuffix = this.variant && this.variant !== "default" ? `:${this.variant}` : "";
14495
+ const deviceIdPart = this.deviceId ?? "rtsp-server";
14496
+ const sessionKey = `live:${deviceIdPart}:ch${this.channel}:${this.profile}${variantSuffix}`;
14497
+ try {
14498
+ const session = await this.api.createDedicatedSession(sessionKey, this.logger);
14499
+ dedicatedClient = session.client;
14500
+ this.dedicatedSessionRelease = session.release;
14501
+ this.logger.info(
14502
+ `[rebroadcast] dedicated session acquired sessionKey=${sessionKey}`
14503
+ );
14504
+ } catch (e) {
14505
+ this.logger.warn(
14506
+ `[rebroadcast] failed to acquire dedicated session, falling back to shared socket: ${e}`
14507
+ );
14508
+ }
14406
14509
  this.logger.info(
14407
- `[rebroadcast] native stream starting profile=${this.profile} channel=${this.channel} clients=${this.connectedClients.size}`
14510
+ `[rebroadcast] native stream starting profile=${this.profile} channel=${this.channel} clients=${this.connectedClients.size} dedicated=${!!dedicatedClient}`
14408
14511
  );
14409
14512
  await this.flow.startKeepAlive(this.api);
14410
14513
  this.nativeFanout = new NativeStreamFanout({
14411
14514
  maxQueueItems: 200,
14412
14515
  createSource: () => createNativeStream(this.api, this.channel, this.profile, {
14413
- variant: this.variant
14516
+ variant: this.variant,
14517
+ ...dedicatedClient ? { client: dedicatedClient } : {}
14414
14518
  }),
14415
14519
  onFrame: (frame) => {
14416
14520
  if (frame.audio) {
@@ -14442,6 +14546,18 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
14442
14546
  if (hasParamSets2) {
14443
14547
  this.markFirstFrameReceived();
14444
14548
  }
14549
+ const isKeyframe = this.isRawFrameKeyframe(frame);
14550
+ this.prebuffer.push({
14551
+ frame: { ...frame, data: Buffer.from(frame.data) },
14552
+ time: Date.now(),
14553
+ isKeyframe
14554
+ });
14555
+ const cutoff = Date.now() - this.PREBUFFER_MAX_MS;
14556
+ let trimIdx = 0;
14557
+ while (trimIdx < this.prebuffer.length && this.prebuffer[trimIdx].time < cutoff) {
14558
+ trimIdx++;
14559
+ }
14560
+ if (trimIdx > 0) this.prebuffer.splice(0, trimIdx);
14445
14561
  },
14446
14562
  onError: (error) => {
14447
14563
  this.logger.warn(
@@ -14455,6 +14571,13 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
14455
14571
  this.firstFramePromise = null;
14456
14572
  this.firstFrameResolve = null;
14457
14573
  this.nativeFanout = null;
14574
+ this.prebuffer = [];
14575
+ if (this.dedicatedSessionRelease) {
14576
+ const release = this.dedicatedSessionRelease;
14577
+ this.dedicatedSessionRelease = void 0;
14578
+ release().catch(() => {
14579
+ });
14580
+ }
14458
14581
  this.logger.info(
14459
14582
  `[rebroadcast] native stream ended (camera sleeping or connection lost) profile=${this.profile} channel=${this.channel} clients=${this.connectedClients.size}`
14460
14583
  );
@@ -14523,6 +14646,15 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
14523
14646
  this.nativeFanout = null;
14524
14647
  await fanout.stop();
14525
14648
  }
14649
+ this.prebuffer = [];
14650
+ if (this.dedicatedSessionRelease) {
14651
+ const release = this.dedicatedSessionRelease;
14652
+ this.dedicatedSessionRelease = void 0;
14653
+ try {
14654
+ await release();
14655
+ } catch {
14656
+ }
14657
+ }
14526
14658
  if (this.tempStreamGenerator) {
14527
14659
  try {
14528
14660
  await this.tempStreamGenerator.return(void 0);
@@ -14532,14 +14664,22 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends import_node_events4.E
14532
14664
  }
14533
14665
  }
14534
14666
  /**
14535
- * Remove a client and stop native stream if no clients remain.
14667
+ * Remove a client and schedule native stream stop if no clients remain.
14668
+ * Uses a grace period so rapid reconnects (e.g. Frigate polling) reuse the running stream
14669
+ * and benefit from the prebuffer instead of waiting for a fresh keyframe.
14536
14670
  */
14537
14671
  removeClient(clientId) {
14538
14672
  if (this.connectedClients.has(clientId)) {
14539
14673
  this.connectedClients.delete(clientId);
14540
14674
  this.emit("clientDisconnected", clientId);
14541
14675
  if (this.connectedClients.size === 0) {
14542
- void this.stopNativeStream();
14676
+ this.clearNoClientAutoStopTimer();
14677
+ this.noClientAutoStopTimer = setTimeout(() => {
14678
+ if (this.connectedClients.size === 0) {
14679
+ void this.stopNativeStream();
14680
+ }
14681
+ }, 3e4);
14682
+ this.noClientAutoStopTimer?.unref?.();
14543
14683
  }
14544
14684
  }
14545
14685
  }
@@ -34111,6 +34251,7 @@ var BaichuanHlsServer = class extends import_node_events10.EventEmitter {
34111
34251
  segmentDuration;
34112
34252
  playlistSize;
34113
34253
  ffmpegPath;
34254
+ externalVideoStream;
34114
34255
  log;
34115
34256
  outputDir = null;
34116
34257
  createdTempDir = false;
@@ -34137,6 +34278,7 @@ var BaichuanHlsServer = class extends import_node_events10.EventEmitter {
34137
34278
  this.outputDir = options.outputDir;
34138
34279
  this.createdTempDir = false;
34139
34280
  }
34281
+ this.externalVideoStream = options.externalVideoStream;
34140
34282
  this.log = options.logger ?? (() => {
34141
34283
  });
34142
34284
  }
@@ -34161,12 +34303,16 @@ var BaichuanHlsServer = class extends import_node_events10.EventEmitter {
34161
34303
  this.playlistPath = import_node_path3.default.join(this.outputDir, "playlist.m3u8");
34162
34304
  this.segmentPattern = import_node_path3.default.join(this.outputDir, "segment_%05d.ts");
34163
34305
  this.log("info", `Starting HLS stream to ${this.outputDir}`);
34164
- this.nativeStream = createNativeStream(
34165
- this.api,
34166
- this.channel,
34167
- this.profile,
34168
- this.variant ? { variant: this.variant } : void 0
34169
- );
34306
+ if (this.externalVideoStream) {
34307
+ this.nativeStream = this.wrapVideoStreamAsGenerator(this.externalVideoStream);
34308
+ } else {
34309
+ this.nativeStream = createNativeStream(
34310
+ this.api,
34311
+ this.channel,
34312
+ this.profile,
34313
+ this.variant ? { variant: this.variant } : void 0
34314
+ );
34315
+ }
34170
34316
  this.pumpPromise = this.pumpNativeToFfmpeg();
34171
34317
  this.startedAt = /* @__PURE__ */ new Date();
34172
34318
  this.state = "running";
@@ -34285,6 +34431,56 @@ var BaichuanHlsServer = class extends import_node_events10.EventEmitter {
34285
34431
  // ============================================================================
34286
34432
  // Private Methods
34287
34433
  // ============================================================================
34434
+ /**
34435
+ * Wrap a BaichuanVideoStream's videoAccessUnit events into an async generator
34436
+ * compatible with createNativeStream's output format.
34437
+ */
34438
+ async *wrapVideoStreamAsGenerator(videoStream) {
34439
+ const queue = [];
34440
+ let resolve = null;
34441
+ let done = false;
34442
+ const onFrame = (au) => {
34443
+ queue.push(au);
34444
+ resolve?.();
34445
+ resolve = null;
34446
+ };
34447
+ const onClose = () => {
34448
+ done = true;
34449
+ resolve?.();
34450
+ resolve = null;
34451
+ };
34452
+ const onError = () => {
34453
+ done = true;
34454
+ resolve?.();
34455
+ resolve = null;
34456
+ };
34457
+ videoStream.on("videoAccessUnit", onFrame);
34458
+ videoStream.on("close", onClose);
34459
+ videoStream.on("error", onError);
34460
+ try {
34461
+ while (!done) {
34462
+ if (queue.length === 0) {
34463
+ await new Promise((r) => {
34464
+ resolve = r;
34465
+ });
34466
+ }
34467
+ while (queue.length > 0) {
34468
+ const frame = queue.shift();
34469
+ yield {
34470
+ audio: false,
34471
+ data: frame.data,
34472
+ videoType: frame.videoType,
34473
+ isKeyframe: frame.isKeyframe,
34474
+ microseconds: frame.microseconds
34475
+ };
34476
+ }
34477
+ }
34478
+ } finally {
34479
+ videoStream.removeListener("videoAccessUnit", onFrame);
34480
+ videoStream.removeListener("close", onClose);
34481
+ videoStream.removeListener("error", onError);
34482
+ }
34483
+ }
34288
34484
  async pumpNativeToFfmpeg() {
34289
34485
  if (!this.nativeStream || !this.playlistPath || !this.segmentPattern) {
34290
34486
  return;