From e8d1ea4b29dcd36e3479642c9b3795466f7e30c1 Mon Sep 17 00:00:00 2001 From: Xu Han Date: Thu, 25 Jan 2024 11:36:17 +0800 Subject: [PATCH] cleaned code --- app/frontend/src/common/annotation.ts | 28 +++++ .../src/component/panel/AnnotationMonitor.tsx | 95 +-------------- .../component/panel/AnnotationMonitorV2.tsx | 98 ++++++++++++++++ .../component/panel/layer/polychain/Draw.tsx | 1 + .../component/panel/layer/rectangle/Draw.tsx | 1 + .../component/panel/layer/rectangle/Edit.tsx | 9 +- .../src/component/panel/menu/common.ts | 4 +- .../src/page/annotate/Panel/Loaded.tsx | 4 +- app/frontend/src/state/annotate/annotation.ts | 45 ++++--- app/frontend/src/state/server/annotation.ts | 48 ++++++-- app/frontend/src/sync/doc.ts | 41 +++++++ app/frontend/src/sync/update.ts | 110 ++++++++++++++++++ 12 files changed, 355 insertions(+), 129 deletions(-) create mode 100644 app/frontend/src/common/annotation.ts create mode 100644 app/frontend/src/component/panel/AnnotationMonitorV2.tsx create mode 100644 app/frontend/src/sync/doc.ts create mode 100644 app/frontend/src/sync/update.ts diff --git a/app/frontend/src/common/annotation.ts b/app/frontend/src/common/annotation.ts new file mode 100644 index 0000000..f90bc01 --- /dev/null +++ b/app/frontend/src/common/annotation.ts @@ -0,0 +1,28 @@ +import {Annotation, Component, EntityId, SliceIndex} from 'type/annotation'; +import {deepClone} from './util'; + +export function addAnnotationComponent( + a: Annotation, + sliceIndex: SliceIndex, + entityId: EntityId, + component: Component +) { + if (entityId in a.entities) { + const slices = a.entities[entityId].geometry.slices; + if (!(sliceIndex in slices)) { + slices[sliceIndex] = {}; + } + slices[sliceIndex][component.id] = deepClone(component); + } else { + a.entities[entityId] = { + id: entityId, + geometry: { + slices: { + [sliceIndex]: { + [component.id]: deepClone(component), + }, + }, + }, + }; + } +} diff --git a/app/frontend/src/component/panel/AnnotationMonitor.tsx b/app/frontend/src/component/panel/AnnotationMonitor.tsx index 2c5b132..39984b5 100644 --- a/app/frontend/src/component/panel/AnnotationMonitor.tsx +++ b/app/frontend/src/component/panel/AnnotationMonitor.tsx @@ -1,12 +1,10 @@ import {FC, useContext, useEffect} from 'react'; -import * as Y from 'yjs'; import * as jsonmergepatch from 'json-merge-patch'; -import * as jsonpatch from 'fast-json-patch'; import {produce} from 'immer'; import {deleteAnnotationComponent, useStore as useAnnoStore} from 'state/annotate/annotation'; import {useStore as useRenderStore} from 'state/annotate/render'; -import {useGetVideoAnnotationV2, usePatchVideoAnnotation} from 'state/server/annotation'; +import {usePatchVideoAnnotation} from 'state/server/annotation'; import {useStore as useUIStore} from 'state/annotate/ui'; import {ConfigContext, NutshClientContext} from 'common/context'; @@ -17,10 +15,9 @@ import {Annotation} from 'type/annotation'; export const MonitorAnnotation: FC<{videoId: Video['id']}> = ({videoId}) => { const config = useContext(ConfigContext); - useSyncAnnotation({videoId}); return ( <> - {/* {!config.readonly && } */} + {!config.readonly && } @@ -135,91 +132,3 @@ function removeDraftComponents(anno: Annotation): Annotation { return draft; }); } - -function useSyncAnnotation({videoId}: {videoId: Video['id']}) { - const {data} = useGetVideoAnnotationV2(videoId); - - useEffect(() => { - const anno = data?.anno; - if (!anno) { - return; - } - const fn = (e: Y.YEvent>[]) => { - console.log(e); - }; - anno.observeDeep(fn); - return () => anno.unobserveDeep(fn); - }, [data?.anno]); - - useEffect(() => { - return useAnnoStore.subscribe( - s => s.annotation, - (curr, prev) => { - if (!data?.anno) { - return; - } - - const newPrev = produce(prev, removeDraftComponents); - const newCurr = produce(curr, removeDraftComponents); - - const ops = jsonpatch.compare(newPrev, newCurr); - ops.forEach(op => updateYjsFromJsonPathOperation(data.anno, op)); - }, - { - fireImmediately: true, - } - ); - }, [data?.anno]); -} - -function updateYjsFromJsonPathOperation(anno: Y.Map, op: jsonpatch.Operation) { - console.log(JSON.stringify(op)); - switch (op.op) { - case 'add': { - if (!op.path.startsWith('/')) { - console.warn(`unexpected json pointer: ${op.path}`); - return; - } - const paths = op.path.split('/'); - let dict = anno; - paths.slice(1, -1).forEach(path => { - if (!dict.has(path)) { - dict.set(path, new Y.Map()); - } - dict = dict.get(path) as Y.Map; - }); - const lastKey = paths[paths.length - 1]; - dict.set(lastKey, op.value); - break; - } - case 'copy': - console.warn(`not implemented: ${JSON.stringify(op)}`); - break; - case 'move': - console.warn(`not implemented: ${JSON.stringify(op)}`); - break; - case 'remove': { - if (!op.path.startsWith('/')) { - console.warn(`unexpected json pointer: ${op.path}`); - return; - } - const paths = op.path.split('/'); - let dict = anno; - paths.slice(1, -1).forEach(path => { - if (!dict.has(path)) { - dict.set(path, new Y.Map()); - } - dict = dict.get(path) as Y.Map; - }); - const lastKey = paths[paths.length - 1]; - dict.delete(lastKey); - break; - } - case 'replace': - console.warn(`not implemented: ${JSON.stringify(op)}`); - break; - case 'test': - console.warn(`not implemented: ${JSON.stringify(op)}`); - break; - } -} diff --git a/app/frontend/src/component/panel/AnnotationMonitorV2.tsx b/app/frontend/src/component/panel/AnnotationMonitorV2.tsx new file mode 100644 index 0000000..20897f8 --- /dev/null +++ b/app/frontend/src/component/panel/AnnotationMonitorV2.tsx @@ -0,0 +1,98 @@ +import {FC, useEffect} from 'react'; +import * as Y from 'yjs'; +import {ComponentInfo, RectangleAnchors, annoDoc} from 'sync/doc'; +import {useStore as useAnnoStore} from 'state/annotate/annotation'; +import {Component} from 'type/annotation'; + +export const MonitorAnnotationV2: FC = () => { + useSyncAnnotation(); + return null; +}; + +function useSyncAnnotation() { + useComponentInfoMonitor(); + useRectangleAnchorsMonitor(); +} + +function useComponentInfoMonitor() { + const comps = annoDoc.componentInfoMap(); + const rects = annoDoc.rectangleAnchorsMap(); + + const addComponent = useAnnoStore(s => s.addComponent); + const deleteComponents = useAnnoStore(s => s.deleteComponents); + + useEffect(() => { + const fn = (e: Y.YMapEvent) => { + for (const [cid, cc] of e.changes.keys) { + switch (cc.action) { + case 'add': { + const info = comps.get(cid); + if (!info) { + break; + } + + // add component + const {sliceIndex, entityId, type} = info; + switch (type) { + case 'rectangle': { + const rect = rects.get(cid); + if (!rect) { + break; + } + + const {topLeft, bottomRight} = rect; + const component: Component = { + type: 'rectangle', + id: cid, + topLeft, + bottomRight, + }; + addComponent({sliceIndex, entityId, component}); + break; + } + default: + // TODO(xu) + console.warn('not implemented'); + } + break; + } + case 'delete': { + // delete components + const {sliceIndex, entityId} = cc.oldValue as ComponentInfo; + deleteComponents({sliceIndex, components: [[entityId, cid]]}); + break; + } + } + } + }; + comps.observe(fn); + return () => comps.unobserve(fn); + }, [addComponent, comps, deleteComponents, rects]); +} + +function useRectangleAnchorsMonitor() { + const comps = annoDoc.componentInfoMap(); + const rects = annoDoc.rectangleAnchorsMap(); + + const updateAnchors = useAnnoStore(s => s.updateRectangleAnchors); + + useEffect(() => { + const fn = (e: Y.YMapEvent) => { + for (const cid of e.keysChanged) { + const info = comps.get(cid); + if (info) { + const {sliceIndex, entityId} = info; + const rect = rects.get(cid); + if (rect) { + const {topLeft, bottomRight} = rect; + updateAnchors({sliceIndex, entityId, componentId: cid, topLeft, bottomRight}); + } + } else { + console.warn(`rectangle ${cid} not found`); + } + } + }; + rects.observe(fn); + return () => rects.unobserve(fn); + }, [comps, rects, updateAnchors]); +} diff --git a/app/frontend/src/component/panel/layer/polychain/Draw.tsx b/app/frontend/src/component/panel/layer/polychain/Draw.tsx index e1f4ac1..47f5b89 100644 --- a/app/frontend/src/component/panel/layer/polychain/Draw.tsx +++ b/app/frontend/src/component/panel/layer/polychain/Draw.tsx @@ -134,6 +134,7 @@ const LayerWithEntityId: FC = ({entityId, width, h vertices: drawVertices, closed, }, + broadcast: true, }); finish(); diff --git a/app/frontend/src/component/panel/layer/rectangle/Draw.tsx b/app/frontend/src/component/panel/layer/rectangle/Draw.tsx index 12d4c49..501c3af 100644 --- a/app/frontend/src/component/panel/layer/rectangle/Draw.tsx +++ b/app/frontend/src/component/panel/layer/rectangle/Draw.tsx @@ -92,6 +92,7 @@ const LayerWithEntityId: FC = ({entityId, width, h topLeft: limitCoordinates(min, imw, imh), bottomRight: limitCoordinates(max, imw, imh), }, + broadcast: true, }); finish(); }} diff --git a/app/frontend/src/component/panel/layer/rectangle/Edit.tsx b/app/frontend/src/component/panel/layer/rectangle/Edit.tsx index 4182e77..aa10f38 100644 --- a/app/frontend/src/component/panel/layer/rectangle/Edit.tsx +++ b/app/frontend/src/component/panel/layer/rectangle/Edit.tsx @@ -45,7 +45,14 @@ const Canvas: FC<{data: Data} & CanvasHTMLAttributes> = ({dat const x2 = Math.max(p.x, q.x); const y2 = Math.max(p.y, q.y); - updateAnchors({sliceIndex, entityId, componentId, topLeft: {x: x1, y: y1}, bottomRight: {x: x2, y: y2}}); + updateAnchors({ + sliceIndex, + entityId, + componentId, + topLeft: {x: x1, y: y1}, + bottomRight: {x: x2, y: y2}, + broadcast: true, + }); finishEdit(); }, [anchors, componentId, entityId, finishEdit, sliceIndex, updateAnchors]); diff --git a/app/frontend/src/component/panel/menu/common.ts b/app/frontend/src/component/panel/menu/common.ts index 74c8ceb..9c68242 100644 --- a/app/frontend/src/component/panel/menu/common.ts +++ b/app/frontend/src/component/panel/menu/common.ts @@ -56,7 +56,7 @@ export function useComponentActions(entityId: EntityId, componentId: ComponentId }, { title: intl.get('menu.delete_hovering_component'), - fn: () => deleteComponents({sliceIndex, components: [[entityId, componentId]]}), + fn: () => deleteComponents({sliceIndex, components: [[entityId, componentId]], broadcast: true}), warning: intl.get('menu.warn.delete_hovering_component'), }, ]; @@ -128,7 +128,7 @@ export function useEntityActions(): Action[] { { title: intl.get('menu.delete_selected_components_in_current_frame'), fn: () => { - deleteComponents({sliceIndex, components}); + deleteComponents({sliceIndex, components, broadcast: true}); }, warning: intl.get('menu.warn.delete_selected_components_in_current_frame', {count: ec}), }, diff --git a/app/frontend/src/page/annotate/Panel/Loaded.tsx b/app/frontend/src/page/annotate/Panel/Loaded.tsx index 414273b..68b66aa 100644 --- a/app/frontend/src/page/annotate/Panel/Loaded.tsx +++ b/app/frontend/src/page/annotate/Panel/Loaded.tsx @@ -5,7 +5,7 @@ import shallow from 'zustand/shallow'; import {useStore as useRenderStore} from 'state/annotate/render'; import {prefetchImages} from 'state/image/store'; import {UI} from 'common/constant'; -import {MonitorAnnotation} from 'component/panel/AnnotationMonitor'; +import {MonitorAnnotationV2} from 'component/panel/AnnotationMonitorV2'; import type {Project, Video} from 'openapi/nutsh'; import type {ProjectSpec} from 'type/project_spec'; import {FrameSlider} from 'component/panel/FrameSlider'; @@ -72,7 +72,7 @@ export const PanelLoaded: FC<{ /> - + ); }; diff --git a/app/frontend/src/state/annotate/annotation.ts b/app/frontend/src/state/annotate/annotation.ts index 4ee82eb..a5a79ea 100644 --- a/app/frontend/src/state/annotate/annotation.ts +++ b/app/frontend/src/state/annotate/annotation.ts @@ -15,6 +15,8 @@ import type { ComponentMap, } from 'type/annotation'; import {newComponentAdapter} from 'common/adapter'; +import {addAnnotationComponent} from 'common/annotation'; +import {syncAnnotation} from 'sync/update'; export type State = { annotation: Annotation; @@ -65,6 +67,7 @@ export type AddComponentInput = { sliceIndex: SliceIndex; entityId: EntityId; component: Component; + broadcast?: boolean; }; export type AddComponentsInput = { @@ -85,6 +88,7 @@ export type TransferComponentInput = { export type DeleteComponentsInput = { sliceIndex: SliceIndex; components: [EntityId, ComponentId][]; + broadcast?: boolean; }; export type SeperateComponentInput = { @@ -123,6 +127,7 @@ export type UpdateRectangleAnchorsInput = { componentId: ComponentId; topLeft: Coordinates; bottomRight: Coordinates; + broadcast?: boolean; }; export type PasteInput = { @@ -248,8 +253,12 @@ export const useStore = create()( addComponent: (input: AddComponentInput) => { set(s => { - const {sliceIndex, entityId, component} = input; + const {sliceIndex, entityId, component, broadcast} = input; addComponent(s, sliceIndex, entityId, component); + + if (broadcast) { + syncAnnotation.addComponent(input); + } }); }, @@ -313,11 +322,12 @@ export const useStore = create()( deleteComponents: (input: DeleteComponentsInput) => { set(s => { - const {sliceIndex, components} = input; + const {sliceIndex, components, broadcast} = input; + components.forEach(([eid, cid]) => deleteComponent(s, sliceIndex, eid, cid)); - components.forEach(([eid, cid]) => { - deleteComponent(s, sliceIndex, eid, cid); - }); + if (broadcast) { + syncAnnotation.deleteComponents(input); + } }); }, @@ -394,7 +404,7 @@ export const useStore = create()( updateRectangleAnchors: (input: UpdateRectangleAnchorsInput) => { set(s => { - const {sliceIndex, entityId, componentId, topLeft, bottomRight} = input; + const {sliceIndex, entityId, componentId, topLeft, bottomRight, broadcast} = input; const slice = getSlice(s, sliceIndex, entityId); if (!slice) return; @@ -403,6 +413,10 @@ export const useStore = create()( if (old?.type !== 'rectangle') return; slice[componentId] = {...old, topLeft, bottomRight}; + + if (broadcast) { + syncAnnotation.updateRectangleAnchors(input); + } }); }, @@ -456,24 +470,7 @@ export const useStore = create()( export const useTemporalStore = create(useStore.temporal); function addComponent(s: State, sliceIndex: SliceIndex, entityId: EntityId, component: Component) { - if (entityId in s.annotation.entities) { - const slices = s.annotation.entities[entityId].geometry.slices; - if (!(sliceIndex in slices)) { - slices[sliceIndex] = {}; - } - slices[sliceIndex][component.id] = deepClone(component); - } else { - s.annotation.entities[entityId] = { - id: entityId, - geometry: { - slices: { - [sliceIndex]: { - [component.id]: deepClone(component), - }, - }, - }, - }; - } + addAnnotationComponent(s.annotation, sliceIndex, entityId, component); } function deleteComponent( diff --git a/app/frontend/src/state/server/annotation.ts b/app/frontend/src/state/server/annotation.ts index c5cbcf0..05d95eb 100644 --- a/app/frontend/src/state/server/annotation.ts +++ b/app/frontend/src/state/server/annotation.ts @@ -1,8 +1,11 @@ -import * as Y from 'yjs'; import {createYjsProvider} from '@y-sweet/client'; import {useQuery, useMutation} from '@tanstack/react-query'; import type {NutshClient, DefaultService, Video} from 'openapi/nutsh'; +import {RectangleComponent} from 'type/annotation'; +import {addAnnotationComponent} from 'common/annotation'; +import {emptyAnnotation} from 'state/annotate/render'; +import {annoDoc} from 'sync/doc'; export const useGetVideoAnnotation = (client: NutshClient, id: Video['id']) => useQuery({ @@ -28,16 +31,47 @@ export const useGetVideoAnnotationV2 = (id: Video['id']) => const clientToken = await res.json(); // create a Yjs document and connect it to the Y-Sweet server - const doc = new Y.Doc(); - createYjsProvider(doc, clientToken, {disableBc: true}); + const doc = annoDoc.doc(); + const provider = createYjsProvider(doc, clientToken, {disableBc: true}); - const anno = doc.getMap('annotation'); + // reconstruct the initial annotation const annoJson = await new Promise(resolve => { - anno.observe(() => { - resolve(JSON.stringify(anno.toJSON())); + provider.on('synced', (isSynced: boolean) => { + if (!isSynced) { + return; + } + + const comps = annoDoc.componentInfoMap(); + const rects = annoDoc.rectangleAnchorsMap(); + + const anno = emptyAnnotation(); + for (const cid of comps.keys()) { + const info = comps.get(cid); + switch (info?.type) { + case 'rectangle': { + const rect = rects.get(cid); + if (!rect) { + console.warn(`rectangle ${cid} not found`); + break; + } + + const {sliceIndex, entityId} = info; + const {topLeft, bottomRight} = rect; + const comp: RectangleComponent = {type: 'rectangle', topLeft, bottomRight}; + addAnnotationComponent(anno, sliceIndex, entityId, {id: cid, ...comp}); + break; + } + default: + // TODO(xu) + console.warn('not implemented'); + } + } + + // TODO(xu): return directly the anno object + resolve(JSON.stringify(anno)); }); }); - return {anno, annoJson, annoVersion: ''}; + return {annoJson, annoVersion: ''}; }, }); diff --git a/app/frontend/src/sync/doc.ts b/app/frontend/src/sync/doc.ts new file mode 100644 index 0000000..137e0d3 --- /dev/null +++ b/app/frontend/src/sync/doc.ts @@ -0,0 +1,41 @@ +import * as Y from 'yjs'; +import {ComponentDetail, RectangleComponent} from 'type/annotation'; + +export type ComponentInfo = { + type: ComponentDetail['type']; + sliceIndex: number; + entityId: string; +}; + +export type RectangleAnchors = Pick; + +const mapKeys = { + componentInfo: 'componentInfo', + rectangleAnchors: 'rectangleAnchors', +}; + +export class AnnotationDoc { + private doc_: Y.Doc; + + constructor() { + this.doc_ = new Y.Doc(); + } + + doc = (): Y.Doc => { + return this.doc_; + }; + + transact = (fn: () => void) => { + this.doc_.transact(fn); + }; + + componentInfoMap = (): Y.Map => { + return this.doc_.getMap(mapKeys.componentInfo); + }; + + rectangleAnchorsMap = (): Y.Map => { + return this.doc_.getMap(mapKeys.rectangleAnchors); + }; +} + +export const annoDoc = new AnnotationDoc(); diff --git a/app/frontend/src/sync/update.ts b/app/frontend/src/sync/update.ts new file mode 100644 index 0000000..b26bb45 --- /dev/null +++ b/app/frontend/src/sync/update.ts @@ -0,0 +1,110 @@ +import { + AddComponentInput, + AddComponentsInput, + ClearEntityCategoryInput, + DeleteComponentsInput, + DeleteEntitiesInput, + DeletePolychainVertexInput, + PasteInput, + SeperateComponentInput, + SetEntityCategoryInput, + SetPolychainVertexBezierInput, + State, + TransferComponentInput, + TranslateInput, + TruncateEntitiesInput, + UpdatePolychainVerticesInput, + UpdateRectangleAnchorsInput, + UpdateSliceMasksInput, +} from 'state/annotate/annotation'; +import {annoDoc} from './doc'; + +export const syncAnnotation: Omit = { + setEntityCategory: (input: SetEntityCategoryInput) => { + console.error('not implemented', input); + }, + clearEntityCategory: (input: ClearEntityCategoryInput) => { + console.error('not implemented', input); + }, + deleteEntities: (input: DeleteEntitiesInput) => { + console.error('not implemented', input); + }, + truncateEntities: (input: TruncateEntitiesInput) => { + console.error('not implemented', input); + }, + addComponent: (input: AddComponentInput) => { + const {sliceIndex, entityId, component} = input; + switch (component.type) { + case 'rectangle': { + const comps = annoDoc.componentInfoMap(); + const rects = annoDoc.rectangleAnchorsMap(); + annoDoc.transact(() => { + const {id: cid, topLeft, bottomRight, type} = component; + comps.set(cid, {type, sliceIndex, entityId}); + rects.set(cid, {topLeft, bottomRight}); + }); + break; + } + default: + console.error('not implemented', input); + } + }, + addComponents: (input: AddComponentsInput) => { + console.error('not implemented', input); + }, + transferComponent: (input: TransferComponentInput) => { + console.error('not implemented', input); + }, + deleteComponents: (input: DeleteComponentsInput) => { + const {components} = input; + annoDoc.transact(() => { + components.forEach(([, cid]) => { + const comps = annoDoc.componentInfoMap(); + const comp = comps.get(cid); + if (!comp) { + console.warn(`component ${cid} not found`); + return; + } + comps.delete(cid); + + const {type} = comp; + switch (type) { + case 'rectangle': + annoDoc.rectangleAnchorsMap().delete(cid); + break; + default: + console.error('not implemented', input); + } + }); + }); + }, + seperateComponent: (input: SeperateComponentInput) => { + console.error('not implemented', input); + }, + commitDraftComponents: () => { + console.error('not implemented'); + }, + updatePolychainVertices: (input: UpdatePolychainVerticesInput) => { + console.error('not implemented', input); + }, + deletePolychainVertex: (input: DeletePolychainVertexInput) => { + console.error('not implemented', input); + }, + setPolychainVertexBezier: (input: SetPolychainVertexBezierInput) => { + console.error('not implemented', input); + }, + updateSliceMasks: (input: UpdateSliceMasksInput) => { + console.error('not implemented', input); + }, + updateRectangleAnchors: (input: UpdateRectangleAnchorsInput) => { + const {componentId, topLeft, bottomRight} = input; + const rects = annoDoc.rectangleAnchorsMap(); + rects.set(componentId, {topLeft, bottomRight}); + }, + paste: (input: PasteInput) => { + console.error('not implemented', input); + }, + translate: (input: TranslateInput) => { + console.error('not implemented', input); + }, +};