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