Skip to content

Commit c95b198

Browse files
author
Gavin McDonald
committed
Merge branch 'develop' into feat/replace-schemaio-revert-position
2 parents 01be8ca + 68d1229 commit c95b198

File tree

108 files changed

+5619
-825
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

108 files changed

+5619
-825
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@fiftyone/annotation",
3+
"version": "1.0.0",
4+
"scripts": {
5+
"dev": "vite",
6+
"build": "vite build",
7+
"preview": "vite preview",
8+
"watch": "nodemon --watch ./src --ext js,jsx,ts,tsx --exec 'yarn build'",
9+
"test": "vitest"
10+
},
11+
"files": [
12+
"dist"
13+
],
14+
"main": "src/index.ts",
15+
"devDependencies": {
16+
"@testing-library/dom": "^10.4.0",
17+
"@testing-library/react": "^16.2.0",
18+
"@typescript-eslint/eslint-plugin": "^6.0.0",
19+
"@typescript-eslint/parser": "^6.0.0",
20+
"eslint": "^8.0.0",
21+
"jsdom": "^26.0.0",
22+
"typescript": "^4.7.4",
23+
"vite": "^5.4.12",
24+
"vite-plugin-externals": "^0.5.0",
25+
"vitest": "^1.6.0"
26+
},
27+
"fiftyone": {
28+
"script": "dist/index.umd.js"
29+
},
30+
"packageManager": "[email protected]",
31+
"peerDependencies": {
32+
"@fiftyone/commands": "*",
33+
"@fiftyone/events": "*",
34+
"jotai": "*",
35+
"react": "*"
36+
}
37+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Copyright 2017-2025, Voxel51, Inc.
3+
*/
4+
5+
import { Command } from "@fiftyone/commands";
6+
import { AnnotationLabel } from "@fiftyone/state";
7+
import { Field } from "@fiftyone/utilities";
8+
9+
/**
10+
* Command to upsert (create or update) an annotation label.
11+
*/
12+
export class UpsertAnnotationCommand extends Command<boolean> {
13+
constructor(
14+
public readonly label: AnnotationLabel,
15+
public readonly schema: Field
16+
) {
17+
super();
18+
}
19+
}
20+
21+
/**
22+
* Command to delete an annotation label.
23+
*/
24+
export class DeleteAnnotationCommand extends Command<boolean> {
25+
constructor(
26+
public readonly label: AnnotationLabel,
27+
public readonly schema: Field
28+
) {
29+
super();
30+
}
31+
}

app/packages/core/src/components/Modal/Lighter/deltas.ts renamed to app/packages/annotation/src/deltas.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import {
88
PolylineAnnotationLabel,
99
Sample,
1010
} from "@fiftyone/state";
11-
import { JSONDeltas } from "../../../client";
12-
import { extractNestedField, generateJsonPatch } from "../../../utils/json";
11+
import { JSONDeltas } from "@fiftyone/core/src/client";
12+
import {
13+
extractNestedField,
14+
generateJsonPatch,
15+
} from "@fiftyone/core/src/utils/json";
1316
import { Field, Schema } from "@fiftyone/utilities";
1417

1518
/**
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { createUseEventHandler, useEventBus } from "@fiftyone/events";
2+
import { AnnotationLabel } from "@fiftyone/state";
3+
import { Field } from "@fiftyone/utilities";
4+
5+
export const AnnotationChannelId = "default";
6+
7+
type MutationError<T> = {
8+
labelId: string;
9+
type: T;
10+
error?: Error;
11+
};
12+
13+
type MutationSuccess<T> = {
14+
labelId: string;
15+
type: T;
16+
};
17+
18+
export type AnnotationEventGroup = {
19+
/**
20+
* Notification event emitted when a label is upserted successfully.
21+
*/
22+
"annotation:notification:upsertSuccess": MutationSuccess<"upsert">;
23+
/**
24+
* Notification event emitted when an error occurs while upserting a label.
25+
*/
26+
"annotation:notification:upsertError": MutationError<"upsert">;
27+
/**
28+
* Notification event emitted when a label is deleted successfully.
29+
*/
30+
"annotation:notification:deleteSuccess": MutationSuccess<"delete">;
31+
/**
32+
* Notification event emitted when an error occurs while deleting a label.
33+
*/
34+
"annotation:notification:deleteError": MutationError<"delete">;
35+
/**
36+
* Notification event emitted when a sidebar value is updated.
37+
*/
38+
"annotation:notification:sidebarValueUpdated": {
39+
overlayId: string;
40+
currentLabel: AnnotationLabel["data"];
41+
value: Partial<AnnotationLabel["data"]>;
42+
};
43+
/**
44+
* Notification event emitted when a label is selected.
45+
*/
46+
"annotation:notification:sidebarLabelSelected": {
47+
id: string;
48+
type: AnnotationLabel["type"];
49+
data?: Partial<AnnotationLabel["data"]>;
50+
};
51+
/**
52+
* Notification event emitted when a label is hovered.
53+
*/
54+
"annotation:notification:sidebarLabelHover": {
55+
id: string;
56+
tooltip?: boolean;
57+
};
58+
/**
59+
* Notification event emitted when a label is unhovered.
60+
*/
61+
"annotation:notification:sidebarLabelUnhover": {
62+
id: string;
63+
};
64+
/**
65+
* Notification event emitted when a canvas overlay is hovered.
66+
* TODO: FOR NOW THIS IS ONLY FOR 3D LABELS.
67+
* USE THIS FOR 2D ONCE WE GET RID OF LIGHTER HOVER EVENTS.
68+
*/
69+
"annotation:notification:canvasOverlayHover": {
70+
id: string;
71+
};
72+
/**
73+
* Notification event emitted when a canvas overlay is unhovered.
74+
* TODO: FOR NOW THIS IS ONLY FOR 3D LABELS.
75+
* USE THIS FOR 2D ONCE WE GET RID OF LIGHTER HOVER EVENTS.
76+
*/
77+
"annotation:notification:canvasOverlayUnhover": {
78+
id: string;
79+
};
80+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from "./useAnnotationEventBus";
2+
export * from "./useAnnotationEventHandler";
3+
export * from "./useRegisterAnnotationCommandHandlers";
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { useEventBus } from "@fiftyone/events";
2+
import { AnnotationEventGroup } from "../events";
3+
4+
export const useAnnotationEventBus = () => useEventBus<AnnotationEventGroup>();
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { createUseEventHandler } from "@fiftyone/events";
2+
import { AnnotationEventGroup } from "../events";
3+
4+
export const useAnnotationEventHandler =
5+
createUseEventHandler<AnnotationEventGroup>();
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/**
2+
* Copyright 2017-2025, Voxel51, Inc.
3+
*/
4+
5+
import { useAnnotationEventBus } from "@fiftyone/annotation";
6+
import { useRegisterCommandHandler } from "@fiftyone/commands";
7+
import { JSONDeltas, patchSample } from "@fiftyone/core/src/client";
8+
import { transformSampleData } from "@fiftyone/core/src/client/transformer";
9+
import { parseTimestamp } from "@fiftyone/core/src/client/util";
10+
import { Sample } from "@fiftyone/looker";
11+
import { isSampleIsh } from "@fiftyone/looker/src/util";
12+
import {
13+
AnnotationLabel,
14+
datasetId as fosDatasetId,
15+
modalSample,
16+
useRefreshSample,
17+
} from "@fiftyone/state";
18+
import { Field } from "@fiftyone/utilities";
19+
import { useCallback } from "react";
20+
import { useRecoilCallback, useRecoilValue } from "recoil";
21+
import { DeleteAnnotationCommand, UpsertAnnotationCommand } from "../commands";
22+
import { OpType, buildJsonPath, buildLabelDeltas } from "../deltas";
23+
24+
/**
25+
* Hook that registers command handlers for annotation persistence.
26+
* This should be called once in the composition root.
27+
*/
28+
export const useRegisterAnnotationCommandHandlers = () => {
29+
const datasetId = useRecoilValue(fosDatasetId);
30+
const refreshSample = useRefreshSample();
31+
const eventBus = useAnnotationEventBus();
32+
33+
// The annotation endpoint requires a version token in order to execute
34+
// mutations.
35+
// Updated version tokens are returned in the response body,
36+
// but the server also allows the current sample timestamp to be used as
37+
// a version token.
38+
const getVersionToken = useRecoilCallback(
39+
({ snapshot }) =>
40+
async (): Promise<string | undefined> => {
41+
const currentSample = (await snapshot.getPromise(modalSample))?.sample;
42+
if (!currentSample?.last_modified_at) {
43+
return undefined;
44+
}
45+
46+
const isoTimestamp = parseTimestamp(
47+
currentSample.last_modified_at as unknown as string
48+
)?.toISOString();
49+
50+
// server doesn't like the iso timestamp ending in 'Z'
51+
if (isoTimestamp?.endsWith("Z")) {
52+
return isoTimestamp.substring(0, isoTimestamp.length - 1);
53+
} else {
54+
return isoTimestamp;
55+
}
56+
},
57+
[]
58+
);
59+
60+
const handlePatchSample = useRecoilCallback(
61+
({ snapshot }) =>
62+
async (sampleDeltas: JSONDeltas): Promise<boolean> => {
63+
const currentSample = (await snapshot.getPromise(modalSample))?.sample;
64+
const versionToken = await getVersionToken();
65+
66+
if (!datasetId || !currentSample?._id || !versionToken) {
67+
return false;
68+
}
69+
70+
if (sampleDeltas.length > 0) {
71+
try {
72+
const response = await patchSample({
73+
datasetId,
74+
sampleId: currentSample._id,
75+
deltas: sampleDeltas,
76+
versionToken,
77+
});
78+
79+
// transform response data to match the graphql sample format
80+
const cleanedSample = transformSampleData(response.sample);
81+
if (isSampleIsh(cleanedSample)) {
82+
refreshSample(cleanedSample as Sample);
83+
} else {
84+
console.error(
85+
"response data does not adhere to sample format",
86+
cleanedSample
87+
);
88+
}
89+
} catch (error) {
90+
console.error("error patching sample", error);
91+
92+
return false;
93+
}
94+
}
95+
96+
return true;
97+
},
98+
[datasetId, refreshSample, getVersionToken]
99+
);
100+
101+
// callback which handles both mutation (upsert) and deletion
102+
const handlePersistence = useRecoilCallback(
103+
({ snapshot }) =>
104+
async (
105+
annotationLabel: AnnotationLabel,
106+
schema: Field,
107+
opType: OpType
108+
): Promise<boolean> => {
109+
const currentSample = (await snapshot.getPromise(modalSample))?.sample;
110+
111+
if (!currentSample) {
112+
console.error("missing sample data!");
113+
return false;
114+
}
115+
116+
if (!annotationLabel) {
117+
console.error("missing annotation label!");
118+
return false;
119+
}
120+
121+
// calculate label deltas between current sample data and new label data
122+
const sampleDeltas = buildLabelDeltas(
123+
currentSample,
124+
annotationLabel,
125+
schema,
126+
opType
127+
).map((delta) => ({
128+
...delta,
129+
// convert label delta to sample delta
130+
path: buildJsonPath(annotationLabel.path, delta.path),
131+
}));
132+
133+
return await handlePatchSample(sampleDeltas);
134+
},
135+
[handlePatchSample]
136+
);
137+
138+
useRegisterCommandHandler(
139+
UpsertAnnotationCommand,
140+
useCallback(
141+
async (cmd) => {
142+
const labelId = cmd.label.data._id;
143+
try {
144+
const success = await handlePersistence(
145+
cmd.label,
146+
cmd.schema,
147+
"mutate"
148+
);
149+
150+
if (success) {
151+
eventBus.dispatch("annotation:notification:upsertSuccess", {
152+
labelId,
153+
type: "upsert",
154+
});
155+
} else {
156+
eventBus.dispatch("annotation:notification:upsertError", {
157+
labelId,
158+
type: "upsert",
159+
});
160+
}
161+
return success;
162+
} catch (error) {
163+
eventBus.dispatch("annotation:notification:upsertError", {
164+
labelId,
165+
type: "upsert",
166+
error: error as Error,
167+
});
168+
throw error;
169+
}
170+
},
171+
[handlePersistence, eventBus]
172+
)
173+
);
174+
175+
useRegisterCommandHandler(
176+
DeleteAnnotationCommand,
177+
useCallback(
178+
async (cmd) => {
179+
const labelId = cmd.label.data._id;
180+
try {
181+
const success = await handlePersistence(
182+
cmd.label,
183+
cmd.schema,
184+
"delete"
185+
);
186+
187+
if (success) {
188+
eventBus.dispatch("annotation:notification:deleteSuccess", {
189+
labelId,
190+
type: "delete",
191+
});
192+
} else {
193+
eventBus.dispatch("annotation:notification:deleteError", {
194+
labelId,
195+
type: "delete",
196+
});
197+
}
198+
return success;
199+
} catch (error) {
200+
eventBus.dispatch("annotation:notification:deleteError", {
201+
labelId,
202+
type: "delete",
203+
error: error as Error,
204+
});
205+
throw error;
206+
}
207+
},
208+
[handlePersistence, eventBus]
209+
)
210+
);
211+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from "./commands";
2+
export * from "./deltas";
3+
export * from "./events";
4+
export * from "./hooks";

0 commit comments

Comments
 (0)