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/ diff --git a/tools/inspector/include/services/timeline_view_service.h b/tools/inspector/include/services/timeline_view_service.h index 6393153..ce9add9 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 { @@ -33,13 +35,58 @@ 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. + // 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 + std::vector gaps; + 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); 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); + + // 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. + 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, + 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/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 a9e08ac..c9f62a5 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,113 @@ 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; + 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; + const uint64_t base_tick = FirstNonZeroTick(ticks); + + 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; + } + 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; + } + return map; + } + + 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..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,11 +10,18 @@ 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; 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 +32,7 @@ namespace { float y_bottom = 0.0f; bool is_current = false; bool is_hovered = false; + bool is_dropped = false; }; struct TimelineStripViewModel { @@ -53,24 +62,34 @@ 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, + const VtxServices::DroppedFrameMap& dropped_map) { 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; 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, float scroll_x, + int current_frame, float duration_seconds, + 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) { @@ -115,10 +134,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); + view_model.hover_info = BuildHoverInfo(hovered_frame_idx, total_frames, duration_seconds, times, dropped_map); return view_model; } @@ -151,12 +172,30 @@ 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, time_span.total.minutes, time_span.total.seconds); + // Expected capture rate for the recording-gap (red bar) detection. + ImGui::SameLine(); + ImGui::SetNextItemWidth(70.0f); + 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); + } + 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); @@ -171,16 +210,56 @@ 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) { - (void)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()); + + // 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) { + 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_ * kDropFpsRealWorldFactor); + 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. @@ -198,11 +277,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, + 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(); @@ -215,7 +294,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)); @@ -232,6 +311,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();