Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,4 @@ __pycache__/
# OS
Thumbs.db
Desktop.ini
.DS_Store
.DS_Storebuild-inspector/
47 changes: 47 additions & 0 deletions tools/inspector/include/services/timeline_view_service.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#pragma once

#include "vtx/common/vtx_types.h"

namespace VtxServices {

struct ClockTime {
Expand Down Expand Up @@ -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<uint8_t> flagged; // 1 = frame sits in a recording gap
std::vector<float> gap_ms; // wall-clock gap the frame belongs to (0 when none)
std::vector<int32_t> missing; // estimated frames missing in that gap
std::vector<DroppedFrameGap> 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);
Expand Down
11 changes: 11 additions & 0 deletions tools/inspector/include/windows/timeline_window.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<InspectorSession> 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;
};
168 changes: 168 additions & 0 deletions tools/inspector/src/services/timeline_view_service.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,53 @@
#include "services/timeline_view_service.h"

#include <algorithm>
#include <cstdint>
#include <vector>

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<uint64_t>& 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<uint64_t>& ticks, int frame) {
if (ticks.empty() || frame < 0) {
return 0;
}
int index = std::min(frame, static_cast<int>(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<uint64_t>& 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<float>(static_cast<double>(frame_tick - base_tick) / kTicksPerSecond);
}

} // namespace

namespace VtxServices {

Expand All @@ -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<float>(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);
Expand All @@ -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<uint64_t>& 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<int>(std::max(seconds, 0.0f) * fps), total_frames);
}

const uint64_t target_tick =
base_tick + static_cast<uint64_t>(std::max(0.0, static_cast<double>(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<int>(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<size_t>(probe)] == 0) {
--probe;
}
if (probe < low) {
low = mid + 1;
continue;
}
if (ticks[static_cast<size_t>(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<size_t>(total_frames), 0);
map.gap_ms.assign(static_cast<size_t>(total_frames), 0.0f);
map.missing.assign(static_cast<size_t>(total_frames), 0);

const std::vector<uint64_t>& 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<double>(expected_fps);
const int last_index = std::min(total_frames, static_cast<int>(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<size_t>(frame)] == 0) {
continue;
}
if (prev_stamped >= 0) {
const int span_frames = frame - prev_stamped;
const double actual_ms =
static_cast<double>(ticks[static_cast<size_t>(frame)] - ticks[static_cast<size_t>(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<int>(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<size_t>(i)] = 1;
map.gap_ms[static_cast<size_t>(i)] = static_cast<float>(actual_ms);
map.missing[static_cast<size_t>(i)] = missing;
}
map.gaps.push_back(DroppedFrameGap {
.first_frame = prev_stamped + 1,
.last_frame = frame,
.start_seconds = static_cast<float>(
static_cast<double>(ticks[static_cast<size_t>(prev_stamped)] - base_tick) / 10'000'000.0),
.duration_ms = static_cast<float>(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;
Expand Down
Loading
Loading