| "use client"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import React, { |
| createContext, |
| useCallback, |
| useContext, |
| useEffect, |
| useMemo, |
| useRef, |
| useState, |
| } from "react"; |
| import type { LanguageAtom } from "../types/language.types"; |
| import { snapToFrame } from "../types/language.types"; |
| import { |
| fetchEpisodeAtoms, |
| saveEpisodeAtoms, |
| fetchFrameTimestamps, |
| isAnnotateBackendEnabled, |
| } from "../utils/annotationsClient"; |
|
|
| const STORAGE_PREFIX = "lerobot-annotations:v2:"; |
|
|
| function storageKey(repoOrPath: string, episodeId: number): string { |
| return `${STORAGE_PREFIX}${repoOrPath}::${episodeId}`; |
| } |
|
|
| export interface PendingBboxDraw { |
| kind: "bbox"; |
| bbox: [number, number, number, number]; |
| label: string; |
| camera?: string; |
| } |
|
|
| export interface PendingPointDraw { |
| kind: "keypoint"; |
| point: [number, number]; |
| label: string; |
| camera?: string; |
| } |
|
|
| export type PendingDraw = PendingBboxDraw | PendingPointDraw | null; |
| |
| |
| |
| |
| |
| export type DrawMode = "off" | "auto" | "bbox" | "keypoint"; |
|
|
| interface DatasetIdent { |
| repoId?: string | null; |
| localPath?: string | null; |
| revision?: string | null; |
| } |
|
|
| interface AnnotationsContextType { |
| episodeId: number | null; |
| ident: DatasetIdent; |
| atoms: LanguageAtom[]; |
| frameTimestamps: number[]; |
| |
| |
| |
| |
| |
| |
| selectedIdx: number | null; |
| selectAtom: (idx: number | null) => void; |
| |
| |
| |
| |
| |
| |
| |
| activeVideoEl: HTMLVideoElement | null; |
| setActiveVideoEl: (el: HTMLVideoElement | null) => void; |
| pendingDraw: PendingDraw; |
| |
| |
| activeCamera: string | null; |
| drawMode: DrawMode; |
| drawLabel: string; |
| backendEnabled: boolean; |
| dirty: boolean; |
| saving: boolean; |
|
|
| setEpisode: ( |
| episodeId: number, |
| ident: DatasetIdent, |
| initialAtoms?: LanguageAtom[], |
| initialFrameTimestamps?: number[], |
| ) => void; |
| setActiveCamera: (camera: string | null) => void; |
| setDrawMode: (mode: DrawMode) => void; |
| setDrawLabel: (label: string) => void; |
|
|
| addAtom: (atom: LanguageAtom) => void; |
| addAtoms: (atoms: LanguageAtom[]) => void; |
| updateAtom: (index: number, updates: Partial<LanguageAtom>) => void; |
| deleteAtom: (atom: LanguageAtom) => void; |
| resetAtoms: () => void; |
|
|
| setPendingDraw: (draw: PendingDraw) => void; |
| clearPendingDraw: () => void; |
|
|
| save: () => Promise<{ ok: boolean; error?: string; path?: string | null }>; |
| |
| snap: (ts: number) => number; |
| } |
|
|
| const AnnotationsContext = createContext<AnnotationsContextType | undefined>( |
| undefined, |
| ); |
|
|
| export function useAnnotations(): AnnotationsContextType { |
| const ctx = useContext(AnnotationsContext); |
| if (!ctx) { |
| throw new Error("useAnnotations must be used within AnnotationsProvider"); |
| } |
| return ctx; |
| } |
|
|
| function identKey(ident: DatasetIdent): string { |
| return ident.localPath || ident.repoId || "unknown"; |
| } |
|
|
| export const AnnotationsProvider: React.FC<{ children: React.ReactNode }> = ({ |
| children, |
| }) => { |
| const [episodeId, setEpisodeId] = useState<number | null>(null); |
| const [ident, setIdent] = useState<DatasetIdent>({}); |
| const [atoms, setAtoms] = useState<LanguageAtom[]>([]); |
| const [frameTimestamps, setFrameTimestamps] = useState<number[]>([]); |
| const [pendingDraw, setPendingDrawState] = useState<PendingDraw>(null); |
| const [activeCamera, setActiveCameraState] = useState<string | null>(null); |
| const [drawMode, setDrawModeState] = useState<DrawMode>("off"); |
| const [drawLabel, setDrawLabelState] = useState<string>(""); |
| const [activeVideoEl, setActiveVideoElState] = |
| useState<HTMLVideoElement | null>(null); |
| const [selectedIdx, setSelectedIdxState] = useState<number | null>(null); |
| const [dirty, setDirty] = useState(false); |
| const [saving, setSaving] = useState(false); |
| const backendEnabled = isAnnotateBackendEnabled(); |
|
|
| |
| const savedSnapshotRef = useRef<string>("[]"); |
|
|
| |
| |
| const setEpisode = useCallback( |
| ( |
| newEpisodeId: number, |
| newIdent: DatasetIdent, |
| initialAtoms?: LanguageAtom[], |
| initialFrameTimestamps?: number[], |
| ) => { |
| setEpisodeId(newEpisodeId); |
| setIdent(newIdent); |
| setPendingDrawState(null); |
| setSelectedIdxState(null); |
|
|
| |
| |
| let initial: LanguageAtom[] = []; |
| try { |
| const raw = sessionStorage.getItem( |
| storageKey(identKey(newIdent), newEpisodeId), |
| ); |
| if (raw) initial = JSON.parse(raw) as LanguageAtom[]; |
| } catch { |
| |
| } |
| if (initial.length === 0 && initialAtoms && initialAtoms.length > 0) { |
| initial = initialAtoms; |
| } |
| setAtoms(initial); |
| savedSnapshotRef.current = JSON.stringify(initial); |
| setDirty(false); |
| |
| |
| setFrameTimestamps(initialFrameTimestamps ?? []); |
|
|
| |
| if (isAnnotateBackendEnabled()) { |
| fetchEpisodeAtoms(newEpisodeId, newIdent) |
| .then((remoteAtoms) => { |
| |
| |
| if (remoteAtoms && remoteAtoms.length > 0) { |
| setAtoms(remoteAtoms); |
| savedSnapshotRef.current = JSON.stringify(remoteAtoms); |
| setDirty(false); |
| } |
| }) |
| .catch(() => { |
| |
| }); |
|
|
| fetchFrameTimestamps(newEpisodeId, newIdent) |
| .then(setFrameTimestamps) |
| .catch(() => setFrameTimestamps([])); |
| } |
| }, |
| [], |
| ); |
|
|
| |
| useEffect(() => { |
| if (episodeId == null) return; |
| try { |
| sessionStorage.setItem( |
| storageKey(identKey(ident), episodeId), |
| JSON.stringify(atoms), |
| ); |
| } catch { |
| |
| } |
| setDirty(JSON.stringify(atoms) !== savedSnapshotRef.current); |
| }, [atoms, episodeId, ident]); |
|
|
| const snap = useCallback( |
| (ts: number) => |
| frameTimestamps.length > 0 ? snapToFrame(frameTimestamps, ts) : ts, |
| [frameTimestamps], |
| ); |
|
|
| const addAtom = useCallback((atom: LanguageAtom) => { |
| setAtoms((prev) => [...prev, atom]); |
| }, []); |
|
|
| const addAtoms = useCallback((newAtoms: LanguageAtom[]) => { |
| setAtoms((prev) => [...prev, ...newAtoms]); |
| }, []); |
|
|
| const updateAtom = useCallback( |
| (index: number, updates: Partial<LanguageAtom>) => { |
| setAtoms((prev) => { |
| if (index < 0 || index >= prev.length) return prev; |
| const next = prev.slice(); |
| next[index] = { ...next[index], ...updates }; |
| return next; |
| }); |
| }, |
| [], |
| ); |
|
|
| const deleteAtom = useCallback((atom: LanguageAtom) => { |
| setAtoms((prev) => { |
| const next = prev.filter((a) => a !== atom); |
| |
| |
| |
| setSelectedIdxState((cur) => { |
| if (cur == null) return null; |
| const oldIdx = prev.indexOf(atom); |
| if (oldIdx < 0) return cur; |
| if (cur === oldIdx) return null; |
| if (cur > oldIdx) return cur - 1; |
| return cur; |
| }); |
| return next; |
| }); |
| }, []); |
|
|
| const resetAtoms = useCallback(() => { |
| setAtoms([]); |
| setSelectedIdxState(null); |
| }, []); |
|
|
| const setPendingDraw = useCallback((draw: PendingDraw) => { |
| setPendingDrawState(draw); |
| }, []); |
|
|
| const clearPendingDraw = useCallback(() => setPendingDrawState(null), []); |
|
|
| const setActiveCamera = useCallback((c: string | null) => { |
| setActiveCameraState(c); |
| }, []); |
|
|
| const setDrawMode = useCallback((m: DrawMode) => setDrawModeState(m), []); |
| const setDrawLabel = useCallback((l: string) => setDrawLabelState(l), []); |
| const setActiveVideoEl = useCallback( |
| (el: HTMLVideoElement | null) => setActiveVideoElState(el), |
| [], |
| ); |
|
|
| const selectAtom = useCallback( |
| (idx: number | null) => setSelectedIdxState(idx), |
| [], |
| ); |
|
|
| const save = useCallback(async (): Promise<{ |
| ok: boolean; |
| error?: string; |
| path?: string | null; |
| }> => { |
| if (episodeId == null) return { ok: false, error: "no episode" }; |
| if (!isAnnotateBackendEnabled()) { |
| |
| |
| |
| savedSnapshotRef.current = JSON.stringify(atoms); |
| setDirty(false); |
| return { |
| ok: true, |
| path: `sessionStorage://${storageKey(identKey(ident), episodeId)}`, |
| }; |
| } |
| setSaving(true); |
| try { |
| const { path } = await saveEpisodeAtoms(episodeId, ident, atoms); |
| savedSnapshotRef.current = JSON.stringify(atoms); |
| setDirty(false); |
| return { ok: true, path }; |
| } catch (e) { |
| return { ok: false, error: e instanceof Error ? e.message : String(e) }; |
| } finally { |
| setSaving(false); |
| } |
| }, [atoms, episodeId, ident]); |
|
|
| const value = useMemo<AnnotationsContextType>( |
| () => ({ |
| episodeId, |
| ident, |
| atoms, |
| frameTimestamps, |
| pendingDraw, |
| activeCamera, |
| activeVideoEl, |
| setActiveVideoEl, |
| drawMode, |
| drawLabel, |
| selectedIdx, |
| selectAtom, |
| backendEnabled, |
| dirty, |
| saving, |
| setEpisode, |
| setActiveCamera, |
| setDrawMode, |
| setDrawLabel, |
| addAtom, |
| addAtoms, |
| updateAtom, |
| deleteAtom, |
| resetAtoms, |
| setPendingDraw, |
| clearPendingDraw, |
| save, |
| snap, |
| }), |
| [ |
| episodeId, |
| ident, |
| atoms, |
| frameTimestamps, |
| pendingDraw, |
| activeCamera, |
| activeVideoEl, |
| setActiveVideoEl, |
| drawMode, |
| drawLabel, |
| selectedIdx, |
| selectAtom, |
| backendEnabled, |
| dirty, |
| saving, |
| setEpisode, |
| setActiveCamera, |
| setDrawMode, |
| setDrawLabel, |
| addAtom, |
| addAtoms, |
| updateAtom, |
| deleteAtom, |
| resetAtoms, |
| setPendingDraw, |
| clearPendingDraw, |
| save, |
| snap, |
| ], |
| ); |
|
|
| return ( |
| <AnnotationsContext.Provider value={value}> |
| {children} |
| </AnnotationsContext.Provider> |
| ); |
| }; |
|
|