diff --git a/.env.example b/.env.example index fe06b61..2ee0c6f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c4579b4..01555d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index a1195f7..76ffa09 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/companion/Lumi.Companion.sln b/companion/Lumi.Companion.sln index 8bcde2d..b9c2e55 100644 --- a/companion/Lumi.Companion.sln +++ b/companion/Lumi.Companion.sln @@ -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 diff --git a/companion/installer/Lumi.Companion.iss b/companion/installer/Lumi.Companion.iss index 365e3ee..a69ba11 100644 --- a/companion/installer/Lumi.Companion.iss +++ b/companion/installer/Lumi.Companion.iss @@ -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. diff --git a/companion/native/obs-bridge/CMakeLists.txt b/companion/native/obs-bridge/CMakeLists.txt index 8107c8f..bba5a52 100644 --- a/companion/native/obs-bridge/CMakeLists.txt +++ b/companion/native/obs-bridge/CMakeLists.txt @@ -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) diff --git a/companion/native/obs-bridge/src/plugin.cpp b/companion/native/obs-bridge/src/plugin.cpp index eef70cb..b5d1665 100644 --- a/companion/native/obs-bridge/src/plugin.cpp +++ b/companion/native/obs-bridge/src/plugin.cpp @@ -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(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(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 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(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)); } diff --git a/companion/plugins/Lumi.Companion.Transcription/ObsBridgePipe.cs b/companion/plugins/Lumi.Companion.Transcription/ObsBridgePipe.cs index 26dfe57..8aa3ccb 100644 --- a/companion/plugins/Lumi.Companion.Transcription/ObsBridgePipe.cs +++ b/companion/plugins/Lumi.Companion.Transcription/ObsBridgePipe.cs @@ -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 AllowedTypes = ["hello", "source_list", "source_state", "selection_state", "obs_state", "health"]; + private static readonly HashSet 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 _onMessage; private readonly Func, Task> _onAudio; private readonly CancellationTokenSource _lifetime = new(); private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly ConcurrentDictionary> _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 RequestAsync(string type, object payload, TimeSpan timeout, CancellationToken cancellationToken = default) + { + var requestId = Guid.NewGuid().ToString(); + var completion = new TaskCompletionSource(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 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(); } } diff --git a/companion/scripts/build-obs-bridge.ps1 b/companion/scripts/build-obs-bridge.ps1 index 99afb69..2a23d62 100644 --- a/companion/scripts/build-obs-bridge.ps1 +++ b/companion/scripts/build-obs-bridge.ps1 @@ -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" diff --git a/companion/scripts/publish-companion.ps1 b/companion/scripts/publish-companion.ps1 index bc773e8..8e95f66 100644 --- a/companion/scripts/publish-companion.ps1 +++ b/companion/scripts/publish-companion.ps1 @@ -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() diff --git a/companion/src/Lumi.Companion.App/CompanionRuntime.cs b/companion/src/Lumi.Companion.App/CompanionRuntime.cs index dc3d298..f68222b 100644 --- a/companion/src/Lumi.Companion.App/CompanionRuntime.cs +++ b/companion/src/Lumi.Companion.App/CompanionRuntime.cs @@ -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? _sessionStartSignal; private TaskCompletionSource? _sessionStopSignal; private TaskCompletionSource? _testFailure; + private TaskCompletionSource? _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 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(); diff --git a/companion/src/Lumi.Companion.App/CompanionState.cs b/companion/src/Lumi.Companion.App/CompanionState.cs index 1f35cd7..043dd56 100644 --- a/companion/src/Lumi.Companion.App/CompanionState.cs +++ b/companion/src/Lumi.Companion.App/CompanionState.cs @@ -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." diff --git a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj index 4fdf415..44aed2c 100644 --- a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj +++ b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj @@ -6,8 +6,8 @@ enable app.manifest Assets\Lumi.Companion.ico - 0.1.0 - 0.1.0.0 + 0.2.0 + 0.2.0.0 diff --git a/companion/src/Lumi.Companion.App/MainWindow.axaml b/companion/src/Lumi.Companion.App/MainWindow.axaml index 502c71e..7e9ecc7 100644 --- a/companion/src/Lumi.Companion.App/MainWindow.axaml +++ b/companion/src/Lumi.Companion.App/MainWindow.axaml @@ -29,7 +29,10 @@ - + <% if ((module.renderType || module.type) !== "audio") { %>
<% } %>
@@ -168,8 +168,7 @@
Sound
-
Player controls
-

Enable “Control audio via OBS” on the Lumi Browser Source to mix this sound separately in OBS.

+

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.

@@ -178,7 +177,8 @@ -
Player controls
+
Sound
+

Audio is invisible in the overlay and is managed here. Enable “Control audio via OBS” on the Lumi Browser Source to mix it separately.

Enable “Control audio via OBS” on the Lumi Browser Source to place this sound in the OBS mixer.

@@ -199,20 +199,20 @@
-
+ <% if ((module.renderType || module.type) !== "audio") { %>
Position and size
-
+
<% } %>
Saved settings
<% if (module.chat_dock_url) { %><% } %> -
+
<% if ((module.renderType || module.type) !== "audio") { %><% } %>
<% }) %> - <% if (scene.modules.length > 1) { %>
<% scene.modules.forEach((module) => { %><% }) %>
<% } %> + <% const visualModules = scene.modules.filter((module) => (module.renderType || module.type) !== "audio"); if (visualModules.length > 1) { %>
<% visualModules.forEach((module) => { %><% }) %>
<% } %>
Add a source diff --git a/src/web/views/admin-stream-testing.ejs b/src/web/views/admin-stream-testing.ejs new file mode 100644 index 0000000..b11254d --- /dev/null +++ b/src/web/views/admin-stream-testing.ejs @@ -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"] +}) %> + +
+
+ <%- 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." + }) %> +
Never use this while publicly live.

Companion refuses to redirect an active stream. The test uses an expiring destination and restores the exact saved OBS service when it ends.

+
+
+ +
+
+
Private receiver

Live output

Idle
+
+ +
Start Stream testing from Lumi Companion on the streaming computer.
+
+
+ + + + +
+
+ + +
+ +
+
Recovery trail

Timeline

+
    +
    Advanced receiver detailsEncoder, ingest safety, and raw metric values
    +
    +
    + +<%- include("partials/layout-bottom") %> diff --git a/update-manifest.json b/update-manifest.json index d9b0d23..dd41e38 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -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." } ] }