IQ.Pilot Release Commit @ e46d557

This commit is contained in:
IQ.Lvbs CI [bot]
2026-08-07 00:13:39 -05:00
parent 03c3158b81
commit 824bb9ddfd
216 changed files with 7457 additions and 3151 deletions

View File

@@ -17,12 +17,14 @@ from iqdbc.car.fingerprints import MIGRATION
from iqdbc.car.values import PLATFORMS
from openpilot.common.basedir import BASEDIR
Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib')
Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs')
jot_env = env.Clone()
# imgui.MESA_DIR only exists on the larch64 wheel; skip missing paths to avoid an ld search-path warning
jot_env["LIBPATH"] += [p for p in [imgui.MESA_DIR, libusb.LIB_DIR] if os.path.isdir(p)]
jot_env["CPPPATH"] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR]
# IQ.Pilot patch: no comma-deps-bootstrap-icons wheel here, so BOOTSTRAP_ICONS_TTF is
# not defined; icons.cc reads the TTF vendored under tools/jotpluggler/assets instead.
jot_env["CXXFLAGS"] += [
"-DGLFW_INCLUDE_NONE",
'-DJOTP_REPO_ROOT=\'"%s"\'' % os.path.realpath(BASEDIR),
@@ -104,16 +106,15 @@ event_extractors = jot_env.Command("generated_event_extractors.h", [
jot_env.PrettyAction(generate_event_extractors, 'GEN'),
)
libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a"),
# IQ.Pilot patch: iqpilot's replay_lib resolves URL/api work via libcurl
# (vs upstream's Python downloader), so jotpluggler needs to link curl too.
# iqpilot's api.cc additionally uses OpenSSL primitives (PEM/RSA/SHA256) for
# JWT signing, and visionipc references OpenCL — both must also be linked.
"avformat", "avcodec", "avutil", "x264", "yuv", "z", "bz2", "zstd", "curl", "ssl", "crypto", "m", "pthread", "usb-1.0"]
# IQ.Pilot patch: iqpilot's replay_lib resolves route/API work via libcurl (vs upstream's
# Python downloader), so jotpluggler needs curl; api.cc signs konn3kt JWTs with OpenSSL
# primitives, and visionipc references OpenCL. Both must also be linked.
libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a")] + \
ffmpeg_libs + ["bz2", "zstd", "curl", "ssl", "crypto", "m", "pthread", "usb-1.0"]
if arch == "Darwin":
jot_env["FRAMEWORKS"] = ["OpenGL", "OpenCL", "Cocoa", "IOKit", "CoreFoundation", "CoreVideo", "CoreMedia", "VideoToolbox"]
else:
libs += ["GL", "OpenCL", "dl", "va", "va-drm", "drm"]
libs += ["GL", "OpenCL", "dl"]
program = jot_env.Program("jotpluggler", jot_env.Glob("*.cc"), LIBS=libs)
jot_env.Depends(program, generated_dbc_stamp)

View File

@@ -3,6 +3,7 @@
#include "tools/jotpluggler/common.h"
#include "tools/jotpluggler/internal.h"
#include "tools/jotpluggler/map.h"
#include "tools/jotpluggler/thumbnail.h"
#include "system/hardware/hw.h"
#include "imgui_impl_glfw.h"
@@ -1033,10 +1034,12 @@ bool apply_special_item_to_pane(WorkspaceTab *tab, TabUiState *tab_state, int pa
if (pane.title == UNTITLED_PANE_TITLE || previous_kind != PaneKind::Plot) {
pane.title = spec->label;
}
} else {
} else if (spec->kind == PaneKind::Camera) {
pane.title = spec->label;
resize_tab_pane_state(tab_state, tab->panes.size());
tab_state->camera_panes[static_cast<size_t>(pane_index)].fit_to_pane = true;
} else {
pane.title = spec->label;
}
tab_state->active_pane_index = pane_index;
return true;
@@ -1565,6 +1568,8 @@ void draw_pane_windows(AppSession *session, UiState *state) {
}
if (pane.kind == PaneKind::Map) {
draw_map_pane(session, state, &pane, static_cast<int>(i));
} else if (pane.kind == PaneKind::Thumbnail) {
draw_thumbnail_pane(session, state);
} else if (pane.kind == PaneKind::Camera) {
draw_camera_pane(session, state, tab_state, static_cast<int>(i), pane);
} else {
@@ -1847,6 +1852,7 @@ int run(const Options &options) {
for (std::unique_ptr<CameraFeedView> &feed : session.pane_camera_feeds) {
feed = std::make_unique<CameraFeedView>();
}
session.thumbnail_view = std::make_unique<ThumbnailView>();
sync_camera_feeds(&session);
if (session.async_route_loading) {
@@ -1892,6 +1898,7 @@ int run(const Options &options) {
for (std::unique_ptr<CameraFeedView> &feed : session.pane_camera_feeds) {
feed.reset();
}
session.thumbnail_view.reset();
return 0;
} catch (const std::exception &err) {
std::cerr << err.what() << "\n";

View File

@@ -81,6 +81,7 @@ struct Curve {
enum class PaneKind : uint8_t {
Plot,
Map,
Thumbnail,
Camera,
};
@@ -141,9 +142,15 @@ struct CameraFeedIndex {
std::vector<CameraFrameIndexEntry> entries;
};
struct ThumbnailFrame {
double timestamp = 0.0;
int segment = -1;
std::vector<uint8_t> jpeg;
};
enum class LogOrigin : uint8_t {
Log,
Android,
OperatingSystem,
Alert,
};
@@ -318,6 +325,7 @@ struct RouteData {
CameraFeedIndex driver_camera;
CameraFeedIndex wide_road_camera;
CameraFeedIndex qroad_camera;
std::vector<ThumbnailFrame> thumbnails;
GpsTrace gps_trace;
std::vector<LogEntry> logs;
std::vector<TimelineEntry> timeline;
@@ -445,6 +453,7 @@ bool icon_menu_item(const char *glyph,
class AsyncRouteLoader;
class CameraFeedView;
class ThumbnailView;
class StreamPoller;
class MapDataManager;
@@ -486,6 +495,7 @@ struct AppSession {
std::unique_ptr<AsyncRouteLoader> route_loader;
std::unique_ptr<StreamPoller> stream_poller;
std::array<std::unique_ptr<CameraFeedView>, 4> pane_camera_feeds;
std::unique_ptr<ThumbnailView> thumbnail_view;
std::unique_ptr<MapDataManager> map_data;
bool async_route_loading = false;
double next_stream_custom_refresh_time = 0.0;
@@ -885,3 +895,20 @@ private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
class ThumbnailView {
public:
ThumbnailView();
~ThumbnailView();
ThumbnailView(const ThumbnailView &) = delete;
ThumbnailView &operator=(const ThumbnailView &) = delete;
void setThumbnails(const std::vector<ThumbnailFrame> &thumbnails);
void update(double tracker_time);
void drawSized(ImVec2 size, bool loading);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};

View File

@@ -47,11 +47,11 @@ const char *special_item_label(std::string_view item_id) {
}
bool pane_kind_is_special(PaneKind kind) {
return kind == PaneKind::Map || kind == PaneKind::Camera;
return kind == PaneKind::Map || kind == PaneKind::Thumbnail || kind == PaneKind::Camera;
}
bool is_default_special_title(std::string_view title) {
if (title == "Map") return true;
if (title == "Map" || title == "Thumbnail") return true;
return std::any_of(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
return title == spec.label;
});
@@ -162,14 +162,12 @@ void open_external_url(std::string_view url) {
}
}
std::string route_useradmin_url(const RouteIdentifier &route_id) {
return route_id.empty() ? std::string()
: "https://useradmin.comma.ai/?onebox=" + route_id.dongle_id + "%7C" + route_id.log_id;
}
std::string route_connect_url(const RouteIdentifier &route_id) {
return route_id.empty() ? std::string()
: "https://connect.comma.ai/" + route_id.canonical();
// IQ.Pilot patch: iqpilot routes live on konn3kt, not comma connect. konn3kt has no
// useradmin equivalent, so that link is gone; the share link keeps connect's
// <dongle_id>/<log_id> path shape (see tools/lib/logreader.py parse_indirect).
std::string route_konn3kt_url(const RouteIdentifier &route_id) {
static const std::string host = util::getenv("KONN3KT_APP_HOST", "https://konn3kt.com");
return route_id.empty() ? std::string() : host + "/" + route_id.canonical();
}
std::string route_google_maps_url(const GpsTrace &trace) {

View File

@@ -28,8 +28,9 @@ inline constexpr std::array<CameraViewSpec, 4> kCameraViewSpecs = {{
{CameraViewKind::QRoad, "qRoad Camera", "qroad", "qroad", "camera_qroad", &RouteData::qroad_camera},
}};
inline constexpr std::array<SpecialItemSpec, 5> kSpecialItemSpecs = {{
inline constexpr std::array<SpecialItemSpec, 6> kSpecialItemSpecs = {{
{"map", "Map", PaneKind::Map, CameraViewKind::Road},
{"thumbnail", "Thumbnail", PaneKind::Thumbnail, CameraViewKind::Road},
{kCameraViewSpecs[0].special_item_id, kCameraViewSpecs[0].label, PaneKind::Camera, kCameraViewSpecs[0].view},
{kCameraViewSpecs[1].special_item_id, kCameraViewSpecs[1].label, PaneKind::Camera, kCameraViewSpecs[1].view},
{kCameraViewSpecs[2].special_item_id, kCameraViewSpecs[2].label, PaneKind::Camera, kCameraViewSpecs[2].view},
@@ -62,6 +63,5 @@ bool app_begin_popup_modal(const char *name,
bool *p_open = nullptr,
ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize);
void open_external_url(std::string_view url);
std::string route_useradmin_url(const RouteIdentifier &route_id);
std::string route_connect_url(const RouteIdentifier &route_id);
std::string route_konn3kt_url(const RouteIdentifier &route_id);
std::string route_google_maps_url(const GpsTrace &trace);

View File

@@ -276,6 +276,7 @@ inline void Database::parseSg(const std::string &line, int line_number, Message
signal.type = Signal::Type::Multiplexor;
} else if (!indicator.empty() && indicator.front() == 'm') {
signal.type = Signal::Type::Multiplexed;
// IQ.Pilot patch: guard a bare "m" indicator; std::stoi("") throws.
const std::string mux = indicator.substr(1);
signal.multiplex_value = mux.empty() ? 0 : std::stoi(mux);
} else {

View File

@@ -62,6 +62,8 @@ class Generator:
def __init__(self, event_schema):
self.event_schema = event_schema
self.fixed_paths = []
self.event_base_slots = {}
self.static_enums = []
self.tmp_index = 0
self.lines = []
self.emits_memo = {}
@@ -103,9 +105,13 @@ class Generator:
self.emit(indent, f"append_dynamic_scalar_point({path_expr}, tm, {double_expr}, series);")
else:
slot = self.add_fixed_path(path)
if kind == "Enum":
self.emit_enum_capture(indent, cxx_string(path), enum_names(schema))
self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});")
names = enum_names(schema) if kind == "Enum" else []
if names:
enum_index = len(self.static_enums)
self.static_enums.append(names)
self.emit(indent, f"append_fixed_enum_point({slot}, {enum_index}, tm, {double_expr}, series);")
else:
self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});")
return
if type_kind == "struct":
@@ -144,9 +150,13 @@ class Generator:
self.emit(indent, f"if ({' && '.join(conditions)}) {{")
indent += 2
value_var = self.tmp("value")
self.emit(indent, f"const auto {value_var} = {get_call};")
self.emit_node(indent, type_kind, type_proto, value_schema, value_var, field_path, field_path_expr, dynamic_path)
# Scalar getters are only consumed once. Emitting them directly avoids
# thousands of single-use locals in the generated extractor.
value_expr = get_call
if kind is None:
value_expr = self.tmp("value")
self.emit(indent, f"const auto {value_expr} = {get_call};")
self.emit_node(indent, type_kind, type_proto, value_schema, value_expr, field_path, field_path_expr, dynamic_path)
if conditions:
indent -= 2
@@ -249,35 +259,47 @@ class Generator:
self.emit(indent + 2, "}")
self.emit(indent, "}")
self.emit(indent, "if (skip_raw_can) {")
self.emit(indent + 2, "return true;")
self.emit(indent + 2, "return;")
self.emit(indent, "}")
def emit_event_case(self, field_name):
def emit_event_reader(self, field_name):
field = self.event_schema.fields[field_name]
proto = field.proto
type_kind = field_type(field)
type_proto = field_type_proto(field)
kind = scalar_kind(type_proto)
schema = field.schema if kind == "Enum" or type_kind in NESTED_TYPE_KINDS else None
self.emit(4, f"case static_cast<cereal::Event::Which>({proto.discriminantValue}): {{")
valid_slot = self.add_fixed_path(f"/{field_name}/valid")
mono_slot = self.add_fixed_path(f"/{field_name}/logMonoTime")
seconds_slot = self.add_fixed_path(f"/{field_name}/t")
self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{valid_slot}], tm, event.getValid() ? 1.0 : 0.0);")
self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{mono_slot}], tm, static_cast<double>(event.getLogMonoTime()));")
self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{seconds_slot}], tm, tm);")
if field_name in {"can", "sendcan"}:
self.emit_can_special(6, field_name)
if self.node_emits(type_kind, type_proto, schema):
self.add_fixed_path(f"/{field_name}/logMonoTime")
self.add_fixed_path(f"/{field_name}/t")
self.event_base_slots[proto.discriminantValue] = valid_slot
emits_payload = self.node_emits(type_kind, type_proto, schema)
if field_name not in {"can", "sendcan"} and not emits_payload:
return None
reader_name = f"append_event_{proto.discriminantValue}"
needs_can = field_name in {"can", "sendcan"}
header_index = len(self.lines)
self.emit(0, "")
if needs_can:
self.emit_can_special(2, field_name)
if emits_payload:
payload = self.tmp("payload")
self.emit(6, f"const auto {payload} = event.{accessor('get', field_name)}();")
self.emit_node(6, type_kind, type_proto, schema, payload, f"/{field_name}", None, False)
self.emit(6, "return true;")
self.emit(4, "}")
self.emit(2, f"const auto {payload} = event.{accessor('get', field_name)}();")
self.emit_node(2, type_kind, type_proto, schema, payload, f"/{field_name}", None, False)
self.emit(0, "}")
self.emit(0, "")
if needs_can:
signature = "const cereal::Event::Reader &event, const dbc::Database *can_dbc, bool skip_raw_can, double tm, SeriesAccumulator *series"
else:
signature = "const cereal::Event::Reader &event, double tm, SeriesAccumulator *series"
self.lines[header_index] = f"__attribute__((noinline)) void {reader_name}({signature}) {{"
return reader_name, needs_can
def generate(self):
self.lines = []
self.emit(0, "// Generated by tools/jotpluggler/generate_event_extractors.py; do not edit.")
self.emit(0, "// Generated by openpilot/tools/jotpluggler/generate_event_extractors.py; do not edit.")
self.emit(0, "")
self.emit(0, "const std::vector<std::string> &static_event_fixed_paths() {")
self.emit(2, "static const std::vector<std::string> paths = {")
@@ -300,11 +322,66 @@ class Generator:
self.emit(2, "}")
self.emit(0, "}")
self.emit(0, "")
self.emit(0, "__attribute__((noinline)) void append_fixed_enum_point(size_t series_slot, size_t enum_index, double tm, double value, SeriesAccumulator *series);") # noqa: E501
self.emit(0, "")
self.emit(0, "// Keep each event payload behind its own optimizer boundary. Combining the")
self.emit(0, "// whole schema into one function creates much more code and runs slower.")
event_readers = {}
for field_name in self.event_schema.union_fields:
event_readers[field_name] = self.emit_event_reader(field_name)
self.emit(0, "static const std::initializer_list<std::string_view> static_event_enum_names[] = {")
for names in self.static_enums:
names_expr = "{" + ", ".join(cxx_string(name) for name in names) + "}"
self.emit(2, f"{names_expr},")
self.emit(0, "};")
self.emit(0, "")
self.emit(0, "__attribute__((noinline)) void append_fixed_enum_point(size_t series_slot, size_t enum_index, double tm, double value, SeriesAccumulator *series) {") # noqa: E501
self.emit(2, "RouteSeries *fixed_series = &series->fixed_series[series_slot];")
self.emit(2, "capture_static_enum_info(fixed_series->path, static_event_enum_names[enum_index], series);")
self.emit(2, "fixed_series->times.push_back(tm);")
self.emit(2, "fixed_series->values.push_back(value);")
self.emit(0, "}")
self.emit(0, "")
self.emit(0, "bool append_event_static_reader(cereal::Event::Which which, const cereal::Event::Reader &event, const dbc::Database *can_dbc, bool skip_raw_can, double time_offset, SeriesAccumulator *series) {") # noqa: E501
self.emit(2, "const double tm = static_cast<double>(event.getLogMonoTime()) / 1.0e9 - time_offset;")
self.emit(2, "const auto log_mono_time = event.getLogMonoTime();")
self.emit(2, "const double tm = static_cast<double>(log_mono_time) / 1.0e9 - time_offset;")
invalid_slot = "static_cast<size_t>(-1)"
max_discriminant = max(self.event_base_slots)
base_slots = [self.event_base_slots.get(i, invalid_slot) for i in range(max_discriminant + 1)]
self.emit(2, "static constexpr size_t event_base_slots[] = {")
for slot in base_slots:
self.emit(4, f"{slot},")
self.emit(2, "};")
self.emit(2, "const size_t event_index = static_cast<size_t>(which);")
self.emit(2, "if (event_index >= sizeof(event_base_slots) / sizeof(event_base_slots[0])) {")
self.emit(4, "return false;")
self.emit(2, "}")
self.emit(2, "const size_t base_slot = event_base_slots[event_index];")
self.emit(2, f"if (base_slot == {invalid_slot}) {{")
self.emit(4, "return false;")
self.emit(2, "}")
self.emit(2, "RouteSeries *base_series = &series->fixed_series[base_slot];")
self.emit(2, "base_series[0].times.push_back(tm);")
self.emit(2, "base_series[0].values.push_back(event.getValid() ? 1.0 : 0.0);")
self.emit(2, "base_series[1].times.push_back(tm);")
self.emit(2, "base_series[1].values.push_back(static_cast<double>(log_mono_time));")
self.emit(2, "base_series[2].times.push_back(tm);")
self.emit(2, "base_series[2].values.push_back(tm);")
self.emit(2, "switch (which) {")
for field_name in self.event_schema.union_fields:
self.emit_event_case(field_name)
field = self.event_schema.fields[field_name]
self.emit(4, f"case static_cast<cereal::Event::Which>({field.proto.discriminantValue}):")
reader = event_readers[field_name]
if reader is not None:
if reader[1]:
self.emit(6, f"{reader[0]}(event, can_dbc, skip_raw_can, tm, series);")
else:
self.emit(6, f"{reader[0]}(event, tm, series);")
self.emit(6, "return true;")
self.emit(4, "default:")
self.emit(6, "return false;")
self.emit(2, "}")
@@ -323,6 +400,8 @@ if __name__ == "__main__":
repo_root = Path(sys.argv[1]).resolve()
output = Path(sys.argv[2])
capnp.remove_import_hook()
# IQ.Pilot patch: iqpilot's tree is not nested under openpilot/ and car.capnp lives
# in cereal/ alongside log.capnp, so no extra import path is needed.
log = capnp.load(str(repo_root / "cereal" / "log.capnp"))
generated = Generator(log.Event.schema).generate()
output.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -6,7 +6,7 @@
void icon_add_font(float size, bool merge, const ImFont *base_font) {
// IQ.Pilot patch: iqpilot's third_party/bootstrap is git-lfs and the TTF isn't
// checked in, so we vendor the font alongside jotpluggler instead of relying on
// third_party/bootstrap/bootstrap-icons.ttf.
// upstream's BOOTSTRAP_ICONS_TTF define from the comma-deps-bootstrap-icons wheel.
const std::filesystem::path ttf = repo_root() / "tools" / "jotpluggler" / "assets" / "bootstrap-icons.ttf";
ImGuiIO &io = ImGui::GetIO();
ImFontConfig config;

View File

@@ -62,6 +62,8 @@ json11::Json workspace_node_to_json(const WorkspaceNode &node, const WorkspaceTa
};
if (pane.kind == PaneKind::Map) {
obj["kind"] = "map";
} else if (pane.kind == PaneKind::Thumbnail) {
obj["kind"] = "thumbnail";
} else if (pane.kind == PaneKind::Camera) {
obj["kind"] = "camera";
obj["camera_view"] = camera_view_spec(pane.camera_view).layout_name;

View File

@@ -7,7 +7,7 @@
#include "imgui_impl_opengl3.h"
#include "imgui_impl_opengl3_loader.h"
#include "implot.h"
#include "libyuv.h"
#include "common/yuv.h"
#include "msgq_repo/msgq/ipc.h"
#include "tools/replay/framereader.h"
@@ -1173,18 +1173,14 @@ struct CameraFeedView::Impl {
result.width = reader->width;
result.height = reader->height;
result.rgba.resize(static_cast<size_t>(result.width) * static_cast<size_t>(result.height) * 4U, 0);
// IQ.Pilot patch: vendored libyuv lacks NV12ToABGR; use NV12ToARGB + in-place ARGBToABGR swap.
libyuv::NV12ToARGB(decode_buffer.y,
static_cast<int>(decode_buffer.stride),
decode_buffer.uv,
static_cast<int>(decode_buffer.stride),
result.rgba.data(),
result.width * 4,
result.width,
result.height);
libyuv::ARGBToABGR(result.rgba.data(), result.width * 4,
result.rgba.data(), result.width * 4,
result.width, result.height);
yuv::nv12_to_rgba(decode_buffer.y,
static_cast<int>(decode_buffer.stride),
decode_buffer.uv,
static_cast<int>(decode_buffer.stride),
result.rgba.data(),
result.width * 4,
result.width,
result.height);
result.success = true;
result.decode_ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - decode_begin).count();
publish_result(*request, std::move(result));
@@ -1207,18 +1203,14 @@ struct CameraFeedView::Impl {
.height = reader->height,
};
prefetched.rgba.resize(static_cast<size_t>(prefetched.width) * static_cast<size_t>(prefetched.height) * 4U, 0);
// IQ.Pilot patch: vendored libyuv lacks NV12ToABGR; use NV12ToARGB + in-place ARGBToABGR swap.
libyuv::NV12ToARGB(decode_buffer.y,
static_cast<int>(decode_buffer.stride),
decode_buffer.uv,
static_cast<int>(decode_buffer.stride),
prefetched.rgba.data(),
prefetched.width * 4,
prefetched.width,
prefetched.height);
libyuv::ARGBToABGR(prefetched.rgba.data(), prefetched.width * 4,
prefetched.rgba.data(), prefetched.width * 4,
prefetched.width, prefetched.height);
yuv::nv12_to_rgba(decode_buffer.y,
static_cast<int>(decode_buffer.stride),
decode_buffer.uv,
static_cast<int>(decode_buffer.stride),
prefetched.rgba.data(),
prefetched.width * 4,
prefetched.width,
prefetched.height);
remember_cached_result(prefetched);
}
}

View File

@@ -19,6 +19,9 @@ void sync_camera_feeds(AppSession *session) {
session->pane_camera_feeds[i]->setCameraIndex(session->route_data.*(kCameraViewSpecs[i].route_member), kCameraViewSpecs[i].view);
}
}
if (session->thumbnail_view) {
session->thumbnail_view->setThumbnails(session->route_data.thumbnails);
}
}
void apply_route_data(AppSession *session, UiState *state, RouteData route_data) {
@@ -456,8 +459,7 @@ void draw_route_info_popup(AppSession *session, UiState *state, ImVec2 anchor) {
const char *copy_icon = icon::CLIPBOARD;
const char *link_icon = icon::BOX_ARROW_UP_RIGHT;
const std::string useradmin_label = std::string("Useradmin ") + link_icon;
const std::string connect_label = std::string("comma connect ") + link_icon;
const std::string konn3kt_label = std::string("konn3kt ") + link_icon;
if (ImGui::Button(copy_icon, ImVec2(34.0f, 26.0f))) {
ImGui::SetClipboardText(session->route_id.canonical().c_str());
state->status_text = "Copied route to clipboard";
@@ -470,14 +472,9 @@ void draw_route_info_popup(AppSession *session, UiState *state, ImVec2 anchor) {
ImGui::EndTooltip();
}
ImGui::SameLine();
if (ImGui::Button(useradmin_label.c_str(), ImVec2(132.0f, 26.0f))) {
open_external_url(route_useradmin_url(session->route_id));
state->status_text = "Opened useradmin";
}
ImGui::SameLine();
if (ImGui::Button(connect_label.c_str(), ImVec2(156.0f, 26.0f))) {
open_external_url(route_connect_url(session->route_id));
state->status_text = "Opened comma connect";
if (ImGui::Button(konn3kt_label.c_str(), ImVec2(120.0f, 26.0f))) {
open_external_url(route_konn3kt_url(session->route_id));
state->status_text = "Opened konn3kt";
}
ImGui::Spacing();

View File

@@ -24,10 +24,11 @@
#include "common/util.h"
#include "third_party/json11/json11.hpp"
#include "tools/replay/api.h"
#include "tools/replay/logreader.h"
// IQ.Pilot patch: iqpilot's tools/replay has no py_downloader; route file listing is
// served directly by CommaApi2::httpGet against the IQ.Pilot konn3kt API.
// served by the konn3kt CommaApi2 helper, which returns the same JSON shape (and the
// same {"error": ...} envelope) as upstream's PyDownloader.
#include "tools/replay/api.h"
namespace fs = std::filesystem;
@@ -100,6 +101,7 @@ struct LoadedRouteArtifacts {
std::vector<CanMessageData> can_messages;
std::vector<LogEntry> logs;
std::vector<TimelineEntry> timeline;
std::vector<ThumbnailFrame> thumbnails;
std::unordered_map<std::string, EnumInfo> enum_info;
};
@@ -263,7 +265,8 @@ RouteSelection parse_route_selection(std::string route_name) {
if (separator == "/") {
size_t pos = range_str.find(':');
int begin_segment = 0;
if (!parse_segment_number(range_str.substr(0, pos), &begin_segment)) {
const std::string begin_str = range_str.substr(0, pos);
if (!begin_str.empty() && !parse_segment_number(begin_str, &begin_segment)) {
return {};
}
route.begin_segment = begin_segment;
@@ -330,11 +333,7 @@ std::map<int, SegmentLogs> load_segments_from_json(const json11::Json &json) {
}
std::map<int, SegmentLogs> load_segments_from_server(const RouteSelection &route) {
// IQ.Pilot patch: replaces upstream PyDownloader::getRouteFiles with the existing
// iqpilot CommaApi2 helper (same JSON shape: /v1/route/<name>/files).
const std::string url = CommaApi2::BASE_URL + "/v1/route/" + route.canonical_name + "/files";
long response_code = 0;
const std::string result = CommaApi2::httpGet(url, &response_code);
const std::string result = CommaApi2::getRouteFiles(route.canonical_name);
if (result.empty()) throw std::runtime_error("Failed to fetch route files for " + route.canonical_name);
std::string parse_error;
@@ -438,7 +437,7 @@ std::array<uint8_t, 3> parse_color(std::string_view color) {
return out;
}
uint8_t android_priority_to_level(uint8_t priority) {
uint8_t operating_system_priority_to_level(uint8_t priority) {
switch (priority) {
case 2:
case 3:
@@ -497,7 +496,7 @@ void append_timeline_entry(std::vector<TimelineEntry> *timeline, double mono_tim
});
}
double android_wall_time_seconds(uint64_t timestamp) {
double operating_system_wall_time_seconds(uint64_t timestamp) {
if (timestamp == 0) return 0.0;
if (timestamp > 1000000000000ULL) return static_cast<double>(timestamp) / 1.0e9;
if (timestamp > 1000000000ULL) return static_cast<double>(timestamp) / 1.0e6;
@@ -615,13 +614,15 @@ void append_log_event(cereal::Event::Which which,
logs->push_back(std::move(entry));
break;
}
// IQ.Pilot patch: upstream renamed cereal's androidLog -> operatingSystemLog (#38209);
// iqpilot's cereal still carries the original field name. Only the accessor differs.
case cereal::Event::Which::ANDROID_LOG: {
const auto android = event.getAndroidLog();
auto entry = make_entry(LogOrigin::Android, android_priority_to_level(android.getPriority()));
entry.wall_time = android_wall_time_seconds(android.getTs());
entry.source = android.hasTag() ? android.getTag().cStr() : "android";
entry.message = android.hasMessage() ? android.getMessage().cStr() : std::string();
entry.context = "pid=" + std::to_string(android.getPid()) + ", tid=" + std::to_string(android.getTid());
const auto operating_system_log = event.getAndroidLog();
auto entry = make_entry(LogOrigin::OperatingSystem, operating_system_priority_to_level(operating_system_log.getPriority()));
entry.wall_time = operating_system_wall_time_seconds(operating_system_log.getTs());
entry.source = operating_system_log.hasTag() ? operating_system_log.getTag().cStr() : "operating_system";
entry.message = operating_system_log.hasMessage() ? operating_system_log.getMessage().cStr() : std::string();
entry.context = "pid=" + std::to_string(operating_system_log.getPid()) + ", tid=" + std::to_string(operating_system_log.getTid());
if (!entry.message.empty()) {
std::string err;
if (const auto p = json11::Json::parse(entry.message, err); err.empty() && p.is_object()) {
@@ -629,10 +630,10 @@ void append_log_event(cereal::Event::Which which,
if (p["SYSLOG_IDENTIFIER"].is_string() && !p["SYSLOG_IDENTIFIER"].string_value().empty())
entry.source = p["SYSLOG_IDENTIFIER"].string_value();
if (auto pri = json_int_value(p["PRIORITY"]); pri.has_value())
entry.level = android_priority_to_level(*pri);
entry.level = operating_system_priority_to_level(*pri);
if (auto ts = json_u64_value(p["__REALTIME_TIMESTAMP"]); ts.has_value())
entry.wall_time = android_wall_time_seconds(*ts);
entry.context = format_journal_context(p, android.getPid(), android.getTid());
entry.wall_time = operating_system_wall_time_seconds(*ts);
entry.context = format_journal_context(p, operating_system_log.getPid(), operating_system_log.getTid());
}
}
logs->push_back(std::move(entry));
@@ -690,6 +691,25 @@ std::vector<LogEntry> extract_segment_logs(const std::vector<Event> &events) {
return logs;
}
std::vector<ThumbnailFrame> extract_segment_thumbnails(const std::vector<Event> &events, int segment) {
std::vector<ThumbnailFrame> thumbnails;
for (const Event &event_record : events) {
if (event_record.which != cereal::Event::Which::THUMBNAIL) continue;
with_parseable_event(event_record.data, [&](const cereal::Event::Reader &event) {
const auto thumbnail = event.getThumbnail();
const auto jpeg = thumbnail.getThumbnail();
if (jpeg.size() == 0) return;
const uint64_t timestamp = thumbnail.getTimestampEof();
ThumbnailFrame frame;
frame.timestamp = static_cast<double>(timestamp != 0 ? timestamp : event.getLogMonoTime()) / 1.0e9;
frame.segment = segment;
frame.jpeg.assign(jpeg.begin(), jpeg.end());
thumbnails.push_back(std::move(frame));
});
}
return thumbnails;
}
RouteMetadata extract_segment_metadata(const std::vector<Event> &events) {
RouteMetadata metadata;
for (const Event &event_record : events) {
@@ -802,6 +822,8 @@ Pane parse_dock_area(const json11::Json &dock_area_node) {
const std::string kind = dock_area_node["kind"].string_value();
if (kind == "map") {
pane.kind = PaneKind::Map;
} else if (kind == "thumbnail") {
pane.kind = PaneKind::Thumbnail;
} else if (kind == "camera") {
pane.kind = PaneKind::Camera;
const std::string camera_view = dock_area_node["camera_view"].string_value();
@@ -910,7 +932,9 @@ void append_scalar_point(RouteSeries *series,
series->values.push_back(value);
}
void append_fixed_scalar_point(RouteSeries *series, double tm, double value) {
// This has thousands of generated call sites. Inlining it duplicates vector
// growth logic throughout the extractor and is slower both to compile and run.
__attribute__((noinline)) void append_fixed_scalar_point(RouteSeries *series, double tm, double value) {
series->times.push_back(tm);
series->values.push_back(value);
}
@@ -1173,6 +1197,7 @@ RouteData build_route_data(std::vector<RouteSeries> &&series_list,
std::vector<CanMessageData> &&can_messages,
std::vector<LogEntry> &&logs,
std::vector<TimelineEntry> &&timeline,
std::vector<ThumbnailFrame> &&thumbnails,
std::unordered_map<std::string, EnumInfo> &&enum_info,
std::string car_fingerprint,
std::string dbc_name) {
@@ -1239,6 +1264,14 @@ RouteData build_route_data(std::vector<RouteSeries> &&series_list,
route_data.x_min = timeline.front().start_time;
route_data.x_max = timeline.back().end_time;
}
std::sort(thumbnails.begin(), thumbnails.end(), [](const ThumbnailFrame &a, const ThumbnailFrame &b) {
return a.timestamp < b.timestamp;
});
if (!route_data.has_time_range && !thumbnails.empty()) {
route_data.has_time_range = true;
route_data.x_min = thumbnails.front().timestamp;
route_data.x_max = thumbnails.back().timestamp;
}
if (route_data.has_time_range) {
const double time_offset = route_data.x_min;
@@ -1260,6 +1293,9 @@ RouteData build_route_data(std::vector<RouteSeries> &&series_list,
entry.start_time -= time_offset;
entry.end_time -= time_offset;
}
for (ThumbnailFrame &thumbnail : thumbnails) {
thumbnail.timestamp -= time_offset;
}
route_data.x_max -= time_offset;
route_data.x_min = 0.0;
}
@@ -1277,6 +1313,7 @@ RouteData build_route_data(std::vector<RouteSeries> &&series_list,
merged_timeline.push_back(std::move(entry));
}
route_data.timeline = std::move(merged_timeline);
route_data.thumbnails = std::move(thumbnails);
std::sort(can_messages.begin(), can_messages.end(), [](const CanMessageData &a, const CanMessageData &b) {
return std::make_tuple(a.id.service, a.id.bus, a.id.address)
< std::make_tuple(b.id.service, b.id.bus, b.id.address);
@@ -1530,6 +1567,7 @@ LoadedRouteArtifacts load_route_series_parallel(
SeriesAccumulator series;
std::vector<LogEntry> logs;
std::vector<TimelineEntry> timeline;
std::vector<ThumbnailFrame> thumbnails;
};
const std::vector<std::pair<int, SegmentLogs>> segment_list(segments.begin(), segments.end());
@@ -1579,15 +1617,12 @@ LoadedRouteArtifacts load_route_series_parallel(
continue;
}
// IQ.Pilot patch: iqpilot's LogReader does not expose load-time telemetry
// (download/decompress/parse seconds, compressed/decompressed sizes). Stub
// these to 0 so the loader UI still renders without per-segment diagnostics.
segment_stats.download_seconds = 0.0;
segment_stats.decompress_seconds = 0.0;
segment_stats.parse_seconds = 0.0;
segment_stats.compressed_bytes = 0;
segment_stats.decompressed_bytes = 0;
stats->bytes_downloaded.fetch_add(0);
segment_stats.download_seconds = reader.download_seconds();
segment_stats.decompress_seconds = reader.decompress_seconds();
segment_stats.parse_seconds = reader.parse_seconds();
segment_stats.compressed_bytes = reader.compressed_size();
segment_stats.decompressed_bytes = reader.decompressed_size();
stats->bytes_downloaded.fetch_add(reader.compressed_size());
stats->segments_downloaded.fetch_add(1);
stats->publish(RouteLoadStage::DownloadingSegment, index, std::to_string(segment_number));
@@ -1595,6 +1630,7 @@ LoadedRouteArtifacts load_route_series_parallel(
results[index].series = extract_segment_series(reader.events, schema, can_dbc, skip_raw_can, worker_budget, segment_workers);
results[index].logs = extract_segment_logs(reader.events);
results[index].timeline = extract_segment_timeline(reader.events);
results[index].thumbnails = extract_segment_thumbnails(reader.events, segment_number);
segment_stats.extract_seconds = std::chrono::duration<double>(LoadStats::Clock::now() - extract_start).count();
segment_stats.event_count = reader.events.size();
segment_stats.series_count = populated_series_count(results[index].series);
@@ -1621,6 +1657,7 @@ LoadedRouteArtifacts load_route_series_parallel(
}
std::vector<LogEntry> logs;
std::vector<TimelineEntry> timeline;
std::vector<ThumbnailFrame> thumbnails;
for (SegmentResult &result : results) {
if (!result.logs.empty()) {
logs.insert(logs.end(),
@@ -1632,12 +1669,18 @@ LoadedRouteArtifacts load_route_series_parallel(
std::make_move_iterator(result.timeline.begin()),
std::make_move_iterator(result.timeline.end()));
}
if (!result.thumbnails.empty()) {
thumbnails.insert(thumbnails.end(),
std::make_move_iterator(result.thumbnails.begin()),
std::make_move_iterator(result.thumbnails.end()));
}
}
LoadedRouteArtifacts artifacts;
artifacts.series = collect_series(std::move(merged));
artifacts.can_messages = std::move(merged.can_messages);
artifacts.logs = std::move(logs);
artifacts.timeline = std::move(timeline);
artifacts.thumbnails = std::move(thumbnails);
artifacts.enum_info = std::move(merged.enum_info);
stats->merge_end = LoadStats::Clock::now();
return artifacts;
@@ -1843,6 +1886,7 @@ RouteData load_route_data(const std::string &route_name,
std::move(artifacts.can_messages),
std::move(artifacts.logs),
std::move(artifacts.timeline),
std::move(artifacts.thumbnails),
std::move(artifacts.enum_info),
metadata.car_fingerprint,
resolved_dbc);

View File

@@ -0,0 +1,53 @@
import subprocess
from pathlib import Path
import pytest
JOTPLUGGLER_DIR = Path(__file__).parent
JOTPLUGGLER_BIN = JOTPLUGGLER_DIR / "jotpluggler"
pytestmark = pytest.mark.skipif(not JOTPLUGGLER_BIN.exists(),
reason="jotpluggler not built (scons -u tools/jotpluggler)")
def run_jotpluggler(*args):
return subprocess.run([str(JOTPLUGGLER_BIN), *args], cwd=JOTPLUGGLER_DIR,
capture_output=True, text=True, timeout=60)
class TestJotpluggler:
def test_help(self):
result = run_jotpluggler("-h")
assert result.returncode == 0, result.stderr
assert "Usage:" in result.stderr
def test_generated_dbcs_materialized(self):
# the build materializes iqdbc's *_generated.dbc files; layout.cc and
# sketch_layout.cc both resolve DBC names against this directory
generated = JOTPLUGGLER_DIR / "generated_dbcs"
assert generated.is_dir()
assert list(generated.glob("*.dbc")), "no generated DBCs — iqdbc create_all did not run"
def test_car_fingerprint_header_uses_iqdbc_platforms(self):
header = (JOTPLUGGLER_DIR / "car_fingerprint_to_dbc.h").read_text()
assert "kCarFingerprintToDbc" in header
assert "dbc_for_car_fingerprint" in header
def test_bootstrap_icons_vendored(self):
# IQ.Pilot vendors the TTF because third_party/bootstrap is git-lfs; icons.cc
# reads this exact path instead of upstream's BOOTSTRAP_ICONS_TTF define
assert (JOTPLUGGLER_DIR / "assets" / "bootstrap-icons.ttf").is_file()
def test_no_comma_connect_links(self):
# iqpilot routes live on konn3kt; the comma connect / useradmin buttons are gone
common = (JOTPLUGGLER_DIR / "common.cc").read_text()
assert "connect.comma.ai" not in common
assert "useradmin.comma.ai" not in common
assert "route_konn3kt_url" in common
def test_route_files_go_through_konn3kt_api(self):
# upstream calls PyDownloader::getRouteFiles here; iqpilot has no py_downloader
sketch = (JOTPLUGGLER_DIR / "sketch_layout.cc").read_text()
assert "PyDownloader::" not in sketch
assert "py_downloader.h" not in sketch
assert "CommaApi2::getRouteFiles" in sketch

View File

@@ -0,0 +1,253 @@
#include "tools/jotpluggler/thumbnail.h"
#include "imgui_impl_opengl3_loader.h"
#include <algorithm>
#include <cmath>
#include <limits>
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/pixfmt.h>
}
namespace {
bool decode_jpeg(const std::vector<uint8_t> &jpeg, int *width, int *height, std::vector<uint8_t> *rgba) {
if (jpeg.empty()) return false;
const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG);
AVCodecContext *context = codec != nullptr ? avcodec_alloc_context3(codec) : nullptr;
AVFrame *frame = av_frame_alloc();
AVPacket *packet = av_packet_alloc();
if (context == nullptr || frame == nullptr || packet == nullptr) {
av_packet_free(&packet);
av_frame_free(&frame);
avcodec_free_context(&context);
return false;
}
const bool packet_ready = jpeg.size() <= static_cast<size_t>(std::numeric_limits<int>::max())
&& av_new_packet(packet, static_cast<int>(jpeg.size())) >= 0;
if (packet_ready) {
std::copy(jpeg.begin(), jpeg.end(), packet->data);
}
const bool decoded = packet_ready
&& avcodec_open2(context, codec, nullptr) >= 0
&& avcodec_send_packet(context, packet) >= 0
&& avcodec_receive_frame(context, frame) >= 0;
if (!decoded || frame->width <= 0 || frame->height <= 0) {
av_packet_free(&packet);
av_frame_free(&frame);
avcodec_free_context(&context);
return false;
}
int chroma_x_shift = 0;
int chroma_y_shift = 0;
switch (static_cast<AVPixelFormat>(frame->format)) {
case AV_PIX_FMT_YUV420P:
case AV_PIX_FMT_YUVJ420P:
chroma_x_shift = 1;
chroma_y_shift = 1;
break;
case AV_PIX_FMT_YUV422P:
case AV_PIX_FMT_YUVJ422P:
chroma_x_shift = 1;
break;
case AV_PIX_FMT_YUV444P:
case AV_PIX_FMT_YUVJ444P:
break;
default:
av_packet_free(&packet);
av_frame_free(&frame);
avcodec_free_context(&context);
return false;
}
*width = frame->width;
*height = frame->height;
rgba->resize(static_cast<size_t>(*width) * static_cast<size_t>(*height) * 4U);
const bool full_range = frame->color_range == AVCOL_RANGE_JPEG
|| frame->format == AV_PIX_FMT_YUVJ420P
|| frame->format == AV_PIX_FMT_YUVJ422P
|| frame->format == AV_PIX_FMT_YUVJ444P;
for (int y = 0; y < *height; ++y) {
const uint8_t *y_row = frame->data[0] + y * frame->linesize[0];
const uint8_t *u_row = frame->data[1] + (y >> chroma_y_shift) * frame->linesize[1];
const uint8_t *v_row = frame->data[2] + (y >> chroma_y_shift) * frame->linesize[2];
uint8_t *out = rgba->data() + static_cast<size_t>(y) * static_cast<size_t>(*width) * 4U;
for (int x = 0; x < *width; ++x) {
const double luma = full_range ? static_cast<double>(y_row[x])
: 1.164383 * (static_cast<double>(y_row[x]) - 16.0);
const double u = static_cast<double>(u_row[x >> chroma_x_shift]) - 128.0;
const double v = static_cast<double>(v_row[x >> chroma_x_shift]) - 128.0;
const double red = luma + (full_range ? 1.402 : 1.596027) * v;
const double green = luma - (full_range ? 0.344136 : 0.391762) * u
- (full_range ? 0.714136 : 0.812968) * v;
const double blue = luma + (full_range ? 1.772 : 2.017232) * u;
out[x * 4 + 0] = static_cast<uint8_t>(std::clamp(std::lround(red), 0L, 255L));
out[x * 4 + 1] = static_cast<uint8_t>(std::clamp(std::lround(green), 0L, 255L));
out[x * 4 + 2] = static_cast<uint8_t>(std::clamp(std::lround(blue), 0L, 255L));
out[x * 4 + 3] = 255;
}
}
av_packet_free(&packet);
av_frame_free(&frame);
avcodec_free_context(&context);
return true;
}
std::string format_thumbnail_time(double seconds) {
const int rounded = std::max(0, static_cast<int>(std::lround(seconds)));
const int hours = rounded / 3600;
const int minutes = (rounded % 3600) / 60;
const int secs = rounded % 60;
if (hours > 0) {
return util::string_format("%d:%02d:%02d", hours, minutes, secs);
}
return util::string_format("%02d:%02d", minutes, secs);
}
} // namespace
struct ThumbnailView::Impl {
~Impl() {
destroy_texture();
}
void setThumbnails(const std::vector<ThumbnailFrame> &next_thumbnails) {
destroy_texture();
thumbnails = &next_thumbnails;
displayed_index = -1;
failed_index = -1;
}
void update(double tracker_time) {
if (thumbnails == nullptr || thumbnails->empty()) return;
auto it = std::lower_bound(thumbnails->begin(), thumbnails->end(), tracker_time,
[](const ThumbnailFrame &frame, double time) {
return frame.timestamp < time;
});
if (it == thumbnails->end()) {
it = std::prev(thumbnails->end());
} else if (it != thumbnails->begin()) {
const auto previous = std::prev(it);
if (std::abs(previous->timestamp - tracker_time) <= std::abs(it->timestamp - tracker_time)) {
it = previous;
}
}
const int index = static_cast<int>(std::distance(thumbnails->begin(), it));
if (index == displayed_index || index == failed_index) return;
int width = 0;
int height = 0;
std::vector<uint8_t> rgba;
if (!decode_jpeg(it->jpeg, &width, &height, &rgba)) {
failed_index = index;
return;
}
if (texture == 0) {
glGenTextures(1, &texture);
}
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, rgba.data());
glBindTexture(GL_TEXTURE_2D, 0);
texture_width = width;
texture_height = height;
displayed_index = index;
failed_index = -1;
}
void drawSized(ImVec2 size, bool loading) const {
size.x = std::max(1.0f, size.x);
size.y = std::max(1.0f, size.y);
ImGui::InvisibleButton("##thumbnail_sized", size);
const ImVec2 pane_min = ImGui::GetItemRectMin();
const ImVec2 pane_max = ImGui::GetItemRectMax();
ImDrawList *draw_list = ImGui::GetWindowDrawList();
draw_list->AddRectFilled(pane_min, pane_max, IM_COL32(24, 24, 24, 255));
if (texture != 0 && texture_width > 0 && texture_height > 0) {
const float scale = std::min(size.x / static_cast<float>(texture_width),
size.y / static_cast<float>(texture_height));
const ImVec2 image_size(static_cast<float>(texture_width) * scale,
static_cast<float>(texture_height) * scale);
const ImVec2 image_min(pane_min.x + (size.x - image_size.x) * 0.5f,
pane_min.y + (size.y - image_size.y) * 0.5f);
const ImVec2 image_max(image_min.x + image_size.x, image_min.y + image_size.y);
draw_list->AddImage(static_cast<ImTextureID>(texture), image_min, image_max);
if (thumbnails != nullptr && displayed_index >= 0
&& displayed_index < static_cast<int>(thumbnails->size())) {
const ThumbnailFrame &frame = (*thumbnails)[static_cast<size_t>(displayed_index)];
const std::string label = util::string_format("%s · segment %d · %d/%zu",
format_thumbnail_time(frame.timestamp).c_str(),
frame.segment,
displayed_index + 1,
thumbnails->size());
const ImVec2 text_size = ImGui::CalcTextSize(label.c_str());
const ImVec2 label_min(image_min.x, std::max(image_min.y, image_max.y - text_size.y - 14.0f));
draw_list->AddRectFilled(label_min, image_max, IM_COL32(0, 0, 0, 175));
draw_list->AddText(ImVec2(label_min.x + 7.0f, label_min.y + 7.0f), IM_COL32_WHITE, label.c_str());
}
return;
}
const bool has_thumbnails = thumbnails != nullptr && !thumbnails->empty();
const char *label = loading ? "loading" : (has_thumbnails ? "invalid thumbnail" : "no thumbnails");
const ImVec2 text_size = ImGui::CalcTextSize(label);
draw_list->AddText(ImVec2(pane_min.x + (size.x - text_size.x) * 0.5f,
pane_min.y + (size.y - text_size.y) * 0.5f),
IM_COL32(187, 187, 187, 255), label);
}
void destroy_texture() {
if (texture != 0) {
glDeleteTextures(1, &texture);
}
texture = 0;
texture_width = 0;
texture_height = 0;
}
const std::vector<ThumbnailFrame> *thumbnails = nullptr;
int displayed_index = -1;
int failed_index = -1;
GLuint texture = 0;
int texture_width = 0;
int texture_height = 0;
};
ThumbnailView::ThumbnailView() : impl_(std::make_unique<Impl>()) {}
ThumbnailView::~ThumbnailView() = default;
void ThumbnailView::setThumbnails(const std::vector<ThumbnailFrame> &thumbnails) {
impl_->setThumbnails(thumbnails);
}
void ThumbnailView::update(double tracker_time) {
impl_->update(tracker_time);
}
void ThumbnailView::drawSized(ImVec2 size, bool loading) {
impl_->drawSized(size, loading);
}
void draw_thumbnail_pane(AppSession *session, UiState *state) {
if (session->thumbnail_view == nullptr) {
ImGui::TextDisabled("Thumbnails unavailable");
return;
}
if (state->has_tracker_time) {
session->thumbnail_view->update(state->tracker_time);
}
session->thumbnail_view->drawSized(ImGui::GetContentRegionAvail(), session->async_route_loading);
}

View File

@@ -0,0 +1,5 @@
#pragma once
#include "tools/jotpluggler/app.h"
void draw_thumbnail_pane(AppSession *session, UiState *state);