How Helen Gets Made A making-of, not the live show Watch her live

YouTube · 24/7 live · the scripts

tile-live.ps1, flagship-live.ps1, foodcam-live.ps1 — the whole thing

speed=0.999x, bitrate=3650, 79 minutes into the session: the mosaic's status line on 2026-09-02, from a desktop that is also running Frigate. It comes out of three PowerShell files in C:\ProgramData\HelenLive\, one ffmpeg each, six ffmpeg processes in all, which have kept two YouTube streams up since 2026-08-29.

Every argument below is the one that runs, with the reason it is there. The reasoning behind the shape is on the 24/7 entry page.

Helen at the water bowl, the frame the Water Cam tile carries into the mosaic at 640 by 360 and 20 frames a second
A tile is a 640×360 H.264 sub-stream copied byte-for-byte into MPEG-TS on a loopback UDP port. The compositor is the only thing that decodes it.

Everything here runs on the Windows side of the box described in the WSL2 setup files. Frigate's go2rtc, inside WSL2, serves every camera on rtsp://127.0.0.1:8554/<cam> (main) and <cam>_sub (640×360); mirrored networking makes those addresses valid from Windows. The scripts read them and never talk to a camera directly. If you want the reasoning before the code, the entry page has it.

The shape

Six processes, two streams
go2rtc (WSL2)  rtsp://127.0.0.1:8554/helen_food_face_sub ──► tile-live.ps1 -Cam helen_food_face      -Port 5107 ─┐
               rtsp://127.0.0.1:8554/cat_water_bowl_sub  ──► tile-live.ps1 -Cam cat_water_bowl       -Port 5103 ─┤  mpegts over
               rtsp://127.0.0.1:8554/cat_treat_dispenser_sub ► tile-live.ps1 -Cam cat_treat_dispenser -Port 5104 ─┤  udp://127.0.0.1
               rtsp://127.0.0.1:8554/bed_cam_sub         ──► tile-live.ps1 -Cam bed_cam              -Port 5105 ─┘
                                                                                                                  │
                                                         flagship-live.ps1: 4×scale=960:540 → hstack/vstack → h264_qsv 3500k @10fps → RTMP
                                                                                                                                (video HaRC7bsF-pM)
               rtsp://127.0.0.1:8554/helen_food_face ────► foodcam-live.ps1: d3d11va decode → fps=15 → h264_qsv 2500k → RTMP
                                                                                                                                (video JrPoY5QHLdw)

The tiles exist so that the compositor can be restarted without re-opening four RTSP sessions, and so that each camera is pulled from go2rtc exactly once by the streaming side. UDP on loopback means a tile that dies takes one pane black for five seconds and nothing else.

Tile ports (one scheduled task per row; only the first four are in the current mosaic)
CameraUDP port · task name
helen_food_face (Face, top-left)5107 · HelenFlagshipTileFace
cat_water_bowl (Water, top-right)5103 · HelenFlagshipTileWater
cat_treat_dispenser (Treat, bottom-left)5104 · HelenFlagshipTileTreat
bed_cam (Bed, bottom-right)5105 · HelenFlagshipTileBed
cat_scratching_pad (Court)5101 · HelenFlagshipTileCourt, registered, not running
cat_food_bowl (Food)5102 · HelenFlagshipTileFood, registered, not running
playroom_cam5106 · HelenFlagshipTilePlayroom, registered, not running

The three idle tiles are earlier layouts (v6 was Court/Food, v7 Court/Water). Swapping a pane is: start that tile's task, change one port in the compositor's input list, restart the compositor. The camera side never changes.

tile-live.ps1

C:\ProgramData\HelenLive\tile-live.ps1
param(
    [Parameter(Mandatory=$true)][string]$Cam,
    [Parameter(Mandatory=$true)][int]$Port
)

# v6 (2026-08-29): restream go2rtc SUB as MPEG-TS copy. No decode, no x264.
# Compositor scales to 960x540. Food cam stays independently re-encoded.
$ffmpeg = "C:\Users\<YOUR_USER>\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe"
$logDir = "C:\ProgramData\HelenLive\logs"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null

$memCapBytes = 250MB
$wrapperLog = Join-Path $logDir "flagship-tile-$Cam-wrapper.log"
$src = "rtsp://127.0.0.1:8554/${Cam}_sub"

while ($true) {
    $ts = Get-Date -Format "yyyyMMdd-HHmmss"
    $logFile = Join-Path $logDir "flagship-tile-$Cam-$ts.log"

    $ffmpegArgs = @(
        "-rtsp_transport","tcp","-i",$src,
        "-an","-c:v","copy",
        "-f","mpegts","udp://127.0.0.1:$Port`?pkt_size=1316"
    )

    $proc = Start-Process -FilePath $ffmpeg -ArgumentList $ffmpegArgs -NoNewWindow -PassThru `
        -RedirectStandardOutput $logFile -RedirectStandardError "$logFile.err"

    while (-not $proc.HasExited) {
        Start-Sleep -Seconds 5
        $proc.Refresh()
        if ($proc.WorkingSet64 -gt $memCapBytes) {
            Add-Content -Path $wrapperLog -Value "$(Get-Date -Format o) memory cap exceeded ($($proc.WorkingSet64) bytes), killing"
            Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
            break
        }
    }
    if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue }

    Add-Content -Path $wrapperLog -Value "$(Get-Date -Format o) ffmpeg exited (code $($proc.ExitCode)), restarting in 5s"
    Start-Sleep -Seconds 5
}
  • -rtsp_transport tcp: go2rtc is on loopback, but RTSP over UDP still loses packets under load on Windows; TCP costs nothing here.
  • -an -c:v copy: the Tapo sub-stream is already H.264 at 640×360, 20 fps, so there is nothing to decode. The PCMA audio is dropped; the compositor supplies its own.
  • pkt_size=1316: seven 188-byte TS packets per UDP datagram, the largest that fits under a 1,500-byte MTU without fragmenting. The backtick before ? stops PowerShell parsing it.
  • The 250 MB cap has never fired; a tile sits at about 20 MB. It is there because an ffmpeg with a stuck output can balloon, and a cap is cheaper than a diagnosis at 3 a.m.
  • The wrapper log for the Face tile shows what "normal" is: four exits in two days, each followed by a five-second restart, every one of them the daily refresh or a go2rtc reconnect.

flagship-live.ps1 — the mosaic

C:\ProgramData\HelenLive\flagship-live.env (read at start; never in the task XML)
YT_FLAGSHIP_RTMP_URL=<YT_RTMP_URL>
YT_FLAGSHIP_STREAM_KEY=<YT_STREAM_KEY>
YT_FLAGSHIP_BROADCAST_ID=<YT_BROADCAST_ID>
C:\ProgramData\HelenLive\flagship-live.ps1
$envFile = "C:\ProgramData\HelenLive\flagship-live.env"
Get-Content $envFile | ForEach-Object {
    if ($_ -match '^(.*?)=(.*)$') {
        [System.Environment]::SetEnvironmentVariable($matches[1], $matches[2])
    }
}

$rtmpUrl = $env:YT_FLAGSHIP_RTMP_URL
$streamKey = $env:YT_FLAGSHIP_STREAM_KEY
$dest = "$rtmpUrl/$streamKey"

$ffmpeg = "C:\Users\<YOUR_USER>\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe"
$logDir = "C:\ProgramData\HelenLive\logs"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null

$memCapBytes = 900MB
$wrapperLog = Join-Path $logDir "flagship-comp-wrapper.log"
$rtmpGraceSec = 45

# v11 (2026-08-30): pane 0 is Face (helen_food_face_sub) on UDP 5107.
# Privacy/framing decision: Court includes the left entrance where people
# may pass; Face avoids showing that entrance on the public mosaic.
# Layout: top row = face(5107), water(5103); bottom row = treat(5104),
# bed(5105). QSV compositor encode - do not regress to libx264.
#
# v11 adds an RTMP health check: the old watchdog only restarted ffmpeg on
# process EXIT, but YouTube can hang up the RTMP socket (state -> CloseWait)
# while ffmpeg itself stays alive and never exits, so the stream goes dark
# with `schtasks /Query` still reporting "Running". Diagnosed 2026-08-30
# 07:31 EDT after the mosaic stopped twice in under an hour; `schtasks` gave
# no signal either time, only `Get-NetTCPConnection -RemotePort 1935` showed
# the real state. After a $rtmpGraceSec startup grace period (handshake takes
# ~10-50s observed), if ffmpeg's PID has no RTMP connection to YouTube in the
# Established state, kill it so the outer while($true) loop restarts with a
# fresh socket.
while ($true) {
    $ts = Get-Date -Format "yyyyMMdd-HHmmss"
    $logFile = Join-Path $logDir "flagship-comp-$ts.log"

    $ffmpegArgs = @(
        "-fflags","nobuffer+genpts","-i","udp://127.0.0.1:5107?fifo_size=1000000&overrun_nonfatal=1",
        "-fflags","nobuffer+genpts","-i","udp://127.0.0.1:5103?fifo_size=1000000&overrun_nonfatal=1",
        "-fflags","nobuffer+genpts","-i","udp://127.0.0.1:5104?fifo_size=1000000&overrun_nonfatal=1",
        "-fflags","nobuffer+genpts","-i","udp://127.0.0.1:5105?fifo_size=1000000&overrun_nonfatal=1",
        "-stream_loop","-1","-i","C:\ProgramData\HelenLive\music\helen_serial.wav",
        "-filter_complex","[0:v]scale=960:540,setsar=1[v0];[1:v]scale=960:540,setsar=1[v1];[2:v]scale=960:540,setsar=1[v2];[3:v]scale=960:540,setsar=1[v3];[v0][v1]hstack=inputs=2[top];[v2][v3]hstack=inputs=2[bottom];[top][bottom]vstack=inputs=2[outv]",
        "-map","[outv]","-map","4:a",
        "-vsync","cfr","-r","10",
        "-c:v","h264_qsv","-preset","veryfast","-look_ahead","0","-bf","0","-b:v","3500k","-maxrate","3500k","-bufsize","7000k","-g","20",
        "-c:a","aac","-b:a","128k","-ar","44100",
        "-f","flv",$dest
    )

    $proc = Start-Process -FilePath $ffmpeg -ArgumentList $ffmpegArgs -NoNewWindow -PassThru `
        -RedirectStandardOutput $logFile -RedirectStandardError "$logFile.err"
    $startTime = Get-Date
    $killReason = $null

    while (-not $proc.HasExited) {
        Start-Sleep -Seconds 5
        $proc.Refresh()
        if ($proc.WorkingSet64 -gt $memCapBytes) {
            $killReason = "memory cap exceeded ($($proc.WorkingSet64) bytes)"
            Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
            break
        }
        $elapsed = (Get-Date) - $startTime
        if ($elapsed.TotalSeconds -gt $rtmpGraceSec) {
            $conn = Get-NetTCPConnection -OwningProcess $proc.Id -RemotePort 1935 -ErrorAction SilentlyContinue
            if (-not $conn) {
                $killReason = "no RTMP connection to YouTube after ${rtmpGraceSec}s"
                Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
                break
            } elseif (@($conn | Where-Object { $_.State -eq 'Established' }).Count -eq 0) {
                $badStates = ($conn | Select-Object -ExpandProperty State) -join ','
                $killReason = "RTMP connection unhealthy (state=$badStates)"
                Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
                break
            }
        }
    }
    if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue }

    if ($killReason) {
        Add-Content -Path $wrapperLog -Value "$(Get-Date -Format o) $killReason, killing and restarting in 5s"
    } else {
        Add-Content -Path $wrapperLog -Value "$(Get-Date -Format o) ffmpeg exited (code $($proc.ExitCode)), restarting in 5s"
    }
    Start-Sleep -Seconds 5
}

Argument by argument, the parts that were not obvious:

Why each number in the compositor is what it is
ArgumentReason
-fflags nobuffer+genpts per inputnobuffer stops ffmpeg accumulating a second of input before starting, which is latency you pay forever. genpts regenerates presentation timestamps for the MPEG-TS packets, which matters because the four tiles started at different moments (the log shows input start offsets of 10.8, 15.9, 21.2 and 27.8 s).
fifo_size=1000000&overrun_nonfatal=1A one-million-byte receive FIFO on each UDP input, and "keep going" if it overruns. Without it a compositor stall of a few hundred milliseconds (QSV re-initialising, a Windows Defender scan) drops UDP packets and ffmpeg exits on the corrupt stream.
scale=960:540,setsar=1 ×4Each 640×360 tile is upscaled to a quarter of 1080p. setsar=1 forces square pixels so hstack does not refuse inputs with mismatched aspect metadata, which the Tapo streams sometimes carry.
hstack=inputs=2 ×2, vstack=inputs=2Two rows, then stack the rows. Cheaper than xstack for a 2×2 and easier to read at 3 a.m.
-stream_loop -1 on the WAV, -map 4:aThe music bed is input 4, looped forever. YouTube needs an audio track; the cameras' PCMA audio was dropped at the tile.
-vsync cfr -r 10Constant frame rate at 10 fps. The tiles arrive at 20 fps on four independent clocks; without CFR the output has non-monotonic timestamps and YouTube buffers. Ten is enough for a cat at a bowl and halves the encoder's work.
-c:v h264_qsv -preset veryfast -look_ahead 0 -bf 0Intel Quick Sync on the UHD 630. Lookahead and B-frames both add latency and memory for a quality gain nobody watching a cat cam can see.
-b:v 3500k -maxrate 3500k -bufsize 7000kA flat 3.5 Mbit/s with a two-second VBV buffer. YouTube's published range for 1080p is 3–6 Mbit/s at 30 fps; at 10 fps, 3.5 is generous for the picture and still leaves headroom on a home upload.
-g 20A keyframe every two seconds at 10 fps. YouTube asks for a keyframe interval of two seconds (four at most); get this wrong and Stream health complains about keyframe frequency even when the picture looks fine.
-c:a aac -b:a 128k -ar 44100AAC-LC at 44.1 kHz is the safe pair for RTMP/FLV.
-f flv $destRTMP is FLV over TCP. $dest is <YT_RTMP_URL>/<YT_STREAM_KEY>, assembled from the .env.

foodcam-live.ps1 — the single camera

C:\ProgramData\HelenLive\foodcam-live.ps1 (the loop and watchdog are identical to the mosaic; only the ffmpeg arguments and the restart delay differ)
$envFile = "C:\ProgramData\HelenLive\foodcam-live.env"
Get-Content $envFile | ForEach-Object {
    if ($_ -match '^(.*?)=(.*)$') {
        [System.Environment]::SetEnvironmentVariable($matches[1], $matches[2])
    }
}

$rtmpUrl = $env:YT_FOODCAM_RTMP_URL
$streamKey = $env:YT_FOODCAM_STREAM_KEY
$dest = "$rtmpUrl/$streamKey"

$ffmpeg = "C:\Users\<YOUR_USER>\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe"
$logDir = "C:\ProgramData\HelenLive\logs"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null

$memCapBytes = 700MB
$wrapperLog = Join-Path $logDir "foodcam-wrapper.log"
$rtmpGraceSec = 45

# v5 (2026-08-30): adds the same RTMP health check built for flagship v11
# (see flagship-live.ps1 for the full incident note). The old watchdog only
# restarted ffmpeg on process EXIT or on a frozen `frame=` counter it never
# actually checked. If YouTube hangs up the RTMP socket (state -> CloseWait)
# ffmpeg can sit there alive and connected-to-nothing indefinitely. After a
# $rtmpGraceSec startup grace period, require an Established RTMP connection
# to YouTube or kill and restart.
while ($true) {
    $ts = Get-Date -Format "yyyyMMdd-HHmmss"
    $logFile = Join-Path $logDir "foodcam-$ts.log"

    # v4: d3d11va decode + QSV encode. Still re-time to 15fps CFR for YouTube
    # (copy to YT caused non-monotonic DTS / buffering). Never camera mic.
    $ffmpegArgs = @(
        "-hwaccel","d3d11va",
        "-rtsp_transport","tcp","-i","rtsp://127.0.0.1:8554/helen_food_face",
        "-stream_loop","-1","-i","C:\ProgramData\HelenLive\music\helen_face.wav",
        "-map","0:v","-map","1:a",
        "-vf","fps=15,format=nv12",
        "-c:v","h264_qsv","-preset","veryfast","-look_ahead","0","-bf","0",
        "-b:v","2500k","-maxrate","2500k","-bufsize","5000k",
        "-g","30","-r","15",
        "-c:a","aac","-b:a","128k","-ar","44100",
        "-f","flv",$dest
    )

    $proc = Start-Process -FilePath $ffmpeg -ArgumentList $ffmpegArgs -NoNewWindow -PassThru `
        -RedirectStandardOutput $logFile -RedirectStandardError "$logFile.err"
    $startTime = Get-Date
    $killReason = $null

    while (-not $proc.HasExited) {
        Start-Sleep -Seconds 5
        $proc.Refresh()
        if ($proc.WorkingSet64 -gt $memCapBytes) {
            $killReason = "memory cap exceeded ($($proc.WorkingSet64) bytes)"
            Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
            break
        }
        $elapsed = (Get-Date) - $startTime
        if ($elapsed.TotalSeconds -gt $rtmpGraceSec) {
            $conn = Get-NetTCPConnection -OwningProcess $proc.Id -RemotePort 1935 -ErrorAction SilentlyContinue
            if (-not $conn) {
                $killReason = "no RTMP connection to YouTube after ${rtmpGraceSec}s"
                Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
                break
            } elseif (@($conn | Where-Object { $_.State -eq 'Established' }).Count -eq 0) {
                $badStates = ($conn | Select-Object -ExpandProperty State) -join ','
                $killReason = "RTMP connection unhealthy (state=$badStates)"
                Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
                break
            }
        }
    }
    if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue }

    if ($killReason) {
        Add-Content -Path $wrapperLog -Value "$(Get-Date -Format o) $killReason, killing and restarting in 10s"
    } else {
        Add-Content -Path $wrapperLog -Value "$(Get-Date -Format o) ffmpeg exited (code $($proc.ExitCode)), restarting in 10s"
    }
    Start-Sleep -Seconds 10
}
  • -hwaccel d3d11va. The main stream is 2560×1440 at 20 fps, and decoding it on the CPU is the most expensive step in the whole setup, so it goes to the iGPU through Direct3D 11. This is the same UHD 630 that Frigate's container cannot reach for decode (there is no /dev/dri in WSL2; see the detector page). From Windows it is available.
  • -vf fps=15,format=nv12. fps=15 re-times to a constant 15 from the camera's 20, and format=nv12 hands the encoder the pixel format QSV wants. Without -hwaccel_output_format the decoded frames come back to system memory between decode and encode; at 15 fps that copy is not worth optimising away.
  • 2,500 kbit/s, -g 30. Two-second keyframes at 15 fps. The camera is looking at a bowl; 2.5 Mbit/s at 1440p is plenty because almost nothing moves between keyframes.
  • The log is noisy. Every few frames ffmpeg prints SEI type 764 size 34 truncated at 32. That is the Tapo encoder writing a slightly short user-data SEI; the decoder ignores it and so should you.
  • Ten-second restart instead of five, because a 1440p d3d11va+QSV pipeline takes longer to initialise and YouTube's edge likes a small pause before a re-handshake.
What foodcam-live.ps1 produces: one 2560×1440 camera decoded with d3d11va, re-timed to a steady 15 fps and encoded with h264_qsv. Watch the motion when she is at the bowl; that smoothness is the CFR re-time doing its job, and it is what stream-copy could not give YouTube.

refresh-tiles.ps1 — the daily reset

C:\ProgramData\HelenLive\refresh-tiles.ps1 (task HelenFlagshipTileRefresh, daily 04:00)
# Daily maintenance: refresh the 4 flagship mosaic tile processes + the
# compositor that reads them. Added 2026-08-31.
#
# Why this exists: the 4 tile ffmpeg processes (Face/Water/Treat/Bed, each a
# plain `-c:v copy` from go2rtc's RTSP `_sub` stream to a loopback UDP port)
# have no health check of their own - only the flagship/foodcam RTMP
# encoders got the RTMP self-heal (see flagship-live.ps1 v11). Left running
# 10+ hours straight, their long-lived RTSP connections can degrade quietly
# (no crash, just bad data), which then feeds the compositor corrupt input
# it can't decode ("no frame!" / "non-existing PPS 0 referenced") - a
# failure the RTMP self-heal cannot detect or fix, because the local
# process/socket state looks fine throughout.
#
# Fix is mechanical: Stop+Start all 4 tiles for fresh RTSP connections, then
# Stop+Start the compositor so it picks up clean input. Same two steps that
# resolved the real outage on 2026-08-30 21:56 EDT, done here proactively
# once a day instead of waiting for someone to notice the stream is dark.

$logDir = "C:\ProgramData\HelenLive\logs"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$log = Join-Path $logDir "tile-refresh.log"

function Log($msg) {
    Add-Content -Path $log -Value "$(Get-Date -Format o) $msg"
}

Log "=== daily tile refresh starting ==="

$tiles = @('HelenFlagshipTileFace', 'HelenFlagshipTileWater', 'HelenFlagshipTileTreat', 'HelenFlagshipTileBed')

foreach ($t in $tiles) {
    $out = schtasks /End /TN $t 2>&1
    Log "End $t -> $out"
}

Start-Sleep -Seconds 5

foreach ($t in $tiles) {
    $out = schtasks /Run /TN $t 2>&1
    Log "Run $t -> $out"
}

Start-Sleep -Seconds 5

$out = schtasks /End /TN HelenFlagshipLive 2>&1
Log "End HelenFlagshipLive -> $out"

Start-Sleep -Seconds 3

$out = schtasks /Run /TN HelenFlagshipLive 2>&1
Log "Run HelenFlagshipLive -> $out"

Log "=== daily tile refresh done ==="

Ending a task kills the PowerShell wrapper and, because the wrapper started ffmpeg as a child, ffmpeg with it. The whole reset costs the mosaic about fifteen seconds at four in the morning. The single-camera stream is not touched; its one RTSP session is re-opened whenever the RTMP watchdog restarts it, which the log shows happening a few times a day anyway.

The scheduled tasks

Every process is a Task Scheduler task running as SYSTEM with a boot trigger. The tile tasks were built in Task Scheduler and exported; the XML for one is below, and the other tiles differ only in -Cam and -Port. Register with schtasks /Create /XML or paste the equivalent into the GUI.

HelenFlagshipTileFace.xml (import with: schtasks /Create /TN HelenFlagshipTileFace /XML HelenFlagshipTileFace.xml)
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <RegistrationInfo>
    <Date>2026-08-29T21:30:00</Date>
    <Author><YOUR_PC>\<YOUR_USER></Author>
    <URI>\HelenFlagshipTileFace</URI>
  </RegistrationInfo>
  <Principals>
    <Principal id="Author">
      <UserId>S-1-5-18</UserId>
      <RunLevel>HighestAvailable</RunLevel>
    </Principal>
  </Principals>
  <Settings>
    <DisallowStartIfOnBatteries>true</DisallowStartIfOnBatteries>
    <StopIfGoingOnBatteries>true</StopIfGoingOnBatteries>
    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
    <IdleSettings>
      <Duration>PT10M</Duration>
      <WaitTimeout>PT1H</WaitTimeout>
      <StopOnIdleEnd>true</StopOnIdleEnd>
      <RestartOnIdle>false</RestartOnIdle>
    </IdleSettings>
  </Settings>
  <Triggers>
    <BootTrigger>
      <StartBoundary>2026-08-29T21:30:00</StartBoundary>
    </BootTrigger>
  </Triggers>
  <Actions Context="Author">
    <Exec>
      <Command>powershell</Command>
      <Arguments>-ExecutionPolicy Bypass -File C:\ProgramData\HelenLive\tile-live.ps1 -Cam helen_food_face -Port 5107</Arguments>
    </Exec>
  </Actions>
</Task>
The two encoders and the refresh, as schtasks one-liners (elevated PowerShell)
# mosaic and single cam: at boot, as SYSTEM, hidden window
schtasks /Create /TN HelenFlagshipLive /SC ONSTART /RU SYSTEM /RL HIGHEST /F `
  /TR "powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\ProgramData\HelenLive\flagship-live.ps1"
schtasks /Create /TN HelenFoodCamLive /SC ONSTART /RU SYSTEM /RL HIGHEST /F `
  /TR "powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\ProgramData\HelenLive\foodcam-live.ps1"

# daily tile + compositor reset at 04:00
schtasks /Create /TN HelenFlagshipTileRefresh /SC DAILY /ST 04:00 /RU SYSTEM /RL HIGHEST /F `
  /TR "powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\ProgramData\HelenLive\refresh-tiles.ps1"

# start everything now without rebooting
'HelenFlagshipTileFace','HelenFlagshipTileWater','HelenFlagshipTileTreat','HelenFlagshipTileBed',
'HelenFlagshipLive','HelenFoodCamLive' | ForEach-Object { schtasks /Run /TN $_ }

Two settings to fix by hand after schtasks /Create, because the command line cannot set them: "Stop the task if it runs longer than" defaults to three days (ours still read 72:00:00 when we checked; the 04:00 refresh has been quietly hiding that for the mosaic, and it is on the list), and "If the task is already running: Do not start a new instance", which the XML above sets as IgnoreNew. A second compositor fighting the first for the same stream key is the one failure that looks like nothing is wrong.

What it measures

Live, 2026-09-02 — the last status line of each encoder's log, 79 minutes into the current session
# flagship-comp-20260902-164809.log.err
Input #0, mpegts, from 'udp://127.0.0.1:5107?fifo_size=1000000&overrun_nonfatal=1':
  Stream #0:0[0x100]: Video: h264 (High), yuv420p(progressive), 640x360, 20 fps, 20 tbr, 90k tbn, start 10.800000
  ... three more identical inputs on 5103, 5104, 5105 ...
Input #4, wav, from 'C:\ProgramData\HelenLive\music\helen_serial.wav':
  Stream #4:0: Audio: pcm_s16le, 44100 Hz, stereo, s16, 1411 kb/s
  vstack:default -> Stream #0:0 (h264_qsv)
      encoder         : Lavc62.28.101 h264_qsv
frame=47550 fps= 10 q=21.0 size= 2118844KiB time=01:19:14.90 bitrate=3650.5kbits/s dup=73 drop=55723 speed=0.999x

# foodcam-20260902-164809.log.err
Input #0, rtsp, from 'rtsp://127.0.0.1:8554/helen_food_face':
  Stream #0:0: Video: h264 (High), yuv420p(progressive), 2560x1440, 20 fps, 20 tbr, 90k tbn
  Stream #0:1: Audio: pcm_alaw, 8000 Hz, mono, s16, 64 kb/s          <- never mapped
Stream mapping:
  Stream #0:0 -> #0:0 (h264 (native) -> h264 (h264_qsv))
  Stream #1:0 -> #0:1 (pcm_s16le (native) -> aac (native))
Output #0, flv, to '<YT_RTMP_URL>/<YT_STREAM_KEY>':
  Stream #0:0: Video: h264, nv12(tv, progressive), 2560x1440, q=2-31, 2500 kb/s, 15 fps, 1k tbn
  Stream #0:1: Audio: aac (LC), 44100 Hz, stereo, fltp, 128 kb/s
frame=71684 fps= 15 q=29.0 size= 1540588KiB time=01:19:38.86 bitrate=2640.9kbits/s dup=21 drop=0 speed=   1x
The watchdog earning its keep — flagship-comp-wrapper.log, same afternoon
2026-09-02T16:36:14-04:00 RTMP connection unhealthy (state=CloseWait), killing and restarting in 5s
2026-09-02T16:37:15-04:00 RTMP connection unhealthy (state=CloseWait), killing and restarting in 5s
2026-09-02T16:38:15-04:00 RTMP connection unhealthy (state=CloseWait), killing and restarting in 5s
   ... nine more, one a minute ...
2026-09-02T16:47:30-04:00 RTMP connection unhealthy (state=CloseWait), killing and restarting in 5s
(next session started 16:48:09 and was still Established at 18:07)

# foodcam-wrapper.log, previous 26 hours
2026-09-01T16:27:54-04:00 no RTMP connection to YouTube after 45s, killing and restarting in 10s
2026-09-01T16:34:04-04:00 RTMP connection unhealthy (state=CloseWait), killing and restarting in 10s
2026-09-02T03:02:38-04:00 RTMP connection unhealthy (state=CloseWait), killing and restarting in 10s
2026-09-02T03:05:22-04:00 RTMP connection unhealthy (state=CloseWait), killing and restarting in 10s
2026-09-02T03:09:38-04:00 RTMP connection unhealthy (state=CloseWait), killing and restarting in 10s
2026-09-02T03:09:53-04:00 ffmpeg exited (code ), restarting in 10s
2026-09-02T03:11:24-04:00 RTMP connection unhealthy (state=CloseWait), killing and restarting in 10s
2026-09-02T06:24:06-04:00 no RTMP connection to YouTube after 45s, killing and restarting in 10s

Read the mosaic's status line carefully. speed=0.999x is the only number that matters for a live stream: anything under 1.0 for long means the encoder cannot keep up and the stream will fall behind and eventually drop. drop=55723 is not an error; it is -vsync cfr discarding the frames it does not need to turn four 20 fps inputs into one 10 fps output. dup=73 in 79 minutes is the compositor filling the gaps when a tile stuttered. bitrate=3650 is 3,500 of video plus 128 of audio plus FLV overhead, pinned, which is what -maxrate equal to -b:v buys you.

The CloseWait storm at 16:36 is what YouTube's ingest edge does when it is having a bad afternoon: it accepts the handshake, takes a minute of video, and half-closes. Twelve restarts a minute apart, then it cleared and the stream has been up since. Before v11 that afternoon would have been an hour of a dark stream and a process list that said everything was fine.

Cost on an i7-8700 / UHD 630, measured during the session above
WhatMeasured
ffmpeg build8.1.1 essentials (gyan.dev via winget install Gyan.FFmpeg)
Four tiles, memory≈ 20 MB working set each (cap 250 MB)
Four tiles, CPU≈ 22 CPU-seconds each over 80 minutes; effectively nothing
Two encoders, memory≈ 220 MB and ≈ 330 MB working set (caps 900 MB and 700 MB)
Two encoders, CPU≈ 2,230 CPU-seconds combined over 80 minutes ≈ 3.9 % of a 12-thread machine; the H.264 work is on the iGPU
Whole machine14–15 % CPU with Frigate decoding ten cameras and the ZMQ detector running alongside
Upload used3.65 + 2.64 ≈ 6.3 Mbit/s continuous
Watchdog restarts, mosaic, 2026-09-0212 in one eleven-minute CloseWait storm, 0 otherwise since the 04:00 refresh
Watchdog restarts, Face cam, 26 h8

What I would change

  • The 72-hour execution limit on the encoder tasks should be zero. It is masked today; it should not need to be.
  • Log rotation. Every restart writes a new .log and .log.err; the current session's .err is 1.2 MB after 80 minutes because of the status line and the SEI warnings. A weekly task that deletes anything older than seven days is a one-liner and is not written yet.
  • A tile health check. The daily refresh is a workaround for not knowing when a tile's RTSP session goes bad. The fix is to have the tile wrapper watch its own .err for a stalled frame= counter and restart itself, the way the encoders watch their socket.
  • -hwaccel_output_format qsv on the Face cam would keep the decoded frames on the GPU between decode and encode and skip a system-memory round trip. At 15 fps it was not worth the extra failure mode; at 30 it would be.