node-av-light 0.0.0-stage → 7.1.0-beta.1
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/LICENSE.md +22 -0
- package/README.md +710 -2
- package/dist/api/audio-frame-buffer.d.ts +636 -0
- package/dist/api/audio-frame-buffer.js +1027 -0
- package/dist/api/audio-frame-buffer.js.map +1 -0
- package/dist/api/bitstream-filter.d.ts +938 -0
- package/dist/api/bitstream-filter.js +1427 -0
- package/dist/api/bitstream-filter.js.map +1 -0
- package/dist/api/constants.d.ts +44 -0
- package/dist/api/constants.js +45 -0
- package/dist/api/constants.js.map +1 -0
- package/dist/api/data/test_av1.ivf +0 -0
- package/dist/api/data/test_h264.h264 +0 -0
- package/dist/api/data/test_hevc.h265 +0 -0
- package/dist/api/data/test_mjpeg.mjpeg +0 -0
- package/dist/api/data/test_vp8.ivf +0 -0
- package/dist/api/data/test_vp9.ivf +0 -0
- package/dist/api/decoder.d.ts +1413 -0
- package/dist/api/decoder.js +2367 -0
- package/dist/api/decoder.js.map +1 -0
- package/dist/api/demuxer.d.ts +1295 -0
- package/dist/api/demuxer.js +2344 -0
- package/dist/api/demuxer.js.map +1 -0
- package/dist/api/device.d.ts +589 -0
- package/dist/api/device.js +964 -0
- package/dist/api/device.js.map +1 -0
- package/dist/api/encoder-pool.d.ts +262 -0
- package/dist/api/encoder-pool.js +397 -0
- package/dist/api/encoder-pool.js.map +1 -0
- package/dist/api/encoder.d.ts +1509 -0
- package/dist/api/encoder.js +2619 -0
- package/dist/api/encoder.js.map +1 -0
- package/dist/api/filter-complex.d.ts +828 -0
- package/dist/api/filter-complex.js +1661 -0
- package/dist/api/filter-complex.js.map +1 -0
- package/dist/api/filter-presets.d.ts +800 -0
- package/dist/api/filter-presets.js +1554 -0
- package/dist/api/filter-presets.js.map +1 -0
- package/dist/api/filter.d.ts +1339 -0
- package/dist/api/filter.js +2075 -0
- package/dist/api/filter.js.map +1 -0
- package/dist/api/fmp4-stream.d.ts +797 -0
- package/dist/api/fmp4-stream.js +1428 -0
- package/dist/api/fmp4-stream.js.map +1 -0
- package/dist/api/hardware.d.ts +677 -0
- package/dist/api/hardware.js +1300 -0
- package/dist/api/hardware.js.map +1 -0
- package/dist/api/index.d.ts +21 -0
- package/dist/api/index.js +38 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/io-stream.d.ts +308 -0
- package/dist/api/io-stream.js +358 -0
- package/dist/api/io-stream.js.map +1 -0
- package/dist/api/muxer.d.ts +1311 -0
- package/dist/api/muxer.js +2672 -0
- package/dist/api/muxer.js.map +1 -0
- package/dist/api/pipeline.d.ts +801 -0
- package/dist/api/pipeline.js +1344 -0
- package/dist/api/pipeline.js.map +1 -0
- package/dist/api/probe.d.ts +128 -0
- package/dist/api/probe.js +227 -0
- package/dist/api/probe.js.map +1 -0
- package/dist/api/rtp-stream.d.ts +497 -0
- package/dist/api/rtp-stream.js +1131 -0
- package/dist/api/rtp-stream.js.map +1 -0
- package/dist/api/scaler.d.ts +561 -0
- package/dist/api/scaler.js +918 -0
- package/dist/api/scaler.js.map +1 -0
- package/dist/api/types.d.ts +17 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/api/utilities/async-queue.d.ts +149 -0
- package/dist/api/utilities/async-queue.js +254 -0
- package/dist/api/utilities/async-queue.js.map +1 -0
- package/dist/api/utilities/audio-sample.d.ts +117 -0
- package/dist/api/utilities/audio-sample.js +112 -0
- package/dist/api/utilities/audio-sample.js.map +1 -0
- package/dist/api/utilities/audio-transcode.d.ts +20 -0
- package/dist/api/utilities/audio-transcode.js +27 -0
- package/dist/api/utilities/audio-transcode.js.map +1 -0
- package/dist/api/utilities/channel-layout.d.ts +76 -0
- package/dist/api/utilities/channel-layout.js +80 -0
- package/dist/api/utilities/channel-layout.js.map +1 -0
- package/dist/api/utilities/codec-format.d.ts +87 -0
- package/dist/api/utilities/codec-format.js +117 -0
- package/dist/api/utilities/codec-format.js.map +1 -0
- package/dist/api/utilities/context-options.d.ts +25 -0
- package/dist/api/utilities/context-options.js +23 -0
- package/dist/api/utilities/context-options.js.map +1 -0
- package/dist/api/utilities/electron-shared-texture.d.ts +368 -0
- package/dist/api/utilities/electron-shared-texture.js +540 -0
- package/dist/api/utilities/electron-shared-texture.js.map +1 -0
- package/dist/api/utilities/image.d.ts +207 -0
- package/dist/api/utilities/image.js +213 -0
- package/dist/api/utilities/image.js.map +1 -0
- package/dist/api/utilities/index.d.ts +14 -0
- package/dist/api/utilities/index.js +27 -0
- package/dist/api/utilities/index.js.map +1 -0
- package/dist/api/utilities/live-decoding.d.ts +15 -0
- package/dist/api/utilities/live-decoding.js +32 -0
- package/dist/api/utilities/live-decoding.js.map +1 -0
- package/dist/api/utilities/media-type.d.ts +49 -0
- package/dist/api/utilities/media-type.js +53 -0
- package/dist/api/utilities/media-type.js.map +1 -0
- package/dist/api/utilities/packet-age-watchdog.d.ts +198 -0
- package/dist/api/utilities/packet-age-watchdog.js +594 -0
- package/dist/api/utilities/packet-age-watchdog.js.map +1 -0
- package/dist/api/utilities/pixel-format.d.ts +89 -0
- package/dist/api/utilities/pixel-format.js +97 -0
- package/dist/api/utilities/pixel-format.js.map +1 -0
- package/dist/api/utilities/sample-format.d.ts +129 -0
- package/dist/api/utilities/sample-format.js +141 -0
- package/dist/api/utilities/sample-format.js.map +1 -0
- package/dist/api/utilities/scale-size.d.ts +51 -0
- package/dist/api/utilities/scale-size.js +55 -0
- package/dist/api/utilities/scale-size.js.map +1 -0
- package/dist/api/utilities/scheduler.d.ts +138 -0
- package/dist/api/utilities/scheduler.js +98 -0
- package/dist/api/utilities/scheduler.js.map +1 -0
- package/dist/api/utilities/streaming.d.ts +186 -0
- package/dist/api/utilities/streaming.js +309 -0
- package/dist/api/utilities/streaming.js.map +1 -0
- package/dist/api/utilities/timestamp.d.ts +193 -0
- package/dist/api/utilities/timestamp.js +206 -0
- package/dist/api/utilities/timestamp.js.map +1 -0
- package/dist/api/utilities/video-transcode.d.ts +218 -0
- package/dist/api/utilities/video-transcode.js +624 -0
- package/dist/api/utilities/video-transcode.js.map +1 -0
- package/dist/api/utilities/whisper-model.d.ts +330 -0
- package/dist/api/utilities/whisper-model.js +587 -0
- package/dist/api/utilities/whisper-model.js.map +1 -0
- package/dist/api/utils.d.ts +19 -0
- package/dist/api/utils.js +39 -0
- package/dist/api/utils.js.map +1 -0
- package/dist/api/webrtc-stream.d.ts +294 -0
- package/dist/api/webrtc-stream.js +546 -0
- package/dist/api/webrtc-stream.js.map +1 -0
- package/dist/api/whisper.d.ts +326 -0
- package/dist/api/whisper.js +402 -0
- package/dist/api/whisper.js.map +1 -0
- package/dist/constants/bsf-options.d.ts +364 -0
- package/dist/constants/bsf-options.js +7 -0
- package/dist/constants/bsf-options.js.map +1 -0
- package/dist/constants/channel-layouts.d.ts +53 -0
- package/dist/constants/channel-layouts.js +57 -0
- package/dist/constants/channel-layouts.js.map +1 -0
- package/dist/constants/constants.d.ts +2435 -0
- package/dist/constants/constants.js +1989 -0
- package/dist/constants/constants.js.map +1 -0
- package/dist/constants/decoders.d.ts +651 -0
- package/dist/constants/decoders.js +641 -0
- package/dist/constants/decoders.js.map +1 -0
- package/dist/constants/encoders.d.ts +314 -0
- package/dist/constants/encoders.js +309 -0
- package/dist/constants/encoders.js.map +1 -0
- package/dist/constants/filter-options.d.ts +12099 -0
- package/dist/constants/filter-options.js +7 -0
- package/dist/constants/filter-options.js.map +1 -0
- package/dist/constants/format-options.d.ts +4319 -0
- package/dist/constants/format-options.js +7 -0
- package/dist/constants/format-options.js.map +1 -0
- package/dist/constants/formats.d.ts +18 -0
- package/dist/constants/formats.js +7 -0
- package/dist/constants/formats.js.map +1 -0
- package/dist/constants/hardware.d.ts +26 -0
- package/dist/constants/hardware.js +27 -0
- package/dist/constants/hardware.js.map +1 -0
- package/dist/constants/index.d.ts +10 -0
- package/dist/constants/index.js +6 -0
- package/dist/constants/index.js.map +1 -0
- package/dist/constants/options.d.ts +6217 -0
- package/dist/constants/options.js +7 -0
- package/dist/constants/options.js.map +1 -0
- package/dist/ffmpeg/index.d.ts +101 -0
- package/dist/ffmpeg/index.js +161 -0
- package/dist/ffmpeg/index.js.map +1 -0
- package/dist/ffmpeg/version.d.ts +6 -0
- package/dist/ffmpeg/version.js +7 -0
- package/dist/ffmpeg/version.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/audio-fifo.d.ts +399 -0
- package/dist/lib/audio-fifo.js +431 -0
- package/dist/lib/audio-fifo.js.map +1 -0
- package/dist/lib/binding.d.ts +242 -0
- package/dist/lib/binding.js +102 -0
- package/dist/lib/binding.js.map +1 -0
- package/dist/lib/bitstream-filter-context.d.ts +381 -0
- package/dist/lib/bitstream-filter-context.js +443 -0
- package/dist/lib/bitstream-filter-context.js.map +1 -0
- package/dist/lib/bitstream-filter.d.ts +140 -0
- package/dist/lib/bitstream-filter.js +154 -0
- package/dist/lib/bitstream-filter.js.map +1 -0
- package/dist/lib/codec-context.d.ts +1069 -0
- package/dist/lib/codec-context.js +1336 -0
- package/dist/lib/codec-context.js.map +1 -0
- package/dist/lib/codec-parameters.d.ts +664 -0
- package/dist/lib/codec-parameters.js +813 -0
- package/dist/lib/codec-parameters.js.map +1 -0
- package/dist/lib/codec-parser.d.ts +201 -0
- package/dist/lib/codec-parser.js +213 -0
- package/dist/lib/codec-parser.js.map +1 -0
- package/dist/lib/codec.d.ts +617 -0
- package/dist/lib/codec.js +746 -0
- package/dist/lib/codec.js.map +1 -0
- package/dist/lib/device.d.ts +294 -0
- package/dist/lib/device.js +326 -0
- package/dist/lib/device.js.map +1 -0
- package/dist/lib/dictionary.d.ts +333 -0
- package/dist/lib/dictionary.js +372 -0
- package/dist/lib/dictionary.js.map +1 -0
- package/dist/lib/error.d.ts +311 -0
- package/dist/lib/error.js +395 -0
- package/dist/lib/error.js.map +1 -0
- package/dist/lib/fifo.d.ts +416 -0
- package/dist/lib/fifo.js +453 -0
- package/dist/lib/fifo.js.map +1 -0
- package/dist/lib/filter-context.d.ts +714 -0
- package/dist/lib/filter-context.js +783 -0
- package/dist/lib/filter-context.js.map +1 -0
- package/dist/lib/filter-graph-segment.d.ts +161 -0
- package/dist/lib/filter-graph-segment.js +172 -0
- package/dist/lib/filter-graph-segment.js.map +1 -0
- package/dist/lib/filter-graph.d.ts +641 -0
- package/dist/lib/filter-graph.js +704 -0
- package/dist/lib/filter-graph.js.map +1 -0
- package/dist/lib/filter-inout.d.ts +202 -0
- package/dist/lib/filter-inout.js +261 -0
- package/dist/lib/filter-inout.js.map +1 -0
- package/dist/lib/filter.d.ts +243 -0
- package/dist/lib/filter.js +272 -0
- package/dist/lib/filter.js.map +1 -0
- package/dist/lib/format-context.d.ts +1335 -0
- package/dist/lib/format-context.js +1455 -0
- package/dist/lib/format-context.js.map +1 -0
- package/dist/lib/frame-utils.d.ts +116 -0
- package/dist/lib/frame-utils.js +98 -0
- package/dist/lib/frame-utils.js.map +1 -0
- package/dist/lib/frame.d.ts +1315 -0
- package/dist/lib/frame.js +1535 -0
- package/dist/lib/frame.js.map +1 -0
- package/dist/lib/hardware-device-context.d.ts +362 -0
- package/dist/lib/hardware-device-context.js +383 -0
- package/dist/lib/hardware-device-context.js.map +1 -0
- package/dist/lib/hardware-frames-context.d.ts +418 -0
- package/dist/lib/hardware-frames-context.js +471 -0
- package/dist/lib/hardware-frames-context.js.map +1 -0
- package/dist/lib/index.d.ts +35 -0
- package/dist/lib/index.js +60 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/input-format.d.ts +249 -0
- package/dist/lib/input-format.js +306 -0
- package/dist/lib/input-format.js.map +1 -0
- package/dist/lib/io-context.d.ts +698 -0
- package/dist/lib/io-context.js +771 -0
- package/dist/lib/io-context.js.map +1 -0
- package/dist/lib/log.d.ts +174 -0
- package/dist/lib/log.js +184 -0
- package/dist/lib/log.js.map +1 -0
- package/dist/lib/native-types.d.ts +1024 -0
- package/dist/lib/native-types.js +2 -0
- package/dist/lib/native-types.js.map +1 -0
- package/dist/lib/option.d.ts +953 -0
- package/dist/lib/option.js +1607 -0
- package/dist/lib/option.js.map +1 -0
- package/dist/lib/output-format.d.ts +222 -0
- package/dist/lib/output-format.js +259 -0
- package/dist/lib/output-format.js.map +1 -0
- package/dist/lib/packet.d.ts +521 -0
- package/dist/lib/packet.js +613 -0
- package/dist/lib/packet.js.map +1 -0
- package/dist/lib/rational.d.ts +251 -0
- package/dist/lib/rational.js +278 -0
- package/dist/lib/rational.js.map +1 -0
- package/dist/lib/software-resample-context.d.ts +552 -0
- package/dist/lib/software-resample-context.js +592 -0
- package/dist/lib/software-resample-context.js.map +1 -0
- package/dist/lib/software-scale-context.d.ts +344 -0
- package/dist/lib/software-scale-context.js +366 -0
- package/dist/lib/software-scale-context.js.map +1 -0
- package/dist/lib/stream.d.ts +383 -0
- package/dist/lib/stream.js +524 -0
- package/dist/lib/stream.js.map +1 -0
- package/dist/lib/sync-queue.d.ts +179 -0
- package/dist/lib/sync-queue.js +197 -0
- package/dist/lib/sync-queue.js.map +1 -0
- package/dist/lib/types.d.ts +34 -0
- package/dist/lib/types.js +2 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/lib/utilities.d.ts +1198 -0
- package/dist/lib/utilities.js +1302 -0
- package/dist/lib/utilities.js.map +1 -0
- package/dist/utils/electron.d.ts +49 -0
- package/dist/utils/electron.js +63 -0
- package/dist/utils/electron.js.map +1 -0
- package/dist/utils/index.d.ts +6 -0
- package/dist/utils/index.js +7 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/libc.d.ts +11 -0
- package/dist/utils/libc.js +35 -0
- package/dist/utils/libc.js.map +1 -0
- package/dist/utils/variant.d.ts +26 -0
- package/dist/utils/variant.js +31 -0
- package/dist/utils/variant.js.map +1 -0
- package/dist/webrtc/index.d.ts +4 -0
- package/dist/webrtc/index.js +7 -0
- package/dist/webrtc/index.js.map +1 -0
- package/install/check.js +124 -0
- package/install/ffmpeg.js +66 -0
- package/package.json +167 -4
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
import { isRtcp, RtpPacket } from 'werift';
|
|
2
|
+
import { AV_CODEC_ID_HEVC, AV_HWDEVICE_TYPE_NONE, AV_SAMPLE_FMT_FLTP } from '../constants/constants.js';
|
|
3
|
+
import { FF_ENCODER_LIBOPUS, FF_ENCODER_LIBX264, FF_ENCODER_LIBX265 } from '../constants/encoders.js';
|
|
4
|
+
import { Codec } from '../lib/codec.js';
|
|
5
|
+
import { Rational } from '../lib/rational.js';
|
|
6
|
+
import { avChannelLayoutDefault } from '../lib/utilities.js';
|
|
7
|
+
import { BitStreamFilterAPI } from './bitstream-filter.js';
|
|
8
|
+
import { MAX_PACKET_SIZE } from './constants.js';
|
|
9
|
+
import { Demuxer } from './demuxer.js';
|
|
10
|
+
import { Encoder } from './encoder.js';
|
|
11
|
+
import { FilterPreset } from './filter-presets.js';
|
|
12
|
+
import { FilterAPI } from './filter.js';
|
|
13
|
+
import { HardwareContext } from './hardware.js';
|
|
14
|
+
import { Muxer } from './muxer.js';
|
|
15
|
+
import { interruptibleFrameSource, pipeline } from './pipeline.js';
|
|
16
|
+
import { openAudioDecoder } from './utilities/audio-transcode.js';
|
|
17
|
+
import { pickSupportedLayout, pickSupportedRate, pickSupportedSampleFormat } from './utilities/codec-format.js';
|
|
18
|
+
import { configureLiveDecoderThreading } from './utilities/live-decoding.js';
|
|
19
|
+
import { transcodeScale } from './utilities/scale-size.js';
|
|
20
|
+
import { applyVideoBitrate, createVideoTranscodeFilter, findSoftwareEncoder, findUsableHardwareEncoder, openTranscodeDecoder } from './utilities/video-transcode.js';
|
|
21
|
+
/**
|
|
22
|
+
* Check whether a streaming-class input is a frame source (not a URL or Demuxer).
|
|
23
|
+
*
|
|
24
|
+
* @param input - The input passed to `create()`
|
|
25
|
+
*
|
|
26
|
+
* @returns True if `input` is a {@link MediaFrameSource}
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export function isMediaFrameSource(input) {
|
|
31
|
+
return typeof input !== 'string' && !(input instanceof Demuxer);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Combine one or two pipeline controls behind a single {@link PipelineControl}.
|
|
35
|
+
*
|
|
36
|
+
* The frame-source path runs separate video/audio pipelines; this presents them
|
|
37
|
+
* as one control so `stop()`/`completion` work uniformly.
|
|
38
|
+
*
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
class CombinedPipelineControl {
|
|
42
|
+
controls;
|
|
43
|
+
constructor(controls) {
|
|
44
|
+
this.controls = controls;
|
|
45
|
+
}
|
|
46
|
+
stop() {
|
|
47
|
+
for (const control of this.controls) {
|
|
48
|
+
control.stop();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
isStopped() {
|
|
52
|
+
return this.controls.every((control) => control.isStopped());
|
|
53
|
+
}
|
|
54
|
+
get completion() {
|
|
55
|
+
return Promise.all(this.controls.map((control) => control.completion)).then(() => undefined);
|
|
56
|
+
}
|
|
57
|
+
get progress() {
|
|
58
|
+
// Delegate to the first control (video when present) - a merged view is not
|
|
59
|
+
// meaningful across independently-clocked streams.
|
|
60
|
+
return this.controls[0]?.progress ?? { frames: 0, bytes: 0, time: 0, fps: 0, bitrate: 0, speed: 0, elapsed: 0 };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Generic RTP streaming with automatic codec detection and transcoding.
|
|
65
|
+
*
|
|
66
|
+
* Provides library-agnostic RTP streaming for various applications.
|
|
67
|
+
* Automatically detects input codecs and transcodes non-compatible formats.
|
|
68
|
+
* Supports hardware acceleration for video transcoding.
|
|
69
|
+
* Essential component for building RTP streaming servers.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```typescript
|
|
73
|
+
* import { RTPStream } from 'node-av/api';
|
|
74
|
+
*
|
|
75
|
+
* // Create stream with RTP packet callbacks
|
|
76
|
+
* const stream = RTPStream.create('rtsp://camera.local/stream', {
|
|
77
|
+
* mtu: 1200,
|
|
78
|
+
* hardware: 'auto',
|
|
79
|
+
* onVideoPacket: (rtp) => {
|
|
80
|
+
* // Send RTP packet
|
|
81
|
+
* sendRtpPacket(rtp);
|
|
82
|
+
* },
|
|
83
|
+
* onAudioPacket: (rtp) => {
|
|
84
|
+
* sendRtpPacket(rtp);
|
|
85
|
+
* }
|
|
86
|
+
* });
|
|
87
|
+
*
|
|
88
|
+
* // Start streaming
|
|
89
|
+
* await stream.start();
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* @see {@link Demuxer} For input media handling
|
|
93
|
+
* @see {@link HardwareContext} For GPU acceleration
|
|
94
|
+
*/
|
|
95
|
+
export class RTPStream {
|
|
96
|
+
options;
|
|
97
|
+
inputUrl;
|
|
98
|
+
inputOptions;
|
|
99
|
+
input;
|
|
100
|
+
source;
|
|
101
|
+
videoOutput;
|
|
102
|
+
audioOutput;
|
|
103
|
+
hardwareContext;
|
|
104
|
+
videoDecoder;
|
|
105
|
+
videoFilter;
|
|
106
|
+
videoEncoder;
|
|
107
|
+
videoBitstreamFilter;
|
|
108
|
+
audioDecoder;
|
|
109
|
+
audioFilter;
|
|
110
|
+
audioEncoder;
|
|
111
|
+
pipeline;
|
|
112
|
+
startPromise;
|
|
113
|
+
signal;
|
|
114
|
+
supportedVideoCodecs;
|
|
115
|
+
supportedAudioCodecs;
|
|
116
|
+
abortHandler;
|
|
117
|
+
stopRequested = false;
|
|
118
|
+
stopPromise;
|
|
119
|
+
startAbort;
|
|
120
|
+
stopSignal = Promise.withResolvers();
|
|
121
|
+
/**
|
|
122
|
+
* @param input - Media input URL or pre-opened Demuxer
|
|
123
|
+
*
|
|
124
|
+
* @param options - Stream configuration options
|
|
125
|
+
*
|
|
126
|
+
* Use {@link create} factory method
|
|
127
|
+
*
|
|
128
|
+
* @internal
|
|
129
|
+
*/
|
|
130
|
+
constructor(input, options) {
|
|
131
|
+
if (typeof input === 'string') {
|
|
132
|
+
this.inputUrl = input;
|
|
133
|
+
}
|
|
134
|
+
else if (input instanceof Demuxer) {
|
|
135
|
+
this.inputUrl = null;
|
|
136
|
+
this.input = input;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
this.inputUrl = null;
|
|
140
|
+
this.source = input;
|
|
141
|
+
}
|
|
142
|
+
const inputUrl = this.inputUrl ?? '';
|
|
143
|
+
this.inputOptions = {
|
|
144
|
+
...options.inputOptions,
|
|
145
|
+
options: {
|
|
146
|
+
flags: 'low_delay',
|
|
147
|
+
// fflags: 'nobuffer',
|
|
148
|
+
analyzeduration: 0,
|
|
149
|
+
probesize: 32,
|
|
150
|
+
timeout: 10000000,
|
|
151
|
+
rtsp_transport: inputUrl.toLowerCase().startsWith('rtsp') ? 'tcp' : undefined,
|
|
152
|
+
...options.inputOptions?.options,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
this.signal = options.signal;
|
|
156
|
+
// If no supported codecs specified, empty set means ALL codecs are supported (passthrough only)
|
|
157
|
+
this.supportedVideoCodecs = new Set(options.supportedVideoCodecs?.filter(Boolean));
|
|
158
|
+
this.supportedAudioCodecs = new Set(options.supportedAudioCodecs?.filter(Boolean));
|
|
159
|
+
this.options = {
|
|
160
|
+
onVideoPacket: options.onVideoPacket ?? (() => { }),
|
|
161
|
+
onAudioPacket: options.onAudioPacket ?? (() => { }),
|
|
162
|
+
onClose: options.onClose ?? (() => { }),
|
|
163
|
+
supportedVideoCodecs: Array.from(this.supportedVideoCodecs),
|
|
164
|
+
supportedAudioCodecs: Array.from(this.supportedAudioCodecs),
|
|
165
|
+
hardware: options.hardware ?? { deviceType: AV_HWDEVICE_TYPE_NONE },
|
|
166
|
+
inputOptions: options.inputOptions,
|
|
167
|
+
video: {
|
|
168
|
+
ssrc: options.video?.ssrc,
|
|
169
|
+
payloadType: options.video?.payloadType,
|
|
170
|
+
mtu: options.video?.mtu ?? MAX_PACKET_SIZE,
|
|
171
|
+
fps: options.video?.fps,
|
|
172
|
+
width: options.video?.width,
|
|
173
|
+
height: options.video?.height,
|
|
174
|
+
bitrate: options.video?.bitrate,
|
|
175
|
+
dumpExtra: options.video?.dumpExtra,
|
|
176
|
+
transcode: options.video?.transcode,
|
|
177
|
+
encoderOptions: options.video?.encoderOptions ?? {},
|
|
178
|
+
},
|
|
179
|
+
audio: {
|
|
180
|
+
ssrc: options.audio?.ssrc,
|
|
181
|
+
payloadType: options.audio?.payloadType,
|
|
182
|
+
mtu: options.audio?.mtu ?? MAX_PACKET_SIZE,
|
|
183
|
+
sampleRate: options.audio?.sampleRate,
|
|
184
|
+
channels: options.audio?.channels,
|
|
185
|
+
transcode: options.audio?.transcode,
|
|
186
|
+
encoderOptions: options.audio?.encoderOptions,
|
|
187
|
+
sampleFormat: options.audio?.sampleFormat,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Create an RTP stream from a media source.
|
|
193
|
+
*
|
|
194
|
+
* Configures the stream with input URL and options. The input is not opened
|
|
195
|
+
* until start() is called, allowing the stream to be reused after stop().
|
|
196
|
+
*
|
|
197
|
+
* @param input - Media source URL (RTSP, file path, HTTP, etc.) or a pre-opened {@link Demuxer}
|
|
198
|
+
*
|
|
199
|
+
* @param options - Stream configuration options
|
|
200
|
+
*
|
|
201
|
+
* @returns Configured RTP stream instance
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* ```typescript
|
|
205
|
+
* // Stream from RTSP camera
|
|
206
|
+
* const stream = RTPStream.create('rtsp://camera.local/stream', {
|
|
207
|
+
* mtu: 1200,
|
|
208
|
+
* onVideoPacket: (rtp) => sendPacket(rtp),
|
|
209
|
+
* onAudioPacket: (rtp) => sendPacket(rtp)
|
|
210
|
+
* });
|
|
211
|
+
* ```
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```typescript
|
|
215
|
+
* // Stream file with auto hardware acceleration
|
|
216
|
+
* const stream = RTPStream.create('video.mp4', {
|
|
217
|
+
* hardware: 'auto'
|
|
218
|
+
* });
|
|
219
|
+
* ```
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```typescript
|
|
223
|
+
* // Stream from a pre-opened Demuxer (e.g. Device.openScreen())
|
|
224
|
+
* const demuxer = await Device.openScreen({ frameRate: 30 });
|
|
225
|
+
* const stream = RTPStream.create(demuxer, {
|
|
226
|
+
* hardware: 'auto',
|
|
227
|
+
* onVideoPacket: (rtp) => sendPacket(rtp)
|
|
228
|
+
* });
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
static create(input, options = {}) {
|
|
232
|
+
return new RTPStream(input, options);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Check if the stream is active.
|
|
236
|
+
*
|
|
237
|
+
* Stays true while a requested stop is still unwinding: the pipeline reports
|
|
238
|
+
* itself stopped as soon as stop() is called, but its packets keep flowing
|
|
239
|
+
* and its native resources stay open until completion settles.
|
|
240
|
+
*
|
|
241
|
+
* @returns True if the stream is active, false otherwise
|
|
242
|
+
*/
|
|
243
|
+
get isStreamActive() {
|
|
244
|
+
return this.pipeline !== undefined;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Get the demuxer instance.
|
|
248
|
+
*
|
|
249
|
+
* Used for accessing the underlying demuxer.
|
|
250
|
+
* Only available after start() is called.
|
|
251
|
+
*
|
|
252
|
+
* @returns Demuxer instance or undefined if not started
|
|
253
|
+
*
|
|
254
|
+
* @example
|
|
255
|
+
* ```typescript
|
|
256
|
+
* const stream = RTPStream.create('input.mp4', {
|
|
257
|
+
* onVideoPacket: (rtp) => sendRtp(rtp)
|
|
258
|
+
* });
|
|
259
|
+
* await stream.start();
|
|
260
|
+
* const input = stream.getInput();
|
|
261
|
+
* console.log('Bitrate:', input?.bitRate);
|
|
262
|
+
* ```
|
|
263
|
+
*/
|
|
264
|
+
getInput() {
|
|
265
|
+
return this.input;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Request that the next encoded video frame is a keyframe.
|
|
269
|
+
*
|
|
270
|
+
* Used to answer receiver feedback (e.g. a WebRTC PLI) so a receiver that
|
|
271
|
+
* missed the stream start or lost packets can resync without waiting for the
|
|
272
|
+
* next scheduled GOP keyframe. Effective whenever the video is encoded, from
|
|
273
|
+
* frame sources and from a transcoded URL or {@link Demuxer} input; a stream
|
|
274
|
+
* passed through keeps the source's keyframes.
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* ```typescript
|
|
278
|
+
* transceiver.sender.onPictureLossIndication.subscribe(() => {
|
|
279
|
+
* stream.requestVideoKeyframe();
|
|
280
|
+
* });
|
|
281
|
+
* ```
|
|
282
|
+
*/
|
|
283
|
+
requestVideoKeyframe() {
|
|
284
|
+
this.videoEncoder?.requestKeyframe();
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Start streaming media to RTP packets.
|
|
288
|
+
*
|
|
289
|
+
* Begins the media processing pipeline, reading packets from input,
|
|
290
|
+
* transcoding if necessary, and invoking RTP packet callbacks.
|
|
291
|
+
* Automatically handles video and audio streams in parallel.
|
|
292
|
+
* This method returns immediately after starting the pipeline.
|
|
293
|
+
*
|
|
294
|
+
* @returns Promise that resolves when pipeline is started
|
|
295
|
+
*
|
|
296
|
+
* @throws {Error} If no video stream found in input
|
|
297
|
+
*
|
|
298
|
+
* @throws {FFmpegError} If setup fails
|
|
299
|
+
*
|
|
300
|
+
* @example
|
|
301
|
+
* ```typescript
|
|
302
|
+
* const stream = RTPStream.create('rtsp://camera.local/stream', {
|
|
303
|
+
* onVideoPacket: (rtp) => sendRtp(rtp)
|
|
304
|
+
* });
|
|
305
|
+
*
|
|
306
|
+
* // Start streaming (returns immediately)
|
|
307
|
+
* await stream.start();
|
|
308
|
+
*
|
|
309
|
+
* // Later: stop streaming
|
|
310
|
+
* await stream.stop();
|
|
311
|
+
* ```
|
|
312
|
+
*/
|
|
313
|
+
async start() {
|
|
314
|
+
// Idempotent: concurrent and repeated calls share a single startup (e.g.
|
|
315
|
+
// an explicit start() racing WebRTCStream.setOffer()'s automatic one).
|
|
316
|
+
this.startPromise ??= this.doStart().catch((error) => {
|
|
317
|
+
this.startPromise = undefined;
|
|
318
|
+
throw error;
|
|
319
|
+
});
|
|
320
|
+
await this.startPromise;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Perform the actual startup (see {@link start}).
|
|
324
|
+
*
|
|
325
|
+
* @internal
|
|
326
|
+
*/
|
|
327
|
+
async doStart() {
|
|
328
|
+
// A start racing an in-flight stop() waits for the teardown to finish,
|
|
329
|
+
// otherwise it would build the pipeline on top of freed resources.
|
|
330
|
+
if (this.stopPromise) {
|
|
331
|
+
await this.stopPromise;
|
|
332
|
+
}
|
|
333
|
+
if (this.pipeline) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
this.signal?.throwIfAborted();
|
|
337
|
+
if (this.signal && !this.abortHandler) {
|
|
338
|
+
// Stop on abort. Keep a reference so stop() can detach the listener again,
|
|
339
|
+
// otherwise restarts would stack one listener per start(). Errors from the
|
|
340
|
+
// async stop() are swallowed here; they already surface via onClose(error).
|
|
341
|
+
this.abortHandler = () => {
|
|
342
|
+
this.abortHandler = undefined;
|
|
343
|
+
this.stop().catch(() => { });
|
|
344
|
+
};
|
|
345
|
+
this.signal.addEventListener('abort', this.abortHandler, { once: true });
|
|
346
|
+
}
|
|
347
|
+
// Re-armed per start so a restart is not torn down by the previous run.
|
|
348
|
+
this.stopSignal = Promise.withResolvers();
|
|
349
|
+
// Lets stop() interrupt a startup that is blocked in the input open
|
|
350
|
+
// (an RTSP connect can take seconds).
|
|
351
|
+
this.startAbort = new AbortController();
|
|
352
|
+
// Frame-source path: encode pre-composited frames directly (no demuxer/decoder).
|
|
353
|
+
if (this.source) {
|
|
354
|
+
await this.startFromFrames();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (!this.input) {
|
|
358
|
+
if (!this.inputUrl) {
|
|
359
|
+
throw new Error('No input URL or Demuxer provided');
|
|
360
|
+
}
|
|
361
|
+
const openSignal = this.signal ? AbortSignal.any([this.signal, this.startAbort.signal]) : this.startAbort.signal;
|
|
362
|
+
this.input = await Demuxer.open(this.inputUrl, { ...this.inputOptions, signal: openSignal });
|
|
363
|
+
}
|
|
364
|
+
this.throwIfStopRequested();
|
|
365
|
+
const videoStream = this.input.video();
|
|
366
|
+
const audioStream = this.input.audio();
|
|
367
|
+
let videoOutputIsHevc = videoStream?.codecpar.codecId === AV_CODEC_ID_HEVC;
|
|
368
|
+
// Setup video transcoding if needed
|
|
369
|
+
if (videoStream && (this.options.video.transcode || !this.isVideoCodecSupported(videoStream.codecpar.codecId))) {
|
|
370
|
+
// Check if we need hardware acceleration
|
|
371
|
+
if (this.options.hardware === 'auto') {
|
|
372
|
+
this.hardwareContext = HardwareContext.auto();
|
|
373
|
+
}
|
|
374
|
+
else if (this.options.hardware.deviceType !== AV_HWDEVICE_TYPE_NONE) {
|
|
375
|
+
this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
|
|
376
|
+
}
|
|
377
|
+
const currentFps = videoStream.avgFrameRate.num / videoStream.avgFrameRate.den;
|
|
378
|
+
const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
|
|
379
|
+
// Get first supported codec
|
|
380
|
+
const targetCodecId = this.options.supportedVideoCodecs[0];
|
|
381
|
+
if (!targetCodecId) {
|
|
382
|
+
throw new Error('No supported video codec specified for transcoding');
|
|
383
|
+
}
|
|
384
|
+
const { codecpar } = videoStream;
|
|
385
|
+
const scale = transcodeScale(codecpar.width, codecpar.height, this.options.video.width, this.options.video.height);
|
|
386
|
+
// a GPU encodes up to a size of its own, test it with the size it will get
|
|
387
|
+
const encoderCodec = await this.findVideoEncoder(targetCodecId, scale.output);
|
|
388
|
+
if (!encoderCodec) {
|
|
389
|
+
throw new Error(`No encoder found for codec ID ${targetCodecId}`);
|
|
390
|
+
}
|
|
391
|
+
this.throwIfStopRequested();
|
|
392
|
+
this.videoDecoder = await openTranscodeDecoder(videoStream, this.hardwareContext, {
|
|
393
|
+
exitOnError: false,
|
|
394
|
+
// Frame threading holds back one frame per thread before the first output
|
|
395
|
+
configure: configureLiveDecoderThreading,
|
|
396
|
+
});
|
|
397
|
+
// The graph follows the frames: whether they come from the device and in
|
|
398
|
+
// which format shows only once they are decoded
|
|
399
|
+
this.videoFilter = createVideoTranscodeFilter({
|
|
400
|
+
...scale.target,
|
|
401
|
+
fps: needsFps ? this.options.video.fps : undefined,
|
|
402
|
+
encoder: encoderCodec,
|
|
403
|
+
hardware: this.hardwareContext,
|
|
404
|
+
});
|
|
405
|
+
const encoderOptions = liveVideoEncoderOptions(encoderCodec.name, this.options.video.encoderOptions);
|
|
406
|
+
const bitrate = this.options.video.bitrate;
|
|
407
|
+
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
408
|
+
decoder: this.videoDecoder,
|
|
409
|
+
filter: this.videoFilter,
|
|
410
|
+
maxBFrames: 0,
|
|
411
|
+
options: encoderOptions,
|
|
412
|
+
configure: (ctx) => {
|
|
413
|
+
// encoder defaults are 120 (VAAPI) or 250 frames (libx264, NVENC), a receiver that lost a packet waits that long;
|
|
414
|
+
// after the short probe the input rate is often unknown, the context has it from the frames
|
|
415
|
+
const fps = ctx.framerate.num > 0 ? ctx.framerate.num / ctx.framerate.den : 30;
|
|
416
|
+
ctx.gopSize = Math.max(1, Math.round(fps * 2));
|
|
417
|
+
applyVideoBitrate(ctx, bitrate);
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
this.throwIfStopRequested();
|
|
421
|
+
videoOutputIsHevc = encoderCodec.id === AV_CODEC_ID_HEVC;
|
|
422
|
+
}
|
|
423
|
+
if (videoStream && videoOutputIsHevc && this.options.video?.dumpExtra) {
|
|
424
|
+
this.videoBitstreamFilter = BitStreamFilterAPI.create('dump_extra', this.videoEncoder ?? videoStream, { options: { freq: 'k' } });
|
|
425
|
+
}
|
|
426
|
+
// Setup video output
|
|
427
|
+
this.videoOutput = await this.createVideoOutput();
|
|
428
|
+
this.throwIfStopRequested();
|
|
429
|
+
// Setup audio if available and needs transcoding
|
|
430
|
+
if (audioStream && (this.options.audio.transcode || !this.isAudioCodecSupported(audioStream.codecpar.codecId))) {
|
|
431
|
+
this.audioDecoder = await openAudioDecoder(audioStream, {
|
|
432
|
+
exitOnError: false,
|
|
433
|
+
});
|
|
434
|
+
// Get first supported audio codec
|
|
435
|
+
const targetCodecId = this.options.supportedAudioCodecs[0];
|
|
436
|
+
if (!targetCodecId) {
|
|
437
|
+
throw new Error('No supported audio codec specified for transcoding');
|
|
438
|
+
}
|
|
439
|
+
let encoderCodec = null;
|
|
440
|
+
if (typeof targetCodecId === 'string') {
|
|
441
|
+
encoderCodec = Codec.findEncoderByName(targetCodecId);
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
encoderCodec = Codec.findEncoder(targetCodecId);
|
|
445
|
+
}
|
|
446
|
+
if (!encoderCodec) {
|
|
447
|
+
throw new Error(`No encoder found for codec ID ${targetCodecId}`);
|
|
448
|
+
}
|
|
449
|
+
// Determine target audio parameters from options
|
|
450
|
+
const desiredSampleFormat = this.options.audio?.sampleFormat ?? audioStream.codecpar.format;
|
|
451
|
+
const desiredSampleRate = this.options.audio?.sampleRate ?? (audioStream.codecpar.sampleRate > 0 ? audioStream.codecpar.sampleRate : 48000);
|
|
452
|
+
const desiredChannels = this.options.audio?.channels ?? (audioStream.codecpar.channels > 0 ? audioStream.codecpar.channels : 2);
|
|
453
|
+
// Select best supported parameters from codec
|
|
454
|
+
const targetSampleFormat = this.selectSampleFormat(encoderCodec, desiredSampleFormat);
|
|
455
|
+
const targetSampleRate = this.selectSampleRate(encoderCodec, desiredSampleRate);
|
|
456
|
+
const channelLayoutStr = this.selectChannelLayout(encoderCodec, desiredChannels);
|
|
457
|
+
// Create audio filter for resampling
|
|
458
|
+
const filterChain = FilterPreset.chain().aformat(targetSampleFormat, targetSampleRate, channelLayoutStr).build();
|
|
459
|
+
// FilterAPI now auto-calculates timeBase from first frame (FFmpeg behavior)
|
|
460
|
+
this.audioFilter = FilterAPI.create(filterChain);
|
|
461
|
+
let encoderOptions = {};
|
|
462
|
+
if (encoderCodec.name === FF_ENCODER_LIBOPUS) {
|
|
463
|
+
encoderOptions.application = 'lowdelay';
|
|
464
|
+
encoderOptions.frame_duration = 20;
|
|
465
|
+
}
|
|
466
|
+
encoderOptions = {
|
|
467
|
+
...encoderOptions,
|
|
468
|
+
...this.options.audio.encoderOptions,
|
|
469
|
+
};
|
|
470
|
+
this.audioEncoder = await Encoder.create(encoderCodec, {
|
|
471
|
+
decoder: this.audioDecoder,
|
|
472
|
+
filter: this.audioFilter,
|
|
473
|
+
options: encoderOptions,
|
|
474
|
+
// Silence for an input gap would only go out once the input resumes, as a burst of
|
|
475
|
+
// late packets; receivers conceal a timestamp gap like packet loss instead
|
|
476
|
+
audioFrameBuffer: { maxGapFill: 0 },
|
|
477
|
+
});
|
|
478
|
+
this.throwIfStopRequested();
|
|
479
|
+
}
|
|
480
|
+
// Setup audio output if available
|
|
481
|
+
if (audioStream) {
|
|
482
|
+
this.audioOutput = await this.createAudioOutput();
|
|
483
|
+
this.throwIfStopRequested();
|
|
484
|
+
}
|
|
485
|
+
// Start pipeline in background (don't await)
|
|
486
|
+
this.attachCompletion(this.runPipeline());
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Abort an in-flight startup once stop() was requested.
|
|
490
|
+
*
|
|
491
|
+
* Checked after every await in the startup path: the resources created so
|
|
492
|
+
* far are all assigned to instance fields, so the stop() that is waiting on
|
|
493
|
+
* this startup tears them down the moment it unwinds.
|
|
494
|
+
*
|
|
495
|
+
* @throws {Error} If stop() was called while starting
|
|
496
|
+
*
|
|
497
|
+
* @internal
|
|
498
|
+
*/
|
|
499
|
+
throwIfStopRequested() {
|
|
500
|
+
if (this.stopRequested) {
|
|
501
|
+
throw new Error('RTPStream stopped during start');
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Wire the pipeline completion to the onClose/error callbacks.
|
|
506
|
+
*
|
|
507
|
+
* On success invokes `onClose()`; on error stops the stream and invokes
|
|
508
|
+
* `onClose(error)`. Either way `onClose` runs exactly once and the detached
|
|
509
|
+
* chain never rejects. Runs in the background (not awaited).
|
|
510
|
+
*
|
|
511
|
+
* @param completion - The pipeline completion promise
|
|
512
|
+
*
|
|
513
|
+
* @internal
|
|
514
|
+
*/
|
|
515
|
+
attachCompletion(completion) {
|
|
516
|
+
// Captured so a restart that installed a newer pipeline keeps it.
|
|
517
|
+
const control = this.pipeline;
|
|
518
|
+
// One then() with both handlers: a throwing onClose() in the success path
|
|
519
|
+
// must not fall through into the error path and fire onClose a second time.
|
|
520
|
+
completion
|
|
521
|
+
.then(() => {
|
|
522
|
+
// Pipeline completed successfully
|
|
523
|
+
this.emitClose();
|
|
524
|
+
}, async (reason) => {
|
|
525
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
526
|
+
// A stop() in flight or an aborted signal means the owner is tearing
|
|
527
|
+
// the stream down - the pipeline unwinding with an AbortError (or an
|
|
528
|
+
// interrupted FFmpeg call) is the expected shutdown path, not a failure.
|
|
529
|
+
// Decided before the stop() below, which would make every failure look deliberate.
|
|
530
|
+
const deliberate = this.isDeliberateStop(reason);
|
|
531
|
+
if (!deliberate) {
|
|
532
|
+
console.error('[RTPStream] Pipeline error:', error);
|
|
533
|
+
}
|
|
534
|
+
// A teardown failure must not swallow the onClose call: without it
|
|
535
|
+
// the owner never learns the stream is gone.
|
|
536
|
+
try {
|
|
537
|
+
await this.stop();
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
// doStop() already released whatever it could.
|
|
541
|
+
}
|
|
542
|
+
this.emitClose(deliberate ? undefined : error);
|
|
543
|
+
})
|
|
544
|
+
.finally(() => {
|
|
545
|
+
// Drop the finished pipeline so isStreamActive reports inactive. The
|
|
546
|
+
// demuxer path already clears it in runPipeline(); a frame source has
|
|
547
|
+
// no such step, so without this it would look active forever.
|
|
548
|
+
if (this.pipeline === control) {
|
|
549
|
+
this.pipeline = undefined;
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Invoke the owner's onClose callback.
|
|
555
|
+
*
|
|
556
|
+
* The completion chain runs detached, so an exception from the callback
|
|
557
|
+
* could only surface as an unhandled rejection - it is logged instead.
|
|
558
|
+
*
|
|
559
|
+
* @param error - Pipeline error, or undefined for a clean end
|
|
560
|
+
*
|
|
561
|
+
* @internal
|
|
562
|
+
*/
|
|
563
|
+
emitClose(error) {
|
|
564
|
+
try {
|
|
565
|
+
this.options.onClose(error);
|
|
566
|
+
}
|
|
567
|
+
catch (callbackError) {
|
|
568
|
+
// The stream is already closed; logged so a broken restart path in the
|
|
569
|
+
// owner does not vanish silently.
|
|
570
|
+
console.error('[RTPStream] onClose callback threw:', callbackError);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Whether a pipeline rejection was caused by an intentional shutdown.
|
|
575
|
+
*
|
|
576
|
+
* @param error - The pipeline rejection reason
|
|
577
|
+
*
|
|
578
|
+
* @returns True when stop() is in flight or the owner's signal aborted
|
|
579
|
+
*
|
|
580
|
+
* @internal
|
|
581
|
+
*/
|
|
582
|
+
isDeliberateStop(error) {
|
|
583
|
+
return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Set up encoders and RTP outputs for a pre-composited frame source and start
|
|
587
|
+
* encoding. No demuxer or decoder is involved - the provided frames are encoded
|
|
588
|
+
* straight to RTP. Video and audio (whichever are present) run as separate
|
|
589
|
+
* pipelines combined behind a single control.
|
|
590
|
+
*
|
|
591
|
+
* @throws {Error} If the source has neither video nor audio frames
|
|
592
|
+
*
|
|
593
|
+
* @internal
|
|
594
|
+
*/
|
|
595
|
+
async startFromFrames() {
|
|
596
|
+
const source = this.source;
|
|
597
|
+
if (!source.video && !source.audio) {
|
|
598
|
+
throw new Error('Frame source must provide video and/or audio frames');
|
|
599
|
+
}
|
|
600
|
+
const controls = [];
|
|
601
|
+
const opts = this.signal ? { signal: this.signal } : undefined;
|
|
602
|
+
// Video
|
|
603
|
+
if (source.video) {
|
|
604
|
+
if (this.options.hardware === 'auto') {
|
|
605
|
+
this.hardwareContext = HardwareContext.auto();
|
|
606
|
+
}
|
|
607
|
+
else if (this.options.hardware.deviceType !== AV_HWDEVICE_TYPE_NONE) {
|
|
608
|
+
this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
|
|
609
|
+
}
|
|
610
|
+
const targetCodecId = this.options.supportedVideoCodecs[0] ?? FF_ENCODER_LIBX264;
|
|
611
|
+
const encoderCodec = await this.findVideoEncoder(targetCodecId);
|
|
612
|
+
if (!encoderCodec) {
|
|
613
|
+
throw new Error(`No encoder found for video codec ${String(targetCodecId)}`);
|
|
614
|
+
}
|
|
615
|
+
this.throwIfStopRequested();
|
|
616
|
+
const encoderOptions = liveVideoEncoderOptions(encoderCodec.name, this.options.video.encoderOptions);
|
|
617
|
+
let fps = this.options.video.fps ?? 20;
|
|
618
|
+
if (!isFinite(fps) || fps <= 0 || isNaN(fps)) {
|
|
619
|
+
fps = 20;
|
|
620
|
+
}
|
|
621
|
+
// No decoder/filter: the encoder adopts width/height/pixelFormat/timeBase
|
|
622
|
+
// from the first composited frame. Set the framerate explicitly, otherwise
|
|
623
|
+
// the encoder infers it from the frame timebase (often a fine-grained tick
|
|
624
|
+
// like 1/12800), which makes x264 pick an absurd level (huge MB rate) that
|
|
625
|
+
// browsers can't decode over WebRTC. Bound the GOP to ~2s: live receivers
|
|
626
|
+
// that join (or lose) mid-stream must get a keyframe quickly - the x264
|
|
627
|
+
// default of 250 frames leaves them waiting for many seconds.
|
|
628
|
+
const bitrate = this.options.video.bitrate;
|
|
629
|
+
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
630
|
+
maxBFrames: 0,
|
|
631
|
+
options: encoderOptions,
|
|
632
|
+
configure: (ctx) => {
|
|
633
|
+
ctx.framerate = new Rational(Math.round(fps), 1);
|
|
634
|
+
ctx.gopSize = Math.max(1, Math.round(fps * 2));
|
|
635
|
+
applyVideoBitrate(ctx, bitrate);
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
// Source frames may sit on another device or in a format the encoder rejects
|
|
639
|
+
this.videoFilter = createVideoTranscodeFilter({ width: -1, height: -1, encoder: encoderCodec, hardware: this.hardwareContext });
|
|
640
|
+
this.videoOutput = await this.createVideoOutput();
|
|
641
|
+
this.throwIfStopRequested();
|
|
642
|
+
// a frame source has no demuxer for stop() to interrupt, a silent one would block teardown
|
|
643
|
+
const video = interruptibleFrameSource(source.video, this.stopSignal.promise);
|
|
644
|
+
controls.push(pipeline(video, this.videoFilter, this.videoEncoder, this.videoOutput, opts));
|
|
645
|
+
}
|
|
646
|
+
// Audio
|
|
647
|
+
if (source.audio) {
|
|
648
|
+
const targetCodecId = this.options.supportedAudioCodecs[0] ?? FF_ENCODER_LIBOPUS;
|
|
649
|
+
let encoderCodec = null;
|
|
650
|
+
if (typeof targetCodecId === 'string') {
|
|
651
|
+
encoderCodec = Codec.findEncoderByName(targetCodecId);
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
encoderCodec = Codec.findEncoder(targetCodecId);
|
|
655
|
+
}
|
|
656
|
+
if (!encoderCodec) {
|
|
657
|
+
throw new Error(`No encoder found for audio codec ${String(targetCodecId)}`);
|
|
658
|
+
}
|
|
659
|
+
// There is no input stream to derive formats from - resample to the desired
|
|
660
|
+
// (or codec-preferred) sample format/rate/layout via an aformat filter.
|
|
661
|
+
const desiredSampleFormat = this.options.audio?.sampleFormat ?? AV_SAMPLE_FMT_FLTP;
|
|
662
|
+
const desiredSampleRate = this.options.audio?.sampleRate ?? 48000;
|
|
663
|
+
const desiredChannels = this.options.audio?.channels ?? 2;
|
|
664
|
+
const targetSampleFormat = this.selectSampleFormat(encoderCodec, desiredSampleFormat);
|
|
665
|
+
const targetSampleRate = this.selectSampleRate(encoderCodec, desiredSampleRate);
|
|
666
|
+
const channelLayoutStr = this.selectChannelLayout(encoderCodec, desiredChannels);
|
|
667
|
+
this.audioFilter = FilterAPI.create(FilterPreset.chain().aformat(targetSampleFormat, targetSampleRate, channelLayoutStr).build());
|
|
668
|
+
let encoderOptions = {};
|
|
669
|
+
if (encoderCodec.name === FF_ENCODER_LIBOPUS) {
|
|
670
|
+
encoderOptions.application = 'lowdelay';
|
|
671
|
+
encoderOptions.frame_duration = 20;
|
|
672
|
+
}
|
|
673
|
+
encoderOptions = { ...encoderOptions, ...this.options.audio.encoderOptions };
|
|
674
|
+
this.audioEncoder = await Encoder.create(encoderCodec, {
|
|
675
|
+
filter: this.audioFilter,
|
|
676
|
+
options: encoderOptions,
|
|
677
|
+
audioFrameBuffer: { maxGapFill: 0 },
|
|
678
|
+
});
|
|
679
|
+
this.audioOutput = await this.createAudioOutput();
|
|
680
|
+
this.throwIfStopRequested();
|
|
681
|
+
const audio = interruptibleFrameSource(source.audio, this.stopSignal.promise);
|
|
682
|
+
controls.push(pipeline(audio, this.audioFilter, this.audioEncoder, this.audioOutput, opts));
|
|
683
|
+
}
|
|
684
|
+
this.pipeline = new CombinedPipelineControl(controls);
|
|
685
|
+
this.attachCompletion(this.pipeline.completion);
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Create the RTP video output muxer.
|
|
689
|
+
*
|
|
690
|
+
* Rewrites RTP sequence numbers (continuous) and rebases the PTS-derived
|
|
691
|
+
* timestamps (90 kHz) onto a random initial value in the write callback, then
|
|
692
|
+
* forwards each packet to `onVideoPacket`. Uses `this.input` when present
|
|
693
|
+
* (demuxer path) and no input for the frame-source path.
|
|
694
|
+
*
|
|
695
|
+
* @returns The configured video muxer
|
|
696
|
+
*
|
|
697
|
+
* @internal
|
|
698
|
+
*/
|
|
699
|
+
async createVideoOutput() {
|
|
700
|
+
let videoSequenceNumber = Math.floor(Math.random() * 0xffff);
|
|
701
|
+
const videoTimestampBase = Math.floor(Math.random() * 0xffffffff) >>> 0; // unsigned 32-bit
|
|
702
|
+
let videoTimestampOffset;
|
|
703
|
+
const videoMuxerOptions = {};
|
|
704
|
+
if (this.options.video.ssrc !== undefined) {
|
|
705
|
+
videoMuxerOptions.ssrc = this.options.video.ssrc;
|
|
706
|
+
}
|
|
707
|
+
if (this.options.video.payloadType !== undefined) {
|
|
708
|
+
videoMuxerOptions.payload_type = this.options.video.payloadType;
|
|
709
|
+
}
|
|
710
|
+
return await Muxer.open({
|
|
711
|
+
write: (buffer) => {
|
|
712
|
+
if (isRtcp(buffer)) {
|
|
713
|
+
// Ignore RTCP packets
|
|
714
|
+
return buffer.length;
|
|
715
|
+
}
|
|
716
|
+
const rtpPacket = RtpPacket.deSerialize(buffer);
|
|
717
|
+
// Fix sequence number - ensure continuous sequence
|
|
718
|
+
rtpPacket.header.sequenceNumber = videoSequenceNumber;
|
|
719
|
+
videoSequenceNumber = (videoSequenceNumber + 1) & 0xffff; // Wrap at 16-bit
|
|
720
|
+
// Rebase the timestamp onto our own random initial value. FFmpeg's RTP
|
|
721
|
+
// muxer derives it from the packet PTS in the 90 kHz clock (plus its own
|
|
722
|
+
// random base), so the deltas carry the real presentation times -
|
|
723
|
+
// including B-frame reordering and VFR cadence. Only the base is swapped;
|
|
724
|
+
// modular uint32 arithmetic keeps wrap-around intact, and packets of the
|
|
725
|
+
// same frame keep sharing a timestamp.
|
|
726
|
+
videoTimestampOffset ??= (videoTimestampBase - rtpPacket.header.timestamp) >>> 0;
|
|
727
|
+
rtpPacket.header.timestamp = (rtpPacket.header.timestamp + videoTimestampOffset) >>> 0;
|
|
728
|
+
this.options.onVideoPacket(rtpPacket);
|
|
729
|
+
return buffer.length;
|
|
730
|
+
},
|
|
731
|
+
}, {
|
|
732
|
+
input: this.input,
|
|
733
|
+
copyInitialNonkeyframes: true,
|
|
734
|
+
format: 'rtp',
|
|
735
|
+
maxPacketSize: this.options.video.mtu,
|
|
736
|
+
options: videoMuxerOptions,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Create the RTP audio output muxer.
|
|
741
|
+
*
|
|
742
|
+
* Rewrites RTP sequence numbers (continuous) in the write callback and forwards
|
|
743
|
+
* each packet to `onAudioPacket`. Uses `this.input` when present (demuxer path)
|
|
744
|
+
* and no input for the frame-source path.
|
|
745
|
+
*
|
|
746
|
+
* @returns The configured audio muxer
|
|
747
|
+
*
|
|
748
|
+
* @internal
|
|
749
|
+
*/
|
|
750
|
+
async createAudioOutput() {
|
|
751
|
+
let audioSequenceNumber = Math.floor(Math.random() * 0xffff);
|
|
752
|
+
const audioMuxerOptions = {};
|
|
753
|
+
if (this.options.audio.ssrc !== undefined) {
|
|
754
|
+
audioMuxerOptions.ssrc = this.options.audio.ssrc;
|
|
755
|
+
}
|
|
756
|
+
if (this.options.audio.payloadType !== undefined) {
|
|
757
|
+
audioMuxerOptions.payload_type = this.options.audio.payloadType;
|
|
758
|
+
}
|
|
759
|
+
return await Muxer.open({
|
|
760
|
+
write: (buffer) => {
|
|
761
|
+
if (isRtcp(buffer)) {
|
|
762
|
+
// Ignore RTCP packets
|
|
763
|
+
return buffer.length;
|
|
764
|
+
}
|
|
765
|
+
const rtpPacket = RtpPacket.deSerialize(buffer);
|
|
766
|
+
// Fix sequence number - ensure continuous sequence
|
|
767
|
+
rtpPacket.header.sequenceNumber = audioSequenceNumber;
|
|
768
|
+
audioSequenceNumber = (audioSequenceNumber + 1) & 0xffff; // Wrap at 16-bit
|
|
769
|
+
this.options.onAudioPacket(rtpPacket);
|
|
770
|
+
return buffer.length;
|
|
771
|
+
},
|
|
772
|
+
}, {
|
|
773
|
+
input: this.input,
|
|
774
|
+
copyInitialNonkeyframes: true,
|
|
775
|
+
format: 'rtp',
|
|
776
|
+
maxPacketSize: this.options.audio.mtu,
|
|
777
|
+
options: audioMuxerOptions,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Run the streaming pipeline until completion or stopped.
|
|
782
|
+
*
|
|
783
|
+
* @internal
|
|
784
|
+
*/
|
|
785
|
+
async runPipeline() {
|
|
786
|
+
if (!this.input) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const hasVideo = this.input.video() !== undefined && this.videoOutput !== undefined;
|
|
790
|
+
const hasAudio = this.input.audio() !== undefined && this.audioOutput !== undefined;
|
|
791
|
+
const opts = this.signal ? { signal: this.signal } : undefined;
|
|
792
|
+
if (hasAudio && hasVideo) {
|
|
793
|
+
this.pipeline = pipeline(this.input, {
|
|
794
|
+
video: [this.videoDecoder, this.videoFilter, this.videoEncoder, this.videoBitstreamFilter],
|
|
795
|
+
audio: [this.audioDecoder, this.audioFilter, this.audioEncoder],
|
|
796
|
+
}, {
|
|
797
|
+
video: this.videoOutput,
|
|
798
|
+
audio: this.audioOutput,
|
|
799
|
+
}, opts);
|
|
800
|
+
}
|
|
801
|
+
else if (hasVideo) {
|
|
802
|
+
this.pipeline = pipeline(this.input, {
|
|
803
|
+
video: [this.videoDecoder, this.videoFilter, this.videoEncoder, this.videoBitstreamFilter],
|
|
804
|
+
}, this.videoOutput, opts);
|
|
805
|
+
}
|
|
806
|
+
else if (hasAudio) {
|
|
807
|
+
this.pipeline = pipeline(this.input, {
|
|
808
|
+
audio: [this.audioDecoder, this.audioFilter, this.audioEncoder],
|
|
809
|
+
}, this.audioOutput, opts);
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
throw new Error('No audio or video streams found in input');
|
|
813
|
+
}
|
|
814
|
+
await this.pipeline.completion;
|
|
815
|
+
this.pipeline = undefined;
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Stop streaming gracefully and clean up all resources.
|
|
819
|
+
*
|
|
820
|
+
* Stops the pipeline, closes output, and releases all FFmpeg resources.
|
|
821
|
+
* Safe to call multiple times. After stopping, you can call start() again
|
|
822
|
+
* to restart the stream. Resolves even if the pipeline had failed; that
|
|
823
|
+
* error is reported through `onClose(error)`.
|
|
824
|
+
*
|
|
825
|
+
* @example
|
|
826
|
+
* ```typescript
|
|
827
|
+
* const stream = RTPStream.create('input.mp4', {
|
|
828
|
+
* onVideoPacket: (rtp) => sendRtp(rtp)
|
|
829
|
+
* });
|
|
830
|
+
* await stream.start();
|
|
831
|
+
*
|
|
832
|
+
* // Stop after 10 seconds
|
|
833
|
+
* setTimeout(async () => await stream.stop(), 10000);
|
|
834
|
+
* ```
|
|
835
|
+
*/
|
|
836
|
+
async stop() {
|
|
837
|
+
// Memoized: concurrent stop() calls share one teardown instead of
|
|
838
|
+
// double-closing native resources.
|
|
839
|
+
this.stopPromise ??= this.doStop().finally(() => {
|
|
840
|
+
this.stopPromise = undefined;
|
|
841
|
+
});
|
|
842
|
+
await this.stopPromise;
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Perform the actual teardown (see {@link stop}).
|
|
846
|
+
*
|
|
847
|
+
* @internal
|
|
848
|
+
*/
|
|
849
|
+
async doStop() {
|
|
850
|
+
// An in-flight start() must unwind before teardown - otherwise it would
|
|
851
|
+
// keep building the pipeline on top of freed resources and leave an
|
|
852
|
+
// orphaned transcode running forever. The flag makes doStart() bail at its
|
|
853
|
+
// next checkpoint, the abort unblocks a startup stuck in the input open.
|
|
854
|
+
this.stopRequested = true;
|
|
855
|
+
this.startAbort?.abort();
|
|
856
|
+
this.startAbort = undefined;
|
|
857
|
+
// Unblocks a frame source that went silent, so the completion barrier below
|
|
858
|
+
// cannot wait on a pull that will never settle.
|
|
859
|
+
this.stopSignal.resolve();
|
|
860
|
+
const inflightStart = this.startPromise;
|
|
861
|
+
// Allow a fresh start() after stopping.
|
|
862
|
+
this.startPromise = undefined;
|
|
863
|
+
if (inflightStart) {
|
|
864
|
+
try {
|
|
865
|
+
await inflightStart;
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
// Aborted or failed startup - resources are torn down below either way.
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
try {
|
|
872
|
+
// Detach the abort listener so it cannot fire for a torn-down session and
|
|
873
|
+
// does not accumulate across start()/stop() cycles.
|
|
874
|
+
if (this.abortHandler) {
|
|
875
|
+
this.signal?.removeEventListener('abort', this.abortHandler);
|
|
876
|
+
this.abortHandler = undefined;
|
|
877
|
+
}
|
|
878
|
+
// Stop pipeline if running and wait for completion. pipeline.stop() interrupts
|
|
879
|
+
// the source demuxer's read (frame-source pipelines unwind via iterator.return
|
|
880
|
+
// instead). Swallow a rejected completion here - the error is already surfaced
|
|
881
|
+
// to the caller via the onClose(error) path; we just need cleanup to proceed.
|
|
882
|
+
const pipeline = this.pipeline;
|
|
883
|
+
if (pipeline) {
|
|
884
|
+
if (!pipeline.isStopped()) {
|
|
885
|
+
pipeline.stop();
|
|
886
|
+
}
|
|
887
|
+
try {
|
|
888
|
+
await pipeline.completion;
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
// Pipeline errored - proceed with teardown regardless.
|
|
892
|
+
}
|
|
893
|
+
if (this.pipeline === pipeline) {
|
|
894
|
+
this.pipeline = undefined;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// Close all resources. close() rethrows a failed background write after it
|
|
898
|
+
// freed the muxer. That error already ended the pipeline and reaches the
|
|
899
|
+
// owner through onClose(error), so it must not abort the teardown here and
|
|
900
|
+
// leak the other output, the codecs and the input (an RTSP connection).
|
|
901
|
+
try {
|
|
902
|
+
await this.videoOutput?.close();
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
// Surfaced through the pipeline completion.
|
|
906
|
+
}
|
|
907
|
+
this.videoOutput = undefined;
|
|
908
|
+
try {
|
|
909
|
+
await this.audioOutput?.close();
|
|
910
|
+
}
|
|
911
|
+
catch {
|
|
912
|
+
// Surfaced through the pipeline completion.
|
|
913
|
+
}
|
|
914
|
+
this.audioOutput = undefined;
|
|
915
|
+
// A codec that refuses to close (still busy) must not leave the input,
|
|
916
|
+
// an RTSP connection, open.
|
|
917
|
+
try {
|
|
918
|
+
this.videoEncoder?.close();
|
|
919
|
+
this.videoEncoder = undefined;
|
|
920
|
+
this.videoFilter?.close();
|
|
921
|
+
this.videoFilter = undefined;
|
|
922
|
+
this.videoDecoder?.close();
|
|
923
|
+
this.videoDecoder = undefined;
|
|
924
|
+
this.audioEncoder?.close();
|
|
925
|
+
this.audioEncoder = undefined;
|
|
926
|
+
this.audioFilter?.close();
|
|
927
|
+
this.audioFilter = undefined;
|
|
928
|
+
this.audioDecoder?.close();
|
|
929
|
+
this.audioDecoder = undefined;
|
|
930
|
+
this.hardwareContext?.dispose();
|
|
931
|
+
this.hardwareContext = undefined;
|
|
932
|
+
}
|
|
933
|
+
finally {
|
|
934
|
+
await this.input?.close();
|
|
935
|
+
this.input = undefined;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
finally {
|
|
939
|
+
this.stopRequested = false;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Check if the given audio codec is supported.
|
|
944
|
+
*
|
|
945
|
+
* @param codecId - The AVCodecID to check
|
|
946
|
+
*
|
|
947
|
+
* @returns True if the codec is supported, false otherwise
|
|
948
|
+
*
|
|
949
|
+
* @internal
|
|
950
|
+
*/
|
|
951
|
+
isAudioCodecSupported(codecId) {
|
|
952
|
+
// Empty set means all codecs are supported (passthrough only)
|
|
953
|
+
if (this.supportedAudioCodecs.size === 0) {
|
|
954
|
+
return true;
|
|
955
|
+
}
|
|
956
|
+
const isSupported = this.supportedAudioCodecs.has(codecId);
|
|
957
|
+
if (!isSupported) {
|
|
958
|
+
try {
|
|
959
|
+
const ffEncoderCodecs = Array.from(this.supportedAudioCodecs).filter((c) => typeof c === 'string');
|
|
960
|
+
for (const encoderName of ffEncoderCodecs) {
|
|
961
|
+
const encoderCodec = Codec.findEncoderByName(encoderName);
|
|
962
|
+
if (encoderCodec?.id === codecId) {
|
|
963
|
+
return true;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
catch {
|
|
968
|
+
// Ignore errors
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return isSupported;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Find the encoder for a target video codec, on the device when it works there.
|
|
975
|
+
*
|
|
976
|
+
* @param target - Codec ID, or the name of a specific encoder to use as it is
|
|
977
|
+
*
|
|
978
|
+
* @param size - The output size, when known before the first frame
|
|
979
|
+
*
|
|
980
|
+
* @param size.width - Width
|
|
981
|
+
*
|
|
982
|
+
* @param size.height - Height
|
|
983
|
+
*
|
|
984
|
+
* @returns The encoder, or null if none exists
|
|
985
|
+
*
|
|
986
|
+
* @internal
|
|
987
|
+
*/
|
|
988
|
+
async findVideoEncoder(target, size) {
|
|
989
|
+
if (typeof target === 'string') {
|
|
990
|
+
return Codec.findEncoderByName(target);
|
|
991
|
+
}
|
|
992
|
+
return (await findUsableHardwareEncoder(this.hardwareContext, target, this.options.video.encoderOptions, size)) ?? findSoftwareEncoder(target);
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Check if the given video codec is supported.
|
|
996
|
+
*
|
|
997
|
+
* @param codecId - The AVCodecID to check
|
|
998
|
+
*
|
|
999
|
+
* @returns True if the codec is supported, false otherwise
|
|
1000
|
+
*
|
|
1001
|
+
* @internal
|
|
1002
|
+
*/
|
|
1003
|
+
isVideoCodecSupported(codecId) {
|
|
1004
|
+
// Empty set means all codecs are supported (passthrough only)
|
|
1005
|
+
if (this.supportedVideoCodecs.size === 0) {
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
const isSupported = this.supportedVideoCodecs.has(codecId);
|
|
1009
|
+
if (!isSupported) {
|
|
1010
|
+
try {
|
|
1011
|
+
const ffEncoderCodecs = Array.from(this.supportedVideoCodecs).filter((c) => typeof c === 'string');
|
|
1012
|
+
for (const encoderName of ffEncoderCodecs) {
|
|
1013
|
+
const encoderCodec = Codec.findEncoderByName(encoderName);
|
|
1014
|
+
if (encoderCodec?.id === codecId) {
|
|
1015
|
+
return true;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
catch {
|
|
1020
|
+
// Ignore errors
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return isSupported;
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Select a codec-supported sample format for the desired one.
|
|
1027
|
+
*
|
|
1028
|
+
* Delegates to the shared {@link pickSupportedSampleFormat} so the RTP path and
|
|
1029
|
+
* the encoder negotiate formats identically.
|
|
1030
|
+
*
|
|
1031
|
+
* @param codec - Audio encoder codec
|
|
1032
|
+
*
|
|
1033
|
+
* @param desiredFormat - Desired sample format
|
|
1034
|
+
*
|
|
1035
|
+
* @returns A sample format the codec accepts
|
|
1036
|
+
*
|
|
1037
|
+
* @internal
|
|
1038
|
+
*/
|
|
1039
|
+
selectSampleFormat(codec, desiredFormat) {
|
|
1040
|
+
return pickSupportedSampleFormat(desiredFormat, codec.sampleFormats);
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Select a codec-supported sample rate for the desired one.
|
|
1044
|
+
*
|
|
1045
|
+
* Delegates to the shared {@link pickSupportedRate} (keeps the desired rate when
|
|
1046
|
+
* accepted, otherwise the nearest supported one).
|
|
1047
|
+
*
|
|
1048
|
+
* @param codec - Audio encoder codec
|
|
1049
|
+
*
|
|
1050
|
+
* @param desiredRate - Desired sample rate
|
|
1051
|
+
*
|
|
1052
|
+
* @returns A sample rate the codec accepts
|
|
1053
|
+
*
|
|
1054
|
+
* @internal
|
|
1055
|
+
*/
|
|
1056
|
+
selectSampleRate(codec, desiredRate) {
|
|
1057
|
+
return pickSupportedRate(desiredRate, codec.supportedSamplerates);
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Select a codec-supported channel layout for the desired channel count, as an
|
|
1061
|
+
* SDP layout string.
|
|
1062
|
+
*
|
|
1063
|
+
* Delegates the choice to the shared {@link pickSupportedLayout} (nearest channel
|
|
1064
|
+
* count when the exact count is unsupported), then renders it as `'mono'`,
|
|
1065
|
+
* `'stereo'`, or the layout mask.
|
|
1066
|
+
*
|
|
1067
|
+
* @param codec - Audio encoder codec
|
|
1068
|
+
*
|
|
1069
|
+
* @param desiredChannels - Desired number of channels
|
|
1070
|
+
*
|
|
1071
|
+
* @returns Channel layout string the codec accepts
|
|
1072
|
+
*
|
|
1073
|
+
* @internal
|
|
1074
|
+
*/
|
|
1075
|
+
selectChannelLayout(codec, desiredChannels) {
|
|
1076
|
+
const supportedLayouts = codec.channelLayouts;
|
|
1077
|
+
if (!supportedLayouts || supportedLayouts.length === 0) {
|
|
1078
|
+
return desiredChannels === 1 ? 'mono' : 'stereo'; // should normally not happen
|
|
1079
|
+
}
|
|
1080
|
+
const picked = pickSupportedLayout(avChannelLayoutDefault(desiredChannels), supportedLayouts);
|
|
1081
|
+
if (picked.nbChannels === 1)
|
|
1082
|
+
return 'mono';
|
|
1083
|
+
if (picked.nbChannels === 2)
|
|
1084
|
+
return 'stereo';
|
|
1085
|
+
return picked.mask.toString();
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Encoder options for live video: low-latency presets for x264/x265 and an IDR
|
|
1090
|
+
* for every requested keyframe, so a receiver that lost packets can resync.
|
|
1091
|
+
*
|
|
1092
|
+
* @param name - The encoder's name
|
|
1093
|
+
*
|
|
1094
|
+
* @param overrides - Caller options, applied last
|
|
1095
|
+
*
|
|
1096
|
+
* @returns The encoder options
|
|
1097
|
+
*
|
|
1098
|
+
* @internal
|
|
1099
|
+
*/
|
|
1100
|
+
function liveVideoEncoderOptions(name, overrides) {
|
|
1101
|
+
const options = {};
|
|
1102
|
+
if (name === FF_ENCODER_LIBX264 || name === FF_ENCODER_LIBX265) {
|
|
1103
|
+
options.preset = 'ultrafast';
|
|
1104
|
+
options.tune = 'zerolatency';
|
|
1105
|
+
}
|
|
1106
|
+
// VAAPI, D3D12 and VideoToolbox make a forced I frame an IDR without an option
|
|
1107
|
+
const forcedIdr = forcedIdrOption(name);
|
|
1108
|
+
if (forcedIdr) {
|
|
1109
|
+
options[forcedIdr] = true;
|
|
1110
|
+
}
|
|
1111
|
+
return { ...options, ...overrides };
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* The option that makes an encoder emit a forced I frame (frame.pictType = I) as IDR.
|
|
1115
|
+
*
|
|
1116
|
+
* @param name - The encoder's name
|
|
1117
|
+
*
|
|
1118
|
+
* @returns The option, or null when the encoder needs none or has none
|
|
1119
|
+
*
|
|
1120
|
+
* @internal
|
|
1121
|
+
*/
|
|
1122
|
+
function forcedIdrOption(name) {
|
|
1123
|
+
if (name === FF_ENCODER_LIBX264 || name === FF_ENCODER_LIBX265 || name?.endsWith('_nvenc')) {
|
|
1124
|
+
return 'forced-idr';
|
|
1125
|
+
}
|
|
1126
|
+
if (name?.endsWith('_qsv') || name?.endsWith('_amf')) {
|
|
1127
|
+
return 'forced_idr';
|
|
1128
|
+
}
|
|
1129
|
+
return null;
|
|
1130
|
+
}
|
|
1131
|
+
//# sourceMappingURL=rtp-stream.js.map
|