Add H264 Eve dashboard relay
This commit is contained in:
parent
c189e9c77f
commit
022567da43
5 changed files with 1018 additions and 6 deletions
|
|
@ -1,6 +1,7 @@
|
|||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Mimir.App/Mimir.App.csproj" />
|
||||
<Project Path="src/Mimir.EveRelay/Mimir.EveRelay.csproj" />
|
||||
<Project Path="src/Mimir.Runtime/Mimir.Runtime.csproj" />
|
||||
<Project Path="src/Mimir.BufferSmoke/Mimir.BufferSmoke.csproj" />
|
||||
</Folder>
|
||||
|
|
|
|||
|
|
@ -27,19 +27,36 @@ dotnet run --project .\src\Mimir.App\Mimir.App.csproj
|
|||
```
|
||||
|
||||
Fensalir creates a shareable D3D12 texture with that name and copies the finished
|
||||
backbuffer into it during the present pass. A hardware encoder process should
|
||||
open that named texture, encode H.264/HEVC with GPU hardware, and send frames to
|
||||
EVE's native receiver.
|
||||
backbuffer into it during the present pass. `Mimir.EveRelay` opens that named
|
||||
texture, reads the completed program frame, feeds a low-latency H.264 encoder,
|
||||
and sends complete Annex-B access units to EveCanvas over the existing
|
||||
`/stream` WebSocket. EveCanvas treats JPEG frames as the old VoidBot dashboard
|
||||
fallback only; Mimir dashboard streaming is `h264-annexb` and displays through a
|
||||
native `AVSampleBufferDisplayLayer` so the iPad owns hardware decode/composite.
|
||||
|
||||
`scripts/start-eve-program-output.ps1` starts the Mimir/Fensalir app with the
|
||||
shared-texture output enabled and verifies the `eve` SSH target before launch.
|
||||
`scripts/start-eve-dashboard-stream.ps1` starts the Mimir/Fensalir app with the
|
||||
shared-texture output enabled, starts the H.264 relay, verifies the `eve` SSH
|
||||
target, opens a reverse SSH tunnel from Eve's `127.0.0.1:8792` back to the
|
||||
local relay when direct inbound TCP is blocked, and launches EveCanvas:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\start-eve-dashboard-stream.ps1
|
||||
```
|
||||
|
||||
The relay defaults to `h264_nvenc`. Use `-Encoder libx264` only as a diagnostic
|
||||
fallback when no NVENC-capable FFmpeg is installed.
|
||||
|
||||
EveCanvas tries `ws://127.0.0.1:8792/stream` first, then falls back to
|
||||
`ws://192.168.1.66:8792/stream`. The localhost path is not JPEG tunneling; it is
|
||||
the same H.264 Annex-B stream carried through SSH port forwarding so a Windows
|
||||
firewall rule cannot silently turn the dashboard into a wall.
|
||||
|
||||
## Receiver Contract
|
||||
|
||||
EVE owns:
|
||||
|
||||
- network receive;
|
||||
- hardware decode;
|
||||
- hardware decode of H.264 access units;
|
||||
- texture upload/import;
|
||||
- final fullscreen composite;
|
||||
- touch/Pencil/sensor uplink later.
|
||||
|
|
@ -56,4 +73,5 @@ EVE does not own:
|
|||
|
||||
```powershell
|
||||
dotnet run --project .\src\Mimir.BufferSmoke\Mimir.BufferSmoke.csproj -- --eve-program-output-contract-smoke
|
||||
dotnet build .\src\Mimir.EveRelay\Mimir.EveRelay.csproj --no-restore --disable-build-servers
|
||||
```
|
||||
|
|
|
|||
135
scripts/start-eve-dashboard-stream.ps1
Normal file
135
scripts/start-eve-dashboard-stream.ps1
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
param(
|
||||
[string] $Target = "eve",
|
||||
[string] $SharedTextureName = "Global\MimirFensalirProgramTexture",
|
||||
[string] $Configuration = "Debug",
|
||||
[int] $Width = 1620,
|
||||
[int] $Height = 2160,
|
||||
[int] $Fps = 30,
|
||||
[int] $Port = 8792,
|
||||
[string] $LanHost = "192.168.1.66",
|
||||
[string] $Encoder = "h264_nvenc",
|
||||
[int] $Quality = 26,
|
||||
[string] $FfmpegPath = "",
|
||||
[switch] $NoReverseTunnel,
|
||||
[string] $MimirLogPath = "logs\eve-dashboard-mimir.log",
|
||||
[string] $RelayLogPath = "logs\eve-dashboard-relay.log",
|
||||
[string] $TunnelLogPath = "logs\eve-dashboard-ssh-tunnel.log",
|
||||
[string[]] $AppArguments = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$mimirLog = Join-Path $repoRoot $MimirLogPath
|
||||
$mimirErr = [System.IO.Path]::ChangeExtension($mimirLog, ".err.log")
|
||||
$relayLog = Join-Path $repoRoot $RelayLogPath
|
||||
$relayErr = [System.IO.Path]::ChangeExtension($relayLog, ".err.log")
|
||||
$tunnelLog = Join-Path $repoRoot $TunnelLogPath
|
||||
$tunnelErr = [System.IO.Path]::ChangeExtension($tunnelLog, ".err.log")
|
||||
New-Item -ItemType Directory -Path (Split-Path -Parent $mimirLog) -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Split-Path -Parent $relayLog) -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Split-Path -Parent $tunnelLog) -Force | Out-Null
|
||||
|
||||
Write-Host "Checking EVE SSH target: $Target"
|
||||
ssh $Target "uname -a >/tmp/mimir-eve-dashboard-check.txt && cat /tmp/mimir-eve-dashboard-check.txt"
|
||||
|
||||
$env:FENSALIR_PROGRAM_OUTPUT_D3D12 = "1"
|
||||
$env:FENSALIR_PROGRAM_OUTPUT_NAME = $SharedTextureName
|
||||
|
||||
$mimirProject = Join-Path $repoRoot "src\Mimir.App\Mimir.App.csproj"
|
||||
$mimirArgs = @(
|
||||
"run",
|
||||
"--project",
|
||||
$mimirProject,
|
||||
"-c",
|
||||
$Configuration,
|
||||
"--"
|
||||
) + $AppArguments
|
||||
|
||||
Write-Host "Starting Mimir/Fensalir program output: $SharedTextureName"
|
||||
$mimirProcess = Start-Process -FilePath "dotnet" `
|
||||
-ArgumentList $mimirArgs `
|
||||
-WorkingDirectory $repoRoot `
|
||||
-RedirectStandardOutput $mimirLog `
|
||||
-RedirectStandardError $mimirErr `
|
||||
-WindowStyle Hidden `
|
||||
-PassThru
|
||||
|
||||
Write-Host "Mimir PID $($mimirProcess.Id); waiting for shared texture publication..."
|
||||
$deadline = [DateTimeOffset]::Now.AddSeconds(30)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 250
|
||||
$mimirText = if (Test-Path $mimirLog) { Get-Content -Raw $mimirLog } else { "" }
|
||||
if ($mimirText -match "D3D12 program output shared texture") {
|
||||
break
|
||||
}
|
||||
} while ([DateTimeOffset]::Now -lt $deadline -and -not $mimirProcess.HasExited)
|
||||
|
||||
if ($mimirProcess.HasExited) {
|
||||
throw "Mimir exited before publishing the shared program texture. Check $mimirLog and $mimirErr."
|
||||
}
|
||||
|
||||
$relayProject = Join-Path $repoRoot "src\Mimir.EveRelay\Mimir.EveRelay.csproj"
|
||||
$relayArgs = @(
|
||||
"run",
|
||||
"--project",
|
||||
$relayProject,
|
||||
"-c",
|
||||
$Configuration,
|
||||
"--no-restore",
|
||||
"--",
|
||||
"--shared-texture", $SharedTextureName,
|
||||
"--width", $Width,
|
||||
"--height", $Height,
|
||||
"--fps", $Fps,
|
||||
"--port", $Port,
|
||||
"--lan-host", $LanHost,
|
||||
"--encoder", $Encoder,
|
||||
"--quality", $Quality
|
||||
)
|
||||
if (-not [string]::IsNullOrWhiteSpace($FfmpegPath)) {
|
||||
$relayArgs += @("--ffmpeg", $FfmpegPath)
|
||||
}
|
||||
|
||||
Write-Host "Starting H.264 Eve relay on port $Port"
|
||||
$relayProcess = Start-Process -FilePath "dotnet" `
|
||||
-ArgumentList $relayArgs `
|
||||
-WorkingDirectory $repoRoot `
|
||||
-RedirectStandardOutput $relayLog `
|
||||
-RedirectStandardError $relayErr `
|
||||
-WindowStyle Hidden `
|
||||
-PassThru
|
||||
|
||||
if (-not $NoReverseTunnel) {
|
||||
$forwardPattern = "-R ${Port}:127.0.0.1:${Port}"
|
||||
$existingTunnel = Get-CimInstance Win32_Process -Filter "name = 'ssh.exe'" |
|
||||
Where-Object { $_.CommandLine -like "*$forwardPattern*" -and $_.CommandLine -like "* $Target*" } |
|
||||
Select-Object -First 1
|
||||
|
||||
if ($existingTunnel) {
|
||||
Write-Host "Using existing EVE reverse tunnel PID $($existingTunnel.ProcessId)."
|
||||
} else {
|
||||
Write-Host "Starting EVE reverse tunnel: $Target 127.0.0.1:$Port -> Starfire 127.0.0.1:$Port"
|
||||
$tunnelProcess = Start-Process -FilePath "ssh" `
|
||||
-ArgumentList @("-N", "-R", "${Port}:127.0.0.1:${Port}", $Target) `
|
||||
-RedirectStandardOutput $tunnelLog `
|
||||
-RedirectStandardError $tunnelErr `
|
||||
-WindowStyle Hidden `
|
||||
-PassThru
|
||||
Write-Host "Tunnel PID $($tunnelProcess.Id)."
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 500
|
||||
ssh $Target "curl -s --max-time 3 http://127.0.0.1:$Port/health || true"
|
||||
}
|
||||
|
||||
Write-Host "Relay PID $($relayProcess.Id). Launching EveCanvas."
|
||||
ssh $Target "uiopen --bundleid org.gamecult.evecanvas"
|
||||
|
||||
Write-Host "Poll with:"
|
||||
Write-Host " Get-Content -Tail 40 '$mimirLog'"
|
||||
Write-Host " Get-Content -Tail 40 '$mimirErr'"
|
||||
Write-Host " Get-Content -Tail 40 '$relayLog'"
|
||||
Write-Host " Get-Content -Tail 40 '$relayErr'"
|
||||
Write-Host " Get-Content -Tail 40 '$tunnelLog'"
|
||||
Write-Host " Get-Content -Tail 40 '$tunnelErr'"
|
||||
31
src/Mimir.EveRelay/Mimir.EveRelay.csproj
Normal file
31
src/Mimir.EveRelay/Mimir.EveRelay.csproj
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>Mimir.EveRelay</AssemblyName>
|
||||
<RootNamespace>Mimir.EveRelay</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="Vortice.Direct3D12">
|
||||
<HintPath>$(UserProfile)\.nuget\packages\vortice.direct3d12\3.8.3\lib\net10.0\Vortice.Direct3D12.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Vortice.DXGI">
|
||||
<HintPath>$(UserProfile)\.nuget\packages\vortice.dxgi\3.8.3\lib\net10.0\Vortice.DXGI.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Vortice.DirectX">
|
||||
<HintPath>$(UserProfile)\.nuget\packages\vortice.directx\3.8.3\lib\net10.0\Vortice.DirectX.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Vortice.Mathematics">
|
||||
<HintPath>$(UserProfile)\.nuget\packages\vortice.mathematics\2.1.1\lib\net10.0\Vortice.Mathematics.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="SharpGen.Runtime">
|
||||
<HintPath>$(UserProfile)\.nuget\packages\sharpgen.runtime\2.4.2-beta\lib\net9.0\SharpGen.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
827
src/Mimir.EveRelay/Program.cs
Normal file
827
src/Mimir.EveRelay/Program.cs
Normal file
|
|
@ -0,0 +1,827 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Vortice.Direct3D;
|
||||
using Vortice.Direct3D12;
|
||||
using Vortice.DXGI;
|
||||
|
||||
var config = EveRelayConfig.Parse(args);
|
||||
using var relay = await MimirEveRelay.StartAsync(config);
|
||||
await relay.RunAsync();
|
||||
|
||||
internal sealed class MimirEveRelay : IDisposable
|
||||
{
|
||||
private readonly EveRelayConfig config;
|
||||
private readonly D3D12SharedTextureReader textureReader;
|
||||
private readonly HardwareH264Encoder encoder;
|
||||
private readonly EveWebSocketServer server;
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
|
||||
private MimirEveRelay(
|
||||
EveRelayConfig config,
|
||||
D3D12SharedTextureReader textureReader,
|
||||
HardwareH264Encoder encoder,
|
||||
EveWebSocketServer server)
|
||||
{
|
||||
this.config = config;
|
||||
this.textureReader = textureReader;
|
||||
this.encoder = encoder;
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
public static async Task<MimirEveRelay> StartAsync(EveRelayConfig config)
|
||||
{
|
||||
var textureReader = D3D12SharedTextureReader.Open(config.SharedTextureName, config.Width, config.Height);
|
||||
var server = new EveWebSocketServer(config, textureReader.Width, textureReader.Height);
|
||||
var encoder = HardwareH264Encoder.Start(config, textureReader.Width, textureReader.Height, server.BroadcastBinaryAsync);
|
||||
var relay = new MimirEveRelay(config, textureReader, encoder, server);
|
||||
await server.StartAsync();
|
||||
Console.WriteLine($"Mimir Eve relay listening: ws://{config.LanHost}:{config.Port}/stream");
|
||||
Console.WriteLine($"Source: {config.SharedTextureName} {textureReader.Width}x{textureReader.Height}");
|
||||
Console.WriteLine($"Codec: h264-annexb via {config.Encoder}");
|
||||
return relay;
|
||||
}
|
||||
|
||||
public async Task RunAsync()
|
||||
{
|
||||
var frameInterval = TimeSpan.FromSeconds(1.0 / Math.Clamp(config.Fps, 1, 120));
|
||||
var nextFrame = Stopwatch.GetTimestamp();
|
||||
var stopwatchFrequency = Stopwatch.Frequency;
|
||||
var frames = 0L;
|
||||
var lastReport = Stopwatch.GetTimestamp();
|
||||
|
||||
while (!stopping.IsCancellationRequested)
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
if (now < nextFrame)
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds((nextFrame - now) / (double)stopwatchFrequency);
|
||||
await Task.Delay(delay, stopping.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
textureReader.CopyNextFrameTo(encoder.Input);
|
||||
await encoder.Input.FlushAsync(stopping.Token).ConfigureAwait(false);
|
||||
frames++;
|
||||
nextFrame += (long)(frameInterval.TotalSeconds * stopwatchFrequency);
|
||||
|
||||
var reportNow = Stopwatch.GetTimestamp();
|
||||
if ((reportNow - lastReport) / (double)stopwatchFrequency >= 2.0)
|
||||
{
|
||||
Console.WriteLine($"Mimir Eve relay: frames={frames} clients={server.ClientCount} encodedBytes={encoder.EncodedBytes}");
|
||||
lastReport = reportNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
stopping.Cancel();
|
||||
encoder.Dispose();
|
||||
server.Dispose();
|
||||
textureReader.Dispose();
|
||||
stopping.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HardwareH264Encoder : IDisposable
|
||||
{
|
||||
private readonly Process process;
|
||||
private readonly Task outputTask;
|
||||
private readonly Task errorTask;
|
||||
private readonly H264AnnexBAccessUnitFramer framer = new();
|
||||
private long encodedBytes;
|
||||
|
||||
private HardwareH264Encoder(Process process, Func<byte[], Task> broadcast)
|
||||
{
|
||||
this.process = process;
|
||||
Input = process.StandardInput.BaseStream;
|
||||
outputTask = Task.Run(async () =>
|
||||
{
|
||||
var buffer = new byte[64 * 1024];
|
||||
while (!process.HasExited)
|
||||
{
|
||||
var read = await process.StandardOutput.BaseStream.ReadAsync(buffer).ConfigureAwait(false);
|
||||
if (read <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var accessUnit in framer.Push(buffer.AsSpan(0, read).ToArray()))
|
||||
{
|
||||
Interlocked.Add(ref encodedBytes, accessUnit.Length);
|
||||
await broadcast(accessUnit).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
errorTask = Task.Run(async () =>
|
||||
{
|
||||
while (!process.StandardError.EndOfStream)
|
||||
{
|
||||
var line = await process.StandardError.ReadLineAsync().ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
Console.Error.WriteLine($"ffmpeg: {line}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public Stream Input { get; }
|
||||
|
||||
public long EncodedBytes => Interlocked.Read(ref encodedBytes);
|
||||
|
||||
public static HardwareH264Encoder Start(
|
||||
EveRelayConfig config,
|
||||
int width,
|
||||
int height,
|
||||
Func<byte[], Task> broadcast)
|
||||
{
|
||||
var ffmpeg = ResolveFfmpeg(config.FfmpegPath);
|
||||
var arguments = BuildArguments(config, width, height);
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpeg,
|
||||
Arguments = arguments,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
},
|
||||
EnableRaisingEvents = true,
|
||||
};
|
||||
process.Start();
|
||||
return new HardwareH264Encoder(process, broadcast);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Input.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
|
||||
Task.WaitAll([outputTask, errorTask], TimeSpan.FromSeconds(1));
|
||||
process.Dispose();
|
||||
}
|
||||
|
||||
private static string BuildArguments(EveRelayConfig config, int width, int height)
|
||||
{
|
||||
var common = $"-hide_banner -loglevel warning -f rawvideo -pix_fmt bgra -s {width}x{height} -r {config.Fps} -i pipe:0 -an ";
|
||||
return config.Encoder.Equals("libx264", StringComparison.OrdinalIgnoreCase)
|
||||
? common + $"-c:v libx264 -preset ultrafast -tune zerolatency -x264-params keyint={config.Fps}:min-keyint={config.Fps}:scenecut=0 -pix_fmt yuv420p -f h264 pipe:1"
|
||||
: common + $"-c:v h264_nvenc -preset p1 -tune ull -rc constqp -qp {config.Quality} -g {config.Fps} -bf 0 -pix_fmt yuv420p -f h264 pipe:1";
|
||||
}
|
||||
|
||||
private static string ResolveFfmpeg(string? configuredPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredPath) && File.Exists(configuredPath))
|
||||
{
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
var pathValue = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
foreach (var directory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var candidate = Path.Combine(directory, "ffmpeg.exe");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var candidate in new[]
|
||||
{
|
||||
@"C:\ffmpeg\bin\ffmpeg.exe",
|
||||
@"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
|
||||
@"C:\Users\Meta\scoop\shims\ffmpeg.exe",
|
||||
})
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("ffmpeg.exe was not found. Install FFmpeg or pass --ffmpeg <path>.");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed unsafe class D3D12SharedTextureReader : IDisposable
|
||||
{
|
||||
private const int BytesPerPixel = 4;
|
||||
|
||||
private readonly ID3D12Device device;
|
||||
private readonly ID3D12CommandQueue queue;
|
||||
private readonly ID3D12CommandAllocator allocator;
|
||||
private readonly ID3D12GraphicsCommandList commandList;
|
||||
private readonly ID3D12Fence fence;
|
||||
private readonly AutoResetEvent fenceEvent = new(false);
|
||||
private readonly ID3D12Resource texture;
|
||||
private readonly ID3D12Resource readback;
|
||||
private readonly int sourceRowBytes;
|
||||
private readonly int readbackRowPitch;
|
||||
private ulong fenceValue;
|
||||
|
||||
private D3D12SharedTextureReader(
|
||||
ID3D12Device device,
|
||||
ID3D12CommandQueue queue,
|
||||
ID3D12CommandAllocator allocator,
|
||||
ID3D12GraphicsCommandList commandList,
|
||||
ID3D12Fence fence,
|
||||
ID3D12Resource texture,
|
||||
ID3D12Resource readback,
|
||||
int width,
|
||||
int height,
|
||||
int readbackRowPitch)
|
||||
{
|
||||
this.device = device;
|
||||
this.queue = queue;
|
||||
this.allocator = allocator;
|
||||
this.commandList = commandList;
|
||||
this.fence = fence;
|
||||
this.texture = texture;
|
||||
this.readback = readback;
|
||||
Width = width;
|
||||
Height = height;
|
||||
this.readbackRowPitch = readbackRowPitch;
|
||||
sourceRowBytes = checked(width * BytesPerPixel);
|
||||
}
|
||||
|
||||
public int Width { get; }
|
||||
|
||||
public int Height { get; }
|
||||
|
||||
public static D3D12SharedTextureReader Open(string sharedTextureName, int configuredWidth, int configuredHeight)
|
||||
{
|
||||
var device = D3D12.D3D12CreateDevice<ID3D12Device>(IntPtr.Zero, FeatureLevel.Level_11_0);
|
||||
device.Name = "Mimir Eve Relay D3D12 Device";
|
||||
var queue = device.CreateCommandQueue(new CommandQueueDescription(CommandListType.Direct));
|
||||
var allocator = device.CreateCommandAllocator(CommandListType.Direct);
|
||||
var commandList = device.CreateCommandList<ID3D12GraphicsCommandList>(0, CommandListType.Direct, allocator, null);
|
||||
commandList.Close();
|
||||
var fence = device.CreateFence(0);
|
||||
var sharedHandle = device.OpenSharedHandleByName(sharedTextureName);
|
||||
var texture = device.OpenSharedHandle<ID3D12Resource>(sharedHandle);
|
||||
var description = texture.Description;
|
||||
var width = checked((int)description.Width);
|
||||
var height = checked((int)description.Height);
|
||||
var rowPitch = Align(checked(width * BytesPerPixel), D3D12.TextureDataPitchAlignment);
|
||||
var readback = device.CreateCommittedResource(
|
||||
HeapType.Readback,
|
||||
ResourceDescription.Buffer((ulong)(rowPitch * height)),
|
||||
ResourceStates.CopyDest,
|
||||
null);
|
||||
readback.Name = "Mimir Eve Relay Readback";
|
||||
return new D3D12SharedTextureReader(device, queue, allocator, commandList, fence, texture, readback, width, height, rowPitch);
|
||||
}
|
||||
|
||||
public void CopyNextFrameTo(Stream destination)
|
||||
{
|
||||
allocator.Reset();
|
||||
commandList.Reset(allocator, null);
|
||||
var source = new TextureCopyLocation(texture, 0);
|
||||
var dest = new TextureCopyLocation(
|
||||
readback,
|
||||
new PlacedSubresourceFootPrint
|
||||
{
|
||||
Offset = 0,
|
||||
Footprint = new SubresourceFootPrint(Format.B8G8R8A8_UNorm, (uint)Width, (uint)Height, 1, (uint)readbackRowPitch),
|
||||
});
|
||||
commandList.CopyTextureRegion(dest, 0, 0, 0, source, null);
|
||||
commandList.Close();
|
||||
queue.ExecuteCommandList(commandList);
|
||||
WaitForGpu();
|
||||
|
||||
var mapped = readback.Map<byte>(0);
|
||||
try
|
||||
{
|
||||
for (var row = 0; row < Height; row++)
|
||||
{
|
||||
destination.Write(new ReadOnlySpan<byte>(mapped + (row * readbackRowPitch), sourceRowBytes));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
readback.Unmap(0, null);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
WaitForGpu();
|
||||
readback.Dispose();
|
||||
texture.Dispose();
|
||||
fence.Dispose();
|
||||
commandList.Dispose();
|
||||
allocator.Dispose();
|
||||
queue.Dispose();
|
||||
device.Dispose();
|
||||
fenceEvent.Dispose();
|
||||
}
|
||||
|
||||
private void WaitForGpu()
|
||||
{
|
||||
var signalValue = ++fenceValue;
|
||||
queue.Signal(fence, signalValue);
|
||||
if (fence.CompletedValue < signalValue)
|
||||
{
|
||||
fence.SetEventOnCompletion(signalValue, fenceEvent.SafeWaitHandle.DangerousGetHandle());
|
||||
fenceEvent.WaitOne();
|
||||
}
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment)
|
||||
{
|
||||
return (value + alignment - 1) & ~(alignment - 1);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class EveWebSocketServer : IDisposable
|
||||
{
|
||||
private readonly EveRelayConfig config;
|
||||
private readonly int width;
|
||||
private readonly int height;
|
||||
private readonly TcpListener listener;
|
||||
private readonly ConcurrentDictionary<Guid, EveSocketClient> clients = new();
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
|
||||
public EveWebSocketServer(EveRelayConfig config, int width, int height)
|
||||
{
|
||||
this.config = config;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
listener = new TcpListener(IPAddress.Any, config.Port);
|
||||
}
|
||||
|
||||
public int ClientCount => clients.Count;
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
listener.Start();
|
||||
_ = Task.Run(AcceptLoopAsync);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task BroadcastBinaryAsync(byte[] payload)
|
||||
{
|
||||
foreach (var (id, client) in clients)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.SendBinaryAsync(payload).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
clients.TryRemove(id, out _);
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
stopping.Cancel();
|
||||
listener.Stop();
|
||||
foreach (var socket in clients.Values)
|
||||
{
|
||||
socket.Dispose();
|
||||
}
|
||||
|
||||
stopping.Dispose();
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
while (!stopping.IsCancellationRequested)
|
||||
{
|
||||
var tcpClient = await listener.AcceptTcpClientAsync(stopping.Token).ConfigureAwait(false);
|
||||
_ = Task.Run(() => HandleAsync(tcpClient));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAsync(TcpClient tcpClient)
|
||||
{
|
||||
await using var stream = tcpClient.GetStream();
|
||||
var request = await ReadHttpRequestAsync(stream).ConfigureAwait(false);
|
||||
if (request.Path == "/health")
|
||||
{
|
||||
var health = JsonSerializer.Serialize(new
|
||||
{
|
||||
ok = true,
|
||||
codec = "h264-annexb",
|
||||
width,
|
||||
height,
|
||||
config.DeviceScaleFactor,
|
||||
clients = clients.Count,
|
||||
});
|
||||
await WriteHttpResponseAsync(stream, "200 OK", "application/json", Encoding.UTF8.GetBytes(health)).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.Path != "/stream" || !request.Headers.TryGetValue("Sec-WebSocket-Key", out var key))
|
||||
{
|
||||
await WriteHttpResponseAsync(stream, "404 Not Found", "text/plain", Encoding.UTF8.GetBytes("not found")).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteWebSocketHandshakeAsync(stream, key).ConfigureAwait(false);
|
||||
var id = Guid.NewGuid();
|
||||
using var client = new EveSocketClient(tcpClient, stream);
|
||||
clients[id] = client;
|
||||
await SendConfigAsync(client).ConfigureAwait(false);
|
||||
await ReceivePointerLoopAsync(id, client).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Task SendConfigAsync(EveSocketClient socket)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "config",
|
||||
codec = "h264-annexb",
|
||||
width,
|
||||
height,
|
||||
config.DeviceScaleFactor,
|
||||
fps = config.Fps,
|
||||
});
|
||||
return socket.SendTextAsync(json);
|
||||
}
|
||||
|
||||
private async Task ReceivePointerLoopAsync(Guid id, EveSocketClient socket)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var message = await socket.ReceiveAsync().ConfigureAwait(false);
|
||||
if (message.Opcode == 0x8)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (message.Opcode == 0x1)
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(message.Payload);
|
||||
Console.WriteLine($"EVE input: {json}");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
clients.TryRemove(id, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<HttpRequest> ReadHttpRequestAsync(NetworkStream stream)
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
var lastFour = new Queue<byte>(4);
|
||||
while (true)
|
||||
{
|
||||
var value = stream.ReadByte();
|
||||
if (value < 0)
|
||||
{
|
||||
throw new IOException("Client closed before HTTP headers completed.");
|
||||
}
|
||||
|
||||
var b = (byte)value;
|
||||
bytes.Add(b);
|
||||
lastFour.Enqueue(b);
|
||||
if (lastFour.Count > 4)
|
||||
{
|
||||
lastFour.Dequeue();
|
||||
}
|
||||
|
||||
if (lastFour.Count == 4 && lastFour.SequenceEqual(new byte[] { 13, 10, 13, 10 }))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var text = Encoding.ASCII.GetString(bytes.ToArray());
|
||||
var lines = text.Split("\r\n", StringSplitOptions.None);
|
||||
var first = lines[0].Split(' ');
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var line in lines.Skip(1))
|
||||
{
|
||||
var colon = line.IndexOf(':');
|
||||
if (colon > 0)
|
||||
{
|
||||
headers[line[..colon].Trim()] = line[(colon + 1)..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
return new HttpRequest(first.Length > 1 ? first[1] : "/", headers);
|
||||
}
|
||||
|
||||
private static async Task WriteHttpResponseAsync(NetworkStream stream, string status, string contentType, byte[] body)
|
||||
{
|
||||
var header = Encoding.ASCII.GetBytes(
|
||||
$"HTTP/1.1 {status}\r\nContent-Type: {contentType}\r\nContent-Length: {body.Length}\r\nConnection: close\r\n\r\n");
|
||||
await stream.WriteAsync(header).ConfigureAwait(false);
|
||||
await stream.WriteAsync(body).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task WriteWebSocketHandshakeAsync(NetworkStream stream, string key)
|
||||
{
|
||||
var acceptBytes = SHA1.HashData(Encoding.ASCII.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"));
|
||||
var accept = Convert.ToBase64String(acceptBytes);
|
||||
var response = Encoding.ASCII.GetBytes(
|
||||
"HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
$"Sec-WebSocket-Accept: {accept}\r\n\r\n");
|
||||
await stream.WriteAsync(response).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class EveSocketClient : IDisposable
|
||||
{
|
||||
private readonly TcpClient tcpClient;
|
||||
private readonly NetworkStream stream;
|
||||
private readonly SemaphoreSlim sendLock = new(1, 1);
|
||||
|
||||
public EveSocketClient(TcpClient tcpClient, NetworkStream stream)
|
||||
{
|
||||
this.tcpClient = tcpClient;
|
||||
this.stream = stream;
|
||||
}
|
||||
|
||||
public Task SendTextAsync(string text)
|
||||
{
|
||||
return SendFrameAsync(0x1, Encoding.UTF8.GetBytes(text));
|
||||
}
|
||||
|
||||
public Task SendBinaryAsync(byte[] payload)
|
||||
{
|
||||
return SendFrameAsync(0x2, payload);
|
||||
}
|
||||
|
||||
public async Task<WebSocketFrame> ReceiveAsync()
|
||||
{
|
||||
var header0 = stream.ReadByte();
|
||||
var header1 = stream.ReadByte();
|
||||
if (header0 < 0 || header1 < 0)
|
||||
{
|
||||
throw new IOException("WebSocket closed.");
|
||||
}
|
||||
|
||||
var opcode = header0 & 0x0f;
|
||||
var masked = (header1 & 0x80) != 0;
|
||||
ulong length = (ulong)(header1 & 0x7f);
|
||||
if (length == 126)
|
||||
{
|
||||
var ext = await ReadExactAsync(2).ConfigureAwait(false);
|
||||
length = (ulong)((ext[0] << 8) | ext[1]);
|
||||
}
|
||||
else if (length == 127)
|
||||
{
|
||||
var ext = await ReadExactAsync(8).ConfigureAwait(false);
|
||||
length = 0;
|
||||
foreach (var b in ext)
|
||||
{
|
||||
length = (length << 8) | b;
|
||||
}
|
||||
}
|
||||
|
||||
var mask = masked ? await ReadExactAsync(4).ConfigureAwait(false) : [];
|
||||
var payload = await ReadExactAsync(checked((int)length)).ConfigureAwait(false);
|
||||
if (masked)
|
||||
{
|
||||
for (var i = 0; i < payload.Length; i++)
|
||||
{
|
||||
payload[i] ^= mask[i & 3];
|
||||
}
|
||||
}
|
||||
|
||||
return new WebSocketFrame(opcode, payload);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
sendLock.Dispose();
|
||||
stream.Dispose();
|
||||
tcpClient.Dispose();
|
||||
}
|
||||
|
||||
private async Task SendFrameAsync(byte opcode, byte[] payload)
|
||||
{
|
||||
await sendLock.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var header = new List<byte> { (byte)(0x80 | opcode) };
|
||||
if (payload.Length <= 125)
|
||||
{
|
||||
header.Add((byte)payload.Length);
|
||||
}
|
||||
else if (payload.Length <= ushort.MaxValue)
|
||||
{
|
||||
header.Add(126);
|
||||
header.Add((byte)((payload.Length >> 8) & 0xff));
|
||||
header.Add((byte)(payload.Length & 0xff));
|
||||
}
|
||||
else
|
||||
{
|
||||
header.Add(127);
|
||||
var length = (ulong)payload.Length;
|
||||
for (var shift = 56; shift >= 0; shift -= 8)
|
||||
{
|
||||
header.Add((byte)((length >> shift) & 0xff));
|
||||
}
|
||||
}
|
||||
|
||||
await stream.WriteAsync(header.ToArray()).ConfigureAwait(false);
|
||||
await stream.WriteAsync(payload).ConfigureAwait(false);
|
||||
await stream.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadExactAsync(int count)
|
||||
{
|
||||
var buffer = new byte[count];
|
||||
var offset = 0;
|
||||
while (offset < count)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset)).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
throw new IOException("WebSocket closed.");
|
||||
}
|
||||
|
||||
offset += read;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record HttpRequest(string Path, IReadOnlyDictionary<string, string> Headers);
|
||||
|
||||
internal sealed record WebSocketFrame(int Opcode, byte[] Payload);
|
||||
|
||||
internal sealed class H264AnnexBAccessUnitFramer
|
||||
{
|
||||
private readonly List<byte> buffer = [];
|
||||
private readonly List<byte[]> currentAccessUnit = [];
|
||||
private bool currentHasSlice;
|
||||
|
||||
public IEnumerable<byte[]> Push(byte[] chunk)
|
||||
{
|
||||
buffer.AddRange(chunk);
|
||||
while (TryPopNal(out var nal))
|
||||
{
|
||||
var nalType = nal.Length == 0 ? 0 : nal[0] & 0x1f;
|
||||
var isSlice = nalType is 1 or 5;
|
||||
var startsNewAccessUnit = nalType == 9 || (isSlice && currentHasSlice);
|
||||
if (startsNewAccessUnit && currentAccessUnit.Count > 0)
|
||||
{
|
||||
yield return BuildCurrentAccessUnit();
|
||||
currentAccessUnit.Clear();
|
||||
currentHasSlice = false;
|
||||
}
|
||||
|
||||
currentAccessUnit.Add(nal);
|
||||
currentHasSlice |= isSlice;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] BuildCurrentAccessUnit()
|
||||
{
|
||||
var byteCount = currentAccessUnit.Sum(nal => nal.Length + 4);
|
||||
var output = new byte[byteCount];
|
||||
var offset = 0;
|
||||
foreach (var nal in currentAccessUnit)
|
||||
{
|
||||
output[offset++] = 0;
|
||||
output[offset++] = 0;
|
||||
output[offset++] = 0;
|
||||
output[offset++] = 1;
|
||||
nal.CopyTo(output.AsSpan(offset));
|
||||
offset += nal.Length;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private bool TryPopNal(out byte[] nal)
|
||||
{
|
||||
nal = [];
|
||||
var firstStart = FindStartCode(buffer, 0, out var firstStartLength);
|
||||
if (firstStart < 0)
|
||||
{
|
||||
buffer.Clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstStart > 0)
|
||||
{
|
||||
buffer.RemoveRange(0, firstStart);
|
||||
firstStart = 0;
|
||||
}
|
||||
|
||||
var payloadStart = firstStart + firstStartLength;
|
||||
var nextStart = FindStartCode(buffer, payloadStart, out _);
|
||||
if (nextStart < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
nal = buffer.GetRange(payloadStart, nextStart - payloadStart).ToArray();
|
||||
buffer.RemoveRange(0, nextStart);
|
||||
return nal.Length > 0;
|
||||
}
|
||||
|
||||
private static int FindStartCode(List<byte> bytes, int offset, out int length)
|
||||
{
|
||||
for (var index = offset; index < bytes.Count - 3; index++)
|
||||
{
|
||||
if (bytes[index] == 0 && bytes[index + 1] == 0 && bytes[index + 2] == 1)
|
||||
{
|
||||
length = 3;
|
||||
return index;
|
||||
}
|
||||
|
||||
if (index < bytes.Count - 4 && bytes[index] == 0 && bytes[index + 1] == 0 && bytes[index + 2] == 0 && bytes[index + 3] == 1)
|
||||
{
|
||||
length = 4;
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
length = 0;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record EveRelayConfig(
|
||||
string SharedTextureName,
|
||||
int Width,
|
||||
int Height,
|
||||
int Fps,
|
||||
int Port,
|
||||
string LanHost,
|
||||
double DeviceScaleFactor,
|
||||
string Encoder,
|
||||
int Quality,
|
||||
string? FfmpegPath)
|
||||
{
|
||||
public static EveRelayConfig Parse(string[] args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = 0; index < args.Length; index++)
|
||||
{
|
||||
if (!args[index].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = args[index][2..];
|
||||
var value = index + 1 < args.Length && !args[index + 1].StartsWith("--", StringComparison.Ordinal)
|
||||
? args[++index]
|
||||
: "true";
|
||||
values[key] = value;
|
||||
}
|
||||
|
||||
return new EveRelayConfig(
|
||||
StringValue(values, "shared-texture", "Global\\MimirFensalirProgramTexture"),
|
||||
IntValue(values, "width", 0),
|
||||
IntValue(values, "height", 0),
|
||||
IntValue(values, "fps", 30),
|
||||
IntValue(values, "port", 8792),
|
||||
StringValue(values, "lan-host", "192.168.1.66"),
|
||||
DoubleValue(values, "scale", 2.0),
|
||||
StringValue(values, "encoder", "h264_nvenc"),
|
||||
IntValue(values, "quality", 26),
|
||||
values.GetValueOrDefault("ffmpeg"));
|
||||
}
|
||||
|
||||
private static string StringValue(IReadOnlyDictionary<string, string> values, string name, string fallback) =>
|
||||
values.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value) ? value : fallback;
|
||||
|
||||
private static int IntValue(IReadOnlyDictionary<string, string> values, string name, int fallback) =>
|
||||
values.TryGetValue(name, out var value) && int.TryParse(value, out var parsed) ? parsed : fallback;
|
||||
|
||||
private static double DoubleValue(IReadOnlyDictionary<string, string> values, string name, double fallback) =>
|
||||
values.TryGetValue(name, out var value) && double.TryParse(value, out var parsed) ? parsed : fallback;
|
||||
}
|
||||
Loading…
Reference in a new issue