Add self-hosted Mimir live edge
This commit is contained in:
parent
2c197cb3ae
commit
1e4ab94776
12 changed files with 610 additions and 0 deletions
|
|
@ -1,6 +1,7 @@
|
|||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Mimir.App/Mimir.App.csproj" />
|
||||
<Project Path="src/Mimir.Broadcast/Mimir.Broadcast.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" />
|
||||
|
|
|
|||
71
docs/self-hosted-live-edge.md
Normal file
71
docs/self-hosted-live-edge.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Self-Hosted Live Edge
|
||||
|
||||
Mimir's self-hosted broadcast path keeps OBS optional. Starfire owns the final
|
||||
program frame and audio mix; Yggdrasil owns public byte distribution.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Fensalir program texture"] --> B["Starfire NVENC encode"]
|
||||
C["Faust/Mimir mix"] --> B
|
||||
B --> D["SSH TCP forward"]
|
||||
D --> E["Yggdrasil localhost RTMP ingest"]
|
||||
E --> F["HLS segment ring"]
|
||||
F --> G["nginx static HTTPS"]
|
||||
G --> H["mimir.gamecult.org static viewer"]
|
||||
```
|
||||
|
||||
## Authority Map
|
||||
|
||||
- Owner: `Mimir.Broadcast` owns the Starfire-side encoded push command.
|
||||
- Inputs: final program media, FFmpeg/NVENC capability, and the SSH-forwarded
|
||||
RTMP endpoint.
|
||||
- Outputs: one CBR H.264/AAC RTMP stream to Yggdrasil.
|
||||
- Derived state: Yggdrasil's HLS playlist and segments are derived from that
|
||||
encoded stream. They do not own composition, timing, or transcoding.
|
||||
- Forbidden writers: Yggdrasil must not transcode the program stream in the
|
||||
first live edge. OBS is not the primary broadcast owner.
|
||||
- Shared paths: synthetic smokes and real program pushes use the same RTMP URL:
|
||||
`rtmp://127.0.0.1:11935/live/mimir`.
|
||||
- Deletion line: if a later CDN or WebRTC edge appears, keep this path as the
|
||||
simple origin proof or delete it. Do not keep two public stream authorities.
|
||||
|
||||
## First Smoke
|
||||
|
||||
On Starfire, with an SSH local forward active to Yggdrasil's localhost RTMP
|
||||
port:
|
||||
|
||||
```powershell
|
||||
dotnet run --project .\src\Mimir.Broadcast\Mimir.Broadcast.csproj -- --smoke
|
||||
dotnet run --project .\src\Mimir.Broadcast\Mimir.Broadcast.csproj -- --print-command
|
||||
```
|
||||
|
||||
The default push target is:
|
||||
|
||||
```text
|
||||
rtmp://127.0.0.1:11935/live/mimir
|
||||
```
|
||||
|
||||
The public HLS playlist is:
|
||||
|
||||
```text
|
||||
https://live.mimir.gamecult.org/hls/mimir.m3u8
|
||||
```
|
||||
|
||||
The static viewer lives at:
|
||||
|
||||
```text
|
||||
https://mimir.gamecult.org/live
|
||||
```
|
||||
|
||||
## Scaling Rule
|
||||
|
||||
Viewer capacity is approximately:
|
||||
|
||||
```text
|
||||
available Yggdrasil egress / encoded bitrate
|
||||
```
|
||||
|
||||
Micro-optimizations count only when they improve target-host measurements:
|
||||
served Mbps, p95 segment latency, CPU, memory, syscalls, or stall rate on
|
||||
Yggdrasil. The first edge uses nginx static serving and kernel file paths so
|
||||
the hot path is byte distribution, not application code.
|
||||
31
live.md
Normal file
31
live.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Mimir Live
|
||||
|
||||
<link rel="stylesheet" href="/static/live/player.css">
|
||||
|
||||
<main class="mimir-live-shell">
|
||||
<section class="mimir-live-stage" aria-label="Mimir live stream">
|
||||
<video id="mimir-live-video" controls playsinline preload="metadata" poster="/Mimir.png"></video>
|
||||
<div class="mimir-live-status" id="mimir-live-status">Waiting for the live edge.</div>
|
||||
</section>
|
||||
<section class="mimir-live-panel">
|
||||
<p class="mimir-live-kicker">Self-hosted broadcast</p>
|
||||
<h2>Mimir Live</h2>
|
||||
<p>
|
||||
Starfire encodes the final Mimir program once. Yggdrasil receives that
|
||||
stream, rotates HLS segments, and serves this player over public HTTPS.
|
||||
</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Playlist</dt>
|
||||
<dd><a id="mimir-live-playlist-link" href="https://live.mimir.gamecult.org/hls/mimir.m3u8">live.mimir.gamecult.org/hls/mimir.m3u8</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authority</dt>
|
||||
<dd>Mimir encodes. Yggdrasil distributes. The static site only watches.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js" crossorigin="anonymous"></script>
|
||||
<script src="/static/live/player.js"></script>
|
||||
|
|
@ -366,6 +366,18 @@ complete: OBS consumes final Mimir/Fensalir surfaces and stems through the
|
|||
native plugin, raw feeds remain debug inputs, and the ledger records the live
|
||||
program texture, texture ring, dedicated producer fence, and consumer-fence
|
||||
proofs.
|
||||
The public self-hosted edge has its first deployable path. `src/Mimir.Broadcast`
|
||||
builds the Starfire-side FFmpeg/NVENC push command for
|
||||
`rtmp://127.0.0.1:11935/live/mimir`; the port is an SSH local forward to
|
||||
Yggdrasil's localhost RTMP listener. Yggdrasil owns nginx RTMP ingest and HLS
|
||||
segment serving only. It must not transcode or compose in v1. The static Mimir
|
||||
viewer route is `https://mimir.gamecult.org/live`, backed by
|
||||
`static/live/player.js` and `static/live/player.css`, and it reads
|
||||
`https://live.mimir.gamecult.org/hls/mimir.m3u8`. On 2026-05-30 the deployed
|
||||
Yggdrasil origin validated nginx, listened on `127.0.0.1:1935`, returned
|
||||
`mimir-live-ok`, and generated four HLS segments plus `mimir.m3u8` from an
|
||||
eight-second Starfire synthetic NVENC push. The unresolved blocker is public
|
||||
DNS/TLS for `live.mimir.gamecult.org`.
|
||||
`MimirSynchronizedBufferPlanner` is now the low-level aligned-buffer primitive:
|
||||
it picks one canonical presentation time inside the retained rolling window and
|
||||
returns per-stream slices for cameras, network display feeds, and audio. Timing
|
||||
|
|
|
|||
|
|
@ -107,6 +107,14 @@ Get-Content .\state\evidence.jsonl -Tail 8
|
|||
over `/stream`; EveCanvas decodes with `AVSampleBufferDisplayLayer`. The
|
||||
launch script uses an SSH reverse tunnel to Eve when Windows firewall blocks
|
||||
direct inbound TCP.
|
||||
- `src/Mimir.Broadcast` is the first self-hosted public sender. Starfire pushes
|
||||
one final H.264/AAC NVENC RTMP stream to
|
||||
`rtmp://127.0.0.1:11935/live/mimir` through an SSH local forward to
|
||||
Yggdrasil. Yggdrasil's nginx RTMP/HLS origin is deployed and has produced
|
||||
HLS segments from a bounded synthetic push; public activation still needs DNS
|
||||
and TLS for `live.mimir.gamecult.org`. The static viewer route is
|
||||
[[live|Mimir Live]] and reads
|
||||
`https://live.mimir.gamecult.org/hls/mimir.m3u8`.
|
||||
- Faust/native DSP owns hot audio alignment, separation, spatialization, and
|
||||
synchronized stems.
|
||||
- OBS receives final program surfaces; it does not own synchronization.
|
||||
|
|
|
|||
12
src/Mimir.Broadcast/Mimir.Broadcast.csproj
Normal file
12
src/Mimir.Broadcast/Mimir.Broadcast.csproj
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>Mimir.Broadcast</AssemblyName>
|
||||
<RootNamespace>Mimir.Broadcast</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
279
src/Mimir.Broadcast/Program.cs
Normal file
279
src/Mimir.Broadcast/Program.cs
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace Mimir.Broadcast;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
var options = MimirBroadcastOptions.Parse(args);
|
||||
if (options.ShowHelp)
|
||||
{
|
||||
Console.WriteLine(MimirBroadcastOptions.HelpText);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var plan = MimirBroadcastPlan.FromOptions(options);
|
||||
if (options.PrintCommand || options.DryRun)
|
||||
{
|
||||
Console.WriteLine(plan.CommandLine);
|
||||
}
|
||||
|
||||
if (options.Smoke)
|
||||
{
|
||||
return RunSmoke(plan);
|
||||
}
|
||||
|
||||
if (options.DryRun || options.PrintCommand)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await RunFfmpegAsync(options, plan).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static int RunSmoke(MimirBroadcastPlan plan)
|
||||
{
|
||||
var command = plan.CommandLine;
|
||||
var ok = command.Contains("h264_nvenc", StringComparison.Ordinal) &&
|
||||
command.Contains("-f flv", StringComparison.Ordinal) &&
|
||||
command.Contains("rtmp://127.0.0.1:11935/live/mimir", StringComparison.Ordinal) &&
|
||||
command.Contains("-g 60", StringComparison.Ordinal) &&
|
||||
command.Contains("-sc_threshold 0", StringComparison.Ordinal);
|
||||
Console.WriteLine($"mimir-broadcast-smoke nvenc={command.Contains("h264_nvenc", StringComparison.Ordinal)} rtmp={command.Contains("rtmp://", StringComparison.Ordinal)} keyframeCadence={command.Contains("-g 60", StringComparison.Ordinal)}");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
private static async Task<int> RunFfmpegAsync(MimirBroadcastOptions options, MimirBroadcastPlan plan)
|
||||
{
|
||||
var start = new ProcessStartInfo
|
||||
{
|
||||
FileName = options.FfmpegPath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
};
|
||||
foreach (var argument in plan.Arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
using var process = Process.Start(start);
|
||||
if (process == null)
|
||||
{
|
||||
Console.Error.WriteLine("Could not start ffmpeg.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var stdout = PumpAsync(process.StandardOutput, Console.Out);
|
||||
var stderr = PumpAsync(process.StandardError, Console.Error);
|
||||
await process.WaitForExitAsync().ConfigureAwait(false);
|
||||
await Task.WhenAll(stdout, stderr).ConfigureAwait(false);
|
||||
return process.ExitCode;
|
||||
}
|
||||
|
||||
private static async Task PumpAsync(StreamReader reader, TextWriter writer)
|
||||
{
|
||||
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line)
|
||||
{
|
||||
writer.WriteLine(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record MimirBroadcastPlan(IReadOnlyList<string> Arguments)
|
||||
{
|
||||
public string CommandLine => $"ffmpeg {string.Join(' ', Arguments.Select(Quote))}";
|
||||
|
||||
public static MimirBroadcastPlan FromOptions(MimirBroadcastOptions options)
|
||||
{
|
||||
var args = new List<string>();
|
||||
if (options.Realtime)
|
||||
{
|
||||
args.Add("-re");
|
||||
}
|
||||
|
||||
if (options.TestPattern)
|
||||
{
|
||||
args.AddRange(["-f", "lavfi", "-i", $"testsrc2=size={options.Width}x{options.Height}:rate={options.FrameRate}"]);
|
||||
args.AddRange(["-f", "lavfi", "-i", $"sine=frequency=997:sample_rate={options.AudioSampleRate}"]);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(options.InputPath))
|
||||
{
|
||||
args.AddRange(["-i", options.InputPath]);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Use --test-pattern or provide --input.");
|
||||
}
|
||||
|
||||
var audioMap = options.TestPattern ? "1:a:0" : "0:a:0?";
|
||||
|
||||
args.AddRange([
|
||||
"-map", "0:v:0",
|
||||
"-map", audioMap,
|
||||
"-c:v", "h264_nvenc",
|
||||
"-preset", options.NvencPreset,
|
||||
"-tune", "ll",
|
||||
"-rc", "cbr",
|
||||
"-b:v", $"{options.VideoBitrateKbps}k",
|
||||
"-maxrate", $"{options.VideoBitrateKbps}k",
|
||||
"-bufsize", $"{options.VideoBitrateKbps * 2}k",
|
||||
"-g", options.GopFrames.ToStringInvariant(),
|
||||
"-keyint_min", options.GopFrames.ToStringInvariant(),
|
||||
"-sc_threshold", "0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-b:a", $"{options.AudioBitrateKbps}k",
|
||||
"-ar", options.AudioSampleRate.ToStringInvariant(),
|
||||
]);
|
||||
|
||||
if (options.DurationSeconds > 0)
|
||||
{
|
||||
args.AddRange(["-t", options.DurationSeconds.ToStringInvariant()]);
|
||||
}
|
||||
|
||||
args.AddRange([
|
||||
"-f", "flv",
|
||||
options.TargetUrl,
|
||||
]);
|
||||
return new MimirBroadcastPlan(args);
|
||||
}
|
||||
|
||||
private static string Quote(string value)
|
||||
{
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
return value.Any(static c => char.IsWhiteSpace(c) || c == '"')
|
||||
? $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\""
|
||||
: value;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record MimirBroadcastOptions
|
||||
{
|
||||
public string FfmpegPath { get; init; } = "ffmpeg";
|
||||
|
||||
public string TargetUrl { get; init; } = "rtmp://127.0.0.1:11935/live/mimir";
|
||||
|
||||
public string InputPath { get; init; } = "";
|
||||
|
||||
public bool TestPattern { get; init; } = true;
|
||||
|
||||
public bool Realtime { get; init; } = true;
|
||||
|
||||
public bool PrintCommand { get; init; }
|
||||
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
public bool Smoke { get; init; }
|
||||
|
||||
public bool ShowHelp { get; init; }
|
||||
|
||||
public int Width { get; init; } = 1280;
|
||||
|
||||
public int Height { get; init; } = 720;
|
||||
|
||||
public int FrameRate { get; init; } = 30;
|
||||
|
||||
public int GopFrames { get; init; } = 60;
|
||||
|
||||
public int VideoBitrateKbps { get; init; } = 4500;
|
||||
|
||||
public int AudioSampleRate { get; init; } = 48_000;
|
||||
|
||||
public int AudioBitrateKbps { get; init; } = 160;
|
||||
|
||||
public string NvencPreset { get; init; } = "p4";
|
||||
|
||||
public int DurationSeconds { get; init; }
|
||||
|
||||
public static string HelpText => """
|
||||
Mimir.Broadcast
|
||||
|
||||
Push a Starfire-encoded Mimir program stream to the Yggdrasil RTMP/HLS edge.
|
||||
The default command is a synthetic NVENC smoke source:
|
||||
|
||||
dotnet run --project src/Mimir.Broadcast -- --print-command
|
||||
|
||||
Options:
|
||||
--target <url> RTMP target. Default: rtmp://127.0.0.1:11935/live/mimir
|
||||
--ffmpeg <path> FFmpeg executable. Default: ffmpeg
|
||||
--input <path> Input media path instead of the synthetic test pattern.
|
||||
--no-test-pattern Require --input.
|
||||
--width <px> Synthetic width. Default: 1280
|
||||
--height <px> Synthetic height. Default: 720
|
||||
--fps <n> Synthetic frame rate. Default: 30
|
||||
--video-kbps <n> CBR video bitrate. Default: 4500
|
||||
--audio-kbps <n> AAC bitrate. Default: 160
|
||||
--gop <frames> Keyframe cadence. Default: 60
|
||||
--preset <name> NVENC preset. Default: p4
|
||||
--duration-seconds <n> Stop after this many seconds. Default: run until stopped.
|
||||
--print-command Print the generated FFmpeg command.
|
||||
--dry-run Print command and exit.
|
||||
--smoke Validate the generated command shape and exit.
|
||||
""";
|
||||
|
||||
public static MimirBroadcastOptions Parse(IReadOnlyList<string> args)
|
||||
{
|
||||
var options = new MimirBroadcastOptions();
|
||||
for (var index = 0; index < args.Count; index++)
|
||||
{
|
||||
var arg = args[index];
|
||||
options = arg switch
|
||||
{
|
||||
"--help" or "-h" => options with { ShowHelp = true },
|
||||
"--print-command" => options with { PrintCommand = true },
|
||||
"--dry-run" => options with { DryRun = true, PrintCommand = true },
|
||||
"--smoke" => options with { Smoke = true },
|
||||
"--no-test-pattern" => options with { TestPattern = false },
|
||||
"--target" => options with { TargetUrl = RequiredValue(args, ref index, arg) },
|
||||
"--ffmpeg" => options with { FfmpegPath = RequiredValue(args, ref index, arg) },
|
||||
"--input" => options with { InputPath = RequiredValue(args, ref index, arg), TestPattern = false },
|
||||
"--width" => options with { Width = ParsePositiveInt(args, ref index, arg) },
|
||||
"--height" => options with { Height = ParsePositiveInt(args, ref index, arg) },
|
||||
"--fps" => options with { FrameRate = ParsePositiveInt(args, ref index, arg) },
|
||||
"--video-kbps" => options with { VideoBitrateKbps = ParsePositiveInt(args, ref index, arg) },
|
||||
"--audio-kbps" => options with { AudioBitrateKbps = ParsePositiveInt(args, ref index, arg) },
|
||||
"--gop" => options with { GopFrames = ParsePositiveInt(args, ref index, arg) },
|
||||
"--preset" => options with { NvencPreset = RequiredValue(args, ref index, arg) },
|
||||
"--duration-seconds" => options with { DurationSeconds = ParsePositiveInt(args, ref index, arg) },
|
||||
_ => throw new ArgumentException($"Unknown argument `{arg}`."),
|
||||
};
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private static string RequiredValue(IReadOnlyList<string> args, ref int index, string option)
|
||||
{
|
||||
if (index + 1 >= args.Count || args[index + 1].StartsWith("-", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException($"{option} requires a value.");
|
||||
}
|
||||
|
||||
index++;
|
||||
return args[index];
|
||||
}
|
||||
|
||||
private static int ParsePositiveInt(IReadOnlyList<string> args, ref int index, string option)
|
||||
{
|
||||
var text = RequiredValue(args, ref index, option);
|
||||
if (!int.TryParse(text, out var value) || value <= 0)
|
||||
{
|
||||
throw new ArgumentException($"{option} requires a positive integer.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class InvariantFormatting
|
||||
{
|
||||
public static string ToStringInvariant(this int value) =>
|
||||
value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
|
@ -237,3 +237,5 @@
|
|||
{"ts":"2026-05-30T00:44:38+01:00","type":"decision","status":"ok","note":"Fensalir now owns stereo-depth dispatch readiness accounting before the SGM kernel exists: it only counts a lowering ready when the Height claim is planned and left/right textures plus the UAV disparity SurfacePage resolve. Mimir remains responsible for real input texture resource identity; no placeholder depth output is written."}
|
||||
{"ts":"2026-05-30T00:48:04+01:00","type":"implementation","status":"ok","note":"Stereo-depth profile/candidate/lowering now carries SGM constants through Mimir into the Fensalir sidecar: min disparity 0, 128 disparities, four aggregation paths, census radius 2, P1 8, and P2 96 for the libSGM-provenance D3D12 profile. Verified Mimir build, --stereo-depth-contract-smoke, and --perfect-machine-profile-smoke."}
|
||||
{"ts":"2026-05-30T21:58:00+01:00","type":"implementation","status":"ok","note":"Mimir now lowers live Leap packed stereo IR into the stereo-depth FieldEvidence lane. MimirRuntime merges a Leap depth frame when a live LeapStereoIr rolling window has a declared GPU texture resource; BuildLeapPackedStereoDepthCandidateFrame treats the packed R8G8 texture as left/right resource identity with R/G observation lanes, emits the pending-calibration Height claim, UAV R16F disparity SurfacePage, R8 confidence texture, and AquariumFieldStereoDepthLowering sidecar. Verified with Mimir build, --leap-packed-stereo-depth-smoke, and --stereo-depth-contract-smoke."}
|
||||
{"ts": "2026-05-30T22:01:42+01:00", "type": "implementation", "status": "ok", "note": "Self-hosted Mimir live edge has a deployed origin proof. Mimir.Broadcast builds the Starfire FFmpeg/NVENC RTMP push to rtmp://127.0.0.1:11935/live/mimir through an SSH local forward; Yggdrasil nginx now has libnginx-mod-rtmp, localhost RTMP ingest, /srv/mimir-live/hls, and a live.mimir.gamecult.org HTTP vhost with CORS for mimir.gamecult.org. An eight-second synthetic NVENC push on 2026-05-30 generated mimir.m3u8 and four TS segments on Yggdrasil. Public DNS/TLS for live.mimir.gamecult.org remains the activation blocker."}
|
||||
{"ts": "2026-05-30T22:04:09+01:00", "type": "verification", "status": "ok", "note": "Yggdrasil target-host fanout smoke ran during an active Starfire synthetic NVENC push through the SSH RTMP tunnel. With LIVE_URL pinned to localhost via curl --resolve, the benchmark fetched a live HLS segment from nginx 320 times with CLIENTS=16 and REQUESTS_PER_CLIENT=20 in 1256 ms. wrk is not installed yet, so p95 latency remains future instrumentation."}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,16 @@ accepted_design:
|
|||
decode/composite. The local launch script opens an SSH reverse tunnel to
|
||||
Eve when Windows firewall policy blocks direct inbound TCP, and EveCanvas
|
||||
tries localhost before the LAN host.
|
||||
- `src/Mimir.Broadcast` is the first self-hosted public edge sender:
|
||||
Starfire uses FFmpeg/NVENC to push one final H.264/AAC RTMP stream through
|
||||
an SSH local forward to Yggdrasil. Yggdrasil owns nginx RTMP ingest and HLS
|
||||
segment serving only; it does not transcode or compose. The public viewer
|
||||
route is `https://mimir.gamecult.org/live`, and it reads
|
||||
`https://live.mimir.gamecult.org/hls/mimir.m3u8`. The deployed Yggdrasil
|
||||
origin already validates, listens on localhost RTMP `127.0.0.1:1935`, and
|
||||
produced HLS segments from an eight-second Starfire synthetic NVENC push on
|
||||
2026-05-30. Public DNS/TLS for `live.mimir.gamecult.org` remains the
|
||||
activation blocker.
|
||||
- `MimirAudioBlockDescriptor` carries sample rate, channels, sample format,
|
||||
frame count, timestamp, and optional native handle.
|
||||
- `MimirFrameEventProcessStreamSource` is a temporary diagnostic adapter for
|
||||
|
|
|
|||
37
static/live/index.html
Normal file
37
static/live/index.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mimir Live</title>
|
||||
<link rel="stylesheet" href="/static/live/player.css">
|
||||
</head>
|
||||
<body class="mimir-live-page">
|
||||
<main class="mimir-live-shell">
|
||||
<section class="mimir-live-stage" aria-label="Mimir live stream">
|
||||
<video id="mimir-live-video" controls playsinline preload="metadata" poster="/Mimir.png"></video>
|
||||
<div class="mimir-live-status" id="mimir-live-status">Waiting for the live edge.</div>
|
||||
</section>
|
||||
<section class="mimir-live-panel">
|
||||
<p class="mimir-live-kicker">Self-hosted broadcast</p>
|
||||
<h1>Mimir Live</h1>
|
||||
<p>
|
||||
Starfire encodes the final Mimir program once. Yggdrasil receives that
|
||||
stream, rotates HLS segments, and serves this player over public HTTPS.
|
||||
</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Playlist</dt>
|
||||
<dd><a id="mimir-live-playlist-link" href="https://live.mimir.gamecult.org/hls/mimir.m3u8">live.mimir.gamecult.org/hls/mimir.m3u8</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authority</dt>
|
||||
<dd>Mimir encodes. Yggdrasil distributes. The static site only watches.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</main>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js" crossorigin="anonymous"></script>
|
||||
<script src="/static/live/player.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
104
static/live/player.css
Normal file
104
static/live/player.css
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
.mimir-live-page,
|
||||
.mimir-live-shell {
|
||||
color-scheme: dark;
|
||||
font-family: Ubuntu, system-ui, sans-serif;
|
||||
color: #f3fbff;
|
||||
}
|
||||
|
||||
.mimir-live-page *,
|
||||
.mimir-live-shell * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mimir-live-page {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #05090d;
|
||||
}
|
||||
|
||||
.mimir-live-shell a {
|
||||
color: #9bd887;
|
||||
}
|
||||
|
||||
.mimir-live-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 380px);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
min-height: 100vh;
|
||||
padding: clamp(16px, 3vw, 42px);
|
||||
}
|
||||
|
||||
.mimir-live-stage {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mimir-live-shell video {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #020507;
|
||||
border: 1px solid rgba(100, 183, 255, 0.28);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mimir-live-status {
|
||||
min-height: 1.4rem;
|
||||
color: #c4dce5;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.mimir-live-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.mimir-live-kicker {
|
||||
margin: 0;
|
||||
color: #64b7ff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mimir-live-panel h1,
|
||||
.mimir-live-panel h2 {
|
||||
margin: 0;
|
||||
font-family: Montserrat, system-ui, sans-serif;
|
||||
font-size: clamp(2rem, 5vw, 4.2rem);
|
||||
font-weight: 200;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.mimir-live-panel p {
|
||||
margin: 0;
|
||||
color: #c4dce5;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.mimir-live-panel dl {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.mimir-live-panel dt {
|
||||
color: #6d8792;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mimir-live-panel dd {
|
||||
margin: 4px 0 0;
|
||||
color: #f3fbff;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.mimir-live-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
43
static/live/player.js
Normal file
43
static/live/player.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
(function () {
|
||||
const playlistUrl = "https://live.mimir.gamecult.org/hls/mimir.m3u8"
|
||||
const video = document.getElementById("mimir-live-video")
|
||||
const status = document.getElementById("mimir-live-status")
|
||||
const link = document.getElementById("mimir-live-playlist-link")
|
||||
|
||||
if (!video || !status || !link) {
|
||||
return
|
||||
}
|
||||
|
||||
link.href = playlistUrl
|
||||
link.textContent = playlistUrl.replace(/^https?:\/\//, "")
|
||||
|
||||
function setStatus(message) {
|
||||
status.textContent = message
|
||||
}
|
||||
|
||||
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = playlistUrl
|
||||
setStatus("Using native HLS playback.")
|
||||
return
|
||||
}
|
||||
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
const hls = new window.Hls({
|
||||
lowLatencyMode: false,
|
||||
backBufferLength: 30,
|
||||
maxBufferLength: 18,
|
||||
})
|
||||
hls.loadSource(playlistUrl)
|
||||
hls.attachMedia(video)
|
||||
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
||||
setStatus("Live edge found. Playback is ready.")
|
||||
})
|
||||
hls.on(window.Hls.Events.ERROR, (_event, data) => {
|
||||
const detail = data && data.details ? ` ${data.details}` : ""
|
||||
setStatus(`Waiting for stream.${detail}`)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setStatus("This browser cannot play HLS here. Try Safari, Edge, or Chrome with hls.js available.")
|
||||
})()
|
||||
Loading…
Reference in a new issue