Examples#
Note
The Sender and Receiver examples both make use of UNIX pipes and will only work on supported operating systems.
Sender#
Frames are generated by ffmpeg running in a subprocess which outputs the raw
data to its stdout.
A Sender is created and an instance of
VideoSendFrame is added to it.
The raw data from ffmpeg is then fed using the Sender’s
write_video_async() method.
1from __future__ import annotations
2
3from typing import NamedTuple, Generator, Any, cast
4from typing_extensions import Self
5import enum
6import io
7import subprocess
8import shlex
9from fractions import Fraction
10from contextlib import contextmanager
11
12import click
13
14from cyndilib.wrapper.ndi_structs import FourCC
15from cyndilib.video_frame import VideoSendFrame
16from cyndilib.sender import Sender
17
18
19FF_CMD = '{ffmpeg} -f lavfi -i testsrc2=size={xres}x{yres}:rate={fps} \
20 -pix_fmt {pix_fmt.name} -f rawvideo pipe: '
21
22
23
24class PixFmt(enum.Enum):
25 """Maps ffmpeg's ``pix_fmt`` names to their corresponding
26 :class:`FourCC <cyndilib.wrapper.ndi_structs.FourCC>` types
27 """
28 uyvy422 = FourCC.UYVY #: uyvy422
29 nv12 = FourCC.NV12 #: nv12
30 rgba = FourCC.RGBA #: rgba
31 bgra = FourCC.BGRA #: bgra
32
33 @classmethod
34 def from_str(cls, name: str) -> Self:
35 return cls.__members__[name]
36
37
38class Options(NamedTuple):
39 """Options set through the cli
40 """
41 pix_fmt: PixFmt #: Pixel format to send
42 xres: int #: Horizontal resolution
43 yres: int #: Vertical resolution
44 fps: str #: Frame rate
45 sender_name: str = 'ffmpeg_sender' #: NDI name for the sender
46 ffmpeg: str = 'ffmpeg' #: Name/Path of the "ffmpeg" executable
47
48
49def parse_frame_rate(fr: str) -> Fraction:
50 """Helper for NTSC frame rates (29.97, 59.94)
51 """
52 if '/' in fr:
53 n, d = [int(s) for s in fr.split('/')]
54 elif '.' in fr:
55 n = round(float(fr)) * 1000
56 d = 1001
57 else:
58 n = int(fr)
59 d = 1
60 return Fraction(n, d)
61
62
63@contextmanager
64def ffmpeg_proc(opts: Options) -> Generator[subprocess.Popen[bytes], Any, None]:
65 """Context manager for the ffmpeg subprocess generating frames
66 """
67 ff_cmd = FF_CMD.format(**opts._asdict())
68 ff_proc = subprocess.Popen(shlex.split(ff_cmd), stdout=subprocess.PIPE)
69 try:
70 ff_proc.poll()
71 if ff_proc.returncode is None:
72 yield ff_proc
73 finally:
74 ff_proc.kill()
75
76
77def send(opts: Options) -> None:
78 """Send frames generated by ffmpeg's ``testsrc2`` as an |NDI| stream
79
80 The raw frames generated by ffmpeg are sent to a pipe and read from its
81 :attr:`~subprocess.Popen.stdout`. The data is then fed directly to
82 :meth:`cyndilib.sender.Sender.write_video_async` using an
83 intermediate :class:`memoryview`
84 """
85 sender = Sender(opts.sender_name)
86
87 # Build a VideoSendFrame and set its resolution and frame rate
88 # to match the options argument
89 vf = VideoSendFrame()
90 vf.set_resolution(opts.xres, opts.yres)
91 fr = parse_frame_rate(opts.fps)
92 vf.set_frame_rate(fr)
93 vf.set_fourcc(opts.pix_fmt.value)
94
95 # Add the VideoSendFrame to the sender
96 sender.set_video_frame(vf)
97
98 # Pre-allocate a bytearray to hold frame data and create a view of it
99 # So we can buffer into it from ffmpeg then pass directly to the sender
100 frame_size_bytes = vf.get_data_size()
101 ba = bytearray(frame_size_bytes)
102 mv = memoryview(ba)
103
104 i = 0
105
106 with sender:
107 with ffmpeg_proc(opts) as ff_proc:
108 stdout = cast(io.BytesIO, ff_proc.stdout)
109 while True:
110 if ff_proc.returncode is not None:
111 break
112 # Read from the ffmpeg process into a view of the bytearray
113 num_read = stdout.readinto(mv)
114
115 # The first few reads might be empty, ignore
116 if num_read == 0:
117 continue
118
119 # Pass the memoryview directly to the sender
120 # (using the buffer protocol)
121 sender.write_video_async(mv)
122
123 i += 1
124 if i % 10 == 0:
125 ff_proc.poll()
126
127
128
129@click.command()
130@click.option(
131 '--pix-fmt',
132 type=click.Choice(choices=[m.name for m in PixFmt]),
133 default=PixFmt.uyvy422.name,
134 show_default=True,
135 show_choices=True,
136)
137@click.option('-x', '--x-res', type=int, default=1920, show_default=True)
138@click.option('-y', '--y-res', type=int, default=1080, show_default=True)
139@click.option('--fps', type=str, default='30', show_default=True)
140@click.option(
141 '-n', '--sender-name',
142 type=str,
143 default='ffmpeg_sender',
144 show_default=True,
145 help='NDI name for the sender',
146)
147@click.option(
148 '--ffmpeg',
149 type=str,
150 default='ffmpeg',
151 show_default=True,
152 help='Name/Path of the "ffmpeg" executable',
153)
154def main(pix_fmt: str, x_res: int, y_res: int, fps: str, sender_name: str, ffmpeg: str):
155 opts = Options(
156 pix_fmt=PixFmt.from_str(pix_fmt),
157 xres=x_res,
158 yres=y_res,
159 fps=fps,
160 sender_name=sender_name,
161 ffmpeg=ffmpeg,
162 )
163 send(opts)
164
165
166if __name__ == '__main__':
167 main()
Receiver#
This example receives video frames from an NDI® Source
and shows them using ffplay.
Finder is used to locate the Source
with the given name.
A Receiver then created and an instance of
VideoFrameSync is added to it.
Video frames are then read using the
FrameSync.capture_video
method which is available from the frame_sync attribute
on the receiver.
The data is then fed to the stdin of the fplay
subprocess directly from the video frame using the
buffer protocol.
1from __future__ import annotations
2
3from typing import NamedTuple, TYPE_CHECKING
4from typing_extensions import Self
5import enum
6import time
7import subprocess
8import shlex
9
10import click
11
12from cyndilib.wrapper.ndi_structs import FourCC
13from cyndilib.wrapper.ndi_recv import RecvColorFormat, RecvBandwidth
14from cyndilib.video_frame import VideoFrameSync
15from cyndilib.receiver import Receiver
16from cyndilib.finder import Finder
17if TYPE_CHECKING:
18 from fractions import Fraction
19 from cyndilib.finder import Source
20 from cyndilib.framesync import FrameSync
21
22
23FF_PLAY = '{ffplay} -video_size {xres}x{yres} -pixel_format {pix_fmt} -f rawvideo -i pipe:'
24"""ffplay command line format"""
25
26
27pix_fmts = {
28 FourCC.UYVY: 'uyvy422',
29 FourCC.NV12: 'nv12',
30 FourCC.RGBA: 'rgba',
31 FourCC.BGRA: 'bgra',
32 FourCC.RGBX: 'rgba',
33 FourCC.BGRX: 'bgra',
34}
35"""Mapping of :class:`FourCC <cyndilib.wrapper.ndi_structs.FourCC>` types to
36ffmpeg's ``pix_fmt`` definitions
37"""
38
39
40class RecvFmt(enum.Enum):
41 """Pixel format to receive (mapped to values of
42 :class:`cyndilib.wrapper.ndi_recv.RecvColorFormat`)
43 """
44 uyvy = RecvColorFormat.UYVY_RGBA #: UYVY (RGBA if alpha is present)
45 rgb = RecvColorFormat.RGBX_RGBA #: RGB / RGBA
46 bgr = RecvColorFormat.BGRX_BGRA #: BGR / BGRA
47
48 @classmethod
49 def from_str(cls, name: str) -> Self:
50 return cls.__members__[name]
51
52
53class Bandwidth(enum.Enum):
54 """Receive bandwidth
55 """
56 lowest = RecvBandwidth.lowest #: Lowest
57 highest = RecvBandwidth.highest #: Highest
58
59 @classmethod
60 def from_str(cls, name: str) -> Self:
61 return cls.__members__[name]
62
63
64class Options(NamedTuple):
65 """Options set through the cli
66 """
67 sender_name: str = 'ffmpeg_sender'
68 """The name of the |NDI| source to connect to"""
69
70 recv_fmt: RecvFmt = RecvFmt.uyvy
71 """Receive pixel format"""
72
73 recv_bandwidth: Bandwidth = Bandwidth.highest
74 """Receive bandwidth"""
75
76 ffplay: str = 'ffplay'
77 """Name/Path of the ``ffplay`` executable"""
78
79
80def get_source(finder: Finder, name: str) -> Source:
81 """Use the Finder to search for an NDI source by name using either its
82 full name or its :attr:`~cyndilib.finder.Source.stream_name`
83 """
84 click.echo('waiting for ndi sources...')
85 finder.wait_for_sources(10)
86 for source in finder:
87 if source.name == name or source.stream_name == name:
88 return source
89 raise Exception(f'source not found. {finder.get_source_names()=}')
90
91
92def wait_for_first_frame(receiver: Receiver) -> None:
93 """The first few frames contain no data. Capture frames until the first
94 non-empty one
95 """
96 vf = receiver.frame_sync.video_frame
97 frame_rate: Fraction = vf.get_frame_rate()
98 wait_time = float(1 / frame_rate)
99 click.echo('waiting for frame...')
100 while receiver.is_connected():
101 receiver.frame_sync.capture_video()
102 resolution = vf.get_resolution()
103 if min(resolution) > 0 and vf.get_data_size() > 0:
104 click.echo('have frame')
105 return
106 time.sleep(wait_time)
107
108
109def play(options: Options) -> None:
110 """Create the :class:`~cyndilib.receiver.Receiver` and send the frames to
111 ``ffplay``
112 """
113 # Get the NDI source and keep the Finder open until exit
114 with Finder() as finder:
115 source = get_source(finder, options.sender_name)
116
117 # Build the receiver and video frame
118 receiver = Receiver(
119 color_format=options.recv_fmt.value,
120 bandwidth=options.recv_bandwidth.value,
121 )
122 vf = VideoFrameSync()
123 frame_sync: FrameSync = receiver.frame_sync
124 frame_sync.set_video_frame(vf)
125
126 # Set the receiver source and wait for it to connect
127 receiver.set_source(source)
128 click.echo(f'connecting to "{source.name}"...')
129 i = 0
130 while not receiver.is_connected():
131 if i > 30:
132 raise Exception('timeout waiting for connection')
133 time.sleep(.5)
134 i += 1
135 click.echo('connected')
136
137 proc: subprocess.Popen|None = None
138
139 try:
140 wait_for_first_frame(receiver)
141 # At this point we should have received a frame, so the pixel format,
142 # resolution and frame rate should be populated.
143 fourcc = vf.get_fourcc()
144 frame_rate: Fraction = vf.get_frame_rate()
145 wait_time = float(1 / frame_rate)
146 xres, yres = vf.get_resolution()
147
148 cmd_str = FF_PLAY.format(
149 xres=xres,
150 yres=yres,
151 pix_fmt=pix_fmts[fourcc],
152 ffplay=options.ffplay,
153 )
154 click.echo(f'{cmd_str=}')
155 proc = subprocess.Popen(shlex.split(cmd_str), stdin=subprocess.PIPE)
156 assert proc.stdin is not None
157
158 # Since we already have a frame with data, write it to ffplay
159 # Note that the frame object itself is directly used as the data source
160 # (since `VideoFrameSync` supports the buffer protocol)
161 proc.stdin.write(vf)
162
163 while receiver.is_connected():
164 # Not the best timing method, but we're using `FrameSync` to
165 # capture frames, so it'll correct things for us (within reason).
166 time.sleep(wait_time)
167 receiver.frame_sync.capture_video()
168 proc.poll()
169 if proc.returncode is not None:
170 break
171 proc.stdin.write(vf)
172
173 finally:
174 if proc is not None:
175 proc.kill()
176
177
178@click.command()
179@click.option(
180 '-s', '--sender-name',
181 type=str,
182 default='ffmpeg_sender',
183 show_default=True,
184 help='The NDI source name to connect to',
185)
186@click.option(
187 '-f', '--recv-fmt',
188 type=click.Choice(choices=[m.name for m in RecvFmt]),
189 default='uyvy',
190 show_default=True,
191 show_choices=True,
192 help='Pixel format'
193)
194@click.option(
195 '-b', '--recv-bandwidth',
196 type=click.Choice(choices=[m.name for m in Bandwidth]),
197 default='highest',
198 show_default=True,
199 show_choices=True,
200)
201@click.option(
202 '--ffplay',
203 type=str,
204 default='ffplay',
205 show_default=True,
206 help='Name/Path of the "ffplay" executable',
207)
208def main(sender_name: str, recv_fmt: str, recv_bandwidth: str, ffplay: str):
209 options = Options(
210 sender_name=sender_name,
211 recv_fmt=RecvFmt.from_str(recv_fmt),
212 recv_bandwidth=Bandwidth.from_str(recv_bandwidth),
213 ffplay=ffplay,
214 )
215 play(options)
216
217
218if __name__ == '__main__':
219 main()