From 023250c0fcc8602aa5a05970311883679d8f657d Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Sat, 15 Aug 2026 21:48:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmake/RenderivePackage.cmake | 4 +- .../detail/Gpu_Completion_Service.cpp | 156 ++++++++++-------- .../render_3D/detail/Gpu_Completion_Service.h | 2 +- render_3D/╡≈╩╘.md | 8 + web_server/CMakeLists.txt | 4 +- web_server/app/Gallery_Plot_Session.cpp | 115 ++++--------- web_server/app/Gallery_Protocol.cpp | 38 ++++- web_server/app/Pixel_Frame.cpp | 60 +------ web_server/app/Pixel_Frame.h | 10 +- web_server/app/Web_Plot_Session.cpp | 5 +- web_server/app/common/Pixel_Frame.cpp | 23 ++- web_server/app/common/Pixel_Frame.h | 5 +- web_server/app/render_2D/Gallery_Scene2D.cpp | 5 +- web_server/app/render_3D/Gallery_Scene3D.cpp | 2 +- web_server/tests/Datoviz_Gallery_Tests.cpp | 8 +- web_server/tests/Web_Bridge_Tests.cpp | 15 +- webapp_gallery/package-lock.json | 35 +++- webapp_gallery/package.json | 4 +- webapp_gallery/src/app.tsx | 2 + .../src/capture/capture_toolbar.tsx | 8 +- webapp_gallery/src/capture/perfetto_export.ts | 9 + .../src/capture/worker_timeline.tsx | 24 ++- webapp_gallery/src/common/copy_button.tsx | 2 +- .../src/common/dashboard_field_grid.tsx | 7 + webapp_gallery/src/common/echarts_view.tsx | 10 ++ webapp_gallery/src/common/json_viewer.tsx | 3 +- webapp_gallery/src/dag/render_dag.tsx | 98 ++--------- webapp_gallery/src/gallery/gallery_page.tsx | 4 +- webapp_gallery/src/gallery/plot_card.tsx | 6 +- .../src/inspector/inspector_panel.tsx | 7 +- .../src/inspector/observer_panel.tsx | 17 +- .../src/inspector/performance_panel.tsx | 30 +++- .../src/plot/kernel_observer_summary.tsx | 6 +- webapp_gallery/src/plot/performance_strip.tsx | 7 +- webapp_gallery/src/protocol/format.ts | 20 ++- webapp_gallery/src/protocol/gallery_types.ts | 6 +- webapp_gallery/src/protocol/pixel_frame.ts | 17 +- .../src/runtime/client_performance.ts | 6 +- webapp_gallery/src/runtime/pixel_presenter.ts | 4 +- .../src/session/gallery_plot_session.ts | 10 +- .../src/transport/catalog_loader.ts | 49 +++--- .../src/transport/gallery_socket.ts | 13 +- .../tests/protocol/pixel_frame.test.ts | 4 +- .../tests/runtime/client_performance.test.ts | 2 +- webapp_gallery/tsconfig.app.tsbuildinfo | 2 +- 45 files changed, 457 insertions(+), 415 deletions(-) create mode 100644 render_3D/╡≈╩╘.md create mode 100644 webapp_gallery/src/capture/perfetto_export.ts create mode 100644 webapp_gallery/src/common/dashboard_field_grid.tsx create mode 100644 webapp_gallery/src/common/echarts_view.tsx diff --git a/cmake/RenderivePackage.cmake b/cmake/RenderivePackage.cmake index 856c821..6e26bd4 100644 --- a/cmake/RenderivePackage.cmake +++ b/cmake/RenderivePackage.cmake @@ -32,7 +32,7 @@ _add_project_zip_target( add_custom_command( TARGET renderive_package_zip POST_BUILD COMMAND "${CMAKE_COMMAND}" -E copy_if_different - "${renderive_package_build_output}" - "${renderive_package_root}/renderive_package.zip" + "${renderive_package_build_output}" + "${renderive_package_root}/renderive_package.zip" COMMENT "Publishing ${renderive_package_root}/renderive_package.zip" ) diff --git a/render_3D/render_3D/detail/Gpu_Completion_Service.cpp b/render_3D/render_3D/detail/Gpu_Completion_Service.cpp index 54fa137..8d83ecf 100644 --- a/render_3D/render_3D/detail/Gpu_Completion_Service.cpp +++ b/render_3D/render_3D/detail/Gpu_Completion_Service.cpp @@ -1,4 +1,5 @@ #include "Gpu_Completion_Service.h" +#include #include #include #include @@ -110,98 +111,119 @@ void Gpu_Completion_Service::wake() noexcept { wake_condition_.notify_one(); } void Gpu_Completion_Service::run() noexcept { + struct Device_Fences { + VkDevice device{VK_NULL_HANDLE}; + std::vector fences; + }; std::vector> active; active.reserve(static_cast(default_capacity)); + std::size_t wait_group_index{}; + const auto finish = [this](const std::shared_ptr& pending, VkResult result) { + Completion completion; + std::chrono::steady_clock::time_point watched_at{}; + bool observe{}; + { + std::lock_guard lock(pending->mutex); + completion = std::move(pending->completion); + watched_at = pending->watched_at; + observe = pending->observe; + pending->status = Pending_Fence::Status::canceled; + } + watched_.fetch_sub(1, std::memory_order_relaxed); + Result completion_result; + if (observe) { + const auto duration = std::chrono::duration_cast( + std::chrono::steady_clock::now() - watched_at).count(); + completion_result.wait_duration_ns = duration > 0 ? static_cast(duration) : 0; + } + if (result != VK_SUCCESS) { + try { + throw std::runtime_error("GPU fence wait failed with Vulkan result " + std::to_string(static_cast(result))); + } catch (...) { + completion_result.error = std::current_exception(); + } + } + try { + completion(std::move(completion_result)); + } catch (...) { + } + release_slot(); + }; for (;;) { const std::uint64_t wake_generation = wake_generation_.load(std::memory_order_acquire); std::shared_ptr incoming; while (pending_.try_pop(incoming)) active.push_back(std::move(incoming)); - bool progressed{}; - bool has_watched{}; + std::vector groups; for (auto iterator = active.begin(); iterator != active.end();) { - auto& pending = *iterator; + Pending_Fence::Status status; VkDevice device{VK_NULL_HANDLE}; VkFence fence{VK_NULL_HANDLE}; - Completion completion; - std::chrono::steady_clock::time_point watched_at{}; - bool observe{}; - Pending_Fence::Status status; { - std::lock_guard lock(pending->mutex); - status = pending->status; - if (status == Pending_Fence::Status::watched) { - device = pending->device; - fence = pending->fence; - watched_at = pending->watched_at; - observe = pending->observe; - } + std::lock_guard lock((*iterator)->mutex); + status = (*iterator)->status; + device = (*iterator)->device; + fence = (*iterator)->fence; } - if (status == Pending_Fence::Status::canceled) { + if (status == Pending_Fence::Status::canceled || + (status == Pending_Fence::Status::reserved && stopping_.load(std::memory_order_acquire))) { + cancel_reserved(*iterator); iterator = active.erase(iterator); release_slot(); - progressed = true; continue; } - if (status == Pending_Fence::Status::reserved) { - if (stopping_.load(std::memory_order_acquire)) { - cancel_reserved(pending); - iterator = active.erase(iterator); - release_slot(); - progressed = true; - continue; + if (status == Pending_Fence::Status::watched) { + auto group = std::find_if(groups.begin(), groups.end(), [device](const Device_Fences& item) { + return item.device == device; + }); + if (group == groups.end()) { + groups.push_back(Device_Fences{device, {}}); + group = groups.end() - 1; } + group->fences.push_back(fence); + } + ++iterator; + } + if (stopping_.load(std::memory_order_acquire) && active.empty() && pending_.empty()) + return; + if (groups.empty()) { + std::unique_lock lock(wait_mutex_); + if (wake_generation_.load(std::memory_order_acquire) == wake_generation) { + wake_condition_.wait(lock, [this, wake_generation] { + return wake_generation_.load(std::memory_order_acquire) != wake_generation; + }); + } + continue; + } + wait_group_index %= groups.size(); + const Device_Fences& group = groups[wait_group_index++]; + const VkResult wait_result = vkWaitForFences( + group.device, static_cast(group.fences.size()), group.fences.data(), VK_FALSE, + fence_wait_timeout_ns); + for (auto iterator = active.begin(); iterator != active.end();) { + VkDevice device{VK_NULL_HANDLE}; + VkFence fence{VK_NULL_HANDLE}; + Pending_Fence::Status status; + { + std::lock_guard lock((*iterator)->mutex); + status = (*iterator)->status; + device = (*iterator)->device; + fence = (*iterator)->fence; + } + if (status != Pending_Fence::Status::watched || device != group.device) { ++iterator; continue; } - has_watched = true; - const VkResult result = vkGetFenceStatus(device, fence); + VkResult result = wait_result; + if (wait_result == VK_SUCCESS || wait_result == VK_TIMEOUT) + result = vkGetFenceStatus(device, fence); if (result == VK_NOT_READY) { ++iterator; continue; } - { - std::lock_guard lock(pending->mutex); - completion = std::move(pending->completion); - pending->status = Pending_Fence::Status::canceled; - } - watched_.fetch_sub(1, std::memory_order_relaxed); - Result completion_result; - if (observe) { - const auto duration = std::chrono::duration_cast( - std::chrono::steady_clock::now() - watched_at).count(); - completion_result.wait_duration_ns = duration > 0 ? static_cast(duration) : 0; - } - if (result != VK_SUCCESS) { - try { - throw std::runtime_error("GPU fence wait failed with Vulkan result " + std::to_string(static_cast(result))); - } catch (...) { - completion_result.error = std::current_exception(); - } - } - try { - completion(std::move(completion_result)); - } catch (...) { - } + auto pending = *iterator; iterator = active.erase(iterator); - release_slot(); - progressed = true; - } - if (stopping_.load(std::memory_order_acquire) && active.empty() && pending_.empty()) - return; - if (progressed) - continue; - std::unique_lock lock(wait_mutex_); - if (wake_generation_.load(std::memory_order_acquire) != wake_generation) - continue; - if (has_watched) { - wake_condition_.wait_for(lock, poll_interval, [this, wake_generation] { - return wake_generation_.load(std::memory_order_acquire) != wake_generation; - }); - } else { - wake_condition_.wait(lock, [this, wake_generation] { - return wake_generation_.load(std::memory_order_acquire) != wake_generation; - }); + finish(pending, result); } } } diff --git a/render_3D/render_3D/detail/Gpu_Completion_Service.h b/render_3D/render_3D/detail/Gpu_Completion_Service.h index f4dd684..b625bfb 100644 --- a/render_3D/render_3D/detail/Gpu_Completion_Service.h +++ b/render_3D/render_3D/detail/Gpu_Completion_Service.h @@ -75,7 +75,7 @@ private: void wake() noexcept; void run() noexcept; static constexpr std::ptrdiff_t default_capacity = 1024; - static constexpr auto poll_interval = std::chrono::microseconds(200); + static constexpr std::uint64_t fence_wait_timeout_ns = 1'000'000; std::counting_semaphore slots_{default_capacity}; oneapi::tbb::concurrent_bounded_queue> pending_; std::mutex wait_mutex_; diff --git a/render_3D/╡≈╩╘.md b/render_3D/╡≈╩╘.md new file mode 100644 index 0000000..9471ba5 --- /dev/null +++ b/render_3D/╡≈╩╘.md @@ -0,0 +1,8 @@ +"C:\Program Files\JetBrains\CLion 2026.1\bin\cmake\win\x64\bin\cmake.exe" -DCMAKE_BUILD_TYPE=Debug --preset vs2022_debug +-S D:\ae\proj\Renderive -B D:\ae\proj\Renderive\cmake-build-vs2022_debug + +默认每次运行程序都通过CDB运行 D:\ae\ewdk\EWDK_22621_230929-1800\Program Files\Windows Kits\10\Debuggers\x64\cdb.exe + +D:\ae\tools 可能会有有用的工具 + +运行前使用环境脚本 env.ps1 \ No newline at end of file diff --git a/web_server/CMakeLists.txt b/web_server/CMakeLists.txt index 9fcea37..229706a 100644 --- a/web_server/CMakeLists.txt +++ b/web_server/CMakeLists.txt @@ -149,7 +149,7 @@ target_compile_definitions(Renderive_Web_2D PRIVATE NOMINMAX) target_link_libraries(Renderive_Web_2D PUBLIC Renderive_Web_Common Renderive_render_2D PRIVATE Adminive::Nlohmann Adminive::MagicEnum magic_enum::magic_enum) -# 3D 模块只负责 Scene3D/Datoviz 到公共 RVP1 像素协议的适配。 +# 3D 模块只负责 Scene3D/Datoviz 到公共 RVP2 像素协议的适配。 set(Renderive_Web_3D_sources "${CMAKE_CURRENT_LIST_DIR}/app/render_3D/Gallery_Scene3D.cpp") add_library(Renderive_Web_3D STATIC ${Renderive_Web_3D_sources}) @@ -183,7 +183,7 @@ 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) renderive_stage_render_3D_runtime(Renderive_Web_Server) -#add_dependencies(Renderive_Web_Server Renderive_Web_Assets) +add_dependencies(Renderive_Web_Server Renderive_Web_Assets) if (MSVC) target_compile_options(Renderive_Web_Common PRIVATE /utf-8) target_compile_options(Renderive_Web_2D PRIVATE /utf-8) diff --git a/web_server/app/Gallery_Plot_Session.cpp b/web_server/app/Gallery_Plot_Session.cpp index 6d5bb2f..4fbc467 100644 --- a/web_server/app/Gallery_Plot_Session.cpp +++ b/web_server/app/Gallery_Plot_Session.cpp @@ -4,20 +4,17 @@ #include "render_2D/Gallery_Scene2D.h" #include "render_3D/Gallery_Scene3D.h" #include +#include #include #include #include #include #include -#include #include -#include #include #include #include #include -#include -#include #include #include namespace renderive::web { @@ -124,80 +121,22 @@ std::unique_ptr make_gallery_scene(std::uint64_t sessio return make_gallery_scene_3d(session_id, std::move(case_id), mode, automatic_low_latency); return make_gallery_scene_2d(session_id, std::move(case_id), mode, automatic_low_latency); } +trantor::EventLoop* automatic_render_loop() { + static trantor::EventLoopThread thread("RenderiveAutomaticRender"); + static const bool started = [] { + thread.run(); + return true; + }(); + static_cast(started); + return thread.getLoop(); +} } struct Gallery_Plot_Session::Impl : std::enable_shared_from_this { using Clock = std::chrono::steady_clock; - class Automatic_Render_Scheduler final { - public: - static Automatic_Render_Scheduler& instance() { - static Automatic_Render_Scheduler scheduler; - return scheduler; - } - void arm(std::uint64_t session_id, std::weak_ptr session, Clock::time_point deadline) { - std::lock_guard lock(mutex_); - if (const auto current = entries_.find(session_id); current != entries_.end()) { - schedule_.erase(current->second); - entries_.erase(current); - } - const auto iterator = schedule_.emplace(deadline, Entry{session_id, std::move(session)}); - entries_.emplace(session_id, iterator); - condition_.notify_one(); - } - void disarm(std::uint64_t session_id) noexcept { - std::lock_guard lock(mutex_); - const auto current = entries_.find(session_id); - if (current == entries_.end()) - return; - schedule_.erase(current->second); - entries_.erase(current); - condition_.notify_one(); - } - private: - struct Entry { - std::uint64_t session_id{}; - std::weak_ptr session; - }; - using Schedule = std::multimap; - Automatic_Render_Scheduler() : thread_([this](std::stop_token stop) { run(stop); }) {} - ~Automatic_Render_Scheduler() { - thread_.request_stop(); - condition_.notify_all(); - } - void run(std::stop_token stop) noexcept { - std::unique_lock lock(mutex_); - while (!stop.stop_requested()) { - if (schedule_.empty()) { - condition_.wait(lock, [this, &stop] { - return stop.stop_requested() || !schedule_.empty(); - }); - continue; - } - const auto deadline = schedule_.begin()->first; - if (condition_.wait_until(lock, deadline) != std::cv_status::timeout) - continue; - if (schedule_.empty() || schedule_.begin()->first > Clock::now()) - continue; - auto iterator = schedule_.begin(); - Entry entry = std::move(iterator->second); - entries_.erase(entry.session_id); - schedule_.erase(iterator); - lock.unlock(); - if (auto session = entry.session.lock()) - session->automatic_render_due(); - lock.lock(); - } - } - std::mutex mutex_; - std::condition_variable condition_; - Schedule schedule_; - std::unordered_map entries_; - std::jthread thread_; - }; explicit Impl(bool enable_automatic_low_latency) : automatic_low_latency(enable_automatic_low_latency), session_id(next_session_id()) {} ~Impl() { - if (automatic_low_latency) - Automatic_Render_Scheduler::instance().disarm(session_id); + disarm_automatic_render(); } static std::uint64_t next_session_id() noexcept { static std::atomic next{1}; @@ -266,27 +205,43 @@ struct Gallery_Plot_Session::Impl : std::enable_shared_from_this acquire_foreground_lock() { return std::unique_lock(mutex); } + void disarm_automatic_render() noexcept { + ++automatic_timer_revision; + if (automatic_timer == trantor::InvalidTimerId) + return; + automatic_render_loop()->invalidateTimer(automatic_timer); + automatic_timer = trantor::InvalidTimerId; + } void arm_automatic_render_locked() noexcept { if (!automatic_low_latency) return; - auto& scheduler = Automatic_Render_Scheduler::instance(); - if (!scene || !scene->can_render_automatically() || render_task_pending) { - scheduler.disarm(session_id); + disarm_automatic_render(); + if (!scene || !scene->can_render_automatically() || render_task_pending) return; - } try { const auto interval = std::chrono::nanoseconds( std::max(1, scene->kernel_refresh_interval_ns())); const auto deadline = last_render_started ? *last_render_started + interval : Clock::now(); - scheduler.arm(session_id, weak_from_this(), deadline); + const auto delay = std::max(Clock::duration::zero(), deadline - Clock::now()); + const std::uint64_t revision = automatic_timer_revision; + automatic_timer = automatic_render_loop()->runAfter( + std::chrono::duration(delay).count(), + [session = weak_from_this(), revision] { + if (auto value = session.lock()) + value->automatic_render_due(revision); + }); } catch (...) { - scheduler.disarm(session_id); + disarm_automatic_render(); } } - void automatic_render_due() { + void automatic_render_due(std::uint64_t revision) { std::uint64_t generation{}; { std::lock_guard lock(mutex); + if (revision != automatic_timer_revision) + return; + automatic_timer = trantor::InvalidTimerId; + ++automatic_timer_revision; if (!automatic_low_latency || !scene || !scene->can_render_automatically() || render_task_pending) return; render_task_pending = true; @@ -345,6 +300,8 @@ struct Gallery_Plot_Session::Impl : std::enable_shared_from_this handle_gallery(const Gallery_Request& request) { if (request.kind == Gallery_Request_Kind::Catalog) return Web_Response{Web_Response_Type::Json, Gallery_Protocol::catalog_json()}; diff --git a/web_server/app/Gallery_Protocol.cpp b/web_server/app/Gallery_Protocol.cpp index d3fe389..0d98e82 100644 --- a/web_server/app/Gallery_Protocol.cpp +++ b/web_server/app/Gallery_Protocol.cpp @@ -219,7 +219,7 @@ using gallery_detail::Json; Json protocol_base() { return {{"category", "gallery"}, {"protocol", "renderive.control-gallery"}, - {"protocol_version", 4}}; + {"protocol_version", 5}}; } Json case_contract(std::string_view case_id) { @@ -234,6 +234,20 @@ Json dashboard_field(std::string_view label, std::string_view source, result["digits"] = digits; return result; } +Json dashboard_trend_field(std::string_view label, std::string_view source, + std::string_view trend_group, + std::string_view format = "integer", int digits = -1) { + Json result = dashboard_field(label, source, format, digits); + result["trend_group"] = trend_group; + return result; +} +Json dashboard_summary_field(std::string_view label, std::string_view source, + std::string_view trend_group, + std::string_view format = "integer", int digits = -1) { + Json result = dashboard_trend_field(label, source, trend_group, format, digits); + result["summary"] = true; + return result; +} template const Json& described_field(std::string_view name) { @@ -374,16 +388,16 @@ Json dashboard_contract() { }}, {"performance", { {"fields", Json::array({ - dashboard_field("后端渲染 FPS", "performance.measured_fps", "fixed", 1), - dashboard_field("像素响应 FPS", "performance.pixel_response_fps", "fixed", 1), - dashboard_field("浏览器呈现 FPS", "client_performance.presentation_fps", "fixed", 1), - dashboard_field("WS 往返 ms", "client_performance.frame_round_trip_ms", "fixed", 2), + dashboard_summary_field("后端渲染 FPS", "performance.measured_fps", "FPS", "fixed", 1), + dashboard_summary_field("像素响应 FPS", "performance.pixel_response_fps", "FPS", "fixed", 1), + dashboard_trend_field("浏览器呈现 FPS", "client_performance.presentation_fps", "FPS", "fixed", 1), + dashboard_trend_field("WS 往返 ms", "client_performance.frame_round_trip_ms", "帧延迟", "fixed", 2), dashboard_field("未呈现覆盖", "client_performance.overwritten_pixel_frames"), - dashboard_field("Core 渲染 ms", "performance.last_render_ms", "fixed", 2), + dashboard_summary_field("帧完成延迟 ms", "performance.last_render_ms", "帧延迟", "fixed", 2), dashboard_field("Core 滑动平均 ms", "performance.average_render_ms", "fixed", 2), dashboard_field("Core P95 ms", "performance.render_p95_ms", "fixed", 2), dashboard_field("Core P99 ms", "performance.render_p99_ms", "fixed", 2), - dashboard_field("像素编码 ms", "performance.last_pixel_encode_ms", "fixed", 2), + dashboard_summary_field("像素编码 ms", "performance.last_pixel_encode_ms", "帧延迟", "fixed", 2), dashboard_field("编码 P95 ms", "performance.pixel_encode_p95_ms", "fixed", 2), dashboard_field("编码 P99 ms", "performance.pixel_encode_p99_ms", "fixed", 2), dashboard_field("WS 平均 ms", "client_performance.frame_round_trip_average_ms", "fixed", 2), @@ -394,9 +408,15 @@ Json dashboard_contract() { dashboard_field("呈现 P99 ms", "client_performance.display_interval_p99_ms", "fixed", 2), dashboard_field("响应负载 MB/s", "performance.pixel_payload_megabytes_per_second", "fixed", 1), dashboard_field("TBB 并发度", "scheduler.concurrency"), - dashboard_field("活动 Worker", "scheduler.active_workers"), + dashboard_trend_field("活动 Worker", "scheduler.active_workers", "调度与队列"), dashboard_field("峰值 Worker", "scheduler.peak_workers"), dashboard_field("活动外部线程", "scheduler.active_external_threads"), + dashboard_trend_field("Scene 排队", "queue_pressure.scene_execution.queued", "调度与队列"), + dashboard_trend_field("Render Domain 排队", "queue_pressure.render_domain.queued", "调度与队列"), + dashboard_trend_field("GPU Completion 活动", "queue_pressure.gpu_completion.active", "调度与队列"), + dashboard_field("Scene Backpressure", "queue_pressure.scene_execution.backpressure_count"), + dashboard_field("Render Backpressure", "queue_pressure.render_domain.backpressure_count"), + dashboard_field("GPU Backpressure", "queue_pressure.gpu_completion.backpressure_count"), described_dashboard_field( "kernel_observer", "pending_frame_count"), described_dashboard_field( @@ -556,7 +576,7 @@ Json frame_mode_contract(Gallery_Frame_Mode mode) { std::string Gallery_Protocol::catalog_json() { Json result = protocol_base(); result["type"] = "catalog"; - result["transport"] = {{"events", "websocket-text"}, {"pixels", "websocket-binary-rvp1"}, + result["transport"] = {{"events", "websocket-text"}, {"pixels", "websocket-binary-rvp2"}, {"http_api", false}, {"socket_per_canvas", true}}; result["navigation"] = { {"default_mode", gallery_enum_id(Gallery_Frame_Mode::Low_Latency)}, diff --git a/web_server/app/Pixel_Frame.cpp b/web_server/app/Pixel_Frame.cpp index 32a2cd9..31a8517 100644 --- a/web_server/app/Pixel_Frame.cpp +++ b/web_server/app/Pixel_Frame.cpp @@ -1,8 +1,6 @@ #include "Pixel_Frame.h" #include -#include -#include #if defined(_M_X64) || defined(__x86_64__) #include #endif @@ -10,13 +8,6 @@ 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( @@ -26,29 +17,17 @@ std::uint8_t flatten(std::uint8_t premultiplied, std::uint8_t alpha, } // namespace -std::string encode_pixel_frame(Image_View image, Color background) { +std::string encode_pixel_frame(Image_View image, std::uint64_t sequence, 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))) { + std::string frame = make_rgba8_pixel_frame( + static_cast(width), static_cast(height), sequence); + if (frame.empty()) 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); const std::uint32_t opaque_background = static_cast(background.r) | @@ -111,35 +90,4 @@ std::string encode_pixel_frame(Image_View image, Color background) { return frame; } -std::string encode_rgba8_pixel_frame(const std::byte* pixels, - std::uint32_t width, - std::uint32_t height, - std::size_t stride) { - constexpr std::size_t bytes_per_pixel = 4; - if (pixels == nullptr || width == 0 || height == 0) - return {}; - if (width > std::numeric_limits::max() / bytes_per_pixel) - return {}; - const std::size_t row_bytes = static_cast(width) * bytes_per_pixel; - if (stride < row_bytes || - height > (std::numeric_limits::max() - pixel_frame_header_size) / - row_bytes) - return {}; - - std::string frame(pixel_frame_header_size + row_bytes * height, '\0'); - frame[0] = 'R'; - frame[1] = 'V'; - frame[2] = 'P'; - frame[3] = '1'; - write_u32_le(frame.data() + 4, width); - write_u32_le(frame.data() + 8, height); - write_u32_le(frame.data() + 12, static_cast(row_bytes)); - char* output = frame.data() + pixel_frame_header_size; - for (std::uint32_t row = 0; row < height; ++row) { - std::memcpy(output + static_cast(row) * row_bytes, - pixels + static_cast(row) * stride, row_bytes); - } - return frame; -} - } // namespace renderive::web diff --git a/web_server/app/Pixel_Frame.h b/web_server/app/Pixel_Frame.h index a9932b9..d448fd2 100644 --- a/web_server/app/Pixel_Frame.h +++ b/web_server/app/Pixel_Frame.h @@ -1,14 +1,8 @@ #pragma once #include "common/Pixel_Frame.h" #include "render_2D/base/Types.h" -#include #include #include namespace renderive::web { -[[nodiscard]] std::string encode_pixel_frame(Image_View image, - Color background = Color::black()); -[[nodiscard]] std::string encode_rgba8_pixel_frame(const std::byte* pixels, - std::uint32_t width, - std::uint32_t height, - std::size_t stride); -} // namespace renderive::web +[[nodiscard]] std::string encode_pixel_frame(Image_View image, std::uint64_t sequence, Color background = Color::black()); +} diff --git a/web_server/app/Web_Plot_Session.cpp b/web_server/app/Web_Plot_Session.cpp index 88a22ab..1ce2666 100644 --- a/web_server/app/Web_Plot_Session.cpp +++ b/web_server/app/Web_Plot_Session.cpp @@ -270,8 +270,9 @@ struct Web_Plot_Session::Impl { 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); + const std::uint64_t sequence = plot.frame_status().latest_sequence; + plot.with_frame([&pixels, background, sequence](Image_View image) { + pixels = encode_pixel_frame(image, sequence, background); }); return pixels.empty() ? std::nullopt : std::optional(std::move(pixels)); } diff --git a/web_server/app/common/Pixel_Frame.cpp b/web_server/app/common/Pixel_Frame.cpp index 323f3f3..3ca9fc8 100644 --- a/web_server/app/common/Pixel_Frame.cpp +++ b/web_server/app/common/Pixel_Frame.cpp @@ -9,22 +9,35 @@ void write_u32_le(char* target, std::uint32_t value) { target[2] = static_cast((value >> 16U) & 0xffU); target[3] = static_cast((value >> 24U) & 0xffU); } +void write_u64_le(char* target, std::uint64_t value) { + write_u32_le(target, static_cast(value)); + write_u32_le(target + 4, static_cast(value >> 32U)); } -std::string encode_rgba8_pixel_frame(const std::byte* data, std::uint32_t width, std::uint32_t height, std::uint32_t stride) { - if (data == nullptr || width == 0 || height == 0 || stride < width * 4U) +} +std::string make_rgba8_pixel_frame(std::uint32_t width, std::uint32_t height, std::uint64_t sequence) { + if (width == 0 || height == 0) return {}; const std::size_t row_size = static_cast(width) * 4U; if (static_cast(height) > (std::numeric_limits::max() - pixel_frame_header_size) / row_size) return {}; - const std::size_t pixel_bytes = row_size * height; - std::string frame(pixel_frame_header_size + pixel_bytes, '\0'); + std::string frame(pixel_frame_header_size + row_size * height, '\0'); frame[0] = 'R'; frame[1] = 'V'; frame[2] = 'P'; - frame[3] = '1'; + frame[3] = '2'; write_u32_le(frame.data() + 4, width); write_u32_le(frame.data() + 8, height); write_u32_le(frame.data() + 12, static_cast(row_size)); + write_u64_le(frame.data() + 16, sequence); + return frame; +} +std::string encode_rgba8_pixel_frame(const std::byte* data, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint64_t sequence) { + if (data == nullptr || stride < width * 4U) + return {}; + std::string frame = make_rgba8_pixel_frame(width, height, sequence); + if (frame.empty()) + return {}; + const std::size_t row_size = static_cast(width) * 4U; char* output = frame.data() + pixel_frame_header_size; for (std::uint32_t y = 0; y < height; ++y) { const auto* row = data + static_cast(y) * stride; diff --git a/web_server/app/common/Pixel_Frame.h b/web_server/app/common/Pixel_Frame.h index 6f85e1c..80057a7 100644 --- a/web_server/app/common/Pixel_Frame.h +++ b/web_server/app/common/Pixel_Frame.h @@ -3,6 +3,7 @@ #include #include namespace renderive::web { -inline constexpr std::size_t pixel_frame_header_size = 16; -[[nodiscard]] std::string encode_rgba8_pixel_frame(const std::byte* data, std::uint32_t width, std::uint32_t height, std::uint32_t stride); +inline constexpr std::size_t pixel_frame_header_size = 24; +[[nodiscard]] std::string make_rgba8_pixel_frame(std::uint32_t width, std::uint32_t height, std::uint64_t sequence); +[[nodiscard]] std::string encode_rgba8_pixel_frame(const std::byte* data, std::uint32_t width, std::uint32_t height, std::uint32_t stride, std::uint64_t sequence); } diff --git a/web_server/app/render_2D/Gallery_Scene2D.cpp b/web_server/app/render_2D/Gallery_Scene2D.cpp index bce71fc..f48b5da 100644 --- a/web_server/app/render_2D/Gallery_Scene2D.cpp +++ b/web_server/app/render_2D/Gallery_Scene2D.cpp @@ -348,8 +348,9 @@ public: rendered_since_last_pixel_ = false; std::string pixels; const Color background = plot_.background_color(); - plot_.with_frame([&pixels, background](Image_View image) { - pixels = encode_pixel_frame(image, background); + const std::uint64_t sequence = plot_.frame_status().latest_sequence; + plot_.with_frame([&pixels, background, sequence](Image_View image) { + pixels = encode_pixel_frame(image, sequence, background); }); return pixels.empty() ? std::nullopt : std::optional(std::move(pixels)); } diff --git a/web_server/app/render_3D/Gallery_Scene3D.cpp b/web_server/app/render_3D/Gallery_Scene3D.cpp index 4f94097..077a2bd 100644 --- a/web_server/app/render_3D/Gallery_Scene3D.cpp +++ b/web_server/app/render_3D/Gallery_Scene3D.cpp @@ -317,7 +317,7 @@ public: return std::nullopt; return encode_rgba8_pixel_frame( frame->rgba8.data(), frame->extent.width, frame->extent.height, - frame->extent.width * 4U); + frame->extent.width * 4U, frame->sequence); } void record_pixel_response( diff --git a/web_server/tests/Datoviz_Gallery_Tests.cpp b/web_server/tests/Datoviz_Gallery_Tests.cpp index 227d26a..3433fd9 100644 --- a/web_server/tests/Datoviz_Gallery_Tests.cpp +++ b/web_server/tests/Datoviz_Gallery_Tests.cpp @@ -75,7 +75,7 @@ void expect_actual_datoviz_pixels(const std::string& frame, std::uint32_t expected_width, std::uint32_t expected_height) { ASSERT_GE(frame.size(), pixel_frame_header_size); - ASSERT_EQ(frame.substr(0, 4), "RVP1"); + ASSERT_EQ(frame.substr(0, 4), "RVP2"); ASSERT_EQ(read_u32_le(frame, 4), expected_width); ASSERT_EQ(read_u32_le(frame, 8), expected_height); ASSERT_EQ(read_u32_le(frame, 12), expected_width * 4U); @@ -162,9 +162,9 @@ TEST(RenderiveWebDatovizGallery, RgbaEncoderPreservesRowsAndRemovesPadding) { source[8] = source[9] = source[10] = source[11] = 99; const std::string frame = encode_rgba8_pixel_frame( - reinterpret_cast(source.data()), 2, 2, 12); + reinterpret_cast(source.data()), 2, 2, 12, 42); ASSERT_EQ(frame.size(), pixel_frame_header_size + expected.size()); - EXPECT_EQ(frame.substr(0, 4), "RVP1"); + EXPECT_EQ(frame.substr(0, 4), "RVP2"); EXPECT_EQ(read_u32_le(frame, 4), 2U); EXPECT_EQ(read_u32_le(frame, 8), 2U); EXPECT_EQ(read_u32_le(frame, 12), 8U); @@ -173,7 +173,7 @@ TEST(RenderiveWebDatovizGallery, RgbaEncoderPreservesRowsAndRemovesPadding) { reinterpret_cast( frame.data() + pixel_frame_header_size))); EXPECT_TRUE(encode_rgba8_pixel_frame( - reinterpret_cast(source.data()), 2, 2, 7) + reinterpret_cast(source.data()), 2, 2, 7, 42) .empty()); } diff --git a/web_server/tests/Web_Bridge_Tests.cpp b/web_server/tests/Web_Bridge_Tests.cpp index e753d9f..b08d720 100644 --- a/web_server/tests/Web_Bridge_Tests.cpp +++ b/web_server/tests/Web_Bridge_Tests.cpp @@ -193,9 +193,9 @@ 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); + const std::string frame = encode_pixel_frame(image, 42); ASSERT_EQ(frame.size(), pixel_frame_header_size + 8); - EXPECT_EQ(frame.substr(0, 4), "RVP1"); + EXPECT_EQ(frame.substr(0, 4), "RVP2"); EXPECT_EQ(read_u32_le(frame, 4), 2U); EXPECT_EQ(read_u32_le(frame, 8), 1U); EXPECT_EQ(read_u32_le(frame, 12), 8U); @@ -216,7 +216,7 @@ TEST(RenderiveWebBridge, LegacyReceiverStillRendersPixels) { ASSERT_TRUE(frame.has_value()); EXPECT_EQ(frame->type, Web_Response_Type::Pixels); ASSERT_GE(frame->payload.size(), pixel_frame_header_size); - EXPECT_EQ(frame->payload.substr(0, 4), "RVP1"); + EXPECT_EQ(frame->payload.substr(0, 4), "RVP2"); EXPECT_EQ(read_u32_le(frame->payload, 4), 640U); EXPECT_EQ(read_u32_le(frame->payload, 8), 360U); } @@ -226,7 +226,8 @@ 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("protocol_version"), 5); + EXPECT_EQ(catalog.at("transport").at("pixels"), "websocket-binary-rvp2"); EXPECT_EQ(catalog.at("case_descriptor").at("protocol"), "adminive.resource"); EXPECT_EQ(catalog.at("cases").size(), 23U); EXPECT_EQ(catalog.at("frame_modes").size(), 3U); @@ -243,7 +244,7 @@ TEST(RenderiveWebGallery, AdminiveCatalogCoversEveryControlCaseAndThreeModes) { TEST(RenderiveWebGallery, CatalogOwnsDashboardFieldsAndDynamicBehavior) { const nlohmann::json catalog = parse_json(Gallery_Protocol::catalog_json()); const auto& dashboard = catalog.at("dashboard"); - EXPECT_EQ(dashboard.at("performance").at("fields").size(), 30U); + EXPECT_EQ(dashboard.at("performance").at("fields").size(), 36U); EXPECT_EQ(dashboard.at("limits").at("fields").size(), 4U); EXPECT_EQ(dashboard.at("observer").at("sections").size(), 4U); std::set client_sources; @@ -904,7 +905,7 @@ TEST(RenderiveWebGallery, EveryCanvasAcceptsTheCompleteWebEventSetAndStillRender const auto frame = wait_for_pixel_frame(session, 420, 260); ASSERT_TRUE(frame.has_value()); ASSERT_EQ(frame->type, Web_Response_Type::Pixels); - EXPECT_EQ(frame->payload.substr(0, 4), "RVP1"); + EXPECT_EQ(frame->payload.substr(0, 4), "RVP2"); EXPECT_EQ(read_u32_le(frame->payload, 4), 420U); EXPECT_EQ(read_u32_le(frame->payload, 8), 260U); const auto telemetry = observe_telemetry(session); @@ -960,7 +961,7 @@ TEST(RenderiveWebGallery, EveryIndependentCore2CanvasBuildsAndRenders) { const auto frame = wait_for_pixel_frame(session, 480, 280); ASSERT_TRUE(frame.has_value()) << case_id; ASSERT_EQ(frame->type, Web_Response_Type::Pixels) << case_id; - EXPECT_EQ(frame->payload.substr(0, 4), "RVP1") << case_id; + EXPECT_EQ(frame->payload.substr(0, 4), "RVP2") << case_id; 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( diff --git a/webapp_gallery/package-lock.json b/webapp_gallery/package-lock.json index 6aceb55..950dcb9 100644 --- a/webapp_gallery/package-lock.json +++ b/webapp_gallery/package-lock.json @@ -16,7 +16,9 @@ "elkjs": "^0.9.3", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-resizable-panels": "^4.12.2" + "react-resizable-panels": "^4.12.2", + "echarts": "^6.1.0", + "reconnecting-websocket": "^4.4.0" }, "devDependencies": { "@playwright/test": "^1.51.1", @@ -4155,6 +4157,37 @@ "optional": true } } + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/reconnecting-websocket": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz", + "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } } } } diff --git a/webapp_gallery/package.json b/webapp_gallery/package.json index 8191354..5e28770 100644 --- a/webapp_gallery/package.json +++ b/webapp_gallery/package.json @@ -19,7 +19,9 @@ "elkjs": "^0.9.3", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-resizable-panels": "^4.12.2" + "react-resizable-panels": "^4.12.2", + "echarts": "^6.1.0", + "reconnecting-websocket": "^4.4.0" }, "devDependencies": { "@playwright/test": "^1.51.1", diff --git a/webapp_gallery/src/app.tsx b/webapp_gallery/src/app.tsx index 181a628..3ebe66a 100644 --- a/webapp_gallery/src/app.tsx +++ b/webapp_gallery/src/app.tsx @@ -83,6 +83,7 @@ export function App() { @@ -132,6 +133,7 @@ export function App() { > set_selected_plot(null)} diff --git a/webapp_gallery/src/capture/capture_toolbar.tsx b/webapp_gallery/src/capture/capture_toolbar.tsx index e93123f..783af84 100644 --- a/webapp_gallery/src/capture/capture_toolbar.tsx +++ b/webapp_gallery/src/capture/capture_toolbar.tsx @@ -1,2 +1,6 @@ -import {Button,LinearProgress,Stack,TextField,Typography} from "@mui/material";import {useState} from "react";import type {Gallery_Performance_Capture} from "../protocol/gallery_types"; -export function Capture_Toolbar({capture,on_capture}:{capture:Gallery_Performance_Capture;on_capture:(action:string,count?:number)=>void}) {const [capture_count_draft,set_capture_count_draft]=useState("20");const active=capture.sessions.find(session=>session.session_id===capture.controller.session_id)||capture.sessions.find(session=>session.active);const requested=active?.requested_count??0,captured=active?.captured_count??0;return set_capture_count_draft(event.target.value)} sx={{width:110}}/>{requested?`captured ${captured} / ${requested}`:"Capture disabled"}{requested>0&&};} +import DownloadIcon from "@mui/icons-material/Download"; +import {Button,LinearProgress,Stack,TextField,Typography} from "@mui/material"; +import {useState} from "react"; +import type {Gallery_Performance_Capture} from "../protocol/gallery_types"; +import {download_perfetto_trace} from "./perfetto_export"; +export function Capture_Toolbar({capture,on_capture}:{capture:Gallery_Performance_Capture;on_capture:(action:string,count?:number)=>void}) {const [capture_count_draft,set_capture_count_draft]=useState("20");const active=capture.sessions.find(session=>session.session_id===capture.controller.session_id)||capture.sessions.find(session=>session.active);const requested=active?.requested_count??0,captured=active?.captured_count??0;const has_frames=capture.sessions.some(session=>session.frames.length>0);return set_capture_count_draft(event.target.value)} sx={{width:110}}/>{requested?`captured ${captured} / ${requested}`:"Capture disabled"}{requested>0&&};} diff --git a/webapp_gallery/src/capture/perfetto_export.ts b/webapp_gallery/src/capture/perfetto_export.ts new file mode 100644 index 0000000..d5d001c --- /dev/null +++ b/webapp_gallery/src/capture/perfetto_export.ts @@ -0,0 +1,9 @@ +import type {Gallery_Performance_Capture} from "../protocol/gallery_types"; +interface Trace_Event {name:string;cat:string;ph:"X"|"M";ts?:number;dur?:number;pid:number;tid:number;args?:Record;} +export function perfetto_trace_json(capture:Gallery_Performance_Capture): string { + const events:Trace_Event[]=[]; + const plans=new Map(capture.plans.map(plan=>[plan.version,new Map(plan.nodes.map(node=>[node.node_id,node]))])); + for(const session of capture.sessions){let frame_base_ns=0;const workers=new Set();for(const frame of session.frames){const nodes=plans.get(frame.render_plan_version);events.push({name:`Frame ${frame.frame_id}`,cat:"renderive.frame",ph:"X",ts:frame_base_ns/1000,dur:frame.render_duration_ns/1000,pid:session.session_id,tid:0,args:{render_plan_version:frame.render_plan_version}});for(const execution of frame.node_executions){const node=nodes?.get(execution.node_id);const name=node?.name??`Node ${execution.node_id}`;if(execution.cpu_end_offset_ns>execution.start_offset_ns){workers.add(execution.worker_id);events.push({name,cat:"renderive.cpu",ph:"X",ts:(frame_base_ns+execution.start_offset_ns)/1000,dur:(execution.cpu_end_offset_ns-execution.start_offset_ns)/1000,pid:session.session_id,tid:execution.worker_id,args:{node_id:execution.node_id,kind:node?.kind,owner:node?.owner,status:execution.status}});}if(execution.external_end_offset_ns>execution.external_start_offset_ns)events.push({name:`${name} external`,cat:"renderive.external",ph:"X",ts:(frame_base_ns+execution.external_start_offset_ns)/1000,dur:(execution.external_end_offset_ns-execution.external_start_offset_ns)/1000,pid:session.session_id,tid:1000000,args:{node_id:execution.node_id,status:execution.status}});}frame_base_ns+=Math.max(frame.render_duration_ns,...frame.node_executions.map(node=>node.end_offset_ns),0)+1_000_000;}for(const worker of workers)events.push({name:"thread_name",cat:"__metadata",ph:"M",pid:session.session_id,tid:worker,args:{name:`oneTBB worker ${worker}`}});events.push({name:"thread_name",cat:"__metadata",ph:"M",pid:session.session_id,tid:1000000,args:{name:"External / GPU"}});} + return JSON.stringify({traceEvents:events,displayTimeUnit:"ns"}); +} +export function download_perfetto_trace(capture:Gallery_Performance_Capture): void {const blob=new Blob([perfetto_trace_json(capture)],{type:"application/json"});const url=URL.createObjectURL(blob);const anchor=document.createElement("a");anchor.href=url;anchor.download=`renderive-perfetto-${Date.now()}.json`;anchor.click();URL.revokeObjectURL(url);} diff --git a/webapp_gallery/src/capture/worker_timeline.tsx b/webapp_gallery/src/capture/worker_timeline.tsx index b8e8d74..fb7dc34 100644 --- a/webapp_gallery/src/capture/worker_timeline.tsx +++ b/webapp_gallery/src/capture/worker_timeline.tsx @@ -1,17 +1,15 @@ -import {Box,Tooltip,Typography} from "@mui/material"; +import * as echarts from "echarts"; +import type {EChartsOption} from "echarts"; +import {ECharts_View} from "../common/echarts_view"; import type {Gallery_Captured_Frame,Gallery_Render_Plan} from "../protocol/gallery_types"; import {format_nanoseconds} from "../protocol/format"; - +interface Timeline_Datum {value:[number,number,number];node_id:number;name:string;duration_ns:number;selected:boolean;} export function Worker_Timeline({frame,plan,selected_node_id,on_select_node}:{frame:Gallery_Captured_Frame;plan:Gallery_Render_Plan;selected_node_id:number|null;on_select_node:(id:number)=>void}) { - const duration=Math.max(1,frame.render_duration_ns); - const cpu_nodes=frame.node_executions.filter(item=>item.cpu_duration_ns>0); - const external_nodes=frame.node_executions.filter(item=>item.external_duration_ns>0); - const workers=[...new Set(cpu_nodes.map(item=>item.worker_id))].toSorted((a,b)=>a-b); - const names=new Map(plan.nodes.map(node=>[node.node_id,node.name])); - const lane=(nodes:Gallery_Captured_Frame["node_executions"],external=false)=>{nodes.map(item=>{ - const start=external?item.external_start_offset_ns:item.start_offset_ns; - const span=external?item.external_duration_ns:item.cpu_duration_ns; - return on_select_node(item.node_id)} sx={{position:"absolute",left:`${start/duration*100}%`,width:`${Math.max(.4,span/duration*100)}%`,top:4,bottom:4,borderRadius:.5,cursor:"pointer",bgcolor:item.node_id===selected_node_id?"secondary.main":external?"warning.main":"primary.main",opacity:external ? .65 : 1}}/>; - })}; - return {workers.map(worker=>Worker {worker}{lane(cpu_nodes.filter(item=>item.worker_id===worker))})}{external_nodes.length>0&&External{lane(external_nodes,true)}}; + const cpu_nodes=frame.node_executions.filter(item=>item.cpu_duration_ns>0),external_nodes=frame.node_executions.filter(item=>item.external_duration_ns>0),workers=[...new Set(cpu_nodes.map(item=>item.worker_id))].toSorted((a,b)=>a-b),lanes=[...workers.map(worker=>`Worker ${worker}`),...(external_nodes.length?["External"]:[])],names=new Map(plan.nodes.map(node=>[node.node_id,node.name])),duration=Math.max(1,frame.render_duration_ns,...frame.node_executions.map(item=>item.end_offset_ns)); + const cpu_data:Timeline_Datum[]=cpu_nodes.map(item=>({value:[workers.indexOf(item.worker_id),item.start_offset_ns,item.cpu_end_offset_ns],node_id:item.node_id,name:names.get(item.node_id)??String(item.node_id),duration_ns:item.cpu_duration_ns,selected:item.node_id===selected_node_id})); + const external_lane=workers.length,external_data:Timeline_Datum[]=external_nodes.map(item=>({value:[external_lane,item.external_start_offset_ns,item.external_end_offset_ns],node_id:item.node_id,name:names.get(item.node_id)??String(item.node_id),duration_ns:item.external_duration_ns,selected:item.node_id===selected_node_id})); + const render_item=(params:any,api:any)=>{const lane=Number(api.value(0)),start=api.coord([api.value(1),lane]),end=api.coord([api.value(2),lane]),height=Math.max(6,Number(api.size([0,1])[1])*.55),shape=echarts.graphic.clipRectByRect({x:start[0],y:start[1]-height/2,width:Math.max(1,end[0]-start[0]),height},{x:params.coordSys.x,y:params.coordSys.y,width:params.coordSys.width,height:params.coordSys.height});return shape?{type:"rect" as const,shape,style:api.style()}:null;}; + const series=(name:string,data:Timeline_Datum[],opacity:number)=>({name,type:"custom" as const,renderItem:render_item,encode:{x:[1,2],y:0},data:data.map(item=>({...item,itemStyle:{opacity,borderWidth:item.selected?2:0,borderColor:item.selected?"#fff":undefined}}))}); + const option:EChartsOption={backgroundColor:"transparent",animation:false,tooltip:{formatter:(params:any)=>{const item=params.data as Timeline_Datum;return `${item.name} · ${params.seriesName} · ${format_nanoseconds(item.duration_ns)}`;}},legend:{data:["CPU",...(external_nodes.length?["External"]:[])]},grid:{left:92,right:24,top:42,bottom:42},xAxis:{type:"value",min:0,max:duration,axisLabel:{formatter:value=>format_nanoseconds(Number(value))}},yAxis:{type:"category",data:lanes},dataZoom:[{type:"inside",xAxisIndex:0},{type:"slider",xAxisIndex:0,height:16,bottom:8}],series:[series("CPU",cpu_data,1),...(external_nodes.length?[series("External",external_data,.65)]:[])]}; + return {const item=data as Timeline_Datum|undefined;if(item?.node_id!==undefined)on_select_node(item.node_id);}}/>; } diff --git a/webapp_gallery/src/common/copy_button.tsx b/webapp_gallery/src/common/copy_button.tsx index 8bf5c84..782ff99 100644 --- a/webapp_gallery/src/common/copy_button.tsx +++ b/webapp_gallery/src/common/copy_button.tsx @@ -1,3 +1,3 @@ import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import {IconButton,Tooltip} from "@mui/material"; -export function Copy_Button({value}:{value:string}) {return void navigator.clipboard.writeText(value)}>;} +export function Copy_Button({value}:{value:string}) {return void navigator.clipboard.writeText(value)}>;} diff --git a/webapp_gallery/src/common/dashboard_field_grid.tsx b/webapp_gallery/src/common/dashboard_field_grid.tsx new file mode 100644 index 0000000..bb60012 --- /dev/null +++ b/webapp_gallery/src/common/dashboard_field_grid.tsx @@ -0,0 +1,7 @@ +import {Box,Typography} from "@mui/material"; +import {format_dashboard_field, value_at_path} from "../protocol/format"; +import type {Gallery_Dashboard, Gallery_Dashboard_Field} from "../protocol/gallery_types"; +export function Dashboard_Field_Grid({fields,telemetry,dashboard}:{fields:Gallery_Dashboard_Field[];telemetry:Record;dashboard:Gallery_Dashboard}) { + const visible=fields.filter(field=>field.source===undefined||value_at_path(telemetry,field.source)!==undefined||field.default!==undefined); + return {visible.map((field,index)=>{format_dashboard_field(field,telemetry,dashboard)}{field.label})}; +} diff --git a/webapp_gallery/src/common/echarts_view.tsx b/webapp_gallery/src/common/echarts_view.tsx new file mode 100644 index 0000000..f916a23 --- /dev/null +++ b/webapp_gallery/src/common/echarts_view.tsx @@ -0,0 +1,10 @@ +import * as echarts from "echarts"; +import type {EChartsOption} from "echarts"; +import {useEffect,useRef} from "react"; +export function ECharts_View({option,height=220,on_click}:{option:EChartsOption;height?:number;on_click?:(data:unknown)=>void}) { + const element=useRef(null),chart=useRef|null>(null); + useEffect(()=>{if(!element.current)return;chart.current=echarts.init(element.current,"dark");const observer=new ResizeObserver(()=>chart.current?.resize());observer.observe(element.current);return()=>{observer.disconnect();chart.current?.dispose();chart.current=null;};},[]); + useEffect(()=>{chart.current?.setOption(option,true);},[option]); + useEffect(()=>{const current=chart.current;if(!current||!on_click)return;const handler=(event:{data?:unknown})=>on_click(event.data);current.on("click",handler);return()=>{current.off("click",handler);};},[on_click]); + return
; +} diff --git a/webapp_gallery/src/common/json_viewer.tsx b/webapp_gallery/src/common/json_viewer.tsx index 731d411..7d11861 100644 --- a/webapp_gallery/src/common/json_viewer.tsx +++ b/webapp_gallery/src/common/json_viewer.tsx @@ -1,2 +1,3 @@ import {Box} from "@mui/material"; -export function Json_Viewer({value}:{value:unknown}) {return {JSON.stringify(value,null,2)};} +import {Copy_Button} from "./copy_button"; +export function Json_Viewer({value}:{value:unknown}) {const text=JSON.stringify(value,null,2)??"undefined";return {text};} diff --git a/webapp_gallery/src/dag/render_dag.tsx b/webapp_gallery/src/dag/render_dag.tsx index 30aadaf..975d44a 100644 --- a/webapp_gallery/src/dag/render_dag.tsx +++ b/webapp_gallery/src/dag/render_dag.tsx @@ -1,85 +1,23 @@ -import {Background, Controls, MiniMap, ReactFlow} from "@xyflow/react"; -import {Box, CircularProgress} from "@mui/material"; -import {useEffect, useMemo, useState} from "react"; -import type { - Gallery_Captured_Frame, - Gallery_Node_Statistics, - Gallery_Render_Plan, -} from "../protocol/gallery_types"; +import MapOutlinedIcon from "@mui/icons-material/MapOutlined"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import {Alert,Box,Button,CircularProgress} from "@mui/material"; +import {Background,ControlButton,Controls,MiniMap,ReactFlow} from "@xyflow/react"; +import {useEffect,useMemo,useState} from "react"; +import type {Gallery_Captured_Frame,Gallery_Node_Statistics,Gallery_Render_Plan} from "../protocol/gallery_types"; import {layout_dag} from "./dag_layout"; import {build_dag_model} from "./dag_model"; import {Dag_Node} from "./dag_node"; import type {Dag_View_Model} from "./dag_types"; - -const node_types = {dag_node: Dag_Node}; - -export function Render_Dag({ - plan, - frame = null, - statistics = [], - selected_node_id, - on_select_node, -}: { - plan: Gallery_Render_Plan; - frame?: Gallery_Captured_Frame | null; - statistics?: Gallery_Node_Statistics[]; - selected_node_id: number | null; - on_select_node: (id: number) => void; -}) { - const source = useMemo( - () => build_dag_model(plan, frame, statistics, selected_node_id), - [plan, frame, statistics, selected_node_id], - ); - const layout_identity = useMemo( - () => `${plan.nodes.map(node => node.node_id).join(",")}:${plan.edges.map(edge => `${edge.from}>${edge.to}`).join(",")}`, - [plan], - ); - const [layout, set_layout] = useState<{identity: string; model: Dag_View_Model} | null>(null); - - useEffect(() => { - let current = true; - void layout_dag(source).then(value => { - if (current) - set_layout({identity: layout_identity, model: value}); - }); - return () => { - current = false; - }; - // Selection and telemetry only decorate nodes; they must not restart ELK layout. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layout_identity]); - - const laid_out = layout?.identity === layout_identity ? layout.model : null; - const model = useMemo(() => { - if (!laid_out) - return null; - const positions = new Map(laid_out.nodes.map(node => [node.id, node.position])); - return { - ...source, - nodes: source.nodes.map(node => ({ - ...node, - position: positions.get(node.id) ?? node.position, - })), - }; - }, [source, laid_out]); - - return - {!model - ? - : on_select_node(Number(node.id))} - > - - - - } - ; +const node_types={dag_node:Dag_Node}; +export function Render_Dag({plan,frame=null,statistics=[],selected_node_id,on_select_node}:{plan:Gallery_Render_Plan;frame?:Gallery_Captured_Frame|null;statistics?:Gallery_Node_Statistics[];selected_node_id:number|null;on_select_node:(id:number)=>void;}) { + const source=useMemo(()=>build_dag_model(plan,frame,statistics,selected_node_id),[plan,frame,statistics,selected_node_id]); + const layout_identity=String(plan.version); + const [layout,set_layout]=useState<{identity:string;model:Dag_View_Model}|null>(null); + const [layout_error,set_layout_error]=useState(""); + const [layout_retry,set_layout_retry]=useState(0); + const [minimap_visible,set_minimap_visible]=useState(true); + useEffect(()=>{let current=true;set_layout_error("");void layout_dag(source).then(value=>{if(current)set_layout({identity:layout_identity,model:value});}).catch(error=>{if(current)set_layout_error(error instanceof Error?error.message:"DAG 自动布局失败");});return()=>{current=false;};},[layout_identity,layout_retry]); + const laid_out=layout?.identity===layout_identity?layout.model:null; + const model=useMemo(()=>{if(!laid_out)return null;const positions=new Map(laid_out.nodes.map(node=>[node.id,node.position]));return {...source,nodes:source.nodes.map(node=>({...node,position:positions.get(node.id)??node.position}))};},[source,laid_out]); + return {layout_error?} onClick={()=>set_layout_retry(value=>value+1)}>重试布局}>{layout_error}:!model?:on_select_node(Number(node.id))}>{minimap_visible&&}set_minimap_visible(value=>!value)}>}; } diff --git a/webapp_gallery/src/gallery/gallery_page.tsx b/webapp_gallery/src/gallery/gallery_page.tsx index 53cc16b..0263dce 100644 --- a/webapp_gallery/src/gallery/gallery_page.tsx +++ b/webapp_gallery/src/gallery/gallery_page.tsx @@ -1,2 +1,2 @@ -import {Box} from "@mui/material";import {useCallback,useEffect,useRef} from "react";import type {Gallery_Case,Gallery_Frame_Mode} from "../protocol/gallery_types";import type {Gallery_Plot_Session} from "../session/gallery_plot_session";import {Plot_Card,type Selected_Plot} from "./plot_card"; -export function Gallery_Page({cases,frame_mode,streams_paused,on_open_inspector}:{cases:Gallery_Case[];frame_mode:Gallery_Frame_Mode;streams_paused:boolean;on_open_inspector:(plot:Selected_Plot)=>void}) {const sessions=useRef(new Set());const register=useCallback((session:Gallery_Plot_Session,mount:boolean)=>{mount?sessions.current.add(session):sessions.current.delete(session);},[]);useEffect(()=>{let animation_frame=0;const loop=(now:number)=>{for(const session of sessions.current)session.tick(now);animation_frame=requestAnimationFrame(loop);};animation_frame=requestAnimationFrame(loop);return()=>cancelAnimationFrame(animation_frame);},[]);return {cases.map(gallery_case=>)};} +import {Box} from "@mui/material";import {useCallback,useEffect,useRef} from "react";import type {Gallery_Case,Gallery_Dashboard,Gallery_Frame_Mode} from "../protocol/gallery_types";import type {Gallery_Plot_Session} from "../session/gallery_plot_session";import {Plot_Card,type Selected_Plot} from "./plot_card"; +export function Gallery_Page({cases,frame_mode,dashboard,streams_paused,on_open_inspector}:{cases:Gallery_Case[];frame_mode:Gallery_Frame_Mode;dashboard:Gallery_Dashboard;streams_paused:boolean;on_open_inspector:(plot:Selected_Plot)=>void}) {const sessions=useRef(new Set());const register=useCallback((session:Gallery_Plot_Session,mount:boolean)=>{mount?sessions.current.add(session):sessions.current.delete(session);},[]);useEffect(()=>{let animation_frame=0;const loop=(now:number)=>{for(const session of sessions.current)session.tick(now);animation_frame=requestAnimationFrame(loop);};animation_frame=requestAnimationFrame(loop);return()=>cancelAnimationFrame(animation_frame);},[]);return {cases.map(gallery_case=>)};} diff --git a/webapp_gallery/src/gallery/plot_card.tsx b/webapp_gallery/src/gallery/plot_card.tsx index b9911f2..f13db68 100644 --- a/webapp_gallery/src/gallery/plot_card.tsx +++ b/webapp_gallery/src/gallery/plot_card.tsx @@ -2,7 +2,7 @@ import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; import RefreshIcon from "@mui/icons-material/Refresh"; import {Box,Button,Card,CardActions,CardContent,CardHeader,Chip,Stack,Typography} from "@mui/material"; import {useEffect,useRef,useState} from "react"; -import type {Gallery_Case,Gallery_Frame_Mode} from "../protocol/gallery_types"; +import type {Gallery_Case,Gallery_Dashboard,Gallery_Frame_Mode} from "../protocol/gallery_types"; import {use_plot_session} from "../hooks/use_plot_session"; import type {Gallery_Plot_Session} from "../session/gallery_plot_session"; import {Plot_Canvas} from "../plot/plot_canvas"; @@ -11,7 +11,7 @@ import {Performance_Strip} from "../plot/performance_strip"; import {Kernel_Observer_Summary} from "../plot/kernel_observer_summary"; export interface Selected_Plot {session:Gallery_Plot_Session;} -export function Plot_Card({gallery_case,frame_mode,active,streams_paused,on_register,on_open_inspector}:{gallery_case:Gallery_Case;frame_mode:Gallery_Frame_Mode;active:boolean;streams_paused:boolean;on_register:(session:Gallery_Plot_Session,mount:boolean)=>void;on_open_inspector:(plot:Selected_Plot)=>void}) { +export function Plot_Card({gallery_case,frame_mode,dashboard,active,streams_paused,on_register,on_open_inspector}:{gallery_case:Gallery_Case;frame_mode:Gallery_Frame_Mode;dashboard:Gallery_Dashboard;active:boolean;streams_paused:boolean;on_register:(session:Gallery_Plot_Session,mount:boolean)=>void;on_open_inspector:(plot:Selected_Plot)=>void}) { const [session,snapshot]=use_plot_session(gallery_case,frame_mode); const card_ref=useRef(null); const [in_viewport,set_in_viewport]=useState(false); @@ -20,7 +20,7 @@ export function Plot_Card({gallery_case,frame_mode,active,streams_paused,on_regi useEffect(()=>session.set_activity(active&&in_viewport,streams_paused),[session,active,in_viewport,streams_paused]); return {event.preventDefault();if(snapshot.ready)on_open_inspector({session});}}> } action={}/> - {frame_mode.observer_visible&&}{gallery_case.description}{snapshot.notice&&{snapshot.notice}} + {frame_mode.observer_visible&&}{gallery_case.description}{snapshot.notice&&{snapshot.notice}} {snapshot.controls.length} 属性{snapshot.actions.length} 动作{snapshot.frame_count} 像素帧 ; } diff --git a/webapp_gallery/src/inspector/inspector_panel.tsx b/webapp_gallery/src/inspector/inspector_panel.tsx index c122641..4f8b296 100644 --- a/webapp_gallery/src/inspector/inspector_panel.tsx +++ b/webapp_gallery/src/inspector/inspector_panel.tsx @@ -6,6 +6,7 @@ import {useEffect, useState, useSyncExternalStore} from "react"; import {Capture_Panel} from "../capture/capture_panel"; import {Empty_State} from "../common/empty_state"; import {Render_Dag} from "../dag/render_dag"; +import type {Gallery_Dashboard} from "../protocol/gallery_types"; import type {Gallery_Plot_Session} from "../session/gallery_plot_session"; import {Actions_Panel} from "./actions_panel"; import {Controls_Panel} from "./controls_panel"; @@ -15,11 +16,13 @@ import {Performance_Panel} from "./performance_panel"; export function Inspector_Panel({ session, + dashboard, active_tab, on_change_tab, on_close, }: { session: Gallery_Plot_Session; + dashboard: Gallery_Dashboard; active_tab: string; on_change_tab: (tab: string) => void; on_close: () => void; @@ -42,9 +45,9 @@ export function Inspector_Panel({ setTimeout(() => session.request_frame(performance.now(), true), 40); }}/>; else if (active_tab === "observer") - body = ; + body = ; else if (active_tab === "performance") - body = ; + body = ; else if (active_tab === "performance_capture") body = snapshot.performance_capture ? { diff --git a/webapp_gallery/src/inspector/observer_panel.tsx b/webapp_gallery/src/inspector/observer_panel.tsx index 1bb8d92..379402e 100644 --- a/webapp_gallery/src/inspector/observer_panel.tsx +++ b/webapp_gallery/src/inspector/observer_panel.tsx @@ -1,2 +1,15 @@ -import {Stack,Typography} from "@mui/material";import type {Gallery_Telemetry} from "../protocol/gallery_types";import {Json_Viewer} from "../common/json_viewer"; -export function Observer_Panel({telemetry}:{telemetry:Gallery_Telemetry}) {return 内核与 Renderable 观察数据;} +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import {Accordion,AccordionDetails,AccordionSummary,Paper,Stack,Typography} from "@mui/material"; +import {Dashboard_Field_Grid} from "../common/dashboard_field_grid"; +import {Json_Viewer} from "../common/json_viewer"; +import type {Gallery_Dashboard,Gallery_Telemetry} from "../protocol/gallery_types"; +export function Observer_Panel({telemetry,dashboard}:{telemetry:Gallery_Telemetry;dashboard:Gallery_Dashboard}) { + const header=dashboard.observer.header; + return + {`${header.prefix}与 Renderable ${header.suffix}`} + + {dashboard.observer.sections.map(section=>{section.aria_label??section.class_name})} + {telemetry.renderable_observers?.map(resource=>}>{resource.title??resource.target})} + }>原始观察数据 + ; +} diff --git a/webapp_gallery/src/inspector/performance_panel.tsx b/webapp_gallery/src/inspector/performance_panel.tsx index c87b15b..2e3d044 100644 --- a/webapp_gallery/src/inspector/performance_panel.tsx +++ b/webapp_gallery/src/inspector/performance_panel.tsx @@ -1,2 +1,28 @@ -import {Stack,Typography} from "@mui/material";import type {Gallery_Telemetry} from "../protocol/gallery_types";import {Json_Viewer} from "../common/json_viewer"; -export function Performance_Panel({telemetry}:{telemetry:Gallery_Telemetry}) {return 性能滑动窗口;} +import type {EChartsOption} from "echarts"; +import {Accordion,AccordionDetails,AccordionSummary,Box,Chip,Paper,Stack,Typography} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import {useEffect,useMemo,useState} from "react"; +import {Dashboard_Field_Grid} from "../common/dashboard_field_grid"; +import {ECharts_View} from "../common/echarts_view"; +import {Json_Viewer} from "../common/json_viewer"; +import {format_nanoseconds,value_at_path} from "../protocol/format"; +import type {Gallery_Dashboard,Gallery_Telemetry} from "../protocol/gallery_types"; +interface Trend_Sample {time:number;values:Record;} +export function Performance_Panel({telemetry,dashboard,history_key}:{telemetry:Gallery_Telemetry;dashboard:Gallery_Dashboard;history_key:unknown}) { + const [history,set_history]=useState([]); + const trend_fields=useMemo(()=>dashboard.performance.fields.filter(field=>field.trend_group&&field.source),[dashboard]); + useEffect(()=>set_history([]),[history_key]); + useEffect(()=>{const values:Record={};for(const field of trend_fields){const raw=value_at_path(telemetry,field.source);if(typeof raw==="number"&&Number.isFinite(raw))values[field.source!]=raw;}if(Object.keys(values).length)set_history(items=>[...items,{time:Date.now(),values}].slice(-180));},[telemetry,trend_fields]); + const groups=useMemo(()=>[...new Set(trend_fields.map(field=>field.trend_group!))],[trend_fields]); + const current_limit=String(value_at_path(telemetry,dashboard.limits.current_source)??""); + return + 性能监测 + + {groups.map(group=>{const fields=trend_fields.filter(field=>field.trend_group===group);const option:EChartsOption={backgroundColor:"transparent",animation:false,tooltip:{trigger:"axis"},legend:{type:"scroll"},grid:{left:58,right:24,top:42,bottom:34},xAxis:{type:"time"},yAxis:{type:"value",scale:true},series:fields.map(field=>{const data:Array<[number,number]>=[];for(const sample of history){const value=sample.values[field.source!];if(value!==undefined)data.push([sample.time,value]);}return {name:field.label,type:"line",showSymbol:false,data};})};return {group};})} + + {dashboard.limits.title} + {dashboard.limits.fields.map(field=>{const enabled=!field.enabled_source||Boolean(value_at_path(telemetry,field.enabled_source));const active=enabled&&String(field.active_value)===current_limit;const duration=field.duration_source?format_nanoseconds(value_at_path(telemetry,field.duration_source)):"";return ;})} + + }>原始性能数据 + ; +} diff --git a/webapp_gallery/src/plot/kernel_observer_summary.tsx b/webapp_gallery/src/plot/kernel_observer_summary.tsx index dd6b281..13dd5b2 100644 --- a/webapp_gallery/src/plot/kernel_observer_summary.tsx +++ b/webapp_gallery/src/plot/kernel_observer_summary.tsx @@ -1,2 +1,4 @@ -import {Box,Typography} from "@mui/material";import type {Gallery_Telemetry} from "../protocol/gallery_types";import {Json_Viewer} from "../common/json_viewer"; -export function Kernel_Observer_Summary({telemetry}:{telemetry:Gallery_Telemetry}) {const observer=telemetry.frame_observer??telemetry.observer;return Kernel Observer{observer?:等待观察数据};} +import {Box,Typography} from "@mui/material"; +import {Dashboard_Field_Grid} from "../common/dashboard_field_grid"; +import type {Gallery_Dashboard,Gallery_Telemetry} from "../protocol/gallery_types"; +export function Kernel_Observer_Summary({telemetry,dashboard}:{telemetry:Gallery_Telemetry;dashboard:Gallery_Dashboard}) {const header=dashboard.observer.header;return Kernel Observer;} diff --git a/webapp_gallery/src/plot/performance_strip.tsx b/webapp_gallery/src/plot/performance_strip.tsx index 83fcdd7..af91cd9 100644 --- a/webapp_gallery/src/plot/performance_strip.tsx +++ b/webapp_gallery/src/plot/performance_strip.tsx @@ -1,3 +1,4 @@ -import {Box,Typography} from "@mui/material";import type {Gallery_Telemetry} from "../protocol/gallery_types";import {value_at_path} from "../protocol/format"; -const METRICS:Array<[string,string,string]>=[["后端 FPS","performance.measured_fps"," FPS"],["像素 FPS","performance.pixel_response_fps"," FPS"],["帧完成延迟","performance.last_render_ms"," ms"],["编码","performance.last_pixel_encode_ms"," ms"]]; -export function Performance_Strip({telemetry}:{telemetry:Gallery_Telemetry}) {return {METRICS.map(([label,path,suffix])=>{label}{Number(value_at_path(telemetry,path)||0).toFixed(1)}{suffix})};} +import {Box,Typography} from "@mui/material"; +import {format_dashboard_field} from "../protocol/format"; +import type {Gallery_Dashboard,Gallery_Telemetry} from "../protocol/gallery_types"; +export function Performance_Strip({telemetry,dashboard}:{telemetry:Gallery_Telemetry;dashboard:Gallery_Dashboard}) {const fields=dashboard.performance.fields.filter(field=>field.summary);return {fields.map(field=>{field.label}{format_dashboard_field(field,telemetry,dashboard)})};} diff --git a/webapp_gallery/src/protocol/format.ts b/webapp_gallery/src/protocol/format.ts index e3df0fd..aa7eb26 100644 --- a/webapp_gallery/src/protocol/format.ts +++ b/webapp_gallery/src/protocol/format.ts @@ -1,9 +1,21 @@ -import type {Gallery_Dashboard_Field, Gallery_Dashboard, Json_Value} from "./gallery_types"; +import type {Gallery_Dashboard_Field, Gallery_Dashboard} from "./gallery_types"; export function format_nanoseconds(value: unknown): string {const ns = Math.max(0, Number(value) || 0); return ns < 1_000 ? `${Math.round(ns)} ns` : ns < 1_000_000 ? `${(ns / 1_000).toFixed(2)} µs` : `${(ns / 1_000_000).toFixed(3)} ms`;} export function value_at_path(root: unknown, path = ""): unknown {return path.split(".").filter(Boolean).reduce((value, key) => value && typeof value === "object" ? (value as Record)[key] : undefined, root);} +function mapped_value(field: Gallery_Dashboard_Field, raw: unknown, dashboard: Gallery_Dashboard): string {return dashboard.value_maps?.[field.value_map ?? ""]?.[String(raw)] ?? String(raw ?? "—");} +function format_bytes(value: number): string {const size=Math.max(0,value);return size<1024?`${Math.round(size)} B`:size<1024*1024?`${(size/1024).toFixed(1)} KiB`:`${(size/1024/1024).toFixed(2)} MiB`;} export function format_dashboard_field(field: Gallery_Dashboard_Field, telemetry: Record, dashboard: Gallery_Dashboard): string { - const raw = value_at_path(telemetry, field.source) ?? field.default ?? ""; const number = Number(raw) || 0; const digits = field.digits ?? 0; - if (field.format === "fixed") return number.toFixed(digits); if (field.format === "integer") return number.toLocaleString(); if (field.format === "milliseconds") return `${number.toFixed(digits)} ms`; if (field.format === "fps") return `${number.toFixed(digits)} FPS`; if (field.format === "bytes") return `${number.toLocaleString()} B`; if (field.format === "nanoseconds") return format_nanoseconds(number); - if (field.format === "enum") return dashboard.value_maps?.[field.value_map ?? ""]?.[String(raw)] ?? String(raw); + if (field.enabled_source && !Boolean(value_at_path(telemetry, field.enabled_source))) return field.disabled_label ?? "已关闭"; + if (field.format === "pair") return (field.sources ?? []).map(source => String(value_at_path(telemetry, source) ?? "—")).join(field.separator ?? " / "); + const raw = value_at_path(telemetry, field.source) ?? field.default ?? ""; + const number = Number(raw) || 0, digits = field.digits ?? 0; + if (field.format === "duration_enum") {const label=mapped_value(field,raw,dashboard);const source=field.duration_sources?.[String(raw)];return source?`${label} · ${format_nanoseconds(value_at_path(telemetry,source))}`:label;} + if (field.format === "fixed") return number.toFixed(digits); + if (field.format === "integer") return number.toLocaleString(); + if (field.format === "milliseconds") return `${number.toFixed(digits)} ms`; + if (field.format === "fps") return `${number.toFixed(digits)} FPS`; + if (field.format === "frequency") return `${number.toFixed(digits >= 0 ? digits : 2)} Hz`; + if (field.format === "bytes") return format_bytes(number); + if (field.format === "nanoseconds") return format_nanoseconds(number); + if (field.format === "enum" || field.format === "flags") return mapped_value(field,raw,dashboard); return String(raw ?? "—"); } diff --git a/webapp_gallery/src/protocol/gallery_types.ts b/webapp_gallery/src/protocol/gallery_types.ts index d38f12d..273da66 100644 --- a/webapp_gallery/src/protocol/gallery_types.ts +++ b/webapp_gallery/src/protocol/gallery_types.ts @@ -17,8 +17,10 @@ export interface Gallery_Navigation {default_mode: string; all_categories_label: export interface Gallery_Coverage {case_count: number; page_count: number; canvas_count: number; manual_control_count: number; manual_action_count: number;} export interface Gallery_Frame_Mode {id: string; title: string; strategy: string; description: string; order: number; accent: string; automatic: boolean; request_after_response: boolean; request_on_animation_frame: boolean; observer_visible: boolean; frame_button_label: string;} export interface Gallery_Case {id: string; title: string; description: string; component: string; category: string; order: number; control_count_by_mode?: Record; action_count_by_mode?: Record;} -export interface Gallery_Dashboard_Field {label: string; source?: string; sources?: string[]; enabled_source?: string; duration_sources?: Record; duration_source?: string; active_value?: Json_Primitive; disabled_label?: string; default?: Json_Primitive; digits?: number; format?: string; separator?: string; joiner?: string; value_map?: string; cell_class?: string;} -export interface Gallery_Dashboard {performance: {fields: Gallery_Dashboard_Field[]}; limits: {title: string; aria_label: string; current_source: string; active_label: string; inactive_label: string; disabled_label: string; fields: Gallery_Dashboard_Field[]}; observer: Record; menu_views: Record; value_maps?: Record>;} +export interface Gallery_Dashboard_Field {label: string; source?: string; sources?: string[]; enabled_source?: string; duration_sources?: Record; duration_source?: string; active_value?: Json_Primitive; disabled_label?: string; default?: Json_Primitive; digits?: number; format?: string; separator?: string; joiner?: string; value_map?: string; cell_class?: string; trend_group?: string; summary?: boolean;} +export interface Gallery_Dashboard_Observer_Section {class_name: string; aria_label?: string; fields: Gallery_Dashboard_Field[];} +export interface Gallery_Dashboard_Observer {aria_label: string; header: {prefix: string; suffix: string; event_label: string; mode: Gallery_Dashboard_Field; limit: Gallery_Dashboard_Field; event: Gallery_Dashboard_Field}; sections: Gallery_Dashboard_Observer_Section[]; descriptor?: Json_Value; consumer_feedback_descriptor?: Json_Value;} +export interface Gallery_Dashboard {performance: {fields: Gallery_Dashboard_Field[]}; limits: {title: string; aria_label: string; current_source: string; active_label: string; inactive_label: string; disabled_label: string; fields: Gallery_Dashboard_Field[]}; observer: Gallery_Dashboard_Observer; menu_views: Record; value_maps?: Record>;} export interface Gallery_Catalog {type: "catalog"; navigation: Gallery_Navigation; dashboard: Gallery_Dashboard; frame_modes: Gallery_Frame_Mode[]; cases: Gallery_Case[]; coverage: Gallery_Coverage; descriptor?: Gallery_Descriptor; transport?: {socket_per_canvas?: boolean};} export interface Gallery_Client_Performance {transport_fps: number; presentation_fps: number; display_interval_latest_ms: number; display_interval_ms: number; display_interval_average_ms: number; display_interval_p95_ms: number; display_interval_p99_ms: number; display_interval_deviation_ms: number; frame_round_trip_ms: number; frame_round_trip_average_ms: number; frame_round_trip_p95_ms: number; frame_round_trip_p99_ms: number; frame_round_trip_deviation_ms: number; changed_pixel_frames: number; duplicate_pixel_frames: number; overwritten_pixel_frames: number; frame_request_timeout_count: number; last_pixel_receive_age_ms: number; last_pixel_change_age_ms: number; websocket_buffered_bytes: number;} diff --git a/webapp_gallery/src/protocol/pixel_frame.ts b/webapp_gallery/src/protocol/pixel_frame.ts index 3694c18..f0712b3 100644 --- a/webapp_gallery/src/protocol/pixel_frame.ts +++ b/webapp_gallery/src/protocol/pixel_frame.ts @@ -1,16 +1,15 @@ -export const PIXEL_FRAME_HEADER_SIZE = 16; -export interface Pixel_Frame {width: number; height: number; stride: number; pixels: Uint8Array;} - +export const PIXEL_FRAME_HEADER_SIZE = 24; +export interface Pixel_Frame {width: number; height: number; stride: number; sequence: bigint; pixels: Uint8Array;} export function decode_pixel_frame(buffer: ArrayBuffer): Pixel_Frame { - if (buffer.byteLength < PIXEL_FRAME_HEADER_SIZE) throw new Error("RVP1 frame is shorter than its header"); + if (buffer.byteLength < PIXEL_FRAME_HEADER_SIZE) throw new Error("RVP2 frame is shorter than its header"); const bytes = new Uint8Array(buffer); - if (String.fromCharCode(...bytes.subarray(0, 4)) !== "RVP1") throw new Error("RVP1 magic is invalid"); + if (String.fromCharCode(...bytes.subarray(0, 4)) !== "RVP2") throw new Error("RVP2 magic is invalid"); const view = new DataView(buffer, 0, PIXEL_FRAME_HEADER_SIZE); - const width = view.getUint32(4, true), height = view.getUint32(8, true), stride = view.getUint32(12, true); - if (!width || !height || stride < width * 4) throw new Error("RVP1 dimensions or stride are invalid"); + const width = view.getUint32(4, true), height = view.getUint32(8, true), stride = view.getUint32(12, true), sequence = view.getBigUint64(16, true); + if (!width || !height || stride < width * 4) throw new Error("RVP2 dimensions or stride are invalid"); const payload_size = stride * height; - if (!Number.isSafeInteger(payload_size) || buffer.byteLength < PIXEL_FRAME_HEADER_SIZE + payload_size) throw new Error("RVP1 pixel payload is truncated"); - return {width, height, stride, pixels: bytes.subarray(PIXEL_FRAME_HEADER_SIZE, PIXEL_FRAME_HEADER_SIZE + payload_size)}; + if (!Number.isSafeInteger(payload_size) || buffer.byteLength < PIXEL_FRAME_HEADER_SIZE + payload_size) throw new Error("RVP2 pixel payload is truncated"); + return {width, height, stride, sequence, pixels: bytes.subarray(PIXEL_FRAME_HEADER_SIZE, PIXEL_FRAME_HEADER_SIZE + payload_size)}; } export function pack_pixel_rows(frame: Pixel_Frame): Uint8ClampedArray { const row_size = frame.width * 4; diff --git a/webapp_gallery/src/runtime/client_performance.ts b/webapp_gallery/src/runtime/client_performance.ts index 269f341..b6d58f3 100644 --- a/webapp_gallery/src/runtime/client_performance.ts +++ b/webapp_gallery/src/runtime/client_performance.ts @@ -7,13 +7,13 @@ function rate(samples: Timed_Sample[], now: number): number {trim(samples, now - export class Client_Performance { private transport_samples: Timed_Sample[] = []; private presentation_samples: Timed_Sample[] = []; private display_samples: Timed_Sample[] = []; private round_trip_samples: Timed_Sample[] = []; - private last_animation_frame = 0; private last_pixel_receive = 0; private last_pixel_change = 0; private previous_signature: number | null = null; + private last_animation_frame = 0; private last_pixel_receive = 0; private last_pixel_change = 0; private previous_sequence: bigint | null = null; private changed_frames = 0; private duplicate_frames = 0; private overwritten_frames = 0; private timeout_count = 0; record_animation_frame(now: number): void {if (this.last_animation_frame) this.display_samples.push({timestamp: now, value: Math.max(0, now - this.last_animation_frame)}); this.last_animation_frame = now; trim(this.display_samples, now - 10_000);} - record_pixel(now: number, signature: number, overwritten: boolean): void {this.transport_samples.push({timestamp: now, value: 0}); this.last_pixel_receive = now; if (signature !== this.previous_signature) {this.changed_frames++; this.last_pixel_change = now;} else this.duplicate_frames++; this.previous_signature = signature; if (overwritten) this.overwritten_frames++;} + record_pixel(now: number, sequence: bigint, overwritten: boolean): void {this.transport_samples.push({timestamp: now, value: 0}); this.last_pixel_receive = now; if (sequence !== this.previous_sequence) {this.changed_frames++; this.last_pixel_change = now;} else this.duplicate_frames++; this.previous_sequence = sequence; if (overwritten) this.overwritten_frames++;} record_presentation(now: number): void {this.presentation_samples.push({timestamp: now, value: 0});} record_round_trip(now: number, value: number): void {this.round_trip_samples.push({timestamp: now, value}); trim(this.round_trip_samples, now - 10_000);} record_timeout(): void {this.timeout_count++;} - reset(): void {this.transport_samples=[]; this.presentation_samples=[]; this.display_samples=[]; this.round_trip_samples=[]; this.last_animation_frame=0; this.last_pixel_receive=0; this.last_pixel_change=0; this.previous_signature=null; this.changed_frames=0; this.duplicate_frames=0; this.overwritten_frames=0; this.timeout_count=0;} + reset(): void {this.transport_samples=[]; this.presentation_samples=[]; this.display_samples=[]; this.round_trip_samples=[]; this.last_animation_frame=0; this.last_pixel_receive=0; this.last_pixel_change=0; this.previous_sequence=null; this.changed_frames=0; this.duplicate_frames=0; this.overwritten_frames=0; this.timeout_count=0;} snapshot(now: number, websocket_buffered_bytes: number): Gallery_Client_Performance {trim(this.round_trip_samples, now - 10_000); trim(this.display_samples, now - 10_000); const display=statistics(this.display_samples.map(item=>item.value)); const round_trip=statistics(this.round_trip_samples.map(item=>item.value)); return {transport_fps:rate(this.transport_samples,now),presentation_fps:rate(this.presentation_samples,now),display_interval_latest_ms:this.display_samples.at(-1)?.value??0,display_interval_ms:display.p50,display_interval_average_ms:display.average,display_interval_p95_ms:display.p95,display_interval_p99_ms:display.p99,display_interval_deviation_ms:display.deviation,frame_round_trip_ms:this.round_trip_samples.at(-1)?.value??0,frame_round_trip_average_ms:round_trip.average,frame_round_trip_p95_ms:round_trip.p95,frame_round_trip_p99_ms:round_trip.p99,frame_round_trip_deviation_ms:round_trip.deviation,changed_pixel_frames:this.changed_frames,duplicate_pixel_frames:this.duplicate_frames,overwritten_pixel_frames:this.overwritten_frames,frame_request_timeout_count:this.timeout_count,last_pixel_receive_age_ms:this.last_pixel_receive?Math.max(0,now-this.last_pixel_receive):0,last_pixel_change_age_ms:this.last_pixel_change?Math.max(0,now-this.last_pixel_change):0,websocket_buffered_bytes};} } diff --git a/webapp_gallery/src/runtime/pixel_presenter.ts b/webapp_gallery/src/runtime/pixel_presenter.ts index 2168c3a..7aa5b75 100644 --- a/webapp_gallery/src/runtime/pixel_presenter.ts +++ b/webapp_gallery/src/runtime/pixel_presenter.ts @@ -3,6 +3,6 @@ export class Pixel_Presenter { private latest_frame: Pixel_Frame | null = null; private canvas: HTMLCanvasElement | null = null; private context: CanvasRenderingContext2D | null = null; attach(canvas: HTMLCanvasElement): void {this.canvas=canvas; this.context=canvas.getContext("2d",{alpha:false});} detach(): void {this.canvas=null;this.context=null;this.latest_frame=null;} - accept(buffer: ArrayBuffer): {signature: number; overwritten: boolean} {const frame=decode_pixel_frame(buffer); let hash=(2166136261^frame.width^(frame.height<<16))>>>0; const count=Math.min(4096,frame.pixels.length); for(let index=0;index>>0;} const overwritten=this.latest_frame!==null;this.latest_frame=frame;return {signature:hash,overwritten};} - present(): boolean {const frame=this.latest_frame, canvas=this.canvas, context=this.context;if(!frame||!canvas||!context)return false;this.latest_frame=null;if(canvas.width!==frame.width||canvas.height!==frame.height){canvas.width=frame.width;canvas.height=frame.height;}const pixels=new Uint8ClampedArray(frame.width*frame.height*4);pixels.set(pack_pixel_rows(frame));context.putImageData(new ImageData(pixels,frame.width,frame.height),0,0);return true;} + accept(buffer: ArrayBuffer): {sequence: bigint; overwritten: boolean} {const frame=decode_pixel_frame(buffer);const overwritten=this.latest_frame!==null;this.latest_frame=frame;return {sequence:frame.sequence,overwritten};} + present(): boolean {const frame=this.latest_frame, canvas=this.canvas, context=this.context;if(!frame||!canvas||!context)return false;this.latest_frame=null;if(canvas.width!==frame.width||canvas.height!==frame.height){canvas.width=frame.width;canvas.height=frame.height;}const pixels=new Uint8ClampedArray(pack_pixel_rows(frame));context.putImageData(new ImageData(pixels,frame.width,frame.height),0,0);return true;} } diff --git a/webapp_gallery/src/session/gallery_plot_session.ts b/webapp_gallery/src/session/gallery_plot_session.ts index bb38b24..ad91d57 100644 --- a/webapp_gallery/src/session/gallery_plot_session.ts +++ b/webapp_gallery/src/session/gallery_plot_session.ts @@ -14,7 +14,7 @@ export class Gallery_Plot_Session { private readonly presenter = new Pixel_Presenter(); private readonly client_performance = new Client_Performance(); private readonly frame_controller: Frame_Request_Controller; - private disposed = false; private visible = false; private streams_paused = false; private reconnect_timer: number | undefined; private last_observe_at = 0; private sent_width = 0; private sent_height = 0; private received_frames = 0; + private disposed = false; private visible = false; private streams_paused = false; private last_observe_at = 0; private sent_width = 0; private sent_height = 0; private received_frames = 0; constructor(readonly gallery_case: Gallery_Case, readonly frame_mode: Gallery_Frame_Mode) { this.socket = new Gallery_Socket(gallery_socket_url(), {on_json: raw=>this.receive_json(raw),on_binary: buffer=>this.receive_pixels(buffer),on_state: state=>this.receive_socket_state(state)}); this.frame_controller = new Frame_Request_Controller(()=>this.socket.send("frame"),()=>{this.client_performance.record_timeout();if(this.stream_active())this.request_frame(performance.now());},(now,value)=>this.client_performance.record_round_trip(now,value)); @@ -22,15 +22,15 @@ export class Gallery_Plot_Session { subscribe = (listener: () => void): (() => void) => {this.listeners.add(listener);return()=>this.listeners.delete(listener);}; get_snapshot = (): Gallery_Plot_Snapshot => this.snapshot; private update(patch: Partial): void {this.snapshot={...this.snapshot,...patch};this.listeners.forEach(listener=>listener());} - connect(): void {if(this.disposed)return;window.clearTimeout(this.reconnect_timer);this.socket.connect();} - dispose(): void {this.disposed=true;window.clearTimeout(this.reconnect_timer);this.frame_controller.cancel();if(this.snapshot.ready)this.socket.send("hide");this.socket.close();this.presenter.detach();this.listeners.clear();} + connect(): void {if(this.disposed)return;this.socket.connect();} + dispose(): void {this.disposed=true;this.frame_controller.cancel();if(this.snapshot.ready)this.socket.send("hide");this.socket.close();this.presenter.detach();this.listeners.clear();} attach_canvas(canvas: HTMLCanvasElement): void {this.presenter.attach(canvas);} detach_canvas(): void {this.presenter.detach();} set_activity(visible: boolean, streams_paused: boolean): void {const was_active=this.stream_active();this.visible=visible;this.streams_paused=streams_paused;if(visible&&!this.socket.is_open())this.connect();const active=this.stream_active();if(this.snapshot.ready&&active!==was_active)this.socket.send(active?"show":"hide");if(active)this.request_frame(performance.now());} private stream_active(): boolean {return this.visible&&!this.streams_paused;} - private receive_socket_state(state: "connecting"|"ready"|"error"|"closed"): void {if(this.disposed)return;if(state==="ready"){this.update({connection_state:"ready",protocol_error:""});this.socket.send("gallery_open",{case:this.gallery_case.id,frame_mode:this.frame_mode.id});}else{this.update({connection_state:state,ready:state==="error"?this.snapshot.ready:false});if(state==="closed"&&this.visible)this.reconnect_timer=window.setTimeout(()=>this.connect(),1000);}} + private receive_socket_state(state: "connecting"|"ready"|"error"|"closed"): void {if(this.disposed)return;if(state==="ready"){this.update({connection_state:"ready",protocol_error:""});this.socket.send("gallery_open",{case:this.gallery_case.id,frame_mode:this.frame_mode.id});return;}this.update({connection_state:state,ready:state==="error"?this.snapshot.ready:false});if(state==="closed"&&!this.visible)this.socket.close();} private receive_json(raw: string): void {try{const response=parse_gallery_response(raw);if(!response)return;if(response.type==="error"){this.update({notice:Object.values(response.field_errors??{})[0]??response.message});return;}if(response.type==="observer_state"){this.update({telemetry:response.telemetry,performance_capture:response.telemetry.performance_capture??this.snapshot.performance_capture,frame_count:this.received_frames});return;}if(response.type==="case_state"||response.type==="refresh_state"){const controls=response.controls;this.update({ready:true,controls:build_controls(controls?.resources),actions:response.actions?.data??[],telemetry:response.telemetry??{},render_plan:controls?.render_plan??null,performance_capture:controls?.performance_capture??response.telemetry?.performance_capture??null,notice:response.notice??"",frame_count:this.received_frames,protocol_error:""});if(this.stream_active()){this.socket.send("show");this.request_frame(performance.now());}}}catch(error){this.update({connection_state:"error",protocol_error:error instanceof Error?error.message:"协议解析失败"});this.socket.close();}} - private receive_pixels(buffer: ArrayBuffer): void {try{const now=performance.now();this.frame_controller.receive_frame(now);const accepted=this.presenter.accept(buffer);this.received_frames++;this.client_performance.record_pixel(now,accepted.signature,accepted.overwritten);}catch(error){this.update({connection_state:"error",protocol_error:error instanceof Error?error.message:"像素协议错误"});this.socket.close();}} + private receive_pixels(buffer: ArrayBuffer): void {try{const now=performance.now();this.frame_controller.receive_frame(now);const accepted=this.presenter.accept(buffer);this.received_frames++;this.client_performance.record_pixel(now,accepted.sequence,accepted.overwritten);}catch(error){this.update({connection_state:"error",protocol_error:error instanceof Error?error.message:"像素协议错误"});this.socket.close();}} tick(now: number): void {if(!this.stream_active())return;const presented=this.presenter.present();if(presented)this.client_performance.record_presentation(now);if(this.frame_mode.request_on_animation_frame||(this.frame_mode.request_after_response&&presented))this.request_frame(now);if(this.snapshot.ready&&now-this.last_observe_at>=650){this.last_observe_at=now;this.socket.send("gallery_observe",{client_metrics:this.client_performance.snapshot(now,this.socket.buffered_bytes()) as unknown as Json_Value});}} request_frame(now=performance.now(), explicit=false): boolean {if(!this.snapshot.ready||!this.socket.is_open()||(!explicit&&!this.stream_active())||(explicit&&!this.visible)||(!explicit&&!this.frame_mode.automatic))return false;return this.frame_controller.request_frame(now);} resize(width: number,height: number): void {width=Math.max(240,Math.round(width));height=Math.max(180,Math.round(height));if(width===this.sent_width&&height===this.sent_height)return;this.sent_width=width;this.sent_height=height;if(this.socket.is_open())this.socket.send("resize",{width,height});} diff --git a/webapp_gallery/src/transport/catalog_loader.ts b/webapp_gallery/src/transport/catalog_loader.ts index 548aa43..ac38919 100644 --- a/webapp_gallery/src/transport/catalog_loader.ts +++ b/webapp_gallery/src/transport/catalog_loader.ts @@ -1,25 +1,36 @@ import {parse_gallery_response} from "../protocol/gallery_parser"; import {Gallery_Socket, gallery_socket_url} from "./gallery_socket"; import type {Gallery_Catalog} from "../protocol/gallery_types"; - export function load_gallery_catalog(on_catalog: (catalog: Gallery_Catalog) => void, on_state: (state: string, message: string) => void): () => void { - let disposed = false, completed = false, reconnect_timer: number | undefined; - let socket: Gallery_Socket; - const connect = () => { - socket = new Gallery_Socket(gallery_socket_url(), { - on_state: state => { - if (disposed) return; - if (state === "ready") socket.send("gallery_catalog"); - else if (state === "error") on_state("error", "目录连接错误 · 自动重连"); - else if (state === "closed" && !completed) reconnect_timer = window.setTimeout(connect, 1000); - }, - on_binary: () => undefined, - on_json: raw => { - try {const response = parse_gallery_response(raw); if (response?.type === "catalog") {completed = true; on_catalog(response); on_state("ready", response.navigation.catalog_loaded_text); socket.close();} else if (response?.type === "error") on_state("error", response.message);} catch (error) {on_state("error", error instanceof Error ? error.message : "目录解析失败");} + let disposed = false, completed = false; + const socket = new Gallery_Socket(gallery_socket_url(), { + on_state: state => { + if (disposed || completed) + return; + if (state === "ready") + socket.send("gallery_catalog"); + else if (state === "connecting" || state === "closed") + on_state("connecting", "目录连接中 · 自动重连"); + else if (state === "error") + on_state("error", "目录连接错误 · 自动重连"); + }, + on_binary: () => undefined, + on_json: raw => { + try { + const response = parse_gallery_response(raw); + if (response?.type === "catalog") { + completed = true; + on_catalog(response); + on_state("ready", response.navigation.catalog_loaded_text); + socket.close(); + } else if (response?.type === "error") { + on_state("error", response.message); + } + } catch (error) { + on_state("error", error instanceof Error ? error.message : "目录解析失败"); } - }); - socket.connect(); - }; - connect(); - return () => {disposed = true; window.clearTimeout(reconnect_timer); socket?.close();}; + } + }); + socket.connect(); + return () => {disposed = true; if (!completed) socket.close();}; } diff --git a/webapp_gallery/src/transport/gallery_socket.ts b/webapp_gallery/src/transport/gallery_socket.ts index 9537e4c..ddecb03 100644 --- a/webapp_gallery/src/transport/gallery_socket.ts +++ b/webapp_gallery/src/transport/gallery_socket.ts @@ -1,6 +1,6 @@ +import ReconnectingWebSocket from "reconnecting-websocket"; import {gallery_message} from "../protocol/gallery_messages"; import type {Json_Value} from "../protocol/gallery_types"; - export interface Gallery_Socket_Events {on_json(raw: string): void; on_binary(buffer: ArrayBuffer): void; on_state(state: "connecting" | "ready" | "error" | "closed"): void;} export function gallery_socket_url(location_value: Location = window.location): string { const query = new URLSearchParams(location_value.search); @@ -11,16 +11,19 @@ export function gallery_socket_url(location_value: Location = window.location): return `${location_value.protocol === "https:" ? "wss" : "ws"}://${host}:${port}/renderive/gallery`; } export class Gallery_Socket { - private socket: WebSocket | null = null; + private socket: ReconnectingWebSocket | null = null; constructor(private readonly url: string, private readonly events: Gallery_Socket_Events) {} connect(): void { - if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) return; + if (this.socket) + return; this.events.on_state("connecting"); - const socket = new WebSocket(this.url); this.socket = socket; socket.binaryType = "arraybuffer"; + const socket = new ReconnectingWebSocket(this.url, [], {minReconnectionDelay: 750, maxReconnectionDelay: 8000, reconnectionDelayGrowFactor: 1.5, minUptime: 3000, connectionTimeout: 4000, maxEnqueuedMessages: 0}); + this.socket = socket; + socket.binaryType = "arraybuffer"; socket.addEventListener("open", () => {if (this.socket === socket) this.events.on_state("ready");}); socket.addEventListener("message", event => {if (this.socket !== socket) return; typeof event.data === "string" ? this.events.on_json(event.data) : event.data instanceof ArrayBuffer && this.events.on_binary(event.data);}); socket.addEventListener("error", () => {if (this.socket === socket) this.events.on_state("error");}); - socket.addEventListener("close", () => {if (this.socket === socket) {this.socket = null; this.events.on_state("closed");}}); + socket.addEventListener("close", () => {if (this.socket === socket) this.events.on_state("closed");}); } close(): void {const socket = this.socket; this.socket = null; socket?.close();} send(type: string, payload: Record = {}): boolean {if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return false; this.socket.send(gallery_message(type, payload)); return true;} diff --git a/webapp_gallery/tests/protocol/pixel_frame.test.ts b/webapp_gallery/tests/protocol/pixel_frame.test.ts index c73cae9..26b5b31 100644 --- a/webapp_gallery/tests/protocol/pixel_frame.test.ts +++ b/webapp_gallery/tests/protocol/pixel_frame.test.ts @@ -1,3 +1,3 @@ import {describe,expect,it} from "vitest";import {decode_pixel_frame,pack_pixel_rows} from "../../src/protocol/pixel_frame"; -function frame(width:number,height:number,stride:number,payload:number[]):ArrayBuffer {const buffer=new ArrayBuffer(16+payload.length);const bytes=new Uint8Array(buffer);bytes.set([82,86,80,49]);const view=new DataView(buffer);view.setUint32(4,width,true);view.setUint32(8,height,true);view.setUint32(12,stride,true);bytes.set(payload,16);return buffer;} -describe("RVP1 decoder",()=>{it("decodes packed RGBA",()=>{const result=decode_pixel_frame(frame(1,1,4,[1,2,3,4]));expect(result).toMatchObject({width:1,height:1,stride:4});expect([...pack_pixel_rows(result)]).toEqual([1,2,3,4]);});it("removes per-row padding",()=>{const result=decode_pixel_frame(frame(1,2,8,[1,2,3,4,90,91,92,93,5,6,7,8,94,95,96,97]));expect([...pack_pixel_rows(result)]).toEqual([1,2,3,4,5,6,7,8]);});it("rejects invalid magic, stride, and truncated payload",()=>{expect(()=>decode_pixel_frame(frame(1,1,3,[1,2,3]))).toThrow();const truncated=frame(2,2,8,[1,2,3]);expect(()=>decode_pixel_frame(truncated)).toThrow();const magic=frame(1,1,4,[1,2,3,4]);new Uint8Array(magic)[0]=0;expect(()=>decode_pixel_frame(magic)).toThrow();});}); +function frame(width:number,height:number,stride:number,payload:number[],sequence=7n):ArrayBuffer {const buffer=new ArrayBuffer(24+payload.length);const bytes=new Uint8Array(buffer);bytes.set([82,86,80,50]);const view=new DataView(buffer);view.setUint32(4,width,true);view.setUint32(8,height,true);view.setUint32(12,stride,true);view.setBigUint64(16,sequence,true);bytes.set(payload,24);return buffer;} +describe("RVP2 decoder",()=>{it("decodes packed RGBA and sequence",()=>{const result=decode_pixel_frame(frame(1,1,4,[1,2,3,4],42n));expect(result).toMatchObject({width:1,height:1,stride:4,sequence:42n});expect([...pack_pixel_rows(result)]).toEqual([1,2,3,4]);});it("removes per-row padding",()=>{const result=decode_pixel_frame(frame(1,2,8,[1,2,3,4,90,91,92,93,5,6,7,8,94,95,96,97]));expect([...pack_pixel_rows(result)]).toEqual([1,2,3,4,5,6,7,8]);});it("rejects invalid magic, stride, and truncated payload",()=>{expect(()=>decode_pixel_frame(frame(1,1,3,[1,2,3]))).toThrow();const truncated=frame(2,2,8,[1,2,3]);expect(()=>decode_pixel_frame(truncated)).toThrow();const magic=frame(1,1,4,[1,2,3,4]);new Uint8Array(magic)[0]=0;expect(()=>decode_pixel_frame(magic)).toThrow();});}); diff --git a/webapp_gallery/tests/runtime/client_performance.test.ts b/webapp_gallery/tests/runtime/client_performance.test.ts index 124a2e7..3851b6f 100644 --- a/webapp_gallery/tests/runtime/client_performance.test.ts +++ b/webapp_gallery/tests/runtime/client_performance.test.ts @@ -1,2 +1,2 @@ import {describe,expect,it} from "vitest";import {Client_Performance} from "../../src/runtime/client_performance"; -describe("client performance",()=>{it("tracks change, duplicate and overwrite independently",()=>{const performance=new Client_Performance();performance.record_animation_frame(10);performance.record_animation_frame(26);performance.record_pixel(30,1,false);performance.record_pixel(40,1,true);performance.record_pixel(50,2,false);performance.record_presentation(55);performance.record_round_trip(60,12);const value=performance.snapshot(70,9);expect(value.changed_pixel_frames).toBe(2);expect(value.duplicate_pixel_frames).toBe(1);expect(value.overwritten_pixel_frames).toBe(1);expect(value.display_interval_latest_ms).toBe(16);expect(value.websocket_buffered_bytes).toBe(9);});}); +describe("client performance",()=>{it("tracks change, duplicate and overwrite independently",()=>{const performance=new Client_Performance();performance.record_animation_frame(10);performance.record_animation_frame(26);performance.record_pixel(30,1n,false);performance.record_pixel(40,1n,true);performance.record_pixel(50,2n,false);performance.record_presentation(55);performance.record_round_trip(60,12);const value=performance.snapshot(70,9);expect(value.changed_pixel_frames).toBe(2);expect(value.duplicate_pixel_frames).toBe(1);expect(value.overwritten_pixel_frames).toBe(1);expect(value.display_interval_latest_ms).toBe(16);expect(value.websocket_buffered_bytes).toBe(9);});}); diff --git a/webapp_gallery/tsconfig.app.tsbuildinfo b/webapp_gallery/tsconfig.app.tsbuildinfo index d4f2310..b7b2d9a 100644 --- a/webapp_gallery/tsconfig.app.tsbuildinfo +++ b/webapp_gallery/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/theme.ts","./src/capture/capture_frames.tsx","./src/capture/capture_panel.tsx","./src/capture/capture_sessions.tsx","./src/capture/capture_toolbar.tsx","./src/capture/frame_summary.tsx","./src/capture/node_detail.tsx","./src/capture/plan_comparison.tsx","./src/capture/worker_timeline.tsx","./src/common/copy_button.tsx","./src/common/empty_state.tsx","./src/common/json_viewer.tsx","./src/common/metric_grid.tsx","./src/common/metric_value.tsx","./src/common/status_chip.tsx","./src/dag/dag_layout.ts","./src/dag/dag_legend.tsx","./src/dag/dag_model.ts","./src/dag/dag_node.tsx","./src/dag/dag_types.ts","./src/dag/render_dag.tsx","./src/gallery/category_filter.tsx","./src/gallery/frame_mode_tabs.tsx","./src/gallery/gallery_page.tsx","./src/gallery/gallery_summary.tsx","./src/gallery/gallery_toolbar.tsx","./src/gallery/plot_card.tsx","./src/hooks/use_element_size.ts","./src/hooks/use_gallery_catalog.ts","./src/hooks/use_plot_session.ts","./src/inspector/actions_panel.tsx","./src/inspector/control_field.tsx","./src/inspector/controls_panel.tsx","./src/inspector/inspector_panel.tsx","./src/inspector/inspector_tabs.tsx","./src/inspector/observer_panel.tsx","./src/inspector/performance_panel.tsx","./src/plot/kernel_observer_summary.tsx","./src/plot/performance_strip.tsx","./src/plot/plot_canvas.tsx","./src/plot/plot_status.tsx","./src/protocol/format.ts","./src/protocol/gallery_descriptor.ts","./src/protocol/gallery_messages.ts","./src/protocol/gallery_parser.ts","./src/protocol/gallery_types.ts","./src/protocol/pixel_frame.ts","./src/runtime/client_performance.ts","./src/runtime/frame_request_controller.ts","./src/runtime/pixel_presenter.ts","./src/session/gallery_plot_session.ts","./src/session/gallery_plot_snapshot.ts","./src/transport/catalog_loader.ts","./src/transport/gallery_socket.ts","./tests/setup.ts","./tests/components/control_field.test.tsx","./tests/dag/dag_model.test.ts","./tests/e2e/gallery.spec.ts","./tests/protocol/gallery_parser.test.ts","./tests/protocol/pixel_frame.test.ts","./tests/runtime/client_performance.test.ts","./tests/runtime/frame_request_controller.test.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/theme.ts","./src/capture/capture_frames.tsx","./src/capture/capture_panel.tsx","./src/capture/capture_sessions.tsx","./src/capture/capture_toolbar.tsx","./src/capture/frame_summary.tsx","./src/capture/node_detail.tsx","./src/capture/perfetto_export.ts","./src/capture/plan_comparison.tsx","./src/capture/worker_timeline.tsx","./src/common/copy_button.tsx","./src/common/dashboard_field_grid.tsx","./src/common/echarts_view.tsx","./src/common/empty_state.tsx","./src/common/json_viewer.tsx","./src/common/metric_grid.tsx","./src/common/metric_value.tsx","./src/common/status_chip.tsx","./src/dag/dag_layout.ts","./src/dag/dag_legend.tsx","./src/dag/dag_model.ts","./src/dag/dag_node.tsx","./src/dag/dag_types.ts","./src/dag/render_dag.tsx","./src/gallery/category_filter.tsx","./src/gallery/frame_mode_tabs.tsx","./src/gallery/gallery_page.tsx","./src/gallery/gallery_summary.tsx","./src/gallery/gallery_toolbar.tsx","./src/gallery/plot_card.tsx","./src/hooks/use_element_size.ts","./src/hooks/use_gallery_catalog.ts","./src/hooks/use_plot_session.ts","./src/inspector/actions_panel.tsx","./src/inspector/control_field.tsx","./src/inspector/controls_panel.tsx","./src/inspector/inspector_panel.tsx","./src/inspector/inspector_tabs.tsx","./src/inspector/observer_panel.tsx","./src/inspector/performance_panel.tsx","./src/plot/kernel_observer_summary.tsx","./src/plot/performance_strip.tsx","./src/plot/plot_canvas.tsx","./src/plot/plot_status.tsx","./src/protocol/format.ts","./src/protocol/gallery_descriptor.ts","./src/protocol/gallery_messages.ts","./src/protocol/gallery_parser.ts","./src/protocol/gallery_types.ts","./src/protocol/pixel_frame.ts","./src/runtime/client_performance.ts","./src/runtime/frame_request_controller.ts","./src/runtime/pixel_presenter.ts","./src/session/gallery_plot_session.ts","./src/session/gallery_plot_snapshot.ts","./src/transport/catalog_loader.ts","./src/transport/gallery_socket.ts","./tests/setup.ts","./tests/components/control_field.test.tsx","./tests/dag/dag_model.test.ts","./tests/e2e/gallery.spec.ts","./tests/protocol/gallery_parser.test.ts","./tests/protocol/pixel_frame.test.ts","./tests/runtime/client_performance.test.ts","./tests/runtime/frame_request_controller.test.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file