Skip to content

IP Cameras

Skynet can serve live video from cameras at your site — a pier cam aimed at the mount, an all-sky cam, a dome/room cam — to anyone with permission to view the telescope, straight in the browser.

The design follows the same posture as SkyNode itself: your site only ever makes outbound connections. A publisher on your LAN pulls each camera once and pushes it out to a UNC-operated streaming origin (stream.skynetgo.org), which fans the stream out to as many web viewers as connect. No inbound firewall rule, no port-forward, no static IP — the same as the SkyNode link you already run. Your uplink carries exactly one stream per camera regardless of how many people are watching.

This page covers the two owner tasks: registering a camera (which issues publish credentials) and pointing a publisher at it with ffmpeg and/or Blue Iris.

1. Register a camera

Cameras attach to either a telescope (e.g. a pier cam) or an observatory (e.g. an all-sky or room cam). Open the parent's IP Cameras tab to manage them.

Creating and editing a camera requires telescope/observatory update permission; deleting requires manage. Owners hold both implicitly.

Click Add camera and fill in:

Field Notes
Name A label, e.g. "Dome cam".
Role pier (aimed at the telescope), all_sky, finder/guide, room/interior, or other.
Vendor Optional, e.g. "Amcrest".
PTZ Whether the camera pans/tilts/zooms (informational for now).
Primary Marks the camera shown first for the parent.

You do not enter a URL. On save, the server provisions the camera, creates its webrtc + hls playback streams automatically, generates a per-camera publish secret, and displays a one-time Publish setup panel.

The publish credentials (shown once)

The Publish setup dialog provides everything the publisher needs:

Item Example What it is
SRT URL srt://stream.skynetgo.org:8890?streamid=publish:<uid>:publisher:<secret>&passphrase=<passphrase>&pbkeylen=16 The full URL to paste into a publisher.
Host / Port stream.skynetgo.org / 8890 The streaming origin's SRT ingest.
Stream ID publish:<uid>:publisher:<secret> Identifies the camera path and carries the secret.
Passphrase (10–79 chars) Encrypts the SRT link. The same passphrase is used for all publishers; the per-camera secret is what actually authorizes this camera.
Publish secret (per-camera) The credential the origin checks. Embedded in the Stream ID.

The publish secret is shown only once and stored only as a hash — it cannot be retrieved again. Copy the SRT URL now. If you lose it (or it leaks), use Rotate publish secret on the camera to generate a new one; the old URL immediately stops working.

The SRT URL already encodes everything the publisher needs, so copying that single field is normally sufficient.

2. Publish with ffmpeg

A publisher pulls each camera's H.264 RTSP stream on your LAN and pushes it outbound to the SRT URL as MPEG-TS. ffmpeg does this in one line. The streaming origin accepts SRT only (not inbound RTSP/RTMP), so something on your side has to speak SRT to it — ffmpeg is that bridge.

Quick test — one camera

Paste your camera's RTSP URL and the SRT URL from the Publish setup panel:

ffmpeg -rtsp_transport tcp -i "rtsp://USER:PASS@192.168.1.50:81/cam1" `
  -c:v copy -c:a libopus -b:a 64k -ac 1 -f mpegts `
  "srt://stream.skynetgo.org:8890?streamid=publish:<uid>:publisher:<secret>&passphrase=<passphrase>&pbkeylen=16"

-c:v copy forwards the video untouched (no re-encode, no CPU/GPU cost). -c:a libopus transcodes the audio to Opus — see Audio for why. Then open the camera on the telescope/observatory page; you should see live video within a second or two.

Many cameras — auto-restarting PowerShell script

For more than one camera, this PowerShell script runs one ffmpeg publisher per camera and automatically restarts any that stop — a camera reboot, a network blip, or a dropped RTSP connection no longer means restarting things by hand. Each camera's ffmpeg output goes to logs\<Name>.log next to the script, so the console stays readable. Fill in the $Cameras list and run it; press Ctrl+C to stop every publisher.

<#
    Start a resilient ffmpeg SRT publisher for each camera. A supervisor loop
    watches every publisher and restarts any that exit, so streams recover on
    their own. Run with:
        powershell -ExecutionPolicy Bypass -File .\start-cameras.ps1
    Press Ctrl+C to stop every publisher. Per-camera fields:
      - Name   : a short label used in console messages and the log file name.
      - Source : the camera's H.264 RTSP URL (Blue Iris re-stream or the camera).
                 WebRTC needs H.264 — don't use an H.265 stream.
      - Srt    : the full SRT URL from the website's "Publish setup" panel.
                 Keep it in single quotes so '&' isn't treated specially.
#>

$FfmpegPath = 'ffmpeg'   # or the full path, e.g. 'C:\ffmpeg\bin\ffmpeg.exe'

# Supervisor timing, and the RTSP read timeout (microseconds) that makes ffmpeg
# exit on a stalled feed. The matching ffmpeg option is detected below.
$PollSeconds         = 3
$RestartDelaySeconds = 5
$ReadTimeoutMicros   = 5000000

$Cameras = @(
    @{
        Name   = 'cam1'
        Source = 'rtsp://USER:PASS@192.168.1.50:81/cam1'
        Srt    = 'srt://stream.skynetgo.org:8890?streamid=publish:UID-1:publisher:SECRET-1&passphrase=PASSPHRASE&pbkeylen=16'
    }
    @{
        Name   = 'cam2'
        Source = 'rtsp://USER:PASS@192.168.1.51:81/cam2'
        Srt    = 'srt://stream.skynetgo.org:8890?streamid=publish:UID-2:publisher:SECRET-2&passphrase=PASSPHRASE&pbkeylen=16'
    }
)

$ffmpeg = (Get-Command $FfmpegPath -ErrorAction SilentlyContinue).Source
if (-not $ffmpeg) {
    Write-Error "ffmpeg not found ('$FfmpegPath'). Install it or set `$FfmpegPath to ffmpeg.exe."
    exit 1
}

# The RTSP socket-timeout option differs by ffmpeg version: -timeout (modern) or
# -stimeout (older). (-rw_timeout is a protocol option the RTSP demuxer rejects
# with "Option not found".) Detect which this build exposes, or skip it.
$TimeoutOption = $null
if ($ReadTimeoutMicros -gt 0) {
    try {
        $rtspHelp = (& $ffmpeg -hide_banner -h demuxer=rtsp 2>&1 | Out-String)
        foreach ($opt in 'timeout', 'stimeout') {
            if ($rtspHelp -match "(?m)^\s*-$opt\s") { $TimeoutOption = "-$opt"; break }
        }
    } catch { }
    if (-not $TimeoutOption) {
        Write-Warning 'No RTSP socket-timeout option found; running without stall detection (auto-restart still works).'
    }
}

$logDir = if ($PSScriptRoot) { Join-Path $PSScriptRoot 'logs' } else { Join-Path (Get-Location) 'logs' }
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir | Out-Null }

function Start-Publisher {
    param($Cam)
    $inputArgs = @()
    if ($TimeoutOption) { $inputArgs += @($TimeoutOption, "$ReadTimeoutMicros") }
    $inputArgs += @('-rtsp_transport', 'tcp', '-i', $Cam.Source)
    # -c:v copy forwards video untouched; -c:a libopus transcodes audio to Opus
    # (WebRTC drops AAC/mp2). See the Audio section for why.
    $ffArgs = @('-hide_banner', '-loglevel', 'warning') + $inputArgs + @(
        '-c:v', 'copy', '-c:a', 'libopus', '-b:a', '64k', '-ac', '1',
        '-f', 'mpegts',
        $Cam.Srt
    )
    $log = Join-Path $logDir ("{0}.log" -f $Cam.Name)
    # -NoNewWindow avoids the SmartScreen block on a downloaded ffmpeg.exe;
    # stderr is redirected to the per-camera log.
    return Start-Process -FilePath $ffmpeg -ArgumentList $ffArgs `
        -NoNewWindow -PassThru -RedirectStandardError $log
}

# Default a Name for any entry that omits one so log files don't collide.
$n = 0
foreach ($cam in $Cameras) { $n++; if (-not $cam.Name) { $cam.Name = "cam$n" } }

$publishers = @(foreach ($cam in $Cameras) {
    [pscustomobject]@{ Name = $cam.Name; Cam = $cam; Process = (Start-Publisher -Cam $cam); Restarts = 0; NextStartAt = $null }
})

Write-Host ("Publishing {0} camera(s). Logs in {1}. Press Ctrl+C to stop." -f $publishers.Count, $logDir)

try {
    while ($true) {
        Start-Sleep -Seconds $PollSeconds
        $now = Get-Date
        foreach ($p in $publishers) {
            if ($p.Process -and -not $p.Process.HasExited) { continue }   # healthy
            if ($null -eq $p.NextStartAt) {
                $why    = Get-Content (Join-Path $logDir ("{0}.log" -f $p.Name)) -Tail 1 -ErrorAction SilentlyContinue
                $suffix = if ($why) { " - $($why.Trim())" } else { '' }
                Write-Warning ("[{0}] {1} stopped{2}; restarting in {3}s" -f $now.ToString('HH:mm:ss'), $p.Name, $suffix, $RestartDelaySeconds)
                $p.NextStartAt = $now.AddSeconds($RestartDelaySeconds)
            }
            elseif ($now -ge $p.NextStartAt) {
                $p.Process = Start-Publisher -Cam $p.Cam; $p.Restarts++; $p.NextStartAt = $null
                Write-Host ("[{0}] {1} restarted (restart #{2})" -f $now.ToString('HH:mm:ss'), $p.Name, $p.Restarts)
            }
        }
    }
}
finally {
    Write-Host 'Stopping all publishers...'
    foreach ($p in $publishers) {
        if ($p.Process -and -not $p.Process.HasExited) { Stop-Process -Id $p.Process.Id -Force -ErrorAction SilentlyContinue }
    }
}

The socket-timeout flag makes ffmpeg exit if the feed stalls instead of hanging on a half-open connection — that exit is what lets the supervisor detect the failure and restart it. Its name differs by ffmpeg version (-timeout on modern builds, -stimeout on older ones), so the script detects which your build supports; set $ReadTimeoutMicros = 0 to disable it.

On Linux/macOS, wrap the publisher in a restart loop for the same behaviour:

while true; do
  # -timeout is the modern RTSP socket-timeout option; older ffmpeg uses -stimeout.
  ffmpeg -hide_banner -loglevel warning -timeout 5000000 \
    -rtsp_transport tcp -i "rtsp://USER:PASS@192.168.1.50:81/cam1" \
    -c:v copy -c:a libopus -b:a 64k -ac 1 -f mpegts \
    "srt://stream.skynetgo.org:8890?streamid=publish:UID:publisher:SECRET&passphrase=PASSPHRASE&pbkeylen=16"
  echo "stream exited; restarting in 5s" >&2
  sleep 5
done

For survival across host reboots, run the publisher as a service or scheduled task (Task Scheduler on Windows, a systemd unit on Linux) so it starts on boot; the loop above keeps it alive within a session.

3. Audio

Audio requires the most attention, because the streaming origin does not transcode: whatever codec you publish is what viewers must be able to play, and the two playback paths accept different audio codecs:

  • WebRTC (the primary in-browser player) carries Opus or G711 only. It silently drops AAC — and also drops mp2, which is what the MPEG-TS muxer picks by default if you forget to set -c:a.
  • HLS (the fallback) carries AAC fine.

Most IP cameras emit AAC audio. With AAC, video plays but the WebRTC viewer has no sound and reports no error. The options:

You want… Use Result
Audio on the WebRTC player -c:a libopus -b:a 64k -ac 1 Transcodes the camera's audio to Opus. Works everywhere. Recommended.
Audio, HLS-only viewing -c:a copy Passes AAC through. Plays on HLS, not WebRTC.
No audio at all -an Strips audio entirely.

Audio transcoding is inexpensive relative to video, so -c:a libopus is a safe default even though the video stays -c:v copy.

Check what your source actually sends first:

ffprobe -rtsp_transport tcp "rtsp://USER:PASS@192.168.1.50:81/cam1"

If the dump shows no Audio: stream, the camera/feed isn't sending audio at all — enable the camera's microphone (and, in Blue Iris, that camera's audio) before any ffmpeg flag can help. If it shows Audio: aac, transcode to Opus as above.

The in-browser player starts muted (browsers require this for autoplay). Click unmute in the video controls to hear audio, even when it's flowing correctly.

4. Video codec

WebRTC playback requires H.264 video. Don't publish an H.265/HEVC stream — browsers can't decode H.265 over WebRTC, and the origin won't transcode it. Most cameras have an H.264 sub-stream or main-stream profile; use that. Since the publisher uses -c:v copy, the codec it pulls is the codec viewers get.

Blue Iris is a Windows NVR that can ingest many cameras (RTSP, ONVIF, vendor protocols) and re-stream each one as a clean, uniform H.264 RTSP feed. If you have more than a couple of cameras, running Blue Iris as the aggregator is the easiest setup:

  • One place to manage every camera, with recording, motion alerts, and a consistent RTSP URL per camera regardless of the camera's own quirks.
  • You can normalize resolution/codec there, so the ffmpeg publisher just does -c:v copy against a predictable H.264 stream.
  • Point the ffmpeg publisher (§2) at Blue Iris's RTSP re-stream URL instead of the camera directly — everything else is identical.

In this arrangement Blue Iris is the RTSP source aggregator and ffmpeg is the SRT bridge to Skynet. Make sure the Blue Iris re-stream you pull is the H.264 profile, and that audio is enabled on that camera in Blue Iris if you want sound (its default re-stream profile may be video-only).

6. Native camera streams (Blue Iris is optional)

Blue Iris is not required. If a camera exposes its own RTSP stream — almost all do — you can point the ffmpeg publisher (§2) straight at it:

rtsp://USER:PASS@<camera-ip>:<port>/<path>

Pulling natively is fully supported and removes a moving part. The requirements are the same as above, verified per camera instead of once at the aggregator:

  • The stream the publisher pulls must be H.264 video (use the camera's H.264 profile/sub-stream, not H.265).
  • Audio, if you want it on WebRTC, gets transcoded to Opus by the publisher (-c:a libopus) — the camera can send AAC; ffmpeg converts it.
  • The camera must be reachable from the publisher on the LAN.

Use Blue Iris when you want central management/recording across many cameras; pull natively when you have a few cameras and just want them live.

Reference

IPCamera Schema

Properties

Name Type Description
id Integer No description
uid UUID No description
telescope_id Integer (Optional) No description
observatory_id Integer (Optional) No description
name String(120) No description
role Enum(pier, all_sky, finder, room, other) No description
vendor String(80) (Optional) No description
onvif_host String(255) (Optional) No description
ptz Boolean No description
pose JSON (Optional) No description
is_primary Boolean No description
order Integer No description
secret_ref String(255) (Optional) No description
created_at DateTime No description
updated_at DateTime No description
last_seen DateTime (Optional) No description

Relationships

Relationship Name Type
streams IPCameraStream
telescope Telescope
observatory Observatory

IPCameraStream Schema

Properties

Name Type Description
id Integer No description
camera_id Integer No description
kind Enum(rtsp, rtmp, mjpeg, hls, webrtc, snapshot) No description
quality Enum(main, sub, thumbnail) (Optional) No description
url_template String(1024) No description
is_public Boolean No description
expires_secs Integer (Optional) No description
active Boolean No description

Relationships

Relationship Name Type
camera IPCamera