Build an FFmpeg Wrapper in Go for Live Video

This article explains how to build a Go-based wrapper around the FFmpeg CLI to ingest raw video frames and broadcast a live stream. You will learn how to configure process execution using Go's os/exec package, establish an input pipe to push video frames dynamically to standard input, set essential FFmpeg flags for low-latency RTMP or HLS streaming, and manage the process lifecycle with proper error handling and context cancellation.

Architecture of the Go FFmpeg Wrapper

Interfacing with FFmpeg directly via Go avoids the complexity of CGo and native C libraries like libavcodec. The wrapper acts as a parent process controlling an FFmpeg subprocess:

  1. Input Pipe (stdin): Feeds raw pixel data (e.g., RGBA or YUV420p) directly from Go memory to FFmpeg.
  2. Process Execution (exec.Cmd): Manages process parameters, working directory, and environment.
  3. Log Pipe (stderr): Captures runtime metrics and encoder errors generated by FFmpeg.
  4. Context Lifecycle (context.Context): Ensures FFmpeg is terminated cleanly when the Go application stops.

Defining the Streamer Structure

Create a struct to hold the command instance, standard input writer, and error log buffer.

package main

import (
    "context"
    "fmt"
    "io"
    "os/exec"
)

type FFmpegStreamer struct {
    cmd    *exec.Cmd
    stdin  io.WriteCloser
    stderr io.ReadCloser
    cancel context.CancelFunc
}

Configuring the FFmpeg Command

Construct the command with parameters configured for live ingestion and streaming. The following configuration ingests raw RGB frames and outputs an RTMP stream encoded with H.264:

func NewFFmpegStreamer(ctx context.Context, width, height, fps int, rtmpURL string) (*FFmpegStreamer, error) {
    ctx, cancel := context.WithCancel(ctx)

    args := []string{
        // Input options
        "-y",                       // Overwrite output
        "-f", "rawvideo",           // Input format is raw uncompressed video
        "-pix_fmt", "rgb24",        // Pixel format of input frames
        "-s", fmt.Sprintf("%dx%d", width, height), // Resolution
        "-r", fmt.Sprintf("%d", fps),              // Frame rate
        "-i", "pipe:0",              // Read from standard input

        // Encoding options
        "-c:v", "libx264",          // Video codec
        "-preset", "veryfast",      // Low-latency encoding preset
        "-tune", "zerolatency",     // Eliminate encoding delay
        "-b:v", "3000k",            // Video bitrate
        "-maxrate", "3000k",
        "-bufsize", "6000k",
        "-pix_fmt", "yuv420p",      // Output pixel format for player compatibility
        "-g", fmt.Sprintf("%d", fps * 2), // Keyframe interval (GOP size)

        // Output options
        "-f", "flv",                // Format for RTMP
        rtmpURL,
    }

    cmd := exec.CommandContext(ctx, "ffmpeg", args...)

    stdin, err := cmd.StdinPipe()
    if err != nil {
        cancel()
        return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
    }

    stderr, err := cmd.StderrPipe()
    if err != nil {
        cancel()
        return nil, fmt.Errorf("failed to create stderr pipe: %w", err)
    }

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

Starting the Process and Handling Logs

FFmpeg emits status and errors to stderr. Reading from this pipe asynchronously prevents buffer deadlocks and provides debugging visibility.

func (s *FFmpegStreamer) Start() error {
    if err := s.cmd.Start(); err != nil {
        return fmt.Errorf("failed to start ffmpeg: %w", err)
    }

    // Read logs asynchronously to avoid blocking
    go func() {
        buf := make([]byte, 1024)
        for {
            n, err := s.stderr.Read(buf)
            if n > 0 {
                // For production, parse or route to a structured logger
                fmt.Print(string(buf[:n]))
            }
            if err != nil {
                break
            }
        }
    }()

    return nil
}

Pushing Frames and Stopping the Stream

Expose methods to write raw byte slices into the input pipe and cleanly finalize the process.

// WriteFrame pushes a raw byte slice corresponding to one frame.
func (s *FFmpegStreamer) WriteFrame(frame []byte) error {
    _, err := s.stdin.Write(frame)
    return err
}

// Stop closes the input pipe and waits for FFmpeg to finish encoding.
func (s *FFmpegStreamer) Stop() error {
    // Closing stdin signals EOF to FFmpeg
    if err := s.stdin.Close(); err != nil {
        s.cancel()
        return err
    }

    err := s.cmd.Wait()
    s.cancel()
    return err
}

Implementation Example

Below is a complete implementation that generates synthetic colored frames and streams them to a local RTMP server.

package main

import (
    "context"
    "log"
    "time"
)

func main() {
    width := 640
    height := 480
    fps := 30
    frameSize := width * height * 3 // 3 bytes per pixel for rgb24
    rtmpTarget := "rtmp://localhost:1935/live/stream"

    streamer, err := NewFFmpegStreamer(context.Background(), width, height, fps, rtmpTarget)
    if err != nil {
        log.Fatalf("Initialization failed: %v", err)
    }

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

    frameInterval := time.Second / time.Duration(fps)
    ticker := time.NewTicker(frameInterval)
    defer ticker.Stop()

    frame := make([]byte, frameSize)
    colorByte := byte(0)

    // Stream for 10 seconds
    stopTimeout := time.After(10 * time.Second)

    log.Println("Streaming started...")
    for {
        select {
        case <-stopTimeout:
            log.Println("Stopping stream...")
            if err := streamer.Stop(); err != nil {
                log.Printf("Error stopping streamer: %v", err)
            }
            return
        case <-ticker.C:
            // Populate frame with a solid color that shifts over time
            for i := 0; i < frameSize; i++ {
                frame[i] = colorByte
            }
            colorByte = (colorByte + 1) % 255

            if err := streamer.WriteFrame(frame); err != nil {
                log.Fatalf("Write frame error: %v", err)
            }
        }
    }
}

Production Considerations

  • Buffer Management: If the frame generation rate exceeds FFmpeg's encoding throughput, the stdin.Write call blocks. Implement a buffered frame channel or drop frames to prevent memory spikes in live scenarios.
  • Pixel Formats: Using yuv420p as input instead of rgb24 reduces memory throughput by 50% and removes the need for FFmpeg to perform color space conversion.
  • Process Recovery: Wrap the execution logic in a supervisor loop that automatically restarts FFmpeg if the subprocess exits unexpectedly due to network drops or corrupt data.