|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "log" |
| 7 | + "os" |
| 8 | + "os/exec" |
| 9 | + "os/signal" |
| 10 | + "syscall" |
| 11 | + "time" |
| 12 | +) |
| 13 | + |
| 14 | +type streamerConfig struct { |
| 15 | + ffmpegBinary string |
| 16 | + cameraDevice string |
| 17 | + audioDevice string |
| 18 | + frameRate string |
| 19 | + resolution string |
| 20 | + bitrate string |
| 21 | + streamName string |
| 22 | + targetHost string |
| 23 | + publishUser string |
| 24 | + publishPass string |
| 25 | + rtspTransport string |
| 26 | + inputFormat string |
| 27 | +} |
| 28 | + |
| 29 | +func main() { |
| 30 | + cfg := loadConfig() |
| 31 | + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) |
| 32 | + defer cancel() |
| 33 | + |
| 34 | + logger := log.New(os.Stdout, "streamer: ", log.LstdFlags|log.Lmicroseconds) |
| 35 | + logger.Printf("starting streamer with camera=%s target=%s stream=%s", cfg.cameraDevice, cfg.targetHost, cfg.streamName) |
| 36 | + if cfg.publishUser == "" { |
| 37 | + logger.Println("warning: RELAY_PUBLISH_USER is empty; publishing will fail if the relay requires authentication") |
| 38 | + } |
| 39 | + if cfg.publishUser != "" && cfg.publishPass == "" { |
| 40 | + logger.Println("warning: RELAY_PUBLISH_PASS is empty while RELAY_PUBLISH_USER is set") |
| 41 | + } |
| 42 | + |
| 43 | + retryDelay := 3 * time.Second |
| 44 | + |
| 45 | + for { |
| 46 | + err := runFFmpeg(ctx, cfg, logger) |
| 47 | + if ctx.Err() != nil { |
| 48 | + logger.Println("shutdown requested, exiting") |
| 49 | + return |
| 50 | + } |
| 51 | + logger.Printf("ffmpeg exited: %v", err) |
| 52 | + logger.Printf("retrying in %s", retryDelay) |
| 53 | + select { |
| 54 | + case <-time.After(retryDelay): |
| 55 | + case <-ctx.Done(): |
| 56 | + logger.Println("shutdown requested during backoff, exiting") |
| 57 | + return |
| 58 | + } |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +func loadConfig() streamerConfig { |
| 63 | + return streamerConfig{ |
| 64 | + ffmpegBinary: readEnv("FFMPEG_BINARY", "ffmpeg"), |
| 65 | + cameraDevice: readEnv("CAMERA_DEVICE", "/dev/video0"), |
| 66 | + audioDevice: os.Getenv("AUDIO_DEVICE"), |
| 67 | + frameRate: readEnv("FRAME_RATE", "30"), |
| 68 | + resolution: readEnv("VIDEO_SIZE", "1280x720"), |
| 69 | + bitrate: readEnv("VIDEO_BITRATE", "1500k"), |
| 70 | + streamName: readEnv("STREAM_NAME", "robot"), |
| 71 | + targetHost: readEnv("RELAY_HOST", "rtsp.nene.02labs.me:8554"), |
| 72 | + publishUser: readEnv("RELAY_PUBLISH_USER", ""), |
| 73 | + publishPass: readEnv("RELAY_PUBLISH_PASS", ""), |
| 74 | + rtspTransport: readEnv("RTSP_TRANSPORT", "tcp"), |
| 75 | + inputFormat: os.Getenv("INPUT_FORMAT"), |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +func runFFmpeg(ctx context.Context, cfg streamerConfig, logger *log.Logger) error { |
| 80 | + args := buildFFmpegArgs(cfg) |
| 81 | + logger.Printf("launching ffmpeg (%d args)", len(args)) |
| 82 | + |
| 83 | + cmd := exec.CommandContext(ctx, cfg.ffmpegBinary, args...) |
| 84 | + cmd.Stdout = os.Stdout |
| 85 | + cmd.Stderr = os.Stderr |
| 86 | + |
| 87 | + if err := cmd.Start(); err != nil { |
| 88 | + return fmt.Errorf("failed to start ffmpeg: %w", err) |
| 89 | + } |
| 90 | + |
| 91 | + return cmd.Wait() |
| 92 | +} |
| 93 | + |
| 94 | +func buildFFmpegArgs(cfg streamerConfig) []string { |
| 95 | + args := []string{ |
| 96 | + "-f", "v4l2", |
| 97 | + } |
| 98 | + if cfg.inputFormat != "" { |
| 99 | + args = append(args, "-input_format", cfg.inputFormat) |
| 100 | + } |
| 101 | + args = append(args, |
| 102 | + "-thread_queue_size", "256", |
| 103 | + "-framerate", cfg.frameRate, |
| 104 | + "-video_size", cfg.resolution, |
| 105 | + "-i", cfg.cameraDevice, |
| 106 | + ) |
| 107 | + |
| 108 | + if cfg.audioDevice != "" { |
| 109 | + audioArgs := []string{ |
| 110 | + "-f", "alsa", |
| 111 | + "-thread_queue_size", "256", |
| 112 | + "-i", cfg.audioDevice, |
| 113 | + } |
| 114 | + args = append(args, audioArgs...) |
| 115 | + } |
| 116 | + |
| 117 | + videoOut := []string{ |
| 118 | + "-vf", "format=yuv420p", |
| 119 | + "-c:v", "libx264", |
| 120 | + "-preset", "ultrafast", |
| 121 | + "-tune", "zerolatency", |
| 122 | + "-pix_fmt", "yuv420p", |
| 123 | + "-b:v", cfg.bitrate, |
| 124 | + "-maxrate", cfg.bitrate, |
| 125 | + "-bufsize", cfg.bitrate, |
| 126 | + "-g", "60", |
| 127 | + "-keyint_min", "30", |
| 128 | + } |
| 129 | + args = append(args, videoOut...) |
| 130 | + |
| 131 | + if cfg.audioDevice != "" { |
| 132 | + audioOut := []string{ |
| 133 | + "-c:a", "aac", |
| 134 | + "-b:a", "128k", |
| 135 | + "-ar", "48000", |
| 136 | + "-ac", "2", |
| 137 | + } |
| 138 | + args = append(args, audioOut...) |
| 139 | + } |
| 140 | + |
| 141 | + args = append(args, |
| 142 | + "-rtsp_transport", cfg.rtspTransport, |
| 143 | + "-f", "rtsp", |
| 144 | + ) |
| 145 | + |
| 146 | + rtspURL := buildRTSPURL(cfg) |
| 147 | + return append(args, rtspURL) |
| 148 | +} |
| 149 | + |
| 150 | +func buildRTSPURL(cfg streamerConfig) string { |
| 151 | + var auth string |
| 152 | + if cfg.publishUser != "" { |
| 153 | + auth = cfg.publishUser |
| 154 | + if cfg.publishPass != "" { |
| 155 | + auth = fmt.Sprintf("%s:%s", cfg.publishUser, cfg.publishPass) |
| 156 | + } |
| 157 | + } |
| 158 | + if auth != "" { |
| 159 | + auth += "@" |
| 160 | + } |
| 161 | + return fmt.Sprintf("rtsp://%s%s/%s", auth, cfg.targetHost, cfg.streamName) |
| 162 | +} |
| 163 | + |
| 164 | +func readEnv(key, fallback string) string { |
| 165 | + if v, ok := os.LookupEnv(key); ok && v != "" { |
| 166 | + return v |
| 167 | + } |
| 168 | + return fallback |
| 169 | +} |
0 commit comments