Mimir/scripts/record-stream-proof-output.ps1
2026-06-01 01:56:19 +01:00

201 lines
8.1 KiB
PowerShell

param(
[int]$RavenPort = 5204,
[int]$DurationSeconds = 20,
[int]$StartupTimeoutSeconds = 20,
[int]$Width = 1280,
[int]$Height = 720,
[int]$FrameRate = 30,
[string]$OutputPath = "artifacts\runtime\stream-proof\stream-proof-output.mp4",
[string]$LogRoot = "artifacts\runtime\stream-proof",
[string]$FfmpegPath = "ffmpeg",
[switch]$StartRavenSender,
[string]$RavenHost = "madman's lullaby@192.168.1.84",
[string]$RavenAddress = "192.168.1.84",
[string]$RavenRepo = "C:\Meta\Mimir",
[string]$StarfireHost = "192.168.1.66",
[switch]$RavenSenderListens,
[ValidateSet("srt", "tcp-listener")]
[string]$RavenSenderTransport = "srt",
[string]$RavenInputOverride = "",
[string]$KiyoVideoDevice = "USB Video Device",
[string]$StarfireScarlettAudioDevice = "Analogue 1 + 2 (2- Focusrite USB Audio)",
[int]$KiyoWidth = 640,
[int]$KiyoHeight = 480,
[int]$KiyoFrameRate = 30,
[int]$KiyoCornerWidth = 320,
[int]$KiyoMargin = 28,
[double]$RavenOffsetSeconds = 0.0,
[double]$KiyoOffsetSeconds = 0.0,
[double]$MicOffsetSeconds = 0.0,
[switch]$NoStarfireScarlett,
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
function Quote-ProcessArgument([string]$Value) {
if ($Value.Length -eq 0) {
return '""'
}
if ($Value -notmatch '[\s"]') {
return $Value
}
return '"' + $Value.Replace('"', '\"') + '"'
}
$repo = Split-Path -Parent $PSScriptRoot
$absoluteOutputPath = if ([System.IO.Path]::IsPathRooted($OutputPath)) {
$OutputPath
} else {
Join-Path $repo $OutputPath
}
$outputDirectory = Split-Path -Parent $absoluteOutputPath
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
$absoluteLogRoot = if ([System.IO.Path]::IsPathRooted($LogRoot)) {
$LogRoot
} else {
Join-Path $repo $LogRoot
}
New-Item -ItemType Directory -Force -Path $absoluteLogRoot | Out-Null
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$stdoutLog = Join-Path $absoluteLogRoot "stream-proof-record-$timestamp.out.log"
$stderrLog = Join-Path $absoluteLogRoot "stream-proof-record-$timestamp.err.log"
$ravenEndpoint = if ($RavenInputOverride.Length -gt 0) {
$RavenInputOverride
} elseif ($RavenSenderListens) {
"srt://${RavenAddress}:${RavenPort}?mode=caller&latency=120000&timeout=30000000"
} else {
"srt://0.0.0.0:${RavenPort}?mode=listener&latency=120000&timeout=30000000"
}
$ravenSender = "${RavenRepo}\scripts\start-raven-av-sender.ps1"
if ($StartRavenSender) {
$taskName = "MimirRavenAvSender$RavenPort"
$remoteCmd = "${RavenRepo}\scripts\run-raven-av-$RavenPort.cmd"
$remoteTargetHost = if ($RavenSenderListens) { "0.0.0.0" } else { $StarfireHost }
$remoteSrtMode = if ($RavenSenderListens) { "listener" } else { "caller" }
$remoteCommand = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File $ravenSender -TargetHost $remoteTargetHost -Port $RavenPort -SrtMode $remoteSrtMode -Transport $RavenSenderTransport"
$taskTime = (Get-Date).AddMinutes(1).ToString("HH:mm")
Write-Host "Starting Raven muxed A/V sender on ${RavenHost} through interactive task ${taskName}: $remoteCommand"
if (-not $DryRun) {
ssh $RavenHost "cmd /c echo $remoteCommand ^> $remoteCmd" | Out-Null
ssh $RavenHost "schtasks /Delete /TN $taskName /F" | Out-Null
ssh $RavenHost "schtasks /Create /TN $taskName /SC ONCE /ST $taskTime /TR $remoteCmd /F /IT" | Out-Host
ssh $RavenHost "schtasks /Run /TN $taskName" | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "Could not start Raven muxed A/V sender task on $RavenHost."
}
Start-Sleep -Milliseconds 1500
}
}
$arguments = @(
"-hide_banner",
"-y",
"-thread_queue_size", "1024",
"-itsoffset", $RavenOffsetSeconds.ToString([Globalization.CultureInfo]::InvariantCulture),
"-i", $ravenEndpoint,
"-thread_queue_size", "1024",
"-rtbufsize", "256M",
"-itsoffset", $KiyoOffsetSeconds.ToString([Globalization.CultureInfo]::InvariantCulture),
"-f", "dshow",
"-video_size", "${KiyoWidth}x${KiyoHeight}",
"-framerate", $KiyoFrameRate.ToString(),
"-i", "video=$KiyoVideoDevice"
)
if (-not $NoStarfireScarlett) {
$arguments += @(
"-thread_queue_size", "1024",
"-rtbufsize", "256M",
"-itsoffset", $MicOffsetSeconds.ToString([Globalization.CultureInfo]::InvariantCulture),
"-f", "dshow",
"-i", "audio=$StarfireScarlettAudioDevice"
)
}
$scaledKiyoHeight = [Math]::Max(1, [int][Math]::Round($KiyoCornerWidth * ($KiyoHeight / [double]$KiyoWidth)))
$overlayX = $KiyoMargin
$overlayY = $KiyoMargin
if ($NoStarfireScarlett) {
$filter = "[0:v]scale=${Width}:${Height},setsar=1,hqdn3d=1.2:1.0:3.0:2.0[base];" +
"[1:v]scale=${KiyoCornerWidth}:${scaledKiyoHeight},setsar=1,hqdn3d=1.4:1.1:3.4:2.2[pip];" +
"[base][pip]overlay=${overlayX}:${overlayY}:format=auto[v];" +
"[0:a]aresample=async=1:first_pts=0,afftdn=nr=8:nf=-35[a]"
} else {
$filter = "[0:v]scale=${Width}:${Height},setsar=1,hqdn3d=1.2:1.0:3.0:2.0[base];" +
"[1:v]scale=${KiyoCornerWidth}:${scaledKiyoHeight},setsar=1,hqdn3d=1.4:1.1:3.4:2.2[pip];" +
"[base][pip]overlay=${overlayX}:${overlayY}:format=auto[v];" +
"[0:a]aresample=async=1:first_pts=0,afftdn=nr=8:nf=-35[ravenAudio];" +
"[2:a]aresample=async=1:first_pts=0,afftdn=nr=10:nf=-38,volume=1.0[heroMic];" +
"[ravenAudio][heroMic]amix=inputs=2:duration=longest:normalize=0[a]"
}
$arguments += @(
"-filter_complex", $filter,
"-map", "[v]",
"-map", "[a]",
"-t", $DurationSeconds.ToString(),
"-r", $FrameRate.ToString(),
"-c:v", "h264_nvenc",
"-preset", "p4",
"-tune", "ll",
"-rc", "cbr",
"-b:v", "4500k",
"-maxrate", "4500k",
"-bufsize", "9000k",
"-g", ($FrameRate * 2).ToString(),
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-b:a", "160k",
"-ar", "48000",
"-movflags", "+faststart",
$absoluteOutputPath
)
Write-Host "Stream-proof recording:"
Write-Host " Raven input: $ravenEndpoint"
Write-Host " Raven SRT mode: $(if ($RavenSenderListens) { 'Raven listener, Starfire caller' } else { 'Starfire listener, Raven caller' })"
Write-Host " Kiyo PIP: $KiyoVideoDevice ${KiyoWidth}x${KiyoHeight}@$KiyoFrameRate -> ${KiyoCornerWidth}x${scaledKiyoHeight}"
Write-Host " Raven audio: Realtek/default render loopback in muxed NVENC/SRT feed"
Write-Host " Starfire Scarlett: $(if ($NoStarfireScarlett) { 'disabled' } else { $StarfireScarlettAudioDevice })"
Write-Host " sync role: Starfire Realtek emits chirps; Raven Realtek carries co-streamer game/program audio; Starfire Scarlett captures hero mics plus Raven shotgun"
Write-Host " Offsets: raven=${RavenOffsetSeconds}s kiyo=${KiyoOffsetSeconds}s mic=${MicOffsetSeconds}s"
Write-Host " Output: $absoluteOutputPath"
Write-Host " stdout: $stdoutLog"
Write-Host " stderr: $stderrLog"
if ($DryRun) {
Write-Host ($FfmpegPath + " " + ($arguments -join " "))
exit 0
}
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $FfmpegPath
$startInfo.UseShellExecute = $false
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$startInfo.Arguments = ($arguments | ForEach-Object { Quote-ProcessArgument $_ }) -join " "
$process = [System.Diagnostics.Process]::Start($startInfo)
if (-not $process) {
throw "Could not start FFmpeg recorder."
}
$stdoutTask = $process.StandardOutput.BaseStream.CopyToAsync([System.IO.File]::Open($stdoutLog, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read))
$stderrTask = $process.StandardError.BaseStream.CopyToAsync([System.IO.File]::Open($stderrLog, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read))
$timeoutMs = [Math]::Max(1, $DurationSeconds + $StartupTimeoutSeconds) * 1000
if (-not $process.WaitForExit($timeoutMs)) {
try { $process.Kill($true) } catch {}
throw "FFmpeg recorder timed out after $($DurationSeconds + $StartupTimeoutSeconds)s. Check $stderrLog."
}
[void]$stdoutTask.GetAwaiter().GetResult()
[void]$stderrTask.GetAwaiter().GetResult()
if ($process.ExitCode -ne 0) {
throw "FFmpeg recorder failed with exit code $($process.ExitCode). Check $stderrLog."
}