@apocaliss92/nodelink-js 0.1.8 → 0.1.9

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.js CHANGED
@@ -37,7 +37,7 @@ import {
37
37
  normalizeUid,
38
38
  parseSupportXml,
39
39
  setGlobalLogger
40
- } from "./chunk-AFUYHWWQ.js";
40
+ } from "./chunk-YUBYINJF.js";
41
41
  import {
42
42
  AesStreamDecryptor,
43
43
  BC_AES_IV,
@@ -203,7 +203,365 @@ import {
203
203
  testChannelStreams,
204
204
  xmlEscape,
205
205
  zipDirectory
206
- } from "./chunk-MC2BRLLE.js";
206
+ } from "./chunk-TZFZ5WJX.js";
207
+
208
+ // src/reolink/baichuan/HlsSessionManager.ts
209
+ var withTimeout = async (p, ms, label) => {
210
+ let t;
211
+ try {
212
+ return await Promise.race([
213
+ p,
214
+ new Promise((_, reject) => {
215
+ t = setTimeout(
216
+ () => reject(new Error(`${label} timed out after ${ms}ms`)),
217
+ ms
218
+ );
219
+ })
220
+ ]);
221
+ } finally {
222
+ if (t) clearTimeout(t);
223
+ }
224
+ };
225
+ var HlsSessionManager = class {
226
+ constructor(api, options) {
227
+ this.api = api;
228
+ this.logger = options?.logger;
229
+ this.sessionTtlMs = options?.sessionTtlMs ?? 5 * 60 * 1e3;
230
+ const cleanupIntervalMs = options?.cleanupIntervalMs ?? 3e4;
231
+ this.cleanupTimer = setInterval(() => {
232
+ void this.cleanupExpiredSessions();
233
+ }, cleanupIntervalMs);
234
+ }
235
+ sessions = /* @__PURE__ */ new Map();
236
+ logger;
237
+ sessionTtlMs;
238
+ cleanupTimer;
239
+ creationLocks = /* @__PURE__ */ new Map();
240
+ /**
241
+ * Handle an HLS request and return the HTTP response.
242
+ *
243
+ * @param params - Request parameters
244
+ * @returns HTTP response ready to be sent
245
+ */
246
+ async handleRequest(params) {
247
+ const {
248
+ sessionKey,
249
+ hlsPath,
250
+ requestUrl,
251
+ createSession,
252
+ exclusiveKeyPrefix
253
+ } = params;
254
+ try {
255
+ let entry = this.sessions.get(sessionKey);
256
+ const isPlaylist = hlsPath === "playlist.m3u8" || hlsPath === "";
257
+ const isSegment = hlsPath.endsWith(".ts");
258
+ if (!entry && isSegment) {
259
+ this.logger?.debug?.(
260
+ `[HlsSessionManager] Segment request without session (likely stale after clip switch): ${sessionKey} ${hlsPath}`
261
+ );
262
+ return {
263
+ statusCode: 404,
264
+ headers: {
265
+ "Content-Type": "text/plain",
266
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
267
+ Pragma: "no-cache",
268
+ "Retry-After": "1"
269
+ },
270
+ body: "Segment not found"
271
+ };
272
+ }
273
+ if (!entry) {
274
+ if (!isPlaylist) {
275
+ return {
276
+ statusCode: 400,
277
+ headers: { "Content-Type": "text/plain" },
278
+ body: "Invalid HLS path"
279
+ };
280
+ }
281
+ const lockKey = exclusiveKeyPrefix ?? sessionKey;
282
+ await this.withCreationLock(lockKey, async () => {
283
+ entry = this.sessions.get(sessionKey);
284
+ if (entry) return;
285
+ if (exclusiveKeyPrefix) {
286
+ await this.stopOtherSessionsWithPrefix(
287
+ exclusiveKeyPrefix,
288
+ sessionKey
289
+ );
290
+ }
291
+ this.logger?.log?.(
292
+ `[HlsSessionManager] Creating new session: ${sessionKey}`
293
+ );
294
+ this.logger?.debug?.(
295
+ `[HlsSessionManager] createSession(): ${sessionKey}`
296
+ );
297
+ const sessionParams = await createSession();
298
+ this.logger?.debug?.(
299
+ `[HlsSessionManager] Starting createRecordingReplayHlsSession: ${sessionKey}`
300
+ );
301
+ const session = await withTimeout(
302
+ this.api.createRecordingReplayHlsSession({
303
+ channel: sessionParams.channel,
304
+ fileName: sessionParams.fileName,
305
+ ...sessionParams.isNvr !== void 0 && {
306
+ isNvr: sessionParams.isNvr
307
+ },
308
+ ...this.logger && { logger: this.logger },
309
+ ...sessionParams.deviceId && {
310
+ deviceId: sessionParams.deviceId
311
+ },
312
+ transcodeH265ToH264: sessionParams.transcodeH265ToH264 ?? true,
313
+ hlsSegmentDuration: sessionParams.hlsSegmentDuration ?? 4
314
+ }),
315
+ 2e4,
316
+ "createRecordingReplayHlsSession"
317
+ );
318
+ try {
319
+ await withTimeout(
320
+ session.waitForReady(),
321
+ 12e3,
322
+ "hls waitForReady"
323
+ );
324
+ } catch (e) {
325
+ this.logger?.warn?.(
326
+ `[HlsSessionManager] waitForReady did not complete in time for ${sessionKey}: ${e instanceof Error ? e.message : String(e)}`
327
+ );
328
+ }
329
+ entry = {
330
+ session,
331
+ createdAt: Date.now(),
332
+ lastAccessAt: Date.now()
333
+ };
334
+ this.sessions.set(sessionKey, entry);
335
+ this.logger?.log?.(
336
+ `[HlsSessionManager] Session ready: ${sessionKey}`
337
+ );
338
+ });
339
+ entry = this.sessions.get(sessionKey);
340
+ if (!entry) {
341
+ return {
342
+ statusCode: 500,
343
+ headers: {
344
+ "Content-Type": "text/plain",
345
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
346
+ Pragma: "no-cache"
347
+ },
348
+ body: "HLS session was not created"
349
+ };
350
+ }
351
+ }
352
+ entry.lastAccessAt = Date.now();
353
+ if (isPlaylist) {
354
+ return this.servePlaylist(entry.session, requestUrl, sessionKey);
355
+ }
356
+ if (isSegment) {
357
+ return this.serveSegment(entry.session, hlsPath, sessionKey);
358
+ }
359
+ return {
360
+ statusCode: 400,
361
+ headers: { "Content-Type": "text/plain" },
362
+ body: "Invalid HLS path"
363
+ };
364
+ } catch (error) {
365
+ const message = error instanceof Error ? error.message : String(error);
366
+ this.logger?.error?.(
367
+ `[HlsSessionManager] Error handling request: ${message}`
368
+ );
369
+ return {
370
+ statusCode: 500,
371
+ headers: { "Content-Type": "text/plain" },
372
+ body: `HLS error: ${message}`
373
+ };
374
+ }
375
+ }
376
+ async withCreationLock(lockKey, fn) {
377
+ const prev = this.creationLocks.get(lockKey) ?? Promise.resolve();
378
+ let release;
379
+ const current = new Promise((resolve) => {
380
+ release = resolve;
381
+ });
382
+ const chained = prev.then(
383
+ () => current,
384
+ () => current
385
+ );
386
+ this.creationLocks.set(lockKey, chained);
387
+ await prev.catch(() => {
388
+ });
389
+ try {
390
+ await fn();
391
+ } finally {
392
+ release();
393
+ if (this.creationLocks.get(lockKey) === chained) {
394
+ this.creationLocks.delete(lockKey);
395
+ }
396
+ }
397
+ }
398
+ /**
399
+ * Check if a session exists for the given key.
400
+ */
401
+ hasSession(sessionKey) {
402
+ return this.sessions.has(sessionKey);
403
+ }
404
+ /**
405
+ * Stop a specific session.
406
+ */
407
+ async stopSession(sessionKey) {
408
+ const entry = this.sessions.get(sessionKey);
409
+ if (entry) {
410
+ this.logger?.debug?.(
411
+ `[HlsSessionManager] Stopping session: ${sessionKey}`
412
+ );
413
+ this.sessions.delete(sessionKey);
414
+ await entry.session.stop().catch(() => {
415
+ });
416
+ }
417
+ }
418
+ /**
419
+ * Stop all sessions and cleanup.
420
+ */
421
+ async stopAll() {
422
+ this.logger?.debug?.(`[HlsSessionManager] Stopping all sessions`);
423
+ if (this.cleanupTimer) {
424
+ clearInterval(this.cleanupTimer);
425
+ this.cleanupTimer = void 0;
426
+ }
427
+ const stopPromises = Array.from(this.sessions.values()).map(
428
+ (entry) => entry.session.stop().catch(() => {
429
+ })
430
+ );
431
+ this.sessions.clear();
432
+ await Promise.all(stopPromises);
433
+ }
434
+ /**
435
+ * Get the number of active sessions.
436
+ */
437
+ get sessionCount() {
438
+ return this.sessions.size;
439
+ }
440
+ /**
441
+ * Serve the HLS playlist with rewritten segment URLs.
442
+ */
443
+ servePlaylist(session, requestUrl, sessionKey) {
444
+ let playlist = session.getPlaylist();
445
+ try {
446
+ const url = new URL(requestUrl, "http://localhost");
447
+ const basePath = url.pathname;
448
+ const baseParams = new URLSearchParams(url.searchParams);
449
+ baseParams.delete("hls");
450
+ playlist = playlist.replace(/^(segment_\d+\.ts)$/gm, (match) => {
451
+ const params = new URLSearchParams(baseParams);
452
+ params.set("hls", match);
453
+ return `${basePath}?${params.toString()}`;
454
+ });
455
+ } catch {
456
+ }
457
+ this.logger?.debug?.(
458
+ `[HlsSessionManager] Serving playlist: ${sessionKey}, length=${playlist.length}`
459
+ );
460
+ return {
461
+ statusCode: 200,
462
+ headers: {
463
+ "Content-Type": "application/vnd.apple.mpegurl",
464
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
465
+ Pragma: "no-cache"
466
+ },
467
+ body: playlist
468
+ };
469
+ }
470
+ /**
471
+ * Serve an HLS segment.
472
+ */
473
+ serveSegment(session, segmentName, sessionKey) {
474
+ const segment = session.getSegment(segmentName);
475
+ if (!segment) {
476
+ this.logger?.warn?.(
477
+ `[HlsSessionManager] Segment not found: ${segmentName}`
478
+ );
479
+ return {
480
+ statusCode: 404,
481
+ headers: {
482
+ "Content-Type": "text/plain",
483
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
484
+ Pragma: "no-cache",
485
+ "Retry-After": "1"
486
+ },
487
+ body: "Segment not found"
488
+ };
489
+ }
490
+ this.logger?.debug?.(
491
+ `[HlsSessionManager] Serving segment: ${segmentName} for ${sessionKey}, size=${segment.length}`
492
+ );
493
+ return {
494
+ statusCode: 200,
495
+ headers: {
496
+ "Content-Type": "video/mp2t",
497
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
498
+ Pragma: "no-cache",
499
+ "Content-Length": String(segment.length)
500
+ },
501
+ body: segment
502
+ };
503
+ }
504
+ /**
505
+ * Cleanup expired sessions.
506
+ */
507
+ async cleanupExpiredSessions() {
508
+ const now = Date.now();
509
+ const expiredKeys = [];
510
+ for (const [key, entry] of this.sessions) {
511
+ if (now - entry.lastAccessAt > this.sessionTtlMs) {
512
+ expiredKeys.push(key);
513
+ }
514
+ }
515
+ if (!expiredKeys.length) return;
516
+ await Promise.allSettled(
517
+ expiredKeys.map(async (key) => {
518
+ const entry = this.sessions.get(key);
519
+ if (!entry) return;
520
+ this.logger?.log?.(
521
+ `[HlsSessionManager] TTL expired: stopping session ${key}`
522
+ );
523
+ this.sessions.delete(key);
524
+ try {
525
+ await entry.session.stop();
526
+ } catch {
527
+ }
528
+ })
529
+ );
530
+ }
531
+ async stopOtherSessionsWithPrefix(prefix, exceptKey) {
532
+ const toStop = [];
533
+ for (const key of this.sessions.keys()) {
534
+ if (key !== exceptKey && key.startsWith(prefix)) toStop.push(key);
535
+ }
536
+ if (!toStop.length) return;
537
+ this.logger?.log?.(
538
+ `[HlsSessionManager] Switch: stopping ${toStop.length} session(s) for prefix=${prefix}`
539
+ );
540
+ await Promise.all(
541
+ toStop.map(async (key) => {
542
+ const entry = this.sessions.get(key);
543
+ if (!entry) return;
544
+ this.sessions.delete(key);
545
+ await entry.session.stop().catch(() => {
546
+ });
547
+ })
548
+ );
549
+ }
550
+ };
551
+ function detectIosClient(userAgent) {
552
+ const ua = (userAgent ?? "").toLowerCase();
553
+ const isIos = /iphone|ipad|ipod/.test(ua);
554
+ const isIosInstalledApp = ua.includes("installedapp");
555
+ return {
556
+ isIos,
557
+ isIosInstalledApp,
558
+ // iOS InstalledApp needs HLS for video playback
559
+ needsHls: isIos && isIosInstalledApp
560
+ };
561
+ }
562
+ function buildHlsRedirectUrl(originalUrl) {
563
+ return `${originalUrl}${originalUrl.includes("?") ? "&" : "?"}hls=playlist.m3u8`;
564
+ }
207
565
 
208
566
  // src/reolink/AutodiscoveryClient.ts
209
567
  var AutodiscoveryClient = class {
@@ -576,6 +934,13 @@ function parseIntParam(v, def) {
576
934
  const n = Number.parseInt(v, 10);
577
935
  return Number.isFinite(n) ? n : def;
578
936
  }
937
+ function parseBoolParam(v, def) {
938
+ if (v == null) return def;
939
+ const s = v.trim().toLowerCase();
940
+ if (s === "1" || s === "true" || s === "yes" || s === "y") return true;
941
+ if (s === "0" || s === "false" || s === "no" || s === "n") return false;
942
+ return def;
943
+ }
579
944
  function parseProfile(v) {
580
945
  const p = (v ?? "sub").trim();
581
946
  if (p === "main" || p === "sub" || p === "ext") return p;
@@ -606,6 +971,11 @@ function createBaichuanEndpointsServer(opts) {
606
971
  const api = new ReolinkBaichuanApi({
607
972
  ...opts.baichuan
608
973
  });
974
+ const hlsManager = new HlsSessionManager(api, {
975
+ logger: console,
976
+ sessionTtlMs: 6e4,
977
+ cleanupIntervalMs: 5e3
978
+ });
609
979
  const listenHost = opts.listenHost ?? "127.0.0.1";
610
980
  const rtspListenHost = opts.rtspListenHost ?? "127.0.0.1";
611
981
  const rtspServers = /* @__PURE__ */ new Map();
@@ -651,6 +1021,46 @@ function createBaichuanEndpointsServer(opts) {
651
1021
  res.end(JSON.stringify({ rtspUrl }));
652
1022
  return;
653
1023
  }
1024
+ if (u.pathname === "/hls") {
1025
+ const channel = parseIntParam(u.searchParams.get("channel"), 0);
1026
+ const fileName = (u.searchParams.get("fileName") ?? "").trim();
1027
+ const deviceId = (u.searchParams.get("deviceId") ?? "anon").trim();
1028
+ const isNvr = parseBoolParam(u.searchParams.get("isNvr"), false);
1029
+ const transcode = parseBoolParam(u.searchParams.get("transcode"), true);
1030
+ const hlsSegmentDuration = parseIntParam(
1031
+ u.searchParams.get("hlsSegmentDuration"),
1032
+ 2
1033
+ );
1034
+ const hlsPath = (u.searchParams.get("hls") ?? "playlist.m3u8").trim();
1035
+ if (!fileName) {
1036
+ res.statusCode = 400;
1037
+ res.end("Missing fileName");
1038
+ return;
1039
+ }
1040
+ const sessionKey = `hls:${deviceId}:ch${channel}:${fileName}`;
1041
+ const exclusiveKeyPrefix = `hls:${deviceId}:ch${channel}:`;
1042
+ const requestUrl = `http://${listenHost}:${opts.listenPort}${u.pathname}${u.search}`;
1043
+ const result = await hlsManager.handleRequest({
1044
+ sessionKey,
1045
+ hlsPath,
1046
+ requestUrl,
1047
+ exclusiveKeyPrefix,
1048
+ createSession: () => ({
1049
+ channel,
1050
+ fileName,
1051
+ isNvr,
1052
+ deviceId,
1053
+ transcodeH265ToH264: transcode,
1054
+ hlsSegmentDuration
1055
+ })
1056
+ });
1057
+ res.statusCode = result.statusCode;
1058
+ for (const [k, v] of Object.entries(result.headers)) {
1059
+ res.setHeader(k, v);
1060
+ }
1061
+ res.end(result.body);
1062
+ return;
1063
+ }
654
1064
  if (u.pathname === "/download") {
655
1065
  const channel = parseIntParam(u.searchParams.get("channel"), 0);
656
1066
  const uid = (u.searchParams.get("uid") ?? "").trim();
@@ -4609,9 +5019,9 @@ var BaichuanMjpegServer = class extends EventEmitter4 {
4609
5019
  this.started = true;
4610
5020
  const port = this.options.port ?? 8080;
4611
5021
  const host = this.options.host ?? "0.0.0.0";
4612
- const path = this.options.path ?? "/mjpeg";
5022
+ const path2 = this.options.path ?? "/mjpeg";
4613
5023
  this.httpServer = http5.createServer((req, res) => {
4614
- this.handleRequest(req, res, path);
5024
+ this.handleRequest(req, res, path2);
4615
5025
  });
4616
5026
  return new Promise((resolve, reject) => {
4617
5027
  this.httpServer.on("error", (err) => {
@@ -4621,9 +5031,9 @@ var BaichuanMjpegServer = class extends EventEmitter4 {
4621
5031
  this.httpServer.listen(port, host, () => {
4622
5032
  this.log(
4623
5033
  "info",
4624
- `MJPEG server started on http://${host}:${port}${path}`
5034
+ `MJPEG server started on http://${host}:${port}${path2}`
4625
5035
  );
4626
- this.emit("started", { host, port, path });
5036
+ this.emit("started", { host, port, path: path2 });
4627
5037
  resolve();
4628
5038
  });
4629
5039
  });
@@ -4990,10 +5400,16 @@ Error: ${err}`
4990
5400
  }
4991
5401
  };
4992
5402
  this.sessions.set(sessionId, session);
4993
- const videoTrack = new MediaStreamTrack({ kind: "video" });
4994
- peerConnection.addTrack(videoTrack);
5403
+ const videoSsrc = Math.random() * 4294967295 >>> 0;
5404
+ const videoTrack = new MediaStreamTrack({ kind: "video", ssrc: videoSsrc });
5405
+ const videoSender = peerConnection.addTrack(videoTrack);
4995
5406
  session.videoTrack = videoTrack;
4996
- const audioTrack = new MediaStreamTrack({ kind: "audio" });
5407
+ this.log(
5408
+ "info",
5409
+ `Video track created: ssrc=${videoTrack.ssrc}, sender params=${JSON.stringify(videoSender?.getParameters?.() ?? {})}`
5410
+ );
5411
+ const audioSsrc = Math.random() * 4294967295 >>> 0;
5412
+ const audioTrack = new MediaStreamTrack({ kind: "audio", ssrc: audioSsrc });
4997
5413
  peerConnection.addTrack(audioTrack);
4998
5414
  session.audioTrack = audioTrack;
4999
5415
  const videoDataChannel = peerConnection.createDataChannel("video", {
@@ -5037,11 +5453,11 @@ Error: ${err}`
5037
5453
  };
5038
5454
  }
5039
5455
  peerConnection.iceConnectionStateChange.subscribe((state) => {
5040
- this.log("debug", `ICE connection state for ${sessionId}: ${state}`);
5456
+ this.log("info", `ICE connection state for ${sessionId}: ${state}`);
5041
5457
  if (state === "connected") {
5042
5458
  session.state = "connected";
5043
5459
  this.emit("session-connected", { sessionId });
5044
- } else if (state === "disconnected" || state === "failed") {
5460
+ } else if (state === "failed") {
5045
5461
  session.state = state;
5046
5462
  this.closeSession(sessionId).catch((err) => {
5047
5463
  this.log("error", `Error closing session on ICE ${state}: ${err}`);
@@ -5261,7 +5677,8 @@ Error: ${err}`
5261
5677
  } else {
5262
5678
  if (frame.data) {
5263
5679
  if (!session.videoCodec && frame.videoType) {
5264
- session.videoCodec = frame.videoType;
5680
+ const detected = detectVideoCodecFromNal(frame.data);
5681
+ session.videoCodec = detected ?? frame.videoType;
5265
5682
  this.log("info", `Detected video codec: ${session.videoCodec}`);
5266
5683
  if (session.videoDataChannel && session.videoDataChannel.readyState === "open") {
5267
5684
  const codecInfo = JSON.stringify({
@@ -5284,22 +5701,45 @@ Error: ${err}`
5284
5701
  }
5285
5702
  lastTimeMicros = frame.microseconds || 0;
5286
5703
  if (session.videoCodec === "H264") {
5287
- await this.sendH264Frame(
5704
+ const connState = session.peerConnection.connectionState;
5705
+ const iceState = session.peerConnection.iceConnectionState;
5706
+ const isConnected = connState === "connected" || iceState === "connected" || iceState === "completed";
5707
+ if (!isConnected) {
5708
+ if (frameNumber < 10) {
5709
+ this.log(
5710
+ "debug",
5711
+ `Waiting for connection, dropping H.264 frame ${frameNumber}`
5712
+ );
5713
+ }
5714
+ frameNumber++;
5715
+ continue;
5716
+ }
5717
+ const packetsSent = await this.sendH264Frame(
5288
5718
  session,
5289
5719
  werift,
5290
5720
  frame.data,
5291
5721
  sequenceNumber,
5292
5722
  timestamp
5293
5723
  );
5294
- sequenceNumber = sequenceNumber + Math.ceil(frame.data.length / 1200) & 65535;
5295
- packetsSentSinceLastLog++;
5724
+ sequenceNumber = sequenceNumber + packetsSent & 65535;
5725
+ packetsSentSinceLastLog += packetsSent;
5726
+ frameNumber++;
5727
+ session.stats.videoFrames++;
5728
+ session.stats.bytesSent += frame.data.length;
5296
5729
  } else if (session.videoCodec === "H265") {
5297
- await this.sendH265Frame(session, frame, frameNumber);
5298
- packetsSentSinceLastLog++;
5730
+ const sent = await this.sendVideoFrameViaDataChannel(
5731
+ session,
5732
+ frame,
5733
+ frameNumber,
5734
+ "H265"
5735
+ );
5736
+ if (sent) {
5737
+ packetsSentSinceLastLog++;
5738
+ frameNumber++;
5739
+ session.stats.videoFrames++;
5740
+ session.stats.bytesSent += frame.data.length;
5741
+ }
5299
5742
  }
5300
- frameNumber++;
5301
- session.stats.videoFrames++;
5302
- session.stats.bytesSent += frame.data.length;
5303
5743
  const now = Date.now();
5304
5744
  if (now - lastLogTime >= 5e3) {
5305
5745
  this.log(
@@ -5322,27 +5762,312 @@ Error: ${err}`
5322
5762
  }
5323
5763
  /**
5324
5764
  * Send H.264 frame via RTP media track
5765
+ * Returns the number of RTP packets sent
5325
5766
  */
5326
5767
  async sendH264Frame(session, werift, frameData, sequenceNumber, timestamp) {
5327
- const nalUnits = parseAnnexBNalUnits(frameData);
5328
- for (let i = 0; i < nalUnits.length; i++) {
5329
- const nalUnit = nalUnits[i];
5768
+ const annexB = convertToAnnexB(frameData);
5769
+ const nalUnits = splitAnnexBToNalPayloads(annexB);
5770
+ let hasSps = false;
5771
+ let hasPps = false;
5772
+ let hasIdr = false;
5773
+ const nalTypes = [];
5774
+ for (const nal of nalUnits) {
5775
+ const t = (nal[0] ?? 0) & 31;
5776
+ nalTypes.push(t);
5777
+ if (t === 7) {
5778
+ hasSps = true;
5779
+ session.lastH264Sps = nal;
5780
+ }
5781
+ if (t === 8) {
5782
+ hasPps = true;
5783
+ session.lastH264Pps = nal;
5784
+ }
5785
+ if (t === 5) hasIdr = true;
5786
+ }
5787
+ if (session.stats.videoFrames < 10) {
5788
+ this.log(
5789
+ "debug",
5790
+ `H.264 frame NAL types: [${nalTypes.join(",")}] (5=IDR, 7=SPS, 8=PPS, 1=P-slice)`
5791
+ );
5792
+ }
5793
+ const isKeyframe = hasIdr;
5794
+ let nalList = nalUnits;
5795
+ if (hasIdr && (!hasSps || !hasPps)) {
5796
+ const prepend = [];
5797
+ if (!hasSps && session.lastH264Sps) {
5798
+ prepend.push(session.lastH264Sps);
5799
+ this.log("debug", `Prepending cached SPS to IDR frame`);
5800
+ }
5801
+ if (!hasPps && session.lastH264Pps) {
5802
+ prepend.push(session.lastH264Pps);
5803
+ this.log("debug", `Prepending cached PPS to IDR frame`);
5804
+ }
5805
+ if (prepend.length > 0) {
5806
+ nalList = [...prepend, ...nalUnits];
5807
+ } else if (!session.lastH264Sps || !session.lastH264Pps) {
5808
+ this.log(
5809
+ "warn",
5810
+ `IDR frame without SPS/PPS and no cached parameters - frame may not decode`
5811
+ );
5812
+ }
5813
+ }
5814
+ if (!session.hasReceivedKeyframe) {
5815
+ if (hasIdr && session.lastH264Sps && session.lastH264Pps) {
5816
+ session.hasReceivedKeyframe = true;
5817
+ this.log(
5818
+ "info",
5819
+ `First H.264 keyframe received with SPS/PPS - starting video stream`
5820
+ );
5821
+ } else if (hasIdr) {
5822
+ this.log(
5823
+ "debug",
5824
+ `IDR received but waiting for SPS/PPS before starting stream`
5825
+ );
5826
+ return 0;
5827
+ } else {
5828
+ if (session.stats.videoFrames < 5) {
5829
+ this.log(
5830
+ "debug",
5831
+ `Dropping P-frame ${session.stats.videoFrames} while waiting for keyframe`
5832
+ );
5833
+ }
5834
+ return 0;
5835
+ }
5836
+ }
5837
+ let totalPacketsSent = 0;
5838
+ let currentSeqNum = sequenceNumber;
5839
+ const ssrc = session.videoTrack.ssrc || 0;
5840
+ for (let i = 0; i < nalList.length; i++) {
5841
+ const nalUnit = nalList[i];
5330
5842
  if (nalUnit.length === 0) continue;
5331
- const isLastNalu = i === nalUnits.length - 1;
5843
+ const isLastNalu = i === nalList.length - 1;
5332
5844
  const nalType = getH264NalType(nalUnit);
5333
5845
  if (nalType === 9) continue;
5334
5846
  const rtpPackets = this.createH264RtpPackets(
5335
5847
  werift,
5336
5848
  nalUnit,
5337
- sequenceNumber,
5849
+ currentSeqNum,
5338
5850
  timestamp,
5339
- isLastNalu
5851
+ isLastNalu,
5852
+ ssrc
5340
5853
  );
5854
+ if (session.stats.videoFrames < 3) {
5855
+ this.log(
5856
+ "info",
5857
+ `NAL ${i}: type=${nalType}, size=${nalUnit.length}, rtpPackets=${rtpPackets.length}`
5858
+ );
5859
+ }
5341
5860
  for (const rtpPacket of rtpPackets) {
5342
- session.videoTrack.writeRtp(rtpPacket);
5343
- sequenceNumber = sequenceNumber + 1 & 65535;
5861
+ try {
5862
+ session.videoTrack.writeRtp(rtpPacket);
5863
+ currentSeqNum = currentSeqNum + 1 & 65535;
5864
+ totalPacketsSent++;
5865
+ } catch (err) {
5866
+ this.log(
5867
+ "error",
5868
+ `Error writing RTP packet for session ${session.id}: ${err}`
5869
+ );
5870
+ }
5344
5871
  }
5345
5872
  }
5873
+ if (session.stats.videoFrames < 3) {
5874
+ this.log(
5875
+ "info",
5876
+ `H.264 frame sent: nalCount=${nalList.length} packets=${totalPacketsSent} seq=${sequenceNumber}->${currentSeqNum} ts=${timestamp} keyframe=${isKeyframe}`
5877
+ );
5878
+ }
5879
+ return totalPacketsSent;
5880
+ }
5881
+ /**
5882
+ * Send video frame via DataChannel (works for both H.264 and H.265)
5883
+ * Format: 12-byte header + Annex-B data
5884
+ * Header: [frameNum (4)] [timestamp (4)] [flags (1)] [keyframe (1)] [reserved (2)]
5885
+ * Flags: 0x01 = H.265, 0x02 = H.264
5886
+ */
5887
+ async sendVideoFrameViaDataChannel(session, frame, frameNumber, codec) {
5888
+ if (!session.videoDataChannel) {
5889
+ if (frameNumber === 0) {
5890
+ this.log("warn", `No video data channel for session ${session.id}`);
5891
+ }
5892
+ return false;
5893
+ }
5894
+ if (session.videoDataChannel.readyState !== "open") {
5895
+ if (frameNumber === 0) {
5896
+ this.log(
5897
+ "warn",
5898
+ `Video data channel not open for session ${session.id}: ${session.videoDataChannel.readyState}`
5899
+ );
5900
+ }
5901
+ return false;
5902
+ }
5903
+ const nalUnits = parseAnnexBNalUnits(frame.data);
5904
+ let isKeyframe = frame.isKeyframe === true;
5905
+ let hasIdr = false;
5906
+ let hasSps = false;
5907
+ let hasPps = false;
5908
+ let hasVps = false;
5909
+ const nalTypes = [];
5910
+ for (const nalUnit of nalUnits) {
5911
+ if (nalUnit.length === 0) continue;
5912
+ if (codec === "H265") {
5913
+ const nalType = getH265NalType2(nalUnit);
5914
+ nalTypes.push(nalType);
5915
+ if (nalType === 32) {
5916
+ hasVps = true;
5917
+ session.lastH265Vps = nalUnit;
5918
+ }
5919
+ if (nalType === 33) {
5920
+ hasSps = true;
5921
+ session.lastH265Sps = nalUnit;
5922
+ }
5923
+ if (nalType === 34) {
5924
+ hasPps = true;
5925
+ session.lastH265Pps = nalUnit;
5926
+ }
5927
+ if (nalType === 19 || nalType === 20) {
5928
+ hasIdr = true;
5929
+ isKeyframe = true;
5930
+ }
5931
+ } else {
5932
+ const nalType = getH264NalType(nalUnit);
5933
+ nalTypes.push(nalType);
5934
+ if (nalType === 7) {
5935
+ hasSps = true;
5936
+ session.lastH264Sps = nalUnit;
5937
+ }
5938
+ if (nalType === 8) {
5939
+ hasPps = true;
5940
+ session.lastH264Pps = nalUnit;
5941
+ }
5942
+ if (nalType === 5) {
5943
+ hasIdr = true;
5944
+ isKeyframe = true;
5945
+ }
5946
+ }
5947
+ }
5948
+ if (frameNumber < 5) {
5949
+ this.log(
5950
+ "debug",
5951
+ `${codec} frame ${frameNumber} NAL types: [${nalTypes.join(",")}] hasIdr=${hasIdr} hasSps=${hasSps} hasPps=${hasPps}`
5952
+ );
5953
+ }
5954
+ if (!session.hasReceivedKeyframe) {
5955
+ if (codec === "H264") {
5956
+ if (hasIdr && session.lastH264Sps && session.lastH264Pps) {
5957
+ session.hasReceivedKeyframe = true;
5958
+ this.log(
5959
+ "info",
5960
+ `First H.264 keyframe received with SPS/PPS - starting video stream`
5961
+ );
5962
+ } else if (hasSps || hasPps) {
5963
+ this.log("debug", `Received H.264 parameter sets, waiting for IDR`);
5964
+ return false;
5965
+ } else if (hasIdr) {
5966
+ this.log("debug", `IDR received but waiting for SPS/PPS`);
5967
+ return false;
5968
+ } else {
5969
+ if (frameNumber < 10) {
5970
+ this.log(
5971
+ "debug",
5972
+ `Dropping H.264 P-frame ${frameNumber} while waiting for keyframe`
5973
+ );
5974
+ }
5975
+ return false;
5976
+ }
5977
+ } else {
5978
+ if (hasIdr && session.lastH265Vps && session.lastH265Sps && session.lastH265Pps) {
5979
+ session.hasReceivedKeyframe = true;
5980
+ this.log(
5981
+ "info",
5982
+ `First H.265 keyframe received with VPS/SPS/PPS - starting video stream`
5983
+ );
5984
+ } else if (hasVps || hasSps || hasPps) {
5985
+ this.log("debug", `Received H.265 parameter sets, waiting for IDR`);
5986
+ return false;
5987
+ } else if (hasIdr) {
5988
+ this.log("debug", `H.265 IDR received but waiting for VPS/SPS/PPS`);
5989
+ return false;
5990
+ } else {
5991
+ if (frameNumber < 10) {
5992
+ this.log(
5993
+ "debug",
5994
+ `Dropping H.265 P-frame ${frameNumber} while waiting for keyframe`
5995
+ );
5996
+ }
5997
+ return false;
5998
+ }
5999
+ }
6000
+ }
6001
+ let frameData = frame.data;
6002
+ if (hasIdr) {
6003
+ if (codec === "H264" && (!hasSps || !hasPps)) {
6004
+ const parts = [];
6005
+ if (!hasSps && session.lastH264Sps) {
6006
+ parts.push(Buffer.from([0, 0, 0, 1]));
6007
+ parts.push(session.lastH264Sps);
6008
+ }
6009
+ if (!hasPps && session.lastH264Pps) {
6010
+ parts.push(Buffer.from([0, 0, 0, 1]));
6011
+ parts.push(session.lastH264Pps);
6012
+ }
6013
+ if (parts.length > 0) {
6014
+ frameData = Buffer.concat([...parts, frame.data]);
6015
+ this.log("debug", `Prepended cached SPS/PPS to H.264 IDR frame`);
6016
+ }
6017
+ } else if (codec === "H265" && (!hasVps || !hasSps || !hasPps)) {
6018
+ const parts = [];
6019
+ if (!hasVps && session.lastH265Vps) {
6020
+ parts.push(Buffer.from([0, 0, 0, 1]));
6021
+ parts.push(session.lastH265Vps);
6022
+ }
6023
+ if (!hasSps && session.lastH265Sps) {
6024
+ parts.push(Buffer.from([0, 0, 0, 1]));
6025
+ parts.push(session.lastH265Sps);
6026
+ }
6027
+ if (!hasPps && session.lastH265Pps) {
6028
+ parts.push(Buffer.from([0, 0, 0, 1]));
6029
+ parts.push(session.lastH265Pps);
6030
+ }
6031
+ if (parts.length > 0) {
6032
+ frameData = Buffer.concat([...parts, frame.data]);
6033
+ this.log("debug", `Prepended cached VPS/SPS/PPS to H.265 IDR frame`);
6034
+ }
6035
+ }
6036
+ }
6037
+ const header = Buffer.alloc(12);
6038
+ header.writeUInt32BE(frameNumber, 0);
6039
+ header.writeUInt32BE(frame.microseconds ? frame.microseconds / 1e3 : 0, 4);
6040
+ header.writeUInt8(codec === "H265" ? 1 : 2, 8);
6041
+ header.writeUInt8(isKeyframe ? 1 : 0, 9);
6042
+ header.writeUInt16BE(0, 10);
6043
+ const packet = Buffer.concat([header, frameData]);
6044
+ if (frameNumber < 3) {
6045
+ this.log(
6046
+ "info",
6047
+ `Sending ${codec} frame ${frameNumber}: ${packet.length} bytes, keyframe=${isKeyframe}`
6048
+ );
6049
+ }
6050
+ const MAX_CHUNK_SIZE = 16e3;
6051
+ try {
6052
+ if (packet.length <= MAX_CHUNK_SIZE) {
6053
+ session.videoDataChannel.send(packet);
6054
+ } else {
6055
+ const totalChunks = Math.ceil(packet.length / MAX_CHUNK_SIZE);
6056
+ for (let i = 0; i < totalChunks; i++) {
6057
+ const start = i * MAX_CHUNK_SIZE;
6058
+ const end = Math.min(start + MAX_CHUNK_SIZE, packet.length);
6059
+ const chunk = packet.subarray(start, end);
6060
+ const chunkHeader = Buffer.alloc(2);
6061
+ chunkHeader.writeUInt8(i, 0);
6062
+ chunkHeader.writeUInt8(totalChunks, 1);
6063
+ session.videoDataChannel.send(Buffer.concat([chunkHeader, chunk]));
6064
+ }
6065
+ }
6066
+ return true;
6067
+ } catch (err) {
6068
+ this.log("error", `Error sending ${codec} frame ${frameNumber}: ${err}`);
6069
+ return false;
6070
+ }
5346
6071
  }
5347
6072
  /**
5348
6073
  * Send H.265 frame via DataChannel
@@ -5414,7 +6139,7 @@ Error: ${err}`
5414
6139
  * Create RTP packets for H.264 NAL unit
5415
6140
  * Handles single NAL, STAP-A aggregation, and FU-A fragmentation
5416
6141
  */
5417
- createH264RtpPackets(werift, nalUnit, sequenceNumber, timestamp, marker) {
6142
+ createH264RtpPackets(werift, nalUnit, sequenceNumber, timestamp, marker, ssrc) {
5418
6143
  const { RtpPacket, RtpHeader } = werift;
5419
6144
  const MTU = 1200;
5420
6145
  const packets = [];
@@ -5424,6 +6149,7 @@ Error: ${err}`
5424
6149
  header.sequenceNumber = sequenceNumber;
5425
6150
  header.timestamp = timestamp;
5426
6151
  header.marker = marker;
6152
+ header.ssrc = ssrc;
5427
6153
  packets.push(new RtpPacket(header, nalUnit));
5428
6154
  } else {
5429
6155
  const nalHeader = nalUnit[0];
@@ -5448,6 +6174,7 @@ Error: ${err}`
5448
6174
  header.sequenceNumber = sequenceNumber + packets.length & 65535;
5449
6175
  header.timestamp = timestamp;
5450
6176
  header.marker = isLast && marker;
6177
+ header.ssrc = ssrc;
5451
6178
  packets.push(new RtpPacket(header, fuPayload));
5452
6179
  offset += chunkSize;
5453
6180
  isFirst = false;
@@ -5484,11 +6211,450 @@ Error: ${err}`
5484
6211
  }
5485
6212
  };
5486
6213
 
5487
- // src/multifocal/compositeRtspServer.ts
6214
+ // src/baichuan/stream/BaichuanHlsServer.ts
5488
6215
  import { EventEmitter as EventEmitter6 } from "events";
6216
+ import fs from "fs";
6217
+ import fsp from "fs/promises";
6218
+ import os from "os";
6219
+ import path from "path";
5489
6220
  import { spawn as spawn7 } from "child_process";
6221
+ function parseAnnexBNalUnits2(data) {
6222
+ const units = [];
6223
+ const len = data.length;
6224
+ const findStart = (from) => {
6225
+ for (let i = from; i + 3 < len; i++) {
6226
+ if (data[i] === 0 && data[i + 1] === 0) {
6227
+ if (data[i + 2] === 1) return i;
6228
+ if (i + 4 < len && data[i + 2] === 0 && data[i + 3] === 1)
6229
+ return i;
6230
+ }
6231
+ }
6232
+ return -1;
6233
+ };
6234
+ const startCodeLenAt = (i) => {
6235
+ if (i + 3 < len && data[i] === 0 && data[i + 1] === 0) {
6236
+ if (data[i + 2] === 1) return 3;
6237
+ if (i + 4 < len && data[i + 2] === 0 && data[i + 3] === 1) return 4;
6238
+ }
6239
+ return 0;
6240
+ };
6241
+ let start = findStart(0);
6242
+ if (start < 0) return units;
6243
+ while (start >= 0) {
6244
+ const scLen = startCodeLenAt(start);
6245
+ if (!scLen) break;
6246
+ const nalStart = start + scLen;
6247
+ let next = findStart(nalStart);
6248
+ if (next < 0) next = len;
6249
+ if (nalStart < next) units.push(data.subarray(nalStart, next));
6250
+ start = next < len ? next : -1;
6251
+ }
6252
+ return units;
6253
+ }
6254
+ function isKeyframeAnnexB(codec, annexB) {
6255
+ const nals = parseAnnexBNalUnits2(annexB);
6256
+ for (const nal of nals) {
6257
+ if (!nal || nal.length === 0) continue;
6258
+ if (codec === "h264") {
6259
+ const nalType = nal[0] & 31;
6260
+ if (nalType === 5) return true;
6261
+ } else {
6262
+ const nalType = nal[0] >> 1 & 63;
6263
+ if (nalType >= 16 && nalType <= 21) return true;
6264
+ }
6265
+ }
6266
+ return false;
6267
+ }
6268
+ function hasParamSets(codec, annexB) {
6269
+ const nals = parseAnnexBNalUnits2(annexB);
6270
+ for (const nal of nals) {
6271
+ if (!nal || nal.length === 0) continue;
6272
+ if (codec === "h264") {
6273
+ const nalType = nal[0] & 31;
6274
+ if (nalType === 7 || nalType === 8) return true;
6275
+ } else {
6276
+ const nalType = nal[0] >> 1 & 63;
6277
+ if (nalType === 32 || nalType === 33 || nalType === 34) return true;
6278
+ }
6279
+ }
6280
+ return false;
6281
+ }
6282
+ function getNalTypes(codec, annexB) {
6283
+ const nals = parseAnnexBNalUnits2(annexB);
6284
+ return nals.map((nal) => {
6285
+ if (codec === "h265") {
6286
+ return nal[0] >> 1 & 63;
6287
+ } else {
6288
+ return nal[0] & 31;
6289
+ }
6290
+ });
6291
+ }
6292
+ var BaichuanHlsServer = class extends EventEmitter6 {
6293
+ api;
6294
+ channel;
6295
+ profile;
6296
+ variant;
6297
+ segmentDuration;
6298
+ playlistSize;
6299
+ ffmpegPath;
6300
+ log;
6301
+ outputDir = null;
6302
+ createdTempDir = false;
6303
+ playlistPath = null;
6304
+ segmentPattern = null;
6305
+ state = "idle";
6306
+ codec = null;
6307
+ framesReceived = 0;
6308
+ ffmpeg = null;
6309
+ nativeStream = null;
6310
+ pumpPromise = null;
6311
+ startedAt = null;
6312
+ lastError = null;
6313
+ constructor(options) {
6314
+ super();
6315
+ this.api = options.api;
6316
+ this.channel = options.channel;
6317
+ this.profile = options.profile;
6318
+ this.variant = options.variant ?? void 0;
6319
+ this.segmentDuration = options.segmentDuration ?? 2;
6320
+ this.playlistSize = options.playlistSize ?? 5;
6321
+ this.ffmpegPath = options.ffmpegPath ?? "ffmpeg";
6322
+ if (options.outputDir) {
6323
+ this.outputDir = options.outputDir;
6324
+ this.createdTempDir = false;
6325
+ }
6326
+ this.log = options.logger ?? (() => {
6327
+ });
6328
+ }
6329
+ /**
6330
+ * Start HLS streaming
6331
+ */
6332
+ async start() {
6333
+ if (this.state === "running" || this.state === "starting") {
6334
+ return;
6335
+ }
6336
+ this.state = "starting";
6337
+ this.lastError = null;
6338
+ try {
6339
+ if (!this.outputDir) {
6340
+ this.outputDir = await fsp.mkdtemp(
6341
+ path.join(os.tmpdir(), `nodelink-hls-${this.profile}-`)
6342
+ );
6343
+ this.createdTempDir = true;
6344
+ } else {
6345
+ await fsp.mkdir(this.outputDir, { recursive: true });
6346
+ }
6347
+ this.playlistPath = path.join(this.outputDir, "playlist.m3u8");
6348
+ this.segmentPattern = path.join(this.outputDir, "segment_%05d.ts");
6349
+ this.log("info", `Starting HLS stream to ${this.outputDir}`);
6350
+ this.nativeStream = createNativeStream(
6351
+ this.api,
6352
+ this.channel,
6353
+ this.profile,
6354
+ this.variant ? { variant: this.variant } : void 0
6355
+ );
6356
+ this.pumpPromise = this.pumpNativeToFfmpeg();
6357
+ this.startedAt = /* @__PURE__ */ new Date();
6358
+ this.state = "running";
6359
+ this.emit("started", { outputDir: this.outputDir });
6360
+ } catch (err) {
6361
+ this.state = "error";
6362
+ this.lastError = String(err);
6363
+ this.log("error", `Failed to start HLS: ${err}`);
6364
+ throw err;
6365
+ }
6366
+ }
6367
+ /**
6368
+ * Stop HLS streaming
6369
+ */
6370
+ async stop() {
6371
+ if (this.state === "idle" || this.state === "stopped") {
6372
+ return;
6373
+ }
6374
+ this.state = "stopping";
6375
+ this.log("info", "Stopping HLS stream");
6376
+ try {
6377
+ this.ffmpeg?.stdin?.end();
6378
+ } catch {
6379
+ }
6380
+ try {
6381
+ this.ffmpeg?.kill("SIGKILL");
6382
+ } catch {
6383
+ }
6384
+ this.ffmpeg = null;
6385
+ if (this.nativeStream) {
6386
+ try {
6387
+ await this.nativeStream.return(void 0);
6388
+ } catch {
6389
+ }
6390
+ this.nativeStream = null;
6391
+ }
6392
+ if (this.pumpPromise) {
6393
+ try {
6394
+ await this.pumpPromise;
6395
+ } catch {
6396
+ }
6397
+ this.pumpPromise = null;
6398
+ }
6399
+ if (this.createdTempDir && this.outputDir) {
6400
+ try {
6401
+ await fsp.rm(this.outputDir, { recursive: true, force: true });
6402
+ } catch {
6403
+ }
6404
+ }
6405
+ this.state = "stopped";
6406
+ this.emit("stopped");
6407
+ }
6408
+ /**
6409
+ * Get current status
6410
+ */
6411
+ getStatus() {
6412
+ return {
6413
+ state: this.state,
6414
+ codec: this.codec,
6415
+ framesReceived: this.framesReceived,
6416
+ ffmpegRunning: this.ffmpeg !== null && !this.ffmpeg.killed,
6417
+ playlistPath: this.playlistPath,
6418
+ outputDir: this.outputDir,
6419
+ startedAt: this.startedAt,
6420
+ error: this.lastError
6421
+ };
6422
+ }
6423
+ /**
6424
+ * Get playlist file path
6425
+ */
6426
+ getPlaylistPath() {
6427
+ return this.playlistPath;
6428
+ }
6429
+ /**
6430
+ * Get output directory
6431
+ */
6432
+ getOutputDir() {
6433
+ return this.outputDir;
6434
+ }
6435
+ /**
6436
+ * Check if playlist file exists
6437
+ */
6438
+ async waitForPlaylist(timeoutMs = 2e4) {
6439
+ if (!this.playlistPath) return false;
6440
+ const deadline = Date.now() + timeoutMs;
6441
+ while (Date.now() < deadline) {
6442
+ if (fs.existsSync(this.playlistPath)) {
6443
+ return true;
6444
+ }
6445
+ await new Promise((r) => setTimeout(r, 150));
6446
+ }
6447
+ return false;
6448
+ }
6449
+ /**
6450
+ * Read an HLS asset (playlist or segment)
6451
+ */
6452
+ async readAsset(assetName) {
6453
+ if (!this.outputDir) return null;
6454
+ const safe = assetName.replace(/^\/+/, "");
6455
+ if (safe.includes("..") || safe.includes("/")) {
6456
+ return null;
6457
+ }
6458
+ const filePath = path.join(this.outputDir, safe);
6459
+ if (!fs.existsSync(filePath)) {
6460
+ return null;
6461
+ }
6462
+ const data = await fsp.readFile(filePath);
6463
+ let contentType = "application/octet-stream";
6464
+ if (safe.endsWith(".m3u8")) {
6465
+ contentType = "application/vnd.apple.mpegurl";
6466
+ } else if (safe.endsWith(".ts")) {
6467
+ contentType = "video/mp2t";
6468
+ }
6469
+ return { data, contentType };
6470
+ }
6471
+ // ============================================================================
6472
+ // Private Methods
6473
+ // ============================================================================
6474
+ async pumpNativeToFfmpeg() {
6475
+ if (!this.nativeStream || !this.playlistPath || !this.segmentPattern) {
6476
+ return;
6477
+ }
6478
+ let startedFfmpeg = false;
6479
+ let pendingParamSets = [];
6480
+ const MAX_FRAMES_WAIT_FOR_KEYFRAME = 180;
6481
+ const collectParamSets = (codec, annexB) => {
6482
+ const nals = parseAnnexBNalUnits2(annexB);
6483
+ for (const nal of nals) {
6484
+ if (!nal || nal.length === 0) continue;
6485
+ if (codec === "h264") {
6486
+ const t = nal[0] & 31;
6487
+ if (t === 7 || t === 8) {
6488
+ pendingParamSets.push(
6489
+ Buffer.concat([Buffer.from([0, 0, 0, 1]), nal])
6490
+ );
6491
+ }
6492
+ } else {
6493
+ const t = nal[0] >> 1 & 63;
6494
+ if (t === 32 || t === 33 || t === 34) {
6495
+ pendingParamSets.push(
6496
+ Buffer.concat([Buffer.from([0, 0, 0, 1]), nal])
6497
+ );
6498
+ }
6499
+ }
6500
+ }
6501
+ if (pendingParamSets.length > 12) {
6502
+ pendingParamSets = pendingParamSets.slice(-12);
6503
+ }
6504
+ };
6505
+ try {
6506
+ for await (const frame of this.nativeStream) {
6507
+ if (this.state !== "running") break;
6508
+ if (frame.audio) continue;
6509
+ if (!frame.data || frame.data.length === 0) continue;
6510
+ if (!this.codec) {
6511
+ const detected = detectVideoCodecFromNal(frame.data);
6512
+ const fromMeta = frame.videoType === "H265" ? "h265" : "h264";
6513
+ this.codec = detected ? detected.toLowerCase() : fromMeta;
6514
+ this.log(
6515
+ "info",
6516
+ `HLS codec detected: meta=${fromMeta} detected=${detected} (using ${this.codec})`
6517
+ );
6518
+ this.emit("codec-detected", { codec: this.codec });
6519
+ }
6520
+ const annexB = this.codec === "h265" ? convertToAnnexB2(frame.data) : convertToAnnexB(frame.data);
6521
+ this.framesReceived++;
6522
+ const shouldLog = this.framesReceived <= 5 || this.framesReceived <= 60 && this.framesReceived % 10 === 0;
6523
+ if (shouldLog) {
6524
+ const nalTypes = getNalTypes(this.codec, annexB);
6525
+ const hasIdr = isKeyframeAnnexB(this.codec, annexB);
6526
+ const hasParams = hasParamSets(this.codec, annexB);
6527
+ this.log(
6528
+ "debug",
6529
+ `HLS frame#${this.framesReceived}: bytes=${annexB.length} nalTypes=[${nalTypes.join(",")}] hasIDR=${hasIdr} hasParams=${hasParams}`
6530
+ );
6531
+ }
6532
+ collectParamSets(this.codec, annexB);
6533
+ const isKeyframe = isKeyframeAnnexB(this.codec, annexB);
6534
+ if (!isKeyframe && !startedFfmpeg) {
6535
+ if (this.framesReceived < MAX_FRAMES_WAIT_FOR_KEYFRAME) {
6536
+ continue;
6537
+ }
6538
+ this.log(
6539
+ "warn",
6540
+ `No keyframe after ${this.framesReceived} frames, starting ffmpeg anyway`
6541
+ );
6542
+ }
6543
+ if (!startedFfmpeg) {
6544
+ this.log(
6545
+ "info",
6546
+ `Starting ffmpeg: codec=${this.codec} framesSeen=${this.framesReceived} isKeyframe=${isKeyframe} paramSets=${pendingParamSets.length}`
6547
+ );
6548
+ this.ffmpeg = this.spawnFfmpeg();
6549
+ startedFfmpeg = true;
6550
+ this.emit("ffmpeg-started");
6551
+ try {
6552
+ if (this.ffmpeg?.stdin && !this.ffmpeg.stdin.destroyed) {
6553
+ for (const ps of pendingParamSets) {
6554
+ this.ffmpeg.stdin.write(ps);
6555
+ }
6556
+ }
6557
+ } catch {
6558
+ }
6559
+ }
6560
+ if (!this.ffmpeg || !this.ffmpeg.stdin || this.ffmpeg.stdin.destroyed) {
6561
+ this.log("warn", "ffmpeg stdin not available, stopping pump");
6562
+ break;
6563
+ }
6564
+ try {
6565
+ this.ffmpeg.stdin.write(annexB);
6566
+ if (this.framesReceived % 100 === 0 || this.framesReceived <= 5 || this.framesReceived <= 50 && this.framesReceived % 10 === 0) {
6567
+ this.log(
6568
+ "debug",
6569
+ `HLS fed frame #${this.framesReceived} to ffmpeg (${annexB.length} bytes)`
6570
+ );
6571
+ }
6572
+ } catch (err) {
6573
+ this.log("error", `Failed to write to ffmpeg: ${err}`);
6574
+ break;
6575
+ }
6576
+ }
6577
+ } catch (e) {
6578
+ this.log("error", `HLS pump error: ${e}`);
6579
+ this.lastError = String(e);
6580
+ this.state = "error";
6581
+ this.emit("error", e);
6582
+ }
6583
+ }
6584
+ spawnFfmpeg() {
6585
+ if (!this.playlistPath || !this.segmentPattern) {
6586
+ throw new Error("Playlist path not set");
6587
+ }
6588
+ const codec = this.codec ?? "h264";
6589
+ const args = [
6590
+ "-hide_banner",
6591
+ "-loglevel",
6592
+ "warning",
6593
+ "-fflags",
6594
+ "+genpts",
6595
+ "-use_wallclock_as_timestamps",
6596
+ "1",
6597
+ "-r",
6598
+ "25",
6599
+ "-f",
6600
+ codec === "h265" ? "hevc" : "h264",
6601
+ "-i",
6602
+ "pipe:0"
6603
+ ];
6604
+ if (codec === "h265") {
6605
+ args.push(
6606
+ "-c:v",
6607
+ "libx264",
6608
+ "-preset",
6609
+ "veryfast",
6610
+ "-tune",
6611
+ "zerolatency",
6612
+ "-pix_fmt",
6613
+ "yuv420p"
6614
+ );
6615
+ } else {
6616
+ args.push("-c:v", "copy");
6617
+ }
6618
+ args.push(
6619
+ "-f",
6620
+ "hls",
6621
+ "-hls_time",
6622
+ String(this.segmentDuration),
6623
+ "-hls_list_size",
6624
+ String(this.playlistSize),
6625
+ "-hls_flags",
6626
+ "delete_segments+append_list+omit_endlist",
6627
+ "-hls_segment_filename",
6628
+ this.segmentPattern,
6629
+ this.playlistPath
6630
+ );
6631
+ const p = spawn7(this.ffmpegPath, args, {
6632
+ stdio: ["pipe", "ignore", "pipe"]
6633
+ });
6634
+ p.on("error", (err) => {
6635
+ this.log("error", `ffmpeg spawn error: ${err}`);
6636
+ this.emit("ffmpeg-error", err);
6637
+ });
6638
+ p.stderr?.on("data", (d) => {
6639
+ const s = String(d ?? "").trim();
6640
+ if (s) this.log("warn", `[ffmpeg] ${s}`);
6641
+ });
6642
+ p.on("exit", (code, signal) => {
6643
+ this.log(
6644
+ "warn",
6645
+ `ffmpeg exited (code=${code ?? "?"} signal=${signal ?? "?"})`
6646
+ );
6647
+ this.emit("ffmpeg-exited", { code, signal });
6648
+ });
6649
+ return p;
6650
+ }
6651
+ };
6652
+
6653
+ // src/multifocal/compositeRtspServer.ts
6654
+ import { EventEmitter as EventEmitter7 } from "events";
6655
+ import { spawn as spawn8 } from "child_process";
5490
6656
  import * as net from "net";
5491
- var CompositeRtspServer = class extends EventEmitter6 {
6657
+ var CompositeRtspServer = class extends EventEmitter7 {
5492
6658
  options;
5493
6659
  compositeStream = null;
5494
6660
  rtspServer = null;
@@ -5593,7 +6759,7 @@ var CompositeRtspServer = class extends EventEmitter6 {
5593
6759
  this.logger.log?.(
5594
6760
  `[CompositeRtspServer] Starting ffmpeg RTSP server: ${ffmpegArgs.join(" ")}`
5595
6761
  );
5596
- this.ffmpegProcess = spawn7("ffmpeg", ffmpegArgs, {
6762
+ this.ffmpegProcess = spawn8("ffmpeg", ffmpegArgs, {
5597
6763
  stdio: ["pipe", "pipe", "pipe"]
5598
6764
  });
5599
6765
  this.ffmpegProcess.on("error", (error) => {
@@ -5805,6 +6971,7 @@ export {
5805
6971
  BaichuanClient,
5806
6972
  BaichuanEventEmitter,
5807
6973
  BaichuanFrameParser,
6974
+ BaichuanHlsServer,
5808
6975
  BaichuanHttpStreamServer,
5809
6976
  BaichuanMjpegServer,
5810
6977
  BaichuanRtspServer,
@@ -5820,6 +6987,7 @@ export {
5820
6987
  DUAL_LENS_SINGLE_MOTION_MODELS,
5821
6988
  H264RtpDepacketizer,
5822
6989
  H265RtpDepacketizer,
6990
+ HlsSessionManager,
5823
6991
  Intercom,
5824
6992
  MjpegTransformer,
5825
6993
  NVR_HUB_EXACT_TYPES,
@@ -5841,6 +7009,7 @@ export {
5841
7009
  buildBinaryExtensionXml,
5842
7010
  buildChannelExtensionXml,
5843
7011
  buildFloodlightManualXml,
7012
+ buildHlsRedirectUrl,
5844
7013
  buildLoginXml,
5845
7014
  buildPreviewStopXml,
5846
7015
  buildPreviewStopXmlV11,
@@ -5879,6 +7048,7 @@ export {
5879
7048
  decideVideoclipTranscodeMode,
5880
7049
  decodeHeader,
5881
7050
  deriveAesKey,
7051
+ detectIosClient,
5882
7052
  detectVideoCodecFromNal,
5883
7053
  discoverReolinkDevices,
5884
7054
  discoverViaHttpScan,