Skip to content

About

Drive Blackmagic ATEM switchers and PTZ cameras from a game controller

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Camera Control

Drive Blackmagic ATEM switchers and PTZ cameras from a game controller.

One operator steers whichever camera is on preview, changes the preview selection, cuts, toggles keyers and runs macros, without taking their eyes off the stage.

The main window

This is a .NET port of Cgf.CameraControl.Main, which is TypeScript and headless. It reads the same configuration files.

What it does

  • Steers the preview camera. The left stick pans and tilts, the right stick zooms and focuses. Change preview and the previous camera is stopped rather than left drifting.
  • Runs the switcher. Cut, auto, keyer toggles, macros, and a macro toggle that picks one of two macros from the live state of a keyer or an auxiliary output.
  • Lights the tally. Cameras that carry a tally light follow the preview and program buses.
  • Shows what is happening. Select an interface and the window draws it: a pad with the symbols printed on it, lit as they are pressed, and a legend of what each one does right now. Mixers and cameras sit underneath with one lamp each, and a log you can filter by component.
  • Runs without a controller. A keyboard interface is a small mixer panel: a program and a preview row of the inputs, cut and auto, every configured function, a mouse pad for pan and tilt and two sliders for zoom and focus. Every key is configurable.
  • Rumbles on a transition, when one of your cameras goes on air, and when a connection drops.

Install

Platform
Windows winget install Sensslen.CameraControl, or the installer from Releases
macOS the .dmg from Releases, then drag to Applications
Linux flatpak install ./CameraControl-<version>-linux-x64.flatpak

Every binary is NativeAOT compiled, so there is no .NET runtime to install.

Releases are signed where the project has certificates for it. Without them a Windows installer shows a SmartScreen warning, and a macOS build is ad-hoc signed rather than notarized, so macOS asks before opening it: System Settings → Privacy & Security → Open Anyway, or clear the download flag once with xattr -dr com.apple.quarantine "/Applications/Camera Control.app". Every release that went out that way says so in its notes.

Configure

Point the application at a config.json with File → Import, and it reopens the same file next time. There is a working example in samples/offline.json that needs no hardware: it uses a passthrough mixer, so a controller and the interface bindings can be checked on a laptop.

{
  "cams": [
    { "type": "Websocket.PtzLanc", "instance": 1, "ip": "10.0.0.11", "showTallyLight": false }
  ],
  "videoMixers": [
    { "type": "blackmagicdesign/atem", "instance": 1, "ip": "10.0.0.240", "mixEffectBlock": 0 }
  ],
  "interfaces": [
    {
      "type": "gamepad",
      "instance": 1,
      "videoMixer": 1,
      "connectionChange": { "type": "direct", "default": { "up": 1, "right": 2, "down": 3, "left": 4 } },
      "functions": { "wide": { "type": "key", "index": 1 } },
      "pad": { "default": { "down": "wide" } },
      "cameraMap": { "1": 1 }
    }
  ]
}

A problem with one entry is reported against that entry and the rest still load, so a typo in one camera does not take the desk down with it.

ATEM mixers and their interfaces appear even while the switcher is offline. Open the Mixers drawer to see each configured mix-effect block: yellow means disconnected or still initializing, and green means connected. Blocks using the same address share one connection. Connection attempts run in the background, reconnect automatically, and stop when the configuration is unloaded. The Atem log reports connection attempts, failures, and changes in connection status.

Every key of every entry is described in docs/Configuration. samples/two-operators.json puts a pad and two keyboards on one mixer.

Editing the configuration

File → Edit configuration turns the window into an editor for the file it has open. Starting the application with no configuration turns it on by itself, because a window with nothing in it but a menu is not an answer to having nothing configured.

Edit mode

Nothing is connected while the mode is on. The cameras and the mixers are torn down, so a pad left in a pocket cannot move a camera while its bindings are being changed, and a half-written address cannot be dialled.

The left column becomes the whole file: interfaces, cameras and video mixers, each with an add at the foot of it. Add asks for the type first and then the fields that type carries, and a cancelled add leaves nothing behind. Deleting says what still names the entry: a camera goes out of the maps that named it, and a video mixer leaves the interfaces that drove it to be pointed somewhere else.

Done asks whether to save. Saving rewrites the whole file, the entries that were edited from the form and everything else from the JSON it was read as, and then loads what it wrote, so what the desk runs is what is on the disk. Comments in the file do not survive, because the loader has always skipped them. Discarding reloads the file as it is.

A save is refused, with the reasons listed above the panel, while the file names something it does not define: two entries claiming one instance, a required field left empty, a cameraMap naming a camera that is not configured, an interface naming a mixer that is not configured, or a binding naming a function the interface has not got. A direction selecting a mixer input with no camera behind it is not one of those, because that is an ordinary desk.

The functions and the bindings themselves are not edited here yet: an interface's entry keeps them exactly as they were written. Clicking a control on the drawing to bind it is the next piece of work.

Cameras

type Talks to Needs
Websocket.PtzLanc the websocket LANC controller firmware ip
Signalr.PtzLanc the SignalR LANC controller service connectionUrl, connectionPort
viscaoverip a VISCA camera over UDP ip, and port if it is not 52381

All three take panTiltInvert. A VISCA camera also takes tallyMode, naming the vendor whose tally payload it understands: avonic, ptzoptics, or none, which is the default. avonic carries a red and a green lamp, and CineTreak cameras answer to it too; ptzoptics has one lamp, so preview leaves it dark. Nothing in the VISCA specification covers a tally lamp, so a camera sent the wrong vendor's payload does something unrelated rather than nothing.

Camera compatibility lists the cameras this has actually been run against and what a VISCA camera has to accept to work at all.

Controller bindings

Control Does
Left stick pan and tilt
Right stick zoom and focus
D-pad change the preview selection
A / B / X / Y the function pad binds to down / right / left / up
Right shoulder cut
Right trigger auto
Left shoulder hold for the alt bindings
Left trigger hold for the altLower bindings

SDL normalises every controller to this one layout, so gamepad is the only interface type that matters. The old logitech/F310, logitech/F710 and logitech/Rumblepad2 strings still load, but they are labels now rather than behaviour.

SDL remains the default backend. For serial-addressed Logitech F310 pads on macOS, set "backend": "hid" on the gamepad entry; this reads the exact device through HidSharp rather than relying on SDL's enumeration order. It requires serialNumber and D mode (replug after changing the switch), and does not support rumble. See choosing a gamepad backend.

The keyboard panel

A keyboard interface

An interface of type keyboard is driven from the window. It is laid out like a mixer: the inputs twice, once to put on program and once on preview, cut and auto between the rows, and every function from functions to the right. Each button names the key that also fires it and lights while that key is held. Below them the mouse takes the sticks' place: a pad for pan and tilt, and a slider each for zoom and focus. Drag and let go, and each springs back to the middle and stops the camera.

Nothing about the keys is fixed. Pan, tilt, zoom, focus, the selection steps, the inputs, the functions, cut and auto are each bound in keys, and only what is bound appears on the panel. docs/Configuration/Interfaces.md has every key name.

A desk that wants a pad and a keyboard on the same mixer configures one interface of each kind against the same videoMixer.

Two controllers on one machine are told apart by serialNumber. That is an absolute filter, never a preference: an interface whose serial is not present stays unbound and says so, because putting an operator on somebody else's mixer is worse than binding nothing.

Languages

English plus ten more, picked from the operating system on first run and changeable under Language. English is the source text; every translation is machine made and none has been reviewed by a native speaker.

Build

Needs the .NET 10 SDK. Nothing else.

dotnet build
dotnet format --verify-no-changes

The test projects are Microsoft.Testing.Platform executables, so run them directly rather than through dotnet test:

for suite in tests/*/bin/Debug/net10.0/*.Tests; do "$suite"; done

Layout

Project
Cgf.CameraControl.Core the domain: factories, configuration, the mixer and camera contracts
Cgf.CameraControl.Atem ATEM support over AtemSharp
Cgf.CameraControl.Cameras.WebsocketPtzLanc the websocket PTZ LANC camera
Cgf.CameraControl.Cameras.SignalrPtzLanc the SignalR PTZ LANC camera
Cgf.CameraControl.Cameras.ViscaOverIp the VISCA over IP camera
Cgf.CameraControl.Input.Sdl the controller logic and the SDL device layer
Cgf.CameraControl.Licenses.Generator compiles the licence report into the app
Cgf.CameraControl.App the Avalonia window and the composition root

Only the app references Avalonia and only Input.Sdl references SDL, so everything else is testable without a display or a controller.

The --aot-probe switch

Two things in this application fail under trimming without failing the build: AtemSharp finds its command types by reflecting over its own assembly, and the translations are embedded resources because NativeAOT does not load satellite assemblies. Either one can leave a binary that starts, connects, and quietly does the wrong thing.

CgfCameraControl --aot-probe decodes a real ATEM command, serializes a SignalR camera update through the real hub protocol, and resolves every language, then exits non-zero if any of them has stopped working. The release workflow runs it against every published binary before anything is uploaded.

Third-party licences

Help → Third-party licences lists every package the application ships. Opening a row shows what it is under and the full text of that licence: what the package published for itself where it published anything, and the text of the SPDX identifier it declared otherwise. Both are compiled into the binary by Cgf.CameraControl.Licenses.Generator, so showing them costs nothing at run time and they cannot drift from what is actually shipped. The artwork the window draws is listed the same way, from a hand-written report beside the package one. A licence with no text fails the build rather than leaving an empty page.

The licence texts are committed. The package report is not: it names the exact versions restored, so every build generates it first, and a build without one fails rather than crediting nobody. Write it, and refresh the texts after changing a package reference:

dotnet tool restore
python packaging/licenses/fetch-texts.py

--report-only writes just the report, which is what the workflows run, leaving the committed texts alone. --runtime win-x64 narrows it to the packages that runtime identifier resolves something from, so a published binary credits what it ships rather than the union of four platforms. The natives of the other three resolve an empty placeholder, and reading that off the restore is what replaces a list of package names per platform. Each release build passes its own identifier. The texts stay committed whole, because a build compiles in only the ones its own report names.

CI reports the whole graph instead, so every package is checked against the licences the project accepts. That check is what guards a dependency bump: nuget-license refuses a licence not on the list, so a new package under something unexpected fails CI even where this run does not build. The script also ignores what builds the binary rather than travelling in it: the diagnostics support package ships in Debug alone, and the NativeAOT compiler and the trimmer carry the host's runtime identifier in their package id, so a report naming them would differ between a developer machine and CI.

The icon

Generated, not drawn by hand, so the SVG and every raster come from the same numbers:

python packaging/icon/generate.py

Licence

MIT. See LICENSE.

About

Drive Blackmagic ATEM switchers and PTZ cameras from a game controller

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages