release: add private stream testing and media fixes

This commit is contained in:
Franz Rolfsvaag 2026-07-24 22:12:39 +02:00
parent 5ec18ac0c4
commit dceebce43a
48 changed files with 1939 additions and 138 deletions

View File

@ -49,3 +49,10 @@ LUMI_OPERATOR_PRIVACY_URL=
# source checkout. Explicit overrides always win.
# LUMI_DEV_MODE=true
# LUMI_HOST=127.0.0.1
# Private OBS stream testing (optional; see docs/stream-testing.md)
# LUMI_STREAM_TEST_INGEST_HOST=stream-test.example.com
# LUMI_STREAM_TEST_INGEST_PORT=19350
# LUMI_STREAM_TEST_PUBLIC_PORT=443
# LUMI_STREAM_TEST_RTMPS=true
# LUMI_FFMPEG_PATH=/absolute/path/to/ffmpeg

View File

@ -1,5 +1,12 @@
# Lumi changelog
## 0.3.0
- Added admin-only private stream testing for the real OBS output with expiring authenticated sessions, supervised FFmpeg ingest, source/720p/480p no-upscale HLS, automatic/manual quality, audio, fullscreen, reused captions, real OBS/receiver diagnostics, bounded cleanup, and a deterministic test pattern.
- Added a crash-safe two-phase OBS destination handoff: Companion protects the complete prior service with current-user DPAPI before redirecting, then restores it on stop, expiry, receiver failure, WebSocket loss, Companion exit, OBS restart, or startup recovery.
- Made public video overlays pixel-only and playback-lifecycle aware, and converted audio sources from visual canvas objects into invisible managed outputs with inspector playback controls and ignored legacy layout data.
- Made Companion update checks safely repeatable after success, no-update, failure, cancellation, or rapid repeated clicks.
## 0.2.27
- Completed bundled-plugin synchronization automatically on the first startup after a legacy core-only update, allowing production hosts on 0.2.25 to receive the Companion transcription and Song Overlay plugins in the same update flow.

View File

@ -55,10 +55,12 @@ You can set these in `.env` or change role IDs in **Admin → Settings**.
Use **Admin → Plugins** to install, enable, update, or uninstall plugins.
You can also create a local plugin from the WebUI.
The experimental `experimental-companion` branch includes the independent Lumi
Companion transcription foundation. Its current scope, trust boundaries, setup,
and unverified target-machine work are documented in
Lumi Companion provides the streaming-computer boundary for transcription,
media capture, and private stream testing. Its trust boundaries and setup are documented in
[`docs/lumi-companion-transcription.md`](docs/lumi-companion-transcription.md).
Private OBS output testing, receiver setup, recovery behavior, and the
deterministic media pattern are documented in
[`docs/stream-testing.md`](docs/stream-testing.md).
## Updates and recovery

View File

@ -21,6 +21,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Transcriptio
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.SongOverlay", "plugins\Lumi.Companion.SongOverlay\Lumi.Companion.SongOverlay.csproj", "{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Core.Tests", "tests\Lumi.Companion.Core.Tests\Lumi.Companion.Core.Tests.csproj", "{AAFDF982-BE30-405F-A39B-E82F1B11D46A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -43,18 +47,6 @@ Global
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|x64.Build.0 = Release|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|x86.ActiveCfg = Release|Any CPU
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|x86.Build.0 = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x64.ActiveCfg = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x64.Build.0 = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x86.ActiveCfg = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x86.Build.0 = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|Any CPU.Build.0 = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x64.ActiveCfg = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x64.Build.0 = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x86.ActiveCfg = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x86.Build.0 = Release|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -115,6 +107,30 @@ Global
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|x64.Build.0 = Release|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|x86.ActiveCfg = Release|Any CPU
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|x86.Build.0 = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x64.ActiveCfg = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x64.Build.0 = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x86.ActiveCfg = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x86.Build.0 = Debug|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|Any CPU.Build.0 = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x64.ActiveCfg = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x64.Build.0 = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x86.ActiveCfg = Release|Any CPU
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x86.Build.0 = Release|Any CPU
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Debug|x64.ActiveCfg = Debug|Any CPU
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Debug|x64.Build.0 = Debug|Any CPU
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Debug|x86.ActiveCfg = Debug|Any CPU
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Debug|x86.Build.0 = Debug|Any CPU
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|Any CPU.Build.0 = Release|Any CPU
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|x64.ActiveCfg = Release|Any CPU
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|x64.Build.0 = Release|Any CPU
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|x86.ActiveCfg = Release|Any CPU
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -127,5 +143,6 @@ Global
{2F0041FD-FD06-4312-BF1E-180F0201EB97} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{49C19CA8-4669-4C33-B3A8-9CB934CD1171} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
{AAFDF982-BE30-405F-A39B-E82F1B11D46A} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal

View File

@ -1,5 +1,5 @@
#ifndef AppVersion
#define AppVersion "0.1.0"
#define AppVersion "0.2.0"
#endif
#ifndef SourceRoot
#error SourceRoot must point at the self-contained Companion publish directory.

View File

@ -2,8 +2,8 @@
# Copyright (c) 2026 OokamiKunTV
cmake_minimum_required(VERSION 3.28)
project(lumi-obs-bridge VERSION 0.1.0 LANGUAGES CXX)
set(LUMI_BRIDGE_VERSION "0.1.0-development" CACHE STRING "Lumi Companion bridge release version")
project(lumi-obs-bridge VERSION 0.2.0 LANGUAGES CXX)
set(LUMI_BRIDGE_VERSION "0.2.0-development" CACHE STRING "Lumi Companion bridge release version")
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

View File

@ -67,6 +67,8 @@ static std::atomic_bool obs_state_dirty{true};
static std::atomic_uint32_t audio_sequence{0};
static obs_source_t *captured_source = nullptr;
static std::mutex captured_source_mutex;
static std::mutex stream_test_mutex;
static std::string stream_test_session;
static std::string wide_to_utf8(const std::wstring &value)
{
@ -324,8 +326,120 @@ static json source_list_message()
static json obs_state_message()
{
obs_video_info video{};
obs_get_video_info(&video);
return {{"type", "obs_state"}, {"protocol_version", protocol_version}, {"version", obs_get_version_string()},
{"streaming", obs_frontend_streaming_active()}, {"recording", obs_frontend_recording_active()}};
{"streaming", obs_frontend_streaming_active()}, {"recording", obs_frontend_recording_active()},
{"output_width", video.output_width}, {"output_height", video.output_height},
{"fps_num", video.fps_num}, {"fps_den", video.fps_den}};
}
struct service_command_context {
bool begin = false;
std::string server;
std::string key;
std::string session_id;
std::string restore_type;
std::string restore_settings;
bool ok = false;
std::string error;
std::string snapshot_type;
std::string snapshot_settings;
};
static void apply_stream_test_service(void *opaque)
{
auto &context = *static_cast<service_command_context *>(opaque);
if (context.begin) {
if (obs_frontend_streaming_active()) {
context.error = "OBS is already streaming. Stop the current output before starting a private test.";
return;
}
obs_service_t *existing = obs_frontend_get_streaming_service();
if (!existing) {
context.error = "OBS does not have a streaming service to restore after the test.";
return;
}
const char *type = obs_service_get_id(existing);
obs_data_t *settings = obs_service_get_settings(existing);
context.snapshot_type = type ? type : "";
context.snapshot_settings = settings ? obs_data_get_json(settings) : "";
if (settings) obs_data_release(settings);
obs_service_release(existing);
if (context.snapshot_type.empty() || context.snapshot_settings.empty()) {
context.error = "The current OBS stream service could not be snapshotted safely.";
return;
}
obs_data_t *test_settings = obs_data_create();
obs_data_set_string(test_settings, "server", context.server.c_str());
obs_data_set_string(test_settings, "key", context.key.c_str());
obs_data_set_bool(test_settings, "use_auth", false);
obs_service_t *test = obs_service_create("rtmp_custom", "Lumi private stream test", test_settings, nullptr);
obs_data_release(test_settings);
if (!test) {
context.error = "OBS could not create the private test service.";
return;
}
obs_frontend_set_streaming_service(test);
obs_frontend_save_streaming_service();
obs_service_release(test);
{
std::scoped_lock lock(stream_test_mutex);
stream_test_session = context.session_id;
}
obs_frontend_streaming_start();
context.ok = true;
return;
}
if (obs_frontend_streaming_active()) obs_frontend_streaming_stop();
obs_data_t *settings = obs_data_create_from_json(context.restore_settings.c_str());
if (!settings) {
context.error = "The encrypted OBS recovery snapshot is invalid.";
return;
}
obs_service_t *restored = obs_service_create(context.restore_type.c_str(), "Restored streaming service", settings, nullptr);
obs_data_release(settings);
if (!restored) {
context.error = "OBS could not restore the saved streaming service.";
return;
}
obs_frontend_set_streaming_service(restored);
obs_frontend_save_streaming_service();
obs_service_release(restored);
{
std::scoped_lock lock(stream_test_mutex);
stream_test_session.clear();
}
context.ok = true;
}
static json stream_test_metrics_message()
{
std::string session_id;
{
std::scoped_lock lock(stream_test_mutex);
session_id = stream_test_session;
}
if (session_id.empty()) return json();
obs_video_info video{};
obs_get_video_info(&video);
json value{{"type", "stream_test_metrics"}, {"protocol_version", protocol_version}, {"session_id", session_id},
{"active", obs_frontend_streaming_active()}, {"width", video.output_width}, {"height", video.output_height},
{"fps", video.fps_den ? static_cast<double>(video.fps_num) / video.fps_den : 0.0}};
obs_output_t *output = obs_frontend_get_streaming_output();
if (output) {
value["total_bytes"] = obs_output_get_total_bytes(output);
value["dropped_frames"] = obs_output_get_frames_dropped(output);
value["total_frames"] = obs_output_get_total_frames(output);
value["congestion"] = obs_output_get_congestion(output);
obs_encoder_t *encoder = obs_output_get_video_encoder(output);
value["encoder"] = encoder && obs_encoder_get_id(encoder) ? obs_encoder_get_id(encoder) : "";
if (encoder) obs_encoder_release(encoder);
obs_output_release(output);
}
return value;
}
static bool output_caption(const std::string &text, double display_seconds)
@ -353,6 +467,62 @@ static std::optional<json> handle_command(const json &message)
const auto text = payload.value("stable_text", "");
const auto duration = payload.value("display_seconds", 2.0);
output_caption(text, duration);
} else if (type == "stream_test_snapshot") {
service_command_context context;
obs_queue_task(OBS_TASK_UI, [](void *opaque) {
auto &value = *static_cast<service_command_context *>(opaque);
if (obs_frontend_streaming_active()) {
value.error = "OBS is already streaming. Stop the current output before starting a private test.";
return;
}
obs_service_t *existing = obs_frontend_get_streaming_service();
if (!existing) {
value.error = "OBS does not have a streaming service to restore after the test.";
return;
}
const char *type_id = obs_service_get_id(existing);
obs_data_t *settings = obs_service_get_settings(existing);
value.snapshot_type = type_id ? type_id : "";
value.snapshot_settings = settings ? obs_data_get_json(settings) : "";
if (settings) obs_data_release(settings);
obs_service_release(existing);
value.ok = !value.snapshot_type.empty() && !value.snapshot_settings.empty();
if (!value.ok) value.error = "The current OBS stream service could not be snapshotted safely.";
}, &context, true);
return json{{"type", "stream_test_service_state"}, {"protocol_version", protocol_version},
{"request_id", message.value("request_id", "")}, {"state", context.ok ? "snapshotted" : "failed"},
{"error", context.error}, {"snapshot", {{"service_type", context.snapshot_type}, {"settings_json", context.snapshot_settings}}}};
} else if (type == "stream_test_begin" && message.contains("payload")) {
const auto &payload = message["payload"];
service_command_context context;
context.begin = true;
context.server = payload.value("server", "");
context.key = payload.value("key", "");
context.session_id = payload.value("session_id", "");
if ((context.server.rfind("rtmp://", 0) != 0 && context.server.rfind("rtmps://", 0) != 0) ||
context.server.size() > 1000 || context.key.empty() || context.key.size() > 1000 || context.session_id.size() > 80) {
context.error = "The private ingest destination is invalid.";
} else {
obs_queue_task(OBS_TASK_UI, apply_stream_test_service, &context, true);
}
return json{{"type", "stream_test_service_state"}, {"protocol_version", protocol_version},
{"request_id", message.value("request_id", "")}, {"state", context.ok ? "started" : "failed"},
{"session_id", context.session_id}, {"error", context.error}};
} else if (type == "stream_test_restore" && message.contains("payload")) {
const auto &payload = message["payload"];
service_command_context context;
context.restore_type = payload.value("service_type", "");
context.restore_settings = payload.value("settings_json", "");
context.session_id = payload.value("session_id", "");
if (context.restore_type.empty() || context.restore_type.size() > 200 || context.restore_settings.empty() ||
context.restore_settings.size() > max_json_bytes / 2) {
context.error = "The OBS recovery snapshot is incomplete.";
} else {
obs_queue_task(OBS_TASK_UI, apply_stream_test_service, &context, true);
}
return json{{"type", "stream_test_service_state"}, {"protocol_version", protocol_version},
{"request_id", message.value("request_id", "")}, {"state", context.ok ? "restored" : "failed"},
{"session_id", context.session_id}, {"error", context.error}};
}
return std::nullopt;
}
@ -399,6 +569,7 @@ static void run_pipe_worker()
source_list_dirty.store(true, std::memory_order_release);
obs_state_dirty.store(true, std::memory_order_release);
auto health_sent = std::chrono::steady_clock::now();
auto stream_metrics_sent = std::chrono::steady_clock::now();
while (connected && !stopping.load(std::memory_order_acquire)) {
connected = read_available_command(pipe);
if (connected && source_list_dirty.exchange(false, std::memory_order_acq_rel)) connected = write_json(pipe, source_list_message());
@ -415,6 +586,11 @@ static void run_pipe_worker()
{"status", "healthy"}, {"audio_frames_dropped", audio_queue.dropped()}});
health_sent = now;
}
if (connected && now - stream_metrics_sent >= std::chrono::seconds(1)) {
const auto metrics = stream_test_metrics_message();
if (!metrics.empty()) connected = write_json(pipe, metrics);
stream_metrics_sent = now;
}
std::unique_lock lock(worker_signal_mutex);
worker_signal.wait_for(lock, std::chrono::milliseconds(10));
}

View File

@ -1,5 +1,6 @@
using System.Buffers.Binary;
using System.IO.Pipes;
using System.Collections.Concurrent;
using System.Text.Json;
using Lumi.Companion.Protocol;
@ -7,12 +8,13 @@ namespace Lumi.Companion.Transcription;
public sealed class ObsBridgePipe : IAsyncDisposable
{
private static readonly HashSet<string> AllowedTypes = ["hello", "source_list", "source_state", "selection_state", "obs_state", "health"];
private static readonly HashSet<string> AllowedTypes = ["hello", "source_list", "source_state", "selection_state", "obs_state", "health", "stream_test_service_state", "stream_test_metrics"];
private readonly string _pipeName;
private readonly Func<JsonElement, Task> _onMessage;
private readonly Func<ReadOnlyMemory<byte>, Task> _onAudio;
private readonly CancellationTokenSource _lifetime = new();
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly ConcurrentDictionary<string, TaskCompletionSource<JsonElement>> _requests = new();
private Task? _loop;
private Stream? _connection;
@ -55,7 +57,10 @@ public sealed class ObsBridgePipe : IAsyncDisposable
using var json = JsonDocument.Parse(message);
var type = json.RootElement.TryGetProperty("type", out var property) ? property.GetString() : null;
if (type is null || !AllowedTypes.Contains(type)) throw new InvalidDataException("OBS bridge message type is not allowed.");
await _onMessage(json.RootElement.Clone());
var clone = json.RootElement.Clone();
if (clone.TryGetProperty("request_id", out var requestId) && requestId.ValueKind == JsonValueKind.String &&
_requests.TryRemove(requestId.GetString()!, out var pending)) pending.TrySetResult(clone);
await _onMessage(clone);
}
}
public static async Task WriteAsync(Stream stream, object message, CancellationToken cancellationToken)
@ -79,6 +84,25 @@ public sealed class ObsBridgePipe : IAsyncDisposable
catch (IOException) { return false; }
finally { _writeLock.Release(); }
}
public async Task<JsonElement> RequestAsync(string type, object payload, TimeSpan timeout, CancellationToken cancellationToken = default)
{
var requestId = Guid.NewGuid().ToString();
var completion = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
if (!_requests.TryAdd(requestId, completion)) throw new InvalidOperationException("OBS request identity could not be reserved.");
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout);
using var registration = timeoutSource.Token.Register(() => completion.TrySetCanceled(timeoutSource.Token));
try
{
if (!await SendAsync(new { type, request_id = requestId, payload }, timeoutSource.Token))
throw new InvalidOperationException("OBS is not connected to Lumi Companion.");
return await completion.Task;
}
finally
{
_requests.TryRemove(requestId, out _);
}
}
private static async Task ReadExactlyAsync(Stream stream, Memory<byte> buffer, CancellationToken cancellationToken)
{
var read = 0;
@ -89,5 +113,5 @@ public sealed class ObsBridgePipe : IAsyncDisposable
read += count;
}
}
public async ValueTask DisposeAsync() { _lifetime.Cancel(); if (_loop is not null) try { await _loop; } catch (OperationCanceledException) { } _writeLock.Dispose(); _lifetime.Dispose(); }
public async ValueTask DisposeAsync() { _lifetime.Cancel(); foreach (var request in _requests.Values) request.TrySetCanceled(); _requests.Clear(); if (_loop is not null) try { await _loop; } catch (OperationCanceledException) { } _writeLock.Dispose(); _lifetime.Dispose(); }
}

View File

@ -1,6 +1,6 @@
param(
[string]$ObsVersion = "31.1.1",
[string]$BridgeVersion = "0.1.0",
[string]$BridgeVersion = "0.2.0",
[string]$CacheRoot = "$env:LOCALAPPDATA\LumiCompanionBuild"
)
@ -17,6 +17,12 @@ $sourceRoot = Join-Path $sourceParent "obs-studio-$ObsVersion"
$importRoot = Join-Path $obsRoot "imports"
$buildRoot = Join-Path $obsRoot "bridge-build"
function Invoke-Native([string]$FilePath, [string[]]$Arguments) {
$argumentLine = ($Arguments | ForEach-Object { '"' + ([string]$_).Replace('"', '\"') + '"' }) -join ' '
$process = Start-Process -FilePath $FilePath -ArgumentList $argumentLine -NoNewWindow -Wait -PassThru
return $process.ExitCode
}
$runtimeUrl = "https://github.com/obsproject/obs-studio/releases/download/$ObsVersion/OBS-Studio-$ObsVersion-Windows-x64.zip"
$sourceUrl = "https://github.com/obsproject/obs-studio/archive/refs/tags/$ObsVersion.zip"
$runtimeSha256 = "9d8dceb77acd8af04af23f877061f63c9bef78ca73d2093d0ccba1bb9104173f"
@ -46,8 +52,8 @@ function New-ImportLibrary([string]$Dll, [string]$Name, [string]$Dumpbin, [strin
if ($_ -match '^\s+\d+\s+[0-9A-F]+\s+[0-9A-F]+\s+(\S+)') { $Matches[1] }
})
@("LIBRARY $Name", "EXPORTS") + ($exports | ForEach-Object { " $_" }) | Set-Content -Encoding Ascii $definition
& $LibExe /nologo /machine:x64 "/def:$definition" "/out:$(Join-Path $importRoot "$Name.lib")"
if ($LASTEXITCODE) { throw "Could not create the $Name import library." }
$exitCode = Invoke-Native $LibExe @("/nologo", "/machine:x64", "/def:$definition", "/out:$(Join-Path $importRoot "$Name.lib")")
if ($exitCode) { throw "Could not create the $Name import library." }
}
Get-VerifiedArchive $runtimeUrl $runtimeArchive $runtimeSha256
@ -88,10 +94,8 @@ $configureArguments = @(
"-DOBS_IMPORT_DIR=$importRoot",
"-DLUMI_BRIDGE_VERSION=$BridgeVersion"
)
& $cmake @configureArguments
if ($LASTEXITCODE) { throw "OBS bridge configuration failed." }
& $cmake --build $buildRoot --config Release
if ($LASTEXITCODE) { throw "OBS bridge build failed." }
if ((Invoke-Native $cmake $configureArguments)) { throw "OBS bridge configuration failed." }
if ((Invoke-Native $cmake @("--build", $buildRoot, "--config", "Release"))) { throw "OBS bridge build failed." }
New-Item -ItemType Directory -Force -Path $componentRoot | Out-Null
$bridgeDll = Join-Path $buildRoot "Release\lumi-obs-bridge.dll"

View File

@ -1,6 +1,6 @@
param(
[string]$Version = "0.1.0",
[string]$BridgeVersion = "0.1.0",
[string]$Version = "0.2.0",
[string]$BridgeVersion = "0.2.0",
[string]$ObsVersion = "31.1.1"
)
@ -16,6 +16,12 @@ $installer = Join-Path $outputRoot "Lumi.Companion-Setup.exe"
$legalSourceRoot = Join-Path $repoRoot "companion\legal"
$publishLegalRoot = Join-Path $publishRoot "legal"
function Invoke-Native([string]$FilePath, [string[]]$Arguments) {
$argumentLine = ($Arguments | ForEach-Object { '"' + ([string]$_).Replace('"', '\"') + '"' }) -join ' '
$process = Start-Process -FilePath $FilePath -ArgumentList $argumentLine -NoNewWindow -Wait -PassThru
return $process.ExitCode
}
function Compress-ArchiveWithRetry {
param(
[Parameter(Mandatory = $true)][string]$Path,
@ -43,13 +49,11 @@ $localDotnet = Join-Path $HOME ".dotnet-sdk\dotnet.exe"
$dotnet = if (Test-Path $localDotnet) { $localDotnet } else { (Get-Command dotnet.exe -ErrorAction Stop).Source }
$publishArguments = @("publish", $project, "-c", "Release", "-r", "win-x64", "--self-contained", "true", "--disable-build-servers", "-o", $publishRoot,
"-p:PublishSingleFile=true", "-p:IncludeNativeLibrariesForSelfExtract=true", "-p:DebugType=None", "-p:Version=$Version")
& $dotnet @publishArguments
if ($LASTEXITCODE) { throw "Companion publish failed." }
if ((Invoke-Native $dotnet $publishArguments)) { throw "Companion publish failed." }
$bridgeDiagnostic = Join-Path $outputRoot "obs-bridge-package-diagnostic.json"
Remove-Item $bridgeDiagnostic -Force -ErrorAction SilentlyContinue
& (Join-Path $publishRoot "Lumi.Companion.App.exe") --diagnose-obs-bridge $bridgeDiagnostic
if ($LASTEXITCODE -ne 0) {
if ((Invoke-Native (Join-Path $publishRoot "Lumi.Companion.App.exe") @("--diagnose-obs-bridge", $bridgeDiagnostic))) {
$detail = if (Test-Path $bridgeDiagnostic) { Get-Content $bridgeDiagnostic -Raw } else { "No diagnostic result was written." }
throw "Published OBS integration payload failed its executable-level check: $detail"
}
@ -123,8 +127,7 @@ $compileArguments = @(
"/DOutputRoot=$outputRoot",
(Join-Path $repoRoot "companion\installer\Lumi.Companion.iss")
)
& $iscc @compileArguments
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $installer)) { throw "Companion installer compilation failed." }
if ((Invoke-Native $iscc $compileArguments) -or -not (Test-Path $installer)) { throw "Companion installer compilation failed." }
$file = Get-Item $archive
$sha = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant()

View File

@ -19,6 +19,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
private readonly CompanionPaths _paths;
private readonly CompanionSettingsStore _settings;
private readonly SecureCredentialStore _credentials;
private readonly StreamTestRecoveryStore _streamTestRecovery;
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly UpdateService _updates;
private readonly ObsBridgeManager _bridgeManager = new();
@ -29,6 +30,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
private TaskCompletionSource<bool>? _sessionStartSignal;
private TaskCompletionSource<bool>? _sessionStopSignal;
private TaskCompletionSource<string>? _testFailure;
private TaskCompletionSource<JsonElement>? _streamTestSessionSignal;
private TaskCompletionSource<(string SourceUuid, bool Attached)>? _bridgeSelectionSignal;
private bool? _bridgeSelectionAttached;
private readonly SemaphoreSlim _bridgeSelectionGate = new(1, 1);
@ -39,6 +41,14 @@ public sealed class CompanionRuntime : IAsyncDisposable
private int _benchmarkStopping;
private long _lastVoiceMeterAt;
private bool _disposed;
private readonly SingleFlightOperation _updateChecks = new();
private int _streamTestRestoreRunning;
private int _obsOutputWidth = 1920;
private int _obsOutputHeight = 1080;
private double _obsOutputFps = 30;
private long _streamMetricBytes;
private DateTimeOffset _streamMetricAt;
private DateTimeOffset _streamTestBeganAt;
private CompanionUpdate? _availableUpdate;
private bool _serverReady;
private string? _serverReadinessFingerprint;
@ -49,6 +59,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
_paths = paths;
_settings = settings;
_credentials = new SecureCredentialStore(paths.Root);
_streamTestRecovery = new StreamTestRecoveryStore(paths.Root);
_updates = new UpdateService(_http, paths);
State = new CompanionState();
TestStages = CreateInitialTestStages();
@ -86,6 +97,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
{
await _settings.LoadAsync();
StartObsBridgeBoundary();
if (_streamTestRecovery.Exists)
SetState(State with { StreamTestRecoveryRequired = true, StreamTestDetail = "A previous private test needs OBS restoration. Open OBS; Lumi Companion will repair it automatically." });
_ = RunUpdateChecksAsync(_maintenanceLifetime.Token);
ApplyAutoStart(_settings.Current.AutoStartWithWindows);
DeviceCredential? credential;
@ -161,6 +174,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
if (_disposed) return;
_testFailure?.TrySetResult(error is null ? "The Lumi connection closed during the test." : $"The Lumi connection closed during the test: {Friendly(error)}");
_benchmarkLifetime?.Cancel();
if (State.StreamTestRunning || _streamTestRecovery.Exists) _ = RestoreObsAfterStreamTestAsync("The Lumi connection closed; OBS recovery started.");
SetState(State with { Connected = false, BenchmarkRunning = false, Health = TrayHealth.Degraded, Detail = "The secure Lumi connection closed. Retry when the host is available.", BenchmarkDetail = State.BenchmarkRunning ? "The benchmark was aborted because the Lumi connection closed." : State.BenchmarkDetail });
_ = WriteLogAsync("disconnected", error?.Message ?? "Connection closed.");
};
@ -258,6 +272,144 @@ public sealed class CompanionRuntime : IAsyncDisposable
}
}
public async Task StartStreamTestAsync(CancellationToken cancellationToken = default)
{
if (State.StreamTestRunning) return;
if (_streamTestRecovery.Exists) throw new InvalidOperationException("Restore the previous OBS stream service before starting another test.");
if (!State.Connected || _socket is null) throw new InvalidOperationException("Reconnect Lumi Companion before starting a private stream test.");
if (!State.ObsConnected || _obsBridge is null) throw new InvalidOperationException("Open OBS and wait for the managed integration to connect.");
if (State.ObsStreaming || State.ObsRecording) throw new InvalidOperationException("Stop streaming and recording in OBS before starting a private test.");
SetState(State with { StreamTestDetail = "Requesting an expiring private receiver from Lumi…", Health = TrayHealth.Operating });
_streamTestSessionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
JsonElement created;
try
{
await _socket.SendAsync("stream_test_create", new
{
source = new { width = _obsOutputWidth, height = _obsOutputHeight, fps = _obsOutputFps }
}, _socket.SessionId, cancellationToken);
created = await _streamTestSessionSignal.Task.WaitAsync(TimeSpan.FromSeconds(12), cancellationToken);
}
catch
{
try { await _socket.SendAsync("stream_test_stop", new { reason = "obs_stopped" }, _socket.SessionId, CancellationToken.None); } catch { }
throw;
}
finally
{
_streamTestSessionSignal = null;
}
var sessionId = ReadString(created, "id") ?? throw new InvalidDataException("Lumi did not return a private test session identity.");
if (!created.TryGetProperty("ingest", out var ingest))
throw new InvalidDataException("Lumi did not return the private OBS destination.");
var server = ReadString(ingest, "server") ?? throw new InvalidDataException("The private OBS server is missing.");
var key = ReadString(ingest, "key") ?? throw new InvalidDataException("The private OBS stream credential is missing.");
try
{
var snapshotReply = await _obsBridge.RequestAsync("stream_test_snapshot", new { }, TimeSpan.FromSeconds(8), cancellationToken);
if (ReadString(snapshotReply, "state") != "snapshotted")
throw new InvalidOperationException(ReadString(snapshotReply, "error") ?? "OBS could not snapshot the current streaming service.");
var snapshot = snapshotReply.GetProperty("snapshot");
var serviceType = ReadString(snapshot, "service_type") ?? throw new InvalidDataException("OBS returned an incomplete recovery snapshot.");
var settingsJson = ReadString(snapshot, "settings_json") ?? throw new InvalidDataException("OBS returned an incomplete recovery snapshot.");
_streamTestRecovery.Save(new StreamTestRecoveryState(sessionId, serviceType, settingsJson, DateTimeOffset.UtcNow));
SetState(State with { StreamTestRecoveryRequired = true, StreamTestDetail = "The original OBS service is protected. Redirecting OBS to the private receiver…" });
var beginReply = await _obsBridge.RequestAsync("stream_test_begin", new { server, key, session_id = sessionId }, TimeSpan.FromSeconds(12), cancellationToken);
if (ReadString(beginReply, "state") != "started")
throw new InvalidOperationException(ReadString(beginReply, "error") ?? "OBS could not start the private output.");
_streamMetricBytes = 0;
_streamMetricAt = DateTimeOffset.UtcNow;
_streamTestBeganAt = _streamMetricAt;
SetState(State with
{
StreamTestRunning = true,
StreamTestRecoveryRequired = true,
StreamTestSessionId = sessionId,
StreamTestDetail = "PRIVATE TEST ACTIVE — OBS is sending only to Lumi. End the test here or in the Lumi Admin page.",
Health = TrayHealth.Operating
});
await WriteLogAsync("stream_test_started", "Private stream test started; an encrypted OBS recovery snapshot is active.");
}
catch
{
try { await _socket.SendAsync("stream_test_stop", new { session_id = sessionId, reason = "obs_stopped" }, _socket.SessionId, CancellationToken.None); } catch { }
await RestoreObsAfterStreamTestAsync("Private test startup did not complete; restoring OBS.");
throw;
}
}
public async Task StopStreamTestAsync(string reason = "requested", CancellationToken cancellationToken = default)
{
var sessionId = State.StreamTestSessionId ?? _streamTestRecovery.Load()?.SessionId;
SetState(State with { StreamTestDetail = "Ending the private receiver and restoring the exact OBS service…" });
if (_socket is not null && State.Connected && sessionId is not null)
try { await _socket.SendAsync("stream_test_stop", new { session_id = sessionId, reason }, _socket.SessionId, cancellationToken); } catch { }
await RestoreObsAfterStreamTestAsync("Private test ended. OBS was restored to its previous streaming service.", cancellationToken);
}
private async Task RestoreObsAfterStreamTestAsync(string detail, CancellationToken cancellationToken = default)
{
if (Interlocked.CompareExchange(ref _streamTestRestoreRunning, 1, 0) != 0) return;
try
{
StreamTestRecoveryState? recovery;
try { recovery = _streamTestRecovery.Load(); }
catch (Exception error)
{
SetState(State with { StreamTestRunning = false, StreamTestRecoveryRequired = true, Health = TrayHealth.Failed, StreamTestDetail = $"The encrypted OBS recovery snapshot could not be opened. {Friendly(error)}" });
return;
}
if (recovery is null)
{
SetState(State with { StreamTestRunning = false, StreamTestRecoveryRequired = false, StreamTestSessionId = null, StreamTestDetail = detail });
return;
}
if (_obsBridge is null || !State.ObsConnected)
{
SetState(State with { StreamTestRunning = false, StreamTestRecoveryRequired = true, StreamTestSessionId = recovery.SessionId, Health = TrayHealth.Degraded, StreamTestDetail = "OBS restoration is pending. Open OBS and keep Companion running; restoration will resume automatically." });
return;
}
var reply = await _obsBridge.RequestAsync("stream_test_restore", new
{
session_id = recovery.SessionId,
service_type = recovery.ServiceType,
settings_json = recovery.ServiceSettingsJson
}, TimeSpan.FromSeconds(12), cancellationToken);
if (ReadString(reply, "state") != "restored")
throw new InvalidOperationException(ReadString(reply, "error") ?? "OBS did not confirm restoration.");
_streamTestRecovery.Remove();
SetState(State with
{
StreamTestRunning = false,
StreamTestRecoveryRequired = false,
StreamTestSessionId = null,
StreamTestBitrateKbps = 0,
StreamTestDroppedFrames = 0,
StreamTestTotalFrames = 0,
StreamTestCongestion = 0,
StreamTestDetail = detail,
Health = State.Connected ? TrayHealth.Ready : TrayHealth.Degraded
});
await WriteLogAsync("stream_test_restored", "OBS streaming service restored from the encrypted recovery snapshot.");
}
catch (Exception error)
{
SetState(State with { StreamTestRunning = false, StreamTestRecoveryRequired = true, Health = TrayHealth.Failed, StreamTestDetail = $"OBS still needs restoration. Keep OBS open and choose Repair restoration. {Friendly(error)}" });
await WriteLogAsync("stream_test_restore_failed", error.Message);
}
finally
{
Interlocked.Exchange(ref _streamTestRestoreRunning, 0);
}
}
public Task RepairStreamTestRecoveryAsync(CancellationToken cancellationToken = default) =>
RestoreObsAfterStreamTestAsync("OBS restoration repaired successfully.", cancellationToken);
public async Task StartBenchmarkAsync(CancellationToken cancellationToken = default)
{
if (State.BenchmarkRunning || State.TestRunning) return;
@ -375,30 +527,43 @@ public sealed class CompanionRuntime : IAsyncDisposable
public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default)
{
var credential = _credentials.Load();
if (credential is null) { SetState(State with { UpdateDetail = "Pair this computer before checking for updates." }); return; }
try
await _updateChecks.RunAsync(async operationToken =>
{
SetState(State with { UpdateDetail = "Checking for Companion updates…" });
_availableUpdate = await _updates.CheckAsync(credential, Version, cancellationToken);
var updateDetail = _availableUpdate is null
? $"Lumi Companion {Version} is current."
: _availableUpdate.Development
? $"Local development update ready: {string.Join(", ", _availableUpdate.ChangedComponents?.Select(item => item.Id) ?? new[] { "Companion source" })}."
: $"Lumi Companion {_availableUpdate.Version} is ready to install when OBS is idle.";
SetState(State with
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(operationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
SetState(State with { UpdateCheckRunning = true, UpdateDetail = "Checking for Companion updates…" });
try
{
UpdateAvailable = _availableUpdate is not null,
AvailableVersion = _availableUpdate?.Version,
UpdateDetail = updateDetail
});
await WriteLogAsync(_availableUpdate is null ? "update_current" : "update_available", updateDetail);
}
catch (Exception error)
{
SetState(State with { UpdateDetail = $"Update check could not finish. {Friendly(error)}" });
await WriteLogAsync("update_check_failed", error.Message);
}
var credential = _credentials.Load();
if (credential is null)
{
SetState(State with { UpdateDetail = "Pair this computer before checking for updates." });
return;
}
_availableUpdate = await _updates.CheckAsync(credential, Version, timeout.Token);
var updateDetail = _availableUpdate is null
? $"Lumi Companion {Version} is current."
: _availableUpdate.Development
? $"Local development update ready: {string.Join(", ", _availableUpdate.ChangedComponents?.Select(item => item.Id) ?? new[] { "Companion source" })}."
: $"Lumi Companion {_availableUpdate.Version} is ready to install when OBS is idle.";
SetState(State with
{
UpdateAvailable = _availableUpdate is not null,
AvailableVersion = _availableUpdate?.Version,
UpdateDetail = updateDetail
});
await WriteLogAsync(_availableUpdate is null ? "update_current" : "update_available", updateDetail);
}
catch (Exception error)
{
SetState(State with { UpdateDetail = $"Update check could not finish. {Friendly(error)}" });
await WriteLogAsync("update_check_failed", error.Message);
}
finally
{
SetState(State with { UpdateCheckRunning = false });
}
}, cancellationToken);
}
public async Task ApplyUpdateAsync(CancellationToken cancellationToken = default)
@ -499,6 +664,13 @@ public sealed class CompanionRuntime : IAsyncDisposable
private Task OnServerMessageAsync(ServerEnvelope message)
{
if (message.Type == "stream_test_session")
_streamTestSessionSignal?.TrySetResult(message.Payload.Clone());
if (message.Type == "stream_test_ended")
{
var reason = ReadString(message.Payload, "reason") ?? "The private receiver ended.";
_ = RestoreObsAfterStreamTestAsync($"{reason} OBS was restored to its previous streaming service.");
}
if (message.Type == "status" && message.Payload.TryGetProperty("kind", out var statusKind) && statusKind.GetString() == "readiness")
{
_serverReady = ReadBoolean(message.Payload, "ready");
@ -534,6 +706,21 @@ public sealed class CompanionRuntime : IAsyncDisposable
CaptionReceived?.Invoke(text, simulated);
if (!simulated) _ = _obsBridge?.SendAsync(new { type = "caption", payload = message.Payload });
if (State.BenchmarkRunning) UpdateBenchmarkCaption(message.Payload, text, final);
if (State.StreamTestRunning && final && _socket is not null)
{
var elapsed = Math.Max(0, (DateTimeOffset.UtcNow - _streamTestBeganAt).TotalSeconds);
var captionDelay = message.Payload.TryGetProperty("latency", out var latency)
? ReadDouble(latency, "total_ms")
: 0;
_ = _socket.SendAsync("stream_test_caption", new
{
session_id = State.StreamTestSessionId,
text,
start_seconds = Math.Max(0, elapsed - 3),
end_seconds = elapsed + 1,
delay_ms = captionDelay
}, _socket.SessionId, _lifetimeToken());
}
}
}
if (message.Type == "benchmark_complete")
@ -544,6 +731,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
if (message.Type == "error")
{
var serverMessage = message.Payload.TryGetProperty("message", out var value) ? value.GetString() : "Lumi reported an error.";
if (_streamTestSessionSignal is not null)
_streamTestSessionSignal.TrySetException(new InvalidOperationException(serverMessage ?? "Lumi could not create the private stream test."));
_testFailure?.TrySetResult(serverMessage ?? "Lumi reported an inference error.");
_benchmarkLifetime?.Cancel();
SetState(State with { Health = TrayHealth.Degraded, Detail = serverMessage ?? "Lumi reported an error.", BenchmarkRunning = false, BenchmarkDetail = serverMessage ?? "Lumi reported an inference error." });
@ -563,7 +752,11 @@ public sealed class CompanionRuntime : IAsyncDisposable
SetState(State with { ObsBridgeInstalled = connected || State.ObsBridgeInstalled, ObsConnected = connected, Health = health, Detail = connected ? "OBS and Lumi are connected. Choose a microphone and run a safe test." : State.Detail });
RefreshPathReadiness();
_ = WriteLogAsync("obs_connection", connected ? "OBS bridge connected." : "OBS bridge disconnected.");
if (connected) _ = SyncBridgeSelectionAsync();
if (connected)
{
_ = SyncBridgeSelectionAsync();
if (_streamTestRecovery.Exists) _ = RestoreObsAfterStreamTestAsync("OBS restarted during a private test and was restored automatically.");
}
_ = SendRuntimeStateAsync(_lifetimeToken());
};
_obsBridge.Start();
@ -576,7 +769,15 @@ public sealed class CompanionRuntime : IAsyncDisposable
{
var streaming = ReadBoolean(message, "streaming");
var recording = ReadBoolean(message, "recording");
SetState(State with { ObsConnected = true, ObsStreaming = streaming, ObsRecording = recording, Health = streaming ? TrayHealth.Operating : TrayHealth.Ready, Detail = streaming ? "OBS is live and companion services are active." : "OBS is connected and ready." });
var outputWidth = ReadInt(message, "output_width");
var outputHeight = ReadInt(message, "output_height");
if (outputWidth > 0) _obsOutputWidth = outputWidth;
if (outputHeight > 0) _obsOutputHeight = outputHeight;
var fpsDen = ReadInt(message, "fps_den");
var fpsNum = ReadInt(message, "fps_num");
if (fpsDen > 0 && fpsNum > 0) _obsOutputFps = (double)fpsNum / fpsDen;
var privateTest = State.StreamTestRunning || _streamTestRecovery.Exists;
SetState(State with { ObsConnected = true, ObsStreaming = streaming, ObsRecording = recording, Health = streaming ? TrayHealth.Operating : TrayHealth.Ready, Detail = privateTest ? "PRIVATE STREAM TEST — the OBS output is redirected only to Lumi." : streaming ? "OBS is live and companion services are active." : "OBS is connected and ready." });
RefreshPathReadiness();
await SendRuntimeStateAsync(_lifetimeToken(), ReadString(message, "version"));
}
@ -614,6 +815,46 @@ public sealed class CompanionRuntime : IAsyncDisposable
{
_bridgeSelectionSignal?.TrySetResult((ReadString(message, "source_uuid") ?? string.Empty, ReadBoolean(message, "attached")));
}
else if (type == "stream_test_metrics")
{
var sessionId = ReadString(message, "session_id");
if (State.StreamTestRunning && sessionId == State.StreamTestSessionId)
{
var now = DateTimeOffset.UtcNow;
var totalBytes = ReadLong(message, "total_bytes");
var seconds = Math.Max(0.1, (now - _streamMetricAt).TotalSeconds);
var bitrate = totalBytes >= _streamMetricBytes ? (totalBytes - _streamMetricBytes) * 8 / seconds / 1000 : 0;
_streamMetricBytes = totalBytes;
_streamMetricAt = now;
SetState(State with
{
StreamTestBitrateKbps = bitrate,
StreamTestDroppedFrames = ReadLong(message, "dropped_frames"),
StreamTestTotalFrames = ReadLong(message, "total_frames"),
StreamTestCongestion = ReadDouble(message, "congestion")
});
if (_socket is not null)
try
{
await _socket.SendAsync("stream_test_obs_metrics", new
{
session_id = sessionId,
bitrate_kbps = bitrate,
dropped_frames = ReadLong(message, "dropped_frames"),
total_frames = ReadLong(message, "total_frames"),
congestion = ReadDouble(message, "congestion"),
active = ReadBoolean(message, "active"),
width = ReadInt(message, "width"),
height = ReadInt(message, "height"),
fps = ReadDouble(message, "fps"),
encoder = ReadString(message, "encoder")
}, _socket.SessionId, _lifetimeToken());
}
catch when (!State.Connected) { }
if (!ReadBoolean(message, "active") && DateTimeOffset.UtcNow - _streamTestBeganAt > TimeSpan.FromSeconds(5))
_ = StopStreamTestAsync("obs_stopped");
}
}
}
private Task OnObsAudioAsync(ReadOnlyMemory<byte> frame)
@ -847,6 +1088,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
private static double ReadDouble(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Number ? property.GetDouble() : 0;
private static double? ReadNullableDouble(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Number ? property.GetDouble() : null;
private static int ReadInt(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.TryGetInt32(out var result) ? result : 0;
private static long ReadLong(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.TryGetInt64(out var result) ? result : 0;
private static bool ReadBoolean(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.True;
private static string? ReadString(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null;
@ -941,6 +1183,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
public async ValueTask DisposeAsync()
{
if (_disposed) return;
if (State.StreamTestRunning || _streamTestRecovery.Exists) try { await StopStreamTestAsync("companion_exit", CancellationToken.None); } catch { }
if (State.BenchmarkRunning) try { await StopBenchmarkAsync("disconnect", CancellationToken.None); } catch { }
_disposed = true;
_maintenanceLifetime.Cancel();

View File

@ -1,7 +1,7 @@
namespace Lumi.Companion.App;
public enum TrayHealth { Ready, Operating, Degraded, Failed }
public enum CompanionPage { Overview, Transcription, Test, SongOverlay, Connection, Logs, Settings }
public enum CompanionPage { Overview, StreamTesting, Transcription, Test, SongOverlay, Connection, Logs, Settings }
public enum TestStageState { Waiting, Running, Passed, Blocked, Failed }
public sealed record CompanionState(
@ -13,15 +13,24 @@ public sealed record CompanionState(
bool ObsRecording = false,
bool TestRunning = false,
bool BenchmarkRunning = false,
bool StreamTestRunning = false,
bool StreamTestRecoveryRequired = false,
TrayHealth Health = TrayHealth.Degraded,
string Detail = "Pair Lumi Companion to get started.",
string? DeviceName = null,
string? Host = null,
DateTimeOffset? LastConnectedAt = null,
bool UpdateAvailable = false,
bool UpdateCheckRunning = false,
string? AvailableVersion = null,
string UpdateDetail = "Checking for updates…",
string BenchmarkDetail = "Start a dedicated test, speak naturally, then end it when you have enough material.",
string StreamTestDetail = "Start a private test only while OBS is not streaming or recording.",
string? StreamTestSessionId = null,
double StreamTestBitrateKbps = 0,
long StreamTestDroppedFrames = 0,
long StreamTestTotalFrames = 0,
double StreamTestCongestion = 0,
bool PathTestValid = false,
string PathTestDetail = "Run once after setup or a relevant configuration change.",
bool ObsBridgeRepairNeeded = false,
@ -39,8 +48,10 @@ public sealed record CompanionState(
_ => "Partially ready"
};
public bool RequiresQuitConfirmation => ObsStreaming || ObsRecording || BenchmarkRunning;
public string QuitWarning => BenchmarkRunning
public bool RequiresQuitConfirmation => ObsStreaming || ObsRecording || BenchmarkRunning || StreamTestRunning || StreamTestRecoveryRequired;
public string QuitWarning => StreamTestRunning || StreamTestRecoveryRequired
? "A private stream test or OBS recovery is active. Quitting will stop the test and restore the exact streaming service saved before it began."
: BenchmarkRunning
? "A transcription accuracy and latency test is running. Quitting will safely end and mark the test as aborted."
: ObsStreaming
? "OBS is streaming. Quitting Lumi Companion will stop transcription and closed captions, but it will not stop the OBS stream."

View File

@ -6,8 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
<Version>0.1.0</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
<Version>0.2.0</Version>
<AssemblyVersion>0.2.0.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />

View File

@ -29,7 +29,10 @@
</Border>
<Grid Grid.Row="2" RowDefinitions="Auto,*,Auto">
<Button x:Name="OverviewNav" Classes="nav selected" Content="Overview" Tag="Overview" />
<StackPanel Spacing="4">
<Button x:Name="OverviewNav" Classes="nav selected" Content="Overview" Tag="Overview" />
<Button x:Name="StreamTestingNav" Classes="nav" Content="Stream testing" Tag="StreamTesting" />
</StackPanel>
<ScrollViewer Grid.Row="1" Margin="0,14,0,14" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="6">
@ -112,6 +115,46 @@
</StackPanel>
</ScrollViewer>
<ScrollViewer x:Name="StreamTestingPage" IsVisible="False">
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
<StackPanel Spacing="6">
<TextBlock Text="PRIVATE OUTPUT" Classes="eyebrow" />
<TextBlock Text="Stream testing" Classes="pageTitle" />
<TextBlock Text="Send the real OBS stream to Lumi's private test receiver, inspect it in the Admin page, then restore the exact normal streaming service automatically." Classes="muted" FontSize="15" />
</StackPanel>
<Border Background="#FFF2D6" BorderBrush="#D58A1F" BorderThickness="1" CornerRadius="12" Padding="18">
<StackPanel Spacing="5">
<TextBlock Text="Never start this while publicly live" FontWeight="Bold" Foreground="#764600" />
<TextBlock Text="Companion refuses to redirect active streaming or recording. While the test is active, the status below remains unmistakable and the prior OBS service is kept in an encrypted current-user recovery file." TextWrapping="Wrap" Foreground="#764600" />
</StackPanel>
</Border>
<Border Classes="card">
<StackPanel Spacing="16">
<StackPanel Spacing="5">
<TextBlock x:Name="StreamTestStatusTitle" Text="Ready to check" Classes="sectionTitle" />
<TextBlock x:Name="StreamTestStatusDetail" Text="Start only while OBS is idle." Classes="muted" TextWrapping="Wrap" />
</StackPanel>
<Grid ColumnDefinitions="*,*,*" ColumnSpacing="12">
<Border Classes="soft"><StackPanel Spacing="4"><TextBlock Text="OBS bitrate" Classes="muted" FontSize="11" /><TextBlock x:Name="StreamTestBitrateText" Text="—" FontWeight="SemiBold" /></StackPanel></Border>
<Border Grid.Column="1" Classes="soft"><StackPanel Spacing="4"><TextBlock Text="Dropped frames" Classes="muted" FontSize="11" /><TextBlock x:Name="StreamTestDroppedText" Text="—" FontWeight="SemiBold" /></StackPanel></Border>
<Border Grid.Column="2" Classes="soft"><StackPanel Spacing="4"><TextBlock Text="Congestion" Classes="muted" FontSize="11" /><TextBlock x:Name="StreamTestCongestionText" Text="—" FontWeight="SemiBold" /></StackPanel></Border>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button x:Name="StartStreamTestButton" Classes="primary" Content="Start private stream test" />
<Button x:Name="StopStreamTestButton" Classes="secondary" Content="End test and restore OBS" IsEnabled="False" />
<Button x:Name="RepairStreamTestButton" Classes="secondary" Content="Repair restoration" IsVisible="False" />
</StackPanel>
</StackPanel>
</Border>
<Border Classes="soft">
<StackPanel Spacing="5">
<TextBlock Text="Where to watch" FontWeight="SemiBold" />
<TextBlock Text="Open Admin → Stream testing in Lumi WebUI for the private player, quality control, receiver facts, warnings, and recovery timeline." Classes="muted" TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<ScrollViewer x:Name="TranscriptionPage" IsVisible="False">
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="25">
<StackPanel Spacing="6">

View File

@ -79,6 +79,7 @@ public partial class MainWindow : Window
PluginNavigation.Children.Add(expander);
}
_navigationButtons[CompanionPage.Overview] = OverviewNav;
_navigationButtons[CompanionPage.StreamTesting] = StreamTestingNav;
_navigationButtons[CompanionPage.Connection] = ConnectionNav;
_navigationButtons[CompanionPage.Logs] = LogsNav;
_navigationButtons[CompanionPage.Settings] = SettingsNav;
@ -87,6 +88,7 @@ public partial class MainWindow : Window
private void WireActions()
{
OverviewNav.Click += OnNavigate;
StreamTestingNav.Click += OnNavigate;
foreach (var button in TechnicalNavigation.Children.OfType<Button>()) button.Click += OnNavigate;
NextActionButton.Click += OnNextAction;
PairButton.Click += async (_, _) => await ChoosePairingPackageAsync();
@ -94,6 +96,9 @@ public partial class MainWindow : Window
OpenWebButton.Click += (_, _) => _runtime.OpenLumiWebUi();
OpenLogsButton.Click += (_, _) => _runtime.OpenLogsDirectory();
RunTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RunTestAsync(), RunTestButton);
StartStreamTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StartStreamTestAsync(), StartStreamTestButton);
StopStreamTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StopStreamTestAsync(), StopStreamTestButton);
RepairStreamTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RepairStreamTestRecoveryAsync(), RepairStreamTestButton);
StartBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StartBenchmarkAsync(), StartBenchmarkButton);
StopBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StopBenchmarkAsync(), StopBenchmarkButton);
ForgetButton.Click += async (_, _) => await ForgetDeviceAsync();
@ -127,6 +132,7 @@ public partial class MainWindow : Window
var pages = new Dictionary<CompanionPage, Control>
{
[CompanionPage.Overview] = OverviewPage,
[CompanionPage.StreamTesting] = StreamTestingPage,
[CompanionPage.Transcription] = TranscriptionPage,
[CompanionPage.Test] = TestPage,
[CompanionPage.SongOverlay] = SongOverlayPage,
@ -300,9 +306,19 @@ public partial class MainWindow : Window
StartBenchmarkButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
StopBenchmarkButton.IsEnabled = state.BenchmarkRunning;
BenchmarkStatusText.Text = state.BenchmarkDetail;
StreamTestStatusTitle.Text = state.StreamTestRunning ? "PRIVATE TEST ACTIVE" : state.StreamTestRecoveryRequired ? "OBS restoration required" : "Ready to check";
StreamTestStatusDetail.Text = state.StreamTestDetail;
StreamTestBitrateText.Text = state.StreamTestRunning ? $"{state.StreamTestBitrateKbps:0} kbps" : "—";
StreamTestDroppedText.Text = state.StreamTestRunning ? $"{state.StreamTestDroppedFrames} / {state.StreamTestTotalFrames}" : "—";
StreamTestCongestionText.Text = state.StreamTestRunning ? $"{state.StreamTestCongestion:P0}" : "—";
StartStreamTestButton.IsEnabled = state.Connected && state.ObsConnected && !state.ObsStreaming && !state.ObsRecording && !state.StreamTestRunning && !state.StreamTestRecoveryRequired;
StopStreamTestButton.IsEnabled = state.StreamTestRunning;
RepairStreamTestButton.IsVisible = state.StreamTestRecoveryRequired && !state.StreamTestRunning;
UpdatePanel.IsVisible = state.UpdateAvailable;
UpdateTitle.Text = state.UpdateAvailable ? $"Lumi Companion {state.AvailableVersion} is available" : "Lumi Companion is current";
UpdateDetail.Text = state.UpdateDetail;
CheckUpdateButton.IsEnabled = !state.UpdateCheckRunning;
CheckUpdateButton.Content = state.UpdateCheckRunning ? "Checking…" : "Check now";
ApplyUpdateButton.IsEnabled = state.UpdateAvailable && !state.ObsStreaming && !state.ObsRecording;
CurrentVersionText.Text = CompanionRuntime.DisplayVersion;
UpdateStatusText.Text = state.UpdateDetail;

View File

@ -0,0 +1,46 @@
namespace Lumi.Companion.Core;
public sealed class SingleFlightOperation
{
private readonly object _gate = new();
private Task? _active;
private long _generation;
public bool IsRunning
{
get { lock (_gate) return _active is { IsCompleted: false }; }
}
public Task RunAsync(Func<CancellationToken, Task> operation, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(operation);
lock (_gate)
{
if (_active is { IsCompleted: false }) return _active;
var generation = ++_generation;
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var task = completion.Task;
_active = task;
_ = RunCoreAsync(generation, operation, cancellationToken, completion);
return task;
}
}
private async Task RunCoreAsync(long generation, Func<CancellationToken, Task> operation, CancellationToken cancellationToken, TaskCompletionSource completion)
{
try
{
await operation(cancellationToken);
completion.TrySetResult();
}
catch (OperationCanceledException error) { completion.TrySetCanceled(error.CancellationToken); }
catch (Exception error) { completion.TrySetException(error); }
finally
{
lock (_gate)
{
if (_generation == generation) _active = null;
}
}
}
}

View File

@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text.Json;
using Lumi.Companion.Protocol;
namespace Lumi.Companion.Core;
public sealed record StreamTestRecoveryState(
string SessionId,
string ServiceType,
string ServiceSettingsJson,
DateTimeOffset CreatedAt);
public sealed class StreamTestRecoveryStore(string root)
{
private readonly string _path = Path.Combine(root, "stream-test.recovery");
private static readonly byte[] Entropy = "Lumi.Companion.StreamTestRecovery.v1"u8.ToArray();
public void Save(StreamTestRecoveryState state)
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
var clear = JsonSerializer.SerializeToUtf8Bytes(state, ProtocolV1.JsonOptions);
var protectedBytes = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser);
var temporary = $"{_path}.{Environment.ProcessId}.tmp";
try
{
File.WriteAllBytes(temporary, protectedBytes);
File.Move(temporary, _path, true);
}
finally
{
File.Delete(temporary);
CryptographicOperations.ZeroMemory(clear);
}
}
public StreamTestRecoveryState? Load()
{
if (!File.Exists(_path)) return null;
var protectedBytes = File.ReadAllBytes(_path);
var clear = ProtectedData.Unprotect(protectedBytes, Entropy, DataProtectionScope.CurrentUser);
try { return JsonSerializer.Deserialize<StreamTestRecoveryState>(clear, ProtocolV1.JsonOptions); }
finally { CryptographicOperations.ZeroMemory(clear); }
}
public bool Exists => File.Exists(_path);
public void Remove() => File.Delete(_path);
}

View File

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RollForward>Major</RollForward>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Lumi.Companion.Core/Lumi.Companion.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,42 @@
using Lumi.Companion.Core;
static void Assert(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
var operation = new SingleFlightOperation();
var calls = 0;
await operation.RunAsync(_ => { calls += 1; return Task.CompletedTask; });
Assert(calls == 1 && !operation.IsRunning, "A successful update-style operation did not reset.");
await operation.RunAsync(_ => { calls += 1; return Task.CompletedTask; });
Assert(calls == 2 && !operation.IsRunning, "A no-update-style repeat did not execute.");
try
{
await operation.RunAsync(_ => throw new InvalidOperationException("expected"));
}
catch (InvalidOperationException error) when (error.Message == "expected") { }
await operation.RunAsync(_ => { calls += 1; return Task.CompletedTask; });
Assert(calls == 3 && !operation.IsRunning, "A failed operation left stale single-flight state.");
using (var cancelled = new CancellationTokenSource())
{
cancelled.Cancel();
try { await operation.RunAsync(token => Task.Delay(1, token), cancelled.Token); }
catch (OperationCanceledException) { }
}
await operation.RunAsync(_ => { calls += 1; return Task.CompletedTask; });
Assert(calls == 4 && !operation.IsRunning, "A cancelled operation left stale single-flight state.");
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var concurrentCalls = 0;
var first = operation.RunAsync(async _ => { concurrentCalls += 1; await release.Task; });
var second = operation.RunAsync(async _ => { concurrentCalls += 1; await Task.Yield(); });
Assert(operation.IsRunning && concurrentCalls == 1, "Rapid update checks started more than one operation.");
release.SetResult();
await Task.WhenAll(first, second);
Assert(concurrentCalls == 1 && !operation.IsRunning, "Rapid update checks did not share and reset the active operation.");
Console.WriteLine("Companion core single-flight regression checks passed.");

View File

@ -5,6 +5,19 @@ normal Lumi session, role checks, layout, forms, lists, modals, and confirmation
flow. Browser Source pages use a separate transparent document with no Lumi
navigation or authenticated website data.
## Media source behavior
Video sources render only their pixels. Browser controls, poster chrome, and
fallback backgrounds are never exposed in the public overlay. A video remains
hidden until the media element reports real playback and is hidden again after
end, stop, clear, or error. Playback controls stay in the Lumi inspector.
Audio sources are managed outputs rather than visual canvas objects. Their
play, pause, stop, seek, volume, mute, loop, test, and removal controls live in
the source list/inspector. Public overlays contain no visible audio UI, and old
audio position, size, layer, crop, alignment, opacity, and anchor fields are
ignored for compatibility.
## Editing an overlay
The editor uses the same basic model as OBS: scenes contain ordered sources, and

94
docs/stream-testing.md Normal file
View File

@ -0,0 +1,94 @@
# Private stream testing
Lumi Stream Testing previews the real OBS output without publishing it to the
normal stream destination. Start it from the core **Stream testing** page in
Lumi Companion, then watch it at **Admin > Stream testing**.
## Architecture
- The existing paired-device WebSocket authenticates session creation, OBS
metrics, caption cues, stop requests, and server-ended notifications.
- The existing same-user OBS bridge snapshots the complete current OBS service,
temporarily installs an expiring custom service, reports real output facts,
and restores the snapshot.
- Companion protects the minimum recovery record with Windows DPAPI and writes
it atomically before OBS is redirected. A normal stop, server failure, expiry,
Companion exit, WebSocket loss, or OBS restart all use the same restore path.
- Core supervises one FFmpeg receiver process and creates a short rolling HLS
window. HLS.js provides authenticated Chromium playback; native HLS remains
available where the browser supports it.
- Stable transcription captions are reused as a private WebVTT side channel.
No second transcription pipeline and no local speech inference are added.
Temporary HLS fragments exist only to serve the active admin player. They are
bounded, deleted as they roll out, and removed when the session ends. Lumi does
not retain a raw stream recording.
## Server setup
Install a current FFmpeg build and ensure the Lumi service account can execute
it. Set:
```text
LUMI_STREAM_TEST_INGEST_HOST=the-hostname-reachable-from-the-streaming-computer
```
Optional controls:
```text
LUMI_FFMPEG_PATH=/absolute/path/to/ffmpeg
LUMI_STREAM_TEST_INGEST_PORT=19350
LUMI_STREAM_TEST_PUBLIC_PORT=443
LUMI_STREAM_TEST_ENCODER=h264_nvenc
LUMI_STREAM_TEST_MAX_MS=1800000
LUMI_STREAM_TEST_INACTIVITY_MS=45000
LUMI_STREAM_TEST_MAX_BYTES=1073741824
```
Allow the configured ingest port only from trusted streaming networks. The
default RTMP hop uses a 256-bit, session-scoped credential and never exposes the
normal stream key, but RTMP itself is not encrypted. Put RTMPS at the network
boundary, set `LUMI_STREAM_TEST_RTMPS=true`, and set
`LUMI_STREAM_TEST_PUBLIC_PORT` to the proxy's external port. FFmpeg continues
listening on the private `LUMI_STREAM_TEST_INGEST_PORT`.
The player, playlists, segments, status, and stop endpoints always require an
active Lumi administrator session.
Lumi automatically selects `h264_nvenc` when the installed FFmpeg advertises
it, otherwise it uses bounded `libx264`. NVIDIA acceleration has no artificial
model lock: an RTX 3060 12 GB is a supported production baseline, faster cards
such as a 3080 Ti work without configuration changes, and CPU fallback remains
available. The ladder contains the source plus 720p and 480p only when the
source is larger, so it never upscales.
## Safety and recovery
Companion refuses to start while OBS is streaming or recording. During a test
its navigation status reads **PRIVATE TEST ACTIVE**. The DPAPI recovery file is
removed only after OBS confirms that the saved service type and complete
settings JSON were restored and saved.
If OBS was closed or unavailable during cleanup, open OBS and leave Companion
running. Automatic restoration resumes when the same-user bridge reconnects.
The **Repair restoration** action retries the same operation and does not create
a new session.
The receiver stops on maximum duration, inactivity, temporary-storage limit,
process failure, server shutdown, Companion disconnect, or an administrator
stop. One active session is allowed today; UUID session identities and scoped
paths are already used throughout so the model can be expanded later.
## Deterministic verification
**Run test pattern** on the Admin page creates a 1280×720 moving color/timing
pattern with a stable 880 Hz audio tone. It exercises receiver startup,
transcoding, adaptive HLS, audio, quality switching, player cleanup, and
diagnostics without OBS. It uses the same limits and temporary-media cleanup as
a Companion session.
The focused checks are:
```text
npm run verify:stream-testing
dotnet run --project companion/tests/Lumi.Companion.Core.Tests/Lumi.Companion.Core.Tests.csproj
```

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime.
## Runtime
Package: lumi-bot
Version: 0.2.27
Version: 0.3.0
## Routes
- POST /api/diagnostics/v1/run
- GET /api/events

View File

@ -14,7 +14,7 @@ editable: false
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
## Metadata
Plugin ID: lumi_transcription
Version: 0.1.0
Version: 0.2.0
Default state: enabled
## Web Routes
- /plugins/lumi_transcription

11
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lumi-bot",
"version": "0.2.27",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.2.27",
"version": "0.3.0",
"dependencies": {
"adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0",
@ -15,6 +15,7 @@
"ejs": "^3.1.10",
"express": "^4.19.2",
"express-session": "^1.18.1",
"hls.js": "^1.6.16",
"multer": "^2.2.0",
"tmi.js": "^1.8.5",
"ws": "^8.21.1"
@ -965,6 +966,12 @@
"node": ">= 0.4"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.2.27",
"version": "0.3.0",
"private": true,
"type": "commonjs",
"scripts": {
@ -16,6 +16,7 @@
"verify:web-auth": "node scripts/verify-web-auth.js",
"verify:destructive-actions": "node scripts/verify-destructive-actions.js",
"verify:overlays": "node scripts/verify-overlays.js",
"verify:stream-testing": "node scripts/verify-stream-testing.js",
"verify:ux": "node scripts/verify-ux-foundation.js",
"test:ui": "playwright test",
"test:ui:update": "playwright test --update-snapshots",
@ -36,6 +37,7 @@
"ejs": "^3.1.10",
"express": "^4.19.2",
"express-session": "^1.18.1",
"hls.js": "^1.6.16",
"multer": "^2.2.0",
"tmi.js": "^1.8.5",
"ws": "^8.21.1"

View File

@ -1,5 +1,11 @@
# Lumi Transcription changelog
## 0.2.0
- Added authenticated private stream-test session control over the existing Companion transport, including reused live caption cues and OBS output diagnostics.
- Added crash-safe DPAPI recovery for exact OBS streaming-service restoration, with automatic startup/exit/server-loss repair and no local speech inference.
- Added repeatable single-flight Companion update checks with success, current, failure, cancellation, and rapid-click regression coverage.
## 0.1.0
- Added server-hosted whisper.cpp transcription with bounded Companion audio transport and no local speech inference.

View File

@ -95,7 +95,7 @@ class CompanionGateway {
this.log.append({ kind: "connection", state: "authenticated", device_id: device.id, session_id: session.id });
return;
}
await this.structured(session, message, send, () => { lastPong = Date.now(); });
await this.structured(session, device, message, send, () => { lastPong = Date.now(); });
} catch (error) {
send("error", { code: error.code || "INVALID_MESSAGE", message: error.message, recoverable: !["INCOMPATIBLE_VERSION", "HELLO_REQUIRED"].includes(error.code) });
this.log.append({ kind: "protocol_error", device_id: device.id, session_id: session?.id, code: error.code || "INVALID_MESSAGE", message: error.message });
@ -106,11 +106,17 @@ class CompanionGateway {
socket.on("close", () => {
clearTimeout(helloTimer); clearInterval(heartbeat);
if (session) this.sessions.disconnect(session.id);
if (session) {
void global.lumiFrameworks?.streamTesting?.stop?.({
device_id: device.id,
reason: "Companion disconnected; the private receiver was stopped."
}).catch(() => {});
}
this.log.append({ kind: "connection", state: "closed", device_id: device.id, session_id: session?.id });
});
socket.on("error", (error) => this.log.append({ kind: "connection", state: "error", device_id: device.id, session_id: session?.id, message: error.message }));
}
async structured(session, message, send, pong) {
async structured(session, device, message, send, pong) {
switch (message.type) {
case "ping":
pong();
@ -132,6 +138,31 @@ class CompanionGateway {
case "readiness": send("status", { kind: "readiness", ...(await this.sessions.readiness()), server_plugin_version: require("../../plugin.json").version }); break;
case "start": send("status", { kind: "session", ...(await this.sessions.start(session.id, message.payload || {})) }); break;
case "stop": send("status", { kind: "session", ...(await this.sessions.stop(session.id, cleanReason(message.payload?.reason))) }); break;
case "stream_test_create": {
const service = global.lumiFrameworks?.streamTesting;
if (!service) throw coded("STREAM_TEST_UNAVAILABLE", "Stream testing is not available on this Lumi host.");
const created = service.create(device, message.payload || {}, send);
send("stream_test_session", created);
break;
}
case "stream_test_obs_metrics": {
const service = global.lumiFrameworks?.streamTesting;
if (!service) throw coded("STREAM_TEST_UNAVAILABLE", "Stream testing is not available on this Lumi host.");
service.updateObs(device.id, message.payload || {});
break;
}
case "stream_test_caption": {
const service = global.lumiFrameworks?.streamTesting;
if (!service) throw coded("STREAM_TEST_UNAVAILABLE", "Stream testing is not available on this Lumi host.");
service.addCaption(device.id, message.payload || {});
break;
}
case "stream_test_stop": {
const service = global.lumiFrameworks?.streamTesting;
if (!service) throw coded("STREAM_TEST_UNAVAILABLE", "Stream testing is not available on this Lumi host.");
await service.stop({ device_id: device.id, session_id: message.payload?.session_id, reason: cleanStreamTestReason(message.payload?.reason) });
break;
}
case "ack": break;
default: throw coded("UNEXPECTED_MESSAGE", `Message ${message.type} is not valid after the handshake.`);
}
@ -147,5 +178,6 @@ function closeWith(socket, code, reason) { if (socket.readyState === WebSocket.O
function sameHostOrigin(origin, host) { try { return new URL(origin).host === host; } catch { return false; } }
function coded(code, message) { return Object.assign(new Error(message), { code }); }
function cleanReason(value) { return ["requested", "test_complete", "benchmark_complete", "silence_timeout", "disconnect"].includes(String(value)) ? String(value) : "requested"; }
function cleanStreamTestReason(value) { return ["requested", "companion_exit", "obs_stopped", "startup_recovery", "server_ended"].includes(String(value)) ? String(value) : "requested"; }
module.exports = { CompanionGateway, sameHostOrigin, MAX_CONTROL_MESSAGES_PER_SECOND, MAX_AUDIO_MESSAGES_PER_SECOND, MAX_SOURCE_MESSAGES_PER_SECOND };

View File

@ -5,7 +5,8 @@ const AUDIO_MAGIC = Buffer.from("LACP");
const AUDIO_HEADER_BYTES = 64;
const MAX_JSON_BYTES = 64 * 1024;
const MAX_AUDIO_PAYLOAD_BYTES = 6400;
const CLIENT_TYPES = new Set(["hello", "ping", "source_update", "obs_state", "readiness", "start", "stop", "ack"]);
const CLIENT_TYPES = new Set(["hello", "ping", "source_update", "obs_state", "readiness", "start", "stop", "ack",
"stream_test_create", "stream_test_obs_metrics", "stream_test_caption", "stream_test_stop"]);
function parseEnvelope(input) {
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(String(input || ""), "utf8");

View File

@ -1,17 +1,17 @@
{
"schema_version": 1,
"version": "0.1.0",
"version": "0.2.0",
"signed": false,
"release_notes": "First stable Companion release with shared plugin authentication, server-hosted transcription, Song Overlay, matching Windows branding, and a steady non-flickering tray status.",
"release_notes": "Adds private OBS stream testing with encrypted exact-service recovery, live output diagnostics, and repeatable update checks.",
"installer": {
"id": "windows-x64-installer",
"platform": "win32",
"architecture": "x64",
"label": "Windows x64 per-user installer",
"filename": "Lumi.Companion-Setup.exe",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0/Lumi.Companion-Setup.exe",
"sha256": "71ee091971a8e246d9b991a16fc28bd73495351c82b44839ae34ca36fb5784ff",
"bytes": 51849287
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.0/Lumi.Companion-Setup.exe",
"sha256": "cdeb43dc1512f537ff4cf8840fb36ee7888c1a92d901a50984079beabba221da",
"bytes": 51882646
},
"artifacts": [
{
@ -20,9 +20,9 @@
"architecture": "x64",
"label": "Windows x64 self-contained",
"filename": "Lumi.Companion-win-x64.zip",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0/Lumi.Companion-win-x64.zip",
"sha256": "1194475b2a6acdeb6ef9429726cb5de31a5c93f1dc53a3faa29bb54eaef0368a",
"bytes": 66731197,
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.0/Lumi.Companion-win-x64.zip",
"sha256": "bf42a9abbf1df9eb604f3d7f0216939608af071fecb8b17e3def55037ce2e77f",
"bytes": 66775103,
"entrypoint": "Lumi.Companion.App.exe"
}
]

View File

@ -1,7 +1,7 @@
{
"id": "lumi_transcription",
"name": "Lumi Transcription",
"version": "0.1.0",
"version": "0.2.0",
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
"main": "index.js",
"channel": "stable",

View File

@ -2,6 +2,38 @@
"schema_version": 1,
"channel": "stable",
"releases": [
{
"version": "0.3.0",
"ref": "refs/tags/v0.3.0",
"released_at": "2026-07-24",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Adds authenticated private OBS stream testing with crash-safe service restoration, adaptive temporary playback, corrected overlay media behavior, and repeatable Companion updates. Existing local and plugin data remains preserved.",
"plugins": {
"auto-vc": "0.1.6",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.5",
"lumi_transcription": "0.2.0",
"moderation": "0.1.5",
"now_playing": "0.1.2",
"okf": "0.1.2",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{
"version": "0.2.27",
"ref": "refs/tags/v0.2.27",

View File

@ -28,6 +28,7 @@ const checks = [
"scripts/verify-command-policies.js",
"scripts/verify-destructive-actions.js",
"scripts/verify-overlays.js",
"scripts/verify-stream-testing.js",
"scripts/verify-content-library.js",
"scripts/verify-overlay-web-documents.js",
"scripts/verify-webhooks.js"

View File

@ -173,8 +173,9 @@ try {
volume: 5
});
assert.strictEqual(audioConfig.playBehavior, "manual");
assert.strictEqual(audioConfig.showControls, true);
assert.strictEqual(audioConfig.volume, 1);
assert.strictEqual("showControls" in audioConfig, false);
for (const obsolete of ["x", "y", "width", "height", "opacity", "anchor"]) assert.strictEqual(obsolete in audioConfig, false);
const chatConfig = overlayModules.normalizeModuleConfig("chat", {
platforms: ["twitch", "discord", "unsupported"],
channels: "twitch:cozycarnage\ndiscord:community-chat",
@ -393,6 +394,12 @@ try {
assert(connectorScript.includes('this.client.call("SaveReplayBuffer")'), "local OBS connector must support replay saves");
assert(connectorScript.includes('action: "save_replay_buffer"'), "Browser Bridge connector must request replay saves");
assert(rendererScript.includes("buildMedia") && rendererScript.includes("buildWebsite"));
assert(rendererScript.includes('media.controls = false'), "public media must never expose browser chrome");
assert(rendererScript.includes('media.addEventListener("playing", show)') && rendererScript.includes('"lumi-media-stop"'), "video visibility must follow actual playback lifecycle");
assert(rendererScript.includes('if (renderType === "audio") return null'), "audio must not create a canvas module");
assert(runtimeStyles.includes("display: none !important"), "audio output must remain visually absent");
assert(!editorView.includes('name="show_controls"'), "media controls must remain in the Lumi inspector, not on public media");
assert(editorView.includes('data-media-control="play"') && editorView.includes('data-media-control="stop"') && editorView.includes("data-media-seek"));
assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts"));
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));
assert(rendererScript.includes("/icons/badges/"));

View File

@ -4,12 +4,12 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.2.27";
const previousStableVersion = "0.2.26";
const priorStableVersion = "0.2.25";
const releaseVersion = "0.3.0";
const previousStableVersion = "0.2.27";
const priorStableVersion = "0.2.26";
const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = {
lumi_transcription: { version: "0.1.0", knowledge: "lumi-transcription" },
lumi_transcription: { version: "0.2.0", knowledge: "lumi-transcription" },
now_playing: { version: "0.1.2", knowledge: "now-playing" }
};
@ -82,4 +82,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: stable core 0.2.27 after 0.2.26 with synchronized Companion plugin metadata.");
console.log("Release metadata verification passed: stable core 0.3.0 after 0.2.27 with synchronized Companion plugin metadata.");

View File

@ -0,0 +1,80 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.join(__dirname, "..");
const { ladderFor, ffmpegArgs, streamTestingService } = require("../src/services/stream-testing");
const protocol = require("../plugins/lumi_transcription/backend/companion/protocol");
async function main() {
const fullHd = ladderFor({ width: 1920, height: 1080, fps: 60 });
assert.deepStrictEqual(fullHd.variants.map((item) => item.name), ["source", "720p", "480p"]);
assert(fullHd.variants.every((item) => item.width <= 1920 && item.height <= 1080), "adaptive ladder must never upscale");
const hd = ladderFor({ width: 1280, height: 720, fps: 30 });
assert.deepStrictEqual(hd.variants.map((item) => item.name), ["source", "480p"]);
const sd = ladderFor({ width: 854, height: 480, fps: 30 });
assert.deepStrictEqual(sd.variants.map((item) => item.name), ["source"]);
const fakeSession = {
source: fullHd,
receiver: { encoder: "h264_nvenc" },
listenerUrl: "rtmp://0.0.0.0:19350/lumi-test/private-secret",
directory: path.join(root, ".tmp-stream-test"),
};
const args = ffmpegArgs(fakeSession);
assert(args.includes("h264_nvenc") && args.includes("master.m3u8"));
assert(args.includes("v:0,a:0,name:source v:1,a:1,name:720p v:2,a:2,name:480p"));
assert(args.includes("delete_segments+independent_segments+program_date_time+temp_file"));
assert(args.includes("512") && args.includes("1024"), "receiver queues must be bounded");
for (const type of ["stream_test_create", "stream_test_obs_metrics", "stream_test_caption", "stream_test_stop"]) {
const parsed = protocol.parseEnvelope(Buffer.from(JSON.stringify(protocol.envelope(type, {}, null))));
assert.strictEqual(parsed.type, type);
}
const server = fs.readFileSync(path.join(root, "src/web/server.js"), "utf8");
const service = fs.readFileSync(path.join(root, "src/services/stream-testing.js"), "utf8");
const gateway = fs.readFileSync(path.join(root, "plugins/lumi_transcription/backend/companion/gateway.js"), "utf8");
const nativeBridge = fs.readFileSync(path.join(root, "companion/native/obs-bridge/src/plugin.cpp"), "utf8");
const companion = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/CompanionRuntime.cs"), "utf8");
const companionUi = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml.cs"), "utf8");
const webUi = fs.readFileSync(path.join(root, "src/web/views/admin-stream-testing.ejs"), "utf8");
assert.match(server, /admin\/stream-testing", requireRole\("admin"\)/);
assert.match(server, /admin\/stream-testing\/media\/:id\/:name", requireRole\("admin"\)/);
assert.match(service, /MAX_SESSION_MS/);
assert.match(service, /INACTIVITY_MS/);
assert.match(service, /MAX_OUTPUT_BYTES/);
assert.match(service, /redactDiagnostic/);
assert.match(service, /caption_delay/);
assert.match(service, /receiver_slow/);
assert.match(service, /PUBLIC_INGEST_PORT/);
assert.strictEqual(/^(?:master|stream_(?:source|720p|480p))(?:\.m3u8|_\d{6}\.ts)$/.test("master.m3u8"), true);
assert.match(service, /spawn\(FFMPEG, args, \{[^}]*stdio:/s);
assert.match(service, /testsrc2=size=1280x720:rate=30/);
assert.match(service, /sine=frequency=880:sample_rate=48000/);
assert(!service.includes("shell: true"), "receiver commands must not use a shell");
assert.match(gateway, /stream_test_create/);
assert.match(gateway, /Companion disconnected; the private receiver was stopped/);
assert.match(nativeBridge, /stream_test_snapshot/);
assert.match(nativeBridge, /obs_frontend_save_streaming_service/);
assert.match(nativeBridge, /stream_test_restore/);
assert.match(companion, /StreamTestRecoveryStore/);
assert.match(companion, /StartStreamTestAsync/);
assert.match(companion, /RestoreObsAfterStreamTestAsync/);
assert.match(companion, /finally\s*\{\s*Interlocked\.Exchange\(ref _streamTestRestoreRunning, 0\)/s);
assert.match(companionUi, /CheckUpdateButton\.IsEnabled = !state\.UpdateCheckRunning/);
assert.match(companionUi, /CheckUpdateButton\.Content = state\.UpdateCheckRunning \? "Checking…" : "Check now"/);
assert.match(webUi, /data-stream-player/);
assert.match(webUi, /data-stream-quality/);
assert.match(webUi, /data-stream-captions/);
assert.match(webUi, /data-stream-timeline/);
await streamTestingService.close();
console.log("Stream testing and Companion lifecycle verification passed.");
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -24,7 +24,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.deepEqual(releaseVersions, ["0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
const coreManifest = readJson("update-manifest.json");
assert.equal(packageVersion, coreManifest.version);
assert.equal(coreManifest.channel, "stable");
assert.equal(packageVersion, "0.2.27");
assert.equal(currentRelease.version, "0.2.27");
assert.equal(packageVersion, "0.3.0");
assert.equal(currentRelease.version, "0.3.0");
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
available_versions: [
{ version: "0.3.0", ref: "refs/tags/v0.3.0", rollback_safe: true },
{ version: "0.2.27", ref: "refs/tags/v0.2.27", rollback_safe: true },
{ version: "0.2.26", ref: "refs/tags/v0.2.26", rollback_safe: true },
{ version: "0.2.25", ref: "refs/tags/v0.2.25", rollback_safe: true },
@ -148,7 +149,7 @@ const corrected = buildStatus({
channel: "stable"
});
assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.2.27");
assert.equal(corrected.safe_target_version, "0.3.0");
assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false);

View File

@ -45,19 +45,18 @@ function commonConfig(input = {}) {
};
}
function mediaConfig(input = {}, { includeFit = false } = {}) {
function mediaConfig(input = {}, { includeFit = false, includeLayout = true } = {}) {
const playBehavior = ["once", "loop", "manual"].includes(input.playBehavior)
? input.playBehavior
: "once";
return {
...commonConfig(input),
...(includeLayout ? commonConfig(input) : {}),
url: url(input.url),
playBehavior,
muted: input.muted === true || input.muted === "on",
volume: number(input.volume, 1, 0, 1),
playbackRate: number(input.playbackRate, 1, 0.25, 4),
startAt: number(input.startAt, 0, 0, 86400),
showControls: playBehavior === "manual" || input.showControls === true || input.showControls === "on",
...(includeFit ? { fit: ["contain", "cover", "fill"].includes(input.fit) ? input.fit : "contain" } : {})
};
}
@ -190,7 +189,9 @@ registerOverlayModuleType({
description: "Play an audio file through the OBS Browser Source.",
renderType: "audio",
normalize(input) {
return mediaConfig(input);
// Audio is a managed output, not a visual canvas object. Deliberately omit
// obsolete placement, opacity, anchor, and browser-control settings.
return mediaConfig(input, { includeLayout: false });
}
});

View File

@ -102,7 +102,9 @@ function getOverlay(id, { includeSecrets = false } = {}) {
.map((module) => ({
...module,
enabled: Boolean(module.enabled),
config: parseConfig(module.config_json)
config: module.type === "audio"
? normalizeModuleConfig("audio", parseConfig(module.config_json))
: parseConfig(module.config_json)
}));
return result;
});
@ -375,6 +377,9 @@ function updateModule(moduleId, input = {}) {
function updateModuleTransform(moduleId, input = {}) {
const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
if (!module) throw new Error("Overlay source not found.");
if ((getOverlayModuleType(module.type)?.renderType || module.type) === "audio") {
throw new Error("Audio sources do not have a canvas position or size.");
}
const current = parseConfig(module.config_json);
updateModule(moduleId, {
type: module.type,
@ -414,7 +419,18 @@ function duplicateModule(moduleId, name) {
function reorderModules(sceneId, ids) {
const overlayId = overlayIdForScene(sceneId);
if (!overlayId) throw new Error("Scene not found.");
reorderRows("overlay_modules", ids, "scene_id", sceneId);
const modules = db.prepare("SELECT id, type FROM overlay_modules WHERE scene_id = ? ORDER BY sort_order, created_at").all(sceneId);
const visualIds = modules.filter((module) => (getOverlayModuleType(module.type)?.renderType || module.type) !== "audio").map((module) => module.id);
const requested = Array.isArray(ids) ? ids.map(String) : [];
if (requested.length === visualIds.length && new Set(requested).size === visualIds.length && visualIds.every((id) => requested.includes(id))) {
let visualIndex = 0;
const completeOrder = modules.map((module) =>
(getOverlayModuleType(module.type)?.renderType || module.type) === "audio" ? module.id : requested[visualIndex++]
);
reorderRows("overlay_modules", completeOrder, "scene_id", sceneId);
} else {
reorderRows("overlay_modules", requested, "scene_id", sceneId);
}
touchOverlay(overlayId);
notifyOverlayChanged(overlayId, "module_reorder");
}

View File

@ -0,0 +1,505 @@
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { spawn, spawnSync } = require("child_process");
const { publishWebEvent } = require("./web-events");
const DATA_ROOT = path.join(
process.env.LUMI_DATA_DIR ? path.resolve(process.env.LUMI_DATA_DIR) : path.join(__dirname, "..", "..", "data"),
"stream-testing"
);
const MAX_SESSION_MS = Math.min(60 * 60 * 1000, Math.max(5 * 60 * 1000, Number(process.env.LUMI_STREAM_TEST_MAX_MS) || 30 * 60 * 1000));
const INACTIVITY_MS = Math.min(5 * 60 * 1000, Math.max(20 * 1000, Number(process.env.LUMI_STREAM_TEST_INACTIVITY_MS) || 45 * 1000));
const MAX_OUTPUT_BYTES = Math.min(4 * 1024 ** 3, Math.max(128 * 1024 ** 2, Number(process.env.LUMI_STREAM_TEST_MAX_BYTES) || 1024 ** 3));
const INGEST_PORT = Math.min(65535, Math.max(1024, Number(process.env.LUMI_STREAM_TEST_INGEST_PORT) || 19350));
const PUBLIC_INGEST_PORT = Math.min(65535, Math.max(1, Number(process.env.LUMI_STREAM_TEST_PUBLIC_PORT) || INGEST_PORT));
const FFMPEG = String(process.env.LUMI_FFMPEG_PATH || "ffmpeg");
function finite(value, fallback, min, max) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
}
function redactUrl(value) {
try {
const parsed = new URL(value);
parsed.pathname = "/lumi-test/[session]";
parsed.search = "";
return parsed.toString();
} catch {
return "configured ingest endpoint";
}
}
function redactDiagnostic(value, session) {
let result = String(value || "");
if (session?.listenerUrl) result = result.split(session.listenerUrl).join("rtmp://[private-stream-test]");
return result.replace(/rtmps?:\/\/[^\s'"]+\/lumi-test\/[^\s'"]+/gi, "rtmp://[private-stream-test]");
}
function directoryBytes(root) {
try {
return fs.readdirSync(root, { withFileTypes: true }).reduce((total, entry) => {
if (!entry.isFile()) return total;
try { return total + fs.statSync(path.join(root, entry.name)).size; } catch { return total; }
}, 0);
} catch {
return 0;
}
}
function refreshWarnings(session) {
session.warnings = [
...session.baseWarnings,
...(session.obsWarnings || []),
...(session.receiverWarnings || []),
...(session.captionWarnings || [])
];
}
let receiverStatusCache = null;
function executableStatus({ refresh = false } = {}) {
if (!refresh && receiverStatusCache && Date.now() - receiverStatusCache.checkedAt < 60_000) return receiverStatusCache.value;
const result = spawnSync(FFMPEG, ["-hide_banner", "-version"], { encoding: "utf8", timeout: 5000, windowsHide: true });
if (result.error || result.status !== 0) {
const value = { available: false, detail: "FFmpeg is not available. Install a current FFmpeg build or set LUMI_FFMPEG_PATH." };
receiverStatusCache = { checkedAt: Date.now(), value };
return value;
}
const encoders = spawnSync(FFMPEG, ["-hide_banner", "-encoders"], { encoding: "utf8", timeout: 8000, windowsHide: true });
const hardwareAdvertised = /\bh264_nvenc\b/.test(encoders.stdout || "");
const hardwareProbe = hardwareAdvertised
? spawnSync(FFMPEG, ["-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=size=64x64:rate=1", "-frames:v", "1", "-c:v", "h264_nvenc", "-f", "null", "-"], { encoding: "utf8", timeout: 8000, windowsHide: true })
: null;
const hardware = hardwareProbe?.status === 0;
const value = {
available: true,
encoder: process.env.LUMI_STREAM_TEST_ENCODER || (hardware ? "h264_nvenc" : "libx264"),
hardware,
detail: hardware
? "FFmpeg with NVIDIA NVENC is ready."
: hardwareAdvertised
? "FFmpeg is ready. NVENC was advertised but failed its encode probe, so Lumi will use CPU H.264."
: "FFmpeg is ready with CPU H.264 encoding."
};
receiverStatusCache = { checkedAt: Date.now(), value };
return value;
}
function ladderFor(source = {}) {
const width = Math.round(finite(source.width, 1920, 160, 7680));
const height = Math.round(finite(source.height, 1080, 90, 4320));
const fps = finite(source.fps, 30, 1, 120);
const ladder = [{ name: "source", label: `Source (${width}×${height})`, width, height, bitrate: Math.min(12000, Math.max(2500, Math.round(width * height * fps / 9000))) }];
if (height > 720) ladder.push({ name: "720p", label: "720p", height: 720, width: Math.max(2, Math.round((width * 720 / height) / 2) * 2), bitrate: 4500 });
if (height > 480) ladder.push({ name: "480p", label: "480p", height: 480, width: Math.max(2, Math.round((width * 480 / height) / 2) * 2), bitrate: 2200 });
return { width, height, fps, variants: ladder };
}
function ffmpegArgs(session) {
const variants = session.source.variants;
const splitNames = variants.map((_, index) => `[split${index}]`).join("");
const filters = variants.length > 1 ? [`[0:v:0]split=${variants.length}${splitNames}`] : [];
const maps = [];
const streamMap = [];
variants.forEach((variant, index) => {
if (variant.name === "source") {
if (variants.length > 1) {
filters.push(`[split${index}]scale=w='min(iw,${variant.width})':h='min(ih,${variant.height})':force_original_aspect_ratio=decrease:force_divisible_by=2[v${index}]`);
}
maps.push("-map", variants.length > 1 ? `[v${index}]` : "0:v:0");
} else {
filters.push(`[split${index}]scale=w=${variant.width}:h=${variant.height}:flags=lanczos[v${index}]`);
maps.push("-map", `[v${index}]`);
}
maps.push("-map", "0:a:0");
streamMap.push(`v:${index},a:${index},name:${variant.name}`);
});
const encoderArgs = session.receiver.encoder === "h264_nvenc"
? ["-c:v", "h264_nvenc", "-preset", "p4", "-tune", "ll", "-rc", "vbr"]
: ["-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency"];
const bitrateArgs = variants.flatMap((variant, index) => [
`-b:v:${index}`, `${variant.bitrate}k`,
`-maxrate:v:${index}`, `${Math.round(variant.bitrate * 1.15)}k`,
`-bufsize:v:${index}`, `${Math.round(variant.bitrate * 2)}k`
]);
return [
"-hide_banner", "-nostdin", "-loglevel", "warning",
"-listen", "1", "-rw_timeout", `${INACTIVITY_MS * 1000}`, "-thread_queue_size", "512",
"-i", session.listenerUrl,
...(filters.length ? ["-filter_complex", filters.join(";")] : []),
...maps,
...encoderArgs,
...bitrateArgs,
"-c:a", "aac", "-b:a", "128k", "-ar", "48000",
"-max_muxing_queue_size", "1024",
"-g", `${Math.max(30, Math.round(session.source.fps * 2))}`, "-keyint_min", `${Math.max(30, Math.round(session.source.fps * 2))}`, "-sc_threshold", "0",
"-f", "hls", "-hls_time", "2", "-hls_list_size", "8",
"-hls_flags", "delete_segments+independent_segments+program_date_time+temp_file",
"-master_pl_name", "master.m3u8",
"-var_stream_map", streamMap.join(" "),
"-hls_segment_filename", path.join(session.directory, "stream_%v_%06d.ts"),
path.join(session.directory, "stream_%v.m3u8"),
"-progress", "pipe:1"
];
}
class StreamTestingService {
constructor() {
this.active = null;
this.timeline = [];
this.lastCreateAt = new Map();
this.timer = setInterval(() => this.sweep(), 2000);
this.timer.unref?.();
this.exitHandler = () => {
for (const child of [this.active?.patternProcess, this.active?.process]) {
try {
if (child && child.exitCode === null) child.kill("SIGKILL");
} catch {}
}
try {
if (this.active?.directory) fs.rmSync(this.active.directory, { recursive: true, force: true });
} catch {}
};
process.once("exit", this.exitHandler);
fs.mkdirSync(DATA_ROOT, { recursive: true });
this.cleanupStale();
}
receiverStatus() {
return executableStatus();
}
publicStatus() {
const session = this.active;
const receiver = session?.receiver || executableStatus();
return {
available: receiver.available,
receiver: { available: receiver.available, encoder: receiver.encoder || null, hardware: Boolean(receiver.hardware), detail: receiver.detail },
active: Boolean(session),
session: session ? {
id: session.id,
state: session.state,
started_at: session.startedAt,
expires_at: session.expiresAt,
last_activity_at: session.lastActivityAt,
device: session.deviceName,
source: session.source,
playback_url: `/admin/stream-testing/media/${session.id}/master.m3u8`,
captions_url: `/admin/stream-testing/media/${session.id}/captions.vtt`,
metrics: session.metrics,
warnings: session.warnings,
ingest: redactUrl(session.listenerUrl)
} : null,
timeline: this.timeline.slice(-100)
};
}
create(device, input = {}, send = null) {
const now = Date.now();
if (this.active) {
const error = new Error("A stream test is already active. End it before starting another.");
error.code = "STREAM_TEST_ACTIVE";
throw error;
}
if (now - (this.lastCreateAt.get(device.id) || 0) < 5000) {
const error = new Error("Wait a few seconds before creating another stream test.");
error.code = "STREAM_TEST_RATE_LIMIT";
throw error;
}
const receiver = executableStatus({ refresh: true });
if (!receiver.available) {
const error = new Error(receiver.detail);
error.code = "STREAM_TEST_RECEIVER_UNAVAILABLE";
throw error;
}
const source = ladderFor(input.source || {});
if (source.width > 3840 || source.height > 2160 || source.fps > 60) {
const error = new Error("Stream testing is bounded to 3840×2160 at 60 fps.");
error.code = "STREAM_TEST_SOURCE_LIMIT";
throw error;
}
const id = crypto.randomUUID();
const secret = crypto.randomBytes(32).toString("base64url");
const directory = path.join(DATA_ROOT, id);
fs.mkdirSync(directory, { recursive: true });
const configuredHost = String(process.env.LUMI_STREAM_TEST_INGEST_HOST || (input.pattern === true ? "127.0.0.1" : "")).trim();
if (!configuredHost) {
fs.rmSync(directory, { recursive: true, force: true });
const error = new Error("Set LUMI_STREAM_TEST_INGEST_HOST to the hostname reachable from the streaming computer.");
error.code = "STREAM_TEST_INGEST_UNCONFIGURED";
throw error;
}
const protocol = process.env.LUMI_STREAM_TEST_RTMPS === "true" ? "rtmps" : "rtmp";
const key = `${id}/${secret}`;
const listenerUrl = `rtmp://0.0.0.0:${INGEST_PORT}/lumi-test/${key}`;
const session = {
id,
deviceId: device.id,
deviceName: String(device.name || device.metadata?.name || "Paired Companion").slice(0, 120),
state: "starting",
source,
receiver,
directory,
listenerUrl,
createdAt: now,
startedAt: null,
expiresAt: now + MAX_SESSION_MS,
lastActivityAt: now,
metrics: { obs: {}, receiver: {}, bytes: 0 },
baseWarnings: protocol === "rtmp" ? [{ severity: "warning", code: "unencrypted_ingest", message: "The ingest hop uses an ephemeral credential but is not transport-encrypted. Keep the ingest port private or enable RTMPS at the reverse-proxy boundary." }] : [],
obsWarnings: [],
receiverWarnings: [],
captionWarnings: [],
warnings: protocol === "rtmp" ? [{ severity: "warning", code: "unencrypted_ingest", message: "The ingest hop uses an ephemeral credential but is not transport-encrypted. Keep the ingest port private or enable RTMPS at the reverse-proxy boundary." }] : [],
captions: [],
process: null,
patternProcess: null,
pattern: input.pattern === true,
send: typeof send === "function" ? send : null,
stopping: false
};
this.active = session;
this.lastCreateAt.set(device.id, now);
this.event("created", "Test session created.", session);
this.startReceiver(session);
return {
id,
state: session.state,
expires_at: session.expiresAt,
ingest: {
server: `${protocol}://${configuredHost}:${PUBLIC_INGEST_PORT}/lumi-test`,
key,
encrypted: protocol === "rtmps"
},
source,
warning: session.warnings[0]?.message || null
};
}
createPattern() {
const created = this.create(
{ id: "admin-test-pattern", name: "Deterministic test pattern" },
{ pattern: true, source: { width: 1280, height: 720, fps: 30 } }
);
const session = this.active;
if (!session) throw new Error("The deterministic receiver session was not created.");
const publishUrl = session.listenerUrl.replace("0.0.0.0", "127.0.0.1");
setTimeout(() => {
if (this.active?.id !== session.id || session.stopping) return;
const args = [
"-hide_banner", "-nostdin", "-loglevel", "warning", "-re",
"-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30",
"-f", "lavfi", "-i", "sine=frequency=880:sample_rate=48000",
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
"-b:v", "3500k", "-g", "60", "-c:a", "aac", "-b:a", "128k",
"-f", "flv", publishUrl
];
const feeder = spawn(FFMPEG, args, { windowsHide: true, stdio: ["ignore", "ignore", "pipe"] });
session.patternProcess = feeder;
feeder.stderr.resume();
feeder.on("error", (error) => this.fail(session, `Test pattern could not start: ${error.message}`));
feeder.on("exit", (code, signal) => {
if (!session.stopping && this.active?.id === session.id)
this.fail(session, `Test pattern stopped unexpectedly (${signal || `exit ${code}`}).`);
});
this.event("pattern_started", "Deterministic color, motion, timing, and audio pattern started.", session);
}, 750).unref?.();
return created;
}
startReceiver(session) {
const args = ffmpegArgs(session);
const child = spawn(FFMPEG, args, { cwd: session.directory, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
session.process = child;
let progress = "";
let diagnostic = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
progress += chunk;
const records = progress.split(/\r?\n/);
progress = records.pop() || "";
for (const line of records) {
const separator = line.indexOf("=");
if (separator < 1) continue;
const key = line.slice(0, separator);
const value = line.slice(separator + 1);
if (["fps", "bitrate", "speed", "drop_frames", "dup_frames", "out_time_ms"].includes(key)) session.metrics.receiver[key] = value;
if (key === "progress") {
session.lastActivityAt = Date.now();
if (session.state === "starting") {
session.state = "receiving";
session.startedAt = Date.now();
this.event("receiving", "OBS stream reached Lumi.", session);
}
}
}
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => { diagnostic = `${diagnostic}${chunk}`.slice(-4000); });
child.on("error", (error) => this.fail(session, `Receiver could not start: ${error.message}`));
child.on("exit", (code, signal) => {
if (session.stopping || this.active?.id !== session.id) return;
this.fail(session, `Receiver stopped unexpectedly (${signal || `exit ${code}`}). ${redactDiagnostic(diagnostic.trim().slice(-600), session)}`);
});
}
updateObs(deviceId, input = {}) {
const session = this.requireDeviceSession(deviceId, input.session_id);
session.lastActivityAt = Date.now();
session.metrics.obs = {
bitrate_kbps: finite(input.bitrate_kbps, 0, 0, 100000),
dropped_frames: Math.round(finite(input.dropped_frames, 0, 0, Number.MAX_SAFE_INTEGER)),
total_frames: Math.round(finite(input.total_frames, 0, 0, Number.MAX_SAFE_INTEGER)),
congestion: finite(input.congestion, 0, 0, 1),
active: input.active === true,
width: Math.round(finite(input.width, session.source.width, 0, 7680)),
height: Math.round(finite(input.height, session.source.height, 0, 4320)),
fps: finite(input.fps, session.source.fps, 0, 120),
encoder: String(input.encoder || "").slice(0, 160) || null,
service: String(input.service || "").slice(0, 160) || null,
at: Date.now()
};
const dynamicWarnings = [];
const droppedRatio = session.metrics.obs.total_frames > 0 ? session.metrics.obs.dropped_frames / session.metrics.obs.total_frames : 0;
if (droppedRatio >= 0.03) dynamicWarnings.push({ severity: "critical", code: "obs_frame_loss", message: `${(droppedRatio * 100).toFixed(1)}% of OBS output frames were dropped.` });
else if (droppedRatio >= 0.005) dynamicWarnings.push({ severity: "warning", code: "obs_frame_loss", message: `${(droppedRatio * 100).toFixed(1)}% of OBS output frames were dropped.` });
if (session.metrics.obs.bitrate_kbps > 0 && session.metrics.obs.bitrate_kbps < 500) dynamicWarnings.push({ severity: "critical", code: "obs_bitrate_collapse", message: `OBS output bitrate fell to ${Math.round(session.metrics.obs.bitrate_kbps)} kbps.` });
if (session.metrics.obs.congestion >= 0.25) dynamicWarnings.push({ severity: "critical", code: "obs_congestion", message: `OBS reports ${Math.round(session.metrics.obs.congestion * 100)}% network congestion.` });
else if (session.metrics.obs.congestion >= 0.08) dynamicWarnings.push({ severity: "warning", code: "obs_congestion", message: `OBS reports ${Math.round(session.metrics.obs.congestion * 100)}% network congestion.` });
session.obsWarnings = dynamicWarnings;
refreshWarnings(session);
this.notify();
return this.publicStatus().session;
}
addCaption(deviceId, input = {}) {
const session = this.requireDeviceSession(deviceId, input.session_id);
const text = String(input.text || "").replace(/[\r\n]+/g, " ").trim().slice(0, 500);
if (!text) return;
const start = finite(input.start_seconds, Math.max(0, (Date.now() - (session.startedAt || Date.now())) / 1000), 0, MAX_SESSION_MS / 1000);
const end = Math.max(start + 0.3, finite(input.end_seconds, start + 3, start, MAX_SESSION_MS / 1000));
session.captions.push({ start, end, text });
if (session.captions.length > 1000) session.captions.shift();
const delayMs = finite(input.delay_ms, 0, 0, MAX_SESSION_MS);
session.metrics.captions = { delivered: session.captions.length, last_delay_ms: delayMs, last_received_at: Date.now() };
session.captionWarnings = delayMs >= 5000
? [{ severity: "critical", code: "caption_delay", message: `The latest caption took ${Math.round(delayMs)} ms to become available.` }]
: delayMs >= 2500
? [{ severity: "warning", code: "caption_delay", message: `The latest caption took ${Math.round(delayMs)} ms to become available.` }]
: [];
refreshWarnings(session);
session.lastActivityAt = Date.now();
}
captionFile(id) {
const session = this.active?.id === id ? this.active : null;
if (!session) return null;
const timestamp = (seconds) => {
const milliseconds = Math.round(seconds * 1000);
const date = new Date(milliseconds);
return `${String(Math.floor(milliseconds / 3600000)).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")}:${String(date.getUTCSeconds()).padStart(2, "0")}.${String(date.getUTCMilliseconds()).padStart(3, "0")}`;
};
return `WEBVTT\n\n${session.captions.map((cue, index) => `${index + 1}\n${timestamp(cue.start)} --> ${timestamp(cue.end)}\n${cue.text}\n`).join("\n")}`;
}
mediaFile(id, name) {
const session = this.active?.id === id ? this.active : null;
if (!session || !/^(?:master|stream_(?:source|720p|480p))(?:\.m3u8|_\d{6}\.ts)$/.test(name)) return null;
const resolved = path.join(session.directory, name);
return fs.existsSync(resolved) && fs.statSync(resolved).isFile() ? resolved : null;
}
requireDeviceSession(deviceId, id) {
const session = this.active;
if (!session || session.id !== id || session.deviceId !== deviceId) {
const error = new Error("The stream test session is not active for this Companion.");
error.code = "STREAM_TEST_NOT_ACTIVE";
throw error;
}
return session;
}
async stop(input = {}) {
const session = this.active;
if (!session || (input.session_id && session.id !== input.session_id)) return { stopped: false };
if (input.device_id && session.deviceId !== input.device_id) throw new Error("This Companion does not own the active stream test.");
session.stopping = true;
session.state = input.failed ? "failed" : "stopping";
this.event(input.failed ? "failed" : "stopping", input.reason || (input.failed ? "The test failed." : "The test is ending."), session);
const child = session.process;
if (session.patternProcess && session.patternProcess.exitCode === null) session.patternProcess.kill("SIGTERM");
if (child && child.exitCode === null) {
child.kill("SIGTERM");
await Promise.race([
new Promise((resolve) => child.once("exit", resolve)),
new Promise((resolve) => setTimeout(resolve, 3000))
]);
if (child.exitCode === null) child.kill("SIGKILL");
}
if (this.active?.id === session.id) this.active = null;
try {
session.send?.("stream_test_ended", {
session_id: session.id,
reason: input.reason || "The private stream test ended.",
failed: Boolean(input.failed)
});
} catch {}
this.event(input.failed ? "failed" : "ended", input.reason || "Test session ended and temporary media was removed.", session);
try { await fs.promises.rm(session.directory, { recursive: true, force: true }); } catch {}
this.notify();
return { stopped: true, id: session.id };
}
fail(session, detail) {
if (this.active?.id !== session.id || session.stopping) return;
session.warnings.push({ severity: "critical", code: "receiver_stopped", message: String(detail).slice(0, 1000) });
this.event("receiver_error", String(detail).slice(0, 1000), session);
void this.stop({ session_id: session.id, failed: true, reason: "The Lumi receiver stopped. OBS recovery was requested." });
}
sweep() {
const session = this.active;
if (!session) return;
session.metrics.bytes = directoryBytes(session.directory);
const receiverSpeed = Number.parseFloat(String(session.metrics.receiver.speed || "").replace(/x$/, ""));
session.receiverWarnings = Number.isFinite(receiverSpeed) && receiverSpeed > 0 && receiverSpeed < 0.75
? [{ severity: "critical", code: "receiver_slow", message: `Receiver transcoding is only ${receiverSpeed.toFixed(2)}× real time.` }]
: Number.isFinite(receiverSpeed) && receiverSpeed > 0 && receiverSpeed < 0.95
? [{ severity: "warning", code: "receiver_slow", message: `Receiver transcoding is ${receiverSpeed.toFixed(2)}× real time.` }]
: [];
refreshWarnings(session);
const now = Date.now();
if (session.metrics.bytes > MAX_OUTPUT_BYTES) return void this.stop({ session_id: session.id, failed: true, reason: "The test reached its temporary media safety limit." });
if (now >= session.expiresAt) return void this.stop({ session_id: session.id, reason: "The maximum test duration expired." });
if (now - session.lastActivityAt >= INACTIVITY_MS) return void this.stop({ session_id: session.id, failed: true, reason: "No stream activity reached Lumi before the inactivity limit." });
this.notify();
}
event(kind, message, session = this.active) {
this.timeline.push({ at: Date.now(), kind, message, session_id: session?.id || null });
if (this.timeline.length > 200) this.timeline.shift();
this.notify();
}
notify() {
publishWebEvent("stream-test:changed", { active: Boolean(this.active), state: this.active?.state || "idle" }, { role: "admin" });
}
cleanupStale() {
for (const entry of fs.readdirSync(DATA_ROOT, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
fs.rmSync(path.join(DATA_ROOT, entry.name), { recursive: true, force: true });
}
}
async close() {
clearInterval(this.timer);
await this.stop({ reason: "Lumi is shutting down; OBS recovery was requested." });
process.removeListener("exit", this.exitHandler);
}
}
const streamTestingService = new StreamTestingService();
module.exports = { StreamTestingService, streamTestingService, ladderFor, executableStatus, ffmpegArgs, DATA_ROOT };

View File

@ -426,7 +426,9 @@
button.className = "button subtle";
button.dataset.canvasSelect = module.id;
button.textContent = `${module.name}${module.enabled ? "" : " (hidden)"}`;
button.disabled = !module.enabled;
const isAudio = (module.renderType || module.type) === "audio";
button.disabled = !module.enabled || isAudio;
if (isAudio) button.title = "Audio is managed from its source settings and has no canvas object.";
button.addEventListener("click", () => selectSource(module.id, { openSettings: true }));
sourceList.appendChild(button);
}
@ -555,6 +557,23 @@
handle: event.target.closest("[data-resize-handle]")?.dataset.resizeHandle || null
};
});
document.querySelectorAll("[data-media-control]").forEach((button) => button.addEventListener("click", () => {
const media = renderer.findSourceContent(layer, button.dataset.mediaSource, "video, audio");
if (!media) return;
const action = button.dataset.mediaControl;
if (action === "play") media.play()?.catch?.(() => {});
if (action === "pause") media.pause();
if (action === "stop") {
media.pause();
try { media.currentTime = Math.max(0, Number(media.dataset.startAt || 0)); } catch {}
media.dispatchEvent(new CustomEvent("lumi-media-stop"));
}
if (action === "seek") {
const field = button.closest("[data-media-controls]")?.querySelector("[data-media-seek]");
try { media.currentTime = Math.max(0, Number(field?.value || 0)); } catch {}
}
}));
window.addEventListener("pointermove", applyDrag, { signal: lifecycleSignal });
window.addEventListener("pointerup", () => {
if (!drag) return;

View File

@ -40,6 +40,7 @@
function restartMediaElement(media) {
if (!media) return false;
media.dispatchEvent(new CustomEvent("lumi-media-restart"));
const startAt = Math.max(0, finite(media.dataset.startAt, 0));
try { media.currentTime = startAt; } catch {}
const playback = media.play?.();
@ -57,14 +58,33 @@
media.preload = "auto";
media.playsInline = true;
media.loop = values.playBehavior === "loop";
media.controls = values.showControls === true || values.playBehavior === "manual";
media.controls = false;
media.muted = values.muted === true;
media.volume = Math.min(1, Math.max(0, finite(values.volume, 1)));
media.playbackRate = Math.min(4, Math.max(0.25, finite(values.playbackRate, 1)));
media.autoplay = !options.editor && values.playBehavior !== "manual";
media.referrerPolicy = "no-referrer";
media.style.pointerEvents = !options.editor && media.controls ? "auto" : "none";
if (renderType === "video") media.style.objectFit = values.fit || "contain";
media.style.pointerEvents = "none";
if (renderType === "video") {
media.style.objectFit = values.fit || "contain";
media.hidden = true;
media.dataset.playbackState = "waiting";
const show = () => {
media.hidden = false;
media.dataset.playbackState = "playing";
};
const hide = () => {
media.hidden = true;
media.dataset.playbackState = "stopped";
};
media.addEventListener("playing", show);
for (const eventName of ["ended", "error", "abort", "emptied"]) media.addEventListener(eventName, hide);
media.addEventListener("lumi-media-stop", hide);
media.addEventListener("lumi-media-restart", hide);
} else {
media.hidden = true;
media.setAttribute("aria-hidden", "true");
}
media.addEventListener("loadedmetadata", () => {
const startAt = Math.max(0, finite(values.startAt, 0));
if (startAt && startAt < finite(media.duration, Infinity)) {
@ -424,12 +444,16 @@
function findSourceContent(root, moduleId, selector) {
const box = root?.querySelector?.(`.lumi-overlay-module[data-module-id="${CSS.escape(moduleId)}"]`);
return box?.querySelector?.(selector) || box?.querySelector?.(".lumi-overlay-web-root")?.shadowRoot?.querySelector?.(selector) || null;
return box?.querySelector?.(selector)
|| box?.querySelector?.(".lumi-overlay-web-root")?.shadowRoot?.querySelector?.(selector)
|| root?.querySelector?.(`.lumi-overlay-managed-media[data-module-id="${CSS.escape(moduleId)}"]${selector === "video, audio" ? "" : selector}`)
|| null;
}
function buildModule(module, options = {}) {
const renderType = module.renderType || module.type;
const values = module.config || {};
if (renderType === "audio") return null;
const scale = finite(options.scale, 1);
const box = document.createElement("section");
box.className = `lumi-overlay-module lumi-overlay-module-${renderType}`;
@ -472,15 +496,9 @@
image.referrerPolicy = "no-referrer";
image.style.objectFit = values.fit || "contain";
box.appendChild(image);
} else if ((renderType === "video" || renderType === "audio") && values.url) {
} else if (renderType === "video" && values.url) {
const media = buildMedia(module, values, options, renderType);
box.appendChild(media);
if (renderType === "audio" && options.editor) {
const placeholder = document.createElement("span");
placeholder.className = "lumi-overlay-audio-placeholder";
placeholder.textContent = "Audio source";
box.appendChild(placeholder);
}
} else if (renderType === "web" && values.url) {
box.appendChild(buildWebsite(module, values, options));
}
@ -526,7 +544,17 @@
if (state?.enabled !== false && state?.scene) {
for (const module of state.scene.modules || []) {
if (module.enabled === false) continue;
const renderType = module.renderType || module.type;
if (renderType === "audio") {
if (module.config?.url) {
const media = buildMedia(module, module.config, options, "audio");
media.classList.add("lumi-overlay-managed-media");
stage.appendChild(media);
}
continue;
}
const element = buildModule(module, { ...options, canvas, scale: 1 });
if (!element) continue;
elements.set(module.id, element);
stage.appendChild(element);
}

View File

@ -83,19 +83,6 @@ body.lumi-overlay-runtime-page {
}
.lumi-overlay-audio {
width: 100%;
min-height: 3rem;
}
.lumi-overlay-audio-placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border: 1px dashed rgba(255,255,255,0.55);
background: rgba(15,23,42,0.72);
color: #fff;
font: 700 0.9rem/1.2 system-ui, sans-serif;
pointer-events: none;
display: none !important;
}
}

View File

@ -0,0 +1,15 @@
@layer features {
.stream-test-layout { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(18rem, .7fr); gap: var(--lumi-space-4); }
.stream-test-player-shell { position: relative; aspect-ratio: 16 / 9; overflow: hidden; border-radius: var(--lumi-radius-md); background: #05070a; }
.stream-test-player-shell video { display: block; width: 100%; height: 100%; object-fit: contain; background: transparent; }
.stream-test-player-shell video:not([src]) { display: none; }
.stream-test-player-shell .empty-state { position: absolute; inset: 0; display: grid; place-items: center; padding: var(--lumi-space-5); text-align: center; }
.stream-test-summary, .stream-test-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: var(--lumi-space-3); }
.stream-test-summary > div, .stream-test-metrics > div { padding: var(--lumi-space-3); border: 1px solid var(--lumi-border); border-radius: var(--lumi-radius-sm); background: var(--lumi-surface-subtle); }
.stream-test-summary span, .stream-test-summary strong, .stream-test-metrics span, .stream-test-metrics strong { display: block; }
.stream-test-summary span, .stream-test-metrics span { color: var(--lumi-text-muted); font-size: .84rem; }
.stream-test-timeline { display: grid; gap: var(--lumi-space-2); padding-left: 1.4rem; }
.stream-test-timeline li::marker { color: var(--lumi-primary); }
.stream-test-warning { margin-top: var(--lumi-space-3); }
@media (max-width: 900px) { .stream-test-layout { grid-template-columns: 1fr; } }
}

View File

@ -0,0 +1,134 @@
(() => {
const root = document.querySelector("[data-stream-testing]");
if (!root) return;
const player = root.querySelector("[data-stream-player]");
const captions = root.querySelector("[data-stream-captions]");
const empty = root.querySelector("[data-stream-empty]");
const statePill = root.querySelector("[data-stream-state]");
const summary = root.querySelector("[data-stream-summary]");
const metrics = root.querySelector("[data-stream-metrics]");
const warnings = root.querySelector("[data-stream-warnings]");
const timeline = root.querySelector("[data-stream-timeline]");
const advanced = root.querySelector("[data-stream-advanced]");
const quality = root.querySelector("[data-stream-quality]");
const fullscreen = root.querySelector("[data-stream-fullscreen]");
const stop = root.querySelector("[data-stream-stop]");
const pattern = root.querySelector("[data-stream-pattern]");
let hls = null;
let sessionId = null;
const text = (value) => String(value ?? "");
const html = (value) => text(value).replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]);
const metric = (label, value) => `<div><span>${html(label)}</span><strong>${html(value)}</strong></div>`;
const bytes = (value) => {
let size = Number(value) || 0;
const units = ["B", "KB", "MB", "GB"];
let unit = 0;
while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; }
return `${size.toFixed(unit ? 1 : 0)} ${units[unit]}`;
};
function attach(session) {
if (!session || sessionId === session.id) return;
sessionId = session.id;
hls?.destroy?.();
hls = null;
player.removeAttribute("src");
const url = `${session.playback_url}?v=${encodeURIComponent(session.id)}`;
captions.src = `${session.captions_url}?v=${encodeURIComponent(session.id)}`;
if (window.Hls?.isSupported?.()) {
hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: false, xhrSetup: (xhr) => { xhr.withCredentials = true; } });
hls.loadSource(url);
hls.attachMedia(player);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
quality.replaceChildren(new Option("Automatic", "-1"), ...hls.levels.map((level, index) => new Option(`${level.height || "Source"}p · ${Math.round((level.bitrate || 0) / 1000)} kbps`, String(index))));
quality.disabled = false;
player.play().catch(() => {});
});
} else if (player.canPlayType("application/vnd.apple.mpegurl")) {
player.src = url;
}
}
function detach() {
sessionId = null;
hls?.destroy?.();
hls = null;
player.pause();
player.removeAttribute("src");
captions.removeAttribute("src");
player.load();
quality.replaceChildren(new Option("Automatic", "-1"));
quality.disabled = true;
}
function render(status) {
const session = status.session;
if (session) attach(session); else detach();
if (session) captions.src = `${session.captions_url}?v=${encodeURIComponent(session.id)}&at=${Date.now()}`;
statePill.textContent = session?.state || "Idle";
statePill.className = `status-pill ${session?.state === "receiving" ? "success" : session ? "warning" : "neutral"}`;
empty.hidden = Boolean(session);
fullscreen.disabled = !session;
stop.disabled = !session;
pattern.disabled = Boolean(session);
summary.innerHTML = [
metric("Receiver", status.receiver.available ? "Ready" : "Needs setup"),
metric("Session", session ? session.state : "No active test"),
metric("Streaming computer", session?.device || "Not connected"),
metric("Expires", session ? new Date(session.expires_at).toLocaleTimeString() : "—")
].join("");
const obs = session?.metrics?.obs || {};
const receiver = session?.metrics?.receiver || {};
const captionMetrics = session?.metrics?.captions || {};
const playerLatency = Number.isFinite(hls?.latency) ? `${hls.latency.toFixed(1)} s` : "Waiting";
metrics.innerHTML = [
metric("OBS bitrate", obs.bitrate_kbps ? `${Math.round(obs.bitrate_kbps)} kbps` : "Waiting"),
metric("Dropped frames", `${obs.dropped_frames || 0} / ${obs.total_frames || 0}`),
metric("OBS congestion", obs.congestion != null ? `${Math.round(obs.congestion * 100)}%` : "Waiting"),
metric("Receiver speed", receiver.speed || "Waiting"),
metric("Receiver FPS", receiver.fps || "Waiting"),
metric("Player latency", playerLatency),
metric("Caption delivery", captionMetrics.delivered ? `${captionMetrics.delivered} cues · ${Math.round(captionMetrics.last_delay_ms || 0)} ms last delay` : "Waiting"),
metric("Temporary media", bytes(session?.metrics?.bytes))
].join("");
warnings.replaceChildren(...(session?.warnings || (!status.receiver.available ? [{ severity: "critical", message: status.receiver.detail }] : [])).map((warning) => {
const item = document.createElement("div");
item.className = `callout ${warning.severity === "critical" ? "danger" : "warning"} stream-test-warning`;
item.textContent = warning.message;
return item;
}));
timeline.replaceChildren(...(status.timeline || []).slice().reverse().map((event) => {
const item = document.createElement("li");
item.textContent = `${new Date(event.at).toLocaleTimeString()}${event.message}`;
return item;
}));
advanced.textContent = JSON.stringify({ receiver: status.receiver, source: session?.source, metrics: session?.metrics, ingest: session?.ingest }, null, 2);
}
async function refresh() {
const response = await fetch("/admin/stream-testing/status", { cache: "no-store" });
if (response.ok) render(await response.json());
}
quality.addEventListener("change", () => { if (hls) hls.currentLevel = Number(quality.value); });
fullscreen.addEventListener("click", () => player.requestFullscreen?.());
stop.addEventListener("click", async () => {
if (!sessionId) return;
stop.disabled = true;
await fetch("/admin/stream-testing/stop", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId }) });
await refresh();
});
pattern.addEventListener("click", async () => {
pattern.disabled = true;
const response = await fetch("/admin/stream-testing/pattern/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload.error || "The deterministic test pattern could not start.");
}
await refresh();
});
const events = new EventSource("/api/events");
events.addEventListener("stream-test:changed", refresh);
window.addEventListener("pagehide", () => { events.close(); hls?.destroy?.(); }, { once: true });
refresh();
})();

View File

@ -80,6 +80,7 @@ const {
const { getClient: getTwitchClient } = require("../services/twitch");
const { twitchEventSubManager } = require("../services/twitch-eventsub");
const { eventHooksApi } = require("../services/overlay-event-hooks");
const { streamTestingService } = require("../services/stream-testing");
const { getClient: getYouTubeClient } = require("../services/youtube");
const {
conditionalRepliesFromBody,
@ -3139,6 +3140,13 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
const contentLibraryFramework = createContentLibraryFramework();
global.lumiFrameworks.content = contentLibraryFramework;
global.lumiFrameworks.resources = contentLibraryFramework;
global.lumiFrameworks.streamTesting = Object.freeze({
create: (device, input, send) => streamTestingService.create(device, input, send),
updateObs: (deviceId, input) => streamTestingService.updateObs(deviceId, input),
addCaption: (deviceId, input) => streamTestingService.addCaption(deviceId, input),
stop: (input) => streamTestingService.stop(input),
status: () => streamTestingService.publicStatus()
});
const assetVersion = Date.now().toString();
const sessionStore = new BetterSqlite3Store({
client: db
@ -6184,6 +6192,55 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
renderDiagnosticsAdmin(req, res);
});
app.get("/admin/stream-testing", requireRole("admin"), (req, res) => {
res.set("Cache-Control", "no-store");
res.render("admin-stream-testing", {
title: "Stream testing",
streamTest: streamTestingService.publicStatus()
});
});
app.get("/admin/stream-testing/status", requireRole("admin"), (_req, res) => {
res.set("Cache-Control", "no-store");
res.json({ ok: true, ...streamTestingService.publicStatus() });
});
app.post("/admin/stream-testing/stop", requireRole("admin"), async (req, res) => {
try {
const result = await streamTestingService.stop({ session_id: req.body?.session_id, reason: "Ended by a Lumi administrator." });
res.json({ ok: true, ...result });
} catch (error) {
res.status(400).json({ ok: false, error: error.message });
}
});
app.post("/admin/stream-testing/pattern/start", requireRole("admin"), (req, res) => {
try {
res.status(201).json({ ok: true, session: streamTestingService.createPattern() });
} catch (error) {
res.status(409).json({ ok: false, error: error.message });
}
});
app.get("/admin/stream-testing/media/:id/captions.vtt", requireRole("admin"), (req, res) => {
const content = streamTestingService.captionFile(req.params.id);
if (content === null) return res.status(404).end();
res.set({ "Cache-Control": "no-store", "Content-Type": "text/vtt; charset=utf-8" }).send(content);
});
app.get("/admin/stream-testing/media/:id/:name", requireRole("admin"), (req, res) => {
const file = streamTestingService.mediaFile(req.params.id, req.params.name);
if (!file) return res.status(404).end();
res.set("Cache-Control", req.params.name.endsWith(".m3u8") ? "no-store" : "private, max-age=30");
res.type(req.params.name.endsWith(".m3u8") ? "application/vnd.apple.mpegurl" : "video/mp2t");
res.sendFile(file);
});
app.get("/admin/stream-testing/hls.js", requireRole("admin"), (_req, res) => {
res.set("Cache-Control", "public, max-age=86400");
res.sendFile(require.resolve("hls.js/dist/hls.min.js"));
});
app.post("/admin/diagnostics/run", requireRole("admin"), (req, res) => {
const requestId = crypto.randomUUID();
const check = String(req.body.check || "").trim();
@ -7837,6 +7894,7 @@ function collectNavItems(user, pluginNav, currentPath) {
section: "admin"
},
{ label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" },
{ label: "Stream testing", path: "/admin/stream-testing", role: "admin", section: "admin" },
{ label: "Diagnostics", path: "/admin/diagnostics", role: "admin", section: "admin" },
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },

View File

@ -108,7 +108,7 @@
<div class="field"><label>Name</label><input name="name" value="<%= module.name %>" required /></div>
<div class="field"><label>Source type</label><select name="type" data-module-type-select><% moduleTypes.forEach((type) => { %><option value="<%= type.id %>" data-render-type="<%= type.renderType || type.id %>" <%= type.id === module.type ? "selected" : "" %>><%= type.label %></option><% }) %></select></div>
<div class="field"><label>Visible</label><label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= module.enabled ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text"><%= module.enabled ? "Shown" : "Hidden" %></span></label></div>
<div class="field"><label>Anchor point <button type="button" class="overlay-help" data-tooltip="The anchor is the point held in place when the source is positioned or resized.">?</button></label><select name="anchor"><% anchors.forEach(([id, label]) => { %><option value="<%= id %>" <%= value(config, "anchor", "top-left") === id ? "selected" : "" %>><%= label %></option><% }) %></select></div>
<% if ((module.renderType || module.type) !== "audio") { %><div class="field"><label>Anchor point <button type="button" class="overlay-help" data-tooltip="The anchor is the point held in place when the source is positioned or resized.">?</button></label><select name="anchor"><% anchors.forEach(([id, label]) => { %><option value="<%= id %>" <%= value(config, "anchor", "top-left") === id ? "selected" : "" %>><%= label %></option><% }) %></select></div><% } %>
<div class="field full overlay-conditional-fields" data-module-fields="text">
<label>Text<textarea name="text" rows="3"><%= value(config, "text") %></textarea></label>
@ -168,8 +168,7 @@
<label>Start at (seconds)<input type="number" name="start_at" min="0" max="86400" step="0.1" value="<%= value(config,"startAt",0) %>" /></label>
<label>Playback speed<input type="number" name="playback_rate" min="0.25" max="4" step="0.25" value="<%= value(config,"playbackRate",1) %>" /></label>
<div class="overlay-switch-field"><span class="overlay-field-label">Sound</span><label class="switch"><input class="switch-input" type="checkbox" name="muted" <%= value(config,"muted",false) ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Mute this video</span></label></div>
<div class="overlay-switch-field"><span class="overlay-field-label">Player controls</span><label class="switch"><input class="switch-input" type="checkbox" name="show_controls" <%= value(config,"showControls",false) ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Show playback controls</span></label></div>
<div><button type="button" class="button subtle" data-refresh-source="<%= module.id %>">Restart playback</button><p class="hint">Enable “Control audio via OBS” on the Lumi Browser Source to mix this sound separately in OBS.</p></div>
<div><button type="button" class="button subtle" data-refresh-source="<%= module.id %>">Restart playback</button><p class="hint">Playback controls stay in Lumi and never cover the public video pixels. Enable “Control audio via OBS” on the Lumi Browser Source to mix this sound separately in OBS.</p></div>
</div>
<div class="field full overlay-conditional-fields" data-module-fields="audio">
@ -178,7 +177,8 @@
<label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="<%= value(config,"volume",1) %>" /></label>
<label>Start at (seconds)<input type="number" name="start_at" min="0" max="86400" step="0.1" value="<%= value(config,"startAt",0) %>" /></label>
<label>Playback speed<input type="number" name="playback_rate" min="0.25" max="4" step="0.25" value="<%= value(config,"playbackRate",1) %>" /></label>
<div class="overlay-switch-field"><span class="overlay-field-label">Player controls</span><label class="switch"><input class="switch-input" type="checkbox" name="show_controls" <%= value(config,"showControls",false) ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Show playback controls</span></label></div>
<div class="overlay-switch-field"><span class="overlay-field-label">Sound</span><label class="switch"><input class="switch-input" type="checkbox" name="muted" <%= value(config,"muted",false) ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Mute this audio source</span></label></div>
<div class="full" data-media-controls><div class="button-group"><button type="button" class="button subtle" data-media-control="play" data-media-source="<%= module.id %>">Play / test</button><button type="button" class="button subtle" data-media-control="pause" data-media-source="<%= module.id %>">Pause</button><button type="button" class="button subtle" data-media-control="stop" data-media-source="<%= module.id %>">Stop</button></div><label>Seek to (seconds)<span class="button-group"><input type="number" min="0" max="86400" step="0.1" value="0" data-media-seek /><button type="button" class="button subtle" data-media-control="seek" data-media-source="<%= module.id %>">Seek</button></span></label><p class="hint">Audio is invisible in the overlay and is managed here. Enable “Control audio via OBS” on the Lumi Browser Source to mix it separately.</p></div>
<div><button type="button" class="button subtle" data-refresh-source="<%= module.id %>">Restart playback</button><p class="hint">Enable “Control audio via OBS” on the Lumi Browser Source to place this sound in the OBS mixer.</p></div>
</div>
@ -199,20 +199,20 @@
<div><div class="button-group"><button type="button" class="button subtle" data-refresh-source="<%= module.id %>">Refresh now</button><button type="button" class="button subtle" data-check-source="<%= module.id %>">Check website</button></div><small class="hint" data-source-health="<%= module.id %>"></small></div>
</div>
<details class="field full overlay-transform-disclosure">
<% if ((module.renderType || module.type) !== "audio") { %><details class="field full overlay-transform-disclosure">
<summary>Position and size</summary>
<fieldset class="overlay-transform-fields" aria-label="Position and size">
<label>X % <input type="number" step="0.1" name="x" value="<%= value(config, "x", 0) %>" /></label><label>Y % <input type="number" step="0.1" name="y" value="<%= value(config, "y", 0) %>" /></label><label>Width % <input type="number" step="0.1" name="width" value="<%= value(config, "width", 100) %>" /></label><label>Height % <input type="number" step="0.1" name="height" value="<%= value(config, "height", 100) %>" /></label><label>Opacity <input type="number" min="0" max="1" step="0.05" name="opacity" value="<%= value(config, "opacity", 1) %>" /></label>
</fieldset>
</details>
</details><% } %>
<div class="form-actions overlay-source-save-row"><button class="button" type="submit" data-source-save>Save source</button><button class="button subtle" type="button" data-source-revert>Undo unsaved changes</button><span class="hint" data-source-preview-status>Saved settings</span></div>
</form>
<% if (module.chat_dock_url) { %><details class="overlay-private-link"><summary>OBS chat dock</summary><p class="hint">Add this private page as a <strong>Custom Browser Dock</strong> in OBS. It uses this chat source's saved filters and appearance, fills any dock size, scrolls like a webpage, and does not animate messages.</p><div class="overlay-url-row"><input value="<%= module.chat_dock_url %>" readonly aria-label="Private OBS chat dock link" /><button type="button" class="button subtle" data-copy-value="<%= module.chat_dock_url %>">Copy dock link</button></div><div class="overlay-list-actions"><a class="button subtle" href="<%= module.chat_dock_url %>" target="_blank" rel="noreferrer">Open chat dock</a></div></details><% } %>
<div class="overlay-module-actions"><button type="button" class="button subtle" data-select-source="<%= module.id %>">Select on canvas</button><form method="post" action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/duplicate" class="inline-form"><button class="button subtle" type="submit">Copy source</button></form><button type="button" class="button danger" data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete source" data-confirm-text="Delete this source?" data-confirm-label="Delete source">Delete source</button></div>
<div class="overlay-module-actions"><% if ((module.renderType || module.type) !== "audio") { %><button type="button" class="button subtle" data-select-source="<%= module.id %>">Select on canvas</button><% } %><form method="post" action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/duplicate" class="inline-form"><button class="button subtle" type="submit">Copy source</button></form><button type="button" class="button danger" data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete source" data-confirm-text="Delete this source?" data-confirm-label="Delete source">Delete source</button></div>
</details>
<% }) %>
<% if (scene.modules.length > 1) { %><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/modules/reorder" class="overlay-order-form" data-reorder-form><div class="field full"><label>Layer order <button type="button" class="overlay-help" data-tooltip="Sources later in the list appear in front of earlier sources.">?</button></label><div class="overlay-reorder-list" data-reorder-list><% scene.modules.forEach((module) => { %><button type="button" class="button subtle" data-reorder-id="<%= module.id %>"><%= module.name %></button><% }) %></div><input type="hidden" name="ids" data-reorder-value /></div><div class="form-actions"><button class="button subtle" type="submit">Save layer order</button></div></form><% } %>
<% const visualModules = scene.modules.filter((module) => (module.renderType || module.type) !== "audio"); if (visualModules.length > 1) { %><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/modules/reorder" class="overlay-order-form" data-reorder-form><div class="field full"><label>Layer order <button type="button" class="overlay-help" data-tooltip="Sources later in the list appear in front of earlier sources. Audio is managed separately and has no visual layer.">?</button></label><div class="overlay-reorder-list" data-reorder-list><% visualModules.forEach((module) => { %><button type="button" class="button subtle" data-reorder-id="<%= module.id %>"><%= module.name %></button><% }) %></div><input type="hidden" name="ids" data-reorder-value /></div><div class="form-actions"><button class="button subtle" type="submit">Save layer order</button></div></form><% } %>
<details class="overlay-add-source">
<summary>Add a source</summary>

View File

@ -0,0 +1,49 @@
<%- include("partials/layout-top", {
title,
pageWidth: "wide",
pageId: "stream-testing",
extraStyles: ["/stream-testing.css"],
extraScripts: ["/admin/stream-testing/hls.js", "/stream-testing.js"]
}) %>
<main data-stream-testing>
<section class="card">
<%- include("partials/page-header", {
eyebrow: "Admin-only preview",
pageTitle: "Stream testing",
description: "Inspect the real OBS output through Lumi without publishing it to a normal streaming destination."
}) %>
<div class="callout warning"><strong>Never use this while publicly live.</strong><p>Companion refuses to redirect an active stream. The test uses an expiring destination and restores the exact saved OBS service when it ends.</p></div>
<div class="stream-test-summary" data-stream-summary aria-live="polite"></div>
</section>
<section class="stream-test-layout">
<div class="card stream-test-player-card">
<div class="section-heading"><div><span class="eyebrow">Private receiver</span><h2>Live output</h2></div><span class="status-pill neutral" data-stream-state>Idle</span></div>
<div class="stream-test-player-shell">
<video data-stream-player controls playsinline crossorigin="use-credentials"><track data-stream-captions kind="captions" srclang="en" label="Lumi captions" default /></video>
<div class="empty-state" data-stream-empty>Start Stream testing from Lumi Companion on the streaming computer.</div>
</div>
<div class="inline-actions">
<label class="field compact"><span>Quality</span><select data-stream-quality disabled><option value="-1">Automatic</option></select></label>
<button class="button subtle" type="button" data-stream-fullscreen disabled>Fullscreen</button>
<button class="button subtle" type="button" data-stream-pattern>Run test pattern</button>
<button class="button danger" type="button" data-stream-stop disabled>End test</button>
</div>
</div>
<aside class="card">
<div class="section-heading"><div><span class="eyebrow">Real-time facts</span><h2>Diagnostics</h2></div></div>
<div class="stream-test-metrics" data-stream-metrics></div>
<div data-stream-warnings></div>
</aside>
</section>
<section class="card">
<div class="section-heading"><div><span class="eyebrow">Recovery trail</span><h2>Timeline</h2></div></div>
<ol class="stream-test-timeline" data-stream-timeline></ol>
<details class="lumi-expandable-settings"><summary><span><strong>Advanced receiver details</strong><span class="hint">Encoder, ingest safety, and raw metric values</span></span></summary><pre class="log-details" data-stream-advanced></pre></details>
</section>
</main>
<%- include("partials/layout-bottom") %>

View File

@ -1,17 +1,18 @@
{
"name": "Lumi Core",
"version": "0.2.27",
"version": "0.3.0",
"channel": "stable",
"released_at": "2026-07-24",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"migration_kind": "minor",
"replaces_versions": [
"1.2.0"
],
"migration_notes": "Completes bundled-plugin synchronization automatically after updates performed by the legacy 0.2.25 core-only updater. The synchronization is snapshot-backed, exact-release verified, idempotent, and preserves settings, databases, pairing records, tokens, overlays, uploads, models, secrets, plugin data, and local-only plugins.",
"migration_notes": "Adds private OBS stream testing, crash-safe exact-service restoration, corrected overlay media boundaries, and repeatable Companion update checks. Existing settings, databases, pairing records, stream credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved. Stream-test fragments are ephemeral and removed at session end.",
"rollback_safe": true,
"requirements": [
"Node.js 18 or newer"
"Node.js 18 or newer",
"FFmpeg for the optional private Stream Testing receiver"
],
"versions": [
{
@ -337,6 +338,18 @@
],
"rollback_safe": true,
"migration_notes": "Adds the stable Lumi Companion, server-hosted transcription, Song Overlay, shared paired-device authentication, and synchronized core-plus-bundled-plugin updates. Existing settings, databases, pairing records, tokens, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved."
},
{
"version": "0.2.27",
"channel": "stable",
"released_at": "2026-07-24",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Completes exact-release bundled-plugin synchronization after legacy core-only updates while preserving local data."
}
]
}