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()