diff --git a/Core2/render/Blend2D_Cache.cpp b/Core2/render/Blend2D_Cache.cpp index ae12612..d70e9cc 100644 --- a/Core2/render/Blend2D_Cache.cpp +++ b/Core2/render/Blend2D_Cache.cpp @@ -164,12 +164,10 @@ void Blend2D_Color_Cache::composite(const ::Color_Cache& source) { if (typed.image_.is_empty()) return; const Size source_size = typed.size(); - if (image_.is_empty()) { + if (image_.is_empty() || size() != source_size) { ensure_size(source_size); clear_image(image_); } - if (size() != source_size) - return; BLContext context(image_); if (!context) diff --git a/main.cmake b/main.cmake index c26f1cf..fd6b9b6 100644 --- a/main.cmake +++ b/main.cmake @@ -1,12 +1,14 @@ include_guard(GLOBAL) - set(renderive_dependencies global::GTest global::blend2d global::taskflow global::mpmcqueue + global::drogon ${Psc_Core_dependencies} ) +rcl_load_dependency_environment(${renderive_dependencies}) +find_package(Drogon) rcl_add_dependency_action_targets(renderive_env ${renderive_dependencies}) library_is_installed_with_rely(renderive_dependencies_installed ${renderive_dependencies}) if (NOT renderive_dependencies_installed) @@ -14,7 +16,6 @@ if (NOT renderive_dependencies_installed) rcl_log_append("${log}") return() endif () - find_package(Qt5 MODULE REQUIRED COMPONENTS Core Gui Widgets Svg Xml PrintSupport QuickWidgets Multimedia ) @@ -23,7 +24,6 @@ find_package(GTest REQUIRED) find_package(Taskflow CONFIG REQUIRED) find_package(MPMCQueue CONFIG REQUIRED) find_package(Threads REQUIRED) - set(qt_libraries Qt5::Widgets Qt5::Core @@ -34,7 +34,6 @@ set(qt_libraries Qt5::QuickWidgets ) set(root_dir "${CMAKE_CURRENT_LIST_DIR}") - # Kernel is the only rendering architecture used by Core2. add_library(Renderive_Kernel STATIC "${root_dir}/Kernel/src/renderive/renderable/Renderable_Task_Graph.cpp" @@ -44,7 +43,6 @@ add_library(Renderive_Kernel STATIC target_include_directories(Renderive_Kernel PUBLIC "${root_dir}/Kernel/src") target_compile_features(Renderive_Kernel PUBLIC cxx_std_20) target_link_libraries(Renderive_Kernel PRIVATE Taskflow::Taskflow Threads::Threads) - # Preserve the legacy Core target and sources, but keep it out of Radio's build. file(GLOB_RECURSE renderive_legacy_core_sources CONFIGURE_DEPENDS "${root_dir}/Core/*.cpp" @@ -63,7 +61,6 @@ target_link_libraries(Renderive_Core PUBLIC MPMCQueue::MPMCQueue Psc_Core_Static ) - file(GLOB_RECURSE renderive_core2_sources CONFIGURE_DEPENDS "${root_dir}/Core2/*.cpp" "${root_dir}/Core2/*.h" @@ -77,7 +74,6 @@ target_link_libraries(Renderive_Core2 PUBLIC Renderive_Kernel PRIVATE blend2d::blend2d ) - set(renderive_qt_sources "${root_dir}/Qt/bridge/Qt_Event_Adapter.cpp" "${root_dir}/Qt/bridge/Qt_Image_Adapter.cpp" @@ -94,14 +90,12 @@ set_property(TARGET Renderive_Qt PROPERTY AUTOMOC ON) target_include_directories(Renderive_Qt PUBLIC "${root_dir}") target_compile_features(Renderive_Qt PUBLIC cxx_std_20) target_link_libraries(Renderive_Qt PUBLIC Renderive_Core2 ${qt_libraries}) - unset(Psc_Widget_src) append_glob_source(Psc_Widget_src "${root_dir}/Widget") add_library(Psc_Widget STATIC ${Psc_Widget_src}) set_property(TARGET Psc_Widget PROPERTY AUTOMOC ON) set_property(TARGET Psc_Widget PROPERTY AUTORCC ON) target_link_libraries(Psc_Widget PUBLIC ${qt_libraries} Psc_Core_Static) - enable_testing() file(GLOB_RECURSE renderive_kernel_test_sources CONFIGURE_DEPENDS "${root_dir}/Kernel/tests/renderive/*.cpp" @@ -113,7 +107,6 @@ add_executable(Renderive_Kernel_Tests EXCLUDE_FROM_ALL target_compile_definitions(Renderive_Kernel_Tests PRIVATE RENDERIVE_WITH_GTEST) target_link_libraries(Renderive_Kernel_Tests PRIVATE Renderive_Kernel GTest::GTest) add_test(NAME Renderive_Kernel_Tests COMMAND Renderive_Kernel_Tests) - add_executable(Renderive_Core2_Tests EXCLUDE_FROM_ALL "${root_dir}/Kernel/tests/main.cpp" "${root_dir}/Core2/tests/Core2_Integration_Tests.cpp" @@ -124,7 +117,6 @@ target_link_libraries(Renderive_Core2_Tests PRIVATE blend2d::blend2d ) add_test(NAME Renderive_Core2_Tests COMMAND Renderive_Core2_Tests) - add_executable(Renderive_Qt_Tests EXCLUDE_FROM_ALL "${root_dir}/Core2/tests/Qt_Bridge_Tests.cpp" ) @@ -137,14 +129,13 @@ add_test(NAME Renderive_Qt_Tests "QT_QPA_PLATFORM_PLUGIN_PATH=${renderive_qt_runtime_root}/plugins/platforms" "$" ) - add_executable(Renderive_Core_Tests EXCLUDE_FROM_ALL "${root_dir}/test/Renderive_Core_Tests.cpp" ) target_link_libraries(Renderive_Core_Tests PRIVATE Renderive_Core GTest::GTest) - if (MSVC) target_compile_options(Renderive_Kernel PRIVATE /utf-8) target_compile_options(Renderive_Core2 PRIVATE /utf-8) target_compile_options(Renderive_Qt PRIVATE /utf-8) endif () +include(${CMAKE_CURRENT_LIST_DIR}/web.cmake) diff --git a/web.cmake b/web.cmake new file mode 100644 index 0000000..6a0c24b --- /dev/null +++ b/web.cmake @@ -0,0 +1,38 @@ +set(renderive_web_sources + "${root_dir}/web_server/Pixel_Frame.cpp" + "${root_dir}/web_server/Renderive_WebSocket_Controller.cpp" + "${root_dir}/web_server/Web_Event_Adapter.cpp" + "${root_dir}/web_server/Web_Plot_Session.cpp" + "${root_dir}/web_server/Web_Server.cpp" +) + +add_library(Renderive_Web STATIC ${renderive_web_sources}) +target_include_directories(Renderive_Web PUBLIC "${root_dir}") +target_compile_features(Renderive_Web PUBLIC cxx_std_20) +target_compile_definitions(Renderive_Web PRIVATE NOMINMAX) +target_link_libraries(Renderive_Web PRIVATE + Renderive_Core2 + Renderive_Kernel + Drogon::Drogon +) + +add_executable(Renderive_Web_Server "${root_dir}/web_server/main.cpp") +target_compile_features(Renderive_Web_Server PRIVATE cxx_std_20) +target_link_libraries(Renderive_Web_Server PRIVATE Renderive_Web) +add_custom_command(TARGET Renderive_Web_Server POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_directory + "${root_dir}/webapp" + "$/webapp" +) + +add_executable(Renderive_Web_Tests EXCLUDE_FROM_ALL + "${root_dir}/Kernel/tests/main.cpp" + "${root_dir}/web_server/tests/Web_Bridge_Tests.cpp" +) +target_link_libraries(Renderive_Web_Tests PRIVATE Renderive_Web GTest::GTest) +add_test(NAME Renderive_Web_Tests COMMAND Renderive_Web_Tests) + +if (MSVC) + target_compile_options(Renderive_Web PRIVATE /utf-8) + target_compile_options(Renderive_Web_Server PRIVATE /utf-8) +endif () diff --git a/web_server/Pixel_Frame.cpp b/web_server/Pixel_Frame.cpp new file mode 100644 index 0000000..f736d39 --- /dev/null +++ b/web_server/Pixel_Frame.cpp @@ -0,0 +1,68 @@ +#include "Pixel_Frame.h" + +#include +#include +#include + +namespace renderive::web { +namespace { + +void write_u32_le(char* target, std::uint32_t value) { + target[0] = static_cast(value & 0xffU); + target[1] = static_cast((value >> 8U) & 0xffU); + target[2] = static_cast((value >> 16U) & 0xffU); + target[3] = static_cast((value >> 24U) & 0xffU); +} + +std::uint8_t flatten(std::uint8_t premultiplied, std::uint8_t alpha, + std::uint8_t background) { + return static_cast( + std::min(255U, static_cast(premultiplied) + + (static_cast(background) * (255U - alpha) + 127U) / 255U)); +} + +} // namespace + +std::string encode_pixel_frame(Image_View image, Color background) { + if (image.empty() || image.format != Pixel_Format::Premultiplied_32 || + image.stride < image.width * static_cast(sizeof(Pixel))) { + return {}; + } + const auto width = static_cast(image.width); + const auto height = static_cast(image.height); + if (width > (std::numeric_limits::max() - pixel_frame_header_size) / + (height * sizeof(Pixel))) { + return {}; + } + + const std::size_t pixel_bytes = width * height * sizeof(Pixel); + std::string frame(pixel_frame_header_size + pixel_bytes, '\0'); + frame[0] = 'R'; + frame[1] = 'V'; + frame[2] = 'P'; + frame[3] = '1'; + write_u32_le(frame.data() + 4, static_cast(image.width)); + write_u32_le(frame.data() + 8, static_cast(image.height)); + write_u32_le(frame.data() + 12, + static_cast(image.width * static_cast(sizeof(Pixel)))); + + auto* output = reinterpret_cast(frame.data() + pixel_frame_header_size); + for (int y = 0; y < image.height; ++y) { + const auto* row = image.data + static_cast(y) * image.stride; + for (int x = 0; x < image.width; ++x) { + Pixel pixel{}; + std::memcpy(&pixel, row + static_cast(x) * sizeof(Pixel), sizeof(pixel)); + const auto alpha = static_cast((pixel >> 24U) & 0xffU); + const auto red = static_cast((pixel >> 16U) & 0xffU); + const auto green = static_cast((pixel >> 8U) & 0xffU); + const auto blue = static_cast(pixel & 0xffU); + *output++ = flatten(red, alpha, background.r); + *output++ = flatten(green, alpha, background.g); + *output++ = flatten(blue, alpha, background.b); + *output++ = 255; + } + } + return frame; +} + +} // namespace renderive::web diff --git a/web_server/Pixel_Frame.h b/web_server/Pixel_Frame.h new file mode 100644 index 0000000..563103e --- /dev/null +++ b/web_server/Pixel_Frame.h @@ -0,0 +1,15 @@ +#pragma once + +#include "Core2/base/Types.h" + +#include +#include + +namespace renderive::web { + +inline constexpr std::size_t pixel_frame_header_size = 16; + +[[nodiscard]] std::string encode_pixel_frame(Image_View image, + Color background = Color::black()); + +} // namespace renderive::web diff --git a/web_server/Renderive_WebSocket_Controller.cpp b/web_server/Renderive_WebSocket_Controller.cpp new file mode 100644 index 0000000..6b89c9c --- /dev/null +++ b/web_server/Renderive_WebSocket_Controller.cpp @@ -0,0 +1,70 @@ +#include "Renderive_WebSocket_Controller.h" + +#include "Web_Event_Adapter.h" +#include "Web_Plot_Session.h" + +#include + +#include +#include + +namespace renderive::web { + +void Renderive_WebSocket_Controller::handleNewConnection( + const drogon::HttpRequestPtr&, + const drogon::WebSocketConnectionPtr& connection) { + connection->setContext(std::make_shared()); + connection->setPingMessage("renderive", std::chrono::seconds(20)); + LOG_INFO << "Renderive WebSocket connected: " << connection->peerAddr().toIpPort(); +} + +void Renderive_WebSocket_Controller::handleNewMessage( + const drogon::WebSocketConnectionPtr& connection, + std::string&& message, + const drogon::WebSocketMessageType& type) { + if (type == drogon::WebSocketMessageType::Ping || + type == drogon::WebSocketMessageType::Pong || + type == drogon::WebSocketMessageType::Close) { + return; + } + if (type != drogon::WebSocketMessageType::Text) { + connection->shutdown(drogon::CloseCode::kInvalidMessage, + "Renderive accepts text events only"); + return; + } + if (message.size() > 16 * 1024) { + connection->shutdown(drogon::CloseCode::kMessageTooBig, + "Renderive event is too large"); + return; + } + const auto event = Web_Event_Adapter::decode(message); + if (!event) { + connection->shutdown(drogon::CloseCode::kWrongMessageContent, + "Invalid Renderive event"); + return; + } + const auto session = connection->getContext(); + if (!session) { + connection->shutdown(drogon::CloseCode::kUnexpectedCondition, + "Renderive session is unavailable"); + return; + } + try { + if (auto pixels = session->handle(*event)) { + connection->send(pixels->data(), pixels->size(), + drogon::WebSocketMessageType::Binary); + } + } catch (const std::exception& error) { + LOG_ERROR << "Renderive WebSocket session failed: " << error.what(); + connection->shutdown(drogon::CloseCode::kUnexpectedCondition, + "Renderive rendering failed"); + } +} + +void Renderive_WebSocket_Controller::handleConnectionClosed( + const drogon::WebSocketConnectionPtr& connection) { + LOG_INFO << "Renderive WebSocket closed: " << connection->peerAddr().toIpPort(); + connection->clearContext(); +} + +} // namespace renderive::web diff --git a/web_server/Renderive_WebSocket_Controller.h b/web_server/Renderive_WebSocket_Controller.h new file mode 100644 index 0000000..68dad1a --- /dev/null +++ b/web_server/Renderive_WebSocket_Controller.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +namespace renderive::web { + +class Renderive_WebSocket_Controller final + : public drogon::WebSocketController { +public: + void handleNewMessage(const drogon::WebSocketConnectionPtr& connection, + std::string&& message, + const drogon::WebSocketMessageType& type) override; + void handleNewConnection(const drogon::HttpRequestPtr& request, + const drogon::WebSocketConnectionPtr& connection) override; + void handleConnectionClosed(const drogon::WebSocketConnectionPtr& connection) override; + + WS_PATH_LIST_BEGIN + WS_PATH_ADD("/renderive"); + WS_PATH_LIST_END +}; + +} // namespace renderive::web diff --git a/web_server/Web_Event.h b/web_server/Web_Event.h new file mode 100644 index 0000000..7ee5c30 --- /dev/null +++ b/web_server/Web_Event.h @@ -0,0 +1,57 @@ +#pragma once + +#include "Core2/event/Event.h" + +#include + +namespace renderive::web { + +struct Frame_Request {}; + +struct Viewport_Resize { + Size size; +}; + +enum class Demo_Mode : std::uint8_t { Am, Fm, Usb, Lsb }; + +struct Set_Demo_Mode { + Demo_Mode mode = Demo_Mode::Fm; +}; + +struct Set_Center_Frequency { + double megahertz = 102.5; +}; + +struct Set_Bandwidth { + double kilohertz = 1200.0; +}; + +struct Set_Gain { + double decibels{}; +}; + +struct Set_Max_Hold { + bool enabled{}; +}; + +struct Set_Smoothing { + bool enabled{}; +}; + +struct Clear_Selection {}; + +using Web_Event = std::variant; + +} // namespace renderive::web diff --git a/web_server/Web_Event_Adapter.cpp b/web_server/Web_Event_Adapter.cpp new file mode 100644 index 0000000..b171b0c --- /dev/null +++ b/web_server/Web_Event_Adapter.cpp @@ -0,0 +1,213 @@ +#include "Web_Event_Adapter.h" + +#include + +#include +#include +#include +#include + +namespace renderive::web { +namespace { + +std::optional parse_json(std::string_view message) { + Json::CharReaderBuilder builder; + builder["collectComments"] = false; + std::unique_ptr reader(builder.newCharReader()); + Json::Value root; + std::string errors; + if (!reader->parse(message.data(), message.data() + message.size(), &root, &errors) || + !root.isObject()) { + return std::nullopt; + } + return root; +} + +std::optional finite_number(const Json::Value& root, const char* name) { + const auto& value = root[name]; + if (!value.isNumeric()) + return std::nullopt; + const double result = value.asDouble(); + return std::isfinite(result) ? std::optional(result) : std::nullopt; +} + +std::optional boolean(const Json::Value& root, const char* name) { + const auto& value = root[name]; + return value.isBool() ? std::optional(value.asBool()) : std::nullopt; +} + +Keyboard_Modifier modifiers(const Json::Value& root) { + const int value = root["modifiers"].isInt() ? root["modifiers"].asInt() : 0; + return static_cast(std::clamp(value, 0, 15)); +} + +Mouse_Button mouse_button(const Json::Value& root) { + const std::string value = root["button"].isString() ? root["button"].asString() : "none"; + if (value == "left") + return Mouse_Button::Left; + if (value == "right") + return Mouse_Button::Right; + if (value == "middle") + return Mouse_Button::Middle; + return Mouse_Button::None; +} + +Key key(const Json::Value& root) { + const std::string value = root["key"].isString() ? root["key"].asString() : ""; + if (value == "Escape") + return Key::Escape; + if (value == "Enter") + return Key::Enter; + if (value == "Space") + return Key::Space; + if (value == "Delete") + return Key::Delete; + if (value == "Backspace") + return Key::Backspace; + if (value == "ArrowLeft") + return Key::Left; + if (value == "ArrowRight") + return Key::Right; + if (value == "ArrowUp") + return Key::Up; + if (value == "ArrowDown") + return Key::Down; + return Key::Unknown; +} + +std::optional pointer_event(const Json::Value& root, Event_Type type) { + const auto x = finite_number(root, "x"); + const auto y = finite_number(root, "y"); + if (!x || !y) + return std::nullopt; + Pointer_Event event(type); + event.position = {*x, *y}; + event.global_position = event.position; + event.button = mouse_button(root); + const int buttons = root["buttons"].isInt() ? root["buttons"].asInt() : 0; + event.buttons = static_cast(std::clamp(buttons, 0, 7)); + event.modifiers = modifiers(root); + return event; +} + +std::optional wheel_event(const Json::Value& root) { + const auto x = finite_number(root, "x"); + const auto y = finite_number(root, "y"); + const auto pixel_x = finite_number(root, "pixelDeltaX"); + const auto pixel_y = finite_number(root, "pixelDeltaY"); + const auto angle_x = finite_number(root, "angleDeltaX"); + const auto angle_y = finite_number(root, "angleDeltaY"); + if (!x || !y || !pixel_x || !pixel_y || !angle_x || !angle_y) + return std::nullopt; + Wheel_Event event; + event.position = {*x, *y}; + event.global_position = event.position; + event.pixel_delta_x = *pixel_x; + event.pixel_delta_y = *pixel_y; + event.angle_delta_x = *angle_x; + event.angle_delta_y = *angle_y; + event.modifiers = modifiers(root); + const int buttons = root["buttons"].isInt() ? root["buttons"].asInt() : 0; + event.buttons = static_cast(std::clamp(buttons, 0, 7)); + return event; +} + +std::optional key_event(const Json::Value& root, Event_Type type) { + Key_Event event(type); + event.key = key(root); + event.native_key = root["nativeKey"].isUInt() ? root["nativeKey"].asUInt() : 0; + event.modifiers = modifiers(root); + event.auto_repeat = root["repeat"].isBool() && root["repeat"].asBool(); + return event; +} + +std::optional control_event(const Json::Value& root) { + if (!root["control"].isString()) + return std::nullopt; + const std::string control = root["control"].asString(); + if (control == "mode" && root["value"].isString()) { + const std::string value = root["value"].asString(); + if (value == "AM") + return Set_Demo_Mode{Demo_Mode::Am}; + if (value == "FM") + return Set_Demo_Mode{Demo_Mode::Fm}; + if (value == "USB") + return Set_Demo_Mode{Demo_Mode::Usb}; + if (value == "LSB") + return Set_Demo_Mode{Demo_Mode::Lsb}; + return std::nullopt; + } + if (control == "center_frequency_mhz") { + const auto value = finite_number(root, "value"); + return value && *value >= 1.0 && *value <= 6000.0 + ? std::optional(Set_Center_Frequency{*value}) + : std::nullopt; + } + if (control == "bandwidth_khz") { + const auto value = finite_number(root, "value"); + return value && *value >= 10.0 && *value <= 50000.0 + ? std::optional(Set_Bandwidth{*value}) + : std::nullopt; + } + if (control == "gain_db") { + const auto value = finite_number(root, "value"); + return value && *value >= -30.0 && *value <= 80.0 + ? std::optional(Set_Gain{*value}) + : std::nullopt; + } + if (control == "max_hold") { + const auto value = boolean(root, "value"); + return value ? std::optional(Set_Max_Hold{*value}) : std::nullopt; + } + if (control == "smoothing") { + const auto value = boolean(root, "value"); + return value ? std::optional(Set_Smoothing{*value}) : std::nullopt; + } + if (control == "clear_selection") + return Clear_Selection{}; + return std::nullopt; +} + +} // namespace + +std::optional Web_Event_Adapter::decode(std::string_view message) { + const auto parsed = parse_json(message); + if (!parsed || (*parsed)["category"].asString() != "event" || !(*parsed)["type"].isString()) + return std::nullopt; + + const Json::Value& root = *parsed; + const std::string type = root["type"].asString(); + if (type == "frame") + return Frame_Request{}; + if (type == "resize") { + const auto width = finite_number(root, "width"); + const auto height = finite_number(root, "height"); + if (!width || !height) + return std::nullopt; + return Viewport_Resize{{std::clamp(static_cast(*width), 240, 1920), + std::clamp(static_cast(*height), 180, 1200)}}; + } + if (type == "show") + return Event(Event_Type::Show); + if (type == "hide") + return Event(Event_Type::Hide); + if (type == "leave") + return Event(Event_Type::Leave); + if (type == "pointer_move") + return pointer_event(root, Event_Type::Pointer_Move); + if (type == "pointer_press") + return pointer_event(root, Event_Type::Pointer_Press); + if (type == "pointer_release") + return pointer_event(root, Event_Type::Pointer_Release); + if (type == "wheel") + return wheel_event(root); + if (type == "key_press") + return key_event(root, Event_Type::Key_Press); + if (type == "key_release") + return key_event(root, Event_Type::Key_Release); + if (type == "control") + return control_event(root); + return std::nullopt; +} + +} // namespace renderive::web diff --git a/web_server/Web_Event_Adapter.h b/web_server/Web_Event_Adapter.h new file mode 100644 index 0000000..ee540f2 --- /dev/null +++ b/web_server/Web_Event_Adapter.h @@ -0,0 +1,15 @@ +#pragma once + +#include "Web_Event.h" + +#include +#include + +namespace renderive::web { + +class Web_Event_Adapter final { +public: + [[nodiscard]] static std::optional decode(std::string_view message); +}; + +} // namespace renderive::web diff --git a/web_server/Web_Plot_Session.cpp b/web_server/Web_Plot_Session.cpp new file mode 100644 index 0000000..8405162 --- /dev/null +++ b/web_server/Web_Plot_Session.cpp @@ -0,0 +1,328 @@ +#include "Web_Plot_Session.h" + +#include "Pixel_Frame.h" +#include "Core2/export.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace renderive::web { +namespace { + +Color blend(Color first, Color second, double amount) { + const auto channel = [amount](std::uint8_t a, std::uint8_t b) { + return static_cast(std::clamp( + std::lround(a + (b - a) * amount), 0L, 255L)); + }; + return {channel(first.r, second.r), channel(first.g, second.g), + channel(first.b, second.b), channel(first.a, second.a)}; +} + +Color_Map radio_color_map() { + constexpr Color stops[] = { + {3, 7, 18, 255}, + {16, 52, 105, 255}, + {23, 163, 184, 255}, + {238, 210, 91, 255}, + {239, 68, 68, 255} + }; + std::vector colors; + colors.reserve(256); + for (int index = 0; index < 256; ++index) { + const double position = index / 255.0 * (std::size(stops) - 1); + const auto stop = static_cast(std::floor(position)); + const auto next = std::min(stop + 1, std::size(stops) - 1); + colors.push_back(premultiply(blend(stops[stop], stops[next], position - stop))); + } + return Color_Map(std::move(colors)); +} + +Time_Of_Day current_time_of_day() { + constexpr std::int64_t day_ms = 24LL * 60LL * 60LL * 1000LL; + const auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + return {now % day_ms}; +} + +double gaussian(double x, double center, double width) { + const double normalized = (x - center) / width; + return std::exp(-0.5 * normalized * normalized); +} + +} // namespace + +struct Web_Plot_Session::Impl { + Plot_Core plot; + std::shared_ptr spectrum_frequency_axis; + std::shared_ptr spectrum_power_axis; + std::shared_ptr waterfall_frequency_axis; + std::shared_ptr waterfall_time_axis; + std::shared_ptr spectrum; + std::shared_ptr waterfall; + std::shared_ptr selection; + Demo_Mode mode = Demo_Mode::Fm; + double gain_db = 8.0; + std::uint64_t frame_index{}; + std::mutex mutex; + + Impl() { + plot.init(); + plot.set_background_color({3, 7, 18, 255}); + plot.set_max_render_fps(30.0); + plot.set_viewport_size({960, 600}); + + const auto root = plot.root_renderable(); + const auto data = plot.create_renderable_node(root, "Web_Data"); + const auto axes = plot.create_renderable_node(root, "Web_Axes"); + const auto overlay = plot.create_renderable_node(root, "Web_Overlay"); + axes->set_cache_mode(Renderable_Cache_Mode::Local_Pixel); + + constexpr Color axis_color{93, 116, 151, 255}; + const Range initial_frequency{101'300'000.0, 103'700'000.0}; + spectrum_frequency_axis = + Frequency_Axis::Builder(axes, Orientation::Horizontal) + .set_coord_range(initial_frequency) + .set_label_precision(2) + .set_tick_length(8) + .set_sub_tick_length(4) + .set_color(axis_color) + .set_use_wheel(true) + .set_use_drag(true) + .build(); + spectrum_power_axis = + Axis::Builder(axes, Orientation::Vertical) + .set_coord_range({-20.0, -120.0}) + .set_label_precision(0) + .set_tick_length(-8) + .set_sub_tick_length(-4) + .set_color(axis_color) + .set_unit_text("dBm") + .build(); + waterfall_frequency_axis = + Frequency_Axis::Builder(axes, Orientation::Horizontal) + .set_coord_range(initial_frequency) + .set_label_precision(2) + .set_tick_length(8) + .set_sub_tick_length(4) + .set_color(axis_color) + .set_use_wheel(true) + .set_use_drag(true) + .build(); + waterfall_time_axis = + Time_Axis::Builder(axes, Orientation::Vertical) + .set_visible_time_point_count(72) + .set_tick_label_spacing_px(34) + .set_time_format("mm:ss") + .set_tick_length(-8) + .set_sub_tick_length(-4) + .set_color(axis_color) + .build(); + + spectrum = Spectrum::Builder(data, spectrum_frequency_axis, spectrum_power_axis) + .set_frequency_range(initial_frequency) + .set_frequency_point_size(768) + .set_center_frequency(102'500'000.0) + .set_sweep_frequency_range({102'200'000.0, 102'800'000.0}) + .set_max_marker_visible(true) + .set_sweep_region_visible(true) + .set_interpolation_mode(Line_Interpolation_Mode::Cubic_Value) + .build(); + spectrum->set_current_pen({Color{57, 224, 177, 255}, 2.0, Line_Style::Solid, + Line_Cap::Round, Line_Join::Round}); + spectrum->set_current_brush({Color{31, 174, 145, 32}, Brush_Style::Solid}); + spectrum->set_max_pen({Color{250, 204, 21, 210}, 1.0}); + spectrum->set_middle_frequency_pen({Color{56, 189, 248, 220}, 1.0, + Line_Style::Dash}); + + waterfall = Waterfall::Builder(data, waterfall_frequency_axis, waterfall_time_axis) + .set_frequency_range(initial_frequency) + .set_power_range({-120.0, -20.0}) + .set_frequency_bin_count(768) + .set_interpolation_mode(Image_Interpolation_Mode::Bilinear) + .set_color_map(radio_color_map()) + .build(); + selection = Selection_Rectangle_Overlay::Builder( + overlay, spectrum_frequency_axis, spectrum_power_axis) + .set_rect_brush({Color{56, 189, 248, 36}, Brush_Style::Solid}) + .set_border_pen({Color{125, 211, 252, 230}, 1.0, Line_Style::Dash}) + .build(); + + apply_layout(plot.viewport_size()); + plot.activate_view(); + update_model(); + (void)plot.render_frame(true); + } + + ~Impl() { plot.deactivate_view(); } + + void apply_layout(Size viewport) { + const int left = viewport.width < 620 ? 54 : 72; + const int right = viewport.width < 620 ? 44 : 70; + const int top = 16; + const int bottom = viewport.height < 480 ? 24 : 32; + const int gap = viewport.height < 480 ? 36 : 48; + const int content_width = std::max(1, viewport.width - left - right); + const int available_height = std::max(2, viewport.height - top - bottom - gap); + const int spectrum_height = std::max(1, available_height * 43 / 100); + const int waterfall_y = top + spectrum_height + gap; + const int waterfall_height = std::max(1, viewport.height - waterfall_y - bottom); + + spectrum_frequency_axis->set_x(left); + spectrum_frequency_axis->set_y(top + spectrum_height); + spectrum_frequency_axis->set_pixel_length(static_cast(content_width)); + spectrum_power_axis->set_x(left); + spectrum_power_axis->set_y(top); + spectrum_power_axis->set_pixel_length(static_cast(spectrum_height)); + + waterfall_frequency_axis->set_x(left); + waterfall_frequency_axis->set_y(waterfall_y + waterfall_height); + waterfall_frequency_axis->set_pixel_length(static_cast(content_width)); + waterfall_time_axis->set_x(left); + waterfall_time_axis->set_y(waterfall_y); + waterfall_time_axis->set_pixel_length(static_cast(waterfall_height)); + } + + void update_model() { + constexpr int sample_count = 768; + const double time = static_cast(frame_index) * 0.075; + std::vector samples(sample_count); + for (int index = 0; index < sample_count; ++index) { + const double x = static_cast(index) / (sample_count - 1); + const double noise = std::sin(index * 12.9898 + frame_index * 0.371) * + std::sin(index * 0.137 + frame_index * 0.071); + double signal{}; + switch (mode) { + case Demo_Mode::Am: + signal = 66.0 * gaussian(x, 0.5, 0.008) + + 39.0 * gaussian(x, 0.42, 0.018) + + 39.0 * gaussian(x, 0.58, 0.018); + break; + case Demo_Mode::Fm: { + const double moving = 0.5 + std::sin(time) * 0.035; + signal = 58.0 * gaussian(x, moving, 0.055) + + 25.0 * gaussian(x, moving - 0.11, 0.023) + + 25.0 * gaussian(x, moving + 0.11, 0.023); + break; + } + case Demo_Mode::Usb: + signal = 61.0 * gaussian(x, 0.57, 0.045) + + 28.0 * gaussian(x, 0.68, 0.025); + break; + case Demo_Mode::Lsb: + signal = 61.0 * gaussian(x, 0.43, 0.045) + + 28.0 * gaussian(x, 0.32, 0.025); + break; + } + samples[index] = std::clamp(-110.0 + noise * 4.5 + signal + gain_db, -120.0, -20.0); + } + spectrum->update_samples(samples); + if ((frame_index & 1U) == 0U) + waterfall->append_row(current_time_of_day(), samples); + ++frame_index; + } + + void set_center_frequency(double megahertz) { + const double center = megahertz * 1'000'000.0; + const double bandwidth = spectrum_frequency_axis->coord_range().size(); + const Range range{center - bandwidth * 0.5, center + bandwidth * 0.5}; + spectrum_frequency_axis->set_coord_range(range); + waterfall_frequency_axis->set_coord_range(range); + spectrum->set_frequency_range(range); + spectrum->set_center_frequency(center); + spectrum->set_sweep_frequency_range({center - bandwidth * 0.125, + center + bandwidth * 0.125}); + waterfall->set_frequency_range(range); + } + + void set_bandwidth(double kilohertz) { + const double center = spectrum->center_frequency(); + const double bandwidth = kilohertz * 1000.0; + const Range range{center - bandwidth * 0.5, center + bandwidth * 0.5}; + spectrum_frequency_axis->set_coord_range(range); + waterfall_frequency_axis->set_coord_range(range); + spectrum->set_frequency_range(range); + spectrum->set_sweep_frequency_range({center - bandwidth * 0.125, + center + bandwidth * 0.125}); + waterfall->set_frequency_range(range); + } + + std::optional render_pixels() { + if (!plot.view_active()) + return std::nullopt; + update_model(); + if (!plot.render_frame(true)) + return std::nullopt; + std::string pixels; + const Color background = plot.background_color(); + plot.with_frame([&pixels, background](Image_View image) { + pixels = encode_pixel_frame(image, background); + }); + return pixels.empty() ? std::nullopt : std::optional(std::move(pixels)); + } + + std::optional handle(const Web_Event& event) { + std::lock_guard lock(mutex); + return std::visit( + [this](const auto& value) -> std::optional { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return render_pixels(); + } else if constexpr (std::is_same_v) { + const Size previous = plot.viewport_size(); + plot.set_viewport_size(value.size); + apply_layout(value.size); + Resize_Event resized; + resized.old_size = previous; + resized.new_size = value.size; + plot.dispatch_event(resized); + } else if constexpr (std::is_same_v) { + if (value.type == Event_Type::Show) + plot.activate_view(); + else if (value.type == Event_Type::Hide) + plot.deactivate_view(); + plot.dispatch_event(value); + } else if constexpr (std::is_base_of_v) { + plot.dispatch_event(value); + } else if constexpr (std::is_same_v) { + mode = value.mode; + plot.notify_model_dirty(); + } else if constexpr (std::is_same_v) { + set_center_frequency(value.megahertz); + } else if constexpr (std::is_same_v) { + set_bandwidth(value.kilohertz); + } else if constexpr (std::is_same_v) { + gain_db = value.decibels; + plot.notify_model_dirty(); + } else if constexpr (std::is_same_v) { + spectrum->set_max_hold_visible(value.enabled); + } else if constexpr (std::is_same_v) { + spectrum->set_interpolation_mode( + value.enabled ? Line_Interpolation_Mode::Cubic_Value + : Line_Interpolation_Mode::Nearest_Sample); + waterfall->set_interpolation_mode( + value.enabled ? Image_Interpolation_Mode::Bilinear + : Image_Interpolation_Mode::Nearest); + } else if constexpr (std::is_same_v) { + selection->clear_selected_regions(); + } + return std::nullopt; + }, + event); + } +}; + +Web_Plot_Session::Web_Plot_Session() : impl_(std::make_unique()) {} +Web_Plot_Session::~Web_Plot_Session() = default; + +std::optional Web_Plot_Session::handle(const Web_Event& event) { + return impl_->handle(event); +} + +} // namespace renderive::web diff --git a/web_server/Web_Plot_Session.h b/web_server/Web_Plot_Session.h new file mode 100644 index 0000000..6f6af09 --- /dev/null +++ b/web_server/Web_Plot_Session.h @@ -0,0 +1,25 @@ +#pragma once + +#include "Web_Event.h" + +#include +#include +#include + +namespace renderive::web { + +class Web_Plot_Session final { +public: + Web_Plot_Session(); + ~Web_Plot_Session(); + Web_Plot_Session(const Web_Plot_Session&) = delete; + Web_Plot_Session& operator=(const Web_Plot_Session&) = delete; + + [[nodiscard]] std::optional handle(const Web_Event& event); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace renderive::web diff --git a/web_server/Web_Server.cpp b/web_server/Web_Server.cpp new file mode 100644 index 0000000..bbdee97 --- /dev/null +++ b/web_server/Web_Server.cpp @@ -0,0 +1,29 @@ +#include "Web_Server.h" + +#include "Renderive_WebSocket_Controller.h" + +#include + +#include +#include +#include +#include + +namespace renderive::web { + +int run_web_server(std::uint16_t port) { + const auto controller = std::make_shared(); + const auto hardware_threads = std::max(2U, std::thread::hardware_concurrency()); + std::cout << "Renderive WebSocket backend: ws://127.0.0.1:" << port + << "/renderive\n" + << "Open webapp/index.html directly; no HTTP application endpoint is used.\n"; + drogon::app() + .registerController(controller) + .addListener("127.0.0.1", port) + .setThreadNum(std::min(8U, hardware_threads)) + .setIdleConnectionTimeout(90) + .run(); + return 0; +} + +} // namespace renderive::web diff --git a/web_server/Web_Server.h b/web_server/Web_Server.h new file mode 100644 index 0000000..7cd9901 --- /dev/null +++ b/web_server/Web_Server.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace renderive::web { + +int run_web_server(std::uint16_t port); + +} // namespace renderive::web diff --git a/web_server/main.cpp b/web_server/main.cpp new file mode 100644 index 0000000..affec88 --- /dev/null +++ b/web_server/main.cpp @@ -0,0 +1,29 @@ +#include "Web_Server.h" + +#include +#include +#include +#include + +namespace { + +std::uint16_t parse_port(int argc, char** argv) { + constexpr std::uint16_t default_port = 8848; + if (argc != 3 || std::string_view(argv[1]) != "--port") + return default_port; + unsigned value{}; + const std::string_view text(argv[2]); + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); + if (error != std::errc{} || end != text.data() + text.size() || value == 0 || value > 65535) { + std::cerr << "Invalid port: " << text << '\n'; + return 0; + } + return static_cast(value); +} + +} // namespace + +int main(int argc, char** argv) { + const std::uint16_t port = parse_port(argc, argv); + return port == 0 ? 2 : renderive::web::run_web_server(port); +} diff --git a/web_server/tests/Web_Bridge_Tests.cpp b/web_server/tests/Web_Bridge_Tests.cpp new file mode 100644 index 0000000..ea57087 --- /dev/null +++ b/web_server/tests/Web_Bridge_Tests.cpp @@ -0,0 +1,70 @@ +#include "web_server/Pixel_Frame.h" +#include "web_server/Web_Event_Adapter.h" +#include "web_server/Web_Plot_Session.h" + +#include + +#include +#include + +namespace renderive::web { +namespace { + +std::uint32_t read_u32_le(const std::string& value, std::size_t offset) { + return static_cast(value[offset]) | + (static_cast(static_cast(value[offset + 1])) << 8U) | + (static_cast(static_cast(value[offset + 2])) << 16U) | + (static_cast(static_cast(value[offset + 3])) << 24U); +} + +TEST(RenderiveWebBridge, DecodesOnlyEventMessages) { + const auto resize = Web_Event_Adapter::decode( + R"({"category":"event","type":"resize","width":800,"height":450})"); + ASSERT_TRUE(resize.has_value()); + ASSERT_TRUE(std::holds_alternative(*resize)); + EXPECT_EQ(std::get(*resize).size, (Size{800, 450})); + + const auto pointer = Web_Event_Adapter::decode( + R"({"category":"event","type":"pointer_press","x":22.5,"y":18,"button":"left","buttons":1,"modifiers":3})"); + ASSERT_TRUE(pointer.has_value()); + ASSERT_TRUE(std::holds_alternative(*pointer)); + EXPECT_EQ(std::get(*pointer).button, Mouse_Button::Left); + EXPECT_DOUBLE_EQ(std::get(*pointer).position.x, 22.5); + + EXPECT_FALSE(Web_Event_Adapter::decode(R"({"category":"command","type":"frame"})")); + EXPECT_FALSE(Web_Event_Adapter::decode("not-json")); +} + +TEST(RenderiveWebBridge, EncodesRgbaPixelFrame) { + const Pixel pixels[] = {pack_rgba(255, 0, 0), pack_rgba(0, 255, 0)}; + const Image_View image{reinterpret_cast(pixels), 2, 1, + static_cast(sizeof(pixels)), Pixel_Format::Premultiplied_32}; + const std::string frame = encode_pixel_frame(image); + ASSERT_EQ(frame.size(), pixel_frame_header_size + 8); + EXPECT_EQ(frame.substr(0, 4), "RVP1"); + EXPECT_EQ(read_u32_le(frame, 4), 2U); + EXPECT_EQ(read_u32_le(frame, 8), 1U); + EXPECT_EQ(read_u32_le(frame, 12), 8U); + const auto* rgba = reinterpret_cast(frame.data() + pixel_frame_header_size); + EXPECT_EQ(rgba[0], 255); + EXPECT_EQ(rgba[1], 0); + EXPECT_EQ(rgba[2], 0); + EXPECT_EQ(rgba[3], 255); + EXPECT_EQ(rgba[4], 0); + EXPECT_EQ(rgba[5], 255); +} + +TEST(RenderiveWebBridge, RendersPixelsOnlyForFrameEvent) { + Web_Plot_Session session; + EXPECT_FALSE(session.handle(Viewport_Resize{{640, 360}}).has_value()); + EXPECT_FALSE(session.handle(Set_Demo_Mode{Demo_Mode::Usb}).has_value()); + const auto frame = session.handle(Frame_Request{}); + ASSERT_TRUE(frame.has_value()); + ASSERT_GE(frame->size(), pixel_frame_header_size); + EXPECT_EQ(frame->substr(0, 4), "RVP1"); + EXPECT_EQ(read_u32_le(*frame, 4), 640U); + EXPECT_EQ(read_u32_le(*frame, 8), 360U); +} + +} // namespace +} // namespace renderive::web diff --git a/webapp/app.js b/webapp/app.js new file mode 100644 index 0000000..73c8ef3 --- /dev/null +++ b/webapp/app.js @@ -0,0 +1,335 @@ +"use strict"; + +const elements = { + canvas: document.getElementById("render-canvas"), + stage: document.getElementById("canvas-stage"), + overlay: document.getElementById("stream-overlay"), + overlayTitle: document.getElementById("overlay-title"), + overlayDetail: document.getElementById("overlay-detail"), + connectionDot: document.getElementById("connection-dot"), + connectionLabel: document.getElementById("connection-label"), + connectionEndpoint: document.getElementById("connection-endpoint"), + reconnect: document.getElementById("reconnect-button"), + fps: document.getElementById("fps-value"), + latency: document.getElementById("latency-value"), + resolution: document.getElementById("resolution-value"), + throughput: document.getElementById("throughput-value"), + frameCounter: document.getElementById("frame-counter"), + frequency: document.getElementById("frequency-value"), + tuneDown: document.getElementById("tune-down"), + tuneUp: document.getElementById("tune-up"), + modeLabel: document.getElementById("mode-label"), + modeButtons: [...document.querySelectorAll(".mode-button")], + bandwidth: document.getElementById("bandwidth"), + bandwidthValue: document.getElementById("bandwidth-value"), + gain: document.getElementById("gain"), + gainValue: document.getElementById("gain-value"), + maxHold: document.getElementById("max-hold"), + smoothing: document.getElementById("smoothing"), + clearSelection: document.getElementById("clear-selection"), + pause: document.getElementById("pause-button"), + pauseLabel: document.getElementById("pause-label") +}; + +const state = { + socket: null, + reconnectTimer: 0, + frameTimer: 0, + framePending: false, + requestStarted: 0, + lastFrameAt: 0, + smoothedFps: 0, + frameCount: 0, + paused: false, + manuallyReconnecting: false, + requestedWidth: 960, + requestedHeight: 600 +}; + +const context = elements.canvas.getContext("2d", {alpha: false, desynchronized: true}); +const query = new URLSearchParams(window.location.search); +const endpoint = query.get("ws") || "ws://127.0.0.1:8848/renderive"; +elements.connectionEndpoint.textContent = endpoint; + +function setConnectionState(kind, title, detail) { + elements.connectionDot.classList.remove("online", "offline"); + if (kind) + elements.connectionDot.classList.add(kind); + elements.connectionLabel.textContent = title; + elements.overlayTitle.textContent = title; + elements.overlayDetail.textContent = detail; + if (kind !== "online") + elements.overlay.classList.remove("hidden"); +} + +function sendEvent(type, payload = {}) { + if (!state.socket || state.socket.readyState !== WebSocket.OPEN) + return false; + state.socket.send(JSON.stringify({category: "event", type, ...payload})); + return true; +} + +function currentFrequency() { + return Number(elements.frequency.dataset.value); +} + +function setFrequency(value, notify = true) { + const frequency = Math.max(1, Math.min(6000, Number(value.toFixed(3)))); + elements.frequency.dataset.value = String(frequency); + elements.frequency.textContent = frequency.toFixed(3); + if (notify) + sendEvent("control", {control: "center_frequency_mhz", value: frequency}); +} + +function activeMode() { + return elements.modeButtons.find(button => button.classList.contains("active"))?.dataset.mode || "FM"; +} + +function buttonEnabled(button) { + return button.getAttribute("aria-pressed") === "true"; +} + +function setToggle(button, enabled, notify = true) { + button.setAttribute("aria-pressed", String(enabled)); + button.classList.toggle("active", enabled); + if (!notify) + return; + const control = button === elements.maxHold ? "max_hold" : "smoothing"; + sendEvent("control", {control, value: enabled}); +} + +function sendControlSnapshot() { + sendEvent("control", {control: "mode", value: activeMode()}); + sendEvent("control", {control: "center_frequency_mhz", value: currentFrequency()}); + sendEvent("control", {control: "bandwidth_khz", value: Number(elements.bandwidth.value)}); + sendEvent("control", {control: "gain_db", value: Number(elements.gain.value)}); + sendEvent("control", {control: "max_hold", value: buttonEnabled(elements.maxHold)}); + sendEvent("control", {control: "smoothing", value: buttonEnabled(elements.smoothing)}); +} + +function syncViewport() { + const bounds = elements.stage.getBoundingClientRect(); + const width = Math.max(240, Math.min(1920, Math.round(bounds.width))); + const height = Math.max(180, Math.min(1200, Math.round(bounds.height))); + if (width === state.requestedWidth && height === state.requestedHeight) + return; + state.requestedWidth = width; + state.requestedHeight = height; + elements.canvas.width = width; + elements.canvas.height = height; + sendEvent("resize", {width, height}); +} + +function scheduleFrame(delay = 42) { + window.clearTimeout(state.frameTimer); + if (!state.paused) + state.frameTimer = window.setTimeout(requestFrame, delay); +} + +function requestFrame() { + if (state.paused || state.framePending) + return; + state.requestStarted = performance.now(); + state.framePending = sendEvent("frame"); +} + +function drawPixelFrame(buffer) { + if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 16) + throw new Error("像素帧长度无效"); + const bytes = new Uint8Array(buffer); + if (bytes[0] !== 82 || bytes[1] !== 86 || bytes[2] !== 80 || bytes[3] !== 49) + throw new Error("像素帧标识无效"); + const view = new DataView(buffer); + const width = view.getUint32(4, true); + const height = view.getUint32(8, true); + const stride = view.getUint32(12, true); + if (!width || !height || stride !== width * 4 || buffer.byteLength !== 16 + stride * height) + throw new Error("像素帧尺寸无效"); + + if (elements.canvas.width !== width || elements.canvas.height !== height) { + elements.canvas.width = width; + elements.canvas.height = height; + } + const pixels = new Uint8ClampedArray(buffer, 16, width * height * 4); + context.putImageData(new ImageData(pixels, width, height), 0, 0); + + const now = performance.now(); + const elapsed = state.lastFrameAt ? now - state.lastFrameAt : 0; + if (elapsed > 0) { + const instantaneous = 1000 / elapsed; + state.smoothedFps = state.smoothedFps ? state.smoothedFps * 0.82 + instantaneous * 0.18 : instantaneous; + } + const roundTrip = Math.max(0, now - state.requestStarted); + const megabitsPerSecond = roundTrip > 0 ? buffer.byteLength * 8 / roundTrip / 1000 : 0; + state.lastFrameAt = now; + state.frameCount += 1; + state.framePending = false; + + elements.fps.textContent = state.smoothedFps.toFixed(1); + elements.latency.textContent = roundTrip.toFixed(1); + elements.resolution.textContent = `${width} × ${height}`; + elements.throughput.textContent = megabitsPerSecond.toFixed(1); + elements.frameCounter.textContent = state.frameCount.toLocaleString("zh-CN"); + elements.overlay.classList.add("hidden"); + scheduleFrame(); +} + +function connect() { + window.clearTimeout(state.reconnectTimer); + window.clearTimeout(state.frameTimer); + state.framePending = false; + if (state.socket && state.socket.readyState < WebSocket.CLOSING) { + state.manuallyReconnecting = true; + state.socket.close(1000, "reconnect"); + } + + setConnectionState("", "正在连接", "建立纯 WebSocket 像素通道"); + const socket = new WebSocket(endpoint); + state.socket = socket; + socket.binaryType = "arraybuffer"; + + socket.addEventListener("open", () => { + if (socket !== state.socket) + return; + state.manuallyReconnecting = false; + setConnectionState("online", "通道在线", "等待首个 RGBA 像素帧"); + elements.connectionLabel.textContent = "通道在线"; + sendEvent("show"); + syncViewport(); + sendEvent("resize", {width: state.requestedWidth, height: state.requestedHeight}); + sendControlSnapshot(); + requestFrame(); + }); + + socket.addEventListener("message", event => { + if (socket !== state.socket) + return; + try { + drawPixelFrame(event.data); + } catch (error) { + state.framePending = false; + setConnectionState("offline", "帧协议错误", error.message); + socket.close(1003, "invalid pixel frame"); + } + }); + + socket.addEventListener("close", event => { + if (socket !== state.socket) + return; + state.framePending = false; + setConnectionState("offline", "通道离线", event.reason || `WebSocket 已关闭 (${event.code})`); + if (!state.manuallyReconnecting) + state.reconnectTimer = window.setTimeout(connect, 1200); + }); + + socket.addEventListener("error", () => { + if (socket === state.socket) + setConnectionState("offline", "连接失败", "确认 Renderive_Web_Server 正在监听 8848 端口"); + }); +} + +function canvasPosition(event) { + const bounds = elements.canvas.getBoundingClientRect(); + return { + x: (event.clientX - bounds.left) * elements.canvas.width / bounds.width, + y: (event.clientY - bounds.top) * elements.canvas.height / bounds.height + }; +} + +function modifiers(event) { + return (event.ctrlKey ? 1 : 0) | (event.shiftKey ? 2 : 0) | + (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0); +} + +function mouseButton(button) { + return button === 0 ? "left" : button === 2 ? "right" : button === 1 ? "middle" : "none"; +} + +function pointerPayload(event) { + const position = canvasPosition(event); + return {x: position.x, y: position.y, button: mouseButton(event.button), + buttons: event.buttons & 7, modifiers: modifiers(event)}; +} + +elements.canvas.addEventListener("pointerdown", event => { + elements.canvas.focus({preventScroll: true}); + elements.canvas.setPointerCapture(event.pointerId); + sendEvent("pointer_press", pointerPayload(event)); +}); +elements.canvas.addEventListener("pointermove", event => sendEvent("pointer_move", pointerPayload(event))); +elements.canvas.addEventListener("pointerup", event => { + sendEvent("pointer_release", pointerPayload(event)); + if (elements.canvas.hasPointerCapture(event.pointerId)) + elements.canvas.releasePointerCapture(event.pointerId); +}); +elements.canvas.addEventListener("pointerleave", () => sendEvent("leave")); +elements.canvas.addEventListener("contextmenu", event => event.preventDefault()); +elements.canvas.addEventListener("wheel", event => { + event.preventDefault(); + const position = canvasPosition(event); + sendEvent("wheel", {x: position.x, y: position.y, buttons: event.buttons & 7, + modifiers: modifiers(event), pixelDeltaX: -event.deltaX, pixelDeltaY: -event.deltaY, + angleDeltaX: -event.deltaX, angleDeltaY: -event.deltaY}); +}, {passive: false}); + +function normalizedKey(key) { + return key === " " ? "Space" : key; +} +elements.canvas.addEventListener("keydown", event => { + sendEvent("key_press", {key: normalizedKey(event.key), nativeKey: event.keyCode || 0, + modifiers: modifiers(event), repeat: event.repeat}); +}); +elements.canvas.addEventListener("keyup", event => { + sendEvent("key_release", {key: normalizedKey(event.key), nativeKey: event.keyCode || 0, + modifiers: modifiers(event), repeat: false}); +}); + +elements.tuneDown.addEventListener("click", () => setFrequency(currentFrequency() - 0.1)); +elements.tuneUp.addEventListener("click", () => setFrequency(currentFrequency() + 0.1)); +elements.modeButtons.forEach(button => button.addEventListener("click", () => { + elements.modeButtons.forEach(candidate => candidate.classList.toggle("active", candidate === button)); + elements.modeLabel.textContent = button.dataset.mode === "FM" ? "FM WIDE" : `${button.dataset.mode} MODE`; + sendEvent("control", {control: "mode", value: button.dataset.mode}); +})); +elements.bandwidth.addEventListener("input", () => { + const value = Number(elements.bandwidth.value); + elements.bandwidthValue.textContent = `${value} kHz`; + sendEvent("control", {control: "bandwidth_khz", value}); +}); +elements.gain.addEventListener("input", () => { + const value = Number(elements.gain.value); + elements.gainValue.textContent = `${value >= 0 ? "+" : ""}${value} dB`; + sendEvent("control", {control: "gain_db", value}); +}); +elements.maxHold.addEventListener("click", () => setToggle(elements.maxHold, !buttonEnabled(elements.maxHold))); +elements.smoothing.addEventListener("click", () => setToggle(elements.smoothing, !buttonEnabled(elements.smoothing))); +elements.clearSelection.addEventListener("click", () => sendEvent("control", {control: "clear_selection"})); +elements.pause.addEventListener("click", () => { + state.paused = !state.paused; + elements.pause.setAttribute("aria-pressed", String(state.paused)); + elements.pause.classList.toggle("paused", state.paused); + elements.pauseLabel.textContent = state.paused ? "PAUSED" : "LIVE"; + sendEvent(state.paused ? "hide" : "show"); + if (!state.paused) + requestFrame(); +}); +elements.reconnect.addEventListener("click", connect); + +let resizeFrame = 0; +new ResizeObserver(() => { + cancelAnimationFrame(resizeFrame); + resizeFrame = requestAnimationFrame(syncViewport); +}).observe(elements.stage); + +document.addEventListener("visibilitychange", () => { + if (document.hidden) { + sendEvent("hide"); + } else if (!state.paused) { + sendEvent("show"); + requestFrame(); + } +}); +window.addEventListener("beforeunload", () => sendEvent("hide")); + +syncViewport(); +connect(); diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..bae9c86 --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,124 @@ + + + + + + + Renderive Web Receiver + + + +
+
+
+ +
+
RENDERIVE
+
WEB RECEIVER / KERNEL LINK
+
+
+
+ CORE2KERNELWEBSOCKETRGBA +
+
+ +
+ 正在连接 + ws://127.0.0.1:8848/renderive +
+ +
+
+ +
+ + +
+
+
+
REAL-TIME PIXEL STREAM
+

宽带频谱监视器

+
滚轮缩放 · 拖动坐标轴 · 在频谱区拖拽框选
+
+
+
FRAME RATE0.0 fps
+
ROUND TRIP ms
+
RESOLUTION— × —
+
PIXEL RATE0.0 Mb/s
+
+
+ +
+ +
SPECTRUM / dBm
+
WATERFALL / UTC
+
+
+ 等待像素流 + 请先启动 Renderive_Web_Server +
+
+ +
+
WS ONLY事件 ↑ / 像素 ↓
+
0PIXEL FRAMES
+
+ +
+
+
+
+ + + diff --git a/webapp/styles.css b/webapp/styles.css new file mode 100644 index 0000000..8733b7e --- /dev/null +++ b/webapp/styles.css @@ -0,0 +1,245 @@ +:root { + color-scheme: dark; + --bg: #02050c; + --panel: #07101d; + --panel-soft: #0a1524; + --line: rgba(128, 157, 196, 0.15); + --line-strong: rgba(128, 157, 196, 0.28); + --text: #e9f1fb; + --muted: #7890ad; + --faint: #4e627d; + --cyan: #38bdf8; + --green: #39e0b1; + --yellow: #f4cf58; + --danger: #fb7185; + --radius: 15px; + font-family: Inter, "Segoe UI", "Microsoft YaHei UI", sans-serif; +} + +* { box-sizing: border-box; } + +html, body { + width: 100%; + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--bg); + color: var(--text); +} + +body { + overflow: hidden; + background: + radial-gradient(circle at 72% 15%, rgba(21, 97, 130, 0.14), transparent 32%), + radial-gradient(circle at 15% 80%, rgba(24, 119, 94, 0.09), transparent 28%), + #02050c; +} + +button, input { font: inherit; } +button { color: inherit; } + +.app-shell { + display: grid; + grid-template-rows: 74px minmax(0, 1fr); + width: 100vw; + height: 100vh; + min-height: 620px; +} + +.topbar { + display: grid; + grid-template-columns: minmax(240px, 1fr) auto minmax(280px, 1fr); + align-items: center; + gap: 26px; + padding: 0 24px; + border-bottom: 1px solid var(--line); + background: rgba(3, 8, 16, 0.92); + box-shadow: 0 12px 38px rgba(0, 0, 0, 0.22); + backdrop-filter: blur(18px); + z-index: 5; +} + +.brand-block, .connection-summary, .transport-strip, +.frequency-stepper, .section-heading, .receiver-header, +.receiver-footer, .switch-row { display: flex; align-items: center; } + +.brand-block { gap: 13px; } +.brand-mark { + display: flex; + align-items: flex-end; + gap: 3px; + width: 34px; + height: 34px; + padding: 7px; + border: 1px solid rgba(56, 189, 248, 0.35); + border-radius: 9px; + background: linear-gradient(145deg, rgba(56, 189, 248, 0.14), rgba(57, 224, 177, 0.04)); + box-shadow: inset 0 0 20px rgba(56, 189, 248, 0.06); +} +.brand-mark span { width: 4px; border-radius: 4px; background: var(--cyan); box-shadow: 0 0 8px rgba(56, 189, 248, 0.55); } +.brand-mark span:nth-child(1) { height: 8px; } +.brand-mark span:nth-child(2) { height: 19px; background: var(--green); } +.brand-mark span:nth-child(3) { height: 13px; } +.brand-name { font-size: 15px; font-weight: 760; letter-spacing: 0.2em; } +.brand-caption { margin-top: 3px; color: var(--muted); font-size: 9px; letter-spacing: 0.16em; } + +.transport-strip { gap: 10px; color: #8297b0; font-family: Consolas, monospace; font-size: 9px; letter-spacing: 0.1em; } +.transport-strip i { width: 18px; height: 1px; background: linear-gradient(90deg, transparent, var(--faint)); position: relative; } +.transport-strip i::after { content: ""; position: absolute; right: 0; top: -2px; border-left: 4px solid var(--faint); border-top: 2px solid transparent; border-bottom: 2px solid transparent; } + +.connection-summary { justify-self: end; gap: 11px; min-width: 272px; justify-content: flex-end; } +.connection-summary strong { display: block; font-size: 11px; letter-spacing: 0.08em; } +.connection-summary small { display: block; margin-top: 3px; color: var(--muted); font: 9px Consolas, monospace; } +.connection-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--yellow); box-shadow: 0 0 14px rgba(244, 207, 88, 0.55); } +.connection-dot.online { background: var(--green); box-shadow: 0 0 14px rgba(57, 224, 177, 0.65); } +.connection-dot.offline { background: var(--danger); box-shadow: 0 0 14px rgba(251, 113, 133, 0.45); } +.icon-button { + width: 32px; height: 32px; border: 1px solid var(--line-strong); border-radius: 8px; + background: rgba(255, 255, 255, 0.025); cursor: pointer; transition: 160ms ease; +} +.icon-button:hover { border-color: rgba(56, 189, 248, 0.55); color: var(--cyan); background: rgba(56, 189, 248, 0.08); } + +.workspace { + display: grid; + grid-template-columns: 286px minmax(0, 1fr); + gap: 14px; + min-height: 0; + padding: 14px; +} + +.control-deck, .receiver-panel { + border: 1px solid var(--line); + border-radius: var(--radius); + background: linear-gradient(160deg, rgba(10, 21, 36, 0.97), rgba(5, 12, 22, 0.97)); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.27), inset 0 1px rgba(255, 255, 255, 0.025); +} + +.control-deck { display: flex; flex-direction: column; min-height: 0; overflow: hidden; } +.frequency-card { padding: 22px 20px 19px; border-bottom: 1px solid var(--line); background: linear-gradient(145deg, rgba(56, 189, 248, 0.065), transparent 65%); } +.section-kicker { color: var(--muted); font-size: 9px; font-weight: 700; letter-spacing: 0.17em; } +.frequency-readout { display: flex; align-items: baseline; gap: 7px; margin: 12px 0 15px; } +.frequency-readout span { color: #f1f8ff; font: 32px/1 Consolas, "SFMono-Regular", monospace; letter-spacing: -0.06em; text-shadow: 0 0 22px rgba(56, 189, 248, 0.19); } +.frequency-readout em { color: var(--cyan); font: normal 10px Consolas, monospace; } +.frequency-stepper { justify-content: space-between; gap: 8px; } +.frequency-stepper button { + width: 37px; height: 30px; border: 1px solid var(--line-strong); border-radius: 8px; + background: rgba(255, 255, 255, 0.025); font-size: 18px; cursor: pointer; +} +.frequency-stepper button:hover { border-color: var(--cyan); color: var(--cyan); } +.frequency-stepper span { color: var(--faint); font-size: 8px; letter-spacing: 0.08em; } +.frequency-stepper strong { color: #a8bbd1; font-weight: 600; } + +.control-section { padding: 17px 20px; border-bottom: 1px solid var(--line); } +.section-heading { justify-content: space-between; margin-bottom: 13px; color: #adbed2; font-size: 9px; font-weight: 700; letter-spacing: 0.12em; } +.section-heading small, .section-heading output { color: var(--cyan); font: 9px Consolas, monospace; letter-spacing: 0; } +.mode-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; } +.mode-button { + height: 32px; border: 1px solid var(--line); border-radius: 7px; background: rgba(255,255,255,0.02); + color: #7188a5; font: 10px Consolas, monospace; cursor: pointer; transition: 150ms ease; +} +.mode-button:hover { color: #c9d9ea; border-color: var(--line-strong); } +.mode-button.active { border-color: rgba(57, 224, 177, 0.48); color: var(--green); background: rgba(57, 224, 177, 0.09); box-shadow: inset 0 0 16px rgba(57, 224, 177, 0.04); } + +.range-section input { width: 100%; accent-color: var(--green); cursor: pointer; } +input[type="range"] { height: 3px; margin: 3px 0 9px; } +.range-scale { display: flex; justify-content: space-between; color: #465b74; font: 8px Consolas, monospace; } + +.switch-list { padding-top: 6px; padding-bottom: 6px; } +.switch-row { justify-content: space-between; width: 100%; padding: 10px 0; border: 0; background: none; cursor: pointer; text-align: left; } +.switch-row + .switch-row { border-top: 1px solid rgba(128, 157, 196, 0.08); } +.switch-row span b { display: block; color: #aabed4; font-size: 9px; letter-spacing: 0.11em; } +.switch-row span small { display: block; margin-top: 3px; color: var(--faint); font-size: 9px; } +.switch-row > i { position: relative; width: 31px; height: 16px; border: 1px solid #304259; border-radius: 10px; background: #0a111b; transition: 160ms ease; } +.switch-row > i::after { content: ""; position: absolute; top: 2px; left: 2px; width: 10px; height: 10px; border-radius: 50%; background: #647892; transition: 160ms ease; } +.switch-row.active > i { border-color: rgba(57, 224, 177, 0.55); background: rgba(57, 224, 177, 0.12); } +.switch-row.active > i::after { left: 16px; background: var(--green); box-shadow: 0 0 8px rgba(57, 224, 177, 0.65); } + +.deck-actions { display: grid; grid-template-columns: 1fr 88px; gap: 8px; margin-top: auto; padding: 15px 20px; } +.deck-actions button { height: 36px; border-radius: 8px; cursor: pointer; font-size: 9px; font-weight: 700; letter-spacing: 0.08em; } +.secondary-action { border: 1px solid var(--line-strong); background: rgba(255,255,255,0.025); color: #8da2ba; } +.secondary-action:hover { color: var(--cyan); border-color: rgba(56, 189, 248, 0.45); } +.live-action { border: 1px solid rgba(57, 224, 177, 0.38); background: rgba(57, 224, 177, 0.075); color: var(--green); } +.live-action.paused { border-color: rgba(244, 207, 88, 0.4); background: rgba(244, 207, 88, 0.08); color: var(--yellow); } +.live-pulse { display: inline-block; width: 6px; height: 6px; margin-right: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 8px currentColor; } + +.receiver-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) 45px; min-width: 0; min-height: 0; overflow: hidden; } +.receiver-header { justify-content: space-between; gap: 25px; min-height: 76px; padding: 14px 20px; border-bottom: 1px solid var(--line); } +.receiver-header h1 { margin: 5px 0 0; font-size: 17px; font-weight: 600; letter-spacing: 0.04em; } +.interaction-hint { margin-top: 5px; color: #526983; font-size: 8px; letter-spacing: 0.04em; } +.metric-rack { display: grid; grid-template-columns: repeat(4, minmax(92px, auto)); } +.metric-rack > div { padding: 2px 16px; border-left: 1px solid var(--line); } +.metric-rack small { display: block; color: var(--faint); font-size: 8px; letter-spacing: 0.1em; } +.metric-rack strong { display: block; margin-top: 5px; color: #bfd0e3; font: 10px Consolas, monospace; white-space: nowrap; } +.metric-rack > div:first-child strong { color: var(--green); } + +.canvas-stage { + position: relative; + min-width: 0; + min-height: 0; + margin: 12px; + overflow: hidden; + border: 1px solid rgba(90, 122, 162, 0.2); + border-radius: 11px; + background: + linear-gradient(rgba(50, 81, 118, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(50, 81, 118, 0.06) 1px, transparent 1px), + #030812; + background-size: 32px 32px; + box-shadow: inset 0 0 45px rgba(0,0,0,0.32); +} +#render-canvas { display: block; width: 100%; height: 100%; outline: none; image-rendering: auto; cursor: crosshair; } +#render-canvas:focus { box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.32); } +.stage-label { position: absolute; right: 13px; z-index: 2; display: flex; align-items: center; gap: 7px; color: #637b98; font: 8px Consolas, monospace; letter-spacing: 0.08em; pointer-events: none; } +.stage-label span { width: 5px; height: 5px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px rgba(57,224,177,0.5); } +.spectrum-label { top: 10px; } +.waterfall-label { top: 51%; } +.waterfall-label span { background: var(--cyan); box-shadow: 0 0 8px rgba(56,189,248,0.55); } + +.stream-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; background: rgba(3, 8, 18, 0.77); backdrop-filter: blur(3px); transition: opacity 220ms ease, visibility 220ms ease; z-index: 3; } +.stream-overlay.hidden { opacity: 0; visibility: hidden; pointer-events: none; } +.stream-overlay strong { color: #c7d7e8; font-size: 12px; letter-spacing: 0.06em; } +.stream-overlay span { color: var(--muted); font-size: 10px; } +.loader-ring { width: 28px; height: 28px; border: 2px solid rgba(56, 189, 248, 0.14); border-top-color: var(--cyan); border-radius: 50%; animation: spin 900ms linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +.receiver-footer { gap: 24px; padding: 0 17px; border-top: 1px solid var(--line); color: var(--muted); } +.receiver-footer > div { display: flex; align-items: center; gap: 7px; } +.receiver-footer strong { color: #a4b8ce; font: 9px Consolas, monospace; } +.receiver-footer small { color: var(--faint); font-size: 8px; } +.footer-led { width: 6px; height: 6px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px rgba(57,224,177,.6); } +.signal-meter { height: 18px; gap: 3px !important; margin-left: auto; align-items: flex-end !important; } +.signal-meter span { width: 3px; border-radius: 2px; background: linear-gradient(var(--green), #147b69); animation: meter 1.4s ease-in-out infinite alternate; } +.signal-meter span:nth-child(1), .signal-meter span:nth-child(8) { height: 4px; } +.signal-meter span:nth-child(2), .signal-meter span:nth-child(7) { height: 7px; animation-delay: -0.5s; } +.signal-meter span:nth-child(3), .signal-meter span:nth-child(6) { height: 11px; animation-delay: -0.8s; } +.signal-meter span:nth-child(4), .signal-meter span:nth-child(5) { height: 16px; animation-delay: -0.2s; } +@keyframes meter { to { transform: scaleY(.45); opacity: .45; } } +.footer-note { color: #455a73; font: 8px Consolas, monospace; letter-spacing: 0.1em; } + +@media (max-width: 1050px) { + .topbar { grid-template-columns: 1fr auto; } + .transport-strip { display: none; } + .metric-rack { grid-template-columns: repeat(2, minmax(100px, auto)); row-gap: 8px; } + .workspace { grid-template-columns: 250px minmax(0, 1fr); } + .control-section { padding-left: 16px; padding-right: 16px; } + .frequency-card, .deck-actions { padding-left: 16px; padding-right: 16px; } +} + +@media (max-width: 760px) { + body { overflow: auto; } + .app-shell { height: auto; min-height: 100vh; grid-template-rows: auto 1fr; } + .topbar { min-height: 68px; grid-template-columns: 1fr; gap: 8px; padding: 12px 16px; } + .connection-summary { justify-self: stretch; justify-content: flex-start; min-width: 0; } + .connection-summary .icon-button { margin-left: auto; } + .workspace { grid-template-columns: 1fr; padding: 9px; } + .control-deck { overflow: visible; } + .receiver-panel { min-height: 620px; } + .receiver-header { align-items: flex-start; flex-direction: column; } + .metric-rack { width: 100%; grid-template-columns: repeat(2, 1fr); } + .metric-rack > div { padding-left: 10px; } + .canvas-stage { min-height: 460px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } +}