Build an FFmpeg Wrapper in Go for Live Streaming

This article provides a step-by-step guide on how to build a custom FFmpeg wrapper in Go (Golang) to facilitate live video streaming. You will learn how to execute FFmpeg as a subprocess, handle input and output pipes, manage the process lifecycle, and stream live video to a destination like an RTMP server.

Why Build a Custom Wrapper?

While there are existing Go bindings for FFmpeg, many rely on CGO, which complicates cross-compilation and deployment. Building a wrapper around the FFmpeg command-line interface (CLI) using Go’s os/exec package avoids CGO, allows for easy scaling, and gives you direct control over the streaming process.

Step 1: Define the Wrapper Structure

First, create a struct in Go to manage the FFmpeg process, its inputs, and its error logs.

package main

import (
    "io"
    "os/exec"
)

// FFmpegStreamer manages the FFmpeg subprocess for live streaming
type FFmpegStreamer struct {
    cmd    *exec.Cmd
    stdin  io.WriteCloser
    stderr io.ReadCloser
}

Step 2: Initialize the FFmpeg Command

To stream live video, you need to configure FFmpeg to accept input via a pipe (stdin) and output the stream to a destination (such as an RTMP or SRT URL).

The following function initializes the command with optimized arguments for low-latency live streaming.

// NewFFmpegStreamer configures the FFmpeg command
func NewFFmpegStreamer(rtmpURL string, width, height int, fps int) (*FFmpegStreamer, error) {
    // Arguments to read raw RGBA video from stdin and stream to an RTMP server
    args := []string{
        "-f", "rawvideo",
        "-vcodec", "rawvideo",
        "-pix_fmt", "rgba",
        "-s", string(width) + "x" + string(height),
        "-r", string(fps),
        "-i", "-", // Read input from stdin pipe
        "-c:v", "libx264",
        "-preset", "veryfast",
        "-tune", "zerolatency",
        "-f", "flv", // FLV container format for RTMP
        rtmpURL,
    }

    cmd := exec.Command("ffmpeg", args...)

    stdin, err := cmd.StdinPipe()
    if err != nil {
        return nil, err
    }

    stderr, err := cmd.StderrPipe()
    if err != nil {
        return nil, err
    }

    return &FFmpegStreamer{
        cmd:    cmd,
        stdin:  stdin,
        stderr: stderr,
    }, nil
}

Step 3: Start and Monitor the Process

You need to start the process and monitor its stderr output. FFmpeg writes its logs and status updates to stderr, which is essential for debugging and tracking stream health.

import (
    "bufio"
    "log"
)

// Start runs the FFmpeg process and monitors errors in a goroutine
func (s *FFmpegStreamer) Start() error {
    if err := s.cmd.Start(); err != nil {
        return err
    }

    // Read stderr in a separate goroutine to prevent blocking
    go func() {
        scanner := bufio.NewScanner(s.stderr)
        for scanner.Scan() {
            log.Printf("[FFmpeg] %s\n", scanner.Text())
        }
    }()

    return nil
}

Step 4: Write Video Frames to the Stream

With the process running and the input pipe open, you can write raw video frame data directly into the FFmpeg wrapper.

// WriteFrame sends raw byte data (e.g., RGBA pixels) to FFmpeg
func (s *FFmpegStreamer) WriteFrame(frame []byte) error {
    _, err := s.stdin.Write(frame)
    return err
}

Step 5: Stop the Stream Safely

To stop streaming, close the stdin pipe to tell FFmpeg that the input stream has ended. This allows FFmpeg to finish writing remaining packets and shut down gracefully.

// Close stops the FFmpeg process safely
func (s *FFmpegStreamer) Close() error {
    err := s.stdin.Close()
    if err != nil {
        return err
    }
    return s.cmd.Wait()
}

How to Use the Wrapper

Below is a complete implementation showing how to initialize the wrapper and stream generated dummy frames (e.g., solid color buffers) to an RTMP server.

package main

import (
    "log"
    "time"
)

func main() {
    rtmpDestination := "rtmp://localhost/live/stream_key"
    width, height, fps := 640, 480, 30
    frameSize := width * height * 4 // 4 bytes per pixel for RGBA

    streamer, err := NewFFmpegStreamer(rtmpDestination, width, height, fps)
    if err != nil {
        log.Fatalf("Failed to initialize streamer: %v", err)
    }

    if err := streamer.Start(); err != nil {
        log.Fatalf("Failed to start streamer: %v", err)
    }

    log.Println("Streaming started...")

    // Create a dummy frame (red color)
    dummyFrame := make([]byte, frameSize)
    for i := 0; i < frameSize; i += 4 {
        dummyFrame[i] = 255   // R
        dummyFrame[i+1] = 0   // G
        dummyFrame[i+2] = 0   // B
        dummyFrame[i+3] = 255 // A
    }

    // Stream for 10 seconds
    ticker := time.NewTicker(time.Second / time.Duration(fps))
    defer ticker.Stop()

    stopChan := time.After(10 * time.Second)

    for {
        select {
        case <-ticker.C:
            if err := streamer.WriteFrame(dummyFrame); err != nil {
                log.Printf("Error writing frame: %v", err)
                return
            }
        case <-stopChan:
            log.Println("Stopping stream...")
            if err := streamer.Close(); err != nil {
                log.Printf("Error closing streamer: %v", err)
            }
            log.Println("Stream stopped.")
            return
        }
    }
}