278 lines
6.7 KiB
Go
278 lines
6.7 KiB
Go
package desktop
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const DefaultRTPPort = 5004
|
|
|
|
func normalizeRTPPort(port int) int {
|
|
if port <= 0 {
|
|
return DefaultRTPPort
|
|
}
|
|
return port
|
|
}
|
|
|
|
func normalizeVideoDimension(value, fallback int) int {
|
|
if value <= 0 {
|
|
value = fallback
|
|
}
|
|
if value%2 != 0 {
|
|
value--
|
|
}
|
|
if value < 2 {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizePipelineConfig(cfg PipelineConfig) PipelineConfig {
|
|
cfg.Port = normalizeRTPPort(cfg.Port)
|
|
cfg.Width = normalizeVideoDimension(cfg.Width, 1280)
|
|
cfg.Height = normalizeVideoDimension(cfg.Height, 720)
|
|
if cfg.FPS <= 0 {
|
|
cfg.FPS = 30
|
|
}
|
|
if cfg.Bitrate <= 0 {
|
|
cfg.Bitrate = 2000
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// PipelineManager manages the screen capture process lifecycle
|
|
type PipelineManager struct {
|
|
cmd *exec.Cmd
|
|
mu sync.Mutex
|
|
isRunning bool
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
type PipelineConfig struct {
|
|
Display string // e.g. ":0"
|
|
Port int // e.g. 5004
|
|
Width int // e.g. 1920
|
|
Height int // e.g. 1080
|
|
FPS int // e.g. 30
|
|
Bitrate int // in kbps, e.g. 2000
|
|
UseGPU bool // try using hardware accelerated encoding (nvh264enc / h264_nvenc)
|
|
ToolType string // "gstreamer" or "ffmpeg" or "auto"
|
|
}
|
|
|
|
func NewPipelineManager() *PipelineManager {
|
|
return &PipelineManager{}
|
|
}
|
|
|
|
// Start spawns the GStreamer or FFmpeg capture process in the background
|
|
func (pm *PipelineManager) Start(cfg PipelineConfig) error {
|
|
pm.mu.Lock()
|
|
defer pm.mu.Unlock()
|
|
|
|
if pm.isRunning {
|
|
return fmt.Errorf("pipeline is already running")
|
|
}
|
|
|
|
if cfg.Display == "" {
|
|
cfg.Display = os.Getenv("DISPLAY")
|
|
if cfg.Display == "" {
|
|
cfg.Display = ":0.0"
|
|
}
|
|
}
|
|
cfg = normalizePipelineConfig(cfg)
|
|
|
|
tool, args, err := pm.resolvePipeline(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve pipeline command: %w", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
pm.cancel = cancel
|
|
|
|
cmd := exec.CommandContext(ctx, tool, args...)
|
|
cmd.Env = desktopCommandEnv(cfg.Display)
|
|
|
|
// Capture stdout/stderr for logging
|
|
cmd.Stdout = log.Writer()
|
|
cmd.Stderr = log.Writer()
|
|
|
|
log.Printf("Starting capture pipeline: %s %s", tool, strings.Join(args, " "))
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
return fmt.Errorf("failed to start pipeline process: %w", err)
|
|
}
|
|
|
|
pm.cmd = cmd
|
|
pm.isRunning = true
|
|
|
|
// Monitor process termination asynchronously
|
|
go func() {
|
|
err := cmd.Wait()
|
|
pm.mu.Lock()
|
|
pm.isRunning = false
|
|
pm.cmd = nil
|
|
pm.mu.Unlock()
|
|
if err != nil {
|
|
log.Printf("Capture pipeline exited with error: %v", err)
|
|
} else {
|
|
log.Printf("Capture pipeline stopped cleanly")
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop terminates the capture process
|
|
func (pm *PipelineManager) Stop() {
|
|
pm.mu.Lock()
|
|
defer pm.mu.Unlock()
|
|
|
|
if !pm.isRunning || pm.cancel == nil {
|
|
return
|
|
}
|
|
|
|
log.Println("Stopping capture pipeline...")
|
|
pm.cancel() // cancels the context, sending SIGKILL or SIGTERM
|
|
|
|
// Wait up to 2 seconds for clean exit
|
|
for i := 0; i < 20; i++ {
|
|
if !pm.isRunning {
|
|
break
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
pm.isRunning = false
|
|
pm.cmd = nil
|
|
pm.cancel = nil
|
|
}
|
|
|
|
func (pm *PipelineManager) IsRunning() bool {
|
|
pm.mu.Lock()
|
|
defer pm.mu.Unlock()
|
|
return pm.isRunning
|
|
}
|
|
|
|
// resolvePipeline checks for available software and builds command arguments
|
|
func (pm *PipelineManager) resolvePipeline(cfg PipelineConfig) (string, []string, error) {
|
|
tool := cfg.ToolType
|
|
if tool == "auto" || tool == "" {
|
|
if pm.hasExecutable("gst-launch-1.0") {
|
|
tool = "gstreamer"
|
|
} else if pm.hasExecutable("ffmpeg") {
|
|
tool = "ffmpeg"
|
|
} else {
|
|
return "", nil, fmt.Errorf("neither GStreamer (gst-launch-1.0) nor FFmpeg was found in PATH")
|
|
}
|
|
}
|
|
|
|
switch tool {
|
|
case "gstreamer":
|
|
return pm.buildGStreamer(cfg)
|
|
case "ffmpeg":
|
|
return pm.buildFFmpeg(cfg)
|
|
default:
|
|
return "", nil, fmt.Errorf("unsupported capture tool type: %s", tool)
|
|
}
|
|
}
|
|
|
|
func (pm *PipelineManager) hasExecutable(name string) bool {
|
|
_, err := exec.LookPath(name)
|
|
return err == nil
|
|
}
|
|
|
|
func (pm *PipelineManager) buildGStreamer(cfg PipelineConfig) (string, []string, error) {
|
|
cfg = normalizePipelineConfig(cfg)
|
|
var pipelineParts []string
|
|
|
|
// 1. Capture Source (X11)
|
|
pipelineParts = append(pipelineParts, fmt.Sprintf("ximagesrc display-name=%s", cfg.Display))
|
|
pipelineParts = append(pipelineParts, fmt.Sprintf("video/x-raw,framerate=%d/1", cfg.FPS))
|
|
pipelineParts = append(pipelineParts, "videoconvert")
|
|
pipelineParts = append(pipelineParts, "videoscale")
|
|
pipelineParts = append(
|
|
pipelineParts,
|
|
fmt.Sprintf("video/x-raw,format=I420,width=%d,height=%d,framerate=%d/1", cfg.Width, cfg.Height, cfg.FPS),
|
|
)
|
|
|
|
// 2. Encoder
|
|
encoderStr := "x264enc speed-preset=ultrafast tune=zerolatency bitrate=" + fmt.Sprintf("%d", cfg.Bitrate) + " byte-stream=true key-int-max=30"
|
|
if cfg.UseGPU {
|
|
// Detect if nvcodec is present by calling gst-inspect-1.0 or simply attempting it.
|
|
// We'll default to nvh264enc.
|
|
encoderStr = "nvh264enc bitrate=" + fmt.Sprintf("%d", cfg.Bitrate) + " preset=low-latency gop-size=30"
|
|
}
|
|
pipelineParts = append(pipelineParts, encoderStr)
|
|
|
|
// 3. Payload and Sink
|
|
pipelineParts = append(pipelineParts, "video/x-h264,profile=baseline")
|
|
pipelineParts = append(pipelineParts, "rtph264pay config-interval=1 pt=96")
|
|
pipelineParts = append(pipelineParts, fmt.Sprintf("udpsink host=127.0.0.1 port=%d sync=false async=false", cfg.Port))
|
|
|
|
// Join GStreamer pipeline with '!'
|
|
pipelineStr := strings.Join(pipelineParts, " ! ")
|
|
args := []string{"-v"}
|
|
args = append(args, strings.Split(pipelineStr, " ")...)
|
|
|
|
var cleanArgs []string
|
|
for _, arg := range args {
|
|
trimmed := strings.TrimSpace(arg)
|
|
if trimmed != "" {
|
|
cleanArgs = append(cleanArgs, trimmed)
|
|
}
|
|
}
|
|
|
|
return "gst-launch-1.0", cleanArgs, nil
|
|
}
|
|
|
|
func (pm *PipelineManager) buildFFmpeg(cfg PipelineConfig) (string, []string, error) {
|
|
cfg = normalizePipelineConfig(cfg)
|
|
args := []string{
|
|
"-f", "x11grab",
|
|
"-draw_mouse", "1",
|
|
"-framerate", fmt.Sprintf("%d", cfg.FPS),
|
|
"-video_size", fmt.Sprintf("%dx%d", cfg.Width, cfg.Height),
|
|
"-i", cfg.Display,
|
|
}
|
|
|
|
// Encoder config
|
|
if cfg.UseGPU {
|
|
args = append(args,
|
|
"-c:v", "h264_nvenc",
|
|
"-preset", "llhp", // low latency high quality
|
|
"-tune", "zerolatency",
|
|
"-g", "30",
|
|
"-pix_fmt", "yuv420p",
|
|
)
|
|
} else {
|
|
args = append(args,
|
|
"-c:v", "libx264",
|
|
"-preset", "ultrafast",
|
|
"-tune", "zerolatency",
|
|
"-profile:v", "baseline",
|
|
"-pix_fmt", "yuv420p",
|
|
"-x264-params", "repeat-headers=1",
|
|
"-g", "30",
|
|
)
|
|
}
|
|
|
|
// Constant Bitrate
|
|
args = append(args,
|
|
"-b:v", fmt.Sprintf("%dk", cfg.Bitrate),
|
|
"-maxrate", fmt.Sprintf("%dk", cfg.Bitrate),
|
|
"-bufsize", fmt.Sprintf("%dk", cfg.Bitrate*2),
|
|
)
|
|
|
|
// RTP Stream over UDP
|
|
args = append(args,
|
|
"-f", "rtp",
|
|
fmt.Sprintf("rtp://127.0.0.1:%d", cfg.Port),
|
|
)
|
|
|
|
return "ffmpeg", args, nil
|
|
}
|