Skip to content

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Latest commit

 

History

64 Commits

Folders and files

Repository files navigation

screencast - Unified Screen Capture (BGRA Frames)

A Go package for screen capture with a unified io.Reader interface that yields raw BGRA frames. Supports Linux (xdg-desktop-portal + PipeWire), macOS (ScreenCaptureKit), and Windows (Windows Graphics Capture).

Features

  • Cross-Platform: Native OS dialogs or direct capture APIs on Linux, macOS 12.3+ (audio on 13+), and Windows 10+.
  • High-Performance Linux Backend: Uses cgo and libpipewire-0.3 to access PipeWire streams with low-overhead buffering for real-time capture.
  • Unified io.Reader Interface: Pipe raw BGRA frames directly into ffmpeg or any standard Go stream.
  • Audio Capture: Optional system audio capture (48kHz, 16-bit, stereo) on supported platforms.
  • Graceful Fallback: Dynamically loads the PipeWire C library at runtime (dlopen).
  • Robust Lifecycle Handling: Idempotent close paths, auto-cleanup when ffmpeg exits, and first-frame startup timeouts on desktop backends.

Requirements

Linux

macOS

  • macOS 12.3 or later
  • CGO enabled
  • System audio capture requires macOS 13.0 or later

Windows

  • Windows 10 (1809) or later
  • CGO enabled with a C/C++ compiler (e.g., MSYS2/MinGW-w64)
  • Uses Windows Graphics Capture (WinRT + D3D11). Windows 8.1 and older are not supported and may fail at runtime even if the application starts.

Installation

# Ubuntu / Debian build requirements (Linux only)
sudo apt install libpipewire-0.3-dev pkg-config

# Get the Go package
go get go2tv.app/screencast

Quick Start (Capture to FFmpeg)

This minimal example shows how to open a capture stream and pipe raw BGRA frames directly to FFmpeg.

package main

import (
	"fmt"
	"io"
	"log"
	"os"
	"os/exec"
	"strconv"

	"go2tv.app/screencast/capture"
)

func main() {
	// Default open enables audio and captures StreamIndex 0.
	stream, err := capture.Open(nil)
	if err != nil {
		log.Fatalf("Failed to open capture stream: %v", err)
	}
	defer stream.Close()

	if stream.Audio != nil {
		fmt.Println("Audio capture enabled (draining in background)")
		go func() {
			_, _ = io.Copy(io.Discard, stream.Audio)
		}()
	}

	frameRate := stream.FrameRate
	if frameRate == 0 {
		frameRate = 60
	}

	width, height := stream.Width, stream.Height

	// Pipe the io.Reader directly into FFmpeg
	cmd := exec.Command("ffmpeg",
		"-y", "-re",
		"-f", "rawvideo",
		"-pix_fmt", "bgra",
		"-s", fmt.Sprintf("%dx%d", width, height),
		"-r", strconv.FormatUint(uint64(frameRate), 10),
		"-i", "pipe:0", // Read from stdin
		"-c:v", "libx264",
		"-preset", "ultrafast",
		"-pix_fmt", "yuv420p",
		"output.mp4",
	)
	
	cmd.Stdin = stream
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	fmt.Println("Recording... Press Ctrl+C to stop.")
	if err := cmd.Run(); err != nil {
		log.Fatalf("FFmpeg exited with error: %v", err)
	}
}

Selecting Displays (macOS/Windows)

For app-driven selection, enumerate displays first and pass the chosen index to capture.Open.

displays, err := capture.ListDisplays()
if err != nil {
	// On unsupported platforms (e.g. Linux), this returns capture.ErrNotImplemented.
}
for _, d := range displays {
	fmt.Printf("[%d] %s (%dx%d) primary=%t\n", d.Index, d.Name, d.Width, d.Height, d.Primary)
}

stream, err := capture.Open(&capture.Options{
	StreamIndex:  1,   // user-selected display index
	IncludeAudio: true,
})

The same index can be forwarded to either output format:

session, err := hls.Start(&hls.Options{
	FFmpegPath:   "ffmpeg",
	IncludeAudio: true,
	StreamIndex:  1, // same display index selection
})

Output Formats

Both formats sit on the same capture, encoder selection and A/V sync machinery, and differ only in how the encoded result is handed back.

  • hls.Start writes a segmented playlist into a temp directory, for serving over HTTP. Use Session.Dir() with hls.NewDirectoryHandler.
  • ts.Start muxes one continuous MPEG-TS stream and hands it back as a single io.ReadCloser. Nothing in it speaks a transport protocol, so it suits a DLNA renderer, a Chromecast, a raw socket or a local player.
session, err := ts.Start(&ts.Options{
	FFmpegPath:   "ffmpeg",
	IncludeAudio: true,
})
if err != nil {
	log.Fatal(err)
}
defer session.Close()

// Closing the stream is a no-op: the session owns the ffmpeg process, so hand
// it to as many consumers as the transport needs without racing the encoder.
_, _ = io.Copy(w, session.Stream())

The stream is muxed in m2ts mode (192-byte packets with a 4-byte timestamp prefix), which is what the DLNA AVC_TS_MP_HD_AAC_MULT5 profile and the video/vnd.dlna.mpeg-tts media type require. That profile promises an AAC track, so only advertise it with IncludeAudio set.

ts.Start does not return until it has read enough of ffmpeg's output to prove it is really a well-formed m2ts stream - a PAT and the PMT it points at - so a renderer is only told to play once there is something valid to play. Those startup bytes are handed on to the first reads rather than discarded.

Running the Examples

go run examples/capture/main.go
SCREENCAST_STREAM_INDEX=1 go run examples/capture/main.go
go run examples/hls/main.go
SCREENCAST_STREAM_INDEX=1 go run examples/hls/main.go

HLS example output is served at http://127.0.0.1:8080/playlist.m3u8 by default. Optional environment variables:

  • SCREENCAST_FFMPEG=/path/to/ffmpeg (default: ffmpeg)
  • SCREENCAST_HLS_PORT=8080
  • SCREENCAST_HLS_AUDIO=1 (set 0 to disable audio)
  • SCREENCAST_STREAM_INDEX=0 (set monitor/display index for both examples)
  • Video encoder is auto-selected per host by probing the configured ffmpeg binary and running a short real encode test on hardware candidates (h264_nvenc, h264_amf, h264_qsv, h264_vaapi, h264_videotoolbox). If none pass, it falls back to libx264.

Cross-Platform Status

  • Linux: Implemented (xdg-desktop-portal + PipeWire)
  • macOS: Implemented (ScreenCaptureKit)
  • Windows: Implemented (Windows Graphics Capture)
    • Limitation: requires Windows 10 version 1809 or newer.

End-to-End Debugging

Set one environment variable to enable debug logging across capture, the encode pipeline, and ffmpeg paths:

SCREENCAST_DEBUG=1

Optional: write debug logs to a file (recommended for GUI apps on Windows/macOS):

SCREENCAST_DEBUG=1 SCREENCAST_DEBUG_FILE=/tmp/screencast-debug.log

What SCREENCAST_DEBUG=1 enables:

  • Capture lifecycle logs on all platforms (Linux/macOS/Windows), including first-frame timing.
  • Windows/macOS async callback queue diagnostics, including slow pipe writes and dropped video frames under pressure.
  • PipeWire internal stream debug logs (Linux backend).
  • ffmpeg command printing and ffmpeg stderr capture in hls.Start and ts.Start.
  • ffmpeg -loglevel debug in hls.Start and ts.Start.
  • Shared pipeline diagnostics (encoder probe, frame pacer, audio pre-roll), logged under screencast/pipeline.
  • HLS HTTP directory handler debug logs in hls.NewDirectoryHandler.

What SCREENCAST_DEBUG_FILE=/path/to/log does:

  • Routes capture debug logs to the file on all platforms (Linux/macOS/Windows).
  • Routes HLS, MPEG-TS and shared pipeline debug logs to the file on all platforms (Linux/macOS/Windows).
  • Routes PipeWire debug logs to the same file on Linux.
  • If your app provides custom log handlers/writers, debug output is still mirrored to this file.

Legacy variables still supported:

  • SCREENCAST_CAPTURE_DEBUG=1
  • SCREENCAST_CAPTURE_DEBUG_FILE=/path/to/file
  • SCREENCAST_PIPEWIRE_DEBUG=1
  • SCREENCAST_PIPEWIRE_DEBUG_FILE=/path/to/file

Session Notes

These apply to both hls.Session and ts.Session, which share the pipeline underneath.

  • Session cleanup is idempotent (Close() can be called multiple times safely).
  • If ffmpeg exits unexpectedly, session resources are now auto-cleaned.
  • When audio is requested but platform capture audio is unavailable, the pipeline injects paced synthetic silence so the audio track remains present.
  • ffmpeg derives timestamps for both raw inputs from byte/frame counts, so both are anchored to the wall clock: video is frame-paced and audio is trimmed/padded to match elapsed real time. Audio captured before ffmpeg attaches is discarded as pre-roll (SCREENCAST_DEBUG=1 reports audio_preroll_dropped).
  • If ffmpeg falls more than a couple of seconds behind, the pacer gives up on the frames it can never emit and the audio relay drops the same span, so the two stay in sync. Session.DroppedFrames() reports the running total: a count that keeps climbing means the capture is too big or too fast for the machine, and the caller should lower the frame rate or resolution.
  • Default startup timeout is 60s to reduce transient initialization failures under load.
  • Desktop backends use a first-frame timeout to avoid indefinite startup hangs.
  • For diagnostics after failure, use Session.StderrTail(n).

Used by

  • go2tv - Cast videos, music, and images from Linux to your TV.

Related projects

  • go2tv - Cast videos, music, and images from Linux to your TV.
  • mcp-beam - MCP server (stdio transport) for casting local files and media URLs to Chromecast and DLNA/UPnP devices on your LAN.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages