Spaces:
Running
Running
fix playback and typing
Browse files- src/app/[org]/[dataset]/[episode]/episode-viewer.tsx +14 -7
- src/app/[org]/[dataset]/[episode]/fetch-data.ts +195 -194
- src/app/explore/page.tsx +2 -2
- src/app/page.tsx +15 -8
- src/components/data-recharts.tsx +12 -15
- src/components/playback-bar.tsx +4 -9
- src/components/side-nav.tsx +6 -4
- src/components/simple-videos-player.tsx +39 -32
- src/components/videos-player.tsx +44 -43
- src/utils/debounce.ts +2 -1
- src/utils/parquetUtils.ts +12 -12
- src/utils/versionUtils.ts +11 -4
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?:
|
| 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:
|
| 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 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 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:
|
| 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:
|
| 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
|
| 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:
|
| 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:
|
| 450 |
-
): Promise<{ chartDataGroups:
|
| 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 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
|
|
|
| 503 |
|
| 504 |
-
// If no instructions
|
| 505 |
if (languageInstructions.length === 0 && episodeData.length > 1) {
|
| 506 |
-
const
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 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 |
-
//
|
| 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 (
|
| 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 |
-
|
| 550 |
-
task =
|
| 551 |
}
|
| 552 |
}
|
| 553 |
-
} catch
|
| 554 |
-
// Could not load tasks metadata
|
| 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:
|
| 567 |
info: DatasetMetadata,
|
| 568 |
-
episodeMetadata?:
|
| 569 |
-
): { chartDataGroups:
|
| 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
|
| 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:
|
| 868 |
-
):
|
| 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 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 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<
|
| 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:
|
| 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:
|
| 975 |
if (typeof value === 'bigint') return Number(value);
|
| 976 |
if (typeof value === 'number') return value;
|
| 977 |
-
return parseInt(value) || 0;
|
|
|
|
| 978 |
};
|
| 979 |
|
| 980 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 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 |
-
|
|
|
|
| 1013 |
}
|
| 1014 |
});
|
| 1015 |
|
| 1016 |
return episodeData;
|
| 1017 |
} else {
|
| 1018 |
// Fallback to numeric keys for compatibility
|
| 1019 |
-
const
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
|
|
|
|
|
|
| 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?:
|
| 1062 |
try {
|
| 1063 |
const data = await getEpisodeData(org, dataset, episodeId);
|
| 1064 |
return { data };
|
| 1065 |
-
} catch (err:
|
| 1066 |
-
|
| 1067 |
-
return { 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:
|
| 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
|
| 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<
|
| 57 |
|
| 58 |
useEffect(() => {
|
| 59 |
// Load YouTube IFrame API if not already present
|
| 60 |
-
if (!
|
| 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 |
-
|
| 67 |
-
|
|
|
|
| 68 |
videoId: "Er8SPJsIYr0",
|
| 69 |
playerVars: {
|
| 70 |
autoplay: 1,
|
|
@@ -79,7 +87,7 @@ function HomeInner() {
|
|
| 79 |
start: 0,
|
| 80 |
},
|
| 81 |
events: {
|
| 82 |
-
onReady: (event:
|
| 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:
|
| 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
|
| 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:
|
| 17 |
onChartsReady?: () => void;
|
| 18 |
};
|
| 19 |
|
|
@@ -57,12 +59,12 @@ const SingleDataGraph = React.memo(
|
|
| 57 |
hoveredTime,
|
| 58 |
setHoveredTime,
|
| 59 |
}: {
|
| 60 |
-
data:
|
| 61 |
hoveredTime: number | null;
|
| 62 |
setHoveredTime: (t: number | null) => void;
|
| 63 |
}) => {
|
| 64 |
const { currentTime, setCurrentTime } = useTime();
|
| 65 |
-
function flattenRow(row: Record<string,
|
| 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 |
-
|
| 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:
|
| 136 |
-
if (data
|
| 137 |
-
|
| 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
|
| 260 |
-
|
| 261 |
-
|
| 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 |
-
|
|
|
|
| 38 |
};
|
| 39 |
|
| 40 |
const handleSliderMouseDown = () => {
|
|
@@ -45,11 +40,11 @@ const PlaybackBar: React.FC = () => {
|
|
| 45 |
|
| 46 |
const handleSliderMouseUp = () => {
|
| 47 |
sliderActiveRef.current = false;
|
| 48 |
-
|
|
|
|
| 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:
|
| 8 |
-
paginatedEpisodes:
|
| 9 |
-
episodeId:
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 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=
|
| 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
|
|
|
|
|
|
|
| 150 |
useEffect(() => {
|
|
|
|
|
|
|
| 151 |
videoRefs.current.forEach((video, index) => {
|
| 152 |
-
if (video
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 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 |
-
|
| 188 |
setCurrentTime(video.currentTime);
|
| 189 |
}
|
| 190 |
}
|
|
@@ -220,10 +221,9 @@ export const VideosPlayer = ({
|
|
| 220 |
|
| 221 |
video.addEventListener('timeupdate', handleTimeUpdate);
|
| 222 |
|
| 223 |
-
|
| 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
|
| 247 |
}
|
| 248 |
}
|
| 249 |
});
|
| 250 |
|
| 251 |
return () => {
|
| 252 |
videoRefs.current.forEach((video) => {
|
| 253 |
-
if (video)
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
|
|
|
| 262 |
}
|
| 263 |
});
|
| 264 |
};
|
|
@@ -395,7 +396,7 @@ export const VideosPlayer = ({
|
|
| 395 |
}}
|
| 396 |
muted
|
| 397 |
loop
|
| 398 |
-
preload=
|
| 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 |
-
|
|
|
|
| 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:
|
| 21 |
-
names:
|
| 22 |
-
info?: Record<string,
|
| 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,
|
| 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<
|
| 60 |
return new Promise((resolve, reject) => {
|
| 61 |
try {
|
| 62 |
parquetRead({
|
| 63 |
file: fileBuffer,
|
| 64 |
-
columns: columns.length > 0 ? columns : undefined,
|
| 65 |
-
onComplete: (data:
|
| 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,
|
| 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 |
-
|
| 92 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
|