@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/README.md +9 -6
- package/dist/{DiagnosticsTools-MTXG65O3.js → DiagnosticsTools-EC7DADEQ.js} +2 -2
- package/dist/{chunk-MC2BRLLE.js → chunk-TZFZ5WJX.js} +71 -9
- package/dist/chunk-TZFZ5WJX.js.map +1 -0
- package/dist/{chunk-AFUYHWWQ.js → chunk-YUBYINJF.js} +673 -64
- package/dist/chunk-YUBYINJF.js.map +1 -0
- package/dist/cli/rtsp-server.cjs +739 -68
- package/dist/cli/rtsp-server.cjs.map +1 -1
- package/dist/cli/rtsp-server.js +2 -2
- package/dist/index.cjs +2041 -191
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +418 -4
- package/dist/index.d.ts +365 -3
- package/dist/index.js +1202 -32
- package/dist/index.js.map +1 -1
- package/package.json +13 -3
- package/dist/chunk-AFUYHWWQ.js.map +0 -1
- package/dist/chunk-MC2BRLLE.js.map +0 -1
- /package/dist/{DiagnosticsTools-MTXG65O3.js.map → DiagnosticsTools-EC7DADEQ.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
normalizeUid,
|
|
38
38
|
parseSupportXml,
|
|
39
39
|
setGlobalLogger
|
|
40
|
-
} from "./chunk-
|
|
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-
|
|
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
|
|
5022
|
+
const path2 = this.options.path ?? "/mjpeg";
|
|
4613
5023
|
this.httpServer = http5.createServer((req, res) => {
|
|
4614
|
-
this.handleRequest(req, res,
|
|
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}${
|
|
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
|
|
4994
|
-
|
|
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
|
-
|
|
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("
|
|
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 === "
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
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.
|
|
5298
|
-
|
|
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
|
|
5328
|
-
|
|
5329
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
5343
|
-
|
|
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/
|
|
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
|
|
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 =
|
|
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,
|