release: add private stream testing and media fixes
This commit is contained in:
parent
5ec18ac0c4
commit
dceebce43a
@ -49,3 +49,10 @@ LUMI_OPERATOR_PRIVACY_URL=
|
|||||||
# source checkout. Explicit overrides always win.
|
# source checkout. Explicit overrides always win.
|
||||||
# LUMI_DEV_MODE=true
|
# LUMI_DEV_MODE=true
|
||||||
# LUMI_HOST=127.0.0.1
|
# 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
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
# Lumi changelog
|
# 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@ -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.
|
Use **Admin → Plugins** to install, enable, update, or uninstall plugins.
|
||||||
You can also create a local plugin from the WebUI.
|
You can also create a local plugin from the WebUI.
|
||||||
|
|
||||||
The experimental `experimental-companion` branch includes the independent Lumi
|
Lumi Companion provides the streaming-computer boundary for transcription,
|
||||||
Companion transcription foundation. Its current scope, trust boundaries, setup,
|
media capture, and private stream testing. Its trust boundaries and setup are documented in
|
||||||
and unverified target-machine work are documented in
|
|
||||||
[`docs/lumi-companion-transcription.md`](docs/lumi-companion-transcription.md).
|
[`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
|
## Updates and recovery
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Transcriptio
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.SongOverlay", "plugins\Lumi.Companion.SongOverlay\Lumi.Companion.SongOverlay.csproj", "{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.SongOverlay", "plugins\Lumi.Companion.SongOverlay\Lumi.Companion.SongOverlay.csproj", "{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|x86.ActiveCfg = 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
|
{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.ActiveCfg = Debug|Any CPU
|
||||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|Any CPU.Build.0 = 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
|
{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|x64.Build.0 = Release|Any CPU
|
||||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@ -127,5 +143,6 @@ Global
|
|||||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{2F0041FD-FD06-4312-BF1E-180F0201EB97} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
{49C19CA8-4669-4C33-B3A8-9CB934CD1171} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2} = {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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#ifndef AppVersion
|
#ifndef AppVersion
|
||||||
#define AppVersion "0.1.0"
|
#define AppVersion "0.2.0"
|
||||||
#endif
|
#endif
|
||||||
#ifndef SourceRoot
|
#ifndef SourceRoot
|
||||||
#error SourceRoot must point at the self-contained Companion publish directory.
|
#error SourceRoot must point at the self-contained Companion publish directory.
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# Copyright (c) 2026 OokamiKunTV
|
# Copyright (c) 2026 OokamiKunTV
|
||||||
|
|
||||||
cmake_minimum_required(VERSION 3.28)
|
cmake_minimum_required(VERSION 3.28)
|
||||||
project(lumi-obs-bridge VERSION 0.1.0 LANGUAGES CXX)
|
project(lumi-obs-bridge VERSION 0.2.0 LANGUAGES CXX)
|
||||||
set(LUMI_BRIDGE_VERSION "0.1.0-development" CACHE STRING "Lumi Companion bridge release version")
|
set(LUMI_BRIDGE_VERSION "0.2.0-development" CACHE STRING "Lumi Companion bridge release version")
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
|
|||||||
@ -67,6 +67,8 @@ static std::atomic_bool obs_state_dirty{true};
|
|||||||
static std::atomic_uint32_t audio_sequence{0};
|
static std::atomic_uint32_t audio_sequence{0};
|
||||||
static obs_source_t *captured_source = nullptr;
|
static obs_source_t *captured_source = nullptr;
|
||||||
static std::mutex captured_source_mutex;
|
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)
|
static std::string wide_to_utf8(const std::wstring &value)
|
||||||
{
|
{
|
||||||
@ -324,8 +326,120 @@ static json source_list_message()
|
|||||||
|
|
||||||
static json obs_state_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()},
|
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)
|
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 text = payload.value("stable_text", "");
|
||||||
const auto duration = payload.value("display_seconds", 2.0);
|
const auto duration = payload.value("display_seconds", 2.0);
|
||||||
output_caption(text, duration);
|
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;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
@ -399,6 +569,7 @@ static void run_pipe_worker()
|
|||||||
source_list_dirty.store(true, std::memory_order_release);
|
source_list_dirty.store(true, std::memory_order_release);
|
||||||
obs_state_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 health_sent = std::chrono::steady_clock::now();
|
||||||
|
auto stream_metrics_sent = std::chrono::steady_clock::now();
|
||||||
while (connected && !stopping.load(std::memory_order_acquire)) {
|
while (connected && !stopping.load(std::memory_order_acquire)) {
|
||||||
connected = read_available_command(pipe);
|
connected = read_available_command(pipe);
|
||||||
if (connected && source_list_dirty.exchange(false, std::memory_order_acq_rel)) connected = write_json(pipe, source_list_message());
|
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()}});
|
{"status", "healthy"}, {"audio_frames_dropped", audio_queue.dropped()}});
|
||||||
health_sent = now;
|
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);
|
std::unique_lock lock(worker_signal_mutex);
|
||||||
worker_signal.wait_for(lock, std::chrono::milliseconds(10));
|
worker_signal.wait_for(lock, std::chrono::milliseconds(10));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.IO.Pipes;
|
using System.IO.Pipes;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Lumi.Companion.Protocol;
|
using Lumi.Companion.Protocol;
|
||||||
|
|
||||||
@ -7,12 +8,13 @@ namespace Lumi.Companion.Transcription;
|
|||||||
|
|
||||||
public sealed class ObsBridgePipe : IAsyncDisposable
|
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 string _pipeName;
|
||||||
private readonly Func<JsonElement, Task> _onMessage;
|
private readonly Func<JsonElement, Task> _onMessage;
|
||||||
private readonly Func<ReadOnlyMemory<byte>, Task> _onAudio;
|
private readonly Func<ReadOnlyMemory<byte>, Task> _onAudio;
|
||||||
private readonly CancellationTokenSource _lifetime = new();
|
private readonly CancellationTokenSource _lifetime = new();
|
||||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
private readonly ConcurrentDictionary<string, TaskCompletionSource<JsonElement>> _requests = new();
|
||||||
private Task? _loop;
|
private Task? _loop;
|
||||||
private Stream? _connection;
|
private Stream? _connection;
|
||||||
|
|
||||||
@ -55,7 +57,10 @@ public sealed class ObsBridgePipe : IAsyncDisposable
|
|||||||
using var json = JsonDocument.Parse(message);
|
using var json = JsonDocument.Parse(message);
|
||||||
var type = json.RootElement.TryGetProperty("type", out var property) ? property.GetString() : null;
|
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.");
|
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)
|
public static async Task WriteAsync(Stream stream, object message, CancellationToken cancellationToken)
|
||||||
@ -79,6 +84,25 @@ public sealed class ObsBridgePipe : IAsyncDisposable
|
|||||||
catch (IOException) { return false; }
|
catch (IOException) { return false; }
|
||||||
finally { _writeLock.Release(); }
|
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)
|
private static async Task ReadExactlyAsync(Stream stream, Memory<byte> buffer, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var read = 0;
|
var read = 0;
|
||||||
@ -89,5 +113,5 @@ public sealed class ObsBridgePipe : IAsyncDisposable
|
|||||||
read += count;
|
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(); }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
param(
|
param(
|
||||||
[string]$ObsVersion = "31.1.1",
|
[string]$ObsVersion = "31.1.1",
|
||||||
[string]$BridgeVersion = "0.1.0",
|
[string]$BridgeVersion = "0.2.0",
|
||||||
[string]$CacheRoot = "$env:LOCALAPPDATA\LumiCompanionBuild"
|
[string]$CacheRoot = "$env:LOCALAPPDATA\LumiCompanionBuild"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -17,6 +17,12 @@ $sourceRoot = Join-Path $sourceParent "obs-studio-$ObsVersion"
|
|||||||
$importRoot = Join-Path $obsRoot "imports"
|
$importRoot = Join-Path $obsRoot "imports"
|
||||||
$buildRoot = Join-Path $obsRoot "bridge-build"
|
$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"
|
$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"
|
$sourceUrl = "https://github.com/obsproject/obs-studio/archive/refs/tags/$ObsVersion.zip"
|
||||||
$runtimeSha256 = "9d8dceb77acd8af04af23f877061f63c9bef78ca73d2093d0ccba1bb9104173f"
|
$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] }
|
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
|
@("LIBRARY $Name", "EXPORTS") + ($exports | ForEach-Object { " $_" }) | Set-Content -Encoding Ascii $definition
|
||||||
& $LibExe /nologo /machine:x64 "/def:$definition" "/out:$(Join-Path $importRoot "$Name.lib")"
|
$exitCode = Invoke-Native $LibExe @("/nologo", "/machine:x64", "/def:$definition", "/out:$(Join-Path $importRoot "$Name.lib")")
|
||||||
if ($LASTEXITCODE) { throw "Could not create the $Name import library." }
|
if ($exitCode) { throw "Could not create the $Name import library." }
|
||||||
}
|
}
|
||||||
|
|
||||||
Get-VerifiedArchive $runtimeUrl $runtimeArchive $runtimeSha256
|
Get-VerifiedArchive $runtimeUrl $runtimeArchive $runtimeSha256
|
||||||
@ -88,10 +94,8 @@ $configureArguments = @(
|
|||||||
"-DOBS_IMPORT_DIR=$importRoot",
|
"-DOBS_IMPORT_DIR=$importRoot",
|
||||||
"-DLUMI_BRIDGE_VERSION=$BridgeVersion"
|
"-DLUMI_BRIDGE_VERSION=$BridgeVersion"
|
||||||
)
|
)
|
||||||
& $cmake @configureArguments
|
if ((Invoke-Native $cmake $configureArguments)) { throw "OBS bridge configuration failed." }
|
||||||
if ($LASTEXITCODE) { throw "OBS bridge configuration failed." }
|
if ((Invoke-Native $cmake @("--build", $buildRoot, "--config", "Release"))) { throw "OBS bridge build failed." }
|
||||||
& $cmake --build $buildRoot --config Release
|
|
||||||
if ($LASTEXITCODE) { throw "OBS bridge build failed." }
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Force -Path $componentRoot | Out-Null
|
New-Item -ItemType Directory -Force -Path $componentRoot | Out-Null
|
||||||
$bridgeDll = Join-Path $buildRoot "Release\lumi-obs-bridge.dll"
|
$bridgeDll = Join-Path $buildRoot "Release\lumi-obs-bridge.dll"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
param(
|
param(
|
||||||
[string]$Version = "0.1.0",
|
[string]$Version = "0.2.0",
|
||||||
[string]$BridgeVersion = "0.1.0",
|
[string]$BridgeVersion = "0.2.0",
|
||||||
[string]$ObsVersion = "31.1.1"
|
[string]$ObsVersion = "31.1.1"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -16,6 +16,12 @@ $installer = Join-Path $outputRoot "Lumi.Companion-Setup.exe"
|
|||||||
$legalSourceRoot = Join-Path $repoRoot "companion\legal"
|
$legalSourceRoot = Join-Path $repoRoot "companion\legal"
|
||||||
$publishLegalRoot = Join-Path $publishRoot "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 {
|
function Compress-ArchiveWithRetry {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)][string]$Path,
|
[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 }
|
$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,
|
$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")
|
"-p:PublishSingleFile=true", "-p:IncludeNativeLibrariesForSelfExtract=true", "-p:DebugType=None", "-p:Version=$Version")
|
||||||
& $dotnet @publishArguments
|
if ((Invoke-Native $dotnet $publishArguments)) { throw "Companion publish failed." }
|
||||||
if ($LASTEXITCODE) { throw "Companion publish failed." }
|
|
||||||
|
|
||||||
$bridgeDiagnostic = Join-Path $outputRoot "obs-bridge-package-diagnostic.json"
|
$bridgeDiagnostic = Join-Path $outputRoot "obs-bridge-package-diagnostic.json"
|
||||||
Remove-Item $bridgeDiagnostic -Force -ErrorAction SilentlyContinue
|
Remove-Item $bridgeDiagnostic -Force -ErrorAction SilentlyContinue
|
||||||
& (Join-Path $publishRoot "Lumi.Companion.App.exe") --diagnose-obs-bridge $bridgeDiagnostic
|
if ((Invoke-Native (Join-Path $publishRoot "Lumi.Companion.App.exe") @("--diagnose-obs-bridge", $bridgeDiagnostic))) {
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
$detail = if (Test-Path $bridgeDiagnostic) { Get-Content $bridgeDiagnostic -Raw } else { "No diagnostic result was written." }
|
$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"
|
throw "Published OBS integration payload failed its executable-level check: $detail"
|
||||||
}
|
}
|
||||||
@ -123,8 +127,7 @@ $compileArguments = @(
|
|||||||
"/DOutputRoot=$outputRoot",
|
"/DOutputRoot=$outputRoot",
|
||||||
(Join-Path $repoRoot "companion\installer\Lumi.Companion.iss")
|
(Join-Path $repoRoot "companion\installer\Lumi.Companion.iss")
|
||||||
)
|
)
|
||||||
& $iscc @compileArguments
|
if ((Invoke-Native $iscc $compileArguments) -or -not (Test-Path $installer)) { throw "Companion installer compilation failed." }
|
||||||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $installer)) { throw "Companion installer compilation failed." }
|
|
||||||
|
|
||||||
$file = Get-Item $archive
|
$file = Get-Item $archive
|
||||||
$sha = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant()
|
$sha = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
|||||||
@ -19,6 +19,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
private readonly CompanionPaths _paths;
|
private readonly CompanionPaths _paths;
|
||||||
private readonly CompanionSettingsStore _settings;
|
private readonly CompanionSettingsStore _settings;
|
||||||
private readonly SecureCredentialStore _credentials;
|
private readonly SecureCredentialStore _credentials;
|
||||||
|
private readonly StreamTestRecoveryStore _streamTestRecovery;
|
||||||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
|
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||||
private readonly UpdateService _updates;
|
private readonly UpdateService _updates;
|
||||||
private readonly ObsBridgeManager _bridgeManager = new();
|
private readonly ObsBridgeManager _bridgeManager = new();
|
||||||
@ -29,6 +30,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
private TaskCompletionSource<bool>? _sessionStartSignal;
|
private TaskCompletionSource<bool>? _sessionStartSignal;
|
||||||
private TaskCompletionSource<bool>? _sessionStopSignal;
|
private TaskCompletionSource<bool>? _sessionStopSignal;
|
||||||
private TaskCompletionSource<string>? _testFailure;
|
private TaskCompletionSource<string>? _testFailure;
|
||||||
|
private TaskCompletionSource<JsonElement>? _streamTestSessionSignal;
|
||||||
private TaskCompletionSource<(string SourceUuid, bool Attached)>? _bridgeSelectionSignal;
|
private TaskCompletionSource<(string SourceUuid, bool Attached)>? _bridgeSelectionSignal;
|
||||||
private bool? _bridgeSelectionAttached;
|
private bool? _bridgeSelectionAttached;
|
||||||
private readonly SemaphoreSlim _bridgeSelectionGate = new(1, 1);
|
private readonly SemaphoreSlim _bridgeSelectionGate = new(1, 1);
|
||||||
@ -39,6 +41,14 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
private int _benchmarkStopping;
|
private int _benchmarkStopping;
|
||||||
private long _lastVoiceMeterAt;
|
private long _lastVoiceMeterAt;
|
||||||
private bool _disposed;
|
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 CompanionUpdate? _availableUpdate;
|
||||||
private bool _serverReady;
|
private bool _serverReady;
|
||||||
private string? _serverReadinessFingerprint;
|
private string? _serverReadinessFingerprint;
|
||||||
@ -49,6 +59,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
_paths = paths;
|
_paths = paths;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_credentials = new SecureCredentialStore(paths.Root);
|
_credentials = new SecureCredentialStore(paths.Root);
|
||||||
|
_streamTestRecovery = new StreamTestRecoveryStore(paths.Root);
|
||||||
_updates = new UpdateService(_http, paths);
|
_updates = new UpdateService(_http, paths);
|
||||||
State = new CompanionState();
|
State = new CompanionState();
|
||||||
TestStages = CreateInitialTestStages();
|
TestStages = CreateInitialTestStages();
|
||||||
@ -86,6 +97,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
await _settings.LoadAsync();
|
await _settings.LoadAsync();
|
||||||
StartObsBridgeBoundary();
|
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);
|
_ = RunUpdateChecksAsync(_maintenanceLifetime.Token);
|
||||||
ApplyAutoStart(_settings.Current.AutoStartWithWindows);
|
ApplyAutoStart(_settings.Current.AutoStartWithWindows);
|
||||||
DeviceCredential? credential;
|
DeviceCredential? credential;
|
||||||
@ -161,6 +174,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_testFailure?.TrySetResult(error is null ? "The Lumi connection closed during the test." : $"The Lumi connection closed during the test: {Friendly(error)}");
|
_testFailure?.TrySetResult(error is null ? "The Lumi connection closed during the test." : $"The Lumi connection closed during the test: {Friendly(error)}");
|
||||||
_benchmarkLifetime?.Cancel();
|
_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 });
|
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.");
|
_ = 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)
|
public async Task StartBenchmarkAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (State.BenchmarkRunning || State.TestRunning) return;
|
if (State.BenchmarkRunning || State.TestRunning) return;
|
||||||
@ -375,30 +527,43 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default)
|
public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var credential = _credentials.Load();
|
await _updateChecks.RunAsync(async operationToken =>
|
||||||
if (credential is null) { SetState(State with { UpdateDetail = "Pair this computer before checking for updates." }); return; }
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
SetState(State with { UpdateDetail = "Checking for Companion updates…" });
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(operationToken);
|
||||||
_availableUpdate = await _updates.CheckAsync(credential, Version, cancellationToken);
|
timeout.CancelAfter(TimeSpan.FromSeconds(30));
|
||||||
var updateDetail = _availableUpdate is null
|
SetState(State with { UpdateCheckRunning = true, UpdateDetail = "Checking for Companion updates…" });
|
||||||
? $"Lumi Companion {Version} is current."
|
try
|
||||||
: _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,
|
var credential = _credentials.Load();
|
||||||
AvailableVersion = _availableUpdate?.Version,
|
if (credential is null)
|
||||||
UpdateDetail = updateDetail
|
{
|
||||||
});
|
SetState(State with { UpdateDetail = "Pair this computer before checking for updates." });
|
||||||
await WriteLogAsync(_availableUpdate is null ? "update_current" : "update_available", updateDetail);
|
return;
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
_availableUpdate = await _updates.CheckAsync(credential, Version, timeout.Token);
|
||||||
{
|
var updateDetail = _availableUpdate is null
|
||||||
SetState(State with { UpdateDetail = $"Update check could not finish. {Friendly(error)}" });
|
? $"Lumi Companion {Version} is current."
|
||||||
await WriteLogAsync("update_check_failed", error.Message);
|
: _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)
|
public async Task ApplyUpdateAsync(CancellationToken cancellationToken = default)
|
||||||
@ -499,6 +664,13 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
|
|
||||||
private Task OnServerMessageAsync(ServerEnvelope message)
|
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")
|
if (message.Type == "status" && message.Payload.TryGetProperty("kind", out var statusKind) && statusKind.GetString() == "readiness")
|
||||||
{
|
{
|
||||||
_serverReady = ReadBoolean(message.Payload, "ready");
|
_serverReady = ReadBoolean(message.Payload, "ready");
|
||||||
@ -534,6 +706,21 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
CaptionReceived?.Invoke(text, simulated);
|
CaptionReceived?.Invoke(text, simulated);
|
||||||
if (!simulated) _ = _obsBridge?.SendAsync(new { type = "caption", payload = message.Payload });
|
if (!simulated) _ = _obsBridge?.SendAsync(new { type = "caption", payload = message.Payload });
|
||||||
if (State.BenchmarkRunning) UpdateBenchmarkCaption(message.Payload, text, final);
|
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")
|
if (message.Type == "benchmark_complete")
|
||||||
@ -544,6 +731,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
if (message.Type == "error")
|
if (message.Type == "error")
|
||||||
{
|
{
|
||||||
var serverMessage = message.Payload.TryGetProperty("message", out var value) ? value.GetString() : "Lumi reported an 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.");
|
_testFailure?.TrySetResult(serverMessage ?? "Lumi reported an inference error.");
|
||||||
_benchmarkLifetime?.Cancel();
|
_benchmarkLifetime?.Cancel();
|
||||||
SetState(State with { Health = TrayHealth.Degraded, Detail = serverMessage ?? "Lumi reported an error.", BenchmarkRunning = false, BenchmarkDetail = serverMessage ?? "Lumi reported an inference error." });
|
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 });
|
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();
|
RefreshPathReadiness();
|
||||||
_ = WriteLogAsync("obs_connection", connected ? "OBS bridge connected." : "OBS bridge disconnected.");
|
_ = 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());
|
_ = SendRuntimeStateAsync(_lifetimeToken());
|
||||||
};
|
};
|
||||||
_obsBridge.Start();
|
_obsBridge.Start();
|
||||||
@ -576,7 +769,15 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
var streaming = ReadBoolean(message, "streaming");
|
var streaming = ReadBoolean(message, "streaming");
|
||||||
var recording = ReadBoolean(message, "recording");
|
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();
|
RefreshPathReadiness();
|
||||||
await SendRuntimeStateAsync(_lifetimeToken(), ReadString(message, "version"));
|
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")));
|
_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)
|
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 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 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 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 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;
|
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()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_disposed) return;
|
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 { }
|
if (State.BenchmarkRunning) try { await StopBenchmarkAsync("disconnect", CancellationToken.None); } catch { }
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_maintenanceLifetime.Cancel();
|
_maintenanceLifetime.Cancel();
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
namespace Lumi.Companion.App;
|
namespace Lumi.Companion.App;
|
||||||
|
|
||||||
public enum TrayHealth { Ready, Operating, Degraded, Failed }
|
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 enum TestStageState { Waiting, Running, Passed, Blocked, Failed }
|
||||||
|
|
||||||
public sealed record CompanionState(
|
public sealed record CompanionState(
|
||||||
@ -13,15 +13,24 @@ public sealed record CompanionState(
|
|||||||
bool ObsRecording = false,
|
bool ObsRecording = false,
|
||||||
bool TestRunning = false,
|
bool TestRunning = false,
|
||||||
bool BenchmarkRunning = false,
|
bool BenchmarkRunning = false,
|
||||||
|
bool StreamTestRunning = false,
|
||||||
|
bool StreamTestRecoveryRequired = false,
|
||||||
TrayHealth Health = TrayHealth.Degraded,
|
TrayHealth Health = TrayHealth.Degraded,
|
||||||
string Detail = "Pair Lumi Companion to get started.",
|
string Detail = "Pair Lumi Companion to get started.",
|
||||||
string? DeviceName = null,
|
string? DeviceName = null,
|
||||||
string? Host = null,
|
string? Host = null,
|
||||||
DateTimeOffset? LastConnectedAt = null,
|
DateTimeOffset? LastConnectedAt = null,
|
||||||
bool UpdateAvailable = false,
|
bool UpdateAvailable = false,
|
||||||
|
bool UpdateCheckRunning = false,
|
||||||
string? AvailableVersion = null,
|
string? AvailableVersion = null,
|
||||||
string UpdateDetail = "Checking for updates…",
|
string UpdateDetail = "Checking for updates…",
|
||||||
string BenchmarkDetail = "Start a dedicated test, speak naturally, then end it when you have enough material.",
|
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,
|
bool PathTestValid = false,
|
||||||
string PathTestDetail = "Run once after setup or a relevant configuration change.",
|
string PathTestDetail = "Run once after setup or a relevant configuration change.",
|
||||||
bool ObsBridgeRepairNeeded = false,
|
bool ObsBridgeRepairNeeded = false,
|
||||||
@ -39,8 +48,10 @@ public sealed record CompanionState(
|
|||||||
_ => "Partially ready"
|
_ => "Partially ready"
|
||||||
};
|
};
|
||||||
|
|
||||||
public bool RequiresQuitConfirmation => ObsStreaming || ObsRecording || BenchmarkRunning;
|
public bool RequiresQuitConfirmation => ObsStreaming || ObsRecording || BenchmarkRunning || StreamTestRunning || StreamTestRecoveryRequired;
|
||||||
public string QuitWarning => BenchmarkRunning
|
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."
|
? "A transcription accuracy and latency test is running. Quitting will safely end and mark the test as aborted."
|
||||||
: ObsStreaming
|
: ObsStreaming
|
||||||
? "OBS is streaming. Quitting Lumi Companion will stop transcription and closed captions, but it will not stop the OBS stream."
|
? "OBS is streaming. Quitting Lumi Companion will stop transcription and closed captions, but it will not stop the OBS stream."
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
||||||
<Version>0.1.0</Version>
|
<Version>0.2.0</Version>
|
||||||
<AssemblyVersion>0.1.0.0</AssemblyVersion>
|
<AssemblyVersion>0.2.0.0</AssemblyVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
||||||
|
|||||||
@ -29,7 +29,10 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid Grid.Row="2" RowDefinitions="Auto,*,Auto">
|
<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">
|
<ScrollViewer Grid.Row="1" Margin="0,14,0,14" VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
@ -112,6 +115,46 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</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">
|
<ScrollViewer x:Name="TranscriptionPage" IsVisible="False">
|
||||||
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="25">
|
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="25">
|
||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
|
|||||||
@ -79,6 +79,7 @@ public partial class MainWindow : Window
|
|||||||
PluginNavigation.Children.Add(expander);
|
PluginNavigation.Children.Add(expander);
|
||||||
}
|
}
|
||||||
_navigationButtons[CompanionPage.Overview] = OverviewNav;
|
_navigationButtons[CompanionPage.Overview] = OverviewNav;
|
||||||
|
_navigationButtons[CompanionPage.StreamTesting] = StreamTestingNav;
|
||||||
_navigationButtons[CompanionPage.Connection] = ConnectionNav;
|
_navigationButtons[CompanionPage.Connection] = ConnectionNav;
|
||||||
_navigationButtons[CompanionPage.Logs] = LogsNav;
|
_navigationButtons[CompanionPage.Logs] = LogsNav;
|
||||||
_navigationButtons[CompanionPage.Settings] = SettingsNav;
|
_navigationButtons[CompanionPage.Settings] = SettingsNav;
|
||||||
@ -87,6 +88,7 @@ public partial class MainWindow : Window
|
|||||||
private void WireActions()
|
private void WireActions()
|
||||||
{
|
{
|
||||||
OverviewNav.Click += OnNavigate;
|
OverviewNav.Click += OnNavigate;
|
||||||
|
StreamTestingNav.Click += OnNavigate;
|
||||||
foreach (var button in TechnicalNavigation.Children.OfType<Button>()) button.Click += OnNavigate;
|
foreach (var button in TechnicalNavigation.Children.OfType<Button>()) button.Click += OnNavigate;
|
||||||
NextActionButton.Click += OnNextAction;
|
NextActionButton.Click += OnNextAction;
|
||||||
PairButton.Click += async (_, _) => await ChoosePairingPackageAsync();
|
PairButton.Click += async (_, _) => await ChoosePairingPackageAsync();
|
||||||
@ -94,6 +96,9 @@ public partial class MainWindow : Window
|
|||||||
OpenWebButton.Click += (_, _) => _runtime.OpenLumiWebUi();
|
OpenWebButton.Click += (_, _) => _runtime.OpenLumiWebUi();
|
||||||
OpenLogsButton.Click += (_, _) => _runtime.OpenLogsDirectory();
|
OpenLogsButton.Click += (_, _) => _runtime.OpenLogsDirectory();
|
||||||
RunTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RunTestAsync(), RunTestButton);
|
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);
|
StartBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StartBenchmarkAsync(), StartBenchmarkButton);
|
||||||
StopBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StopBenchmarkAsync(), StopBenchmarkButton);
|
StopBenchmarkButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.StopBenchmarkAsync(), StopBenchmarkButton);
|
||||||
ForgetButton.Click += async (_, _) => await ForgetDeviceAsync();
|
ForgetButton.Click += async (_, _) => await ForgetDeviceAsync();
|
||||||
@ -127,6 +132,7 @@ public partial class MainWindow : Window
|
|||||||
var pages = new Dictionary<CompanionPage, Control>
|
var pages = new Dictionary<CompanionPage, Control>
|
||||||
{
|
{
|
||||||
[CompanionPage.Overview] = OverviewPage,
|
[CompanionPage.Overview] = OverviewPage,
|
||||||
|
[CompanionPage.StreamTesting] = StreamTestingPage,
|
||||||
[CompanionPage.Transcription] = TranscriptionPage,
|
[CompanionPage.Transcription] = TranscriptionPage,
|
||||||
[CompanionPage.Test] = TestPage,
|
[CompanionPage.Test] = TestPage,
|
||||||
[CompanionPage.SongOverlay] = SongOverlayPage,
|
[CompanionPage.SongOverlay] = SongOverlayPage,
|
||||||
@ -300,9 +306,19 @@ public partial class MainWindow : Window
|
|||||||
StartBenchmarkButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
|
StartBenchmarkButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
|
||||||
StopBenchmarkButton.IsEnabled = state.BenchmarkRunning;
|
StopBenchmarkButton.IsEnabled = state.BenchmarkRunning;
|
||||||
BenchmarkStatusText.Text = state.BenchmarkDetail;
|
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;
|
UpdatePanel.IsVisible = state.UpdateAvailable;
|
||||||
UpdateTitle.Text = state.UpdateAvailable ? $"Lumi Companion {state.AvailableVersion} is available" : "Lumi Companion is current";
|
UpdateTitle.Text = state.UpdateAvailable ? $"Lumi Companion {state.AvailableVersion} is available" : "Lumi Companion is current";
|
||||||
UpdateDetail.Text = state.UpdateDetail;
|
UpdateDetail.Text = state.UpdateDetail;
|
||||||
|
CheckUpdateButton.IsEnabled = !state.UpdateCheckRunning;
|
||||||
|
CheckUpdateButton.Content = state.UpdateCheckRunning ? "Checking…" : "Check now";
|
||||||
ApplyUpdateButton.IsEnabled = state.UpdateAvailable && !state.ObsStreaming && !state.ObsRecording;
|
ApplyUpdateButton.IsEnabled = state.UpdateAvailable && !state.ObsStreaming && !state.ObsRecording;
|
||||||
CurrentVersionText.Text = CompanionRuntime.DisplayVersion;
|
CurrentVersionText.Text = CompanionRuntime.DisplayVersion;
|
||||||
UpdateStatusText.Text = state.UpdateDetail;
|
UpdateStatusText.Text = state.UpdateDetail;
|
||||||
|
|||||||
46
companion/src/Lumi.Companion.Core/SingleFlightOperation.cs
Normal file
46
companion/src/Lumi.Companion.Core/SingleFlightOperation.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
companion/src/Lumi.Companion.Core/StreamTestRecoveryStore.cs
Normal file
47
companion/src/Lumi.Companion.Core/StreamTestRecoveryStore.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
42
companion/tests/Lumi.Companion.Core.Tests/Program.cs
Normal file
42
companion/tests/Lumi.Companion.Core.Tests/Program.cs
Normal 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.");
|
||||||
@ -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
|
flow. Browser Source pages use a separate transparent document with no Lumi
|
||||||
navigation or authenticated website data.
|
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
|
## Editing an overlay
|
||||||
|
|
||||||
The editor uses the same basic model as OBS: scenes contain ordered sources, and
|
The editor uses the same basic model as OBS: scenes contain ordered sources, and
|
||||||
|
|||||||
94
docs/stream-testing.md
Normal file
94
docs/stream-testing.md
Normal 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
|
||||||
|
```
|
||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Lumi is the core web UI and bot runtime.
|
Lumi is the core web UI and bot runtime.
|
||||||
## Runtime
|
## Runtime
|
||||||
Package: lumi-bot
|
Package: lumi-bot
|
||||||
Version: 0.2.27
|
Version: 0.3.0
|
||||||
## Routes
|
## Routes
|
||||||
- POST /api/diagnostics/v1/run
|
- POST /api/diagnostics/v1/run
|
||||||
- GET /api/events
|
- GET /api/events
|
||||||
|
|||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
||||||
## Metadata
|
## Metadata
|
||||||
Plugin ID: lumi_transcription
|
Plugin ID: lumi_transcription
|
||||||
Version: 0.1.0
|
Version: 0.2.0
|
||||||
Default state: enabled
|
Default state: enabled
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- /plugins/lumi_transcription
|
- /plugins/lumi_transcription
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.27",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.27",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.6.0",
|
"adm-zip": "^0.6.0",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
@ -15,6 +15,7 @@
|
|||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"tmi.js": "^1.8.5",
|
"tmi.js": "^1.8.5",
|
||||||
"ws": "^8.21.1"
|
"ws": "^8.21.1"
|
||||||
@ -965,6 +966,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.27",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -16,6 +16,7 @@
|
|||||||
"verify:web-auth": "node scripts/verify-web-auth.js",
|
"verify:web-auth": "node scripts/verify-web-auth.js",
|
||||||
"verify:destructive-actions": "node scripts/verify-destructive-actions.js",
|
"verify:destructive-actions": "node scripts/verify-destructive-actions.js",
|
||||||
"verify:overlays": "node scripts/verify-overlays.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",
|
"verify:ux": "node scripts/verify-ux-foundation.js",
|
||||||
"test:ui": "playwright test",
|
"test:ui": "playwright test",
|
||||||
"test:ui:update": "playwright test --update-snapshots",
|
"test:ui:update": "playwright test --update-snapshots",
|
||||||
@ -36,6 +37,7 @@
|
|||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"tmi.js": "^1.8.5",
|
"tmi.js": "^1.8.5",
|
||||||
"ws": "^8.21.1"
|
"ws": "^8.21.1"
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
# Lumi Transcription changelog
|
# 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
|
## 0.1.0
|
||||||
|
|
||||||
- Added server-hosted whisper.cpp transcription with bounded Companion audio transport and no local speech inference.
|
- Added server-hosted whisper.cpp transcription with bounded Companion audio transport and no local speech inference.
|
||||||
|
|||||||
@ -95,7 +95,7 @@ class CompanionGateway {
|
|||||||
this.log.append({ kind: "connection", state: "authenticated", device_id: device.id, session_id: session.id });
|
this.log.append({ kind: "connection", state: "authenticated", device_id: device.id, session_id: session.id });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.structured(session, message, send, () => { lastPong = Date.now(); });
|
await this.structured(session, device, message, send, () => { lastPong = Date.now(); });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
send("error", { code: error.code || "INVALID_MESSAGE", message: error.message, recoverable: !["INCOMPATIBLE_VERSION", "HELLO_REQUIRED"].includes(error.code) });
|
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 });
|
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", () => {
|
socket.on("close", () => {
|
||||||
clearTimeout(helloTimer); clearInterval(heartbeat);
|
clearTimeout(helloTimer); clearInterval(heartbeat);
|
||||||
if (session) this.sessions.disconnect(session.id);
|
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 });
|
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 }));
|
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) {
|
switch (message.type) {
|
||||||
case "ping":
|
case "ping":
|
||||||
pong();
|
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 "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 "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 "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;
|
case "ack": break;
|
||||||
default: throw coded("UNEXPECTED_MESSAGE", `Message ${message.type} is not valid after the handshake.`);
|
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 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 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 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 };
|
module.exports = { CompanionGateway, sameHostOrigin, MAX_CONTROL_MESSAGES_PER_SECOND, MAX_AUDIO_MESSAGES_PER_SECOND, MAX_SOURCE_MESSAGES_PER_SECOND };
|
||||||
|
|||||||
@ -5,7 +5,8 @@ const AUDIO_MAGIC = Buffer.from("LACP");
|
|||||||
const AUDIO_HEADER_BYTES = 64;
|
const AUDIO_HEADER_BYTES = 64;
|
||||||
const MAX_JSON_BYTES = 64 * 1024;
|
const MAX_JSON_BYTES = 64 * 1024;
|
||||||
const MAX_AUDIO_PAYLOAD_BYTES = 6400;
|
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) {
|
function parseEnvelope(input) {
|
||||||
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(String(input || ""), "utf8");
|
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(String(input || ""), "utf8");
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"signed": false,
|
"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": {
|
"installer": {
|
||||||
"id": "windows-x64-installer",
|
"id": "windows-x64-installer",
|
||||||
"platform": "win32",
|
"platform": "win32",
|
||||||
"architecture": "x64",
|
"architecture": "x64",
|
||||||
"label": "Windows x64 per-user installer",
|
"label": "Windows x64 per-user installer",
|
||||||
"filename": "Lumi.Companion-Setup.exe",
|
"filename": "Lumi.Companion-Setup.exe",
|
||||||
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0/Lumi.Companion-Setup.exe",
|
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.0/Lumi.Companion-Setup.exe",
|
||||||
"sha256": "71ee091971a8e246d9b991a16fc28bd73495351c82b44839ae34ca36fb5784ff",
|
"sha256": "cdeb43dc1512f537ff4cf8840fb36ee7888c1a92d901a50984079beabba221da",
|
||||||
"bytes": 51849287
|
"bytes": 51882646
|
||||||
},
|
},
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
@ -20,9 +20,9 @@
|
|||||||
"architecture": "x64",
|
"architecture": "x64",
|
||||||
"label": "Windows x64 self-contained",
|
"label": "Windows x64 self-contained",
|
||||||
"filename": "Lumi.Companion-win-x64.zip",
|
"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",
|
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.0/Lumi.Companion-win-x64.zip",
|
||||||
"sha256": "1194475b2a6acdeb6ef9429726cb5de31a5c93f1dc53a3faa29bb54eaef0368a",
|
"sha256": "bf42a9abbf1df9eb604f3d7f0216939608af071fecb8b17e3def55037ce2e77f",
|
||||||
"bytes": 66731197,
|
"bytes": 66775103,
|
||||||
"entrypoint": "Lumi.Companion.App.exe"
|
"entrypoint": "Lumi.Companion.App.exe"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "lumi_transcription",
|
"id": "lumi_transcription",
|
||||||
"name": "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.",
|
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
|
|||||||
@ -2,6 +2,38 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"releases": [
|
"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",
|
"version": "0.2.27",
|
||||||
"ref": "refs/tags/v0.2.27",
|
"ref": "refs/tags/v0.2.27",
|
||||||
|
|||||||
@ -28,6 +28,7 @@ const checks = [
|
|||||||
"scripts/verify-command-policies.js",
|
"scripts/verify-command-policies.js",
|
||||||
"scripts/verify-destructive-actions.js",
|
"scripts/verify-destructive-actions.js",
|
||||||
"scripts/verify-overlays.js",
|
"scripts/verify-overlays.js",
|
||||||
|
"scripts/verify-stream-testing.js",
|
||||||
"scripts/verify-content-library.js",
|
"scripts/verify-content-library.js",
|
||||||
"scripts/verify-overlay-web-documents.js",
|
"scripts/verify-overlay-web-documents.js",
|
||||||
"scripts/verify-webhooks.js"
|
"scripts/verify-webhooks.js"
|
||||||
|
|||||||
@ -173,8 +173,9 @@ try {
|
|||||||
volume: 5
|
volume: 5
|
||||||
});
|
});
|
||||||
assert.strictEqual(audioConfig.playBehavior, "manual");
|
assert.strictEqual(audioConfig.playBehavior, "manual");
|
||||||
assert.strictEqual(audioConfig.showControls, true);
|
|
||||||
assert.strictEqual(audioConfig.volume, 1);
|
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", {
|
const chatConfig = overlayModules.normalizeModuleConfig("chat", {
|
||||||
platforms: ["twitch", "discord", "unsupported"],
|
platforms: ["twitch", "discord", "unsupported"],
|
||||||
channels: "twitch:cozycarnage\ndiscord:community-chat",
|
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('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(connectorScript.includes('action: "save_replay_buffer"'), "Browser Bridge connector must request replay saves");
|
||||||
assert(rendererScript.includes("buildMedia") && rendererScript.includes("buildWebsite"));
|
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("buildChat") && rendererScript.includes("chatAccepts"));
|
||||||
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));
|
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));
|
||||||
assert(rendererScript.includes("/icons/badges/"));
|
assert(rendererScript.includes("/icons/badges/"));
|
||||||
|
|||||||
@ -4,12 +4,12 @@ const path = require("path");
|
|||||||
const { findSafeTarget } = require("../src/services/versioning");
|
const { findSafeTarget } = require("../src/services/versioning");
|
||||||
|
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const releaseVersion = "0.2.27";
|
const releaseVersion = "0.3.0";
|
||||||
const previousStableVersion = "0.2.26";
|
const previousStableVersion = "0.2.27";
|
||||||
const priorStableVersion = "0.2.25";
|
const priorStableVersion = "0.2.26";
|
||||||
const earliestCompatibleCoreVersion = "0.1.9";
|
const earliestCompatibleCoreVersion = "0.1.9";
|
||||||
const introducedPlugins = {
|
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" }
|
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(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
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.");
|
||||||
|
|||||||
80
scripts/verify-stream-testing.js
Normal file
80
scripts/verify-stream-testing.js
Normal 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;
|
||||||
|
});
|
||||||
@ -24,7 +24,7 @@ function readJson(relativePath) {
|
|||||||
|
|
||||||
const releaseIndex = readJson("release-index.json");
|
const releaseIndex = readJson("release-index.json");
|
||||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
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");
|
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||||
for (const release of releaseIndex.releases) {
|
for (const release of releaseIndex.releases) {
|
||||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||||
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
|
|||||||
const coreManifest = readJson("update-manifest.json");
|
const coreManifest = readJson("update-manifest.json");
|
||||||
assert.equal(packageVersion, coreManifest.version);
|
assert.equal(packageVersion, coreManifest.version);
|
||||||
assert.equal(coreManifest.channel, "stable");
|
assert.equal(coreManifest.channel, "stable");
|
||||||
assert.equal(packageVersion, "0.2.27");
|
assert.equal(packageVersion, "0.3.0");
|
||||||
assert.equal(currentRelease.version, "0.2.27");
|
assert.equal(currentRelease.version, "0.3.0");
|
||||||
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
|
||||||
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
|
||||||
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
|
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 = {
|
const baseTarget = {
|
||||||
current_version: "0.2.4",
|
current_version: "0.2.4",
|
||||||
available_versions: [
|
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.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.26", ref: "refs/tags/v0.2.26", rollback_safe: true },
|
||||||
{ version: "0.2.25", ref: "refs/tags/v0.2.25", 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"
|
channel: "stable"
|
||||||
});
|
});
|
||||||
assert.equal(corrected.version_correction, true);
|
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.update_available, true);
|
||||||
assert.equal(corrected.blocked, false);
|
assert.equal(corrected.blocked, false);
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
const playBehavior = ["once", "loop", "manual"].includes(input.playBehavior)
|
||||||
? input.playBehavior
|
? input.playBehavior
|
||||||
: "once";
|
: "once";
|
||||||
return {
|
return {
|
||||||
...commonConfig(input),
|
...(includeLayout ? commonConfig(input) : {}),
|
||||||
url: url(input.url),
|
url: url(input.url),
|
||||||
playBehavior,
|
playBehavior,
|
||||||
muted: input.muted === true || input.muted === "on",
|
muted: input.muted === true || input.muted === "on",
|
||||||
volume: number(input.volume, 1, 0, 1),
|
volume: number(input.volume, 1, 0, 1),
|
||||||
playbackRate: number(input.playbackRate, 1, 0.25, 4),
|
playbackRate: number(input.playbackRate, 1, 0.25, 4),
|
||||||
startAt: number(input.startAt, 0, 0, 86400),
|
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" } : {})
|
...(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.",
|
description: "Play an audio file through the OBS Browser Source.",
|
||||||
renderType: "audio",
|
renderType: "audio",
|
||||||
normalize(input) {
|
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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -102,7 +102,9 @@ function getOverlay(id, { includeSecrets = false } = {}) {
|
|||||||
.map((module) => ({
|
.map((module) => ({
|
||||||
...module,
|
...module,
|
||||||
enabled: Boolean(module.enabled),
|
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;
|
return result;
|
||||||
});
|
});
|
||||||
@ -375,6 +377,9 @@ function updateModule(moduleId, input = {}) {
|
|||||||
function updateModuleTransform(moduleId, input = {}) {
|
function updateModuleTransform(moduleId, input = {}) {
|
||||||
const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
|
const module = db.prepare("SELECT * FROM overlay_modules WHERE id = ?").get(moduleId);
|
||||||
if (!module) throw new Error("Overlay source not found.");
|
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);
|
const current = parseConfig(module.config_json);
|
||||||
updateModule(moduleId, {
|
updateModule(moduleId, {
|
||||||
type: module.type,
|
type: module.type,
|
||||||
@ -414,7 +419,18 @@ function duplicateModule(moduleId, name) {
|
|||||||
function reorderModules(sceneId, ids) {
|
function reorderModules(sceneId, ids) {
|
||||||
const overlayId = overlayIdForScene(sceneId);
|
const overlayId = overlayIdForScene(sceneId);
|
||||||
if (!overlayId) throw new Error("Scene not found.");
|
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);
|
touchOverlay(overlayId);
|
||||||
notifyOverlayChanged(overlayId, "module_reorder");
|
notifyOverlayChanged(overlayId, "module_reorder");
|
||||||
}
|
}
|
||||||
|
|||||||
505
src/services/stream-testing.js
Normal file
505
src/services/stream-testing.js
Normal 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 };
|
||||||
@ -426,7 +426,9 @@
|
|||||||
button.className = "button subtle";
|
button.className = "button subtle";
|
||||||
button.dataset.canvasSelect = module.id;
|
button.dataset.canvasSelect = module.id;
|
||||||
button.textContent = `${module.name}${module.enabled ? "" : " (hidden)"}`;
|
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 }));
|
button.addEventListener("click", () => selectSource(module.id, { openSettings: true }));
|
||||||
sourceList.appendChild(button);
|
sourceList.appendChild(button);
|
||||||
}
|
}
|
||||||
@ -555,6 +557,23 @@
|
|||||||
handle: event.target.closest("[data-resize-handle]")?.dataset.resizeHandle || null
|
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("pointermove", applyDrag, { signal: lifecycleSignal });
|
||||||
window.addEventListener("pointerup", () => {
|
window.addEventListener("pointerup", () => {
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
|
|||||||
@ -40,6 +40,7 @@
|
|||||||
|
|
||||||
function restartMediaElement(media) {
|
function restartMediaElement(media) {
|
||||||
if (!media) return false;
|
if (!media) return false;
|
||||||
|
media.dispatchEvent(new CustomEvent("lumi-media-restart"));
|
||||||
const startAt = Math.max(0, finite(media.dataset.startAt, 0));
|
const startAt = Math.max(0, finite(media.dataset.startAt, 0));
|
||||||
try { media.currentTime = startAt; } catch {}
|
try { media.currentTime = startAt; } catch {}
|
||||||
const playback = media.play?.();
|
const playback = media.play?.();
|
||||||
@ -57,14 +58,33 @@
|
|||||||
media.preload = "auto";
|
media.preload = "auto";
|
||||||
media.playsInline = true;
|
media.playsInline = true;
|
||||||
media.loop = values.playBehavior === "loop";
|
media.loop = values.playBehavior === "loop";
|
||||||
media.controls = values.showControls === true || values.playBehavior === "manual";
|
media.controls = false;
|
||||||
media.muted = values.muted === true;
|
media.muted = values.muted === true;
|
||||||
media.volume = Math.min(1, Math.max(0, finite(values.volume, 1)));
|
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.playbackRate = Math.min(4, Math.max(0.25, finite(values.playbackRate, 1)));
|
||||||
media.autoplay = !options.editor && values.playBehavior !== "manual";
|
media.autoplay = !options.editor && values.playBehavior !== "manual";
|
||||||
media.referrerPolicy = "no-referrer";
|
media.referrerPolicy = "no-referrer";
|
||||||
media.style.pointerEvents = !options.editor && media.controls ? "auto" : "none";
|
media.style.pointerEvents = "none";
|
||||||
if (renderType === "video") media.style.objectFit = values.fit || "contain";
|
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", () => {
|
media.addEventListener("loadedmetadata", () => {
|
||||||
const startAt = Math.max(0, finite(values.startAt, 0));
|
const startAt = Math.max(0, finite(values.startAt, 0));
|
||||||
if (startAt && startAt < finite(media.duration, Infinity)) {
|
if (startAt && startAt < finite(media.duration, Infinity)) {
|
||||||
@ -424,12 +444,16 @@
|
|||||||
|
|
||||||
function findSourceContent(root, moduleId, selector) {
|
function findSourceContent(root, moduleId, selector) {
|
||||||
const box = root?.querySelector?.(`.lumi-overlay-module[data-module-id="${CSS.escape(moduleId)}"]`);
|
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 = {}) {
|
function buildModule(module, options = {}) {
|
||||||
const renderType = module.renderType || module.type;
|
const renderType = module.renderType || module.type;
|
||||||
const values = module.config || {};
|
const values = module.config || {};
|
||||||
|
if (renderType === "audio") return null;
|
||||||
const scale = finite(options.scale, 1);
|
const scale = finite(options.scale, 1);
|
||||||
const box = document.createElement("section");
|
const box = document.createElement("section");
|
||||||
box.className = `lumi-overlay-module lumi-overlay-module-${renderType}`;
|
box.className = `lumi-overlay-module lumi-overlay-module-${renderType}`;
|
||||||
@ -472,15 +496,9 @@
|
|||||||
image.referrerPolicy = "no-referrer";
|
image.referrerPolicy = "no-referrer";
|
||||||
image.style.objectFit = values.fit || "contain";
|
image.style.objectFit = values.fit || "contain";
|
||||||
box.appendChild(image);
|
box.appendChild(image);
|
||||||
} else if ((renderType === "video" || renderType === "audio") && values.url) {
|
} else if (renderType === "video" && values.url) {
|
||||||
const media = buildMedia(module, values, options, renderType);
|
const media = buildMedia(module, values, options, renderType);
|
||||||
box.appendChild(media);
|
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) {
|
} else if (renderType === "web" && values.url) {
|
||||||
box.appendChild(buildWebsite(module, values, options));
|
box.appendChild(buildWebsite(module, values, options));
|
||||||
}
|
}
|
||||||
@ -526,7 +544,17 @@
|
|||||||
if (state?.enabled !== false && state?.scene) {
|
if (state?.enabled !== false && state?.scene) {
|
||||||
for (const module of state.scene.modules || []) {
|
for (const module of state.scene.modules || []) {
|
||||||
if (module.enabled === false) continue;
|
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 });
|
const element = buildModule(module, { ...options, canvas, scale: 1 });
|
||||||
|
if (!element) continue;
|
||||||
elements.set(module.id, element);
|
elements.set(module.id, element);
|
||||||
stage.appendChild(element);
|
stage.appendChild(element);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,19 +83,6 @@ body.lumi-overlay-runtime-page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lumi-overlay-audio {
|
.lumi-overlay-audio {
|
||||||
width: 100%;
|
display: none !important;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/web/public/stream-testing.css
Normal file
15
src/web/public/stream-testing.css
Normal 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; } }
|
||||||
|
}
|
||||||
134
src/web/public/stream-testing.js
Normal file
134
src/web/public/stream-testing.js
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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();
|
||||||
|
})();
|
||||||
@ -80,6 +80,7 @@ const {
|
|||||||
const { getClient: getTwitchClient } = require("../services/twitch");
|
const { getClient: getTwitchClient } = require("../services/twitch");
|
||||||
const { twitchEventSubManager } = require("../services/twitch-eventsub");
|
const { twitchEventSubManager } = require("../services/twitch-eventsub");
|
||||||
const { eventHooksApi } = require("../services/overlay-event-hooks");
|
const { eventHooksApi } = require("../services/overlay-event-hooks");
|
||||||
|
const { streamTestingService } = require("../services/stream-testing");
|
||||||
const { getClient: getYouTubeClient } = require("../services/youtube");
|
const { getClient: getYouTubeClient } = require("../services/youtube");
|
||||||
const {
|
const {
|
||||||
conditionalRepliesFromBody,
|
conditionalRepliesFromBody,
|
||||||
@ -3139,6 +3140,13 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
const contentLibraryFramework = createContentLibraryFramework();
|
const contentLibraryFramework = createContentLibraryFramework();
|
||||||
global.lumiFrameworks.content = contentLibraryFramework;
|
global.lumiFrameworks.content = contentLibraryFramework;
|
||||||
global.lumiFrameworks.resources = 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 assetVersion = Date.now().toString();
|
||||||
const sessionStore = new BetterSqlite3Store({
|
const sessionStore = new BetterSqlite3Store({
|
||||||
client: db
|
client: db
|
||||||
@ -6184,6 +6192,55 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
renderDiagnosticsAdmin(req, res);
|
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) => {
|
app.post("/admin/diagnostics/run", requireRole("admin"), (req, res) => {
|
||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
const check = String(req.body.check || "").trim();
|
const check = String(req.body.check || "").trim();
|
||||||
@ -7837,6 +7894,7 @@ function collectNavItems(user, pluginNav, currentPath) {
|
|||||||
section: "admin"
|
section: "admin"
|
||||||
},
|
},
|
||||||
{ label: "Feedback review", path: "/admin/feedback", role: "admin", 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: "Diagnostics", path: "/admin/diagnostics", role: "admin", section: "admin" },
|
||||||
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
|
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
|
||||||
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
|
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
|
||||||
|
|||||||
@ -108,7 +108,7 @@
|
|||||||
<div class="field"><label>Name</label><input name="name" value="<%= module.name %>" required /></div>
|
<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>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>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">
|
<div class="field full overlay-conditional-fields" data-module-fields="text">
|
||||||
<label>Text<textarea name="text" rows="3"><%= value(config, "text") %></textarea></label>
|
<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>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>
|
<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">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">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><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>
|
</div>
|
||||||
|
|
||||||
<div class="field full overlay-conditional-fields" data-module-fields="audio">
|
<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>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>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>
|
<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><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>
|
</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><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>
|
</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>
|
<summary>Position and size</summary>
|
||||||
<fieldset class="overlay-transform-fields" aria-label="Position and size">
|
<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>
|
<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>
|
</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>
|
<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>
|
</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><% } %>
|
<% 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>
|
</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">
|
<details class="overlay-add-source">
|
||||||
<summary>Add a source</summary>
|
<summary>Add a source</summary>
|
||||||
|
|||||||
49
src/web/views/admin-stream-testing.ejs
Normal file
49
src/web/views/admin-stream-testing.ejs
Normal 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") %>
|
||||||
@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "Lumi Core",
|
"name": "Lumi Core",
|
||||||
"version": "0.2.27",
|
"version": "0.3.0",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"released_at": "2026-07-24",
|
"released_at": "2026-07-24",
|
||||||
"compatible_from": "0.1.9",
|
"compatible_from": "0.1.9",
|
||||||
"migration_kind": "patch",
|
"migration_kind": "minor",
|
||||||
"replaces_versions": [
|
"replaces_versions": [
|
||||||
"1.2.0"
|
"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,
|
"rollback_safe": true,
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"Node.js 18 or newer"
|
"Node.js 18 or newer",
|
||||||
|
"FFmpeg for the optional private Stream Testing receiver"
|
||||||
],
|
],
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
{
|
||||||
@ -337,6 +338,18 @@
|
|||||||
],
|
],
|
||||||
"rollback_safe": true,
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user