diff --git a/CMakeLists.txt b/CMakeLists.txt index 5daba4c..df54a8e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,12 +16,7 @@ endif () if (RENDERIVE_BUILD_3D) message(FATAL_ERROR "RENDERIVE_BUILD_3D is reserved; the 3D renderer is not implemented") endif () -if (CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) - set(third_party "${CMAKE_CURRENT_LIST_DIR}/third_party") -else () - set(third_party "${CMAKE_CURRENT_LIST_DIR}/..") -endif () -include("${third_party}/build_infra/start.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/third_party/build_infra/start.cmake") if (RENDERIVE_BUILD_TESTS) enable_testing() endif () @@ -36,4 +31,4 @@ if (RENDERIVE_BUILD_WEB) add_subdirectory(web_server) endif () include("${CMAKE_CURRENT_LIST_DIR}/cmake/RenderivePackage.cmake") -include("${third_party}/build_infra/end.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/third_party/build_infra/end.cmake") diff --git a/Kernel/readme.md b/Kernel/readme.md index 13eec04..33602a2 100644 --- a/Kernel/readme.md +++ b/Kernel/readme.md @@ -86,7 +86,7 @@ Taskflow 只在 `Scene_Base.cpp` 中包含。`Scene_Base` 通过内部执行上 ## 测试入口 -`RENDERIVE_BUILD_TESTS=ON` 时构建 `Renderive_Kernel_Tests` 并通过 CTest 运行;`RENDERIVE_BUILD_TESTS=OFF` 时只构建 `Renderive_Kernel` 库,不编译任何测试源文件。 +`RENDERIVE_BUILD_TESTS=ON` 时遍历 `tests`,为每个测试源文件生成独立 target 并注册到 CTest;`RENDERIVE_BUILD_TESTS=OFF` 时只构建 `Renderive_Kernel` 库,不编译测试源文件。 ## 线程模型 diff --git a/web_server/CMakeLists.txt b/web_server/CMakeLists.txt index 2f9c3dd..1e3cf80 100644 --- a/web_server/CMakeLists.txt +++ b/web_server/CMakeLists.txt @@ -1,77 +1,76 @@ set(Renderive_Web_dependencies global::drogon global::spdlog) -if(RENDERIVE_BUILD_TESTS) +if (RENDERIVE_BUILD_TESTS) list(APPEND Renderive_Web_dependencies global::GTest) -endif() +endif () rcl_add_dependency_action_targets(Renderive_Web_env ${Renderive_Web_dependencies}) set_target_properties(Renderive_Web_env PROPERTIES FOLDER Renderive_Web) library_is_installed_with_rely(Renderive_Web_dependencies_installed ${Renderive_Web_dependencies}) -if(NOT Renderive_Web_dependencies_installed) +if (NOT Renderive_Web_dependencies_installed) rcl_log_append("[FATAL_ERROR] Renderive_Web dependencies are not installed. Build Renderive_Web_env first") return() -endif() +endif () function(renderive_find_web_dependencies) - if(POLICY CMP0144) + if (POLICY CMP0144) cmake_policy(SET CMP0144 NEW) - endif() + endif () rcl_load_dependency_environment(${Renderive_Web_dependencies}) find_package(Drogon CONFIG REQUIRED) find_package(spdlog CONFIG REQUIRED) - if(RENDERIVE_BUILD_TESTS) + if (RENDERIVE_BUILD_TESTS) find_package(GTest CONFIG REQUIRED) - endif() + endif () endfunction() renderive_find_web_dependencies() -if(NOT TARGET Renderive_render_2D) +if (NOT TARGET Renderive_render_2D) rcl_log_append("[FATAL_ERROR] Renderive_Web requires Renderive_render_2D") return() -endif() -if(NOT TARGET Adminive::Nlohmann) +endif () +if (NOT TARGET Adminive::Nlohmann) set(Renderive_Web_saved_build_testing "${BUILD_TESTING}") set(BUILD_TESTING OFF) add_subdirectory( - "${CMAKE_CURRENT_LIST_DIR}/../third_party/Adminive" - "${CMAKE_CURRENT_BINARY_DIR}/third_party/Adminive" - EXCLUDE_FROM_ALL + "${CMAKE_CURRENT_LIST_DIR}/../third_party/Adminive" + "${CMAKE_CURRENT_BINARY_DIR}/third_party/Adminive" + EXCLUDE_FROM_ALL ) set(BUILD_TESTING "${Renderive_Web_saved_build_testing}") unset(Renderive_Web_saved_build_testing) -endif() -set(Renderive_Web_source_dir "${CMAKE_CURRENT_LIST_DIR}") +endif () +set(Renderive_Web_source_dir "${CMAKE_CURRENT_LIST_DIR}/app") append_glob_source(Renderive_Web_sources "${Renderive_Web_source_dir}") -list(FILTER Renderive_Web_sources EXCLUDE REGEX "/(app|tests)/") add_library(Renderive_Web STATIC ${Renderive_Web_sources}) target_include_directories(Renderive_Web PUBLIC - "$" + "$" ) target_compile_features(Renderive_Web PUBLIC cxx_std_20) target_compile_definitions(Renderive_Web PRIVATE NOMINMAX) target_link_libraries(Renderive_Web - PUBLIC Renderive_render_2D Drogon::Drogon - PRIVATE Adminive::Nlohmann spdlog::spdlog + PUBLIC Renderive_render_2D Drogon::Drogon + PRIVATE Adminive::Nlohmann spdlog::spdlog ) -set(Renderive_Web_Server_source_dir "${CMAKE_CURRENT_LIST_DIR}/app") +set(Renderive_Web_Server_source_dir "${CMAKE_CURRENT_LIST_DIR}/server") append_glob_source(Renderive_Web_Server_sources "${Renderive_Web_Server_source_dir}") add_executable(Renderive_Web_Server ${Renderive_Web_Server_sources}) target_compile_features(Renderive_Web_Server PRIVATE cxx_std_20) target_link_libraries(Renderive_Web_Server PRIVATE Renderive_Web) add_custom_target(Renderive_Web_Assets - COMMAND "${CMAKE_COMMAND}" -E copy_directory - "${CMAKE_CURRENT_LIST_DIR}/../webapp_gallery" - "${CMAKE_CURRENT_BINARY_DIR}/webapp_gallery" - COMMENT "Synchronizing Renderive Web assets" + COMMAND "${CMAKE_COMMAND}" -E copy_directory + "${CMAKE_CURRENT_LIST_DIR}/../webapp_gallery" + "${CMAKE_CURRENT_BINARY_DIR}/webapp_gallery" + COMMENT "Synchronizing Renderive Web assets" ) add_dependencies(Renderive_Web_Server Renderive_Web_Assets) -if(MSVC) +if (MSVC) target_compile_options(Renderive_Web PRIVATE /utf-8) target_compile_options(Renderive_Web_Server PRIVATE /utf-8) -endif() -if(RENDERIVE_BUILD_TESTS) +endif () +if (RENDERIVE_BUILD_TESTS) set(Renderive_Web_test_dir "${CMAKE_CURRENT_LIST_DIR}/tests") append_glob_source(Renderive_Web_test_sources "${Renderive_Web_test_dir}") - foreach(Renderive_Web_test_source IN LISTS Renderive_Web_test_sources) - if(NOT Renderive_Web_test_source MATCHES "\\.(c|cc|cpp|cxx)$") + foreach (Renderive_Web_test_source IN LISTS Renderive_Web_test_sources) + if (NOT Renderive_Web_test_source MATCHES "\\.(c|cc|cpp|cxx)$") continue() - endif() + endif () file(RELATIVE_PATH Renderive_Web_test_name "${Renderive_Web_test_dir}" "${Renderive_Web_test_source}") string(MD5 Renderive_Web_test_hash "${Renderive_Web_test_name}") string(SUBSTRING "${Renderive_Web_test_hash}" 0 8 Renderive_Web_test_hash) @@ -81,5 +80,5 @@ if(RENDERIVE_BUILD_TESTS) add_executable("${Renderive_Web_test_target}" "${Renderive_Web_test_source}") target_link_libraries("${Renderive_Web_test_target}" PRIVATE Renderive_Web Adminive::Nlohmann GTest::gtest_main) add_test(NAME "${Renderive_Web_test_target}" COMMAND "${Renderive_Web_test_target}") - endforeach() -endif() + endforeach () +endif () diff --git a/web_server/Gallery_Plot_Session.cpp b/web_server/app/Gallery_Plot_Session.cpp similarity index 99% rename from web_server/Gallery_Plot_Session.cpp rename to web_server/app/Gallery_Plot_Session.cpp index f7747f0..85cbad5 100644 --- a/web_server/Gallery_Plot_Session.cpp +++ b/web_server/app/Gallery_Plot_Session.cpp @@ -1757,8 +1757,7 @@ struct Gallery_Plot_Session::Impl { } else if constexpr (std::is_same_v) { return value.kind != Gallery_Request_Kind::Catalog && - value.kind != Gallery_Request_Kind::Refresh && - value.kind != Gallery_Request_Kind::Feedback; + value.kind != Gallery_Request_Kind::Observe; } else { return true; @@ -1805,20 +1804,15 @@ struct Gallery_Plot_Session::Impl { Web_Response_Type::Json, Gallery_Protocol::error_json("请先发送 gallery_open") }; - if (request.kind == Gallery_Request_Kind::Feedback) { + if (request.kind == Gallery_Request_Kind::Observe) { if (update_client_metrics(request.message)) { ++scheduler_revision; scheduler_condition.notify_all(); } - return std::nullopt; - } - if (request.kind == Gallery_Request_Kind::Refresh) { return Web_Response{ Web_Response_Type::Json, - Gallery_Protocol::case_json(scene->case_id(), scene->state(), - scene->telemetry_json(), - "后端状态已手动刷新", - scene->frame_mode()) + Gallery_Protocol::observer_json( + scene->case_id(), scene->frame_mode(), scene->telemetry_json()) }; } if (request.kind == Gallery_Request_Kind::Patch) { diff --git a/web_server/Gallery_Plot_Session.h b/web_server/app/Gallery_Plot_Session.h similarity index 100% rename from web_server/Gallery_Plot_Session.h rename to web_server/app/Gallery_Plot_Session.h diff --git a/web_server/Gallery_Protocol.cpp b/web_server/app/Gallery_Protocol.cpp similarity index 95% rename from web_server/Gallery_Protocol.cpp rename to web_server/app/Gallery_Protocol.cpp index 7e6a79f..1a2a517 100644 --- a/web_server/Gallery_Protocol.cpp +++ b/web_server/app/Gallery_Protocol.cpp @@ -522,72 +522,6 @@ Json value_json(const Gallery_Value& value) { return std::visit([](const auto& item) { return Json(item); }, value); } -Json control_amis_schema(const Control_Definition& definition) { - const auto& model = definition.model; - Json result{ - {"name", model.id}, - {"label", model.label}, - {"description", model.description.empty() ? model.api : model.description}, - {"remark", model.api}, - {"size", "md"} - }; - if (model.input == "boolean") { - result["type"] = "switch"; - return result; - } - if (model.input == "select") { - result["type"] = "select"; - result["required"] = true; - result["clearable"] = false; - result["options"] = Json::array(); - for (const auto& option : definition.options) - result["options"].push_back({{"label", option}, {"value", option}}); - return result; - } - if (model.input == "color") { - result["type"] = "input-color"; - result["required"] = true; - result["clearable"] = false; - result["validations"] = {{"matchRegexp", "^#[0-9A-Fa-f]{6}$"}, {"maxLength", 7}}; - result["validationErrors"] = {{"matchRegexp", "颜色必须使用 #RRGGBB"}, {"maxLength", "颜色必须使用 #RRGGBB"}}; - return result; - } - if (model.input == "number") { - result["type"] = "input-number"; - result["required"] = true; - result["min"] = model.minimum; - result["max"] = model.maximum; - result["step"] = model.step; - result["validations"] = {{"minimum", model.minimum}, {"maximum", model.maximum}}; - result["validationErrors"] = { - {"minimum", "数值低于后端允许范围"}, - {"maximum", "数值超出后端允许范围"} - }; - if (model.integer) { - result["precision"] = 0; - result["validations"]["isInt"] = true; - result["validationErrors"]["isInt"] = "必须是整数"; - } - return result; - } - result["type"] = "input-text"; - result["validations"] = {{"maxLength", 160}}; - result["validationErrors"] = {{"maxLength", "文本长度不能超过 160 个字符"}}; - return result; -} - -Json action_argument_amis_schema(const Action_Definition& definition) { - const auto& model = definition.model; - if (model.argument_input.empty()) - return Json(); - return { - {"type", model.argument_input == "number" ? "input-number" : "input-text"}, - {"name", "action_" + model.id}, - {"label", model.argument_label.empty() ? "参数" : model.argument_label}, - {"required", true} - }; -} - std::optional parse_value(const Json& value, const Control_Definition& definition, std::string& error) { @@ -755,7 +689,7 @@ using gallery_detail::Json; Json protocol_base() { return {{"category", "gallery"}, {"protocol", "renderive.control-gallery"}, - {"protocol_version", 4}}; + {"protocol_version", 3}}; } Json case_contract(std::string_view case_id) { @@ -889,7 +823,6 @@ std::string Gallery_Protocol::case_json(std::string_view case_id, ? gallery_detail::value_json(definition.initial) : gallery_detail::value_json(found->second); item["options"] = definition.options; - item["amis"] = gallery_detail::control_amis_schema(definition); result["controls"]["data"].push_back(std::move(item)); } result["actions"] = { @@ -898,12 +831,9 @@ std::string Gallery_Protocol::case_json(std::string_view case_id, gallery_detail::action_view())}, {"data", Json::array()} }; - for (const auto& definition : gallery_detail::actions(case_id, frame_mode)) { - Json item = adminive::to_frontend_json(definition.model); - if (!definition.model.argument_input.empty()) - item["argument_amis"] = gallery_detail::action_argument_amis_schema(definition); - result["actions"]["data"].push_back(std::move(item)); - } + for (const auto& definition : gallery_detail::actions(case_id, frame_mode)) + result["actions"]["data"].push_back( + adminive::to_frontend_json(definition.model)); try { result["telemetry"] = Json::parse(telemetry_json.begin(), telemetry_json.end()); } catch (const std::exception&) { @@ -933,6 +863,21 @@ std::string Gallery_Protocol::error_json(std::string_view message, return result.dump(); } +std::string Gallery_Protocol::observer_json(std::string_view case_id, + Gallery_Frame_Mode frame_mode, + std::string_view telemetry_json) { + Json result = protocol_base(); + result["type"] = "observer_state"; + result["case_id"] = case_id; + result["frame_mode"] = frame_mode_contract(frame_mode); + try { + result["telemetry"] = Json::parse(telemetry_json.begin(), telemetry_json.end()); + } catch (const std::exception&) { + result["telemetry"] = Json::object(); + } + return result.dump(); +} + Gallery_Patch_Result Gallery_Protocol::apply_patch(std::string_view case_id, const Gallery_State& current, std::string_view message, diff --git a/web_server/Gallery_Protocol.h b/web_server/app/Gallery_Protocol.h similarity index 94% rename from web_server/Gallery_Protocol.h rename to web_server/app/Gallery_Protocol.h index 1d225a6..d28713d 100644 --- a/web_server/Gallery_Protocol.h +++ b/web_server/app/Gallery_Protocol.h @@ -57,6 +57,10 @@ public: [[nodiscard]] static std::string error_json( std::string_view message, std::string_view field = {}); + [[nodiscard]] static std::string observer_json( + std::string_view case_id, + Gallery_Frame_Mode frame_mode, + std::string_view telemetry_json); [[nodiscard]] static Gallery_Patch_Result apply_patch( std::string_view case_id, const Gallery_State& current, diff --git a/web_server/Gallery_WebSocket_Controller.cpp b/web_server/app/Gallery_WebSocket_Controller.cpp similarity index 100% rename from web_server/Gallery_WebSocket_Controller.cpp rename to web_server/app/Gallery_WebSocket_Controller.cpp diff --git a/web_server/Gallery_WebSocket_Controller.h b/web_server/app/Gallery_WebSocket_Controller.h similarity index 100% rename from web_server/Gallery_WebSocket_Controller.h rename to web_server/app/Gallery_WebSocket_Controller.h diff --git a/web_server/Pixel_Frame.cpp b/web_server/app/Pixel_Frame.cpp similarity index 100% rename from web_server/Pixel_Frame.cpp rename to web_server/app/Pixel_Frame.cpp diff --git a/web_server/Pixel_Frame.h b/web_server/app/Pixel_Frame.h similarity index 100% rename from web_server/Pixel_Frame.h rename to web_server/app/Pixel_Frame.h diff --git a/web_server/Renderive_WebSocket_Controller.cpp b/web_server/app/Renderive_WebSocket_Controller.cpp similarity index 100% rename from web_server/Renderive_WebSocket_Controller.cpp rename to web_server/app/Renderive_WebSocket_Controller.cpp diff --git a/web_server/Renderive_WebSocket_Controller.h b/web_server/app/Renderive_WebSocket_Controller.h similarity index 100% rename from web_server/Renderive_WebSocket_Controller.h rename to web_server/app/Renderive_WebSocket_Controller.h diff --git a/web_server/Web_Event.h b/web_server/app/Web_Event.h similarity index 97% rename from web_server/Web_Event.h rename to web_server/app/Web_Event.h index 01c6f4d..8c3dde8 100644 --- a/web_server/Web_Event.h +++ b/web_server/app/Web_Event.h @@ -27,7 +27,7 @@ struct Set_Smoothing { bool enabled{}; }; struct Clear_Selection {}; -enum class Gallery_Request_Kind : std::uint8_t { Catalog, Open, Refresh, Feedback, Patch, Action }; +enum class Gallery_Request_Kind : std::uint8_t { Catalog, Open, Patch, Action, Observe }; struct Gallery_Request { Gallery_Request_Kind kind = Gallery_Request_Kind::Catalog; std::string message; diff --git a/web_server/Web_Event_Adapter.cpp b/web_server/app/Web_Event_Adapter.cpp similarity index 97% rename from web_server/Web_Event_Adapter.cpp rename to web_server/app/Web_Event_Adapter.cpp index 98cee98..7de67eb 100644 --- a/web_server/Web_Event_Adapter.cpp +++ b/web_server/app/Web_Event_Adapter.cpp @@ -211,14 +211,12 @@ std::optional Web_Event_Adapter::decode(std::string_view message) { return Gallery_Request{Gallery_Request_Kind::Catalog, std::string(message)}; if (type == "gallery_open") return Gallery_Request{Gallery_Request_Kind::Open, std::string(message)}; - if (type == "gallery_refresh") - return Gallery_Request{Gallery_Request_Kind::Refresh, std::string(message)}; - if (type == "gallery_feedback") - return Gallery_Request{Gallery_Request_Kind::Feedback, std::string(message)}; if (type == "gallery_patch") return Gallery_Request{Gallery_Request_Kind::Patch, std::string(message)}; if (type == "gallery_action") return Gallery_Request{Gallery_Request_Kind::Action, std::string(message)}; + if (type == "gallery_observe") + return Gallery_Request{Gallery_Request_Kind::Observe, std::string(message)}; return std::nullopt; } diff --git a/web_server/Web_Event_Adapter.h b/web_server/app/Web_Event_Adapter.h similarity index 100% rename from web_server/Web_Event_Adapter.h rename to web_server/app/Web_Event_Adapter.h diff --git a/web_server/Web_Performance_Log.cpp b/web_server/app/Web_Performance_Log.cpp similarity index 100% rename from web_server/Web_Performance_Log.cpp rename to web_server/app/Web_Performance_Log.cpp diff --git a/web_server/Web_Performance_Log.h b/web_server/app/Web_Performance_Log.h similarity index 100% rename from web_server/Web_Performance_Log.h rename to web_server/app/Web_Performance_Log.h diff --git a/web_server/Web_Plot_Session.cpp b/web_server/app/Web_Plot_Session.cpp similarity index 100% rename from web_server/Web_Plot_Session.cpp rename to web_server/app/Web_Plot_Session.cpp diff --git a/web_server/Web_Plot_Session.h b/web_server/app/Web_Plot_Session.h similarity index 100% rename from web_server/Web_Plot_Session.h rename to web_server/app/Web_Plot_Session.h diff --git a/web_server/Web_Server.cpp b/web_server/app/Web_Server.cpp similarity index 100% rename from web_server/Web_Server.cpp rename to web_server/app/Web_Server.cpp diff --git a/web_server/Web_Server.h b/web_server/app/Web_Server.h similarity index 100% rename from web_server/Web_Server.h rename to web_server/app/Web_Server.h diff --git a/web_server/app/main.cpp b/web_server/server/main.cpp similarity index 96% rename from web_server/app/main.cpp rename to web_server/server/main.cpp index 090b828..2d595a1 100644 --- a/web_server/app/main.cpp +++ b/web_server/server/main.cpp @@ -1,4 +1,4 @@ -#include "web_server/Web_Server.h" +#include "web_server/app/Web_Server.h" #include #include #include diff --git a/web_server/tests/Web_Bridge_Tests.cpp b/web_server/tests/Web_Bridge_Tests.cpp index 55b75a2..2455d0c 100644 --- a/web_server/tests/Web_Bridge_Tests.cpp +++ b/web_server/tests/Web_Bridge_Tests.cpp @@ -1,8 +1,8 @@ -#include "web_server/Gallery_Plot_Session.h" -#include "web_server/Gallery_Protocol.h" -#include "web_server/Pixel_Frame.h" -#include "web_server/Web_Event_Adapter.h" -#include "web_server/Web_Plot_Session.h" +#include "../app/Gallery_Plot_Session.h" +#include "../app/Gallery_Protocol.h" +#include "../app/Pixel_Frame.h" +#include "../app/Web_Event_Adapter.h" +#include "../app/Web_Plot_Session.h" #include #include @@ -52,10 +52,10 @@ nlohmann::json response_json(std::optional response) { return parse_json(response->payload); } -nlohmann::json refresh_telemetry(Gallery_Plot_Session& session) { +nlohmann::json observe_telemetry(Gallery_Plot_Session& session) { return response_json(session.handle(gallery_request( - Gallery_Request_Kind::Refresh, - R"({"category":"event","type":"gallery_refresh"})"))).value( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe"})"))).value( "telemetry", nlohmann::json::object()); } @@ -76,11 +76,6 @@ nlohmann::json patch_controls(Gallery_Plot_Session& session, nlohmann::json patc Gallery_Request_Kind::Patch, request.dump()))); } -void send_client_feedback(Gallery_Plot_Session& session, std::string_view encoded) { - EXPECT_FALSE(session.handle(gallery_request( - Gallery_Request_Kind::Feedback, std::string(encoded))).has_value()); -} - template bool wait_for_condition(Predicate&& predicate, std::chrono::milliseconds timeout = std::chrono::milliseconds(750)) { @@ -116,7 +111,7 @@ void set_gallery_view_active(Gallery_Plot_Session& session, bool active) { } std::uint64_t successful_render_count(Gallery_Plot_Session& session) { - return refresh_telemetry(session).at("performance").at("successful_render_count") + return observe_telemetry(session).at("performance").at("successful_render_count") .get(); } @@ -148,14 +143,10 @@ TEST(RenderiveWebBridge, DecodesOnlyEventMessages) { ASSERT_TRUE(open.has_value()); EXPECT_EQ(std::get(*open).kind, Gallery_Request_Kind::Open); - const auto refresh = Web_Event_Adapter::decode( - R"({"category":"event","type":"gallery_refresh"})"); - ASSERT_TRUE(refresh.has_value()); - EXPECT_EQ(std::get(*refresh).kind, Gallery_Request_Kind::Refresh); - const auto feedback = Web_Event_Adapter::decode( - R"({"category":"event","type":"gallery_feedback","client_metrics":{}})"); - ASSERT_TRUE(feedback.has_value()); - EXPECT_EQ(std::get(*feedback).kind, Gallery_Request_Kind::Feedback); + const auto observe = Web_Event_Adapter::decode( + R"({"category":"event","type":"gallery_observe"})"); + ASSERT_TRUE(observe.has_value()); + EXPECT_EQ(std::get(*observe).kind, Gallery_Request_Kind::Observe); const auto bounded = Web_Event_Adapter::decode( R"({"category":"event","type":"resize","width":1e100,"height":-1e100})"); @@ -200,7 +191,6 @@ TEST(RenderiveWebGallery, AdminiveCatalogCoversEveryControlCaseAndThreeModes) { EXPECT_EQ(catalog.at("category"), "gallery"); EXPECT_EQ(catalog.at("type"), "catalog"); EXPECT_EQ(catalog.at("protocol"), "renderive.control-gallery"); - EXPECT_EQ(catalog.at("protocol_version"), 4); EXPECT_EQ(catalog.at("case_descriptor").at("protocol"), "adminive.resource"); EXPECT_EQ(catalog.at("cases").size(), 8U); EXPECT_EQ(catalog.at("frame_modes").size(), 3U); @@ -320,8 +310,6 @@ TEST(RenderiveWebGallery, EveryPublishedControlValidatesItsCompleteInputContract EXPECT_TRUE(ids.insert(id).second); EXPECT_FALSE(control.at("api").get().empty()); EXPECT_FALSE(control.at("group").get().empty()); - const auto& amis = control.at("amis"); - EXPECT_EQ(amis.at("name"), id); const std::string input = control.at("input"); const auto apply = [&](const nlohmann::json& value) { @@ -337,28 +325,16 @@ TEST(RenderiveWebGallery, EveryPublishedControlValidatesItsCompleteInputContract }; if (input == "boolean") { - EXPECT_EQ(amis.at("type"), "switch"); const bool alternative = !control.at("value").get(); const auto accepted = apply(alternative); ASSERT_TRUE(accepted.candidate.has_value()); EXPECT_EQ(std::get(accepted.candidate->values.at(id)), alternative); expect_rejected(1); } else if (input == "number") { - EXPECT_EQ(amis.at("type"), "input-number"); - EXPECT_TRUE(amis.at("required").get()); const double minimum = control.at("minimum"); const double maximum = control.at("maximum"); const double current = control.at("value"); const double step = std::max(control.at("step").get(), 1e-9); - EXPECT_DOUBLE_EQ(amis.at("min"), minimum); - EXPECT_DOUBLE_EQ(amis.at("max"), maximum); - EXPECT_DOUBLE_EQ(amis.at("step"), control.at("step").get()); - EXPECT_DOUBLE_EQ(amis.at("validations").at("minimum"), minimum); - EXPECT_DOUBLE_EQ(amis.at("validations").at("maximum"), maximum); - if (control.at("integer").get()) { - EXPECT_EQ(amis.at("precision"), 0); - EXPECT_TRUE(amis.at("validations").at("isInt").get()); - } double alternative = current + step; if (alternative > maximum) alternative = current - step; @@ -380,11 +356,7 @@ TEST(RenderiveWebGallery, EveryPublishedControlValidatesItsCompleteInputContract expect_rejected(std::clamp(std::floor(current) + 0.5, minimum + 0.5, maximum - 0.5)); } else if (input == "select") { - EXPECT_EQ(amis.at("type"), "select"); - EXPECT_TRUE(amis.at("required").get()); - EXPECT_FALSE(amis.at("clearable").get()); const auto& options = control.at("options"); - EXPECT_EQ(amis.at("options").size(), options.size()); ASSERT_FALSE(options.empty()); const std::string current = control.at("value"); const auto alternative = std::find_if( @@ -399,10 +371,6 @@ TEST(RenderiveWebGallery, EveryPublishedControlValidatesItsCompleteInputContract expect_rejected("__invalid_gallery_option__"); expect_rejected(7); } else if (input == "color") { - EXPECT_EQ(amis.at("type"), "input-color"); - EXPECT_TRUE(amis.at("required").get()); - EXPECT_FALSE(amis.at("clearable").get()); - EXPECT_EQ(amis.at("validations").at("matchRegexp"), "^#[0-9A-Fa-f]{6}$"); const std::string value = control.at("value") == "#123456" ? "#654321" : "#123456"; const auto accepted = apply(value); @@ -412,8 +380,6 @@ TEST(RenderiveWebGallery, EveryPublishedControlValidatesItsCompleteInputContract expect_rejected(7); } else { ASSERT_EQ(input, "text"); - EXPECT_EQ(amis.at("type"), "input-text"); - EXPECT_EQ(amis.at("validations").at("maxLength"), 160); std::string value = control.at("value").get() + "_qa"; const auto accepted = apply(value); ASSERT_TRUE(accepted.candidate.has_value()); @@ -449,13 +415,6 @@ TEST(RenderiveWebGallery, EveryPublishedActionDispatchesToItsCanvasAndFrameMode) EXPECT_TRUE(ids.insert(id).second); EXPECT_FALSE(action.at("api").get().empty()); EXPECT_FALSE(action.at("group").get().empty()); - const std::string argument_input = action.at("argument_input"); - if (!argument_input.empty()) { - const auto& amis = action.at("argument_amis"); - EXPECT_EQ(amis.at("name"), "action_" + id); - EXPECT_TRUE(amis.at("required").get()); - EXPECT_EQ(amis.at("type"), argument_input == "number" ? "input-number" : "input-text"); - } Gallery_Plot_Session session; ASSERT_EQ(response_json(session.handle(gallery_request( @@ -700,7 +659,7 @@ TEST(RenderiveWebGallery, SelectionOverlayPointerLifecycleCreatesAndClearsRegion EXPECT_FALSE(session.handle(*event).has_value()); } - EXPECT_EQ(refresh_telemetry(session).at("selection_regions"), 1); + EXPECT_EQ(observe_telemetry(session).at("selection_regions"), 1); EXPECT_EQ(invoke_action(session, "clear_selection").at("telemetry") .at("selection_regions"), 0); const auto rebound = invoke_action(session, "rebind_selection_axes").at("telemetry"); @@ -756,7 +715,7 @@ TEST(RenderiveWebGallery, EveryCanvasAcceptsTheCompleteWebEventSetAndStillRender EXPECT_EQ(frame->payload.substr(0, 4), "RVP1"); EXPECT_EQ(read_u32_le(frame->payload, 4), 420U); EXPECT_EQ(read_u32_le(frame->payload, 8), 260U); - const auto telemetry = refresh_telemetry(session); + const auto telemetry = observe_telemetry(session); EXPECT_TRUE(telemetry.at("view_active").get()); EXPECT_EQ(telemetry.at("viewport").at("width"), 420); EXPECT_EQ(telemetry.at("viewport").at("height"), 260); @@ -809,11 +768,11 @@ TEST(RenderiveWebGallery, EveryIndependentCore2CanvasBuildsAndRenders) { EXPECT_EQ(read_u32_le(frame->payload, 4), 480U) << case_id; EXPECT_EQ(read_u32_le(frame->payload, 8), 280U) << case_id; const auto observed = session.handle(gallery_request( - Gallery_Request_Kind::Refresh, - R"({"category":"event","type":"gallery_refresh"})")); + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe"})")); ASSERT_TRUE(observed.has_value()) << case_id; const auto telemetry = parse_json(observed->payload); - EXPECT_EQ(telemetry.at("type"), "case_state") << case_id; + EXPECT_EQ(telemetry.at("type"), "observer_state") << case_id; EXPECT_EQ(telemetry.at("frame_mode").at("id"), std::string(mode)) << case_id; EXPECT_TRUE(telemetry.at("telemetry").contains("kernel_observer")) << case_id; EXPECT_TRUE(telemetry.at("telemetry").contains("performance")) << case_id; @@ -824,54 +783,6 @@ TEST(RenderiveWebGallery, EveryIndependentCore2CanvasBuildsAndRenders) { } } -TEST(RenderiveWebGallery, BackendPublishesAmisValidationSchemas) { - Gallery_Plot_Session low_latency; - const auto opened = low_latency.handle(gallery_request( - Gallery_Request_Kind::Open, open_message("spectrum", "low_latency"))); - ASSERT_TRUE(opened.has_value()); - const auto state = parse_json(opened->payload); - const auto& controls = state.at("controls").at("data"); - const auto max_fps = std::find_if(controls.begin(), controls.end(), [](const auto& item) { - return item.at("id") == "max_render_fps"; - }); - ASSERT_NE(max_fps, controls.end()); - const auto& max_fps_amis = max_fps->at("amis"); - EXPECT_EQ(max_fps_amis.at("type"), "input-number"); - EXPECT_TRUE(max_fps_amis.at("required").get()); - EXPECT_DOUBLE_EQ(max_fps_amis.at("min").get(), 0.01); - EXPECT_DOUBLE_EQ(max_fps_amis.at("max").get(), 1'000'000'000.0); - EXPECT_DOUBLE_EQ(max_fps_amis.at("validations").at("minimum").get(), 0.01); - EXPECT_DOUBLE_EQ(max_fps_amis.at("validations").at("maximum").get(), 1'000'000'000.0); - const auto background = std::find_if(controls.begin(), controls.end(), [](const auto& item) { - return item.at("id") == "background_color"; - }); - ASSERT_NE(background, controls.end()); - EXPECT_TRUE(background->at("amis").at("required").get()); - EXPECT_FALSE(background->at("amis").at("clearable").get()); - EXPECT_EQ(background->at("amis").at("validations").at("matchRegexp"), - "^#[0-9A-Fa-f]{6}$"); - const auto patched = Gallery_Protocol::apply_patch( - "spectrum", Gallery_Protocol::default_state("spectrum", Gallery_Frame_Mode::Low_Latency), - R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":100}})", - Gallery_Frame_Mode::Low_Latency); - ASSERT_TRUE(patched.candidate.has_value()); - EXPECT_DOUBLE_EQ(std::get(patched.candidate->values.at("max_render_fps")), 100.0); - Gallery_Plot_Session playback; - const auto playback_opened = playback.handle(gallery_request( - Gallery_Request_Kind::Open, open_message("spectrum", "playback"))); - ASSERT_TRUE(playback_opened.has_value()); - const auto playback_state = parse_json(playback_opened->payload); - const auto& actions = playback_state.at("actions").at("data"); - const auto burst = std::find_if(actions.begin(), actions.end(), [](const auto& item) { - return item.at("id") == "mode_enqueue_burst"; - }); - ASSERT_NE(burst, actions.end()); - const auto& burst_amis = burst->at("argument_amis"); - EXPECT_EQ(burst_amis.at("type"), "input-number"); - EXPECT_EQ(burst_amis.at("name"), "action_mode_enqueue_burst"); - EXPECT_TRUE(burst_amis.at("required").get()); -} - TEST(RenderiveWebGallery, ActionsMutateLiveCore2ObjectsAndReturnTelemetry) { Gallery_Plot_Session session; ASSERT_TRUE(session.handle(gallery_request( @@ -908,10 +819,10 @@ TEST(RenderiveWebGallery, ThreeFrameStrategiesExposeTheirRealActionsAndObservers request["argument"] = *argument; return session.handle(gallery_request(Gallery_Request_Kind::Action, request.dump())); }; - const auto refresh = [](Gallery_Plot_Session& session) { + const auto observe = [](Gallery_Plot_Session& session) { const auto response = session.handle(gallery_request( - Gallery_Request_Kind::Refresh, - R"({"category":"event","type":"gallery_refresh"})")); + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe"})")); EXPECT_TRUE(response.has_value()); return parse_json(response->payload).at("telemetry"); }; @@ -920,12 +831,12 @@ TEST(RenderiveWebGallery, ThreeFrameStrategiesExposeTheirRealActionsAndObservers ASSERT_TRUE(manual.handle(gallery_request( Gallery_Request_Kind::Open, open_message("spectrum", "manual")))); ASSERT_TRUE(action(manual, "mode_prepare")); - auto manual_observer = refresh(manual).at("kernel_observer"); + auto manual_observer = observe(manual).at("kernel_observer"); EXPECT_EQ(manual_observer.at("last_event"), "prepared"); EXPECT_EQ(manual_observer.at("pending_frame_count"), 1); ASSERT_TRUE(action(manual, "mode_refresh")); ASSERT_TRUE(action(manual, "mode_render")); - manual_observer = refresh(manual).at("kernel_observer"); + manual_observer = observe(manual).at("kernel_observer"); EXPECT_EQ(manual_observer.at("last_event"), "rendered"); EXPECT_GE(manual_observer.at("consumed_frame_count").get(), 2U); @@ -938,7 +849,7 @@ TEST(RenderiveWebGallery, ThreeFrameStrategiesExposeTheirRealActionsAndObservers ASSERT_TRUE(high_frequency.has_value()); ASSERT_EQ(parse_json(high_frequency->payload).at("type"), "case_state"); ASSERT_TRUE(low_latency.handle(Frame_Request{})); - const auto low_telemetry = refresh(low_latency); + const auto low_telemetry = observe(low_latency); EXPECT_EQ(low_telemetry.at("frame_mode"), "low_latency"); EXPECT_GT(low_telemetry.at("kernel_observer").at("observation_count").get(), 0U); EXPECT_TRUE(low_telemetry.contains("render_fps")); @@ -971,11 +882,11 @@ TEST(RenderiveWebGallery, ThreeFrameStrategiesExposeTheirRealActionsAndObservers ASSERT_TRUE(playback.handle(gallery_request( Gallery_Request_Kind::Open, open_message("spectrum", "playback")))); ASSERT_TRUE(action(playback, "mode_enqueue_burst", 5)); - auto playback_observer = refresh(playback).at("kernel_observer"); + auto playback_observer = observe(playback).at("kernel_observer"); EXPECT_EQ(playback_observer.at("last_event"), "enqueued"); EXPECT_EQ(playback_observer.at("pending_frame_count"), 5); ASSERT_TRUE(action(playback, "mode_dequeue")); - playback_observer = refresh(playback).at("kernel_observer"); + playback_observer = observe(playback).at("kernel_observer"); EXPECT_EQ(playback_observer.at("last_event"), "rendered"); EXPECT_EQ(playback_observer.at("pending_frame_count"), 4); EXPECT_GT(playback_observer.at("queue_wait_ns").get(), 0U); @@ -991,7 +902,7 @@ TEST(RenderiveWebGallery, ManualStrategyRunsOnlyExplicitPrepareRefreshRenderCycl EXPECT_EQ(baseline.at("limit_state"), "not_applicable"); std::this_thread::sleep_for(std::chrono::milliseconds(80)); - auto observer = refresh_telemetry(session).at("kernel_observer"); + auto observer = observe_telemetry(session).at("kernel_observer"); EXPECT_EQ(observer.at("latest_sequence"), baseline.at("latest_sequence")); EXPECT_EQ(observer.at("produced_frame_count"), baseline.at("produced_frame_count")); EXPECT_EQ(observer.at("consumed_frame_count"), baseline.at("consumed_frame_count")); @@ -1032,7 +943,7 @@ TEST(RenderiveWebGallery, ManualStrategyRunsOnlyExplicitPrepareRefreshRenderCycl const auto sequence_after_actions = observer.at("latest_sequence"); std::this_thread::sleep_for(std::chrono::milliseconds(80)); - EXPECT_EQ(refresh_telemetry(session).at("kernel_observer").at("latest_sequence"), + EXPECT_EQ(observe_telemetry(session).at("kernel_observer").at("latest_sequence"), sequence_after_actions); } @@ -1056,7 +967,7 @@ TEST(RenderiveWebGallery, LowLatencyStrategyKeepsForegroundResponsiveUnderAggres const auto frame = session.handle(Frame_Request{}); ASSERT_TRUE(frame.has_value()) << iteration; ASSERT_EQ(frame->type, Web_Response_Type::Pixels) << iteration; - const auto telemetry = refresh_telemetry(session); + const auto telemetry = observe_telemetry(session); EXPECT_LT(std::chrono::steady_clock::now() - started, std::chrono::milliseconds(250)) << iteration; EXPECT_EQ(telemetry.at("kernel_observer").at("configured_frequency_hz"), 1000.0); @@ -1067,7 +978,7 @@ TEST(RenderiveWebGallery, LowLatencyStrategyKeepsForegroundResponsiveUnderAggres } EXPECT_NE(first_pixels, last_pixels); - const auto telemetry = refresh_telemetry(session); + const auto telemetry = observe_telemetry(session); const auto& observer = telemetry.at("kernel_observer"); EXPECT_EQ(observer.at("target_interval_ns"), 1'000'000); EXPECT_GT(observer.at("latest_sequence").get(), 4U); @@ -1111,7 +1022,7 @@ TEST(RenderiveWebGallery, FrequencyLimitCannotBeBypassedByForegroundRescheduleEv const auto rendered = after - before; EXPECT_GT(rendered, 0U); EXPECT_LE(rendered, 16U); - const auto telemetry = refresh_telemetry(session); + const auto telemetry = observe_telemetry(session); const auto& observer = telemetry.at("kernel_observer"); EXPECT_EQ(observer.at("target_interval_ns"), 33'333'333); EXPECT_GE(observer.at("next_refresh_interval_ns").get(), 33'333'333U); @@ -1127,10 +1038,10 @@ TEST(RenderiveWebGallery, ConsumerFeedbackCannotBeBypassedByForegroundReschedule {"frequency_limit_enabled", false}, {"consumer_feedback_enabled", true} }).at("type"), "case_state"); - send_client_feedback(session, - R"({"category":"event","type":"gallery_feedback","client_metrics":{"transport_fps":25,"presentation_fps":25,"websocket_buffered_bytes":0,"changed_pixel_frames":1,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":5,"display_interval_ms":40,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})"); - auto observed = refresh_telemetry(session); - auto observer = observed.at("kernel_observer"); + auto observed = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":25,"presentation_fps":25,"websocket_buffered_bytes":0,"changed_pixel_frames":1,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":5,"display_interval_ms":40,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})"))); + auto observer = observed.at("telemetry").at("kernel_observer"); EXPECT_FALSE(observer.at("frequency_limit_enabled").get()); EXPECT_TRUE(observer.at("consumer_feedback_enabled").get()); EXPECT_EQ(observer.at("consumer_interval_ns"), 40'000'000); @@ -1151,7 +1062,7 @@ TEST(RenderiveWebGallery, ConsumerFeedbackCannotBeBypassedByForegroundReschedule const auto rendered = after - before; EXPECT_GT(rendered, 0U); EXPECT_LE(rendered, 14U); - observed = refresh_telemetry(session); + observed = observe_telemetry(session); observer = observed.at("kernel_observer"); EXPECT_EQ(observer.at("consumer_interval_ns"), 40'000'000); EXPECT_GE(observer.at("next_refresh_interval_ns").get(), 40'000'000U); @@ -1166,11 +1077,11 @@ TEST(RenderiveWebGallery, WebRuntimeMetricsPublishKernelSmoothedConsumerFeedback Gallery_Request_Kind::Patch, R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":1000000000}})"))).at("type"), "case_state"); - send_client_feedback(session, - R"({"category":"event","type":"gallery_feedback","client_metrics":{"transport_fps":50,"presentation_fps":50,"websocket_buffered_bytes":0,"changed_pixel_frames":10,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":80,"display_interval_ms":20,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})"); - auto observed = refresh_telemetry(session); - auto observer = observed.at("kernel_observer"); - auto limit = observed.at("low_latency_limit"); + auto observed = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":50,"presentation_fps":50,"websocket_buffered_bytes":0,"changed_pixel_frames":10,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":80,"display_interval_ms":20,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})"))); + auto observer = observed.at("telemetry").at("kernel_observer"); + auto limit = observed.at("telemetry").at("low_latency_limit"); EXPECT_TRUE(observer.at("frequency_limit_enabled").get()); EXPECT_TRUE(observer.at("consumer_feedback_enabled").get()); EXPECT_EQ(observer.at("consumer_sample_interval_ns"), 20'000'000); @@ -1180,10 +1091,10 @@ TEST(RenderiveWebGallery, WebRuntimeMetricsPublishKernelSmoothedConsumerFeedback EXPECT_EQ(observer.at("consumer_interval_ns"), 20'000'000); EXPECT_EQ(observer.at("limit_state"), "consumer_limited"); EXPECT_EQ(limit.at("consumer_feedback_source"), "presentation"); - send_client_feedback(session, - R"({"category":"event","type":"gallery_feedback","client_metrics":{"transport_fps":45,"presentation_fps":45,"websocket_buffered_bytes":0,"changed_pixel_frames":20,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":4,"display_interval_ms":22,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})"); - observed = refresh_telemetry(session); - observer = observed.at("kernel_observer"); + observed = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":45,"presentation_fps":45,"websocket_buffered_bytes":0,"changed_pixel_frames":20,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":4,"display_interval_ms":22,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})"))); + observer = observed.at("telemetry").at("kernel_observer"); EXPECT_EQ(observer.at("consumer_sample_interval_ns"), 22'000'000); EXPECT_EQ(observer.at("consumer_smoothed_interval_ns"), 21'000'000); EXPECT_EQ(observer.at("consumer_variation_ns"), 125'000); @@ -1218,10 +1129,10 @@ TEST(RenderiveWebGallery, ConsumerFeedbackSourcesCanBeSelectedIndependentlyAndMa {"consumer_presentation_feedback_enabled", false}, {"consumer_manual_feedback_enabled", false} }).at("type"), "case_state"); - send_client_feedback(session, - R"({"category":"event","type":"gallery_feedback","client_metrics":{"transport_fps":25,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":1,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":5,"display_interval_ms":16,"display_interval_latest_ms":16,"display_interval_p95_ms":17,"display_jitter_ms":1,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})"); - auto observed = refresh_telemetry(session); - auto observer = observed.at("kernel_observer"); + auto observed = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":25,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":1,"duplicate_pixel_frames":0,"frame_request_timeout_count":0,"frame_round_trip_ms":5,"display_interval_ms":16,"display_interval_latest_ms":16,"display_interval_p95_ms":17,"display_jitter_ms":1,"overwritten_pixel_frames":0,"last_pixel_receive_age_ms":1,"last_pixel_change_age_ms":1}})"))); + auto observer = observed.at("telemetry").at("kernel_observer"); EXPECT_EQ(observer.at("consumer_feedback_source"), "pixel"); EXPECT_EQ(observer.at("consumer_pixel_interval_ns"), 40'000'000); EXPECT_EQ(observer.at("consumer_presentation_interval_ns"), 16'000'000); @@ -1262,7 +1173,7 @@ TEST(RenderiveWebGallery, PlaybackStrategyQueuesWithoutAutomaticConsumptionAndRe EXPECT_EQ(baseline.at("limit_state"), "not_applicable"); std::this_thread::sleep_for(std::chrono::milliseconds(80)); - auto observer = refresh_telemetry(session).at("kernel_observer"); + auto observer = observe_telemetry(session).at("kernel_observer"); EXPECT_EQ(observer.at("latest_sequence"), baseline.at("latest_sequence")); EXPECT_EQ(observer.at("consumed_frame_count"), baseline.at("consumed_frame_count")); @@ -1271,7 +1182,7 @@ TEST(RenderiveWebGallery, PlaybackStrategyQueuesWithoutAutomaticConsumptionAndRe EXPECT_EQ(observer.at("last_event"), "enqueued"); EXPECT_EQ(observer.at("pending_frame_count"), 5); std::this_thread::sleep_for(std::chrono::milliseconds(20)); - EXPECT_EQ(refresh_telemetry(session).at("kernel_observer").at("pending_frame_count"), 5); + EXPECT_EQ(observe_telemetry(session).at("kernel_observer").at("pending_frame_count"), 5); state = invoke_action(session, "mode_dequeue"); observer = state.at("telemetry").at("kernel_observer"); @@ -1298,7 +1209,7 @@ TEST(RenderiveWebGallery, PlaybackStrategyQueuesWithoutAutomaticConsumptionAndRe const auto sequence_after_actions = observer.at("latest_sequence"); std::this_thread::sleep_for(std::chrono::milliseconds(80)); - EXPECT_EQ(refresh_telemetry(session).at("kernel_observer").at("latest_sequence"), + EXPECT_EQ(observe_telemetry(session).at("kernel_observer").at("latest_sequence"), sequence_after_actions); } @@ -1335,9 +1246,11 @@ TEST(RenderiveWebGallery, ProductionLowLatencySessionRendersWithoutPixelPulls) { ASSERT_TRUE(wait_for_condition([&session] { return successful_render_count(session) >= 4; })); - send_client_feedback(session, - R"({"category":"event","type":"gallery_feedback","client_metrics":{"transport_fps":120,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":17,"duplicate_pixel_frames":3,"frame_request_timeout_count":2,"frame_round_trip_ms":4.5,"display_interval_ms":16.7,"display_interval_latest_ms":16.9,"display_interval_p95_ms":17.4,"display_jitter_ms":0.7,"overwritten_pixel_frames":5,"last_pixel_receive_age_ms":8.5,"last_pixel_change_age_ms":12.5}})"); - const auto telemetry = refresh_telemetry(session); + const auto observed = session.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":120,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":17,"duplicate_pixel_frames":3,"frame_request_timeout_count":2,"frame_round_trip_ms":4.5,"display_interval_ms":16.7,"display_interval_latest_ms":16.9,"display_interval_p95_ms":17.4,"display_jitter_ms":0.7,"overwritten_pixel_frames":5,"last_pixel_receive_age_ms":8.5,"last_pixel_change_age_ms":12.5}})")); + ASSERT_TRUE(observed.has_value()); + const auto telemetry = parse_json(observed->payload).at("telemetry"); const auto& performance = telemetry.at("performance"); EXPECT_TRUE(performance.at("automatic_low_latency_scheduler").get()); EXPECT_GE(performance.at("successful_render_count").get(), 4U); @@ -1449,8 +1362,8 @@ void expect_low_latency_canvas_to_change(std::string_view case_id) { EXPECT_NE(first->payload, second->payload); const auto observed = session.handle(gallery_request( - Gallery_Request_Kind::Refresh, - R"({"category":"event","type":"gallery_refresh"})")); + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe"})")); ASSERT_TRUE(observed.has_value()); const auto telemetry = parse_json(observed->payload).at("telemetry"); EXPECT_GT(telemetry.at("frame_index").get(), 2U); @@ -1525,8 +1438,8 @@ TEST(RenderiveWebGallery, BuilderOnlyControlsRebuildAndAllImageModesRender) { ASSERT_TRUE(palette.has_value()); ASSERT_TRUE(waterfall.handle(Frame_Request{}).has_value()); const auto waterfall_observer = waterfall.handle(gallery_request( - Gallery_Request_Kind::Refresh, - R"({"category":"event","type":"gallery_refresh"})")); + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe"})")); ASSERT_TRUE(waterfall_observer.has_value()); const auto waterfall_telemetry = parse_json(waterfall_observer->payload).at("telemetry"); EXPECT_GT(waterfall_telemetry.at("waterfall").at("row_count").get(), 0U); diff --git a/webapp_gallery/app.js b/webapp_gallery/app.js index 5fb0d09..869e862 100644 --- a/webapp_gallery/app.js +++ b/webapp_gallery/app.js @@ -1,41 +1,52 @@ "use strict"; + +const $ = id => document.getElementById(id); +const elements = { + pages: $("pages"), pageTemplate: $("page-template"), cardTemplate: $("card-template"), + connection: $("connection-status"), filters: $("category-filter"), modeTabs: $("mode-tabs"), + streamToggle: $("toggle-streams"), pageCount: $("page-count"), caseCount: $("case-count"), + canvasCount: $("canvas-count"), apiCount: $("api-count"), modeDescription: $("mode-description"), + menu: $("context-menu"), menuTitle: $("menu-title"), menuComponent: $("menu-component"), + menuDescription: $("menu-description"), menuBody: $("menu-body"), menuStatus: $("menu-status"), + menuClose: $("menu-close"), menuTabs: [...document.querySelectorAll(".menu-tabs button")], toast: $("toast") +}; + const query = new URLSearchParams(location.search); const hosted = location.protocol !== "file:" && ["/", "/index.html", "/gallery", "/gallery/"].includes(location.pathname); const jetBrainsPreview = location.port === "63342"; const socketPort = query.get("port") || (hosted && !jetBrainsPreview ? location.port : "8848") || "8848"; const socketHost = query.get("host") || (hosted ? location.hostname : "127.0.0.1") || "127.0.0.1"; const socketUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${socketHost}:${socketPort}/renderive/gallery`; -const amisEmbed = window.amisRequire("amis/embed"); -const amisLib = window.amisRequire("amis"); -const React = window.amisRequire("react"); -const sessions = new Map(); -const rootNode = document.getElementById("root"); -const menuHost = document.getElementById("menu-host"); -let catalog = null; + +const pages = new Map(); let definitions = []; let modes = []; -let mainScoped = null; -let menuScoped = null; -let menuSession = null; -let selectedCategory = "全部"; +let activeMode = "low_latency"; +let activeCategory = "全部"; +let activeCard = null; +let activeTab = "controls"; let streamsPaused = false; -let connectionState = "connecting"; -let connectionText = "读取后端目录"; -let noticeText = ""; -let noticeError = false; -let noticeTimer = 0; -let menuStatus = "字段、验证与 API 映射来自后端 JSON"; +let toastTimer = 0; let lastAnimationFrameAt = 0; let displayIntervalMs = 0; let displayIntervalLatestMs = 0; let displayIntervalP95Ms = 0; let displayJitterMs = 0; const displayIntervalSamples = []; + const message = (type, payload = {}) => JSON.stringify({category: "event", type, ...payload}); -const quote = value => JSON.stringify(String(value)); -const escapeHtml = value => String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); -const expression = path => `\${${path}}`; -const groupItems = items => { +const setConnection = (state, text) => { + elements.connection.dataset.state = state; + elements.connection.querySelector("span").textContent = text; +}; +function toast(text, error = false) { + clearTimeout(toastTimer); + elements.toast.textContent = text; + elements.toast.dataset.error = String(error); + elements.toast.hidden = false; + toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 2600); +} +function grouped(items) { const groups = new Map(); for (const item of items || []) { const group = item.group || "其他"; @@ -43,37 +54,19 @@ const groupItems = items => { groups.get(group).push(item); } return groups; -}; +} +function flatten(value, prefix = "", result = []) { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + for (const [key, child] of Object.entries(value)) flatten(child, prefix ? `${prefix}.${key}` : key, result); + } else result.push([prefix, typeof value === "object" ? JSON.stringify(value) : String(value)]); + return result; +} function formatNanoseconds(value) { const nanoseconds = Math.max(0, Number(value) || 0); if (nanoseconds < 1_000) return `${Math.round(nanoseconds)} ns`; if (nanoseconds < 1_000_000) return `${(nanoseconds / 1_000).toFixed(2)} µs`; return `${(nanoseconds / 1_000_000).toFixed(3)} ms`; } -function formatInteger(value) { - return Number(value || 0).toLocaleString(); -} -function formatFps(value, digits = 1) { - return Number(value || 0).toFixed(digits); -} -function normalizeEventValue(payload, name) { - if (payload !== null && typeof payload === "object") { - if (Object.prototype.hasOwnProperty.call(payload, name)) return payload[name]; - if (payload.data !== null && typeof payload.data === "object") return normalizeEventValue(payload.data, name); - if (Object.prototype.hasOwnProperty.call(payload, "value")) return payload.value; - } - return payload; -} -function notify(text, error = false) { - clearTimeout(noticeTimer); - noticeText = String(text || ""); - noticeError = Boolean(error); - updateMainData(); - noticeTimer = setTimeout(() => { - noticeText = ""; - updateMainData(); - }, 2600); -} function updateDisplayTiming(time) { if (lastAnimationFrameAt > 0) { displayIntervalLatestMs = Math.max(0, time - lastAnimationFrameAt); @@ -93,76 +86,14 @@ function updateDisplayTiming(time) { displayIntervalP95Ms = percentile(0.95); displayJitterMs = Math.max(0, displayIntervalP95Ms - displayIntervalMs); } -const observerMetricDefinitions = [ - ["configured_frequency_hz", "配置频率"], ["observation_count", "观察次数"], ["latest_sequence", "最新序号"], ["produced_frame_count", "发布"], - ["consumed_frame_count", "完成"], ["pending_frame_count", "待处理"], ["dropped_frame_count", "丢弃"], ["failed_operation_count", "失败"], - ["target_interval_ns", "目标间隔"], ["paint_duration_ns", "PaintEvent"], ["render_duration_ns", "后台渲染"], ["bottleneck_duration_ns", "内部瓶颈"], - ["consumer_feedback_master_enabled", "消费者总开关"], ["consumer_feedback_enabled", "Kernel 反馈有效"], ["consumer_pixel_feedback_enabled", "像素响应反馈"], ["consumer_presentation_feedback_enabled", "浏览器呈现反馈"], - ["consumer_manual_feedback_enabled", "手动消费者反馈"], ["consumer_manual_fps", "手动消费者 FPS"], ["consumer_feedback_source", "生效反馈来源"], ["consumer_pixel_interval_ns", "像素响应周期"], - ["consumer_presentation_interval_ns", "浏览器呈现周期"], ["consumer_manual_interval_ns", "手动消费者周期"], ["consumer_sample_interval_ns", "消费者原始采样"], ["consumer_smoothed_interval_ns", "消费者平滑周期"], - ["consumer_variation_ns", "消费者抖动"], ["consumer_safety_interval_ns", "消费者安全期限"], ["consumer_interval_ns", "消费者限速周期"], ["consumer_effective_fps", "消费者限速 FPS"], - ["next_refresh_interval_ns", "下次刷新"], ["paint_lease_wait_ns", "Paint lease 等待"], ["paint_state_wait_ns", "Paint state 等待"], ["publish_state_wait_ns", "Publish state 等待"], - ["ready_wait_ns", "Ready 等待"], ["frame_age_at_render_ns", "渲染时帧龄"], ["render_lease_wait_ns", "Render lease 等待"], ["render_state_wait_ns", "Render state 等待"], - ["render_finish_state_wait_ns", "Render finish 等待"], ["queue_wait_ns", "Queue 等待"], ["end_to_end_ns", "端到端延迟"], ["last_event", "观察事件"] -]; -const clientMetricDefinitions = [ - ["display_interval_latest_ms", "RAF 最新周期"], ["display_interval_ms", "RAF 中位周期"], ["display_interval_p95_ms", "RAF P95 周期"], ["display_jitter_ms", "RAF P95-P50 抖动"], - ["frame_round_trip_ms", "WS 往返"], ["transport_fps", "像素响应 FPS"], ["presentation_fps", "浏览器呈现 FPS"], ["websocket_buffered_bytes", "WS 缓冲"], - ["overwritten_pixel_frames", "未呈现覆盖"], ["changed_pixel_frames", "变化像素帧"], ["duplicate_pixel_frames", "重复像素帧"], ["frame_request_timeout_count", "像素请求超时"], - ["last_pixel_receive_age_ms", "最近像素龄"], ["last_pixel_change_age_ms", "最近变化龄"] -]; -const mainMetricDefinitions = [ - ["backendFps", "后端渲染 FPS"], ["transportFps", "像素响应 FPS"], ["presentationFps", "浏览器呈现 FPS"], ["roundTrip", "WS 往返 ms"], ["overwritten", "未呈现覆盖"], - ["renderMs", "Core 渲染 ms"], ["encodeMs", "像素编码 ms"], ["bandwidth", "响应负载 MB/s"], ["pending", "Kernel 待处理"], ["dropped", "Kernel 丢弃"], - ["points", "输入→绘制"], ["limit", "当前瓶颈"], ["event", "观察事件"], ["timeouts", "像素超时"], ["pixelAge", "最近像素龄"] -]; -const limitDefinitions = [ - ["limitFrequency", "Kernel 用户频率"], ["limitPaint", "Kernel PaintEvent"], ["limitRender", "Kernel 后台渲染"], ["limitConsumer", "消费者反馈"] -]; -const nanosecondFields = new Set([ - "paint_duration_ns", "render_duration_ns", "target_interval_ns", "bottleneck_duration_ns", "consumer_pixel_interval_ns", "consumer_presentation_interval_ns", - "consumer_manual_interval_ns", "consumer_sample_interval_ns", "consumer_smoothed_interval_ns", "consumer_variation_ns", "consumer_safety_interval_ns", "consumer_interval_ns", - "next_refresh_interval_ns", "paint_lease_wait_ns", "paint_state_wait_ns", "publish_state_wait_ns", "ready_wait_ns", "frame_age_at_render_ns", "render_lease_wait_ns", - "render_state_wait_ns", "render_finish_state_wait_ns", "queue_wait_ns", "end_to_end_ns" -]); -const booleanObserverFields = new Set([ - "consumer_feedback_master_enabled", "consumer_feedback_enabled", "consumer_pixel_feedback_enabled", "consumer_presentation_feedback_enabled", "consumer_manual_feedback_enabled" -]); -function observerValue(name, observer) { - const raw = observer[name] ?? (name === "last_event" || name === "consumer_feedback_source" ? "none" : 0); - if (name === "consumer_effective_fps") { - const interval = Number(observer.consumer_interval_ns || 0); - return interval > 0 ? `${(1e9 / interval).toFixed(2)} FPS` : "0 FPS"; - } - if (name === "consumer_manual_fps") return `${Number(raw || 0).toFixed(2)} FPS`; - if (name === "consumer_feedback_source") { - const names = {disabled: "总开关关闭", none: "无", pixel: "像素响应", presentation: "浏览器呈现", manual: "手动"}; - return String(raw).split("+").map(value => names[value] || value).join(" + "); - } - if (booleanObserverFields.has(name)) return raw ? "启用" : "关闭"; - if (nanosecondFields.has(name)) return formatNanoseconds(raw); - if (name === "configured_frequency_hz") return observer.frequency_limit_enabled === false ? "已关闭" : `${Number(raw || 0).toLocaleString()} Hz`; - return typeof raw === "number" ? raw.toLocaleString() : String(raw); -} -function clientValue(name, client) { - const raw = Number(client[name] || 0); - if (name.endsWith("_fps")) return `${raw.toFixed(2)} FPS`; - if (name.endsWith("_ms")) return `${raw.toFixed(3)} ms`; - if (name === "websocket_buffered_bytes") return `${raw.toLocaleString()} B`; - return raw.toLocaleString(); -} -class GallerySession { - constructor(key, definition, mode) { - this.key = key; + +class GalleryCard { + constructor(definition, mode) { this.definition = definition; this.mode = mode; this.controls = []; this.actions = []; this.telemetry = {}; - this.controlValues = new Map(); - this.lastSubmittedControls = new Map(); - this.pendingStateRequests = []; - this.lastFeedbackAt = 0; this.frameCount = 0; this.framePending = false; this.frameTimeout = null; @@ -178,9 +109,6 @@ class GallerySession { this.lastPixelReceivedAt = 0; this.lastPixelChangeAt = 0; this.motionState = "waiting"; - this.motionText = "等待动态帧"; - this.socketState = "connecting"; - this.socketText = "等待可见"; this.reconnectTimer = null; this.disposed = false; this.ready = false; @@ -189,72 +117,55 @@ class GallerySession { this.frameRequestStartedAt = 0; this.frameRoundTripMs = 0; this.overwrittenPixelFrames = 0; - this.shell = null; - this.canvas = null; - this.context = null; - this.eventAbort = null; - this.resizeObserver = null; - this.intersectionObserver = null; - this.lastResizeWidth = 0; - this.lastResizeHeight = 0; - } - attach(shell, canvas) { - this.shell = shell; - this.canvas = canvas; - this.context = canvas.getContext("2d", {alpha: false}); - this.eventAbort = new AbortController(); - const options = {signal: this.eventAbort.signal}; - const pointer = (type, event) => this.send(type, {...this.position(event), button: ["left", "middle", "right"][event.button] || "none", buttons: event.buttons, modifiers: this.modifiers(event)}); - canvas.addEventListener("pointermove", event => pointer("pointer_move", event), options); - canvas.addEventListener("pointerdown", event => { - if (event.button === 2) return; - shell.focus(); - canvas.setPointerCapture(event.pointerId); - pointer("pointer_press", event); - }, options); - canvas.addEventListener("pointerup", event => { - if (event.button !== 2) pointer("pointer_release", event); - }, options); - canvas.addEventListener("pointerleave", () => this.send("leave"), options); - canvas.addEventListener("contextmenu", event => { + this.lastObserveRequest = 0; + this.node = elements.cardTemplate.content.firstElementChild.cloneNode(true); + this.node.dataset.category = definition.category; + this.node.dataset.mode = mode.id; + this.canvas = this.node.querySelector("canvas"); + this.shell = this.node.querySelector(".canvas-shell"); + this.context = this.canvas.getContext("2d", {alpha: false}); + this.socketState = this.node.querySelector(".card-socket"); + this.motionStatus = this.node.querySelector(".motion-status"); + this.observerFields = new Map([...this.node.querySelectorAll("[data-observer-field]")] + .map(field => [field.dataset.observerField, field])); + this.frameLabel = this.node.querySelector(".card-frames"); + this.node.querySelector(".card-category").textContent = definition.category; + this.node.querySelector(".card-title").textContent = definition.title; + this.node.querySelector(".card-description").textContent = definition.description; + this.node.querySelector(".card-component").textContent = definition.component; + this.node.querySelector(".card-controls").textContent = definition.control_count_by_mode?.[mode.id] ?? "—"; + this.node.querySelector(".card-actions").textContent = definition.action_count_by_mode?.[mode.id] ?? "—"; + const frameButton = this.node.querySelector(".frame-button"); + frameButton.textContent = mode.id === "manual" ? "手动刷新一帧" : mode.id === "playback" ? "消费下一帧" : "立即刷新"; + frameButton.addEventListener("click", () => this.requestFrame(performance.now(), true)); + this.node.querySelector(".open-menu").addEventListener("click", event => { + const rect = event.currentTarget.getBoundingClientRect(); + openMenu(this, rect.right, rect.bottom); + }); + this.node.addEventListener("contextmenu", event => { event.preventDefault(); - window.RenderiveGallery.openMenu(this.key); - }, options); - canvas.addEventListener("wheel", event => { - event.preventDefault(); - this.send("wheel", {...this.position(event), pixelDeltaX: event.deltaX, pixelDeltaY: event.deltaY, angleDeltaX: -event.deltaX * 8, angleDeltaY: -event.deltaY * 8, buttons: event.buttons, modifiers: this.modifiers(event)}); - }, {passive: false, signal: this.eventAbort.signal}); - shell.addEventListener("keydown", event => this.send("key_press", {key: event.key, nativeKey: event.keyCode, repeat: event.repeat, modifiers: this.modifiers(event)}), options); - shell.addEventListener("keyup", event => this.send("key_release", {key: event.key, nativeKey: event.keyCode, repeat: false, modifiers: this.modifiers(event)}), options); + openMenu(this, event.clientX, event.clientY); + }); + this.installCanvasEvents(); this.resizeObserver = new ResizeObserver(() => this.resize()); - this.resizeObserver.observe(shell); + this.resizeObserver.observe(this.shell); this.intersectionObserver = new IntersectionObserver(entries => { const intersecting = entries[0]?.isIntersecting === true; if (this.intersecting === intersecting) return; this.intersecting = intersecting; this.syncActivity(); }, {rootMargin: "160px"}); - this.intersectionObserver.observe(shell); - this.syncActivity(true); + this.intersectionObserver.observe(this.node); } - detach() { - this.eventAbort?.abort(); - this.eventAbort = null; - this.resizeObserver?.disconnect(); - this.resizeObserver = null; - this.intersectionObserver?.disconnect(); - this.intersectionObserver = null; - this.intersecting = false; - this.lastResizeWidth = 0; - this.lastResizeHeight = 0; - this.shell = null; - this.canvas = null; - this.context = null; - if (this.ready && this.backendActive) this.send("hide"); - this.backendActive = false; + setSocketState(state, text) { + this.socketState.dataset.state = state; + this.socketState.querySelector("span").textContent = text; + } + categoryVisible() { + return activeCategory === "全部" || this.definition.category === activeCategory; } displayVisible() { - return !document.hidden && this.intersecting && this.shell !== null; + return !document.hidden && this.mode.id === activeMode && this.categoryVisible() && this.intersecting; } streamActive() { return !streamsPaused && this.displayVisible(); @@ -277,93 +188,160 @@ class GallerySession { clearTimeout(this.reconnectTimer); const socket = new WebSocket(socketUrl); this.socket = socket; - this.socketState = "connecting"; - this.socketText = "连接中"; - updateMainData(); socket.binaryType = "arraybuffer"; socket.addEventListener("open", () => { if (this.socket !== socket) return; - this.socketState = "ready"; - this.socketText = "WS 已连接"; - this.lastFeedbackAt = 0; - this.sendStateRequest("open", "gallery_open", {case: this.definition.id, frame_mode: this.mode.id}); + this.setSocketState("ready", "WS 已连接"); + this.send("gallery_open", {case: this.definition.id, frame_mode: this.mode.id}); this.resize(); }); socket.addEventListener("message", event => { - if (this.socket !== socket) return; - if (typeof event.data === "string") this.receiveJson(event.data); - else this.receivePixels(event.data); + if (this.socket === socket) + typeof event.data === "string" ? this.receiveJson(event.data) : this.receivePixels(event.data); }); socket.addEventListener("close", () => { if (this.socket !== socket) return; this.ready = false; - this.shell?.classList.remove("rv-canvas-ready"); this.backendActive = null; this.framePending = false; - clearTimeout(this.frameTimeout); - this.frameTimeout = null; - this.pendingStateRequests.length = 0; - this.lastSubmittedControls.clear(); - this.lastFeedbackAt = 0; - this.socketState = "error"; - this.socketText = this.displayVisible() ? "连接关闭 · 自动重连" : "连接关闭 · 等待可见"; + clearTimeout(this.frameTimeout); this.frameTimeout = null; + this.setSocketState("error", this.displayVisible() ? "连接关闭 · 自动重连" : "连接关闭 · 等待可见"); this.updateMotionStatus(); - updateMainData(); if (!this.disposed && this.displayVisible()) this.reconnectTimer = setTimeout(() => this.connect(), 1000); }); socket.addEventListener("error", () => { - if (this.socket !== socket) return; - this.socketState = "error"; - this.socketText = "连接错误 · 自动重连"; - updateMainData(); + if (this.socket === socket) this.setSocketState("error", "连接错误 · 自动重连"); }); } send(type, payload = {}) { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(message(type, payload)); } - sendStateRequest(kind, type, payload = {}) { - if (this.socket?.readyState !== WebSocket.OPEN) return; - this.pendingStateRequests.push(kind); - this.socket.send(message(type, payload)); - } receiveJson(raw) { let data; - try { - data = JSON.parse(raw); - } catch { - notify(`${this.definition.title} 返回无效 JSON`, true); + try { data = JSON.parse(raw); } catch { toast(`${this.definition.title} 返回无效 JSON`, true); return; } + if (data.type === "error") { + const detail = Object.values(data.field_errors || {})[0] || data.message; + toast(detail || "后端拒绝操作", true); + if (activeCard === this) elements.menuStatus.textContent = detail; return; } - if (data.type === "error") { - this.pendingStateRequests.shift(); - const detail = Object.values(data.field_errors || {})[0] || data.message || "后端拒绝操作"; - this.lastSubmittedControls.clear(); - notify(detail, true); - if (menuSession === this) { - menuStatus = detail; - updateMenuData(); - } + if (data.type === "observer_state") { + this.telemetry = data.telemetry || {}; + this.updatePerformance(); + if (activeCard === this && ["observer", "performance"].includes(activeTab)) renderMenuBody(); return; } if (data.type !== "case_state") return; - const requestKind = this.pendingStateRequests.shift(); this.controls = data.controls?.data || []; this.actions = data.actions?.data || []; - if (requestKind === "open" || requestKind === "refresh") this.telemetry = data.telemetry || {}; - this.controlValues.clear(); - for (const item of this.controls) this.controlValues.set(item.id, item.value); - this.lastSubmittedControls.clear(); + this.telemetry = data.telemetry || {}; this.ready = true; - this.shell?.classList.add("rv-canvas-ready"); this.backendActive = null; - this.socketState = "ready"; - this.socketText = `${data.frame_mode?.strategy || "Core2"} 在线`; + this.node.dataset.ready = "true"; this.syncActivity(); - if (data.notice && !data.notice.includes("已创建")) notify(`${this.definition.title}:${data.notice}`); - updateMainData(); - if (menuSession === this) { - menuStatus = data.notice || "后端状态已回读"; - updateMenuData(); + this.node.querySelector(".card-controls").textContent = this.controls.length; + this.node.querySelector(".card-actions").textContent = this.actions.length; + this.setSocketState("ready", `${data.frame_mode?.strategy || "Core2"} 在线`); + this.updatePerformance(); + if (data.notice && !data.notice.includes("已创建")) toast(`${this.definition.title}:${data.notice}`); + if (activeCard === this) { elements.menuStatus.textContent = data.notice || "后端状态已回读"; renderMenuBody(); } + } + updatePerformance() { + const performanceData = this.telemetry.performance || {}; + const observer = this.telemetry.kernel_observer || {}; + const dataShape = this.telemetry.data_shape || {}; + const limit = this.telemetry.low_latency_limit || {}; + const limitNames = {frequency_limited: "Kernel 频率受限", paint_limited: "PaintEvent 受限", render_limited: "后台渲染受限", consumer_limited: "消费者反馈受限", unlimited: "无限制", not_applicable: "N/A"}; + this.node.querySelector(".perf-fps").textContent = Number(performanceData.measured_fps || 0).toFixed(1); + this.node.querySelector(".perf-transport-fps").textContent = Number(performanceData.pixel_response_fps || 0).toFixed(1); + this.node.querySelector(".perf-present-fps").textContent = Number(this.presentationFps || 0).toFixed(1); + this.node.querySelector(".perf-rtt").textContent = Number(this.frameRoundTripMs || 0).toFixed(2); + this.node.querySelector(".perf-overwritten").textContent = this.overwrittenPixelFrames.toLocaleString(); + this.node.querySelector(".perf-render").textContent = Number(performanceData.last_render_ms || 0).toFixed(2); + this.node.querySelector(".perf-encode").textContent = Number(performanceData.last_pixel_encode_ms || 0).toFixed(2); + this.node.querySelector(".perf-bandwidth").textContent = Number(performanceData.pixel_payload_megabytes_per_second || 0).toFixed(1); + this.node.querySelector(".perf-pending").textContent = observer.pending_frame_count ?? 0; + this.node.querySelector(".perf-dropped").textContent = observer.dropped_frame_count ?? 0; + this.node.querySelector(".perf-points").textContent = `${Number(dataShape.input_points || 0).toLocaleString()}→${Number(dataShape.rendered_elements || 0).toLocaleString()}`; + const limitDurations = {frequency_limited: observer.target_interval_ns, paint_limited: observer.paint_duration_ns, render_limited: observer.render_duration_ns, consumer_limited: observer.consumer_interval_ns}; + const limitName = limitNames[limit.current] || limit.current || "N/A"; + const limitDuration = limitDurations[limit.current]; + const limitValue = limitDuration === undefined ? limitName : `${limitName} · ${formatNanoseconds(limitDuration)}`; + const limitNode = this.node.querySelector(".perf-limit"); + limitNode.textContent = limitValue; + limitNode.title = limitDuration === undefined ? limitName : `${limitName} · ${Number(limitDuration || 0).toLocaleString()} ns`; + this.node.querySelector(".perf-event").textContent = observer.last_event || "none"; + this.node.querySelector(".perf-timeouts").textContent = this.frameTimeoutCount.toLocaleString(); + this.node.querySelector(".perf-pixel-age").textContent = this.lastPixelReceivedAt ? `${Math.max(0, performance.now() - this.lastPixelReceivedAt).toFixed(0)} ms` : "—"; + this.updateObserverDashboard(observer); + this.updateClientDashboard(this.telemetry.client_performance || {}); + this.updateMotionStatus(); + const setLimitFlag = (selector, enabled, active, duration) => { + const flag = this.node.querySelector(selector); + flag.dataset.active = String(Boolean(active)); + flag.querySelector("b").textContent = enabled ? `${active ? "当前瓶颈" : "未受限"} · ${formatNanoseconds(duration)}` : `已关闭 · ${formatNanoseconds(duration)}`; + }; + setLimitFlag(".limit-frequency", observer.frequency_limit_enabled !== false, limit.current === "frequency_limited", observer.target_interval_ns); + setLimitFlag(".limit-paint", true, limit.current === "paint_limited", observer.paint_duration_ns); + setLimitFlag(".limit-render", true, limit.current === "render_limited", observer.render_duration_ns); + setLimitFlag(".limit-consumer", Boolean(observer.consumer_feedback_enabled), limit.current === "consumer_limited", observer.consumer_interval_ns); + } + updateObserverDashboard(observer) { + const nanosecondFields = new Set([ + "paint_duration_ns", "render_duration_ns", "target_interval_ns", + "bottleneck_duration_ns", "consumer_pixel_interval_ns", "consumer_presentation_interval_ns", + "consumer_manual_interval_ns", "consumer_sample_interval_ns", "consumer_smoothed_interval_ns", + "consumer_variation_ns", "consumer_safety_interval_ns", "consumer_interval_ns", "next_refresh_interval_ns", "paint_lease_wait_ns", + "paint_state_wait_ns", "publish_state_wait_ns", "ready_wait_ns", + "frame_age_at_render_ns", "render_lease_wait_ns", "render_state_wait_ns", + "render_finish_state_wait_ns", "queue_wait_ns", "end_to_end_ns" + ]); + const booleanFields = new Set([ + "consumer_feedback_master_enabled", "consumer_feedback_enabled", + "consumer_pixel_feedback_enabled", "consumer_presentation_feedback_enabled", + "consumer_manual_feedback_enabled" + ]); + for (const [name, field] of this.observerFields) { + const raw = observer[name] ?? (name === "limit_state" ? "not_applicable" : + name === "last_event" || name === "consumer_feedback_source" ? "none" : 0); + if (name === "consumer_effective_fps") { + const interval = Number(observer.consumer_interval_ns || 0); + field.textContent = interval > 0 ? `${(1e9 / interval).toFixed(2)} FPS` : "0 FPS"; + field.title = interval > 0 ? `${1e9 / interval} FPS` : "0 FPS"; + continue; + } + if (name === "consumer_manual_fps") { + field.textContent = `${Number(raw || 0).toFixed(2)} FPS`; + field.title = `${Number(raw || 0)} FPS`; + continue; + } + if (name === "consumer_feedback_source") { + const names = {disabled: "总开关关闭", none: "无", pixel: "像素响应", + presentation: "浏览器呈现", manual: "手动"}; + field.textContent = String(raw).split("+").map(value => names[value] || value).join(" + "); + field.title = String(raw); + continue; + } + if (booleanFields.has(name)) { + field.textContent = raw ? "启用" : "关闭"; + field.title = String(Boolean(raw)); + continue; + } + field.textContent = nanosecondFields.has(name) ? formatNanoseconds(raw) : + name === "configured_frequency_hz" ? (observer.frequency_limit_enabled === false ? "已关闭" : `${Number(raw || 0).toLocaleString()} Hz`) : + typeof raw === "number" ? raw.toLocaleString() : String(raw); + field.title = nanosecondFields.has(name) ? `${Number(raw || 0).toLocaleString()} ns` : String(raw); + } + } + updateClientDashboard(client) { + for (const field of this.node.querySelectorAll("[data-client-field]")) { + const name = field.dataset.clientField; + const raw = Number(client[name] || 0); + if (name.endsWith("_fps")) field.textContent = `${raw.toFixed(2)} FPS`; + else if (name.endsWith("_ms")) field.textContent = `${raw.toFixed(3)} ms`; + else if (name === "websocket_buffered_bytes") field.textContent = `${raw.toLocaleString()} B`; + else field.textContent = raw.toLocaleString(); + field.title = String(raw); } } pixelSignature(buffer, width, height, stride) { @@ -377,26 +355,38 @@ class GallerySession { } return hash; } + updateMotionStatus(now = performance.now()) { + let state = "waiting", label = "等待动态帧"; + if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) { + state = "stalled"; label = "像素流断开"; + } else if (!this.streamActive()) { + state = "waiting"; label = streamsPaused && this.displayVisible() ? "像素流已暂停" : "非活动视图"; + } else if (this.lastPixelChangeAt && now - this.lastPixelChangeAt < 1500) { + state = "moving"; label = `画面变化 ${this.changedPixelFrames.toLocaleString()}`; + } else if (this.lastPixelReceivedAt && now - this.lastPixelReceivedAt < 1500) { + state = "duplicate"; label = `重复像素帧 ${this.duplicatePixelFrames.toLocaleString()}`; + } else if (this.lastPixelReceivedAt) { + state = "stalled"; label = "像素帧已停滞"; + } + if (state !== this.motionState || this.motionStatus.querySelector("span").textContent !== label) { + this.motionState = state; + this.motionStatus.dataset.state = state; + this.motionStatus.querySelector("span").textContent = label; + } + } receivePixels(buffer) { this.framePending = false; - if (this.frameTimeout !== null) { - clearTimeout(this.frameTimeout); - this.frameTimeout = null; - } + if (this.frameTimeout !== null) { clearTimeout(this.frameTimeout); this.frameTimeout = null; } if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 16) { - this.socketState = "error"; - this.socketText = "像素帧无效 · 自动重连"; + this.setSocketState("error", "像素帧无效 · 自动重连"); this.socket?.close(1003, "invalid pixel frame"); return; } const header = new DataView(buffer, 0, 16); const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4)); - const width = header.getUint32(4, true); - const height = header.getUint32(8, true); - const stride = header.getUint32(12, true); + const width = header.getUint32(4, true), height = header.getUint32(8, true), stride = header.getUint32(12, true); if (magic !== "RVP1" || !width || !height || stride < width * 4 || buffer.byteLength < 16 + stride * height) { - this.socketState = "error"; - this.socketText = "像素帧协议错误 · 自动重连"; + this.setSocketState("error", "像素帧协议错误 · 自动重连"); this.socket?.close(1003, "invalid pixel frame"); return; } @@ -414,21 +404,19 @@ class GallerySession { } if (this.latestPixelBuffer !== null) this.overwrittenPixelFrames++; this.latestPixelBuffer = buffer; - this.socketState = "ready"; - this.socketText = `${this.mode.strategy || "Core2"} 在线`; + this.setSocketState("ready", `${this.mode.strategy || "Core2"} 在线`); this.transportFps = this.recordRate(this.transportTimes, now); this.frameCount++; + this.frameLabel.textContent = this.frameCount.toLocaleString(); this.updateMotionStatus(now); this.requestFrame(now); } presentLatest(time) { const buffer = this.latestPixelBuffer; - if (!buffer || !this.context || !this.canvas) return; + if (!buffer) return; this.latestPixelBuffer = null; const header = new DataView(buffer, 0, 16); - const width = header.getUint32(4, true); - const height = header.getUint32(8, true); - const stride = header.getUint32(12, true); + const width = header.getUint32(4, true), height = header.getUint32(8, true), stride = header.getUint32(12, true); if (this.canvas.width !== width || this.canvas.height !== height) { this.canvas.width = width; this.canvas.height = height; @@ -436,8 +424,7 @@ class GallerySession { if (stride === width * 4) { this.context.putImageData(new ImageData(new Uint8ClampedArray(buffer, 16, width * height * 4), width, height), 0, 0); } else { - const packed = new Uint8ClampedArray(width * height * 4); - const source = new Uint8Array(buffer, 16); + const packed = new Uint8ClampedArray(width * height * 4), source = new Uint8Array(buffer, 16); for (let row = 0; row < height; row++) packed.set(source.subarray(row * stride, row * stride + width * 4), row * width * 4); this.context.putImageData(new ImageData(packed, width, height), 0, 0); } @@ -453,13 +440,9 @@ class GallerySession { return this.currentRate(history, now); } resize() { - if (this.socket?.readyState !== WebSocket.OPEN || !this.shell) return; - const width = Math.max(240, Math.round(this.shell.clientWidth)); - const height = Math.max(180, Math.round(this.shell.clientHeight)); - if (width === this.lastResizeWidth && height === this.lastResizeHeight) return; - this.lastResizeWidth = width; - this.lastResizeHeight = height; - this.send("resize", {width, height}); + if (this.socket?.readyState !== WebSocket.OPEN) return; + const rect = this.shell.getBoundingClientRect(); + this.send("resize", {width: Math.max(240, Math.round(rect.width)), height: Math.max(180, Math.round(rect.height))}); } requestFrame(time, explicit = false) { if ((!explicit && !this.streamActive()) || (explicit && !this.displayVisible()) || !this.ready || this.framePending || this.socket?.readyState !== WebSocket.OPEN) return; @@ -473,448 +456,265 @@ class GallerySession { this.frameTimeout = null; this.frameTimeoutCount++; if (!this.streamActive() || this.socket?.readyState !== WebSocket.OPEN) return; - this.socketState = "error"; - this.socketText = "像素响应超时 · 正在恢复"; - updateMainData(); + this.setSocketState("error", "像素响应超时 · 正在恢复"); this.syncActivity(true); }, 1500); } - refreshBackendState() { - if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return; - this.pushClientFeedback(performance.now()); - this.sendStateRequest("refresh", "gallery_refresh"); - } - pushClientFeedback(time) { - this.lastFeedbackAt = time; + observe(time) { + if (!this.ready || !this.displayVisible() || this.socket?.readyState !== WebSocket.OPEN || time - this.lastObserveRequest < 650) return; + this.lastObserveRequest = time; this.transportFps = this.currentRate(this.transportTimes, time); this.presentationFps = this.currentRate(this.presentationTimes, time); - this.send("gallery_feedback", {client_metrics: this.clientMetrics(time)}); - } - sendClientFeedback(time) { - if (!this.ready || !this.displayVisible() || this.socket?.readyState !== WebSocket.OPEN || time - this.lastFeedbackAt < 650) return; - this.pushClientFeedback(time); - } - clientMetrics(time = performance.now()) { - return { + this.send("gallery_observe", {client_metrics: { transport_fps: this.transportFps, presentation_fps: this.presentationFps, - websocket_buffered_bytes: this.socket?.bufferedAmount || 0, + websocket_buffered_bytes: this.socket.bufferedAmount || 0, changed_pixel_frames: this.changedPixelFrames, duplicate_pixel_frames: this.duplicatePixelFrames, frame_request_timeout_count: this.frameTimeoutCount, frame_round_trip_ms: this.frameRoundTripMs, - display_interval_latest_ms: displayIntervalLatestMs, display_interval_ms: displayIntervalMs, + display_interval_latest_ms: displayIntervalLatestMs, display_interval_p95_ms: displayIntervalP95Ms, display_jitter_ms: displayJitterMs, overwritten_pixel_frames: this.overwrittenPixelFrames, last_pixel_receive_age_ms: this.lastPixelReceivedAt ? Math.max(0, time - this.lastPixelReceivedAt) : 0, last_pixel_change_age_ms: this.lastPixelChangeAt ? Math.max(0, time - this.lastPixelChangeAt) : 0 - }; - } - updateMotionStatus(now = performance.now()) { - let state = "waiting"; - let label = "等待动态帧"; - if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) { - state = "stalled"; - label = "像素流断开"; - } else if (!this.streamActive()) { - state = "waiting"; - label = streamsPaused && this.displayVisible() ? "像素流已暂停" : "非活动视图"; - } else if (this.lastPixelChangeAt && now - this.lastPixelChangeAt < 1500) { - state = "moving"; - label = `画面变化 ${this.changedPixelFrames.toLocaleString()}`; - } else if (this.lastPixelReceivedAt && now - this.lastPixelReceivedAt < 1500) { - state = "duplicate"; - label = `重复像素帧 ${this.duplicatePixelFrames.toLocaleString()}`; - } else if (this.lastPixelReceivedAt) { - state = "stalled"; - label = "像素帧已停滞"; - } - this.motionState = state; - this.motionText = label; + }}); } position(event) { const rect = this.canvas.getBoundingClientRect(); return {x: (event.clientX - rect.left) * this.canvas.width / Math.max(1, rect.width), y: (event.clientY - rect.top) * this.canvas.height / Math.max(1, rect.height)}; } - modifiers(event) { - return (event.shiftKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0); - } - commitControl(id, payload) { - const item = this.controls.find(control => control.id === id); - if (!item) return; - const value = normalizeEventValue(payload, id); - if (Object.is(this.lastSubmittedControls.get(id), value) || Object.is(this.controlValues.get(id), value)) return; - this.lastSubmittedControls.set(id, value); - this.sendStateRequest("patch", "gallery_patch", {patch: {[id]: value}}); - menuStatus = `提交 ${item.api} · 等待后端回读`; - } - runAction(id, payload) { - const item = this.actions.find(action => action.id === id); - if (!item) return; - const request = {action: item.id}; - if (item.argument_input) { - const field = `action_${item.id}`; - request.argument = normalizeEventValue(payload, field); - } - this.sendStateRequest("action", "gallery_action", request); - menuStatus = `执行 ${item.api}`; - if (["mode_render", "mode_dequeue", "mode_cycle"].includes(item.id)) setTimeout(() => this.requestFrame(performance.now(), true), 40); - } - controlData() { - const data = {}; - for (const item of this.controls) data[item.id] = this.controlValues.get(item.id) ?? item.value; - for (const item of this.actions) if (item.argument_input) data[`action_${item.id}`] = item.argument_default; - return data; - } - snapshot() { - const performanceData = this.telemetry.performance || {}; - const observer = this.telemetry.kernel_observer || {}; - const dataShape = this.telemetry.data_shape || {}; - const limit = this.telemetry.low_latency_limit || {}; - const limitNames = {frequency_limited: "Kernel 频率受限", paint_limited: "PaintEvent 受限", render_limited: "后台渲染受限", consumer_limited: "消费者反馈受限", unlimited: "无限制", not_applicable: "N/A"}; - const limitDurations = {frequency_limited: observer.target_interval_ns, paint_limited: observer.paint_duration_ns, render_limited: observer.render_duration_ns, consumer_limited: observer.consumer_interval_ns}; - const limitName = limitNames[limit.current] || limit.current || "N/A"; - const limitDuration = limitDurations[limit.current]; - const client = this.telemetry.client_performance || {}; - const observerValues = {}; - const clientValues = {}; - observerValues.mode = observer.mode || this.mode.id; - observerValues.limit_state = observer.limit_state || "not_applicable"; - for (const [name] of observerMetricDefinitions) observerValues[name] = observerValue(name, observer); - for (const [name] of clientMetricDefinitions) clientValues[name] = clientValue(name, client); - const limitTile = (enabled, active, duration) => ({text: enabled ? `${active ? "当前瓶颈" : "未受限"} · ${formatNanoseconds(duration)}` : `已关闭 · ${formatNanoseconds(duration)}`, active: Boolean(active)}); - return { - ready: this.ready, - socketState: this.socketState, - socketText: this.socketText, - motionState: this.motionState, - motionText: this.motionText, - frameCount: formatInteger(this.frameCount), - backendFps: formatFps(performanceData.measured_fps), - transportFps: formatFps(performanceData.pixel_response_fps || client.transport_fps), - presentationFps: formatFps(client.presentation_fps), - roundTrip: Number(client.frame_round_trip_ms || 0).toFixed(2), - overwritten: formatInteger(client.overwritten_pixel_frames), - renderMs: Number(performanceData.last_render_ms || 0).toFixed(2), - encodeMs: Number(performanceData.last_pixel_encode_ms || 0).toFixed(2), - bandwidth: Number(performanceData.pixel_payload_megabytes_per_second || 0).toFixed(1), - pending: formatInteger(observer.pending_frame_count), - dropped: formatInteger(observer.dropped_frame_count), - points: `${formatInteger(dataShape.input_points)}→${formatInteger(dataShape.rendered_elements)}`, - limit: limitDuration === undefined ? limitName : `${limitName} · ${formatNanoseconds(limitDuration)}`, - event: observer.last_event || "none", - timeouts: formatInteger(client.frame_request_timeout_count), - pixelAge: Number(client.last_pixel_receive_age_ms || 0) > 0 ? `${Number(client.last_pixel_receive_age_ms).toFixed(0)} ms` : "—", - limitFrequency: limitTile(observer.frequency_limit_enabled !== false, limit.current === "frequency_limited", observer.target_interval_ns), - limitPaint: limitTile(true, limit.current === "paint_limited", observer.paint_duration_ns), - limitRender: limitTile(true, limit.current === "render_limited", observer.render_duration_ns), - limitConsumer: limitTile(Boolean(observer.consumer_feedback_enabled), limit.current === "consumer_limited", observer.consumer_interval_ns), - observer: observerValues, - client: clientValues, - rawTelemetry: this.telemetry - }; - } - dispose() { - this.disposed = true; - clearTimeout(this.frameTimeout); - clearTimeout(this.reconnectTimer); - this.send("hide"); - this.socket?.close(); - this.detach(); + modifiers(event) { return (event.shiftKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0); } + installCanvasEvents() { + const pointer = (type, event) => this.send(type, {...this.position(event), button: ["left", "middle", "right"][event.button] || "none", buttons: event.buttons, modifiers: this.modifiers(event)}); + this.canvas.addEventListener("pointermove", event => pointer("pointer_move", event)); + this.canvas.addEventListener("pointerdown", event => { if (event.button !== 2) { this.shell.focus(); this.canvas.setPointerCapture(event.pointerId); pointer("pointer_press", event); } }); + this.canvas.addEventListener("pointerup", event => { if (event.button !== 2) pointer("pointer_release", event); }); + this.canvas.addEventListener("pointerleave", () => this.send("leave")); + this.canvas.addEventListener("wheel", event => { event.preventDefault(); this.send("wheel", {...this.position(event), pixelDeltaX: event.deltaX, pixelDeltaY: event.deltaY, angleDeltaX: -event.deltaX * 8, angleDeltaY: -event.deltaY * 8, buttons: event.buttons, modifiers: this.modifiers(event)}); }, {passive: false}); + this.shell.addEventListener("keydown", event => this.send("key_press", {key: event.key, nativeKey: event.keyCode, repeat: event.repeat, modifiers: this.modifiers(event)})); + this.shell.addEventListener("keyup", event => this.send("key_release", {key: event.key, nativeKey: event.keyCode, repeat: false, modifiers: this.modifiers(event)})); } } -function sessionKey(modeId, caseId) { - return `${modeId}_${caseId}`; + +function createPage(mode) { + const page = elements.pageTemplate.content.firstElementChild.cloneNode(true); + page.dataset.mode = mode.id; + page.querySelector(".page-strategy").textContent = mode.strategy; + page.querySelector(".page-title").textContent = `${mode.title} · 全控件页`; + page.querySelector(".page-description").textContent = mode.description; + const gallery = page.querySelector(".gallery"); + const cards = definitions.map(definition => new GalleryCard(definition, mode)); + cards.forEach(card => gallery.append(card.node)); + elements.pages.append(page); + pages.set(mode.id, {mode, page, cards}); + return pages.get(mode.id); } -function ensureSession(key, definition, mode) { - if (!sessions.has(key)) sessions.set(key, new GallerySession(key, definition, mode)); - return sessions.get(key); +function syncCardActivity() { + for (const page of pages.values()) for (const card of page.cards) card.syncActivity(); } -function RenderiveCanvas(props) { - const shellRef = React.useRef(null); - const canvasRef = React.useRef(null); - const definition = definitions.find(item => item.id === props.caseId); - const mode = modes.find(item => item.id === props.modeId); - const session = ensureSession(props.cardKey, definition, mode); - React.useEffect(() => { - session.attach(shellRef.current, canvasRef.current); - return () => session.detach(); - }, [session]); - return React.createElement("div", {ref: shellRef, className: `rv-canvas-shell ${session.ready ? "rv-canvas-ready" : ""}`, tabIndex: 0}, - React.createElement("canvas", {ref: canvasRef, "aria-label": "Core2 后端像素画布"}), - React.createElement("div", {className: "rv-canvas-hint"}, "右键:本控件全部 API"), - React.createElement("div", {className: "rv-canvas-loading"}, React.createElement("small", null, "创建 Kernel Scene"))); +function selectMode(id) { + activeMode = id; + let selected = pages.get(id); + if (!selected) selected = createPage(modes.find(mode => mode.id === id)); + for (const [modeId, page] of pages) page.page.hidden = modeId !== id; + [...elements.modeTabs.children].forEach(button => button.classList.toggle("active", button.dataset.mode === id)); + elements.modeDescription.textContent = selected.mode.description; + applyCategory(); + syncCardActivity(); + closeMenu(); } -amisLib.Renderer({test: /(^|\/)renderive-canvas$/})(RenderiveCanvas); -function metricGrid(key, definitionsList, className, prefix = "sessions") { - return { - type: "grid", - className, - columns: definitionsList.map(([field, label]) => ({xs: 6, sm: 4, md: 2, body: {type: "tpl", tpl: `
${expression(`${prefix}.${key}.${field}`)}${label}
`}})) - }; -} -function observerGrid(key, definitionList, source, className) { - return { - type: "grid", - className, - columns: definitionList.map(([field, label]) => ({xs: 6, sm: 4, md: 3, body: {type: "tpl", tpl: `
${expression(`sessions.${key}.${source}.${field}`)}${label}
`}})) - }; -} -function buildCardSchema(definition, mode) { - const key = sessionKey(mode.id, definition.id); - ensureSession(key, definition, mode); - const category = String(definition.category).replace(/'/g, "\\'"); - const frameLabel = mode.id === "manual" ? "手动刷新一帧" : mode.id === "playback" ? "消费下一帧" : "立即刷新"; - const body = [ - {type: "tpl", tpl: `
${definition.category}

${definition.title}

${expression(`sessions.${key}.motionText`)}${expression(`sessions.${key}.socketText`)}
`}, - {type: "renderive-canvas", cardKey: key, caseId: definition.id, modeId: mode.id}, - metricGrid(key, mainMetricDefinitions, "rv-metrics-grid") - ]; - if (mode.id === "low_latency") { - body.push({ - type: "grid", - className: "rv-limit-grid", - columns: limitDefinitions.map(([field, label]) => ({xs: 12, sm: 6, body: {type: "tpl", tpl: `
${label}${expression(`sessions.${key}.${field}.text`)}
`}})) - }); - body.push({type: "tpl", tpl: `
KERNEL ${expression(`sessions.${key}.observer.mode`)} OBSERVER事件 ${expression(`sessions.${key}.observer.last_event`)}
`}); - body.push(observerGrid(key, observerMetricDefinitions, "observer", "rv-observer-grid")); - body.push(observerGrid(key, clientMetricDefinitions, "client", "rv-client-grid")); +function applyCategory() { + const page = pages.get(activeMode); + if (!page) return; + for (const card of page.cards) { + card.node.hidden = !card.categoryVisible(); + card.syncActivity(); } - body.push({type: "tpl", tpl: `
${definition.component}Core2 组件
${definition.control_count_by_mode?.[mode.id] ?? "—"}属性入口
${definition.action_count_by_mode?.[mode.id] ?? "—"}动作入口
${expression(`sessions.${key}.frameCount`)}像素帧
`}); - return { - type: "panel", - className: "rv-card", - visibleOn: `\${selectedCategory === '全部' || selectedCategory === '${category}'}`, - body, - actions: [ - {type: "button", label: frameLabel, level: "primary", onEvent: {click: {actions: [{actionType: "custom", script: `window.RenderiveGallery.requestFrame(${quote(key)});`}]}}}, - {type: "button", label: "刷新后端状态", onEvent: {click: {actions: [{actionType: "custom", script: `window.RenderiveGallery.refreshBackendState(${quote(key)});`}]}}}, - {type: "button", label: "打开测试菜单", onEvent: {click: {actions: [{actionType: "custom", script: `window.RenderiveGallery.openMenu(${quote(key)});`}]}}} - ] - }; } -function buildModeBody(mode) { - return { - type: "container", - body: [ - {type: "tpl", tpl: `
${mode.strategy}

${mode.title} · 全控件页

${mode.description}

`}, - {type: "grid", className: "rv-gallery", columns: definitions.map(definition => ({xs: 12, lg: 6, body: buildCardSchema(definition, mode)}))} - ] - }; -} -function buildMainSchema() { +function installNavigation() { + elements.modeTabs.replaceChildren(...modes.map(mode => { + const button = document.createElement("button"); + button.type = "button"; button.dataset.mode = mode.id; + button.innerHTML = `${mode.title}${mode.strategy}`; + button.addEventListener("click", () => selectMode(mode.id)); + return button; + })); const categories = ["全部", ...new Set(definitions.map(item => item.category))]; - return { - type: "page", - className: "rv-shell", - body: [ - {type: "tpl", tpl: `
R2

CORE2 · KERNEL · WEBSOCKET · AMIS

全控件 API 与帧策略性能画廊

${expression("connectionText")}
`}, - {type: "flex", justify: "flex-end", className: "rv-top-actions", items: [{type: "button", label: "${streamsPaused ? '恢复自动像素流' : '暂停自动像素流'}", onEvent: {click: {actions: [{actionType: "custom", script: "window.RenderiveGallery.toggleStreams();"}]}}}]}, - {type: "grid", className: "rv-hero", columns: [ - {xs: 12, lg: 7, body: {type: "tpl", tpl: `

THREE REAL KERNEL STRATEGIES

三套对称页面,同一批控件,直接比较

标准 UI 由 AMIS SDK 渲染;实时二进制像素画布使用 React 自定义 Renderer。

`}}, - {xs: 12, lg: 5, body: {type: "grid", className: "rv-hero-metrics", columns: [ - {xs: 6, body: {type: "tpl", tpl: `
${expression("pageCount")}帧策略页
`}}, - {xs: 6, body: {type: "tpl", tpl: `
${expression("caseCount")}每页控件
`}}, - {xs: 6, body: {type: "tpl", tpl: `
${expression("canvasCount")}独立场景
`}}, - {xs: 6, body: {type: "tpl", tpl: `
${expression("apiCount")}手测入口
`}} - ]}} - ]}, - {type: "alert", level: "danger", body: "${noticeText}", visibleOn: "${noticeText && noticeError}", className: "rv-notice", showIcon: true}, - {type: "alert", level: "info", body: "${noticeText}", visibleOn: "${noticeText && !noticeError}", className: "rv-notice", showIcon: true}, - {type: "form", wrapWithPanel: false, className: "rv-filter-form", body: [{type: "button-group-select", name: "selectedCategory", options: categories.map(value => ({label: value, value})), onEvent: {change: {actions: [{actionType: "custom", script: "window.RenderiveGallery.setCategory(event.data);"}]}}}]}, - {type: "tabs", className: "rv-mode-tabs", activeKey: modes.some(mode => mode.id === "low_latency") ? "low_latency" : modes[0]?.id, mountOnEnter: true, unmountOnExit: false, tabs: modes.map(mode => ({title: `${mode.title} · ${mode.strategy}`, key: mode.id, body: buildModeBody(mode)}))} - ] - }; + elements.filters.replaceChildren(...categories.map(category => { + const button = document.createElement("button"); + button.type = "button"; button.textContent = category; + button.classList.toggle("active", category === activeCategory); + button.addEventListener("click", () => { + activeCategory = category; + [...elements.filters.children].forEach(child => child.classList.toggle("active", child === button)); + applyCategory(); + }); + return button; + })); } -function controlEventAction(session, item, source) { - return {actionType: "custom", script: `window.RenderiveGallery.commitControl(${quote(session.key)},${quote(item.id)},${source});`}; + +function openMenu(card, x, y) { + if (!card.ready) { toast("该控件仍在等待后端描述", true); return; } + activeCard = card; activeTab = "controls"; + elements.menuComponent.textContent = `${card.mode.strategy} / ${card.definition.component}`; + elements.menuTitle.textContent = card.definition.title; + elements.menuDescription.textContent = card.definition.description; + elements.menuStatus.textContent = "菜单仅包含当前控件和当前帧策略可调用的 API"; + elements.menuTabs.forEach(button => button.classList.toggle("active", button.dataset.tab === activeTab)); + renderMenuBody(); + elements.menu.hidden = false; + const rect = elements.menu.getBoundingClientRect(), gap = 10; + elements.menu.style.left = `${Math.max(gap, Math.min(x, innerWidth - rect.width - gap))}px`; + elements.menu.style.top = `${Math.max(gap, Math.min(y, innerHeight - rect.height - gap))}px`; } -function controlSchema(session, item) { - const control = {...item.amis}; - const formId = `control_${session.key}_${item.id}`.replace(/[^A-Za-z0-9_-]/g, "_"); - const eventName = ["switch", "select", "input-color"].includes(control.type) ? "change" : "blur"; - control.onEvent = {[eventName]: {actions: [{actionType: "submit", componentId: formId}]}}; - return {type: "form", id: formId, wrapWithPanel: false, className: "rv-control-form", body: [control], actions: [], onEvent: {submit: {preventDefault: true, actions: [controlEventAction(session, item, "context.data")]}}}; -} -function buildControlTab(session) { - return [...groupItems(session.controls)].map(([group, items]) => ({type: "panel", className: "rv-control-panel", title: group, body: items.map(item => controlSchema(session, item))})); -} -function actionSchema(session, item) { - const field = `action_${item.id}`; - const label = item.label || item.id; - const api = item.api || item.id; - const description = item.description || ""; - const body = [{type: "tpl", tpl: `
${escapeHtml(label)}${escapeHtml(api)}
${description ? `${escapeHtml(description)}` : ""}
`}]; - if (item.argument_input) body.push({...item.argument_amis, name: field}); - body.push({type: "button", actionType: "submit", label: `执行 · ${label}`, level: "primary"}); - return {type: "form", wrapWithPanel: false, className: "rv-action-form", body, actions: [], onEvent: {submit: {preventDefault: true, actions: [{actionType: "custom", script: `window.RenderiveGallery.runAction(${quote(session.key)},${quote(item.id)},context.data);`}]}}}; -} -function buildActionTab(session) { - return [...groupItems(session.actions)].map(([group, items]) => ({type: "panel", className: "rv-control-panel", title: group, body: items.map(item => actionSchema(session, item))})); -} -function menuObserverGrid(session) { - return { - type: "grid", - className: "rv-observer-grid", - columns: observerMetricDefinitions.map(([field, label]) => ({xs: 6, sm: 4, md: 3, body: {type: "tpl", tpl: `
${expression(`observer.${field}`)}${label}
`}})) - }; -} -function buildMenuSchema(session) { - return { - type: "page", - className: "rv-menu-shell", - body: [ - {type: "flex", className: "rv-menu-header", justify: "space-between", alignItems: "flex-start", items: [ - {type: "tpl", tpl: `

${session.mode.strategy} / ${session.definition.component}

${session.definition.title}

${session.definition.description}

`}, - {type: "flex", gap: "sm", items: [ - {type: "button", label: "刷新后端状态", level: "primary", onEvent: {click: {actions: [{actionType: "custom", script: `window.RenderiveGallery.refreshBackendState(${quote(session.key)});`}]}}}, - {type: "button", label: "关闭", onEvent: {click: {actions: [{actionType: "custom", script: "window.RenderiveGallery.closeMenu();"}]}}} - ]} - ]}, - {type: "alert", level: "info", body: "${menuStatus}", showIcon: false}, - {type: "tabs", className: "rv-menu-tabs", mountOnEnter: true, tabs: [ - {title: "控件属性", body: buildControlTab(session)}, - {title: "专属 API", body: buildActionTab(session)}, - {title: "Kernel 观察者", body: menuObserverGrid(session)}, - {title: "性能", body: {type: "json", className: "rv-json", source: "${rawTelemetry}", levelExpand: 2}} - ]} - ] - }; -} -function rootData() { - const sessionData = {}; - for (const [key, session] of sessions) sessionData[key] = session.snapshot(); - const coverage = catalog?.coverage || {}; - return { - connectionState, - connectionText, - streamsPaused, - selectedCategory, - noticeText, - noticeError, - pageCount: coverage.page_count ?? modes.length, - caseCount: coverage.case_count ?? definitions.length, - canvasCount: coverage.canvas_count ?? definitions.length * modes.length, - apiCount: (coverage.manual_control_count || 0) + (coverage.manual_action_count || 0), - sessions: sessionData - }; -} -function updateMainData() { - if (mainScoped?.updateProps) mainScoped.updateProps({data: rootData()}); -} -function menuData(session) { - const snapshot = session.snapshot(); - return {...session.controlData(), menuStatus, observer: snapshot.observer, rawTelemetry: snapshot.rawTelemetry}; -} -function updateMenuData() { - if (menuScoped?.updateProps && menuSession) menuScoped.updateProps({data: menuData(menuSession)}); -} -function renderMenu(session, rebuild = false) { - if (!session.ready) { - notify("该控件仍在等待后端描述", true); - return; +function closeMenu() { elements.menu.hidden = true; activeCard = null; } +function renderControl(item) { + const card = activeCard; + const row = document.createElement("div"); row.className = "control-row"; + const copy = document.createElement("div"); copy.className = "control-copy"; + const label = document.createElement("label"); label.textContent = item.label; + const api = document.createElement("code"); api.textContent = item.api; api.title = item.api; copy.append(label, api); + let input; + if (item.input === "select") { + input = document.createElement("select"); + for (const value of item.options || []) { const option = document.createElement("option"); option.value = value; option.textContent = value; input.append(option); } + input.value = String(item.value); + } else { + input = document.createElement("input"); input.type = item.input === "boolean" ? "checkbox" : item.input; + if (item.input === "boolean") input.checked = Boolean(item.value); else input.value = item.value; + if (item.input === "number") { input.min = item.minimum; input.max = item.maximum; input.step = item.step || "any"; } } - menuSession = session; - menuHost.hidden = false; - if (rebuild) { - menuScoped?.unmount(); - menuScoped = null; + input.className = "control-input"; input.title = item.description || item.api; + const submit = value => { + card.send("gallery_patch", {patch: {[item.id]: value}}); + elements.menuStatus.textContent = `提交 ${item.api} · 等待后端回读`; + }; + if (item.input === "number") { + let committedValue = input.value; + const commit = () => { + const value = input.valueAsNumber; + const minimum = Number(item.minimum); + const maximum = Number(item.maximum); + if (input.value === "" || !Number.isFinite(value) || + (Number.isFinite(minimum) && value < minimum) || + (Number.isFinite(maximum) && value > maximum)) { + input.value = committedValue; + return; + } + if (value === Number(committedValue)) { + input.value = committedValue; + return; + } + committedValue = input.value; + item.value = value; + submit(value); + }; + input.addEventListener("blur", commit); + input.addEventListener("keydown", event => { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + input.value = committedValue; + } + }); + } else input.addEventListener("change", () => submit(item.input === "boolean" ? input.checked : input.value)); + row.append(copy, input); return row; +} +function renderAction(item) { + const row = document.createElement("div"); row.className = "action-row"; + const copy = document.createElement("div"); copy.className = "action-copy"; + const label = document.createElement("strong"); label.textContent = item.label; + const api = document.createElement("code"); api.textContent = item.api; api.title = item.api; copy.append(label, api); + const controls = document.createElement("div"); controls.className = "action-controls"; + let argument = null; + if (item.argument_input) { argument = document.createElement("input"); argument.className = "control-input"; argument.type = item.argument_input; argument.value = item.argument_default; controls.append(argument); } + const button = document.createElement("button"); button.className = "action-button"; button.type = "button"; button.textContent = "执行"; + button.addEventListener("click", () => { + const card = activeCard; + const payload = {action: item.id}; + if (argument) payload.argument = item.argument_input === "number" ? Number(argument.value) : argument.value; + card.send("gallery_action", payload); + elements.menuStatus.textContent = `执行 ${item.api}`; + if (["mode_render", "mode_dequeue", "mode_cycle"].includes(item.id)) setTimeout(() => card.requestFrame(performance.now(), true), 40); + }); + controls.append(button); row.append(copy, controls); return row; +} +function renderGroups(items, renderer) { + const fragment = document.createDocumentFragment(); + for (const [name, children] of grouped(items)) { + const section = document.createElement("section"); section.className = "control-group"; + const title = document.createElement("h3"); title.textContent = name; + section.append(title, ...children.map(renderer)); fragment.append(section); } - if (!menuScoped) menuScoped = amisEmbed.embed("#menu-root", buildMenuSchema(session), {data: menuData(session)}, {theme: "cxd"}); - else updateMenuData(); + elements.menuBody.replaceChildren(fragment); } -function closeMenu() { - menuScoped?.unmount(); - menuScoped = null; - menuSession = null; - menuHost.hidden = true; +function renderData(value) { + const list = document.createElement("dl"); list.className = "telemetry-grid"; + for (const [key, content] of flatten(value)) { const dt = document.createElement("dt"), dd = document.createElement("dd"); dt.textContent = key; dd.textContent = content; list.append(dt, dd); } + elements.menuBody.replaceChildren(list); } +function renderMenuBody() { + if (!activeCard) return; + if (activeTab === "actions") renderGroups(activeCard.actions, renderAction); + else if (activeTab === "observer") renderData(activeCard.telemetry.kernel_observer || {}); + else if (activeTab === "performance") renderData({performance: activeCard.telemetry.performance || {}, client_performance: activeCard.telemetry.client_performance || {}, low_latency_limit: activeCard.telemetry.low_latency_limit || {current: "not_applicable"}, data_shape: activeCard.telemetry.data_shape || {}, overlay: {enabled: activeCard.telemetry.performance_overlay_enabled, lines: activeCard.telemetry.performance_overlay_lines}, frame_mode: activeCard.telemetry.frame_mode}); + else renderGroups(activeCard.controls, renderControl); +} + function buildCatalog(data) { - catalog = data; - definitions = [...(data.cases || [])].sort((left, right) => left.order - right.order); - modes = [...(data.frame_modes || [])].sort((left, right) => left.order - right.order); + definitions = [...(data.cases || [])].sort((a, b) => a.order - b.order); + modes = [...(data.frame_modes || [])].sort((a, b) => a.order - b.order); if (!modes.length) throw new Error("后端没有返回帧策略目录"); - for (const mode of modes) for (const definition of definitions) ensureSession(sessionKey(mode.id, definition.id), definition, mode); - connectionState = "ready"; - connectionText = "三种 Kernel 策略目录已加载"; - mainScoped?.unmount(); - mainScoped = amisEmbed.embed(rootNode, buildMainSchema(), {data: rootData()}, {theme: "cxd"}); + elements.pages.replaceChildren(); + elements.pageCount.textContent = data.coverage?.page_count ?? modes.length; + elements.caseCount.textContent = data.coverage?.case_count ?? definitions.length; + elements.canvasCount.textContent = data.coverage?.canvas_count ?? definitions.length * modes.length; + elements.apiCount.textContent = (data.coverage?.manual_control_count || 0) + (data.coverage?.manual_action_count || 0); + installNavigation(); + if (!modes.some(mode => mode.id === activeMode)) activeMode = modes[0].id; + selectMode(activeMode); + setConnection("ready", "三种 Kernel 策略目录已加载"); } function connectCatalog() { const socket = new WebSocket(socketUrl); socket.addEventListener("open", () => socket.send(message("gallery_catalog"))); socket.addEventListener("message", event => { if (typeof event.data !== "string") return; - try { - const data = JSON.parse(event.data); - if (data.type === "catalog") { - buildCatalog(data); - socket.close(); - } else if (data.type === "error") throw new Error(data.message); - } catch (error) { - connectionState = "error"; - connectionText = "目录解析失败"; - notify(error instanceof Error ? error.message : String(error), true); - } - }); - socket.addEventListener("error", () => { - connectionState = "error"; - connectionText = `无法连接 ${socketUrl} · 自动重连`; - updateMainData(); + try { const data = JSON.parse(event.data); if (data.type === "catalog") { buildCatalog(data); socket.close(); } else if (data.type === "error") throw new Error(data.message); } + catch (error) { setConnection("error", "目录解析失败"); toast(error.message, true); } }); + socket.addEventListener("error", () => { setConnection("error", `无法连接 ${socketUrl} · 自动重连`); }); socket.addEventListener("close", () => { - if (!catalog) setTimeout(connectCatalog, 1000); + if (!definitions.length) setTimeout(connectCatalog, 1000); }); } function loop(time) { updateDisplayTiming(time); - for (const session of sessions.values()) { - if (!session.shell) continue; - session.presentLatest(time); - session.updateMotionStatus(time); - if (session.mode.id !== "low_latency") session.requestFrame(time); - session.sendClientFeedback(time); + const page = pages.get(activeMode); + for (const card of page?.cards || []) { + card.presentLatest(time); + card.updateMotionStatus(time); + if (card.mode.id !== "low_latency") card.requestFrame(time); + card.observe(time); } requestAnimationFrame(loop); } -window.RenderiveGallery = { - setCategory(payload) { - const value = normalizeEventValue(payload, "selectedCategory"); - selectedCategory = String(value || "全部"); - updateMainData(); - for (const session of sessions.values()) session.syncActivity(); - }, - toggleStreams() { - streamsPaused = !streamsPaused; - updateMainData(); - for (const session of sessions.values()) session.syncActivity(true); - }, - requestFrame(key) { - sessions.get(key)?.requestFrame(performance.now(), true); - }, - refreshBackendState(key) { - sessions.get(key)?.refreshBackendState(); - }, - openMenu(key) { - const session = sessions.get(key); - if (session) renderMenu(session, menuSession !== session); - }, - closeMenu, - commitControl(key, id, payload) { - sessions.get(key)?.commitControl(id, payload); - }, - runAction(key, id, payload) { - sessions.get(key)?.runAction(id, payload); - } -}; -menuHost.addEventListener("pointerdown", event => { - if (event.target === menuHost) closeMenu(); -}); -document.addEventListener("keydown", event => { - if (event.key === "Escape" && !menuHost.hidden) closeMenu(); + +elements.streamToggle.addEventListener("click", () => { + streamsPaused = !streamsPaused; + elements.streamToggle.textContent = streamsPaused ? "恢复自动像素流" : "暂停自动像素流"; + syncCardActivity(); }); +elements.menuClose.addEventListener("click", closeMenu); +elements.menuTabs.forEach(button => button.addEventListener("click", () => { activeTab = button.dataset.tab; elements.menuTabs.forEach(item => item.classList.toggle("active", item === button)); renderMenuBody(); })); +document.addEventListener("keydown", event => { if (event.key === "Escape" && !elements.menu.hidden) closeMenu(); }); +document.addEventListener("pointerdown", event => { if (!elements.menu.hidden && !elements.menu.contains(event.target) && !event.target.closest(".open-menu")) closeMenu(); }); document.addEventListener("visibilitychange", () => { lastAnimationFrameAt = 0; displayIntervalMs = 0; @@ -922,11 +722,9 @@ document.addEventListener("visibilitychange", () => { displayIntervalP95Ms = 0; displayJitterMs = 0; displayIntervalSamples.length = 0; - for (const session of sessions.values()) session.syncActivity(); + syncCardActivity(); }); -window.addEventListener("beforeunload", () => { - for (const session of sessions.values()) session.dispose(); -}); -mainScoped = amisEmbed.embed(rootNode, {type: "page", className: "rv-shell", body: [{type: "tpl", tpl: "

RENDERIVE · AMIS SDK

等待后端返回控件与帧策略目录

正在连接 WebSocket catalog。

"}]}, {data: rootData()}, {theme: "cxd"}); +window.addEventListener("beforeunload", () => { for (const page of pages.values()) for (const card of page.cards) { card.disposed = true; clearTimeout(card.frameTimeout); clearTimeout(card.reconnectTimer); card.send("hide"); card.socket?.close(); } }); + connectCatalog(); requestAnimationFrame(loop); diff --git a/webapp_gallery/index.html b/webapp_gallery/index.html index cb54879..a45bd59 100644 --- a/webapp_gallery/index.html +++ b/webapp_gallery/index.html @@ -3,19 +3,182 @@ - Renderive Core2 全控件性能画廊 - - - -
- - +
+
+ +

CORE2 · KERNEL · WEBSOCKET

全控件 API 与帧策略性能画廊

+
+
+ 读取后端目录 + +
+
+ +
+
+

THREE REAL KERNEL STRATEGIES

+

三套对称页面,同一批控件,直接比较

+

所有属性、动作、观察者和性能数据均由后端通过 WebSocket 返回。

+
+
+
3
帧策略页
+
每页控件
+
独立场景
+
手测入口
+
+
+ + + + +
+
等待后端返回控件与帧策略目录
+
+ + + + + + + + + diff --git a/webapp_gallery/styles.css b/webapp_gallery/styles.css index b1d51be..6a4ddb7 100644 --- a/webapp_gallery/styles.css +++ b/webapp_gallery/styles.css @@ -1,599 +1,84 @@ :root { - color-scheme:dark; - --bg:#060a10; - --panel:#0b1119; - --panel2:#101823; - --panel3:#151f2c; - --line:#1e2b3a; - --line2:#2a3b4e; - --text:#dce9ff; - --muted:#8190a5; - --accent:#35e6b2; - --accent2:#6bc8ff; - --warn:#ffcf82; - --danger:#ff6b81; -} -* { - box-sizing:border-box; -} -html,body,#root { - min-height:100%; - margin:0; + color-scheme: dark; + font-family: Inter, "Segoe UI Variable", "Microsoft YaHei UI", sans-serif; + --bg: #07090d; --panel: #10141b; --panel2: #151b24; --line: #29313d; + --strong: #3b4655; --text: #f2f5f8; --muted: #929eac; --accent: #45ddbe; + --blue: #6aa9ff; --warning: #ffd166; --danger: #ff6885; } +* { box-sizing: border-box; } +html { background: var(--bg); } body { - overflow-x:hidden; - background:radial-gradient(circle at 18% 0,#102136 0,transparent 30rem),linear-gradient(180deg,#070c13,#05080d 58%,#060a10); - color:var(--text); - font:13px/1.5 Inter,"Segoe UI","Microsoft YaHei",sans-serif; -} -button,input,select,textarea { - font:inherit; -} -.rv-app,.cxd-Page,.cxd-Page-body,.cxd-Page-content { - min-height:100vh; - background:transparent!important; - color:var(--text)!important; -} -.cxd-Page-body,.cxd-Page-content { - padding:0!important; -} -.rv-shell { - width:min(1760px,100%); - margin:0 auto; - padding:18px 22px 44px; -} -.rv-topbar { - display:flex; - align-items:center; - justify-content:space-between; - gap:18px; - padding:8px 0 18px; -} -.rv-brand { - display:flex; - align-items:center; - gap:12px; -} -.rv-brand-mark { - display:grid; - width:42px; - height:42px; - place-items:center; - border:1px solid #35e6b255; - border-radius:10px; - color:var(--accent); - background:#35e6b20d; - box-shadow:inset 0 0 20px #35e6b20a; - font:800 13px/1 ui-monospace,SFMono-Regular,Consolas,monospace; -} -.rv-eyebrow { - margin:0 0 4px; - color:var(--accent); - font:700 10px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace; - letter-spacing:.15em; - text-transform:uppercase; -} -.rv-brand h1,.rv-hero h2 { - margin:0; - color:#f2f7ff; - font-weight:720; - letter-spacing:-.02em; -} -.rv-brand h1 { - font-size:18px; -} -.rv-top-actions { - display:flex; - align-items:center; - justify-content:flex-end; - gap:10px; -} -.rv-connection,.rv-live-status { - display:inline-flex; - align-items:center; - gap:7px; - min-width:0; - color:var(--muted); - font:700 10px/1.25 ui-monospace,SFMono-Regular,Consolas,monospace; -} -.rv-connection i,.rv-live-status i { - width:7px; - height:7px; - flex:0 0 auto; - border-radius:50%; - background:#8793a3; - box-shadow:0 0 0 3px #8793a312; -} -.rv-connection-ready i,.rv-live-status-moving i,.rv-live-status-ready i { - background:var(--accent); - box-shadow:0 0 0 3px #35e6b21a,0 0 12px #35e6b280; -} -.rv-connection-error i,.rv-live-status-stalled i,.rv-live-status-error i { - background:var(--danger); - box-shadow:0 0 0 3px #ff6b811a,0 0 12px #ff6b8166; -} -.rv-live-status-duplicate i { - background:var(--warn); -} -.rv-hero { - display:grid; - grid-template-columns:minmax(0,1.45fr) minmax(380px,.75fr); - gap:20px; - padding:22px; - border:1px solid var(--line); - border-radius:12px; - background:linear-gradient(135deg,#0c1420ee,#091019ee); - box-shadow:0 16px 50px #00000036; -} -.rv-hero h2 { - margin-top:6px; - font-size:25px; -} -.rv-hero p { - margin:10px 0 0; - color:var(--muted); -} -.rv-hero-metrics .cxd-Grid-col { - padding:4px!important; -} -.rv-hero-metric,.rv-metric,.rv-limit-tile,.rv-observer-metric { - min-width:0; - height:100%; - padding:9px 10px; - border:1px solid var(--line); - border-radius:7px; - background:#090f17; -} -.rv-hero-metric b,.rv-metric b,.rv-observer-metric b { - display:block; - min-width:0; - overflow-wrap:anywhere; - color:var(--accent); - font:700 11px/1.35 ui-monospace,SFMono-Regular,Consolas,monospace; -} -.rv-hero-metric b { - font-size:18px; -} -.rv-hero-metric small,.rv-metric small,.rv-observer-metric small { - display:block; - margin-top:5px; - color:var(--muted); - font-size:9px; - line-height:1.35; -} -.rv-notice { - margin-top:12px; -} -.rv-filter-form { - margin:12px 0 5px; -} -.rv-filter-form .cxd-Form-row { - margin:0!important; -} -.rv-filter-form .cxd-ButtonGroup { - display:flex; - flex-wrap:wrap; - gap:5px; -} -.rv-mode-tabs { - margin-top:12px; -} -.rv-mode-tabs>.cxd-Tabs-links { - display:grid!important; - grid-template-columns:repeat(3,minmax(0,1fr)); - gap:8px; - border:0!important; - background:transparent!important; -} -.rv-mode-tabs>.cxd-Tabs-links>li { - margin:0!important; - border:1px solid var(--line)!important; - border-radius:7px!important; - background:#0a111a!important; -} -.rv-mode-tabs>.cxd-Tabs-links>li>a { - padding:10px 12px!important; - color:var(--muted)!important; - font-weight:700!important; -} -.rv-mode-tabs>.cxd-Tabs-links>li.is-active { - border-color:#35e6b266!important; - background:#35e6b20d!important; -} -.rv-mode-tabs>.cxd-Tabs-links>li.is-active>a { - color:var(--accent)!important; -} -.rv-mode-tabs>.cxd-Tabs-content { - padding:10px 0 0!important; - border:0!important; - background:transparent!important; -} -.rv-page-header { - display:flex; - align-items:flex-end; - justify-content:space-between; - gap:14px; - margin:6px 0 12px; - padding:0 2px; -} -.rv-page-header h2 { - margin:2px 0 0; - color:#f2f7ff; - font-size:18px; -} -.rv-page-header p { - max-width:720px; - margin:0; - color:var(--muted); - text-align:right; -} -.rv-gallery>.cxd-Grid-col { - padding:6px!important; -} -.rv-card { - overflow:hidden; - min-width:0; - height:100%; - margin:0!important; - border:1px solid var(--line)!important; - border-radius:9px!important; - background:var(--panel)!important; - box-shadow:0 12px 32px #0000002b!important; -} -.rv-card>.cxd-Panel-body { - padding:0!important; - background:transparent!important; -} -.rv-card>.cxd-Panel-footer { - display:flex; - flex-wrap:wrap; - gap:8px; - padding:10px!important; - border-top:1px solid var(--line)!important; - background:#090f17!important; -} -.rv-card-header { - display:flex; - align-items:flex-start; - justify-content:space-between; - gap:12px; - padding:12px 13px 10px; -} -.rv-card-category { - display:block; - margin-bottom:3px; - color:var(--accent2); - font:700 9px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace; - text-transform:uppercase; -} -.rv-card-title { - margin:0; - color:#eef5ff; - font-size:15px; -} -.rv-card-statuses { - display:flex; - flex-wrap:wrap; - justify-content:flex-end; - gap:8px; -} -.rv-canvas-shell { - position:relative; - width:100%; - height:clamp(300px,34vw,520px); - min-height:0; - overflow:hidden; - outline:none; - border-block:1px solid var(--line); - background:#04070b; -} -.rv-canvas-shell:focus-within { - box-shadow:inset 0 0 0 1px #35e6b25c; -} -.rv-canvas-shell canvas { - display:block; - width:100%; - height:100%; - background:#07111f; - image-rendering:auto; -} -.rv-canvas-hint { - position:absolute; - right:8px; - bottom:7px; - padding:3px 6px; - border:1px solid #ffffff14; - border-radius:4px; - color:#9cabc0; - background:#05090dc7; - pointer-events:none; - font:9px/1.25 ui-monospace,SFMono-Regular,Consolas,monospace; -} -.rv-canvas-loading { - position:absolute; - inset:0; - display:grid; - place-items:center; - color:var(--muted); - background:#05090de6; - pointer-events:none; -} -.rv-canvas-ready .rv-canvas-loading { - display:none; -} -.rv-metrics-grid,.rv-limit-grid,.rv-observer-grid,.rv-client-grid { - padding:6px; - border-bottom:1px solid var(--line); - background:#070c12; -} -.rv-metrics-grid .cxd-Grid-col,.rv-limit-grid .cxd-Grid-col,.rv-observer-grid .cxd-Grid-col,.rv-client-grid .cxd-Grid-col { - padding:3px!important; -} -.rv-limit-tile { - color:var(--muted); - font:700 9px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; -} -.rv-limit-tile b { - display:block; - margin-top:3px; - overflow-wrap:anywhere; - color:inherit; -} -.rv-limit-tile[data-active="true"] { - border-color:#ffb84d66; - color:var(--warn); - background:#ff9d170c; -} -.rv-observer-title { - display:flex; - align-items:center; - justify-content:space-between; - gap:12px; - padding:7px 10px; - border-bottom:1px solid var(--line); - color:#9ab1cc; - background:#07101a; - font:700 9px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; -} -.rv-observer-title b { - color:var(--accent); -} -.rv-card-meta { - display:grid; - grid-template-columns:repeat(4,minmax(0,1fr)); - gap:1px; - border-top:1px solid var(--line); - background:var(--line); -} -.rv-card-meta div { - min-width:0; - padding:8px 10px; - background:#0a1018; -} -.rv-card-meta b,.rv-card-meta code { - display:block; - min-width:0; - overflow-wrap:anywhere; - color:#c8d7ea; - font-size:10px; -} -.rv-card-meta small { - display:block; - margin-top:3px; - color:var(--muted); - font-size:8px; -} -.rv-menu-host { - position:fixed; - z-index:10000; - inset:0; - padding:12px; - background:#00000078; - backdrop-filter:blur(3px); -} -.rv-menu-host[hidden] { - display:none; -} -.rv-menu-root { - width:min(760px,calc(100vw - 24px)); - max-height:calc(100vh - 24px); - margin-left:auto; - overflow:auto; - border:1px solid var(--line2); - border-radius:10px; - background:#0a1018; - box-shadow:0 24px 80px #00000099; -} -.rv-menu-root .cxd-Page,.rv-menu-root .cxd-Page-body,.rv-menu-root .cxd-Page-content { - min-height:0!important; -} -.rv-menu-shell { - padding:12px; -} -.rv-menu-header { - display:flex; - align-items:flex-start; - justify-content:space-between; - gap:12px; - padding-bottom:10px; - border-bottom:1px solid var(--line); -} -.rv-menu-header h2 { - margin:2px 0 4px; - color:#f2f7ff; - font-size:18px; -} -.rv-menu-header p { - margin:0; - color:var(--muted); -} -.rv-menu-tabs>.cxd-Tabs-links { - margin-top:10px!important; - border-bottom-color:var(--line)!important; -} -.rv-menu-tabs>.cxd-Tabs-content { - padding:10px 0 0!important; - border:0!important; - background:transparent!important; -} -.rv-control-panel { - margin:0 0 8px!important; - border:1px solid var(--line)!important; - background:#0b121b!important; -} -.rv-control-panel>.cxd-Panel-heading { - border-bottom:1px solid var(--line)!important; - background:#0d1621!important; - color:var(--accent2)!important; - font-weight:700!important; -} -.rv-control-panel>.cxd-Panel-body { - padding:8px 10px!important; - background:transparent!important; -} -.rv-control-form { - margin:0!important; - padding:4px 0!important; - border-bottom:1px solid #182331; -} -.rv-control-form:last-child { - border-bottom:0; -} -.rv-control-form .cxd-Form-row { - margin:0!important; -} -.rv-action-form { - margin:0!important; - padding:10px 0!important; - border-bottom:1px solid #182331; -} -.rv-action-form:last-child { - border-bottom:0; -} -.rv-action-head { - display:flex; - align-items:flex-start; - justify-content:space-between; - gap:12px; - margin-bottom:8px; -} -.rv-action-head>div { - min-width:0; -} -.rv-action-head b { - display:block; - color:#edf7ff; - font-size:12px; -} -.rv-action-head code { - display:block; - margin-top:3px; - color:var(--accent); - font:9px/1.35 ui-monospace,monospace; - overflow-wrap:anywhere; -} -.rv-action-head small { - max-width:45%; - color:var(--muted); - font-size:9px; - line-height:1.4; -} -.rv-json .cxd-JSONField { - border-color:var(--line)!important; - background:#070c12!important; - color:#cddbf0!important; -} -.cxd-Panel,.cxd-Card,.cxd-Form,.cxd-Tabs-content,.cxd-Drawer-content,.cxd-Modal-content { - color:var(--text)!important; -} -.cxd-Button { - border-color:var(--line2)!important; - background:#111b27!important; - color:#c9d7ea!important; - box-shadow:none!important; -} -.cxd-Button:hover,.cxd-Button.is-active,.cxd-ButtonGroup .cxd-Button.is-active { - border-color:#35e6b266!important; - background:#35e6b212!important; - color:var(--accent)!important; -} -.cxd-Button--primary { - border-color:#35e6b25c!important; - background:#12342e!important; - color:#75f6d2!important; -} -.cxd-Form-label,.cxd-Form-itemLabel,.cxd-Form-itemLabel label { - color:#c9d7ea!important; -} -.cxd-Form-description,.cxd-Form-help,.cxd-Form-itemDesc { - color:var(--muted)!important; -} -.cxd-TextControl-input,.cxd-NumberControl-input,.cxd-Select,.cxd-SelectControl,.cxd-ColorPicker,.cxd-InputBox,.cxd-TextControl { - border-color:var(--line2)!important; - background:#070d14!important; - color:var(--text)!important; -} -.cxd-Select-menu,.cxd-PopOver,.cxd-PopOverAble-popover { - border-color:var(--line2)!important; - background:#0d1621!important; - color:var(--text)!important; -} -.cxd-Tabs-links>li>a { - color:var(--muted)!important; -} -.cxd-Tabs-links>li.is-active>a { - color:var(--accent)!important; -} -.cxd-Tabs-links>li.is-active>a:after { - background:var(--accent)!important; -} -.cxd-Alert { - border-color:var(--line2)!important; - background:#0c1420!important; - color:#cbd9eb!important; -} -.cxd-Alert--danger { - border-color:#ff6b8159!important; - background:#271018!important; - color:#ffb2bd!important; -} -.cxd-Switch.is-checked { - background:var(--accent)!important; -} -@media (max-width:1100px) { - .rv-hero { - grid-template-columns:1fr; - } - .rv-card-meta { - grid-template-columns:repeat(2,minmax(0,1fr)); - } -} -@media (max-width:720px) { - .rv-shell { - padding:12px 8px 28px; - } - .rv-topbar,.rv-page-header { - align-items:flex-start; - flex-direction:column; - } - .rv-top-actions { - width:100%; - justify-content:space-between; - } - .rv-page-header p { - text-align:left; - } - .rv-mode-tabs>.cxd-Tabs-links { - grid-template-columns:1fr; - } - .rv-card-meta { - grid-template-columns:1fr; - } - .rv-menu-host { - padding:6px; - } - .rv-menu-root { - width:100%; - max-height:calc(100vh - 12px); - } -} + margin: 0; min-width: 320px; min-height: 100vh; color: var(--text); + background: linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px), + radial-gradient(circle at 16% -10%, rgba(69,221,190,.12), transparent 34rem), var(--bg); + background-size: 24px 24px, 24px 24px, auto, auto; +} +button, input, select { font: inherit; } button { color: inherit; } +h1, h2, h3, p { margin-top: 0; } +.eyebrow { margin: 0 0 5px; color: var(--accent); font: 700 10px/1.2 ui-monospace, monospace; letter-spacing: .15em; text-transform: uppercase; } +.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 22px; min-height: 76px; padding: 12px clamp(18px,4vw,56px); border-bottom: 1px solid #ffffff14; background: #07090dea; backdrop-filter: blur(20px); } +.brand, .topbar-actions { display: flex; align-items: center; gap: 14px; } +.brand-mark { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 12px; color: #07110f; background: var(--accent); font: 800 16px/1 ui-monospace, monospace; } +h1 { margin: 0; font-size: clamp(17px,2vw,23px); } +.connection-status, .card-socket { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: 600 11px/1 ui-monospace, monospace; } +.connection-status i, .card-socket i { width: 8px; height: 8px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 12px currentColor; } +[data-state="ready"] i { background: var(--accent); } [data-state="error"] i { background: var(--danger); } +.card-health { display:flex; align-items:flex-end; flex-direction:column; gap:7px; } +.motion-status { display:inline-flex; align-items:center; gap:6px; color:var(--muted); font:600 9px/1 ui-monospace,monospace; } +.motion-status i { width:6px; height:6px; border-radius:50%; background:var(--warning); } +.motion-status[data-state="moving"] { color:var(--accent); }.motion-status[data-state="moving"] i { background:var(--accent); box-shadow:0 0 10px var(--accent); animation:motion-pulse .9s ease-in-out infinite alternate; } +.motion-status[data-state="duplicate"] { color:var(--warning); }.motion-status[data-state="duplicate"] i { background:var(--warning); } +.motion-status[data-state="stalled"] { color:var(--danger); }.motion-status[data-state="stalled"] i { background:var(--danger); } +@keyframes motion-pulse { to { opacity:.35; transform:scale(.72); } } +.quiet-button, .open-menu, .frame-button, .icon-button, .menu-tabs button, .category-filter button, .mode-tabs button, .action-button { border: 1px solid var(--line); border-radius: 8px; background: var(--panel2); cursor: pointer; transition: .16s border-color,.16s background,.16s transform; } +button:hover { border-color: var(--accent); } button:active { transform: translateY(1px); } +.quiet-button { padding: 9px 13px; font-size: 12px; } +.hero { display: grid; grid-template-columns: minmax(300px,1fr) minmax(430px,.9fr); align-items: end; gap: 40px; max-width: 1680px; margin: auto; padding: clamp(32px,5vw,64px) clamp(18px,4vw,56px) 28px; } +.hero h2 { margin-bottom: 10px; font-size: clamp(27px,4vw,47px); letter-spacing: -.045em; } +.hero > div > p:last-child { margin: 0; max-width: 750px; color: var(--muted); line-height: 1.7; } +.metrics { display: grid; grid-template-columns: repeat(4,1fr); gap: 1px; margin: 0; border: 1px solid var(--line); background: var(--line); } +.metrics div { padding: 17px; background: var(--panel); } .metrics dt { color: var(--text); font: 750 clamp(20px,3vw,33px)/1 ui-monospace,monospace; } .metrics dd { margin: 8px 0 0; color: var(--muted); font-size: 10px; } +.mode-tabs { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; max-width: 1680px; margin: auto; padding: 0 clamp(18px,4vw,56px) 18px; } +.mode-tabs button { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; color: var(--muted); text-align: left; } +.mode-tabs button span { color: var(--text); font-weight: 750; } .mode-tabs button code { font-size: 10px; } +.mode-tabs button.active { border-color: var(--accent); background: #123029; box-shadow: inset 0 0 0 1px #45ddbe35; } +.category-filter { display: flex; gap: 7px; overflow-x: auto; max-width: 1680px; margin: auto; padding: 0 clamp(18px,4vw,56px) 20px; } +.category-filter button { flex: 0 0 auto; padding: 8px 11px; color: var(--muted); font-size: 11px; } +.category-filter button.active { color: #06110e; border-color: var(--accent); background: var(--accent); } +.pages, .mode-page { max-width: 1680px; margin: auto; } +.mode-page[hidden] { display: none; } +.page-header { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding: 3px clamp(18px,4vw,56px) 18px; } +.page-header h2 { margin: 0; font-size: 23px; }.page-strategy { margin-bottom: 5px; color: var(--accent); font: 700 10px/1 ui-monospace,monospace; }.page-description { max-width: 650px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; text-align: right; } +.gallery { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 18px; padding: 0 clamp(18px,4vw,56px) 70px; } +.loading-card { display: grid; place-items: center; gap: 12px; min-height: 360px; margin: 0 clamp(18px,4vw,56px); border: 1px dashed var(--strong); color: var(--muted); } +.loader { width: 18px; height: 18px; border: 2px solid var(--strong); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } +.plot-card { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 13px; background: linear-gradient(180deg,#ffffff08,transparent 30%),var(--panel); box-shadow: 0 20px 55px #0000002e; } +.plot-card[data-mode="manual"] { border-top-color: #ffd166aa; }.plot-card[data-mode="low_latency"] { border-top-color: #45ddbeaa; }.plot-card[data-mode="playback"] { border-top-color: #6aa9ffaa; }.plot-card[hidden] { display:none; } +.card-header, .card-footer { display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 15px; }.card-header { border-bottom:1px solid var(--line); }.card-category { display:block; margin-bottom:4px; color:var(--blue); font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; }.card-title { margin:0; font-size:17px; } +.canvas-shell { position:relative; height:clamp(245px,27vw,350px); outline:none; background:#060a11; cursor:crosshair; }.canvas-shell:focus-visible { box-shadow:inset 0 0 0 2px var(--accent); }.canvas-shell canvas { display:block; width:100%; height:100%; }.canvas-hint { position:absolute; right:9px; bottom:8px; padding:5px 7px; border:1px solid #ffffff1e; border-radius:5px; color:#ffffffa8; background:#04070bc9; font:9px/1 ui-monospace,monospace; pointer-events:none; }.canvas-loading { position:absolute; inset:0; display:grid; place-content:center; justify-items:center; gap:9px; color:var(--muted); background:#080d15; }.plot-card[data-ready="true"] .canvas-loading { display:none; } +.performance-strip { display:grid; grid-template-columns:repeat(auto-fit,minmax(105px,1fr)); gap:1px; margin:0; border-block:1px solid var(--line); background:var(--line); }.performance-strip div { min-width:0; padding:9px 8px; background:#0c1118; }.performance-strip dt { min-width:0; overflow:visible; color:var(--accent); font:700 11px/1.35 ui-monospace,monospace; text-overflow:clip; white-space:normal; overflow-wrap:anywhere; word-break:break-word; }.performance-strip .perf-event,.performance-strip .perf-limit { font-size:10px; }.performance-strip .perf-limit-cell { grid-column:auto; }.performance-strip dd { margin:5px 0 0; color:var(--muted); font-size:8px; line-height:1.35; white-space:normal; overflow-wrap:anywhere; word-break:break-word; } +.limit-flags { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; padding:7px 10px; border-bottom:1px solid var(--line); color:var(--muted); background:#090e15; font:9px/1.35 ui-monospace,monospace; }.limit-flags strong { grid-column:1/-1; color:var(--text); }.limit-flags span { min-width:0; padding:6px 7px; border:1px solid var(--line); border-radius:4px; line-height:1.45; white-space:normal; overflow-wrap:anywhere; word-break:break-word; }.limit-flags span[data-active="true"] { border-color:#ffb84d88; color:#ffcf82; background:#ff9d1712; }.limit-flags b { color:inherit; white-space:normal; overflow-wrap:anywhere; }.plot-card:not([data-mode="low_latency"]) .limit-flags { display:none; } +.kernel-observer-panel { border-bottom:1px solid var(--line); background:#080d14; font-family:ui-monospace,monospace; } +.kernel-observer-panel > header { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 11px; border-bottom:1px solid var(--line); color:var(--muted); font-size:8px; flex-wrap:wrap; } +.kernel-observer-panel > header div:first-child { display:flex; align-items:center; gap:9px; min-width:0; flex-wrap:wrap; }.kernel-observer-panel > header span { color:var(--accent); letter-spacing:.08em; overflow-wrap:anywhere; }.kernel-observer-panel > header strong,.kernel-observer-panel > header b { color:var(--text); font-weight:700; overflow-wrap:anywhere; } +.observer-counters,.latency-summary,.client-summary,.latency-details { display:grid; gap:1px; background:var(--line); } +.observer-counters { grid-template-columns:repeat(auto-fit,minmax(90px,1fr)); }.latency-summary,.client-summary { grid-template-columns:repeat(auto-fit,minmax(105px,1fr)); border-top:1px solid var(--line); }.latency-details { grid-template-columns:repeat(auto-fit,minmax(145px,1fr)); border-top:1px solid var(--line); } +.observer-counters div,.latency-summary div,.client-summary div,.latency-details div { min-width:0; padding:8px 7px; background:#0b1119; } +.kernel-observer-panel small { display:block; min-width:0; overflow:visible; margin-bottom:5px; color:var(--muted); font-size:7px; line-height:1.35; text-overflow:clip; white-space:normal; overflow-wrap:anywhere; word-break:break-word; } +.kernel-observer-panel b { display:block; min-width:0; overflow:visible; color:#b7c7dc; font-size:9px; line-height:1.35; text-overflow:clip; white-space:normal; overflow-wrap:anywhere; word-break:break-word; } +.latency-summary b { color:var(--accent); font-size:10px; }.latency-summary .critical { background:#10201e; }.latency-summary .critical b { color:#72f3d9; } +.plot-card:not([data-mode="low_latency"]) .kernel-observer-panel { display:none; } +.card-meta { display:grid; grid-template-columns:1fr auto; gap:17px; padding:13px 15px; }.card-description { margin:0; color:var(--muted); font-size:11px; line-height:1.55; }.card-meta dl { display:flex; margin:0; }.card-meta dl div { min-width:52px; padding-left:11px; border-left:1px solid var(--line); }.card-meta dt { font:700 14px/1 ui-monospace,monospace; }.card-meta dd { margin:5px 0 0; color:var(--muted); font-size:9px; } +.card-footer { border-top:1px solid var(--line); background:#0000001e; }.card-footer code { color:var(--accent); font-size:10px; }.card-footer > div { display:flex; gap:7px; }.open-menu,.frame-button { padding:7px 9px; color:var(--muted); font-size:10px; }.frame-button { color:var(--text); } +.context-menu { position:fixed; z-index:100; width:min(570px,calc(100vw - 24px)); max-height:min(840px,calc(100vh - 24px)); overflow:hidden; border:1px solid var(--strong); border-radius:12px; background:#0f131afa; box-shadow:0 30px 90px #0000009e; backdrop-filter:blur(22px); }.context-menu[hidden] { display:none; } +.menu-header { display:flex; justify-content:space-between; gap:18px; padding:16px 18px 13px; border-bottom:1px solid var(--line); }.menu-header h2 { margin-bottom:6px; font-size:19px; }.menu-header p:last-child { margin:0; color:var(--muted); font-size:10px; line-height:1.5; }.icon-button { flex:0 0 auto; width:31px; height:31px; font-size:20px; } +.menu-tabs { display:grid; grid-template-columns:repeat(4,1fr); padding:8px; border-bottom:1px solid var(--line); }.menu-tabs button { padding:8px 4px; border-color:transparent; color:var(--muted); background:transparent; font-size:10px; }.menu-tabs button.active { color:var(--text); border-color:var(--strong); background:var(--panel2); } +.menu-body { max-height:calc(min(840px,100vh - 24px) - 190px); overflow:auto; padding:12px; overscroll-behavior:contain; }.control-group + .control-group { margin-top:15px; }.control-group h3 { position:sticky; z-index:1; top:-12px; margin:0 -2px 6px; padding:9px 4px 7px; color:var(--accent); background:#0f131af5; font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; } +.control-row { display:grid; grid-template-columns:minmax(145px,1fr) minmax(130px,.75fr); gap:13px; align-items:center; min-height:49px; padding:8px 9px; border-top:1px solid #ffffff0e; }.control-copy label,.action-copy strong { display:block; margin-bottom:4px; font-size:11px; }.control-copy code,.action-copy code { display:block; overflow:hidden; color:var(--muted); font-size:9px; text-overflow:ellipsis; white-space:nowrap; }.control-input { width:100%; min-width:0; padding:7px 8px; border:1px solid var(--strong); border-radius:6px; outline:none; color:var(--text); background:#090d13; font-size:10px; }.control-input:focus { border-color:var(--accent); }input[type="color"].control-input { height:34px; padding:3px; }input[type="checkbox"].control-input { justify-self:end; width:38px; height:20px; accent-color:var(--accent); } +.action-row { display:flex; align-items:center; justify-content:space-between; gap:13px; padding:10px 9px; border-top:1px solid #ffffff0e; }.action-copy { min-width:0; }.action-controls { display:flex; gap:7px; align-items:center; }.action-controls input { width:105px; }.action-button { padding:7px 10px; color:#07110f; border-color:var(--accent); background:var(--accent); font-size:10px; font-weight:700; } +.telemetry-grid { display:grid; grid-template-columns:minmax(165px,.7fr) 1fr; margin:0; border:1px solid var(--line); }.telemetry-grid dt,.telemetry-grid dd { margin:0; padding:8px 10px; border-bottom:1px solid var(--line); font:10px/1.4 ui-monospace,monospace; overflow-wrap:anywhere; }.telemetry-grid dt { color:var(--muted); background:#ffffff06; }.telemetry-grid dd { color:var(--accent); } +.menu-footer { display:flex; justify-content:space-between; gap:14px; padding:10px 14px; border-top:1px solid var(--line); color:var(--muted); font-size:9px; }.menu-footer code { color:var(--accent); } +.toast { position:fixed; z-index:140; left:50%; bottom:22px; max-width:min(560px,calc(100vw - 30px)); padding:11px 15px; border:1px solid var(--strong); border-radius:8px; background:var(--panel2); box-shadow:0 18px 50px #00000073; transform:translateX(-50%); font-size:11px; }.toast[data-error="true"] { border-color:var(--danger); } +@media (max-width:980px) { .hero { grid-template-columns:1fr; }.gallery { grid-template-columns:1fr; }.mode-tabs button { flex-direction:column; align-items:flex-start; }.page-header { align-items:flex-start; flex-direction:column; }.page-description { text-align:left; } } +@media (max-width:650px) { .topbar { align-items:flex-start; }.topbar-actions { align-items:flex-end; flex-direction:column; }.connection-status span { display:none; }.metrics { grid-template-columns:repeat(2,1fr); }.mode-tabs { grid-template-columns:1fr; }.mode-tabs button { flex-direction:row; }.card-meta { grid-template-columns:1fr; }.performance-strip { grid-template-columns:repeat(auto-fit,minmax(92px,1fr)); }.limit-flags { grid-template-columns:1fr; }.observer-counters { grid-template-columns:repeat(auto-fit,minmax(80px,1fr)); }.latency-summary,.client-summary,.latency-details { grid-template-columns:repeat(auto-fit,minmax(100px,1fr)); }.context-menu { inset:auto 6px 6px!important; width:auto; max-height:calc(100vh - 12px); } }