From 5b64b4362ef941e11f51577117c8cc2c0b3fc16d Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 21:45:25 +0200 Subject: [PATCH 1/8] fix(inspector): timeline time uses footer time table, not avg fps The timeline window derived frame time as frame_index / (total_frames / duration_seconds). On captures with recording stalls the average fps is far below the live rate, so displayed times drift by the total stalled time (observed: a frame stamped 13:54 wall clock displayed as ~6:47 on a capture with 643s of stalls at ~29fps live rate). Map frame -> elapsed seconds through the footer ReplayTimeData table instead: created_utc preferred, game_time fallback, walking back to the nearest stamped frame for frames recorded without a value. Files without a time table keep the old linear mapping. Applies to the current-time readout and the strip hover tooltip. --- .../include/services/timeline_view_service.h | 13 ++++ .../src/services/timeline_view_service.cpp | 72 +++++++++++++++++++ .../inspector/src/windows/timeline_window.cpp | 30 ++++---- 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/tools/inspector/include/services/timeline_view_service.h b/tools/inspector/include/services/timeline_view_service.h index 6393153..902c6aa 100644 --- a/tools/inspector/include/services/timeline_view_service.h +++ b/tools/inspector/include/services/timeline_view_service.h @@ -1,5 +1,7 @@ #pragma once +#include "vtx/common/vtx_types.h" + namespace VtxServices { struct ClockTime { @@ -38,8 +40,19 @@ namespace VtxServices { static float ComputePlaybackFps(int total_frames, float duration_seconds, float fallback_fps = 30.0f); static ClockTime ToClockTime(float total_seconds); static DurationSplit SplitDuration(float total_seconds); + + // Elapsed seconds since capture start for `frame`, read from the footer + // per-frame time table (created_utc preferred, game_time fallback). + // Falls back to linear frame/avg-fps mapping when the table is absent, + // so captures with recording stalls resolve to wall-clock positions + // instead of drifting by the stalled time. + static float FrameToElapsedSeconds(int frame, const VTX::ReplayTimeData& times, int total_frames, + float duration_seconds, float fallback_fps = 30.0f); + static TimelineClockSpan BuildTimelineClockSpan(int current_frame, int total_frames, float duration_seconds, float fallback_fps = 30.0f); + static TimelineClockSpan BuildTimelineClockSpan(int current_frame, int total_frames, float duration_seconds, + const VTX::ReplayTimeData& times, float fallback_fps = 30.0f); static int ClampFrame(int frame, int total_frames); static float ComputeItemFullWidth(const TimelineBarState& bar_state); static float ComputeCenteredScroll(int frame_index, float item_full_width, float view_width); diff --git a/tools/inspector/src/services/timeline_view_service.cpp b/tools/inspector/src/services/timeline_view_service.cpp index a9e08ac..82d623c 100644 --- a/tools/inspector/src/services/timeline_view_service.cpp +++ b/tools/inspector/src/services/timeline_view_service.cpp @@ -1,6 +1,53 @@ #include "services/timeline_view_service.h" #include +#include +#include + +namespace { + + constexpr double kTicksPerSecond = 10'000'000.0; + + // First nonzero tick in the table; 0 when the table has no usable entries. + uint64_t FirstNonZeroTick(const std::vector& ticks) { + for (const uint64_t tick : ticks) { + if (tick != 0) { + return tick; + } + } + return 0; + } + + // Tick for `frame`, walking back to the nearest earlier stamped frame when + // the entry is missing/zero (frames without a recorded value store 0). + uint64_t TickAtOrBefore(const std::vector& ticks, int frame) { + if (ticks.empty() || frame < 0) { + return 0; + } + int index = std::min(frame, static_cast(ticks.size()) - 1); + for (; index >= 0; --index) { + if (ticks[index] != 0) { + return ticks[index]; + } + } + return 0; + } + + // Elapsed seconds for `frame` from a per-frame tick table (100ns ticks). + // Returns a negative value when the table cannot answer for this frame. + float ElapsedFromTickTable(const std::vector& ticks, int frame) { + const uint64_t base_tick = FirstNonZeroTick(ticks); + if (base_tick == 0) { + return -1.0f; + } + const uint64_t frame_tick = TickAtOrBefore(ticks, frame); + if (frame_tick == 0 || frame_tick < base_tick) { + return -1.0f; + } + return static_cast(static_cast(frame_tick - base_tick) / kTicksPerSecond); + } + +} // namespace namespace VtxServices { @@ -24,6 +71,20 @@ namespace VtxServices { return DurationSplit {.minutes = minutes, .seconds = seconds}; } + float TimelineViewService::FrameToElapsedSeconds(int frame, const VTX::ReplayTimeData& times, int total_frames, + float duration_seconds, float fallback_fps) { + const float utc_elapsed = ElapsedFromTickTable(times.created_utc, frame); + if (utc_elapsed >= 0.0f) { + return utc_elapsed; + } + const float game_elapsed = ElapsedFromTickTable(times.game_time, frame); + if (game_elapsed >= 0.0f) { + return game_elapsed; + } + const float fps = ComputePlaybackFps(total_frames, duration_seconds, fallback_fps); + return static_cast(frame) / fps; + } + TimelineClockSpan TimelineViewService::BuildTimelineClockSpan(int current_frame, int total_frames, float duration_seconds, float fallback_fps) { const float fps = ComputePlaybackFps(total_frames, duration_seconds, fallback_fps); @@ -32,6 +93,17 @@ namespace VtxServices { .current = ToClockTime(current_time_sec), .total = ToClockTime(duration_seconds), .fps = fps}; } + TimelineClockSpan TimelineViewService::BuildTimelineClockSpan(int current_frame, int total_frames, + float duration_seconds, + const VTX::ReplayTimeData& times, + float fallback_fps) { + const float fps = ComputePlaybackFps(total_frames, duration_seconds, fallback_fps); + const float current_time_sec = + FrameToElapsedSeconds(current_frame, times, total_frames, duration_seconds, fallback_fps); + return TimelineClockSpan { + .current = ToClockTime(current_time_sec), .total = ToClockTime(duration_seconds), .fps = fps}; + } + int TimelineViewService::ClampFrame(int frame, int total_frames) { if (total_frames <= 0) { return 0; diff --git a/tools/inspector/src/windows/timeline_window.cpp b/tools/inspector/src/windows/timeline_window.cpp index 9bc9943..c913db2 100644 --- a/tools/inspector/src/windows/timeline_window.cpp +++ b/tools/inspector/src/windows/timeline_window.cpp @@ -53,14 +53,15 @@ namespace { } // Builds hover payload (frame index + derived time labels) for tooltip rendering. - TimelineHoverInfo BuildHoverInfo(int hovered_frame, int total_frames, float duration_seconds) { + TimelineHoverInfo BuildHoverInfo(int hovered_frame, int total_frames, float duration_seconds, + const VTX::ReplayTimeData& times) { TimelineHoverInfo info; if (hovered_frame < 0) { return info; } - const float fps = VtxServices::TimelineViewService::ComputePlaybackFps(total_frames, duration_seconds); - const float frame_time_sec = static_cast(hovered_frame) / fps; + const float frame_time_sec = VtxServices::TimelineViewService::FrameToElapsedSeconds( + hovered_frame, times, total_frames, duration_seconds); info.has_hover = true; info.frame_index = hovered_frame; info.frame_time_seconds = frame_time_sec; @@ -70,10 +71,10 @@ namespace { // Builds strip bars, hover state, and optional scroll requests for current frame. TimelineStripViewModel BuildStripViewModel(VtxServices::TimelineBarState& timeline_bar_state, int total_frames, - int current_frame, float duration_seconds, float scroll_x, - float view_width, float origin_x, float origin_y, float timeline_height, - bool ctrl_down, float wheel, bool is_window_hovered, float mouse_x, - float mouse_y) { + int current_frame, float duration_seconds, + const VTX::ReplayTimeData& times, float scroll_x, float view_width, + float origin_x, float origin_y, float timeline_height, bool ctrl_down, + float wheel, bool is_window_hovered, float mouse_x, float mouse_y) { TimelineStripViewModel view_model; float working_scroll_x = scroll_x; @@ -118,7 +119,7 @@ namespace { }); } - view_model.hover_info = BuildHoverInfo(hovered_frame_idx, total_frames, duration_seconds); + view_model.hover_info = BuildHoverInfo(hovered_frame_idx, total_frames, duration_seconds, times); return view_model; } @@ -151,7 +152,8 @@ void TimelineWindow::DrawTimeAndFrameInfo(int total_frames, float duration) { (void)duration; if (ImGui::BeginChild("InfoPanel", ImVec2(0, 45), true)) { const auto time_span = VtxServices::TimelineViewService::BuildTimelineClockSpan( - inspector_session_->GetCurrentFrame(), total_frames, inspector_session_->GetFooter().duration_seconds); + inspector_session_->GetCurrentFrame(), total_frames, inspector_session_->GetFooter().duration_seconds, + inspector_session_->GetFooter().times); ImGui::AlignTextToFramePadding(); ImGui::Text("Time: %02d:%02d / %02d:%02d", time_span.current.minutes, time_span.current.seconds, @@ -198,11 +200,11 @@ void TimelineWindow::DrawFrameStripTimeline(int total_frames) { // Step 1: Build strip model from current viewport + input state. const ImVec2 p = ImGui::GetCursorScreenPos(); const auto mouse_pos = ImGui::GetMousePos(); - const auto strip_vm = - BuildStripViewModel(timeline_bar_state_, total_frames, inspector_session_->GetCurrentFrame(), - inspector_session_->GetFooter().duration_seconds, ImGui::GetScrollX(), - ImGui::GetWindowWidth(), p.x, p.y, timeline_height, ImGui::GetIO().KeyCtrl, - ImGui::GetIO().MouseWheel, ImGui::IsWindowHovered(), mouse_pos.x, mouse_pos.y); + const auto strip_vm = BuildStripViewModel( + timeline_bar_state_, total_frames, inspector_session_->GetCurrentFrame(), + inspector_session_->GetFooter().duration_seconds, inspector_session_->GetFooter().times, + ImGui::GetScrollX(), ImGui::GetWindowWidth(), p.x, p.y, timeline_height, ImGui::GetIO().KeyCtrl, + ImGui::GetIO().MouseWheel, ImGui::IsWindowHovered(), mouse_pos.x, mouse_pos.y); if (strip_vm.request_scroll) { ImGui::SetScrollX(strip_vm.desired_scroll_x); timeline_bar_state_.last_tracked_frame = inspector_session_->GetCurrentFrame(); From 5e897c46cf3d028d2b98338a0386167de1f70d1d Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 21:45:29 +0200 Subject: [PATCH 2/8] chore: ignore build-inspector/ --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4b1582d..d991c32 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,4 @@ __pycache__/ # OS Thumbs.db Desktop.ini -.DS_Store \ No newline at end of file +.DS_Storebuild-inspector/ From f0f7a695c8cce3e4148f33a5afae4cf22130cce5 Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 21:58:45 +0200 Subject: [PATCH 3/8] feat(inspector): paint recording gaps red on the timeline strip BuildDroppedFrameMap flags every frame whose wall-clock span from the previous stamped frame (footer created_utc, game_time fallback) exceeds 1.5x the expected interval, and estimates the missing frame count per gap. The timeline strip paints flagged frames red, the info panel shows a gap/missing-frame summary plus an editable "Drop FPS" expected rate (default 30), and the hover tooltip reports the gap duration and estimated missing frames. Files without a time table flag nothing. --- .../include/services/timeline_view_service.h | 18 ++++++ .../include/windows/timeline_window.h | 11 ++++ .../src/services/timeline_view_service.cpp | 47 ++++++++++++++ .../inspector/src/windows/timeline_window.cpp | 62 ++++++++++++++++--- 4 files changed, 130 insertions(+), 8 deletions(-) diff --git a/tools/inspector/include/services/timeline_view_service.h b/tools/inspector/include/services/timeline_view_service.h index 902c6aa..a2a765e 100644 --- a/tools/inspector/include/services/timeline_view_service.h +++ b/tools/inspector/include/services/timeline_view_service.h @@ -35,6 +35,18 @@ namespace VtxServices { float total_content_width = 0.0f; }; + // Per-frame recording-gap map derived from the footer time table against an + // expected capture rate. A frame is flagged when the wall-clock span from the + // previous stamped frame exceeds 1.5x the expected interval; every frame + // inside that span carries the gap duration and the estimated missing count. + struct DroppedFrameMap { + std::vector flagged; // 1 = frame sits in a recording gap + std::vector gap_ms; // wall-clock gap the frame belongs to (0 when none) + std::vector missing; // estimated frames missing in that gap + int gap_count = 0; // number of distinct gaps + int64_t total_missing = 0; // estimated frames missing across all gaps + }; + class TimelineViewService { public: static float ComputePlaybackFps(int total_frames, float duration_seconds, float fallback_fps = 30.0f); @@ -49,6 +61,12 @@ namespace VtxServices { static float FrameToElapsedSeconds(int frame, const VTX::ReplayTimeData& times, int total_frames, float duration_seconds, float fallback_fps = 30.0f); + // Builds the recording-gap map for the whole replay from the footer time + // table (created_utc preferred, game_time fallback). Returns an empty map + // (no frames flagged) when the file carries no usable time table. + static DroppedFrameMap BuildDroppedFrameMap(const VTX::ReplayTimeData& times, int total_frames, + float expected_fps); + static TimelineClockSpan BuildTimelineClockSpan(int current_frame, int total_frames, float duration_seconds, float fallback_fps = 30.0f); static TimelineClockSpan BuildTimelineClockSpan(int current_frame, int total_frames, float duration_seconds, diff --git a/tools/inspector/include/windows/timeline_window.h b/tools/inspector/include/windows/timeline_window.h index 4c3c960..b0d3d9d 100644 --- a/tools/inspector/include/windows/timeline_window.h +++ b/tools/inspector/include/windows/timeline_window.h @@ -18,7 +18,18 @@ class TimelineWindow : public ImGuiWindow { //dynamic timeline void DrawFrameStripTimeline(int total_frames); +private: + // Returns the recording-gap map for the loaded replay, rebuilding the cache + // when the expected fps or the loaded file changed. + const VtxServices::DroppedFrameMap& GetDroppedFrameMap(int total_frames); + private: VtxServices::TimelineBarState timeline_bar_state_ {}; std::shared_ptr inspector_session_; + + float drop_detect_fps_ = 30.0f; + VtxServices::DroppedFrameMap dropped_map_ {}; + float dropped_map_fps_ = 0.0f; + int dropped_map_frames_ = -1; + size_t dropped_map_stamp_count_ = 0; }; diff --git a/tools/inspector/src/services/timeline_view_service.cpp b/tools/inspector/src/services/timeline_view_service.cpp index 82d623c..78e8aed 100644 --- a/tools/inspector/src/services/timeline_view_service.cpp +++ b/tools/inspector/src/services/timeline_view_service.cpp @@ -93,6 +93,53 @@ namespace VtxServices { .current = ToClockTime(current_time_sec), .total = ToClockTime(duration_seconds), .fps = fps}; } + DroppedFrameMap TimelineViewService::BuildDroppedFrameMap(const VTX::ReplayTimeData& times, int total_frames, + float expected_fps) { + DroppedFrameMap map; + if (total_frames <= 0 || expected_fps <= 0.0f) { + return map; + } + map.flagged.assign(static_cast(total_frames), 0); + map.gap_ms.assign(static_cast(total_frames), 0.0f); + map.missing.assign(static_cast(total_frames), 0); + + const std::vector& ticks = + FirstNonZeroTick(times.created_utc) != 0 ? times.created_utc : times.game_time; + if (FirstNonZeroTick(ticks) == 0) { + return map; + } + + const double expected_interval_ms = 1000.0 / static_cast(expected_fps); + const int last_index = std::min(total_frames, static_cast(ticks.size())) - 1; + + int prev_stamped = -1; + for (int frame = 0; frame <= last_index; ++frame) { + if (ticks[static_cast(frame)] == 0) { + continue; + } + if (prev_stamped >= 0) { + const int span_frames = frame - prev_stamped; + const double actual_ms = + static_cast(ticks[static_cast(frame)] - ticks[static_cast(prev_stamped)]) / + 10'000.0; + const double expected_ms = expected_interval_ms * span_frames; + if (actual_ms > expected_ms * 1.5) { + const int missing = + std::max(1, static_cast(actual_ms / expected_interval_ms + 0.5) - span_frames); + ++map.gap_count; + map.total_missing += missing; + for (int i = prev_stamped + 1; i <= frame; ++i) { + map.flagged[static_cast(i)] = 1; + map.gap_ms[static_cast(i)] = static_cast(actual_ms); + map.missing[static_cast(i)] = missing; + } + } + } + prev_stamped = frame; + } + return map; + } + TimelineClockSpan TimelineViewService::BuildTimelineClockSpan(int current_frame, int total_frames, float duration_seconds, const VTX::ReplayTimeData& times, diff --git a/tools/inspector/src/windows/timeline_window.cpp b/tools/inspector/src/windows/timeline_window.cpp index c913db2..32a12c0 100644 --- a/tools/inspector/src/windows/timeline_window.cpp +++ b/tools/inspector/src/windows/timeline_window.cpp @@ -14,6 +14,9 @@ namespace { int frame_index = -1; float frame_time_seconds = 0.0f; VtxServices::ClockTime frame_time_clock; + bool in_gap = false; + float gap_ms = 0.0f; + int gap_missing = 0; }; struct TimelineFrameBarView { @@ -24,6 +27,7 @@ namespace { float y_bottom = 0.0f; bool is_current = false; bool is_hovered = false; + bool is_dropped = false; }; struct TimelineStripViewModel { @@ -54,7 +58,8 @@ namespace { // Builds hover payload (frame index + derived time labels) for tooltip rendering. TimelineHoverInfo BuildHoverInfo(int hovered_frame, int total_frames, float duration_seconds, - const VTX::ReplayTimeData& times) { + const VTX::ReplayTimeData& times, + const VtxServices::DroppedFrameMap& dropped_map) { TimelineHoverInfo info; if (hovered_frame < 0) { return info; @@ -66,15 +71,23 @@ namespace { info.frame_index = hovered_frame; info.frame_time_seconds = frame_time_sec; info.frame_time_clock = VtxServices::TimelineViewService::ToClockTime(frame_time_sec); + const size_t idx = static_cast(hovered_frame); + if (idx < dropped_map.flagged.size() && dropped_map.flagged[idx] != 0) { + info.in_gap = true; + info.gap_ms = dropped_map.gap_ms[idx]; + info.gap_missing = dropped_map.missing[idx]; + } return info; } // Builds strip bars, hover state, and optional scroll requests for current frame. TimelineStripViewModel BuildStripViewModel(VtxServices::TimelineBarState& timeline_bar_state, int total_frames, int current_frame, float duration_seconds, - const VTX::ReplayTimeData& times, float scroll_x, float view_width, - float origin_x, float origin_y, float timeline_height, bool ctrl_down, - float wheel, bool is_window_hovered, float mouse_x, float mouse_y) { + const VTX::ReplayTimeData& times, + const VtxServices::DroppedFrameMap& dropped_map, float scroll_x, + float view_width, float origin_x, float origin_y, float timeline_height, + bool ctrl_down, float wheel, bool is_window_hovered, float mouse_x, + float mouse_y) { TimelineStripViewModel view_model; float working_scroll_x = scroll_x; @@ -116,10 +129,12 @@ namespace { .y_bottom = y_bottom, .is_current = i == current_frame, .is_hovered = i == hovered_frame_idx, + .is_dropped = static_cast(i) < dropped_map.flagged.size() && + dropped_map.flagged[static_cast(i)] != 0, }); } - view_model.hover_info = BuildHoverInfo(hovered_frame_idx, total_frames, duration_seconds, times); + view_model.hover_info = BuildHoverInfo(hovered_frame_idx, total_frames, duration_seconds, times, dropped_map); return view_model; } @@ -159,6 +174,20 @@ void TimelineWindow::DrawTimeAndFrameInfo(int total_frames, float duration) { ImGui::Text("Time: %02d:%02d / %02d:%02d", time_span.current.minutes, time_span.current.seconds, time_span.total.minutes, time_span.total.seconds); + // Expected capture rate for the recording-gap (red bar) detection. + ImGui::SameLine(); + ImGui::SetNextItemWidth(70.0f); + if (ImGui::InputFloat("Drop FPS", &drop_detect_fps_, 0.0f, 0.0f, "%.1f", + ImGuiInputTextFlags_EnterReturnsTrue)) { + drop_detect_fps_ = std::clamp(drop_detect_fps_, 1.0f, 1000.0f); + } + const auto& dropped_map = GetDroppedFrameMap(total_frames); + if (dropped_map.gap_count > 0) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.9f, 0.35f, 0.35f, 1.0f), "%d gaps, ~%lld frames missing", dropped_map.gap_count, + static_cast(dropped_map.total_missing)); + } + ImGui::SameLine(ImGui::GetWindowWidth() - 320.0f); ImGui::Text("Frame: %d / %d", inspector_session_->GetCurrentFrame(), total_frames); @@ -185,6 +214,19 @@ void TimelineWindow::DrawTimelineSlider(int total_frames, float duration_seconds inspector_session_->SetScrubbingTimeline(ImGui::IsItemActive()); } +const VtxServices::DroppedFrameMap& TimelineWindow::GetDroppedFrameMap(int total_frames) { + const auto& times = inspector_session_->GetFooter().times; + const size_t stamp_count = times.created_utc.size() + times.game_time.size(); + if (dropped_map_fps_ != drop_detect_fps_ || dropped_map_frames_ != total_frames || + dropped_map_stamp_count_ != stamp_count) { + dropped_map_ = VtxServices::TimelineViewService::BuildDroppedFrameMap(times, total_frames, drop_detect_fps_); + dropped_map_fps_ = drop_detect_fps_; + dropped_map_frames_ = total_frames; + dropped_map_stamp_count_ = stamp_count; + } + return dropped_map_; +} + // Renders zoomable frame bars and hover/click seek interactions. void TimelineWindow::DrawFrameStripTimeline(int total_frames) { const float avail_width = ImGui::GetContentRegionAvail().x; @@ -203,8 +245,8 @@ void TimelineWindow::DrawFrameStripTimeline(int total_frames) { const auto strip_vm = BuildStripViewModel( timeline_bar_state_, total_frames, inspector_session_->GetCurrentFrame(), inspector_session_->GetFooter().duration_seconds, inspector_session_->GetFooter().times, - ImGui::GetScrollX(), ImGui::GetWindowWidth(), p.x, p.y, timeline_height, ImGui::GetIO().KeyCtrl, - ImGui::GetIO().MouseWheel, ImGui::IsWindowHovered(), mouse_pos.x, mouse_pos.y); + GetDroppedFrameMap(total_frames), ImGui::GetScrollX(), ImGui::GetWindowWidth(), p.x, p.y, timeline_height, + ImGui::GetIO().KeyCtrl, ImGui::GetIO().MouseWheel, ImGui::IsWindowHovered(), mouse_pos.x, mouse_pos.y); if (strip_vm.request_scroll) { ImGui::SetScrollX(strip_vm.desired_scroll_x); timeline_bar_state_.last_tracked_frame = inspector_session_->GetCurrentFrame(); @@ -217,7 +259,7 @@ void TimelineWindow::DrawFrameStripTimeline(int total_frames) { IM_COL32(30, 30, 30, 255)); for (const auto& bar : strip_vm.bars) { - ImU32 bar_color = IM_COL32(128, 128, 128, 255); + ImU32 bar_color = bar.is_dropped ? IM_COL32(220, 60, 60, 255) : IM_COL32(128, 128, 128, 255); if (bar.is_current) { child_draw_list->AddRectFilled(ImVec2(bar.x_start - 1.0f, p.y), ImVec2(bar.x_end + 1.0f, bar.y_bottom), IM_COL32(255, 255, 255, 100)); @@ -234,6 +276,10 @@ void TimelineWindow::DrawFrameStripTimeline(int total_frames) { ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Frame: %d", hover_info.frame_index); ImGui::Text("Time: %02d:%02d (%.2fs)", hover_info.frame_time_clock.minutes, hover_info.frame_time_clock.seconds, hover_info.frame_time_seconds); + if (hover_info.in_gap) { + ImGui::TextColored(ImVec4(0.9f, 0.35f, 0.35f, 1.0f), "Gap: %.2fs (~%d frames missing)", + hover_info.gap_ms / 1000.0f, hover_info.gap_missing); + } ImGui::Separator(); ImGui::TextDisabled("(Click to seek)"); ImGui::EndTooltip(); From 286fb4caa528f6c2a71c5f9b8df2cdbc3514ca30 Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 22:01:45 +0200 Subject: [PATCH 4/8] feat(inspector): paint recording gaps on the main slider, time-proportional Each gap from the dropped-frame map is overlaid on the slider track as a red band positioned and sized by wall clock (start_seconds / duration), so band width reads as gap duration - unlike the frame strip, where a two-minute stall spans only a few bars. --- .../include/services/timeline_view_service.h | 13 ++++++++++-- .../src/services/timeline_view_service.cpp | 8 +++++++ .../inspector/src/windows/timeline_window.cpp | 21 ++++++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/tools/inspector/include/services/timeline_view_service.h b/tools/inspector/include/services/timeline_view_service.h index a2a765e..2ea823e 100644 --- a/tools/inspector/include/services/timeline_view_service.h +++ b/tools/inspector/include/services/timeline_view_service.h @@ -39,12 +39,21 @@ namespace VtxServices { // expected capture rate. A frame is flagged when the wall-clock span from the // previous stamped frame exceeds 1.5x the expected interval; every frame // inside that span carries the gap duration and the estimated missing count. + // One recording gap as a wall-clock span, for time-proportional painting. + struct DroppedFrameGap { + int first_frame = 0; // first flagged frame + int last_frame = 0; // stamped frame that ends the gap + float start_seconds = 0.0f; // elapsed seconds where the gap begins + float duration_ms = 0.0f; + }; + struct DroppedFrameMap { std::vector flagged; // 1 = frame sits in a recording gap std::vector gap_ms; // wall-clock gap the frame belongs to (0 when none) std::vector missing; // estimated frames missing in that gap - int gap_count = 0; // number of distinct gaps - int64_t total_missing = 0; // estimated frames missing across all gaps + std::vector gaps; + int gap_count = 0; // number of distinct gaps + int64_t total_missing = 0; // estimated frames missing across all gaps }; class TimelineViewService { diff --git a/tools/inspector/src/services/timeline_view_service.cpp b/tools/inspector/src/services/timeline_view_service.cpp index 78e8aed..69d40e8 100644 --- a/tools/inspector/src/services/timeline_view_service.cpp +++ b/tools/inspector/src/services/timeline_view_service.cpp @@ -111,6 +111,7 @@ namespace VtxServices { const double expected_interval_ms = 1000.0 / static_cast(expected_fps); const int last_index = std::min(total_frames, static_cast(ticks.size())) - 1; + const uint64_t base_tick = FirstNonZeroTick(ticks); int prev_stamped = -1; for (int frame = 0; frame <= last_index; ++frame) { @@ -133,6 +134,13 @@ namespace VtxServices { map.gap_ms[static_cast(i)] = static_cast(actual_ms); map.missing[static_cast(i)] = missing; } + map.gaps.push_back(DroppedFrameGap { + .first_frame = prev_stamped + 1, + .last_frame = frame, + .start_seconds = static_cast( + static_cast(ticks[static_cast(prev_stamped)] - base_tick) / 10'000'000.0), + .duration_ms = static_cast(actual_ms), + }); } } prev_stamped = frame; diff --git a/tools/inspector/src/windows/timeline_window.cpp b/tools/inspector/src/windows/timeline_window.cpp index 32a12c0..b914ddf 100644 --- a/tools/inspector/src/windows/timeline_window.cpp +++ b/tools/inspector/src/windows/timeline_window.cpp @@ -204,7 +204,6 @@ void TimelineWindow::DrawTimeAndFrameInfo(int total_frames, float duration) { // Renders main timeline slider and updates scrubbing state. void TimelineWindow::DrawTimelineSlider(int total_frames, float duration_seconds) { - (void)duration_seconds; ImGui::SetNextItemWidth(-1.0f); int current_frame = inspector_session_->GetCurrentFrame(); @@ -212,6 +211,26 @@ void TimelineWindow::DrawTimelineSlider(int total_frames, float duration_seconds inspector_session_->SetCurrentFrame(HandleGoToFrame(current_frame, total_frames)); } inspector_session_->SetScrubbingTimeline(ImGui::IsItemActive()); + + // Overlay recording gaps on the slider track, positioned and sized by wall + // clock so a band's width reads as the gap's duration. + if (duration_seconds > 0.0f) { + const auto& dropped_map = GetDroppedFrameMap(total_frames); + if (!dropped_map.gaps.empty()) { + const ImVec2 rect_min = ImGui::GetItemRectMin(); + const ImVec2 rect_max = ImGui::GetItemRectMax(); + const float rect_width = rect_max.x - rect_min.x; + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + for (const auto& gap : dropped_map.gaps) { + const float t0 = std::clamp(gap.start_seconds / duration_seconds, 0.0f, 1.0f); + const float t1 = + std::clamp((gap.start_seconds + gap.duration_ms / 1000.0f) / duration_seconds, 0.0f, 1.0f); + const float x0 = rect_min.x + t0 * rect_width; + const float x1 = std::max(rect_min.x + t1 * rect_width, x0 + 1.0f); + draw_list->AddRectFilled(ImVec2(x0, rect_min.y), ImVec2(x1, rect_max.y), IM_COL32(220, 60, 60, 150)); + } + } + } } const VtxServices::DroppedFrameMap& TimelineWindow::GetDroppedFrameMap(int total_frames) { From 9577be7546ee2185c334d7cad494797c6abd3943 Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 22:06:18 +0200 Subject: [PATCH 5/8] feat(inspector): main slider scrubs wall-clock time, not frame index The slider axis is now elapsed seconds over the full capture duration: the grab sits at the current frame's footer-table time, and dragging resolves the frame via binary search for the last stamp at or before the grabbed time (zero entries skipped; linear fallback without a table). Equal slider distance now means equal wall-clock time, and the red gap bands align with the grab. Inside a gap the grab resolves to the frame at the gap's start, snapping to it on release. --- .../include/services/timeline_view_service.h | 7 ++++ .../src/services/timeline_view_service.cpp | 41 +++++++++++++++++++ .../inspector/src/windows/timeline_window.cpp | 15 +++++-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/tools/inspector/include/services/timeline_view_service.h b/tools/inspector/include/services/timeline_view_service.h index 2ea823e..ce9add9 100644 --- a/tools/inspector/include/services/timeline_view_service.h +++ b/tools/inspector/include/services/timeline_view_service.h @@ -70,6 +70,13 @@ namespace VtxServices { static float FrameToElapsedSeconds(int frame, const VTX::ReplayTimeData& times, int total_frames, float duration_seconds, float fallback_fps = 30.0f); + // Inverse of FrameToElapsedSeconds: the last frame stamped at or before + // `seconds`, found by binary search over the footer time table (zero + // entries are skipped). Falls back to linear time*avg-fps mapping when + // the table is absent. + static int FrameAtElapsedSeconds(float seconds, const VTX::ReplayTimeData& times, int total_frames, + float duration_seconds, float fallback_fps = 30.0f); + // Builds the recording-gap map for the whole replay from the footer time // table (created_utc preferred, game_time fallback). Returns an empty map // (no frames flagged) when the file carries no usable time table. diff --git a/tools/inspector/src/services/timeline_view_service.cpp b/tools/inspector/src/services/timeline_view_service.cpp index 69d40e8..c9f62a5 100644 --- a/tools/inspector/src/services/timeline_view_service.cpp +++ b/tools/inspector/src/services/timeline_view_service.cpp @@ -93,6 +93,47 @@ namespace VtxServices { .current = ToClockTime(current_time_sec), .total = ToClockTime(duration_seconds), .fps = fps}; } + int TimelineViewService::FrameAtElapsedSeconds(float seconds, const VTX::ReplayTimeData& times, int total_frames, + float duration_seconds, float fallback_fps) { + if (total_frames <= 0) { + return 0; + } + const std::vector& ticks = + FirstNonZeroTick(times.created_utc) != 0 ? times.created_utc : times.game_time; + const uint64_t base_tick = FirstNonZeroTick(ticks); + if (base_tick == 0) { + const float fps = ComputePlaybackFps(total_frames, duration_seconds, fallback_fps); + return ClampFrame(static_cast(std::max(seconds, 0.0f) * fps), total_frames); + } + + const uint64_t target_tick = + base_tick + static_cast(std::max(0.0, static_cast(seconds) * 10'000'000.0)); + + // Binary search for the greatest stamped frame with tick <= target. + // Zero (unstamped) entries are skipped by walking the probe left. + int low = 0; + int high = std::min(total_frames, static_cast(ticks.size())) - 1; + int result = 0; + while (low <= high) { + const int mid = low + (high - low) / 2; + int probe = mid; + while (probe >= low && ticks[static_cast(probe)] == 0) { + --probe; + } + if (probe < low) { + low = mid + 1; + continue; + } + if (ticks[static_cast(probe)] <= target_tick) { + result = probe; + low = mid + 1; + } else { + high = probe - 1; + } + } + return ClampFrame(result, total_frames); + } + DroppedFrameMap TimelineViewService::BuildDroppedFrameMap(const VTX::ReplayTimeData& times, int total_frames, float expected_fps) { DroppedFrameMap map; diff --git a/tools/inspector/src/windows/timeline_window.cpp b/tools/inspector/src/windows/timeline_window.cpp index b914ddf..34297e2 100644 --- a/tools/inspector/src/windows/timeline_window.cpp +++ b/tools/inspector/src/windows/timeline_window.cpp @@ -202,13 +202,20 @@ void TimelineWindow::DrawTimeAndFrameInfo(int total_frames, float duration) { ImGui::EndChild(); } -// Renders main timeline slider and updates scrubbing state. +// Renders the main timeline slider (wall-clock axis) and updates scrubbing +// state. The grab position is the current frame's elapsed time; dragging +// resolves the frame at the grabbed time via the footer time table, so equal +// slider distance means equal wall-clock time even across recording gaps. void TimelineWindow::DrawTimelineSlider(int total_frames, float duration_seconds) { ImGui::SetNextItemWidth(-1.0f); - int current_frame = inspector_session_->GetCurrentFrame(); - if (ImGui::SliderInt("##MainTimeline", ¤t_frame, 0, total_frames - 1, "")) { - inspector_session_->SetCurrentFrame(HandleGoToFrame(current_frame, total_frames)); + const auto& times = inspector_session_->GetFooter().times; + float current_seconds = VtxServices::TimelineViewService::FrameToElapsedSeconds( + inspector_session_->GetCurrentFrame(), times, total_frames, duration_seconds); + if (ImGui::SliderFloat("##MainTimeline", ¤t_seconds, 0.0f, duration_seconds, "")) { + const int frame = VtxServices::TimelineViewService::FrameAtElapsedSeconds(current_seconds, times, total_frames, + duration_seconds); + inspector_session_->SetCurrentFrame(HandleGoToFrame(frame, total_frames)); } inspector_session_->SetScrubbingTimeline(ImGui::IsItemActive()); From a7e2cc2c940d9650280dc43f690adcb960f83cef Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 22:09:58 +0200 Subject: [PATCH 6/8] feat(inspector): derate entered Drop FPS by 0.75 for real-world rates Captures never sustain their nominal rate, so gap detection now runs at entered_fps * 0.75 (30 -> 22.5 effective). The input label shows the factor and the resulting effective rate. --- tools/inspector/src/windows/timeline_window.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tools/inspector/src/windows/timeline_window.cpp b/tools/inspector/src/windows/timeline_window.cpp index 34297e2..d29c4a9 100644 --- a/tools/inspector/src/windows/timeline_window.cpp +++ b/tools/inspector/src/windows/timeline_window.cpp @@ -1,6 +1,7 @@ #include "windows/timeline_window.h" #include +#include #include #include @@ -9,6 +10,10 @@ namespace { + // Real captures never sustain their nominal rate; the entered Drop FPS is + // derated by this factor before gap detection. + constexpr float kDropFpsRealWorldFactor = 0.75f; + struct TimelineHoverInfo { bool has_hover = false; int frame_index = -1; @@ -177,7 +182,10 @@ void TimelineWindow::DrawTimeAndFrameInfo(int total_frames, float duration) { // Expected capture rate for the recording-gap (red bar) detection. ImGui::SameLine(); ImGui::SetNextItemWidth(70.0f); - if (ImGui::InputFloat("Drop FPS", &drop_detect_fps_, 0.0f, 0.0f, "%.1f", + char drop_fps_label[64]; + std::snprintf(drop_fps_label, sizeof(drop_fps_label), "Drop FPS (x0.75 = %.1f)###DropFps", + drop_detect_fps_ * kDropFpsRealWorldFactor); + if (ImGui::InputFloat(drop_fps_label, &drop_detect_fps_, 0.0f, 0.0f, "%.1f", ImGuiInputTextFlags_EnterReturnsTrue)) { drop_detect_fps_ = std::clamp(drop_detect_fps_, 1.0f, 1000.0f); } @@ -245,7 +253,8 @@ const VtxServices::DroppedFrameMap& TimelineWindow::GetDroppedFrameMap(int total const size_t stamp_count = times.created_utc.size() + times.game_time.size(); if (dropped_map_fps_ != drop_detect_fps_ || dropped_map_frames_ != total_frames || dropped_map_stamp_count_ != stamp_count) { - dropped_map_ = VtxServices::TimelineViewService::BuildDroppedFrameMap(times, total_frames, drop_detect_fps_); + dropped_map_ = VtxServices::TimelineViewService::BuildDroppedFrameMap( + times, total_frames, drop_detect_fps_ * kDropFpsRealWorldFactor); dropped_map_fps_ = drop_detect_fps_; dropped_map_frames_ = total_frames; dropped_map_stamp_count_ = stamp_count; From 06ac84dc83cd5a7ff4e3e62184ba09c511be5e31 Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 22:28:33 +0200 Subject: [PATCH 7/8] feat(inspector): File > Cut Replay -- write a sub-range as a new .vtx ReplayCutService plans a cut snapped to whole chunks (the chunks containing the requested first/last frame) and writes a new file: header block and kept chunks copied verbatim, footer rebuilt with frame numbering rebased to 0, the time table sliced (tick values stay absolute), gaps/segments filtered+rebased, and duration recomputed from the sliced UTC stamps. Timeline events are not carried over; the footer is written uncompressed (the reader sniffs zstd by magic). The Cut Replay window accepts the range as elapsed time, frames, or absolute UTC (ISO-8601 or unix seconds/ms/100ns ticks), previews the snapped chunks/frames/time/size live, and runs the copy on a worker thread with a Save As dialog (defaults to _cut.vtx). Verified headless on a 16556-frame capture: mid cut (frames 5000-9999) and tail cut (16000-16555) both reopen cleanly with matching frame contents, sliced time tables, and absolute UTC stamps. --- tools/inspector/include/inspector_layout.h | 4 + .../include/services/replay_cut_service.h | 40 +++ .../include/windows/cut_replay_window.h | 66 ++++ tools/inspector/src/inspector_layout.cpp | 18 + .../src/services/replay_cut_service.cpp | 213 +++++++++++ .../src/windows/cut_replay_window.cpp | 332 ++++++++++++++++++ 6 files changed, 673 insertions(+) create mode 100644 tools/inspector/include/services/replay_cut_service.h create mode 100644 tools/inspector/include/windows/cut_replay_window.h create mode 100644 tools/inspector/src/services/replay_cut_service.cpp create mode 100644 tools/inspector/src/windows/cut_replay_window.cpp diff --git a/tools/inspector/include/inspector_layout.h b/tools/inspector/include/inspector_layout.h index b53e7a5..19405dd 100644 --- a/tools/inspector/include/inspector_layout.h +++ b/tools/inspector/include/inspector_layout.h @@ -3,6 +3,7 @@ #include #include "gui/gui_layer.h" +class CutReplayWindow; class GuiScaleController; class InspectorSession; class RepairReplayWindow; @@ -28,4 +29,7 @@ class InspectorLayout : public IGuiLayer { // File > Repair Replay: floating, independent of a loaded replay (it repairs a // possibly-unopenable .vtx from its ".recovery" sidecar). At most one at a time. std::shared_ptr repair_window_; + + // File > Cut Replay: floating, operates on the loaded replay. At most one. + std::shared_ptr cut_window_; }; diff --git a/tools/inspector/include/services/replay_cut_service.h b/tools/inspector/include/services/replay_cut_service.h new file mode 100644 index 0000000..496653b --- /dev/null +++ b/tools/inspector/include/services/replay_cut_service.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +#include "vtx/common/vtx_types.h" + +namespace VtxServices { + + // Resolved cut request, snapped to chunk boundaries. Chunks are copied + // verbatim (no rebalancing), so the kept range is the union of the chunks + // containing the requested first and last frame. + struct ReplayCutPlan { + bool valid = false; + std::string error; // set when !valid + + int first_chunk = 0; // index into footer.chunk_index + int last_chunk = 0; + int first_frame = 0; // snapped range, original frame numbering + int last_frame = 0; + uint64_t chunk_bytes = 0; // total on-disk bytes of the kept chunks + }; + + // Cuts a new .vtx out of an existing flatbuffers replay: header block copied + // verbatim, kept chunks copied verbatim, footer rebuilt (frame numbering + // rebased to 0, time table sliced, duration recomputed). Timeline events are + // not carried over. + class ReplayCutService { + public: + // Snaps [start_frame, end_frame] (original numbering, inclusive) to the + // chunks that contain them. + static ReplayCutPlan PlanCut(const VTX::FileFooter& footer, int start_frame, int end_frame); + + // Writes the cut to dest_path. Returns false and fills `error` on failure. + // dest_path must differ from source_path. + static bool ExecuteCut(const std::string& source_path, const VTX::FileFooter& footer, const ReplayCutPlan& plan, + const std::string& dest_path, std::string& error); + }; + +} // namespace VtxServices diff --git a/tools/inspector/include/windows/cut_replay_window.h b/tools/inspector/include/windows/cut_replay_window.h new file mode 100644 index 0000000..64a3cae --- /dev/null +++ b/tools/inspector/include/windows/cut_replay_window.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include + +#include "gui/gui_layer.h" +#include "services/replay_cut_service.h" + +class InspectorSession; + +// Floating window (File > Cut Replay...) that writes a new .vtx containing a +// sub-range of the loaded replay. The range can be given as elapsed time, +// frames, or absolute UTC; it is snapped to whole chunks (chunks are copied +// verbatim, only the footer is rebuilt). The copy runs on a worker thread. +// +// Implements IGuiLayer directly (rather than the shared ImGuiWindow base) so this +// header never pulls in gui/gui_window.h -- whose `class ImGuiWindow` would clash +// with Dear ImGui's `struct ImGuiWindow` in translation units that include +// imgui_internal.h (e.g. inspector_layout.cpp). +class CutReplayWindow : public IGuiLayer { +public: + explicit CutReplayWindow(std::shared_ptr session); + ~CutReplayWindow() override = default; + + void OnUpdate() override {} + void OnRender() override; + + void SetOpen(bool open) { is_open_ = open; } + bool IsOpen() const { return is_open_; } + +private: + enum class Phase { Idle, Running, Done }; + + struct Outcome { + bool ok = false; + std::string error; + std::string dest_path; + }; + + void DrawContent(); + void ResetRangeToFullReplay(); + // Resolves the current inputs to an inclusive frame range; false when the + // inputs cannot be parsed (error_out explains why). + bool ResolveRequestedFrames(int& start_frame, int& end_frame, std::string& error_out) const; + void StartCut(const VtxServices::ReplayCutPlan& plan, const std::string& dest_path); + static Outcome RunCut(std::string source_path, VTX::FileFooter footer, VtxServices::ReplayCutPlan plan, + std::string dest_path); + + std::shared_ptr session_; + + int range_mode_ = 0; // 0 = time, 1 = frame, 2 = UTC + double time_start_seconds_ = 0.0; + double time_end_seconds_ = 0.0; + int frame_start_ = 0; + int frame_end_ = 0; + char utc_start_[64] = {}; + char utc_end_[64] = {}; + bool range_initialized_ = false; + + Phase phase_ = Phase::Idle; + std::future future_; + Outcome outcome_; + int spinner_tick_ = 0; + bool is_open_ = true; +}; diff --git a/tools/inspector/src/inspector_layout.cpp b/tools/inspector/src/inspector_layout.cpp index dbd27fa..a6b2754 100644 --- a/tools/inspector/src/inspector_layout.cpp +++ b/tools/inspector/src/inspector_layout.cpp @@ -11,6 +11,7 @@ #include "gui/gui_types.h" #include "inspector_session.h" #include "windows/analysis_window_factory.h" +#include "windows/cut_replay_window.h" #include "windows/repair_replay_window.h" namespace { @@ -260,6 +261,15 @@ void InspectorLayout::OnRender() { } } + // Cut a sub-range of the loaded replay into a new .vtx. + if (ImGui::MenuItem("Cut Replay...", nullptr, false, session_->HasLoadedReplay())) { + if (!cut_window_) { + cut_window_ = std::make_shared(session_); + } else { + cut_window_->SetOpen(true); + } + } + ImGui::Separator(); if (ImGui::MenuItem("Exit")) { @@ -332,4 +342,12 @@ void InspectorLayout::OnRender() { repair_window_.reset(); } } + + // Cut-replay window (floating, single instance), same lifecycle as repair. + if (cut_window_) { + cut_window_->OnRender(); + if (!cut_window_->IsOpen()) { + cut_window_.reset(); + } + } } diff --git a/tools/inspector/src/services/replay_cut_service.cpp b/tools/inspector/src/services/replay_cut_service.cpp new file mode 100644 index 0000000..9b8189c --- /dev/null +++ b/tools/inspector/src/services/replay_cut_service.cpp @@ -0,0 +1,213 @@ +#include "services/replay_cut_service.h" + +#include +#include +#include +#include +#include + +#include "vtx/writer/policies/formatters/flatbuffers_vtx_policy.h" + +namespace { + + constexpr double kTicksPerSecond = 10'000'000.0; + + // Streams [offset, offset+bytes) from `in` to `out` through a bounded buffer. + bool CopyRange(std::ifstream& in, std::ofstream& out, uint64_t offset, uint64_t bytes, std::string& error) { + constexpr size_t kBufferSize = 4 * 1024 * 1024; + std::vector buffer(std::min(bytes, kBufferSize)); + in.seekg(static_cast(offset)); + uint64_t remaining = bytes; + while (remaining > 0) { + const size_t step = static_cast(std::min(remaining, buffer.size())); + if (!in.read(buffer.data(), static_cast(step))) { + error = "Read failed at offset " + std::to_string(offset + (bytes - remaining)); + return false; + } + if (!out.write(buffer.data(), static_cast(step))) { + error = "Write failed"; + return false; + } + remaining -= step; + } + return true; + } + + // Duration of the sliced range from a tick table (first..last nonzero), or a + // negative value when the table cannot answer. + double DurationFromTicks(const std::vector& ticks) { + int64_t first = 0; + int64_t last = 0; + for (const int64_t t : ticks) { + if (t != 0) { + if (first == 0) { + first = t; + } + last = t; + } + } + if (first == 0 || last < first) { + return -1.0; + } + return static_cast(last - first) / kTicksPerSecond; + } + +} // namespace + +namespace VtxServices { + + ReplayCutPlan ReplayCutService::PlanCut(const VTX::FileFooter& footer, int start_frame, int end_frame) { + ReplayCutPlan plan; + if (footer.chunk_index.empty() || footer.total_frames <= 0) { + plan.error = "The loaded replay has no chunk index."; + return plan; + } + start_frame = std::clamp(start_frame, 0, footer.total_frames - 1); + end_frame = std::clamp(end_frame, 0, footer.total_frames - 1); + if (end_frame < start_frame) { + plan.error = "End of range is before its start."; + return plan; + } + + int first_chunk = -1; + int last_chunk = -1; + for (int i = 0; i < static_cast(footer.chunk_index.size()); ++i) { + const auto& entry = footer.chunk_index[static_cast(i)]; + if (first_chunk < 0 && start_frame <= entry.end_frame) { + first_chunk = i; + } + if (end_frame >= entry.start_frame) { + last_chunk = i; + } + } + if (first_chunk < 0 || last_chunk < first_chunk) { + plan.error = "The requested range does not overlap any chunk."; + return plan; + } + + plan.first_chunk = first_chunk; + plan.last_chunk = last_chunk; + plan.first_frame = footer.chunk_index[static_cast(first_chunk)].start_frame; + plan.last_frame = footer.chunk_index[static_cast(last_chunk)].end_frame; + for (int i = first_chunk; i <= last_chunk; ++i) { + plan.chunk_bytes += footer.chunk_index[static_cast(i)].chunk_size_bytes; + } + plan.valid = true; + return plan; + } + + bool ReplayCutService::ExecuteCut(const std::string& source_path, const VTX::FileFooter& footer, + const ReplayCutPlan& plan, const std::string& dest_path, std::string& error) { + if (!plan.valid) { + error = plan.error.empty() ? "Invalid cut plan." : plan.error; + return false; + } + namespace fs = std::filesystem; + std::error_code ec; + if (fs::equivalent(fs::path(source_path), fs::path(dest_path), ec)) { + error = "Destination must be a different file than the source."; + return false; + } + + std::ifstream in(source_path, std::ios::binary); + if (!in) { + error = "Could not open the source replay: " + source_path; + return false; + } + std::ofstream out(dest_path, std::ios::binary | std::ios::trunc); + if (!out) { + error = "Could not create the output file: " + dest_path; + return false; + } + + // 1) Header region, verbatim: everything before the first chunk of the + // ORIGINAL file (magic + length-prefixed header payload). The header + // carries no frame-dependent data; copying it keeps the schema and + // recording metadata bit-identical. + const uint64_t header_end = footer.chunk_index.front().file_offset; + if (!CopyRange(in, out, 0, header_end, error)) { + return false; + } + + // 2) Kept chunks, verbatim, while rebuilding the seek table with new + // offsets and frame numbering rebased so the cut starts at frame 0. + std::vector seek_table; + seek_table.reserve(static_cast(plan.last_chunk - plan.first_chunk + 1)); + for (int i = plan.first_chunk; i <= plan.last_chunk; ++i) { + const auto& entry = footer.chunk_index[static_cast(i)]; + VTX::ChunkIndexData rebased; + rebased.chunk_index = i - plan.first_chunk; + rebased.file_offset = static_cast(out.tellp()); + rebased.chunk_size_bytes = entry.chunk_size_bytes; + rebased.start_frame = entry.start_frame - plan.first_frame; + rebased.end_frame = entry.end_frame - plan.first_frame; + rebased.checksum = entry.checksum; + seek_table.push_back(rebased); + if (!CopyRange(in, out, entry.file_offset, entry.chunk_size_bytes, error)) { + return false; + } + } + + // 3) Footer: time table sliced to the kept frames (tick values stay + // absolute; only frame indices are rebased), duration recomputed. + const auto slice_ticks = [&](const std::vector& ticks) { + std::vector sliced; + const size_t begin = std::min(static_cast(plan.first_frame), ticks.size()); + const size_t end = std::min(static_cast(plan.last_frame) + 1, ticks.size()); + sliced.reserve(end - begin); + for (size_t i = begin; i < end; ++i) { + sliced.push_back(static_cast(ticks[i])); + } + return sliced; + }; + const auto slice_frame_indices = [&](const std::vector& values) { + std::vector sliced; + for (const uint32_t value : values) { + const int frame = static_cast(value); + if (frame >= plan.first_frame && frame <= plan.last_frame) { + sliced.push_back(frame - plan.first_frame); + } + } + return sliced; + }; + + const std::vector game_times = slice_ticks(footer.times.game_time); + const std::vector created_utc = slice_ticks(footer.times.created_utc); + const std::vector gaps = slice_frame_indices(footer.times.gaps); + const std::vector segments = slice_frame_indices(footer.times.segments); + + VTX::SessionFooter session_footer; + session_footer.total_frames = plan.last_frame - plan.first_frame + 1; + double duration = DurationFromTicks(created_utc); + if (duration < 0.0) { + duration = DurationFromTicks(game_times); + } + if (duration < 0.0 && footer.total_frames > 0) { + duration = static_cast(footer.duration_seconds) * session_footer.total_frames / footer.total_frames; + } + session_footer.duration_seconds = std::max(duration, 0.0); + session_footer.game_times = &game_times; + session_footer.created_utc = &created_utc; + session_footer.gaps = &gaps; + session_footer.segments = &segments; + + // Written uncompressed; the reader sniffs the zstd magic and passes + // uncompressed payloads through. + const std::string footer_payload = VTX::FlatBuffersVtxPolicy::SerializeFooter(seek_table, session_footer); + const uint32_t footer_size = static_cast(footer_payload.size()); + const std::string magic = VTX::FlatBuffersVtxPolicy::GetMagicBytes(); + if (!out.write(footer_payload.data(), static_cast(footer_payload.size())) || + !out.write(reinterpret_cast(&footer_size), sizeof(footer_size)) || + !out.write(magic.data(), static_cast(magic.size()))) { + error = "Write failed while finishing the footer."; + return false; + } + out.flush(); + if (!out) { + error = "Flush failed for the output file."; + return false; + } + return true; + } + +} // namespace VtxServices diff --git a/tools/inspector/src/windows/cut_replay_window.cpp b/tools/inspector/src/windows/cut_replay_window.cpp new file mode 100644 index 0000000..11fed41 --- /dev/null +++ b/tools/inspector/src/windows/cut_replay_window.cpp @@ -0,0 +1,332 @@ +#include "windows/cut_replay_window.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "gui/portable-file-dialogs.h" +#include "inspector_session.h" +#include "services/time_display_service.h" +#include "services/timeline_view_service.h" + +namespace { + + const ImVec4 kOk {0.40f, 0.85f, 0.45f, 1.0f}; + const ImVec4 kWarn {0.95f, 0.80f, 0.30f, 1.0f}; + const ImVec4 kErr {0.95f, 0.45f, 0.45f, 1.0f}; + const ImVec4 kDim {0.65f, 0.65f, 0.65f, 1.0f}; + + constexpr int64_t kTicksPerSecond = 10'000'000; + + // Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm). + int64_t DaysFromCivil(int64_t y, unsigned m, unsigned d) { + y -= m <= 2; + const int64_t era = (y >= 0 ? y : y - 399) / 400; + const unsigned yoe = static_cast(y - era * 400); + const unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1; + const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + static_cast(doe) - 719468; + } + + // Accepts an ISO-8601 UTC timestamp ("2026-07-24T09:50:29.195Z", 'T' or + // space, 'Z' optional, fractional seconds optional) or a bare number + // (unix-epoch 100ns ticks, unix milliseconds, or unix seconds -- picked by + // magnitude). Returns unix-epoch 100ns ticks. + bool ParseUtcInput(const char* text, int64_t& ticks_out) { + if (!text || !*text) { + return false; + } + + int year = 0, month = 0, day = 0, hour = 0, minute = 0; + double seconds = 0.0; + if (std::sscanf(text, "%d-%d-%d%*1[T ]%d:%d:%lf", &year, &month, &day, &hour, &minute, &seconds) == 6) { + if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || seconds >= 61.0) { + return false; + } + const int64_t days = DaysFromCivil(year, static_cast(month), static_cast(day)); + const double unix_seconds = static_cast(days) * 86400.0 + hour * 3600.0 + minute * 60.0 + seconds; + ticks_out = static_cast(unix_seconds * static_cast(kTicksPerSecond)); + return true; + } + + char* end = nullptr; + const double value = std::strtod(text, &end); + if (end == text || value <= 0.0) { + return false; + } + if (value > 1.0e15) { // already 100ns ticks + ticks_out = static_cast(value); + } else if (value > 1.0e11) { // unix milliseconds + ticks_out = static_cast(value * 10'000.0); + } else { // unix seconds + ticks_out = static_cast(value * static_cast(kTicksPerSecond)); + } + return true; + } + + // First nonzero tick of the table the timeline uses (created_utc preferred). + int64_t BaseTick(const VTX::ReplayTimeData& times) { + for (const uint64_t tick : times.created_utc) { + if (tick != 0) { + return static_cast(tick); + } + } + for (const uint64_t tick : times.game_time) { + if (tick != 0) { + return static_cast(tick); + } + } + return 0; + } + + std::string FormatClock(float seconds) { + const auto clock = VtxServices::TimelineViewService::ToClockTime(seconds); + char buffer[32]; + std::snprintf(buffer, sizeof(buffer), "%02d:%02d", clock.minutes, clock.seconds); + return buffer; + } + +} // namespace + +CutReplayWindow::CutReplayWindow(std::shared_ptr session) + : session_(std::move(session)) {} + +void CutReplayWindow::OnRender() { + if (!is_open_) + return; + if (ImGui::Begin("Cut Replay", &is_open_)) { + DrawContent(); + } + ImGui::End(); +} + +void CutReplayWindow::ResetRangeToFullReplay() { + const auto& footer = session_->GetFooter(); + const int total_frames = footer.total_frames; + frame_start_ = 0; + frame_end_ = std::max(total_frames - 1, 0); + time_start_seconds_ = 0.0; + time_end_seconds_ = static_cast(footer.duration_seconds); + utc_start_[0] = '\0'; + utc_end_[0] = '\0'; + if (total_frames > 0) { + const auto tick_at = [&](int frame) -> int64_t { + const auto& utc = footer.times.created_utc; + for (int i = std::min(frame, static_cast(utc.size()) - 1); i >= 0; --i) { + if (utc[static_cast(i)] != 0) { + return static_cast(utc[static_cast(i)]); + } + } + return 0; + }; + const int64_t first = tick_at(0) != 0 ? tick_at(0) : BaseTick(footer.times); + const int64_t last = tick_at(total_frames - 1); + if (first != 0) { + std::snprintf(utc_start_, sizeof(utc_start_), "%" PRId64, first); + } + if (last != 0) { + std::snprintf(utc_end_, sizeof(utc_end_), "%" PRId64, last); + } + } +} + +bool CutReplayWindow::ResolveRequestedFrames(int& start_frame, int& end_frame, std::string& error_out) const { + const auto& footer = session_->GetFooter(); + const int total_frames = footer.total_frames; + const float duration = footer.duration_seconds; + + const auto frame_at_seconds = [&](double seconds) { + return VtxServices::TimelineViewService::FrameAtElapsedSeconds(static_cast(seconds), footer.times, + total_frames, duration); + }; + + switch (range_mode_) { + case 0: // time + if (time_end_seconds_ < time_start_seconds_) { + error_out = "End time is before start time."; + return false; + } + start_frame = frame_at_seconds(time_start_seconds_); + end_frame = frame_at_seconds(time_end_seconds_); + return true; + case 1: // frame + start_frame = frame_start_; + end_frame = frame_end_; + return true; + default: { // UTC + int64_t start_ticks = 0; + int64_t end_ticks = 0; + if (!ParseUtcInput(utc_start_, start_ticks) || !ParseUtcInput(utc_end_, end_ticks)) { + error_out = "UTC bounds must be ISO-8601 (2026-07-24T09:50:29.195Z) or a unix seconds/ms/ticks number."; + return false; + } + if (end_ticks < start_ticks) { + error_out = "UTC end is before UTC start."; + return false; + } + const int64_t base = BaseTick(footer.times); + if (base == 0) { + error_out = "The replay has no time table; use the Frame range mode."; + return false; + } + start_frame = frame_at_seconds(static_cast(start_ticks - base) / kTicksPerSecond); + end_frame = frame_at_seconds(static_cast(end_ticks - base) / kTicksPerSecond); + return true; + } + } +} + +CutReplayWindow::Outcome CutReplayWindow::RunCut(std::string source_path, VTX::FileFooter footer, + VtxServices::ReplayCutPlan plan, std::string dest_path) { + // Touches only its by-value arguments -- safe to run detached from the window. + Outcome out; + out.dest_path = dest_path; + out.ok = VtxServices::ReplayCutService::ExecuteCut(source_path, footer, plan, dest_path, out.error); + return out; +} + +void CutReplayWindow::StartCut(const VtxServices::ReplayCutPlan& plan, const std::string& dest_path) { + phase_ = Phase::Running; + spinner_tick_ = 0; + outcome_ = Outcome {}; + future_ = std::async(std::launch::async, &CutReplayWindow::RunCut, session_->current_file_path_, + session_->GetFooter(), plan, dest_path); +} + +void CutReplayWindow::DrawContent() { + if (phase_ == Phase::Running && future_.valid() && + future_.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + outcome_ = future_.get(); + phase_ = Phase::Done; + } + + if (!session_->HasLoadedReplay()) { + ImGui::TextDisabled("Load a replay first; Cut works on the currently loaded .vtx."); + range_initialized_ = false; + return; + } + if (session_->GetFormat() != VTX::VtxFormat::FlatBuffers) { + ImGui::TextColored(kWarn, "Cut currently supports flatbuffers (.vtx \"VTXF\") replays only."); + return; + } + if (!range_initialized_) { + ResetRangeToFullReplay(); + range_initialized_ = true; + } + + const auto& footer = session_->GetFooter(); + ImGui::TextWrapped("Write a new .vtx containing a sub-range of the loaded replay. The range snaps to " + "whole chunks: chunks are copied verbatim, and the footer is rebuilt for the cut."); + ImGui::Spacing(); + + const bool busy = (phase_ == Phase::Running); + ImGui::BeginDisabled(busy); + + ImGui::SetNextItemWidth(160.0f); + ImGui::Combo("Range mode", &range_mode_, "Time (elapsed seconds)\0Frame\0UTC\0"); + + if (range_mode_ == 0) { + ImGui::SetNextItemWidth(120.0f); + ImGui::InputDouble("Start (s)", &time_start_seconds_, 0.0, 0.0, "%.3f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(120.0f); + ImGui::InputDouble("End (s)", &time_end_seconds_, 0.0, 0.0, "%.3f"); + ImGui::TextColored(kDim, "%s -> %s of %s", FormatClock(static_cast(time_start_seconds_)).c_str(), + FormatClock(static_cast(time_end_seconds_)).c_str(), + FormatClock(footer.duration_seconds).c_str()); + } else if (range_mode_ == 1) { + ImGui::SetNextItemWidth(120.0f); + ImGui::InputInt("Start frame", &frame_start_, 0, 0); + ImGui::SameLine(); + ImGui::SetNextItemWidth(120.0f); + ImGui::InputInt("End frame", &frame_end_, 0, 0); + ImGui::TextColored(kDim, "Replay frames: 0 .. %d", std::max(footer.total_frames - 1, 0)); + } else { + ImGui::SetNextItemWidth(260.0f); + ImGui::InputText("Start UTC", utc_start_, sizeof(utc_start_)); + ImGui::SetNextItemWidth(260.0f); + ImGui::InputText("End UTC", utc_end_, sizeof(utc_end_)); + ImGui::TextColored(kDim, "ISO-8601 (2026-07-24T09:50:29.195Z) or unix seconds / ms / 100ns ticks."); + } + + if (ImGui::Button("Reset to full replay")) { + ResetRangeToFullReplay(); + } + + ImGui::EndDisabled(); + ImGui::Separator(); + + // Live plan preview. + int requested_start = 0; + int requested_end = 0; + std::string input_error; + VtxServices::ReplayCutPlan plan; + if (ResolveRequestedFrames(requested_start, requested_end, input_error)) { + plan = VtxServices::ReplayCutService::PlanCut(footer, requested_start, requested_end); + } else { + plan.error = input_error; + } + + if (!plan.valid) { + ImGui::TextColored(kWarn, "%s", plan.error.c_str()); + } else { + const float start_sec = VtxServices::TimelineViewService::FrameToElapsedSeconds( + plan.first_frame, footer.times, footer.total_frames, footer.duration_seconds); + const float end_sec = VtxServices::TimelineViewService::FrameToElapsedSeconds( + plan.last_frame, footer.times, footer.total_frames, footer.duration_seconds); + ImGui::Text("Cut (snapped to chunks): frames %d .. %d (%d frames)", plan.first_frame, plan.last_frame, + plan.last_frame - plan.first_frame + 1); + ImGui::Text("Time %s .. %s | chunks %d .. %d of %d | ~%.1f MB", FormatClock(start_sec).c_str(), + FormatClock(end_sec).c_str(), plan.first_chunk, plan.last_chunk, + static_cast(footer.chunk_index.size()), plan.chunk_bytes / (1024.0 * 1024.0)); + } + + ImGui::Spacing(); + ImGui::BeginDisabled(!plan.valid || busy); + if (ImGui::Button("Cut && Save As...", ImVec2(160, 0))) { + namespace fs = std::filesystem; + const fs::path source(session_->current_file_path_); + const std::string default_name = source.stem().string() + "_cut.vtx"; + auto dialog = pfd::save_file("Save cut replay", (source.parent_path() / default_name).string(), + {"VTX Files (.vtx)", "*.vtx", "All Files", "*"}); + std::string dest = dialog.result(); + if (!dest.empty()) { + if (fs::path(dest).extension().empty()) { + dest += ".vtx"; + } + StartCut(plan, dest); + } + } + ImGui::EndDisabled(); + + if (phase_ == Phase::Done) { + ImGui::SameLine(); + if (ImGui::Button("Reset")) { + phase_ = Phase::Idle; + outcome_ = Outcome {}; + } + } + + ImGui::Spacing(); + if (phase_ == Phase::Running) { + ++spinner_tick_; + const char* dots[] = {"", ".", "..", "..."}; + ImGui::TextColored(kWarn, "Cutting%s", dots[(spinner_tick_ / 15) % 4]); + ImGui::TextColored(kDim, "Copying chunks and rebuilding the footer."); + } else if (phase_ == Phase::Done) { + if (!outcome_.ok) { + ImGui::TextColored(kErr, "Cut FAILED"); + ImGui::TextWrapped("%s", outcome_.error.c_str()); + } else { + ImGui::TextColored(kOk, "Cut SUCCEEDED"); + ImGui::TextWrapped("Wrote %s", outcome_.dest_path.c_str()); + ImGui::TextColored(kDim, "The new file opens like any other replay; its frames are renumbered " + "from 0 and UTC timestamps stay absolute."); + } + } +} From fe101e84992b0f9a035849d040b425bb331334db Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 22:41:44 +0200 Subject: [PATCH 8/8] feat(inspector): exact-frame cuts with edge-chunk rewriting Time/Frame/UTC cut ranges are now exact: when a bound falls inside a chunk, that edge chunk is re-serialized with only the kept frames (frames copied via a private SDK reader, SaveChunk framing mirrored: length prefix, zstd-if-beneficial, xxHash64 of the on-disk payload) and its seek-table entry regenerated. Whole interior chunks still copy verbatim. A new "Chunk (whole chunks)" range mode keeps the previous snap-to-chunk behavior via start/end chunk indices, and the preview reports how many frames each edge rewrite drops. Verified headless on a real capture: mid-chunk cut 5100..9150 (both edges rewritten), single-chunk double-trim 5150..5160, chunk-aligned 5000..9999 (pure verbatim path), and a 301-frame cut in the single-frame-chunk tail zone -- all reopen cleanly with per-frame entity content-hash matches, contiguous rebased seek tables, and absolute UTC stamps intact. --- .../include/services/replay_cut_service.h | 31 ++-- .../include/windows/cut_replay_window.h | 4 +- .../src/services/replay_cut_service.cpp | 157 +++++++++++++++--- .../src/windows/cut_replay_window.cpp | 54 ++++-- 4 files changed, 200 insertions(+), 46 deletions(-) diff --git a/tools/inspector/include/services/replay_cut_service.h b/tools/inspector/include/services/replay_cut_service.h index 496653b..15e3780 100644 --- a/tools/inspector/include/services/replay_cut_service.h +++ b/tools/inspector/include/services/replay_cut_service.h @@ -7,32 +7,39 @@ namespace VtxServices { - // Resolved cut request, snapped to chunk boundaries. Chunks are copied - // verbatim (no rebalancing), so the kept range is the union of the chunks - // containing the requested first and last frame. + // Resolved cut request. Interior chunks are always copied verbatim; when the + // requested range starts or ends inside a chunk (trims_head/trims_tail) that + // edge chunk is re-serialized with only the kept frames, and its seek-table + // entry (size, checksum, frame range) is regenerated. struct ReplayCutPlan { bool valid = false; std::string error; // set when !valid int first_chunk = 0; // index into footer.chunk_index int last_chunk = 0; - int first_frame = 0; // snapped range, original frame numbering + int first_frame = 0; // exact kept range, original frame numbering int last_frame = 0; - uint64_t chunk_bytes = 0; // total on-disk bytes of the kept chunks + bool trims_head = false; // first chunk starts before first_frame + bool trims_tail = false; // last chunk ends after last_frame + uint64_t chunk_bytes = 0; // on-disk bytes of the involved source chunks }; // Cuts a new .vtx out of an existing flatbuffers replay: header block copied - // verbatim, kept chunks copied verbatim, footer rebuilt (frame numbering - // rebased to 0, time table sliced, duration recomputed). Timeline events are - // not carried over. + // verbatim, whole chunks copied verbatim, partial edge chunks rewritten, and + // the footer rebuilt (frame numbering rebased to 0, time table sliced, + // duration recomputed). Timeline events are not carried over. class ReplayCutService { public: - // Snaps [start_frame, end_frame] (original numbering, inclusive) to the - // chunks that contain them. - static ReplayCutPlan PlanCut(const VTX::FileFooter& footer, int start_frame, int end_frame); + // Exact cut: keeps [start_frame, end_frame] inclusive, trimming inside + // the edge chunks when the bounds do not fall on chunk boundaries. + static ReplayCutPlan PlanCutFrames(const VTX::FileFooter& footer, int start_frame, int end_frame); + + // Whole-chunk cut: keeps chunks [first_chunk, last_chunk] untouched. + static ReplayCutPlan PlanCutChunks(const VTX::FileFooter& footer, int first_chunk, int last_chunk); // Writes the cut to dest_path. Returns false and fills `error` on failure. - // dest_path must differ from source_path. + // dest_path must differ from source_path. When the plan trims an edge + // chunk, the source is reopened with the SDK reader to re-serialize it. static bool ExecuteCut(const std::string& source_path, const VTX::FileFooter& footer, const ReplayCutPlan& plan, const std::string& dest_path, std::string& error); }; diff --git a/tools/inspector/include/windows/cut_replay_window.h b/tools/inspector/include/windows/cut_replay_window.h index 64a3cae..87b405c 100644 --- a/tools/inspector/include/windows/cut_replay_window.h +++ b/tools/inspector/include/windows/cut_replay_window.h @@ -49,13 +49,15 @@ class CutReplayWindow : public IGuiLayer { std::shared_ptr session_; - int range_mode_ = 0; // 0 = time, 1 = frame, 2 = UTC + int range_mode_ = 0; // 0 = time, 1 = frame, 2 = UTC (exact cuts); 3 = chunk (whole chunks) double time_start_seconds_ = 0.0; double time_end_seconds_ = 0.0; int frame_start_ = 0; int frame_end_ = 0; char utc_start_[64] = {}; char utc_end_[64] = {}; + int chunk_start_ = 0; + int chunk_end_ = 0; bool range_initialized_ = false; Phase phase_ = Phase::Idle; diff --git a/tools/inspector/src/services/replay_cut_service.cpp b/tools/inspector/src/services/replay_cut_service.cpp index 9b8189c..6aa805b 100644 --- a/tools/inspector/src/services/replay_cut_service.cpp +++ b/tools/inspector/src/services/replay_cut_service.cpp @@ -4,8 +4,13 @@ #include #include #include +#include #include +#include +#include + +#include "vtx/reader/core/vtx_reader_facade.h" #include "vtx/writer/policies/formatters/flatbuffers_vtx_policy.h" namespace { @@ -33,6 +38,24 @@ namespace { return true; } + // Mirror of ChunkedFileSink::CompressIfBeneficial with the writer defaults + // (skip tiny payloads, keep the raw payload when zstd does not shrink it). + std::string CompressIfBeneficial(std::string payload) { + constexpr int kCompressionLevel = 10; + if (payload.size() < 512) { + return payload; + } + const size_t max_size = ZSTD_compressBound(payload.size()); + std::string compressed(max_size, '\0'); + const size_t compressed_size = + ZSTD_compress(compressed.data(), max_size, payload.data(), payload.size(), kCompressionLevel); + if (ZSTD_isError(compressed_size) || compressed_size >= payload.size()) { + return payload; + } + compressed.resize(compressed_size); + return compressed; + } + // Duration of the sliced range from a tick table (first..last nonzero), or a // negative value when the table cannot answer. double DurationFromTicks(const std::vector& ticks) { @@ -52,11 +75,64 @@ namespace { return static_cast(last - first) / kTicksPerSecond; } + // Serializes frames [kept_lo, kept_hi] of the source into a fresh chunk + // payload (mirrors ChunkedFileSink::SaveChunk framing: length-prefixed, + // compressed when beneficial, checksummed on the on-disk bytes). + bool WriteRewrittenChunk(VTX::IVtxReaderFacade& reader, std::ofstream& out, int kept_lo, int kept_hi, + int new_chunk_index, int rebase_frame, VTX::ChunkIndexData& entry_out, + std::string& error) { + std::vector> frames; + frames.reserve(static_cast(kept_hi - kept_lo + 1)); + for (int frame = kept_lo; frame <= kept_hi; ++frame) { + const VTX::Frame* source_frame = reader.GetFrameSync(frame); + if (!source_frame) { + error = "Could not read frame " + std::to_string(frame) + " from the source replay."; + return false; + } + frames.push_back(std::make_unique(*source_frame)); + } + + std::string payload = + VTX::FlatBuffersVtxPolicy::SerializeChunk(frames, new_chunk_index, /*is_compressed=*/true); + payload = CompressIfBeneficial(std::move(payload)); + + entry_out.chunk_index = new_chunk_index; + entry_out.file_offset = static_cast(out.tellp()); + entry_out.chunk_size_bytes = static_cast(payload.size() + sizeof(uint32_t)); + entry_out.start_frame = kept_lo - rebase_frame; + entry_out.end_frame = kept_hi - rebase_frame; + entry_out.checksum = XXH3_64bits(payload.data(), payload.size()); + + const uint32_t payload_size = static_cast(payload.size()); + if (!out.write(reinterpret_cast(&payload_size), sizeof(payload_size)) || + !out.write(payload.data(), static_cast(payload.size()))) { + error = "Write failed for rewritten chunk " + std::to_string(new_chunk_index); + return false; + } + return true; + } + + VtxServices::ReplayCutPlan PlanForChunkSpan(const VTX::FileFooter& footer, int first_chunk, int last_chunk, + int first_frame, int last_frame) { + VtxServices::ReplayCutPlan plan; + plan.first_chunk = first_chunk; + plan.last_chunk = last_chunk; + plan.first_frame = first_frame; + plan.last_frame = last_frame; + plan.trims_head = footer.chunk_index[static_cast(first_chunk)].start_frame < first_frame; + plan.trims_tail = footer.chunk_index[static_cast(last_chunk)].end_frame > last_frame; + for (int i = first_chunk; i <= last_chunk; ++i) { + plan.chunk_bytes += footer.chunk_index[static_cast(i)].chunk_size_bytes; + } + plan.valid = true; + return plan; + } + } // namespace namespace VtxServices { - ReplayCutPlan ReplayCutService::PlanCut(const VTX::FileFooter& footer, int start_frame, int end_frame) { + ReplayCutPlan ReplayCutService::PlanCutFrames(const VTX::FileFooter& footer, int start_frame, int end_frame) { ReplayCutPlan plan; if (footer.chunk_index.empty() || footer.total_frames <= 0) { plan.error = "The loaded replay has no chunk index."; @@ -84,16 +160,29 @@ namespace VtxServices { plan.error = "The requested range does not overlap any chunk."; return plan; } + // Clamp the exact bounds into the covered span (start may fall in a gap + // between chunks; snap it to the first covered frame). + const int lo = std::max(start_frame, footer.chunk_index[static_cast(first_chunk)].start_frame); + const int hi = std::min(end_frame, footer.chunk_index[static_cast(last_chunk)].end_frame); + return PlanForChunkSpan(footer, first_chunk, last_chunk, lo, hi); + } - plan.first_chunk = first_chunk; - plan.last_chunk = last_chunk; - plan.first_frame = footer.chunk_index[static_cast(first_chunk)].start_frame; - plan.last_frame = footer.chunk_index[static_cast(last_chunk)].end_frame; - for (int i = first_chunk; i <= last_chunk; ++i) { - plan.chunk_bytes += footer.chunk_index[static_cast(i)].chunk_size_bytes; + ReplayCutPlan ReplayCutService::PlanCutChunks(const VTX::FileFooter& footer, int first_chunk, int last_chunk) { + ReplayCutPlan plan; + if (footer.chunk_index.empty()) { + plan.error = "The loaded replay has no chunk index."; + return plan; } - plan.valid = true; - return plan; + const int max_chunk = static_cast(footer.chunk_index.size()) - 1; + first_chunk = std::clamp(first_chunk, 0, max_chunk); + last_chunk = std::clamp(last_chunk, 0, max_chunk); + if (last_chunk < first_chunk) { + plan.error = "End chunk is before start chunk."; + return plan; + } + return PlanForChunkSpan(footer, first_chunk, last_chunk, + footer.chunk_index[static_cast(first_chunk)].start_frame, + footer.chunk_index[static_cast(last_chunk)].end_frame); } bool ReplayCutService::ExecuteCut(const std::string& source_path, const VTX::FileFooter& footer, @@ -109,6 +198,19 @@ namespace VtxServices { return false; } + // Edge chunks that start/end inside the kept range must be re-serialized + // frame by frame; open a private reader for that (the caller's reader may + // be busy on the UI thread). + VTX::ReaderContext reader_context; + if (plan.trims_head || plan.trims_tail) { + reader_context = VTX::OpenReplayFile(source_path); + if (!reader_context.Loaded() || !reader_context.WaitUntilReady()) { + error = + "Could not reopen the source replay for edge-chunk rewriting: " + reader_context.GetError().message; + return false; + } + } + std::ifstream in(source_path, std::ios::binary); if (!in) { error = "Could not open the source replay: " + source_path; @@ -129,22 +231,35 @@ namespace VtxServices { return false; } - // 2) Kept chunks, verbatim, while rebuilding the seek table with new - // offsets and frame numbering rebased so the cut starts at frame 0. + // 2) Chunks. Whole chunks are copied verbatim; partial edge chunks are + // rebuilt with only the kept frames and get fresh index metadata. std::vector seek_table; seek_table.reserve(static_cast(plan.last_chunk - plan.first_chunk + 1)); for (int i = plan.first_chunk; i <= plan.last_chunk; ++i) { const auto& entry = footer.chunk_index[static_cast(i)]; - VTX::ChunkIndexData rebased; - rebased.chunk_index = i - plan.first_chunk; - rebased.file_offset = static_cast(out.tellp()); - rebased.chunk_size_bytes = entry.chunk_size_bytes; - rebased.start_frame = entry.start_frame - plan.first_frame; - rebased.end_frame = entry.end_frame - plan.first_frame; - rebased.checksum = entry.checksum; - seek_table.push_back(rebased); - if (!CopyRange(in, out, entry.file_offset, entry.chunk_size_bytes, error)) { - return false; + const int kept_lo = std::max(entry.start_frame, plan.first_frame); + const int kept_hi = std::min(entry.end_frame, plan.last_frame); + const int new_index = static_cast(seek_table.size()); + + if (kept_lo == entry.start_frame && kept_hi == entry.end_frame) { + VTX::ChunkIndexData rebased; + rebased.chunk_index = new_index; + rebased.file_offset = static_cast(out.tellp()); + rebased.chunk_size_bytes = entry.chunk_size_bytes; + rebased.start_frame = entry.start_frame - plan.first_frame; + rebased.end_frame = entry.end_frame - plan.first_frame; + rebased.checksum = entry.checksum; + seek_table.push_back(rebased); + if (!CopyRange(in, out, entry.file_offset, entry.chunk_size_bytes, error)) { + return false; + } + } else { + VTX::ChunkIndexData rewritten; + if (!WriteRewrittenChunk(*reader_context.reader, out, kept_lo, kept_hi, new_index, plan.first_frame, + rewritten, error)) { + return false; + } + seek_table.push_back(rewritten); } } diff --git a/tools/inspector/src/windows/cut_replay_window.cpp b/tools/inspector/src/windows/cut_replay_window.cpp index 11fed41..9cb97a2 100644 --- a/tools/inspector/src/windows/cut_replay_window.cpp +++ b/tools/inspector/src/windows/cut_replay_window.cpp @@ -110,6 +110,8 @@ void CutReplayWindow::ResetRangeToFullReplay() { const int total_frames = footer.total_frames; frame_start_ = 0; frame_end_ = std::max(total_frames - 1, 0); + chunk_start_ = 0; + chunk_end_ = std::max(static_cast(footer.chunk_index.size()) - 1, 0); time_start_seconds_ = 0.0; time_end_seconds_ = static_cast(footer.duration_seconds); utc_start_[0] = '\0'; @@ -220,15 +222,16 @@ void CutReplayWindow::DrawContent() { } const auto& footer = session_->GetFooter(); - ImGui::TextWrapped("Write a new .vtx containing a sub-range of the loaded replay. The range snaps to " - "whole chunks: chunks are copied verbatim, and the footer is rebuilt for the cut."); + ImGui::TextWrapped("Write a new .vtx containing a sub-range of the loaded replay. Time/Frame/UTC ranges cut " + "exactly: partial edge chunks are rewritten with only the kept frames. Chunk ranges keep " + "whole chunks verbatim. The footer is rebuilt either way."); ImGui::Spacing(); const bool busy = (phase_ == Phase::Running); ImGui::BeginDisabled(busy); - ImGui::SetNextItemWidth(160.0f); - ImGui::Combo("Range mode", &range_mode_, "Time (elapsed seconds)\0Frame\0UTC\0"); + ImGui::SetNextItemWidth(200.0f); + ImGui::Combo("Range mode", &range_mode_, "Time (elapsed seconds)\0Frame\0UTC\0Chunk (whole chunks)\0"); if (range_mode_ == 0) { ImGui::SetNextItemWidth(120.0f); @@ -246,12 +249,20 @@ void CutReplayWindow::DrawContent() { ImGui::SetNextItemWidth(120.0f); ImGui::InputInt("End frame", &frame_end_, 0, 0); ImGui::TextColored(kDim, "Replay frames: 0 .. %d", std::max(footer.total_frames - 1, 0)); - } else { + } else if (range_mode_ == 2) { ImGui::SetNextItemWidth(260.0f); ImGui::InputText("Start UTC", utc_start_, sizeof(utc_start_)); ImGui::SetNextItemWidth(260.0f); ImGui::InputText("End UTC", utc_end_, sizeof(utc_end_)); ImGui::TextColored(kDim, "ISO-8601 (2026-07-24T09:50:29.195Z) or unix seconds / ms / 100ns ticks."); + } else { + ImGui::SetNextItemWidth(120.0f); + ImGui::InputInt("Start chunk", &chunk_start_, 0, 0); + ImGui::SameLine(); + ImGui::SetNextItemWidth(120.0f); + ImGui::InputInt("End chunk", &chunk_end_, 0, 0); + ImGui::TextColored(kDim, "Replay chunks: 0 .. %d (kept whole, copied verbatim)", + std::max(static_cast(footer.chunk_index.size()) - 1, 0)); } if (ImGui::Button("Reset to full replay")) { @@ -262,14 +273,18 @@ void CutReplayWindow::DrawContent() { ImGui::Separator(); // Live plan preview. - int requested_start = 0; - int requested_end = 0; - std::string input_error; VtxServices::ReplayCutPlan plan; - if (ResolveRequestedFrames(requested_start, requested_end, input_error)) { - plan = VtxServices::ReplayCutService::PlanCut(footer, requested_start, requested_end); + if (range_mode_ == 3) { + plan = VtxServices::ReplayCutService::PlanCutChunks(footer, chunk_start_, chunk_end_); } else { - plan.error = input_error; + int requested_start = 0; + int requested_end = 0; + std::string input_error; + if (ResolveRequestedFrames(requested_start, requested_end, input_error)) { + plan = VtxServices::ReplayCutService::PlanCutFrames(footer, requested_start, requested_end); + } else { + plan.error = input_error; + } } if (!plan.valid) { @@ -279,11 +294,26 @@ void CutReplayWindow::DrawContent() { plan.first_frame, footer.times, footer.total_frames, footer.duration_seconds); const float end_sec = VtxServices::TimelineViewService::FrameToElapsedSeconds( plan.last_frame, footer.times, footer.total_frames, footer.duration_seconds); - ImGui::Text("Cut (snapped to chunks): frames %d .. %d (%d frames)", plan.first_frame, plan.last_frame, + ImGui::Text("Cut: frames %d .. %d (%d frames)", plan.first_frame, plan.last_frame, plan.last_frame - plan.first_frame + 1); ImGui::Text("Time %s .. %s | chunks %d .. %d of %d | ~%.1f MB", FormatClock(start_sec).c_str(), FormatClock(end_sec).c_str(), plan.first_chunk, plan.last_chunk, static_cast(footer.chunk_index.size()), plan.chunk_bytes / (1024.0 * 1024.0)); + if (plan.trims_head || plan.trims_tail) { + const auto& head = footer.chunk_index[static_cast(plan.first_chunk)]; + const auto& tail = footer.chunk_index[static_cast(plan.last_chunk)]; + std::string edge = "Edge rewrite: "; + if (plan.trims_head) { + edge += "head chunk drops " + std::to_string(plan.first_frame - head.start_frame) + " frame(s)"; + } + if (plan.trims_head && plan.trims_tail) { + edge += ", "; + } + if (plan.trims_tail) { + edge += "tail chunk drops " + std::to_string(tail.end_frame - plan.last_frame) + " frame(s)"; + } + ImGui::TextColored(kDim, "%s", edge.c_str()); + } } ImGui::Spacing();