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 cyndilib.finder import Source
19
20
21FF_PLAY = '{ffplay} -video_size {xres}x{yres} -pixel_format {pix_fmt} -f rawvideo -i pipe:'
22"""ffplay command line format"""
23
24
25pix_fmts = {
26 FourCC.UYVY: 'uyvy422',
27 FourCC.NV12: 'nv12',
28 FourCC.RGBA: 'rgba',
29 FourCC.BGRA: 'bgra',
30 FourCC.RGBX: 'rgba',
31 FourCC.BGRX: 'bgra',
32}
33"""Mapping of :class:`FourCC <cyndilib.wrapper.ndi_structs.FourCC>` types to
34ffmpeg's ``pix_fmt`` definitions
35"""
36
37
38class RecvFmt(enum.Enum):
39 """Pixel format to receive (mapped to values of
40 :class:`cyndilib.wrapper.ndi_recv.RecvColorFormat`)
41 """
42 uyvy = RecvColorFormat.UYVY_RGBA #: UYVY (RGBA if alpha is present)
43 rgb = RecvColorFormat.RGBX_RGBA #: RGB / RGBA
44 bgr = RecvColorFormat.BGRX_BGRA #: BGR / BGRA
45
46 @classmethod
47 def from_str(cls, name: str) -> Self:
48 return cls.__members__[name]
49
50
51class Bandwidth(enum.Enum):
52 """Receive bandwidth
53 """
54 lowest = RecvBandwidth.lowest #: Lowest
55 highest = RecvBandwidth.highest #: Highest
56
57 @classmethod
58 def from_str(cls, name: str) -> Self:
59 return cls.__members__[name]
60
61
62class Options(NamedTuple):
63 """Options set through the cli
64 """
65 sender_name: str = 'ffmpeg_sender'
66 """The name of the |NDI| source to connect to"""
67
68 recv_fmt: RecvFmt = RecvFmt.uyvy
69 """Receive pixel format"""
70
71 recv_bandwidth: Bandwidth = Bandwidth.highest
72 """Receive bandwidth"""
73
74 ffplay: str = 'ffplay'
75 """Name/Path of the ``ffplay`` executable"""
76
77
78def get_source(finder: Finder, name: str) -> Source:
79 """Use the Finder to search for an NDI source by name using either its
80 full name or its :attr:`~cyndilib.finder.Source.stream_name`
81 """
82 click.echo('waiting for ndi sources...')
83 finder.wait_for_sources(10)
84 for source in finder:
85 if source.name == name or source.stream_name == name:
86 return source
87 raise Exception(f'source not found. {finder.get_source_names()=}')
88
89
90def wait_for_first_frame(receiver: Receiver) -> None:
91 """The first few frames contain no data. Capture frames until the first
92 non-empty one
93 """
94 vf = receiver.frame_sync.video_frame
95 assert vf is not None
96 frame_rate = vf.get_frame_rate()
97 wait_time = float(1 / frame_rate)
98 click.echo('waiting for frame...')
99 while receiver.is_connected():
100 receiver.frame_sync.capture_video()
101 resolution = vf.get_resolution()
102 if min(resolution) > 0 and vf.get_data_size() > 0:
103 click.echo('have frame')
104 return
105 time.sleep(wait_time)
106
107
108def play(options: Options) -> None:
109 """Create the :class:`~cyndilib.receiver.Receiver` and send the frames to
110 ``ffplay``
111 """
112 # Get the NDI source and keep the Finder open until exit
113 with Finder() as finder:
114 source = get_source(finder, options.sender_name)
115
116 # Build the receiver and video frame
117 receiver = Receiver(
118 color_format=options.recv_fmt.value,
119 bandwidth=options.recv_bandwidth.value,
120 )
121 vf = VideoFrameSync()
122 frame_sync = receiver.frame_sync
123 frame_sync.set_video_frame(vf)
124
125 # Set the receiver source and wait for it to connect
126 receiver.set_source(source)
127 click.echo(f'connecting to "{source.name}"...')
128 i = 0
129 while not receiver.is_connected():
130 if i > 30:
131 raise Exception('timeout waiting for connection')
132 time.sleep(.5)
133 i += 1
134 click.echo('connected')
135
136 proc: subprocess.Popen|None = None
137
138 try:
139 wait_for_first_frame(receiver)
140 # At this point we should have received a frame, so the pixel format,
141 # resolution and frame rate should be populated.
142 fourcc = vf.get_fourcc()
143 frame_rate = vf.get_frame_rate()
144 wait_time = float(1 / frame_rate)
145 xres, yres = vf.get_resolution()
146
147 cmd_str = FF_PLAY.format(
148 xres=xres,
149 yres=yres,
150 pix_fmt=pix_fmts[fourcc],
151 ffplay=options.ffplay,
152 )
153 click.echo(f'{cmd_str=}')
154 proc = subprocess.Popen(shlex.split(cmd_str), stdin=subprocess.PIPE)
155 assert proc.stdin is not None
156
157 # Since we already have a frame with data, write it to ffplay
158 # Note that the frame object itself is directly used as the data source
159 # (since `VideoFrameSync` supports the buffer protocol)
160 proc.stdin.write(vf)
161
162 while receiver.is_connected():
163 # Not the best timing method, but we're using `FrameSync` to
164 # capture frames, so it'll correct things for us (within reason).
165 time.sleep(wait_time)
166 receiver.frame_sync.capture_video()
167 proc.poll()
168 if proc.returncode is not None:
169 break
170 proc.stdin.write(vf)
171
172 finally:
173 if proc is not None:
174 proc.kill()
175
176
177@click.command()
178@click.option(
179 '-s', '--sender-name',
180 type=str,
181 default='ffmpeg_sender',
182 show_default=True,
183 help='The NDI source name to connect to',
184)
185@click.option(
186 '-f', '--recv-fmt',
187 type=click.Choice(choices=[m.name for m in RecvFmt]),
188 default='uyvy',
189 show_default=True,
190 show_choices=True,
191 help='Pixel format'
192)
193@click.option(
194 '-b', '--recv-bandwidth',
195 type=click.Choice(choices=[m.name for m in Bandwidth]),
196 default='highest',
197 show_default=True,
198 show_choices=True,
199)
200@click.option(
201 '--ffplay',
202 type=str,
203 default='ffplay',
204 show_default=True,
205 help='Name/Path of the "ffplay" executable',
206)
207def main(sender_name: str, recv_fmt: str, recv_bandwidth: str, ffplay: str):
208 options = Options(
209 sender_name=sender_name,
210 recv_fmt=RecvFmt.from_str(recv_fmt),
211 recv_bandwidth=Bandwidth.from_str(recv_bandwidth),
212 ffplay=ffplay,
213 )
214 play(options)
215
216
217if __name__ == '__main__':
218 main()