pepijn223 HF Staff commited on
Commit
a990603
·
unverified ·
1 Parent(s): 9196260

fix playback and typing

Browse files
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx CHANGED
@@ -9,7 +9,7 @@ import PlaybackBar from "@/components/playback-bar";
9
  import { TimeProvider, useTime } from "@/context/time-context";
10
  import Sidebar from "@/components/side-nav";
11
  import Loading from "@/components/loading-component";
12
- import { getAdjacentEpisodesVideoInfo } from "./fetch-data";
13
 
14
  export default function EpisodeViewer({
15
  data,
@@ -17,7 +17,7 @@ export default function EpisodeViewer({
17
  org,
18
  dataset,
19
  }: {
20
- data?: any;
21
  error?: string;
22
  org?: string;
23
  dataset?: string;
@@ -33,13 +33,13 @@ export default function EpisodeViewer({
33
  );
34
  }
35
  return (
36
- <TimeProvider duration={data.duration}>
37
- <EpisodeViewerInner data={data} org={org} dataset={dataset} />
38
  </TimeProvider>
39
  );
40
  }
41
 
42
- function EpisodeViewerInner({ data, org, dataset }: { data: any; org?: string; dataset?: string; }) {
43
  const {
44
  datasetInfo,
45
  episodeId,
@@ -53,6 +53,13 @@ function EpisodeViewerInner({ data, org, dataset }: { data: any; org?: string; d
53
  const [chartsReady, setChartsReady] = useState(false);
54
  const isLoading = !videosReady || !chartsReady;
55
 
 
 
 
 
 
 
 
56
  const router = useRouter();
57
  const searchParams = useSearchParams();
58
 
@@ -84,7 +91,7 @@ function EpisodeViewerInner({ data, org, dataset }: { data: any; org?: string; d
84
  link.href = v.url;
85
  document.head.appendChild(link);
86
  links.push(link);
87
- }
88
  }
89
  })
90
  .catch(() => {});
@@ -244,7 +251,7 @@ function EpisodeViewerInner({ data, org, dataset }: { data: any; org?: string; d
244
  <span className="font-semibold text-slate-100">Language Instruction:</span>
245
  </p>
246
  <div className="mt-2 text-slate-300">
247
- {task.split('\n').map((instruction, index) => (
248
  <p key={index} className="mb-1">
249
  {instruction}
250
  </p>
 
9
  import { TimeProvider, useTime } from "@/context/time-context";
10
  import Sidebar from "@/components/side-nav";
11
  import Loading from "@/components/loading-component";
12
+ import { getAdjacentEpisodesVideoInfo, type EpisodeData } from "./fetch-data";
13
 
14
  export default function EpisodeViewer({
15
  data,
 
17
  org,
18
  dataset,
19
  }: {
20
+ data?: EpisodeData;
21
  error?: string;
22
  org?: string;
23
  dataset?: string;
 
33
  );
34
  }
35
  return (
36
+ <TimeProvider duration={data!.duration}>
37
+ <EpisodeViewerInner data={data!} org={org} dataset={dataset} />
38
  </TimeProvider>
39
  );
40
  }
41
 
42
+ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: string; dataset?: string }) {
43
  const {
44
  datasetInfo,
45
  episodeId,
 
53
  const [chartsReady, setChartsReady] = useState(false);
54
  const isLoading = !videosReady || !chartsReady;
55
 
56
+ const loadStartRef = useRef(performance.now());
57
+ useEffect(() => {
58
+ if (!isLoading) {
59
+ console.log(`[perf] Loading complete in ${(performance.now() - loadStartRef.current).toFixed(0)}ms (videos: ${videosReady ? '✓' : '…'}, charts: ${chartsReady ? '✓' : '…'})`);
60
+ }
61
+ }, [isLoading]);
62
+
63
  const router = useRouter();
64
  const searchParams = useSearchParams();
65
 
 
91
  link.href = v.url;
92
  document.head.appendChild(link);
93
  links.push(link);
94
+ }
95
  }
96
  })
97
  .catch(() => {});
 
251
  <span className="font-semibold text-slate-100">Language Instruction:</span>
252
  </p>
253
  <div className="mt-2 text-slate-300">
254
+ {task.split('\n').map((instruction: string, index: number) => (
255
  <p key={index} className="mb-1">
256
  {instruction}
257
  </p>
src/app/[org]/[dataset]/[episode]/fetch-data.ts CHANGED
@@ -9,25 +9,105 @@ import { getDatasetVersionAndInfo, buildVersionedUrl } from "@/utils/versionUtil
9
 
10
  const SERIES_NAME_DELIMITER = " | ";
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  export async function getEpisodeData(
13
  org: string,
14
  dataset: string,
15
  episodeId: number,
16
- ) {
17
  const repoId = `${org}/${dataset}`;
18
  try {
 
19
  const { version, info: rawInfo } = await getDatasetVersionAndInfo(repoId);
 
20
  const info = rawInfo as unknown as DatasetMetadata;
21
 
22
  if (info.video_path === null) {
23
  throw new Error("Only videos datasets are supported in this visualizer.\nPlease use Rerun visualizer for images datasets.");
24
  }
25
 
26
- if (version === "v3.0") {
27
- return await getEpisodeDataV3(repoId, version, info, episodeId);
28
- } else {
29
- return await getEpisodeDataV2(repoId, version, info, episodeId);
30
- }
 
31
  } catch (err) {
32
  console.error("Error loading episode data:", err);
33
  throw err;
@@ -46,7 +126,7 @@ export async function getAdjacentEpisodesVideoInfo(
46
  const info = rawInfo as unknown as DatasetMetadata;
47
 
48
  const totalEpisodes = info.total_episodes;
49
- const adjacentVideos: Array<{episodeId: number; videosInfo: any[]}> = [];
50
 
51
  // Calculate adjacent episode IDs
52
  for (let offset = -radius; offset <= radius; offset++) {
@@ -55,7 +135,7 @@ export async function getAdjacentEpisodesVideoInfo(
55
  const episodeId = currentEpisodeId + offset;
56
  if (episodeId >= 0 && episodeId < totalEpisodes) {
57
  try {
58
- let videosInfo: any[] = [];
59
 
60
  if (version === "v3.0") {
61
  const episodeMetadata = await loadEpisodeMetadataV3Simple(repoId, version, episodeId);
@@ -98,7 +178,7 @@ async function getEpisodeDataV2(
98
  version: string,
99
  info: DatasetMetadata,
100
  episodeId: number,
101
- ) {
102
  const episode_chunk = Math.floor(0 / 1000);
103
 
104
  // Dataset information
@@ -162,16 +242,16 @@ async function getEpisodeDataV2(
162
  ...filteredColumns.map((column) => column.key),
163
  ];
164
 
165
- const columns = filteredColumns.map(({ key }) => {
166
- let column_names = info.features[key].names;
167
- while (typeof column_names === "object") {
168
  if (Array.isArray(column_names)) break;
169
- column_names = Object.values(column_names ?? {})[0];
170
  }
171
  return {
172
  key,
173
  value: Array.isArray(column_names)
174
- ? column_names.map((name) => `${key}${SERIES_NAME_DELIMITER}${name}`)
175
  : Array.from(
176
  { length: columnNames.find((c) => c.key === key)?.length ?? 1 },
177
  (_, i) => `${key}${SERIES_NAME_DELIMITER}${i}`,
@@ -198,13 +278,13 @@ async function getEpisodeDataV2(
198
  const firstRow = allData[0];
199
  const languageInstructions: string[] = [];
200
 
201
- if (firstRow.language_instruction) {
202
  languageInstructions.push(firstRow.language_instruction);
203
  }
204
 
205
  let instructionNum = 2;
206
- while (firstRow[`language_instruction_${instructionNum}`]) {
207
- languageInstructions.push(firstRow[`language_instruction_${instructionNum}`]);
208
  instructionNum++;
209
  }
210
 
@@ -213,7 +293,7 @@ async function getEpisodeDataV2(
213
  }
214
  }
215
 
216
- if (!task && allData.length > 0 && allData[0].task) {
217
  task = allData[0].task;
218
  }
219
 
@@ -251,13 +331,13 @@ async function getEpisodeDataV2(
251
 
252
  const chartData = allData.map((row) => {
253
  const obj: Record<string, number> = {};
254
- obj["timestamp"] = row.timestamp;
255
  for (const col of columns) {
256
  const rawVal = row[col.key];
257
  if (Array.isArray(rawVal)) {
258
- rawVal.forEach((v: any, i: number) => {
259
  if (i < col.value.length) obj[col.value[i]] = Number(v);
260
- });
261
  } else if (rawVal !== undefined) {
262
  obj[col.value[0]] = Number(rawVal);
263
  }
@@ -351,37 +431,6 @@ async function getEpisodeDataV2(
351
 
352
  const duration = chartData[chartData.length - 1].timestamp;
353
 
354
- // Utility: group row keys by suffix
355
- function groupRowBySuffix(row: Record<string, number>): Record<string, any> {
356
- const result: Record<string, any> = {};
357
- const suffixGroups: Record<string, Record<string, number>> = {};
358
- for (const [key, value] of Object.entries(row)) {
359
- if (key === "timestamp") {
360
- result["timestamp"] = value;
361
- continue;
362
- }
363
- const parts = key.split(SERIES_NAME_DELIMITER);
364
- if (parts.length === 2) {
365
- const [prefix, suffix] = parts;
366
- if (!suffixGroups[suffix]) suffixGroups[suffix] = {};
367
- suffixGroups[suffix][prefix] = value;
368
- } else {
369
- result[key] = value;
370
- }
371
- }
372
- for (const [suffix, group] of Object.entries(suffixGroups)) {
373
- const keys = Object.keys(group);
374
- if (keys.length === 1) {
375
- // Use the full original name as the key
376
- const fullName = `${keys[0]}${SERIES_NAME_DELIMITER}${suffix}`;
377
- result[fullName] = group[keys[0]];
378
- } else {
379
- result[suffix] = group;
380
- }
381
- }
382
- return result;
383
- }
384
-
385
  const chartDataGroups = chartGroups.map((group) =>
386
  chartData.map((row) => groupRowBySuffix(pick(row, [...group, "timestamp"])))
387
  );
@@ -404,7 +453,7 @@ async function getEpisodeDataV3(
404
  version: string,
405
  info: DatasetMetadata,
406
  episodeId: number,
407
- ) {
408
  // Create dataset info structure (like v2.x)
409
  const datasetInfo = {
410
  repoId,
@@ -446,8 +495,8 @@ async function loadEpisodeDataV3(
446
  repoId: string,
447
  version: string,
448
  info: DatasetMetadata,
449
- episodeMetadata: any,
450
- ): Promise<{ chartDataGroups: any[]; ignoredColumns: string[]; task?: string }> {
451
  // Build data file path using chunk and file indices
452
  const dataChunkIndex = episodeMetadata.data_chunk_index || 0;
453
  const dataFileIndex = episodeMetadata.data_file_index || 0;
@@ -486,72 +535,54 @@ async function loadEpisodeDataV3(
486
  // First check for language_instruction fields in the data (preferred)
487
  let task: string | undefined;
488
  if (episodeData.length > 0) {
489
- const firstRow = episodeData[0];
490
  const languageInstructions: string[] = [];
491
 
492
- // Check for language_instruction field
493
- if (firstRow.language_instruction) {
494
- languageInstructions.push(firstRow.language_instruction);
495
- }
496
-
497
- // Check for numbered language_instruction fields
498
- let instructionNum = 2;
499
- while (firstRow[`language_instruction_${instructionNum}`]) {
500
- languageInstructions.push(firstRow[`language_instruction_${instructionNum}`]);
501
- instructionNum++;
502
- }
 
503
 
504
- // If no instructions found in first row, check a few more rows
505
  if (languageInstructions.length === 0 && episodeData.length > 1) {
506
- const middleIndex = Math.floor(episodeData.length / 2);
507
- const lastIndex = episodeData.length - 1;
508
-
509
- [middleIndex, lastIndex].forEach((idx) => {
510
- const row = episodeData[idx];
511
-
512
- if (row.language_instruction && languageInstructions.length === 0) {
513
- // Use this row's instructions
514
- if (row.language_instruction) {
515
- languageInstructions.push(row.language_instruction);
516
- }
517
- let num = 2;
518
- while (row[`language_instruction_${num}`]) {
519
- languageInstructions.push(row[`language_instruction_${num}`]);
520
- num++;
521
- }
522
- }
523
- });
524
  }
525
 
526
- // Join all instructions with line breaks
527
  if (languageInstructions.length > 0) {
528
  task = languageInstructions.join('\n');
529
  }
530
  }
531
 
532
- // If no language instructions found, fall back to tasks metadata
533
- if (!task) {
534
  try {
535
- // Load tasks metadata
536
  const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.parquet");
537
  const tasksArrayBuffer = await fetchParquetFile(tasksUrl);
538
  const tasksData = await readParquetAsObjects(tasksArrayBuffer, []);
539
 
540
- if (episodeData.length > 0 && tasksData && tasksData.length > 0) {
541
  const taskIndex = episodeData[0].task_index;
 
 
542
 
543
- // Convert BigInt to number for comparison
544
- const taskIndexNum = typeof taskIndex === 'bigint' ? Number(taskIndex) : taskIndex;
545
-
546
- // Look up task by index
547
  if (taskIndexNum !== undefined && taskIndexNum < tasksData.length) {
548
  const taskData = tasksData[taskIndexNum];
549
- // Extract task from __index_level_0__ field
550
- task = taskData.__index_level_0__ || taskData.task || taskData['task'] || taskData[0];
551
  }
552
  }
553
- } catch (error) {
554
- // Could not load tasks metadata - dataset might not have language tasks
555
  }
556
  }
557
 
@@ -563,10 +594,10 @@ async function loadEpisodeDataV3(
563
 
564
  // Process episode data for charts (v3.0 compatible)
565
  function processEpisodeDataForCharts(
566
- episodeData: any[],
567
  info: DatasetMetadata,
568
- episodeMetadata?: any,
569
- ): { chartDataGroups: any[]; ignoredColumns: string[] } {
570
 
571
  // Get numeric column features
572
  const columnNames = Object.entries(info.features)
@@ -612,22 +643,22 @@ function processEpisodeDataForCharts(
612
  const excludedColumns = ['index', 'task_index', 'episode_index', 'frame_index', 'next.done'];
613
 
614
  // Create columns structure similar to V2.1 for proper hierarchical naming
615
- const columns = Object.entries(info.features)
616
  .filter(([key, value]) =>
617
  ["float32", "int32"].includes(value.dtype) &&
618
  value.shape.length === 1 &&
619
  !excludedColumns.includes(key)
620
  )
621
  .map(([key, feature]) => {
622
- let column_names = feature.names;
623
- while (typeof column_names === "object") {
624
  if (Array.isArray(column_names)) break;
625
- column_names = Object.values(column_names ?? {})[0];
626
  }
627
  return {
628
  key,
629
  value: Array.isArray(column_names)
630
- ? column_names.map((name) => `${key}${SERIES_NAME_DELIMITER}${name}`)
631
  : Array.from(
632
  { length: feature.shape[0] || 1 },
633
  (_, i) => `${key}${SERIES_NAME_DELIMITER}${i}`,
@@ -819,42 +850,10 @@ function processEpisodeDataForCharts(
819
  return [merged];
820
  });
821
 
822
- // Utility function to group row keys by suffix (same as V2.1)
823
- function groupRowBySuffix(row: Record<string, number>): Record<string, any> {
824
- const result: Record<string, any> = {};
825
- const suffixGroups: Record<string, Record<string, number>> = {};
826
- for (const [key, value] of Object.entries(row)) {
827
- if (key === "timestamp") {
828
- result["timestamp"] = value;
829
- continue;
830
- }
831
- const parts = key.split(SERIES_NAME_DELIMITER);
832
- if (parts.length === 2) {
833
- const [prefix, suffix] = parts;
834
- if (!suffixGroups[suffix]) suffixGroups[suffix] = {};
835
- suffixGroups[suffix][prefix] = value;
836
- } else {
837
- result[key] = value;
838
- }
839
- }
840
- for (const [suffix, group] of Object.entries(suffixGroups)) {
841
- const keys = Object.keys(group);
842
- if (keys.length === 1) {
843
- // Use the full original name as the key
844
- const fullName = `${keys[0]}${SERIES_NAME_DELIMITER}${suffix}`;
845
- result[fullName] = group[keys[0]];
846
- } else {
847
- result[suffix] = group;
848
- }
849
- }
850
- return result;
851
- }
852
-
853
  const chartDataGroups = chartGroups.map((group) =>
854
  chartData.map((row) => groupRowBySuffix(pick(row, [...group, "timestamp"])))
855
  );
856
 
857
-
858
  return { chartDataGroups, ignoredColumns };
859
  }
860
 
@@ -864,8 +863,8 @@ function extractVideoInfoV3WithSegmentation(
864
  repoId: string,
865
  version: string,
866
  info: DatasetMetadata,
867
- episodeMetadata: any,
868
- ): any[] {
869
  // Get video features from dataset info
870
  const videoFeatures = Object.entries(info.features)
871
  .filter(([, value]) => value.dtype === "video");
@@ -876,18 +875,16 @@ function extractVideoInfoV3WithSegmentation(
876
  key.startsWith(`videos/${videoKey}/`)
877
  );
878
 
879
- let chunkIndex, fileIndex, segmentStart, segmentEnd;
880
 
 
 
881
  if (cameraSpecificKeys.length > 0) {
882
- // Use camera-specific metadata
883
- const chunkValue = episodeMetadata[`videos/${videoKey}/chunk_index`];
884
- const fileValue = episodeMetadata[`videos/${videoKey}/file_index`];
885
- chunkIndex = typeof chunkValue === 'bigint' ? Number(chunkValue) : (chunkValue || 0);
886
- fileIndex = typeof fileValue === 'bigint' ? Number(fileValue) : (fileValue || 0);
887
- segmentStart = episodeMetadata[`videos/${videoKey}/from_timestamp`] || 0;
888
- segmentEnd = episodeMetadata[`videos/${videoKey}/to_timestamp`] || 30;
889
  } else {
890
- // Fallback to generic video metadata
891
  chunkIndex = episodeMetadata.video_chunk_index || 0;
892
  fileIndex = episodeMetadata.video_file_index || 0;
893
  segmentStart = episodeMetadata.video_from_timestamp || 0;
@@ -916,7 +913,7 @@ async function loadEpisodeMetadataV3Simple(
916
  repoId: string,
917
  version: string,
918
  episodeId: number,
919
- ): Promise<any> {
920
  // Pattern: meta/episodes/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet
921
  // Most datasets have all episodes in chunk-000/file-000, but episodes can be split across files
922
 
@@ -964,72 +961,76 @@ async function loadEpisodeMetadataV3Simple(
964
  }
965
 
966
  // Simple parser for episode row - focuses on key fields for episodes
967
- function parseEpisodeRowSimple(row: any): any {
968
  // v3.0 uses named keys in the episode metadata
969
  if (row && typeof row === 'object') {
970
  // Check if this is v3.0 format with named keys
971
  if ('episode_index' in row) {
972
  // v3.0 format - use named keys
973
  // Convert BigInt values to numbers
974
- const toBigIntSafe = (value: any) => {
975
  if (typeof value === 'bigint') return Number(value);
976
  if (typeof value === 'number') return value;
977
- return parseInt(value) || 0;
 
978
  };
979
 
980
- const episodeData: any = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
981
  episode_index: toBigIntSafe(row['episode_index']),
982
  data_chunk_index: toBigIntSafe(row['data/chunk_index']),
983
  data_file_index: toBigIntSafe(row['data/file_index']),
984
  dataset_from_index: toBigIntSafe(row['dataset_from_index']),
985
  dataset_to_index: toBigIntSafe(row['dataset_to_index']),
986
  length: toBigIntSafe(row['length']),
 
 
 
 
987
  };
988
 
989
- // Handle video metadata - look for video-specific keys
990
- const videoKeys = Object.keys(row).filter(key => key.includes('videos/') && key.includes('/chunk_index'));
991
- if (videoKeys.length > 0) {
992
- // Use the first video stream for basic info
993
- const firstVideoKey = videoKeys[0];
994
- const videoBaseName = firstVideoKey.replace('/chunk_index', '');
995
-
996
- episodeData.video_chunk_index = toBigIntSafe(row[`${videoBaseName}/chunk_index`]);
997
- episodeData.video_file_index = toBigIntSafe(row[`${videoBaseName}/file_index`]);
998
- episodeData.video_from_timestamp = row[`${videoBaseName}/from_timestamp`] || 0;
999
- episodeData.video_to_timestamp = row[`${videoBaseName}/to_timestamp`] || 0;
1000
- } else {
1001
- // Fallback video values
1002
- episodeData.video_chunk_index = 0;
1003
- episodeData.video_file_index = 0;
1004
- episodeData.video_from_timestamp = 0;
1005
- episodeData.video_to_timestamp = 30;
1006
- }
1007
-
1008
- // Store the raw row data to preserve per-camera metadata
1009
- // This allows extractVideoInfoV3WithSegmentation to access camera-specific timestamps
1010
  Object.keys(row).forEach(key => {
1011
  if (key.startsWith('videos/')) {
1012
- episodeData[key] = row[key];
 
1013
  }
1014
  });
1015
 
1016
  return episodeData;
1017
  } else {
1018
  // Fallback to numeric keys for compatibility
1019
- const episodeData = {
1020
- episode_index: row['0'] || 0,
1021
- data_chunk_index: row['1'] || 0,
1022
- data_file_index: row['2'] || 0,
1023
- dataset_from_index: row['3'] || 0,
1024
- dataset_to_index: row['4'] || 0,
1025
- video_chunk_index: row['5'] || 0,
1026
- video_file_index: row['6'] || 0,
1027
- video_from_timestamp: row['7'] || 0,
1028
- video_to_timestamp: row['8'] || 30,
1029
- length: row['9'] || 30,
 
 
1030
  };
1031
-
1032
- return episodeData;
1033
  }
1034
  }
1035
 
@@ -1058,12 +1059,12 @@ export async function getEpisodeDataSafe(
1058
  org: string,
1059
  dataset: string,
1060
  episodeId: number,
1061
- ): Promise<{ data?: any; error?: string }> {
1062
  try {
1063
  const data = await getEpisodeData(org, dataset, episodeId);
1064
  return { data };
1065
- } catch (err: any) {
1066
- // Only expose the error message, not stack or sensitive info
1067
- return { error: err?.message || String(err) || "Unknown error" };
1068
  }
1069
  }
 
9
 
10
  const SERIES_NAME_DELIMITER = " | ";
11
 
12
+ export type VideoInfo = {
13
+ filename: string;
14
+ url: string;
15
+ isSegmented?: boolean;
16
+ segmentStart?: number;
17
+ segmentEnd?: number;
18
+ segmentDuration?: number;
19
+ };
20
+
21
+ export type DatasetDisplayInfo = {
22
+ repoId: string;
23
+ total_frames: number;
24
+ total_episodes: number;
25
+ fps: number;
26
+ };
27
+
28
+ export type ChartRow = Record<string, number | Record<string, number>>;
29
+
30
+ export type EpisodeData = {
31
+ datasetInfo: DatasetDisplayInfo;
32
+ episodeId: number;
33
+ videosInfo: VideoInfo[];
34
+ chartDataGroups: ChartRow[][];
35
+ episodes: number[];
36
+ ignoredColumns: string[];
37
+ duration: number;
38
+ task?: string;
39
+ };
40
+
41
+ type EpisodeMetadataV3 = {
42
+ episode_index: number;
43
+ data_chunk_index: number;
44
+ data_file_index: number;
45
+ dataset_from_index: number;
46
+ dataset_to_index: number;
47
+ video_chunk_index: number;
48
+ video_file_index: number;
49
+ video_from_timestamp: number;
50
+ video_to_timestamp: number;
51
+ length: number;
52
+ [key: string]: string | number;
53
+ };
54
+
55
+ type ColumnDef = {
56
+ key: string;
57
+ value: string[];
58
+ };
59
+
60
+ function groupRowBySuffix(row: Record<string, number>): ChartRow {
61
+ const result: ChartRow = {};
62
+ const suffixGroups: Record<string, Record<string, number>> = {};
63
+ for (const [key, value] of Object.entries(row)) {
64
+ if (key === "timestamp") {
65
+ result["timestamp"] = value;
66
+ continue;
67
+ }
68
+ const parts = key.split(SERIES_NAME_DELIMITER);
69
+ if (parts.length === 2) {
70
+ const [prefix, suffix] = parts;
71
+ if (!suffixGroups[suffix]) suffixGroups[suffix] = {};
72
+ suffixGroups[suffix][prefix] = value;
73
+ } else {
74
+ result[key] = value;
75
+ }
76
+ }
77
+ for (const [suffix, group] of Object.entries(suffixGroups)) {
78
+ const keys = Object.keys(group);
79
+ if (keys.length === 1) {
80
+ const fullName = `${keys[0]}${SERIES_NAME_DELIMITER}${suffix}`;
81
+ result[fullName] = group[keys[0]];
82
+ } else {
83
+ result[suffix] = group;
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+
89
  export async function getEpisodeData(
90
  org: string,
91
  dataset: string,
92
  episodeId: number,
93
+ ): Promise<EpisodeData> {
94
  const repoId = `${org}/${dataset}`;
95
  try {
96
+ console.time(`[perf] getDatasetVersionAndInfo`);
97
  const { version, info: rawInfo } = await getDatasetVersionAndInfo(repoId);
98
+ console.timeEnd(`[perf] getDatasetVersionAndInfo`);
99
  const info = rawInfo as unknown as DatasetMetadata;
100
 
101
  if (info.video_path === null) {
102
  throw new Error("Only videos datasets are supported in this visualizer.\nPlease use Rerun visualizer for images datasets.");
103
  }
104
 
105
+ console.time(`[perf] getEpisodeData (${version})`);
106
+ const result = version === "v3.0"
107
+ ? await getEpisodeDataV3(repoId, version, info, episodeId)
108
+ : await getEpisodeDataV2(repoId, version, info, episodeId);
109
+ console.timeEnd(`[perf] getEpisodeData (${version})`);
110
+ return result;
111
  } catch (err) {
112
  console.error("Error loading episode data:", err);
113
  throw err;
 
126
  const info = rawInfo as unknown as DatasetMetadata;
127
 
128
  const totalEpisodes = info.total_episodes;
129
+ const adjacentVideos: Array<{episodeId: number; videosInfo: VideoInfo[]}> = [];
130
 
131
  // Calculate adjacent episode IDs
132
  for (let offset = -radius; offset <= radius; offset++) {
 
135
  const episodeId = currentEpisodeId + offset;
136
  if (episodeId >= 0 && episodeId < totalEpisodes) {
137
  try {
138
+ let videosInfo: VideoInfo[] = [];
139
 
140
  if (version === "v3.0") {
141
  const episodeMetadata = await loadEpisodeMetadataV3Simple(repoId, version, episodeId);
 
178
  version: string,
179
  info: DatasetMetadata,
180
  episodeId: number,
181
+ ): Promise<EpisodeData> {
182
  const episode_chunk = Math.floor(0 / 1000);
183
 
184
  // Dataset information
 
242
  ...filteredColumns.map((column) => column.key),
243
  ];
244
 
245
+ const columns: ColumnDef[] = filteredColumns.map(({ key }) => {
246
+ let column_names: unknown = info.features[key].names;
247
+ while (typeof column_names === "object" && column_names !== null) {
248
  if (Array.isArray(column_names)) break;
249
+ column_names = Object.values(column_names)[0];
250
  }
251
  return {
252
  key,
253
  value: Array.isArray(column_names)
254
+ ? column_names.map((name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`)
255
  : Array.from(
256
  { length: columnNames.find((c) => c.key === key)?.length ?? 1 },
257
  (_, i) => `${key}${SERIES_NAME_DELIMITER}${i}`,
 
278
  const firstRow = allData[0];
279
  const languageInstructions: string[] = [];
280
 
281
+ if (typeof firstRow.language_instruction === 'string') {
282
  languageInstructions.push(firstRow.language_instruction);
283
  }
284
 
285
  let instructionNum = 2;
286
+ while (typeof firstRow[`language_instruction_${instructionNum}`] === 'string') {
287
+ languageInstructions.push(firstRow[`language_instruction_${instructionNum}`] as string);
288
  instructionNum++;
289
  }
290
 
 
293
  }
294
  }
295
 
296
+ if (!task && allData.length > 0 && typeof allData[0].task === 'string') {
297
  task = allData[0].task;
298
  }
299
 
 
331
 
332
  const chartData = allData.map((row) => {
333
  const obj: Record<string, number> = {};
334
+ obj["timestamp"] = Number(row.timestamp);
335
  for (const col of columns) {
336
  const rawVal = row[col.key];
337
  if (Array.isArray(rawVal)) {
338
+ rawVal.forEach((v: unknown, i: number) => {
339
  if (i < col.value.length) obj[col.value[i]] = Number(v);
340
+ });
341
  } else if (rawVal !== undefined) {
342
  obj[col.value[0]] = Number(rawVal);
343
  }
 
431
 
432
  const duration = chartData[chartData.length - 1].timestamp;
433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  const chartDataGroups = chartGroups.map((group) =>
435
  chartData.map((row) => groupRowBySuffix(pick(row, [...group, "timestamp"])))
436
  );
 
453
  version: string,
454
  info: DatasetMetadata,
455
  episodeId: number,
456
+ ): Promise<EpisodeData> {
457
  // Create dataset info structure (like v2.x)
458
  const datasetInfo = {
459
  repoId,
 
495
  repoId: string,
496
  version: string,
497
  info: DatasetMetadata,
498
+ episodeMetadata: EpisodeMetadataV3,
499
+ ): Promise<{ chartDataGroups: ChartRow[][]; ignoredColumns: string[]; task?: string }> {
500
  // Build data file path using chunk and file indices
501
  const dataChunkIndex = episodeMetadata.data_chunk_index || 0;
502
  const dataFileIndex = episodeMetadata.data_file_index || 0;
 
535
  // First check for language_instruction fields in the data (preferred)
536
  let task: string | undefined;
537
  if (episodeData.length > 0) {
 
538
  const languageInstructions: string[] = [];
539
 
540
+ const extractInstructions = (row: Record<string, unknown>) => {
541
+ if (typeof row.language_instruction === 'string') {
542
+ languageInstructions.push(row.language_instruction);
543
+ }
544
+ let num = 2;
545
+ while (typeof row[`language_instruction_${num}`] === 'string') {
546
+ languageInstructions.push(row[`language_instruction_${num}`] as string);
547
+ num++;
548
+ }
549
+ };
550
+
551
+ extractInstructions(episodeData[0]);
552
 
553
+ // If no instructions in first row, check middle and last rows
554
  if (languageInstructions.length === 0 && episodeData.length > 1) {
555
+ for (const idx of [Math.floor(episodeData.length / 2), episodeData.length - 1]) {
556
+ extractInstructions(episodeData[idx]);
557
+ if (languageInstructions.length > 0) break;
558
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  }
560
 
 
561
  if (languageInstructions.length > 0) {
562
  task = languageInstructions.join('\n');
563
  }
564
  }
565
 
566
+ // Fall back to tasks metadata parquet
567
+ if (!task && episodeData.length > 0) {
568
  try {
 
569
  const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.parquet");
570
  const tasksArrayBuffer = await fetchParquetFile(tasksUrl);
571
  const tasksData = await readParquetAsObjects(tasksArrayBuffer, []);
572
 
573
+ if (tasksData.length > 0) {
574
  const taskIndex = episodeData[0].task_index;
575
+ const taskIndexNum = typeof taskIndex === 'bigint' ? Number(taskIndex) :
576
+ typeof taskIndex === 'number' ? taskIndex : undefined;
577
 
 
 
 
 
578
  if (taskIndexNum !== undefined && taskIndexNum < tasksData.length) {
579
  const taskData = tasksData[taskIndexNum];
580
+ const rawTask = taskData.__index_level_0__ ?? taskData.task;
581
+ task = typeof rawTask === 'string' ? rawTask : undefined;
582
  }
583
  }
584
+ } catch {
585
+ // Could not load tasks metadata
586
  }
587
  }
588
 
 
594
 
595
  // Process episode data for charts (v3.0 compatible)
596
  function processEpisodeDataForCharts(
597
+ episodeData: Record<string, unknown>[],
598
  info: DatasetMetadata,
599
+ episodeMetadata?: EpisodeMetadataV3,
600
+ ): { chartDataGroups: ChartRow[][]; ignoredColumns: string[] } {
601
 
602
  // Get numeric column features
603
  const columnNames = Object.entries(info.features)
 
643
  const excludedColumns = ['index', 'task_index', 'episode_index', 'frame_index', 'next.done'];
644
 
645
  // Create columns structure similar to V2.1 for proper hierarchical naming
646
+ const columns: ColumnDef[] = Object.entries(info.features)
647
  .filter(([key, value]) =>
648
  ["float32", "int32"].includes(value.dtype) &&
649
  value.shape.length === 1 &&
650
  !excludedColumns.includes(key)
651
  )
652
  .map(([key, feature]) => {
653
+ let column_names: unknown = feature.names;
654
+ while (typeof column_names === "object" && column_names !== null) {
655
  if (Array.isArray(column_names)) break;
656
+ column_names = Object.values(column_names)[0];
657
  }
658
  return {
659
  key,
660
  value: Array.isArray(column_names)
661
+ ? column_names.map((name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`)
662
  : Array.from(
663
  { length: feature.shape[0] || 1 },
664
  (_, i) => `${key}${SERIES_NAME_DELIMITER}${i}`,
 
850
  return [merged];
851
  });
852
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
853
  const chartDataGroups = chartGroups.map((group) =>
854
  chartData.map((row) => groupRowBySuffix(pick(row, [...group, "timestamp"])))
855
  );
856
 
 
857
  return { chartDataGroups, ignoredColumns };
858
  }
859
 
 
863
  repoId: string,
864
  version: string,
865
  info: DatasetMetadata,
866
+ episodeMetadata: EpisodeMetadataV3,
867
+ ): VideoInfo[] {
868
  // Get video features from dataset info
869
  const videoFeatures = Object.entries(info.features)
870
  .filter(([, value]) => value.dtype === "video");
 
875
  key.startsWith(`videos/${videoKey}/`)
876
  );
877
 
878
+ let chunkIndex: number, fileIndex: number, segmentStart: number, segmentEnd: number;
879
 
880
+ const toNum = (v: string | number): number => typeof v === 'string' ? parseFloat(v) || 0 : v;
881
+
882
  if (cameraSpecificKeys.length > 0) {
883
+ chunkIndex = toNum(episodeMetadata[`videos/${videoKey}/chunk_index`]);
884
+ fileIndex = toNum(episodeMetadata[`videos/${videoKey}/file_index`]);
885
+ segmentStart = toNum(episodeMetadata[`videos/${videoKey}/from_timestamp`]) || 0;
886
+ segmentEnd = toNum(episodeMetadata[`videos/${videoKey}/to_timestamp`]) || 30;
 
 
 
887
  } else {
 
888
  chunkIndex = episodeMetadata.video_chunk_index || 0;
889
  fileIndex = episodeMetadata.video_file_index || 0;
890
  segmentStart = episodeMetadata.video_from_timestamp || 0;
 
913
  repoId: string,
914
  version: string,
915
  episodeId: number,
916
+ ): Promise<EpisodeMetadataV3> {
917
  // Pattern: meta/episodes/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet
918
  // Most datasets have all episodes in chunk-000/file-000, but episodes can be split across files
919
 
 
961
  }
962
 
963
  // Simple parser for episode row - focuses on key fields for episodes
964
+ function parseEpisodeRowSimple(row: Record<string, unknown>): EpisodeMetadataV3 {
965
  // v3.0 uses named keys in the episode metadata
966
  if (row && typeof row === 'object') {
967
  // Check if this is v3.0 format with named keys
968
  if ('episode_index' in row) {
969
  // v3.0 format - use named keys
970
  // Convert BigInt values to numbers
971
+ const toBigIntSafe = (value: unknown): number => {
972
  if (typeof value === 'bigint') return Number(value);
973
  if (typeof value === 'number') return value;
974
+ if (typeof value === 'string') return parseInt(value) || 0;
975
+ return 0;
976
  };
977
 
978
+ const toNumSafe = (value: unknown): number => {
979
+ if (typeof value === 'number') return value;
980
+ if (typeof value === 'bigint') return Number(value);
981
+ if (typeof value === 'string') return parseFloat(value) || 0;
982
+ return 0;
983
+ };
984
+
985
+ // Handle video metadata - look for video-specific keys
986
+ const videoKeys = Object.keys(row).filter(key => key.includes('videos/') && key.includes('/chunk_index'));
987
+ let videoChunkIndex = 0, videoFileIndex = 0, videoFromTs = 0, videoToTs = 30;
988
+ if (videoKeys.length > 0) {
989
+ const videoBaseName = videoKeys[0].replace('/chunk_index', '');
990
+ videoChunkIndex = toBigIntSafe(row[`${videoBaseName}/chunk_index`]);
991
+ videoFileIndex = toBigIntSafe(row[`${videoBaseName}/file_index`]);
992
+ videoFromTs = toNumSafe(row[`${videoBaseName}/from_timestamp`]);
993
+ videoToTs = toNumSafe(row[`${videoBaseName}/to_timestamp`]) || 30;
994
+ }
995
+
996
+ const episodeData: EpisodeMetadataV3 = {
997
  episode_index: toBigIntSafe(row['episode_index']),
998
  data_chunk_index: toBigIntSafe(row['data/chunk_index']),
999
  data_file_index: toBigIntSafe(row['data/file_index']),
1000
  dataset_from_index: toBigIntSafe(row['dataset_from_index']),
1001
  dataset_to_index: toBigIntSafe(row['dataset_to_index']),
1002
  length: toBigIntSafe(row['length']),
1003
+ video_chunk_index: videoChunkIndex,
1004
+ video_file_index: videoFileIndex,
1005
+ video_from_timestamp: videoFromTs,
1006
+ video_to_timestamp: videoToTs,
1007
  };
1008
 
1009
+ // Store per-camera metadata for extractVideoInfoV3WithSegmentation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1010
  Object.keys(row).forEach(key => {
1011
  if (key.startsWith('videos/')) {
1012
+ const val = row[key];
1013
+ episodeData[key] = typeof val === 'bigint' ? Number(val) : (typeof val === 'number' || typeof val === 'string' ? val : 0);
1014
  }
1015
  });
1016
 
1017
  return episodeData;
1018
  } else {
1019
  // Fallback to numeric keys for compatibility
1020
+ const toNum = (v: unknown, fallback = 0): number =>
1021
+ typeof v === 'number' ? v : typeof v === 'bigint' ? Number(v) : fallback;
1022
+ return {
1023
+ episode_index: toNum(row['0']),
1024
+ data_chunk_index: toNum(row['1']),
1025
+ data_file_index: toNum(row['2']),
1026
+ dataset_from_index: toNum(row['3']),
1027
+ dataset_to_index: toNum(row['4']),
1028
+ video_chunk_index: toNum(row['5']),
1029
+ video_file_index: toNum(row['6']),
1030
+ video_from_timestamp: toNum(row['7']),
1031
+ video_to_timestamp: toNum(row['8'], 30),
1032
+ length: toNum(row['9'], 30),
1033
  };
 
 
1034
  }
1035
  }
1036
 
 
1059
  org: string,
1060
  dataset: string,
1061
  episodeId: number,
1062
+ ): Promise<{ data?: EpisodeData; error?: string }> {
1063
  try {
1064
  const data = await getEpisodeData(org, dataset, episodeId);
1065
  return { data };
1066
+ } catch (err: unknown) {
1067
+ const message = err instanceof Error ? err.message : String(err);
1068
+ return { error: message || "Unknown error" };
1069
  }
1070
  }
src/app/explore/page.tsx CHANGED
@@ -12,7 +12,7 @@ export default async function ExplorePage({
12
  }: {
13
  searchParams: { p?: string };
14
  }) {
15
- let datasets: any[] = [];
16
  let currentPage = 1;
17
  let totalPages = 1;
18
  try {
@@ -42,7 +42,7 @@ export default async function ExplorePage({
42
  // Fetch episode 0 data for each dataset
43
  const datasetWithVideos = (
44
  await Promise.all(
45
- datasets.map(async (ds: any) => {
46
  try {
47
  const [org, dataset] = ds.id.split("/");
48
  const repoId = `${org}/${dataset}`;
 
12
  }: {
13
  searchParams: { p?: string };
14
  }) {
15
+ let datasets: { id: string }[] = [];
16
  let currentPage = 1;
17
  let totalPages = 1;
18
  try {
 
42
  // Fetch episode 0 data for each dataset
43
  const datasetWithVideos = (
44
  await Promise.all(
45
+ datasets.map(async (ds) => {
46
  try {
47
  const [org, dataset] = ds.id.split("/");
48
  const repoId = `${org}/${dataset}`;
src/app/page.tsx CHANGED
@@ -4,6 +4,13 @@ import Link from "next/link";
4
  import { useRouter } from "next/navigation";
5
  import { useSearchParams } from "next/navigation";
6
 
 
 
 
 
 
 
 
7
  export default function Home() {
8
  return (
9
  <Suspense fallback={null}>
@@ -53,18 +60,19 @@ function HomeInner() {
53
  }
54
  }, [searchParams, router]);
55
 
56
- const playerRef = useRef<any>(null);
57
 
58
  useEffect(() => {
59
  // Load YouTube IFrame API if not already present
60
- if (!(window as any).YT) {
61
  const tag = document.createElement("script");
62
  tag.src = "https://www.youtube.com/iframe_api";
63
  document.body.appendChild(tag);
64
  }
65
  let interval: NodeJS.Timeout;
66
- (window as any).onYouTubeIframeAPIReady = () => {
67
- playerRef.current = new (window as any).YT.Player("yt-bg-player", {
 
68
  videoId: "Er8SPJsIYr0",
69
  playerVars: {
70
  autoplay: 1,
@@ -79,7 +87,7 @@ function HomeInner() {
79
  start: 0,
80
  },
81
  events: {
82
- onReady: (event: any) => {
83
  event.target.playVideo();
84
  event.target.mute();
85
  interval = setInterval(() => {
@@ -101,7 +109,7 @@ function HomeInner() {
101
 
102
  const inputRef = useRef<HTMLInputElement>(null);
103
 
104
- const handleGo = (e: React.FormEvent) => {
105
  e.preventDefault();
106
  const value = inputRef.current?.value.trim();
107
  if (value) {
@@ -138,9 +146,8 @@ function HomeInner() {
138
  className="px-4 py-2 rounded-md text-base text-white border-white border-1 focus:outline-none min-w-[220px] shadow-md"
139
  onKeyDown={(e) => {
140
  if (e.key === "Enter") {
141
- // Prevent double submission if form onSubmit also fires
142
  e.preventDefault();
143
- handleGo(e as any);
144
  }
145
  }}
146
  />
 
4
  import { useRouter } from "next/navigation";
5
  import { useSearchParams } from "next/navigation";
6
 
7
+ declare global {
8
+ interface Window {
9
+ YT?: { Player: new (id: string, config: Record<string, unknown>) => { destroy?: () => void } };
10
+ onYouTubeIframeAPIReady?: () => void;
11
+ }
12
+ }
13
+
14
  export default function Home() {
15
  return (
16
  <Suspense fallback={null}>
 
60
  }
61
  }, [searchParams, router]);
62
 
63
+ const playerRef = useRef<{ destroy?: () => void } | null>(null);
64
 
65
  useEffect(() => {
66
  // Load YouTube IFrame API if not already present
67
+ if (!window.YT) {
68
  const tag = document.createElement("script");
69
  tag.src = "https://www.youtube.com/iframe_api";
70
  document.body.appendChild(tag);
71
  }
72
  let interval: NodeJS.Timeout;
73
+ window.onYouTubeIframeAPIReady = () => {
74
+ if (!window.YT) return;
75
+ playerRef.current = new window.YT.Player("yt-bg-player", {
76
  videoId: "Er8SPJsIYr0",
77
  playerVars: {
78
  autoplay: 1,
 
87
  start: 0,
88
  },
89
  events: {
90
+ onReady: (event: { target: { playVideo: () => void; mute: () => void; seekTo: (t: number) => void; getCurrentTime: () => number } }) => {
91
  event.target.playVideo();
92
  event.target.mute();
93
  interval = setInterval(() => {
 
109
 
110
  const inputRef = useRef<HTMLInputElement>(null);
111
 
112
+ const handleGo = (e: { preventDefault: () => void }) => {
113
  e.preventDefault();
114
  const value = inputRef.current?.value.trim();
115
  if (value) {
 
146
  className="px-4 py-2 rounded-md text-base text-white border-white border-1 focus:outline-none min-w-[220px] shadow-md"
147
  onKeyDown={(e) => {
148
  if (e.key === "Enter") {
 
149
  e.preventDefault();
150
+ handleGo(e);
151
  }
152
  }}
153
  />
src/components/data-recharts.tsx CHANGED
@@ -12,8 +12,10 @@ import {
12
  Tooltip,
13
  } from "recharts";
14
 
 
 
15
  type DataGraphProps = {
16
- data: Array<Array<Record<string, number>>>;
17
  onChartsReady?: () => void;
18
  };
19
 
@@ -57,12 +59,12 @@ const SingleDataGraph = React.memo(
57
  hoveredTime,
58
  setHoveredTime,
59
  }: {
60
- data: Array<Record<string, number>>;
61
  hoveredTime: number | null;
62
  setHoveredTime: (t: number | null) => void;
63
  }) => {
64
  const { currentTime, setCurrentTime } = useTime();
65
- function flattenRow(row: Record<string, any>, prefix = ""): Record<string, number> {
66
  const result: Record<string, number> = {};
67
  for (const [key, value] of Object.entries(row)) {
68
  // Special case: if this is a group value that is a primitive, assign to prefix.key
@@ -77,8 +79,7 @@ const SingleDataGraph = React.memo(
77
  Object.assign(result, flattenRow(value, prefix ? `${prefix}${SERIES_NAME_DELIMITER}${key}` : key));
78
  }
79
  }
80
- // Always keep timestamp at top level if present
81
- if ("timestamp" in row) {
82
  result["timestamp"] = row["timestamp"];
83
  }
84
  return result;
@@ -132,10 +133,9 @@ const SingleDataGraph = React.memo(
132
  setHoveredTime(null);
133
  };
134
 
135
- const handleClick = (data: any) => {
136
- if (data && data.activePayload && data.activePayload.length) {
137
- const timeValue = data.activePayload[0].payload.timestamp;
138
- setCurrentTime(timeValue);
139
  }
140
  };
141
 
@@ -256,12 +256,9 @@ const SingleDataGraph = React.memo(
256
  syncId="episode-sync"
257
  margin={{ top: 24, right: 16, left: 0, bottom: 16 }}
258
  onClick={handleClick}
259
- onMouseMove={(state: any) => {
260
- setHoveredTime(
261
- state?.activePayload?.[0]?.payload?.timestamp ??
262
- state?.activeLabel ??
263
- null,
264
- );
265
  }}
266
  onMouseLeave={handleMouseLeave}
267
  >
 
12
  Tooltip,
13
  } from "recharts";
14
 
15
+ type ChartRow = Record<string, number | Record<string, number>>;
16
+
17
  type DataGraphProps = {
18
+ data: ChartRow[][];
19
  onChartsReady?: () => void;
20
  };
21
 
 
59
  hoveredTime,
60
  setHoveredTime,
61
  }: {
62
+ data: ChartRow[];
63
  hoveredTime: number | null;
64
  setHoveredTime: (t: number | null) => void;
65
  }) => {
66
  const { currentTime, setCurrentTime } = useTime();
67
+ function flattenRow(row: Record<string, number | Record<string, number>>, prefix = ""): Record<string, number> {
68
  const result: Record<string, number> = {};
69
  for (const [key, value] of Object.entries(row)) {
70
  // Special case: if this is a group value that is a primitive, assign to prefix.key
 
79
  Object.assign(result, flattenRow(value, prefix ? `${prefix}${SERIES_NAME_DELIMITER}${key}` : key));
80
  }
81
  }
82
+ if ("timestamp" in row && typeof row["timestamp"] === "number") {
 
83
  result["timestamp"] = row["timestamp"];
84
  }
85
  return result;
 
133
  setHoveredTime(null);
134
  };
135
 
136
+ const handleClick = (data: { activePayload?: { payload: { timestamp: number } }[] } | null) => {
137
+ if (data?.activePayload?.length) {
138
+ setCurrentTime(data.activePayload[0].payload.timestamp);
 
139
  }
140
  };
141
 
 
256
  syncId="episode-sync"
257
  margin={{ top: 24, right: 16, left: 0, bottom: 16 }}
258
  onClick={handleClick}
259
+ onMouseMove={(state) => {
260
+ const payload = state?.activePayload?.[0]?.payload as { timestamp?: number } | undefined;
261
+ setHoveredTime(payload?.timestamp ?? null);
 
 
 
262
  }}
263
  onMouseLeave={handleMouseLeave}
264
  >
src/components/playback-bar.tsx CHANGED
@@ -10,8 +10,6 @@ import {
10
  FaArrowUp,
11
  } from "react-icons/fa";
12
 
13
- import { debounce } from "@/utils/debounce";
14
-
15
  const PlaybackBar: React.FC = () => {
16
  const { duration, isPlaying, setIsPlaying, currentTime, setCurrentTime } =
17
  useTime();
@@ -27,14 +25,11 @@ const PlaybackBar: React.FC = () => {
27
  }
28
  }, [currentTime]);
29
 
30
- const updateTime = debounce((t: number) => {
31
- setCurrentTime(t);
32
- }, 200);
33
-
34
  const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
35
  const t = Number(e.target.value);
36
  setSliderValue(t);
37
- updateTime(t);
 
38
  };
39
 
40
  const handleSliderMouseDown = () => {
@@ -45,11 +40,11 @@ const PlaybackBar: React.FC = () => {
45
 
46
  const handleSliderMouseUp = () => {
47
  sliderActiveRef.current = false;
48
- setCurrentTime(sliderValue); // Snap to final value
 
49
  if (wasPlayingRef.current) {
50
  setIsPlaying(true);
51
  }
52
- // If it was paused before, keep it paused
53
  };
54
 
55
  return (
 
10
  FaArrowUp,
11
  } from "react-icons/fa";
12
 
 
 
13
  const PlaybackBar: React.FC = () => {
14
  const { duration, isPlaying, setIsPlaying, currentTime, setCurrentTime } =
15
  useTime();
 
25
  }
26
  }, [currentTime]);
27
 
 
 
 
 
28
  const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
29
  const t = Number(e.target.value);
30
  setSliderValue(t);
31
+ // Seek videos immediately while dragging (no debounce)
32
+ setCurrentTime(t);
33
  };
34
 
35
  const handleSliderMouseDown = () => {
 
40
 
41
  const handleSliderMouseUp = () => {
42
  sliderActiveRef.current = false;
43
+ // Final seek to exact slider position
44
+ setCurrentTime(sliderValue);
45
  if (wasPlayingRef.current) {
46
  setIsPlaying(true);
47
  }
 
48
  };
49
 
50
  return (
src/components/side-nav.tsx CHANGED
@@ -3,10 +3,12 @@
3
  import Link from "next/link";
4
  import React from "react";
5
 
 
 
6
  interface SidebarProps {
7
- datasetInfo: any;
8
- paginatedEpisodes: any[];
9
- episodeId: any;
10
  totalPages: number;
11
  currentPage: number;
12
  prevPage: () => void;
@@ -53,7 +55,7 @@ const Sidebar: React.FC<SidebarProps> = ({
53
  aria-label="Sidebar navigation"
54
  >
55
  <ul>
56
- <li>Number of samples/frames: {datasetInfo.total_frames}</li>
57
  <li>Number of episodes: {datasetInfo.total_episodes}</li>
58
  <li>Frames per second: {datasetInfo.fps}</li>
59
  </ul>
 
3
  import Link from "next/link";
4
  import React from "react";
5
 
6
+ import type { DatasetDisplayInfo } from "@/app/[org]/[dataset]/[episode]/fetch-data";
7
+
8
  interface SidebarProps {
9
+ datasetInfo: DatasetDisplayInfo;
10
+ paginatedEpisodes: number[];
11
+ episodeId: number;
12
  totalPages: number;
13
  currentPage: number;
14
  prevPage: () => void;
 
55
  aria-label="Sidebar navigation"
56
  >
57
  <ul>
58
+ <li>Number of frames: {datasetInfo.total_frames}</li>
59
  <li>Number of episodes: {datasetInfo.total_episodes}</li>
60
  <li>Frames per second: {datasetInfo.fps}</li>
61
  </ul>
src/components/simple-videos-player.tsx CHANGED
@@ -3,21 +3,15 @@
3
  import React, { useEffect, useRef } from "react";
4
  import { useTime } from "../context/time-context";
5
  import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
6
-
7
- type VideoInfo = {
8
- filename: string;
9
- url: string;
10
- isSegmented?: boolean;
11
- segmentStart?: number;
12
- segmentEnd?: number;
13
- segmentDuration?: number;
14
- };
15
 
16
  type VideoPlayerProps = {
17
  videosInfo: VideoInfo[];
18
  onVideosReady?: () => void;
19
  };
20
 
 
 
21
  export const SimpleVideosPlayer = ({
22
  videosInfo,
23
  onVideosReady,
@@ -33,6 +27,10 @@ export const SimpleVideosPlayer = ({
33
  (video) => !hiddenVideos.includes(video.filename)
34
  );
35
 
 
 
 
 
36
  // Initialize video refs array
37
  useEffect(() => {
38
  videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
@@ -78,11 +76,10 @@ export const SimpleVideosPlayer = ({
78
  video.addEventListener('timeupdate', handleTimeUpdate);
79
  video.addEventListener('loadeddata', handleLoadedData);
80
 
81
- // Store cleanup
82
- (video as any)._segmentHandlers = () => {
83
  video.removeEventListener('timeupdate', handleTimeUpdate);
84
  video.removeEventListener('loadeddata', handleLoadedData);
85
- };
86
  } else {
87
  // For non-segmented videos, handle end of video
88
  const handleEnded = () => {
@@ -95,18 +92,20 @@ export const SimpleVideosPlayer = ({
95
  video.addEventListener('ended', handleEnded);
96
  video.addEventListener('canplaythrough', checkReady, { once: true });
97
 
98
- // Store cleanup
99
- (video as any)._segmentHandlers = () => {
100
  video.removeEventListener('ended', handleEnded);
101
- };
102
  }
103
  }
104
  });
105
 
106
  return () => {
107
  videoRefs.current.forEach((video) => {
108
- if (video && (video as any)._segmentHandlers) {
109
- (video as any)._segmentHandlers();
 
 
 
110
  }
111
  });
112
  };
@@ -131,25 +130,32 @@ export const SimpleVideosPlayer = ({
131
  });
132
  }, [isPlaying, videosReady, hiddenVideos, videosInfo]);
133
 
134
- // Sync video times
 
 
135
  useEffect(() => {
136
  if (!videosReady) return;
 
 
137
 
138
  videoRefs.current.forEach((video, index) => {
139
- if (video && !hiddenVideos.includes(videosInfo[index].filename)) {
140
- const info = videosInfo[index];
141
- let targetTime = currentTime;
142
-
143
- if (info.isSegmented) {
144
- targetTime = (info.segmentStart || 0) + currentTime;
145
- }
146
-
147
- if (Math.abs(video.currentTime - targetTime) > 0.2) {
148
- video.currentTime = targetTime;
149
- }
 
 
 
150
  }
151
  });
152
- }, [currentTime, videosInfo, videosReady, hiddenVideos]);
153
 
154
  // Handle time update from first visible video
155
  const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
@@ -162,6 +168,7 @@ export const SimpleVideosPlayer = ({
162
  if (info.isSegmented) {
163
  globalTime = video.currentTime - (info.segmentStart || 0);
164
  }
 
165
  setCurrentTime(globalTime);
166
  }
167
  };
@@ -247,12 +254,12 @@ export const SimpleVideosPlayer = ({
247
  </span>
248
  </p>
249
  <video
250
- ref={el => videoRefs.current[idx] = el}
251
  className={`w-full object-contain ${
252
  isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""
253
  }`}
254
  muted
255
- preload={isFirstVisible ? "auto" : "metadata"}
256
  onPlay={(e) => handlePlay(e.currentTarget, info)}
257
  onTimeUpdate={isFirstVisible ? handleTimeUpdate : undefined}
258
  >
 
3
  import React, { useEffect, useRef } from "react";
4
  import { useTime } from "../context/time-context";
5
  import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
6
+ import type { VideoInfo } from "@/app/[org]/[dataset]/[episode]/fetch-data";
 
 
 
 
 
 
 
 
7
 
8
  type VideoPlayerProps = {
9
  videosInfo: VideoInfo[];
10
  onVideosReady?: () => void;
11
  };
12
 
13
+ const videoEventCleanup = new WeakMap<HTMLVideoElement, () => void>();
14
+
15
  export const SimpleVideosPlayer = ({
16
  videosInfo,
17
  onVideosReady,
 
27
  (video) => !hiddenVideos.includes(video.filename)
28
  );
29
 
30
+ // Tracks the last time value set by the primary video's onTimeUpdate.
31
+ // If currentTime differs from this, an external source (slider/chart click) changed it.
32
+ const lastVideoTimeRef = useRef(0);
33
+
34
  // Initialize video refs array
35
  useEffect(() => {
36
  videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
 
76
  video.addEventListener('timeupdate', handleTimeUpdate);
77
  video.addEventListener('loadeddata', handleLoadedData);
78
 
79
+ videoEventCleanup.set(video, () => {
 
80
  video.removeEventListener('timeupdate', handleTimeUpdate);
81
  video.removeEventListener('loadeddata', handleLoadedData);
82
+ });
83
  } else {
84
  // For non-segmented videos, handle end of video
85
  const handleEnded = () => {
 
92
  video.addEventListener('ended', handleEnded);
93
  video.addEventListener('canplaythrough', checkReady, { once: true });
94
 
95
+ videoEventCleanup.set(video, () => {
 
96
  video.removeEventListener('ended', handleEnded);
97
+ });
98
  }
99
  }
100
  });
101
 
102
  return () => {
103
  videoRefs.current.forEach((video) => {
104
+ if (!video) return;
105
+ const cleanup = videoEventCleanup.get(video);
106
+ if (cleanup) {
107
+ cleanup();
108
+ videoEventCleanup.delete(video);
109
  }
110
  });
111
  };
 
130
  });
131
  }, [isPlaying, videosReady, hiddenVideos, videosInfo]);
132
 
133
+ // Sync all video times when currentTime changes.
134
+ // For the primary video, only seek when the change came from an external source
135
+ // (slider drag, chart click, etc.) — detected by comparing against lastVideoTimeRef.
136
  useEffect(() => {
137
  if (!videosReady) return;
138
+
139
+ const isExternalSeek = Math.abs(currentTime - lastVideoTimeRef.current) > 0.3;
140
 
141
  videoRefs.current.forEach((video, index) => {
142
+ if (!video) return;
143
+ if (hiddenVideos.includes(videosInfo[index].filename)) return;
144
+
145
+ // Skip the primary video unless the time was changed externally
146
+ if (index === firstVisibleIdx && !isExternalSeek) return;
147
+
148
+ const info = videosInfo[index];
149
+ let targetTime = currentTime;
150
+ if (info.isSegmented) {
151
+ targetTime = (info.segmentStart || 0) + currentTime;
152
+ }
153
+
154
+ if (Math.abs(video.currentTime - targetTime) > 0.2) {
155
+ video.currentTime = targetTime;
156
  }
157
  });
158
+ }, [currentTime, videosInfo, videosReady, hiddenVideos, firstVisibleIdx]);
159
 
160
  // Handle time update from first visible video
161
  const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
 
168
  if (info.isSegmented) {
169
  globalTime = video.currentTime - (info.segmentStart || 0);
170
  }
171
+ lastVideoTimeRef.current = globalTime;
172
  setCurrentTime(globalTime);
173
  }
174
  };
 
254
  </span>
255
  </p>
256
  <video
257
+ ref={(el: HTMLVideoElement | null) => { videoRefs.current[idx] = el; }}
258
  className={`w-full object-contain ${
259
  isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""
260
  }`}
261
  muted
262
+ preload="auto"
263
  onPlay={(e) => handlePlay(e.currentTarget, info)}
264
  onTimeUpdate={isFirstVisible ? handleTimeUpdate : undefined}
265
  >
src/components/videos-player.tsx CHANGED
@@ -3,21 +3,16 @@
3
  import { useEffect, useRef, useState } from "react";
4
  import { useTime } from "../context/time-context";
5
  import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
6
-
7
- type VideoInfo = {
8
- filename: string;
9
- url: string;
10
- isSegmented?: boolean;
11
- segmentStart?: number;
12
- segmentEnd?: number;
13
- segmentDuration?: number;
14
- };
15
 
16
  type VideoPlayerProps = {
17
  videosInfo: VideoInfo[];
18
  onVideosReady?: () => void;
19
  };
20
 
 
 
 
21
  export const VideosPlayer = ({
22
  videosInfo,
23
  onVideosReady,
@@ -43,6 +38,10 @@ export const VideosPlayer = ({
43
  const showHiddenBtnRef = useRef<HTMLButtonElement | null>(null);
44
  const [videoCodecError, setVideoCodecError] = useState(false);
45
 
 
 
 
 
46
  // Initialize video refs
47
  useEffect(() => {
48
  videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
@@ -146,45 +145,47 @@ export const VideosPlayer = ({
146
  }
147
  }, [hiddenVideos, showHiddenMenu, enlargedVideo]);
148
 
149
- // Sync video times (with segment awareness)
 
 
150
  useEffect(() => {
 
 
151
  videoRefs.current.forEach((video, index) => {
152
- if (video && Math.abs(video.currentTime - currentTime) > 0.2) {
153
- const videoInfo = videosInfo[index];
154
-
155
- if (videoInfo?.isSegmented) {
156
- // For segmented videos, map the global time to segment time
157
- const segmentStart = videoInfo.segmentStart || 0;
158
- const segmentDuration = videoInfo.segmentDuration || 0;
159
-
160
- if (segmentDuration > 0) {
161
- // Map currentTime (0 to segmentDuration) to video time (segmentStart to segmentEnd)
162
- const segmentTime = segmentStart + currentTime;
163
- video.currentTime = segmentTime;
164
- }
165
- } else {
166
- // For non-segmented videos, use direct time mapping
167
  video.currentTime = currentTime;
168
  }
169
  }
170
  });
171
- }, [currentTime, videosInfo]);
172
 
173
  // Handle time update
174
  const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
175
  const video = e.target as HTMLVideoElement;
176
  if (video && video.duration) {
177
- // Find the video info for this video element
178
  const videoIndex = videoRefs.current.findIndex(ref => ref === video);
179
  const videoInfo = videosInfo[videoIndex];
180
 
181
  if (videoInfo?.isSegmented) {
182
- // For segmented videos, map the video time back to global time (0 to segmentDuration)
183
  const segmentStart = videoInfo.segmentStart || 0;
184
  const globalTime = Math.max(0, video.currentTime - segmentStart);
 
185
  setCurrentTime(globalTime);
186
  } else {
187
- // For non-segmented videos, use direct time mapping
188
  setCurrentTime(video.currentTime);
189
  }
190
  }
@@ -220,10 +221,9 @@ export const VideosPlayer = ({
220
 
221
  video.addEventListener('timeupdate', handleTimeUpdate);
222
 
223
- // Store cleanup function
224
- (video as any)._segmentCleanup = () => {
225
  video.removeEventListener('timeupdate', handleTimeUpdate);
226
- };
227
  }
228
 
229
  videosReadyCount += 1;
@@ -243,22 +243,23 @@ export const VideosPlayer = ({
243
  } else {
244
  const readyHandler = () => onCanPlayThrough(index);
245
  video.addEventListener("canplaythrough", readyHandler);
246
- (video as any)._readyHandler = readyHandler;
247
  }
248
  }
249
  });
250
 
251
  return () => {
252
  videoRefs.current.forEach((video) => {
253
- if (video) {
254
- // Remove ready handler
255
- if ((video as any)._readyHandler) {
256
- video.removeEventListener("canplaythrough", (video as any)._readyHandler);
257
- }
258
- // Remove segment handler
259
- if ((video as any)._segmentCleanup) {
260
- (video as any)._segmentCleanup();
261
- }
 
262
  }
263
  });
264
  };
@@ -395,7 +396,7 @@ export const VideosPlayer = ({
395
  }}
396
  muted
397
  loop
398
- preload={idx === firstVisibleIdx ? "auto" : "metadata"}
399
  className={`w-full object-contain ${isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""}`}
400
  onTimeUpdate={
401
  idx === firstVisibleIdx ? handleTimeUpdate : undefined
 
3
  import { useEffect, useRef, useState } from "react";
4
  import { useTime } from "../context/time-context";
5
  import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
6
+ import type { VideoInfo } from "@/app/[org]/[dataset]/[episode]/fetch-data";
 
 
 
 
 
 
 
 
7
 
8
  type VideoPlayerProps = {
9
  videosInfo: VideoInfo[];
10
  onVideosReady?: () => void;
11
  };
12
 
13
+ const videoCleanupHandlers = new WeakMap<HTMLVideoElement, () => void>();
14
+ const videoReadyHandlers = new WeakMap<HTMLVideoElement, EventListener>();
15
+
16
  export const VideosPlayer = ({
17
  videosInfo,
18
  onVideosReady,
 
38
  const showHiddenBtnRef = useRef<HTMLButtonElement | null>(null);
39
  const [videoCodecError, setVideoCodecError] = useState(false);
40
 
41
+ // Tracks the last time value set by the primary video's onTimeUpdate.
42
+ // If currentTime differs from this, an external source (slider/chart click) changed it.
43
+ const lastVideoTimeRef = useRef(0);
44
+
45
  // Initialize video refs
46
  useEffect(() => {
47
  videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
 
145
  }
146
  }, [hiddenVideos, showHiddenMenu, enlargedVideo]);
147
 
148
+ // Sync all video times when currentTime changes.
149
+ // For the primary video, only seek when the change came from an external source
150
+ // (slider drag, chart click, etc.) — detected by comparing against lastVideoTimeRef.
151
  useEffect(() => {
152
+ const isExternalSeek = Math.abs(currentTime - lastVideoTimeRef.current) > 0.3;
153
+
154
  videoRefs.current.forEach((video, index) => {
155
+ if (!video) return;
156
+
157
+ // Skip the primary video unless the time was changed externally
158
+ if (index === firstVisibleIdx && !isExternalSeek) return;
159
+
160
+ const videoInfo = videosInfo[index];
161
+ if (videoInfo?.isSegmented) {
162
+ const segmentStart = videoInfo.segmentStart || 0;
163
+ const segmentTime = segmentStart + currentTime;
164
+ if (Math.abs(video.currentTime - segmentTime) > 0.2) {
165
+ video.currentTime = segmentTime;
166
+ }
167
+ } else {
168
+ if (Math.abs(video.currentTime - currentTime) > 0.2) {
 
169
  video.currentTime = currentTime;
170
  }
171
  }
172
  });
173
+ }, [currentTime, videosInfo, firstVisibleIdx]);
174
 
175
  // Handle time update
176
  const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
177
  const video = e.target as HTMLVideoElement;
178
  if (video && video.duration) {
 
179
  const videoIndex = videoRefs.current.findIndex(ref => ref === video);
180
  const videoInfo = videosInfo[videoIndex];
181
 
182
  if (videoInfo?.isSegmented) {
 
183
  const segmentStart = videoInfo.segmentStart || 0;
184
  const globalTime = Math.max(0, video.currentTime - segmentStart);
185
+ lastVideoTimeRef.current = globalTime;
186
  setCurrentTime(globalTime);
187
  } else {
188
+ lastVideoTimeRef.current = video.currentTime;
189
  setCurrentTime(video.currentTime);
190
  }
191
  }
 
221
 
222
  video.addEventListener('timeupdate', handleTimeUpdate);
223
 
224
+ videoCleanupHandlers.set(video, () => {
 
225
  video.removeEventListener('timeupdate', handleTimeUpdate);
226
+ });
227
  }
228
 
229
  videosReadyCount += 1;
 
243
  } else {
244
  const readyHandler = () => onCanPlayThrough(index);
245
  video.addEventListener("canplaythrough", readyHandler);
246
+ videoReadyHandlers.set(video, readyHandler);
247
  }
248
  }
249
  });
250
 
251
  return () => {
252
  videoRefs.current.forEach((video) => {
253
+ if (!video) return;
254
+ const readyHandler = videoReadyHandlers.get(video);
255
+ if (readyHandler) {
256
+ video.removeEventListener("canplaythrough", readyHandler);
257
+ videoReadyHandlers.delete(video);
258
+ }
259
+ const cleanup = videoCleanupHandlers.get(video);
260
+ if (cleanup) {
261
+ cleanup();
262
+ videoCleanupHandlers.delete(video);
263
  }
264
  });
265
  };
 
396
  }}
397
  muted
398
  loop
399
+ preload="auto"
400
  className={`w-full object-contain ${isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""}`}
401
  onTimeUpdate={
402
  idx === firstVisibleIdx ? handleTimeUpdate : undefined
src/utils/debounce.ts CHANGED
@@ -1,4 +1,5 @@
1
- export function debounce<F extends (...args: any[]) => any>(
 
2
  func: F,
3
  waitFor: number,
4
  ): (...args: Parameters<F>) => void {
 
1
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2
+ export function debounce<F extends (...args: any[]) => void>(
3
  func: F,
4
  waitFor: number,
5
  ): (...args: Parameters<F>) => void {
src/utils/parquetUtils.ts CHANGED
@@ -17,9 +17,9 @@ export interface DatasetMetadata {
17
  string,
18
  {
19
  dtype: string;
20
- shape: any[];
21
- names: any[] | Record<string, any> | null;
22
- info?: Record<string, any>;
23
  }
24
  >;
25
  }
@@ -36,7 +36,7 @@ export async function fetchJson<T>(url: string): Promise<T> {
36
 
37
  export function formatStringWithVars(
38
  format: string,
39
- vars: Record<string, any>,
40
  ): string {
41
  return format.replace(/{(\w+)(?::\d+d)?}/g, (_, key) => vars[key]);
42
  }
@@ -56,13 +56,13 @@ export async function fetchParquetFile(url: string): Promise<ArrayBuffer> {
56
  export async function readParquetColumn(
57
  fileBuffer: ArrayBuffer,
58
  columns: string[],
59
- ): Promise<any[]> {
60
  return new Promise((resolve, reject) => {
61
  try {
62
  parquetRead({
63
  file: fileBuffer,
64
- columns: columns.length > 0 ? columns : undefined, // Let hyparquet read all columns if empty array
65
- onComplete: (data: any[]) => {
66
  resolve(data);
67
  }
68
  });
@@ -72,15 +72,14 @@ export async function readParquetColumn(
72
  });
73
  }
74
 
75
- // Read parquet file and return objects with column names as keys
76
  export async function readParquetAsObjects(
77
  fileBuffer: ArrayBuffer,
78
  columns: string[] = [],
79
- ): Promise<Record<string, any>[]> {
80
  return parquetReadObjects({
81
  file: fileBuffer,
82
  columns: columns.length > 0 ? columns : undefined,
83
- });
84
  }
85
 
86
  // Convert a 2D array to a CSV string
@@ -88,8 +87,9 @@ export function arrayToCSV(data: (number | string)[][]): string {
88
  return data.map((row) => row.join(",")).join("\n");
89
  }
90
 
91
- // Get rows from the current frame data
92
- export function getRows(currentFrameData: any[], columns: any[]) {
 
93
  if (!currentFrameData || currentFrameData.length === 0) {
94
  return [];
95
  }
 
17
  string,
18
  {
19
  dtype: string;
20
+ shape: number[];
21
+ names: string[] | Record<string, unknown> | null;
22
+ info?: Record<string, unknown>;
23
  }
24
  >;
25
  }
 
36
 
37
  export function formatStringWithVars(
38
  format: string,
39
+ vars: Record<string, string>,
40
  ): string {
41
  return format.replace(/{(\w+)(?::\d+d)?}/g, (_, key) => vars[key]);
42
  }
 
56
  export async function readParquetColumn(
57
  fileBuffer: ArrayBuffer,
58
  columns: string[],
59
+ ): Promise<unknown[][]> {
60
  return new Promise((resolve, reject) => {
61
  try {
62
  parquetRead({
63
  file: fileBuffer,
64
+ columns: columns.length > 0 ? columns : undefined,
65
+ onComplete: (data: unknown[][]) => {
66
  resolve(data);
67
  }
68
  });
 
72
  });
73
  }
74
 
 
75
  export async function readParquetAsObjects(
76
  fileBuffer: ArrayBuffer,
77
  columns: string[] = [],
78
+ ): Promise<Record<string, unknown>[]> {
79
  return parquetReadObjects({
80
  file: fileBuffer,
81
  columns: columns.length > 0 ? columns : undefined,
82
+ }) as Promise<Record<string, unknown>[]>;
83
  }
84
 
85
  // Convert a 2D array to a CSV string
 
87
  return data.map((row) => row.join(",")).join("\n");
88
  }
89
 
90
+ type ColumnInfo = { key: string; value: string[] };
91
+
92
+ export function getRows(currentFrameData: unknown[], columns: ColumnInfo[]) {
93
  if (!currentFrameData || currentFrameData.length === 0) {
94
  return [];
95
  }
src/utils/versionUtils.ts CHANGED
@@ -7,7 +7,14 @@ const DATASET_URL = process.env.DATASET_URL || "https://huggingface.co/datasets"
7
  /**
8
  * Dataset information structure from info.json
9
  */
10
- interface DatasetInfo {
 
 
 
 
 
 
 
11
  codebase_version: string;
12
  robot_type: string | null;
13
  total_episodes: number;
@@ -20,7 +27,7 @@ interface DatasetInfo {
20
  splits: Record<string, string>;
21
  data_path: string;
22
  video_path: string;
23
- features: Record<string, any>;
24
  }
25
 
26
  // In-memory cache for dataset info (5 min TTL)
@@ -30,8 +37,10 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
30
  export async function getDatasetInfo(repoId: string): Promise<DatasetInfo> {
31
  const cached = datasetInfoCache.get(repoId);
32
  if (cached && Date.now() < cached.expiry) {
 
33
  return cached.data;
34
  }
 
35
 
36
  try {
37
  const testUrl = `${DATASET_URL}/${repoId}/resolve/main/meta/info.json`;
@@ -77,7 +86,6 @@ const SUPPORTED_VERSIONS = ["v3.0", "v2.1", "v2.0"];
77
  */
78
  export async function getDatasetVersionAndInfo(repoId: string): Promise<{ version: string; info: DatasetInfo }> {
79
  const info = await getDatasetInfo(repoId);
80
-
81
  const version = info.codebase_version;
82
  if (!version) {
83
  throw new Error("Dataset info.json does not contain codebase_version");
@@ -89,7 +97,6 @@ export async function getDatasetVersionAndInfo(repoId: string): Promise<{ versio
89
  "Please use a compatible dataset version."
90
  );
91
  }
92
-
93
  return { version, info };
94
  }
95
 
 
7
  /**
8
  * Dataset information structure from info.json
9
  */
10
+ type FeatureInfo = {
11
+ dtype: string;
12
+ shape: number[];
13
+ names: string[] | Record<string, unknown> | null;
14
+ info?: Record<string, unknown>;
15
+ };
16
+
17
+ export interface DatasetInfo {
18
  codebase_version: string;
19
  robot_type: string | null;
20
  total_episodes: number;
 
27
  splits: Record<string, string>;
28
  data_path: string;
29
  video_path: string;
30
+ features: Record<string, FeatureInfo>;
31
  }
32
 
33
  // In-memory cache for dataset info (5 min TTL)
 
37
  export async function getDatasetInfo(repoId: string): Promise<DatasetInfo> {
38
  const cached = datasetInfoCache.get(repoId);
39
  if (cached && Date.now() < cached.expiry) {
40
+ console.log(`[perf] getDatasetInfo cache HIT for ${repoId}`);
41
  return cached.data;
42
  }
43
+ console.log(`[perf] getDatasetInfo cache MISS for ${repoId} — fetching`);
44
 
45
  try {
46
  const testUrl = `${DATASET_URL}/${repoId}/resolve/main/meta/info.json`;
 
86
  */
87
  export async function getDatasetVersionAndInfo(repoId: string): Promise<{ version: string; info: DatasetInfo }> {
88
  const info = await getDatasetInfo(repoId);
 
89
  const version = info.codebase_version;
90
  if (!version) {
91
  throw new Error("Dataset info.json does not contain codebase_version");
 
97
  "Please use a compatible dataset version."
98
  );
99
  }
 
100
  return { version, info };
101
  }
102