├── .gitignore ├── README.md └── go-version ├── build.sh ├── go.mod ├── go.sum ├── test.go ├── vmix-snapshot-proxy.bat ├── vmix-snapshot-proxy.exe ├── vmix-snapshot-proxy.go └── vmix-snapshot-proxy.zip /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vMix Snapshot Proxy 2 | 3 | vMix has a robust API, but one key limitation of the API is that while you can 4 | tell vMix to take a snapshot of an input, it will save the image on the vMix 5 | machine but not send the image over the network. 6 | 7 | The Snapshot Proxy is a small application written in the Go language to automatically generate 8 | those images and provide them over the network to other applications. 9 | 10 | The application is especially helpful in providing preview images of each input for 11 | 12 | - [Unofficial vMix Remote Control for Android](https://play.google.com/store/apps/details?id=org.jeffmikels.vmix_remote) 13 | - [Unofficial vMix Remote Control for iOS](https://apps.apple.com/us/app/unofficial-vmix-remote-control/id1551404035) 14 | 15 | Instructional Video here: https://youtu.be/7tXUx9Q_O58 16 | 17 | ## Installation: 18 | 19 | - Download the latest zip file from the [Releases Page](https://github.com/jeffmikels/vmix-snapshot-proxy/releases) 20 | - Unzip the file. 21 | - Put the `.exe` and the `.bat` files both in the same directory wherever you want (NOTE: they must be on the SAME computer that's running vMix). 22 | - Start vMix. 23 | - Double-click on the `.bat` file. 24 | - If you have problems, look at the `.bat` file for the available command line options: 25 | - `-h` will print the help 26 | - `-p` will allow you to specify the Web API port vMix is using 27 | - `-d` will allow you to specify the directory where vMix stores snapshot images 28 | 29 | ## Advanced Usage: 30 | 31 | When running, the proxy will open a web server at port `8098` and will expose the following HTTP endpoints: 32 | 33 | - `http://[IP_ADDRESS]:8098/` will return a list of all the discovered vMix inputs 34 | - `http://[IP_ADDRESS]:8098/regen` will trigger a global regeneration of all input snapshots 35 | - `http://[IP_ADDRESS]:8098/regen/[INPUT_NUMBER]` will trigger a regeneration of one input's snapshot 36 | - `http://[IP_ADDRESS]:8098/[INPUT_NUMBER].jpg` will serve the input snapshot as a jpg image. 37 | -------------------------------------------------------------------------------- /go-version/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | go mod tidy 4 | env GOOS=windows GOARCH=amd64 go build -o vmix-snapshot-proxy.exe 5 | 6 | zip -u vmix-snapshot-proxy.zip vmix-snapshot-proxy.exe vmix-snapshot-proxy.bat 7 | -------------------------------------------------------------------------------- /go-version/go.mod: -------------------------------------------------------------------------------- 1 | module org.jeffmikels/vmix-snapshot-proxy 2 | 3 | go 1.17 4 | 5 | require github.com/gofiber/fiber/v2 v2.29.0 6 | 7 | require ( 8 | github.com/andybalholm/brotli v1.0.4 // indirect 9 | github.com/klauspost/compress v1.15.0 // indirect 10 | github.com/valyala/bytebufferpool v1.0.0 // indirect 11 | github.com/valyala/fasthttp v1.34.0 // indirect 12 | github.com/valyala/tcplisten v1.0.0 // indirect 13 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go-version/go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= 2 | github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 3 | github.com/gofiber/fiber/v2 v2.29.0 h1:wopU1kXxdD9XxvQqYd1vSWMGu2PiZN0yy+DojygTRRA= 4 | github.com/gofiber/fiber/v2 v2.29.0/go.mod h1:1Ega6O199a3Y7yDGuM9FyXDPYQfv+7/y48wl6WCwUF4= 5 | github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U= 6 | github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 7 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 8 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 9 | github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4= 10 | github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0= 11 | github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= 12 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 13 | golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 14 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 15 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 16 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 17 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 18 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 19 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 20 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 h1:nhht2DYV/Sn3qOayu8lM+cU1ii9sTLUeBQwQQfUHtrs= 21 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 22 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 23 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 24 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 25 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 26 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 27 | -------------------------------------------------------------------------------- /go-version/test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/gofiber/fiber/v2" 4 | 5 | func tmain() { 6 | app := fiber.New() 7 | 8 | app.Get("/", func(c *fiber.Ctx) error { 9 | return c.SendString("Hello, World!") 10 | }) 11 | 12 | app.Listen(":3000") 13 | } 14 | -------------------------------------------------------------------------------- /go-version/vmix-snapshot-proxy.bat: -------------------------------------------------------------------------------- 1 | 2 | @REM vmix-snapshot-proxy.exe -h 3 | @REM vmix-snapshot-proxy.exe -d default 4 | @REM vmix-snapshot-proxy.exe -d vmixStoragePath -p vmixWebAPIPort 5 | 6 | vmix-snapshot-proxy.exe -------------------------------------------------------------------------------- /go-version/vmix-snapshot-proxy.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffmikels/vmix-snapshot-proxy/9fa1257805aa4676d980c50994c646633176ab4d/go-version/vmix-snapshot-proxy.exe -------------------------------------------------------------------------------- /go-version/vmix-snapshot-proxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | "net/http" 10 | "os" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "encoding/xml" 16 | 17 | "github.com/gofiber/fiber/v2" 18 | ) 19 | 20 | var myIp string 21 | var proxyPort int = 8098 22 | var vmixIP string = "localhost" 23 | var vmixPort int = 8088 24 | var vmixUrl string 25 | var vmixPath string = fmt.Sprintf("%s\\Documents\\vMixStorage", os.Getenv("USERPROFILE")) 26 | var snapshotInterval = 2000 * time.Millisecond 27 | 28 | /* Example vMix Input XML 29 | 30 | 31 | NewsHD.xaml 32 | Hello 33 | Hello 34 | 35 | 36 | LateNightNews 37 | 38 | 39 | 40 | 41 | 42 | */ 43 | 44 | // XML structs for later decomposition 45 | type Vmix struct { 46 | XMLName xml.Name `xml:"vmix"` 47 | Inputs Inputs `xml:"inputs"` 48 | } 49 | 50 | type Inputs struct { 51 | XMLName xml.Name `xml:"inputs"` 52 | Inputs []Input `xml:"input"` 53 | } 54 | 55 | // Input{name: "Camera 1", number: 1} 56 | type Input struct { 57 | XMLName xml.Name `xml:"input"` 58 | Name string `xml:"title,attr"` 59 | Number string `xml:"number,attr"` 60 | } 61 | 62 | // declare this as a global variable 63 | var vmix Vmix 64 | 65 | func main() { 66 | myIp = GetOutboundIP().String() 67 | 68 | // flag parser settings 69 | pathPtr := flag.String("d", "default", "path to the vMix Storage Directory") 70 | portPtr := flag.Int("p", vmixPort, "port as set in the vMix web API settings") 71 | 72 | flag.Parse() 73 | if *pathPtr != "default" { 74 | vmixPath = *pathPtr 75 | } 76 | vmixUrl = fmt.Sprintf("http://%s:%d/api", vmixIP, *portPtr) 77 | 78 | // start the http server 79 | app := fiber.New() 80 | 81 | // on the root route, refresh inputs and return them 82 | app.Get("/", func(c *fiber.Ctx) error { 83 | GetInputs() 84 | json := "[" 85 | var jsonStrings []string 86 | for i := 0; i < len(vmix.Inputs.Inputs); i++ { 87 | input := vmix.Inputs.Inputs[i] 88 | jsonStrings = append(jsonStrings, fmt.Sprintf(`{"name":"%s", "number":%s}`, input.Name, input.Number)) 89 | } 90 | json += strings.Join(jsonStrings, ",") + "]" 91 | return c.SendString(json) 92 | }) 93 | 94 | // on the regen route, re-request all snapshots 95 | app.Get("/regen", func(c *fiber.Ctx) error { 96 | RequestSnapshots(-1) 97 | return c.SendString("snapshots are regenerating") 98 | }) 99 | 100 | // request regeneration of one input 101 | app.Get("/regen/:input", func(c *fiber.Ctx) error { 102 | input, err := strconv.Atoi(c.Params("input")) 103 | if err != nil { 104 | return c.SendString("request was invalid") 105 | } 106 | RequestSnapshots(input) 107 | return c.SendString("snapshot " + c.Params("input") + " is regenerating") 108 | }) 109 | 110 | // app.Use("/:input.jpg", func(c *fiber.Ctx) error { 111 | // // Set some security headers: 112 | // // c.Set("X-XSS-Protection", "1; mode=block") 113 | // // c.Set("X-Content-Type-Options", "nosniff") 114 | // // c.Set("X-Download-Options", "noopen") 115 | // // c.Set("Strict-Transport-Security", "max-age=5184000") 116 | // // c.Set("X-Frame-Options", "SAMEORIGIN") 117 | // // c.Set("X-DNS-Prefetch-Control", "off") 118 | // input, err := strconv.Atoi(c.Params("input")) 119 | // if err != nil { 120 | // return c.SendString("request was invalid") 121 | // } 122 | // RequestSnapshots(input) 123 | 124 | // // Go to next middleware: 125 | // return c.Next() 126 | // }) 127 | 128 | // do the static route 129 | app.Static("/", vmixPath) 130 | 131 | // start the interval to refresh snapshots 132 | ticker := time.NewTicker(snapshotInterval) 133 | defer ticker.Stop() 134 | lastInput := 0 135 | tickerCounter := 0 136 | go func() { 137 | for range ticker.C { 138 | // ask for a snapshot 139 | if len(vmix.Inputs.Inputs) > 0 { 140 | // inputs are indexed by 1 in vmix 141 | lastInput = lastInput%len(vmix.Inputs.Inputs) + 1 142 | RequestSnapshots(lastInput) 143 | RequestSnapshots(0) 144 | } 145 | 146 | // maybe ask for all inputs again 147 | tickerCounter = (tickerCounter + 1) % 20 // counts to 10 seconds 148 | if tickerCounter == 0 { 149 | GetInputs() 150 | PrintStatus() 151 | } 152 | } 153 | }() 154 | 155 | // listen to the telnet socket 156 | // setup the telnet connection to vmix too 157 | var conn net.Conn 158 | go func() { 159 | var telnetError error 160 | buffer := make([]byte, 1024) 161 | accum := []byte{} 162 | for { 163 | if conn == nil { 164 | address := "localhost:8099" 165 | conn, telnetError = net.Dial("tcp", address) 166 | if telnetError != nil { 167 | fmt.Printf("No connection to vMix Telent API: (%s)\r\n", address) 168 | time.Sleep(time.Second * 2) 169 | continue 170 | } 171 | conn.Write([]byte("SUBSCRIBE TALLY\r\n")) 172 | } else { 173 | conn.SetReadDeadline(time.Now().Add(time.Millisecond * 10)) 174 | count, err := conn.Read(buffer) 175 | if err != nil { 176 | conn = nil 177 | accum = []byte{} 178 | } else { 179 | accum = append(accum, buffer[0:count]...) 180 | str := string(accum) 181 | lines := strings.Split(str, "\r\n") 182 | for i, line := range lines { 183 | fields := strings.Split(line, " ") 184 | // will be SUBSCRIBE OK TALLY 185 | // or TALLY OK 0121... 186 | if len(fields) > 0 && fields[0] == "TALLY" { 187 | RequestSnapshots(0) 188 | } 189 | // if it is the last element of the lines slice, make it the new accumulator 190 | if i == len(lines)-1 { 191 | accum = []byte(line) 192 | } 193 | } 194 | 195 | } 196 | } 197 | time.Sleep(time.Second) 198 | } 199 | }() 200 | 201 | PrintStatus() 202 | 203 | // start the server 204 | app.Listen(fmt.Sprintf("%s:%d", "0.0.0.0", proxyPort)) 205 | 206 | if conn != nil { 207 | conn.Close() 208 | } 209 | 210 | } 211 | 212 | func PrintStatus() { 213 | fmt.Println("=====================================================================================") 214 | fmt.Println("|- SETTINGS ---------------------------------------------------------------------------") 215 | fmt.Printf("| vMix Storage Path: %s\n", vmixPath) 216 | fmt.Printf("| vMix Web API URL: %s\n", vmixUrl) 217 | fmt.Println("|- AVAILABLE COMMANDS -----------------------------------------------------------------") 218 | fmt.Printf("| Running vMix Snapshot Proxy at port %d\n", proxyPort) 219 | fmt.Println("| NOTE: If this computer has multiple IP addresses, one of them will be displayed below") 220 | fmt.Println("| but this program will listen for connections on all available interfaces") 221 | fmt.Println("|") 222 | fmt.Printf("| Get a list of all inputs: http://%s:%d/\n", myIp, proxyPort) 223 | fmt.Printf("| Force regen one input (0 means program): http://%s:%d/regen/#\n", myIp, proxyPort) 224 | fmt.Printf("| Force regen all inputs: http://%s:%d/regen\n", myIp, proxyPort) 225 | fmt.Printf("| Get input snapshot: http://%s:%d/#.jpg\n", myIp, proxyPort) 226 | fmt.Println("|") 227 | fmt.Println("| Getting an input snapshot sends the most recent snapshot, and queues the generation of a new one.") 228 | fmt.Println("| If there are no snapshots for that input yet, it will wait a bit before trying again.") 229 | fmt.Println("| Snapshots take about 1 second to process") 230 | fmt.Println("=====================================================================================") 231 | } 232 | 233 | func DoRequest(url string) []byte { 234 | resp, err := http.Get(url) 235 | if err != nil { 236 | print(`ERROR attempting to reach vMix: ` + url) 237 | return nil 238 | } 239 | defer resp.Body.Close() 240 | 241 | // get response body 242 | bodyBytes, _ := io.ReadAll(resp.Body) 243 | return bodyBytes 244 | } 245 | 246 | // will request the XML from vMix and parse the inputs saving the results 247 | // to the global `inputs` variable 248 | func GetInputs() { 249 | url := vmixUrl + "?XML" 250 | fmt.Println(url) 251 | bodyBytes := DoRequest(url) 252 | if bodyBytes == nil { 253 | fmt.Println("\nERROR: vMix failed to retrieve inputs... Is vMix running?") 254 | return 255 | } 256 | fmt.Println(string(bodyBytes)) 257 | 258 | // clear out the old vmix data 259 | vmix = Vmix{} 260 | err := xml.Unmarshal(bodyBytes, &vmix) 261 | if err != nil { 262 | print(err) 263 | } 264 | 265 | for i := 0; i < len(vmix.Inputs.Inputs); i++ { 266 | fmt.Println("Input Name: " + vmix.Inputs.Inputs[i].Name) 267 | fmt.Println("Input Number: " + vmix.Inputs.Inputs[i].Number) 268 | } 269 | } 270 | 271 | // this will tell vMix to generate a snapshot of the specified input 272 | // if `inputNumber` is -1, it will request a snapshot for all inputs 273 | // if `inputNumber` is 0, it will generate a snapshot for the program 274 | // remember that vMix inputs are 1-indexed 275 | func RequestSnapshots(inputNumber int) { 276 | if inputNumber == -1 { 277 | for i := 0; i <= len(vmix.Inputs.Inputs); i++ { 278 | go RequestSnapshots(i) 279 | } 280 | } else { 281 | var url string 282 | if inputNumber == 0 { 283 | url = vmixUrl + "?Function=Snapshot&Value=0.jpg" 284 | } else { 285 | url = fmt.Sprintf("%s?Function=SnapshotInput&Input=%d&Value=%d.jpg", vmixUrl, inputNumber, inputNumber) 286 | } 287 | fmt.Println(url) 288 | bytes := DoRequest(url) 289 | fmt.Println(string(bytes)) 290 | } 291 | } 292 | 293 | // Get preferred outbound ip of this machine 294 | func GetOutboundIP() net.IP { 295 | conn, err := net.Dial("udp", "8.8.8.8:80") 296 | if err != nil { 297 | log.Fatal(err) 298 | } 299 | defer conn.Close() 300 | 301 | localAddr := conn.LocalAddr().(*net.UDPAddr) 302 | 303 | return localAddr.IP 304 | } 305 | -------------------------------------------------------------------------------- /go-version/vmix-snapshot-proxy.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffmikels/vmix-snapshot-proxy/9fa1257805aa4676d980c50994c646633176ab4d/go-version/vmix-snapshot-proxy.zip --------------------------------------------------------------------------------