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:
- Input Pipe (
stdin): Feeds raw pixel data (e.g., RGBA or YUV420p) directly from Go memory to FFmpeg. - Process Execution (
exec.Cmd): Manages process parameters, working directory, and environment. - Log Pipe (
stderr): Captures runtime metrics and encoder errors generated by FFmpeg. - 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.Writecall blocks. Implement a buffered frame channel or drop frames to prevent memory spikes in live scenarios. - Pixel Formats: Using
yuv420pas input instead ofrgb24reduces 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.