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..15e3780 --- /dev/null +++ b/tools/inspector/include/services/replay_cut_service.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +#include "vtx/common/vtx_types.h" + +namespace VtxServices { + + // 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; // exact kept range, original frame numbering + int last_frame = 0; + 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, 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: + // 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. 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); + }; + +} // 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..87b405c --- /dev/null +++ b/tools/inspector/include/windows/cut_replay_window.h @@ -0,0 +1,68 @@ +#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 (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; + 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..6aa805b --- /dev/null +++ b/tools/inspector/src/services/replay_cut_service.cpp @@ -0,0 +1,328 @@ +#include "services/replay_cut_service.h" + +#include +#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 { + + 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; + } + + // 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) { + 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; + } + + // 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::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."; + 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; + } + // 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); + } + + 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; + } + 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, + 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; + } + + // 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; + 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) 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)]; + 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); + } + } + + // 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..9cb97a2 --- /dev/null +++ b/tools/inspector/src/windows/cut_replay_window.cpp @@ -0,0 +1,362 @@ +#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); + 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'; + 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. 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(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); + 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 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")) { + ResetRangeToFullReplay(); + } + + ImGui::EndDisabled(); + ImGui::Separator(); + + // Live plan preview. + VtxServices::ReplayCutPlan plan; + if (range_mode_ == 3) { + plan = VtxServices::ReplayCutService::PlanCutChunks(footer, chunk_start_, chunk_end_); + } else { + 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) { + 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: 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(); + 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."); + } + } +}