diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index 55fca437c..7645360c4 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -125,6 +125,22 @@ interface FrameImage { id?: string; } +interface FrameMetadata { + [field: string]: string; +} + +interface FrameMetadataMap { + [frame: string]: FrameMetadata; +} + +interface FrameMetadataCameraMap { + [camera: string]: FrameMetadataMap; +} + +interface FrameMetadataResponse { + cameras: FrameMetadataCameraMap; +} + export interface MultiCamImportFolderArgs { datasetName?: string; // Girder parent folder name (required on web) defaultDisplay: string; // In multicam the default camera to display @@ -266,6 +282,8 @@ interface Api { loadMetadata(datasetId: string): Promise; loadDetections(datasetId: string, revision?: number, set?: string): Promise; + loadFrameMetadata?(datasetId: string, startFrame: number, endFrame: number): + Promise; saveDetections(datasetId: string, args: SaveDetectionsArgs): Promise; saveMetadata(datasetId: string, metadata: DatasetMetaMutable): Promise; @@ -444,6 +462,10 @@ export { PipelineRuntimeParams, PipeMetadata, Pipelines, + FrameMetadataCameraMap, + FrameMetadataMap, + FrameMetadataResponse, + FrameMetadata, SaveDetectionsArgs, SaveAttributeArgs, SaveAttributeTrackFilterArgs, diff --git a/client/dive-common/components/DatasetInfo.spec.ts b/client/dive-common/components/DatasetInfo.spec.ts new file mode 100644 index 000000000..2aa0ffe8f --- /dev/null +++ b/client/dive-common/components/DatasetInfo.spec.ts @@ -0,0 +1,265 @@ +// @vitest-environment jsdom +// eslint-disable-next-line import/no-extraneous-dependencies -- Vue Test Utils is only used in tests +import { mount } from '@vue/test-utils'; +import Vue, { + defineComponent, nextTick, ref, +} from 'vue'; + +// eslint-disable-next-line import/no-extraneous-dependencies -- Vitest is only used in tests +import { + describe, expect, it, vi, +} from 'vitest'; + +import { + DatasetMeta, + FrameMetadataResponse, + provideApi, +} from 'dive-common/apispec'; +import { + dummyHandler, + dummyState, + provideAnnotator, +} from 'vue-media-annotator/provides'; +import DatasetInfo from './DatasetInfo.vue'; + +Vue.config.ignoredElements = [/^v-/]; + +function flushPromises() { + return new Promise((resolve) => { + window.setTimeout(resolve, 0); + }); +} + +const defaultMetadata: DatasetMeta = { + id: 'dataset-id', + imageData: [], + videoUrl: undefined, + type: 'image-sequence', + fps: 5, + name: 'Mouss Set', + createdAt: '2024-01-02T03:04:05.000Z', + originalFps: 10, + subType: null, + multiCamMedia: null, + datasetInfo: { + cruise: '2403', + station: 'TXN-012', + }, +}; + +function apiWithMetadata({ + loadMetadata, + loadFrameMetadata, + saveMetadata, +}: { + loadMetadata: (datasetId: string) => Promise; + loadFrameMetadata?: (datasetId: string, startFrame: number, endFrame: number) => + Promise; + saveMetadata: Parameters[0]['saveMetadata']; +}): Parameters[0] { + return { + getPipelineList: async () => ({}), + runPipeline: async () => undefined, + deleteTrainedPipeline: async () => undefined, + exportTrainedPipeline: async () => undefined, + getDatasetCalibration: async () => null, + getTrainingConfigurations: async () => ({ training: { configs: [], default: '' }, models: {} }), + runTraining: async () => undefined, + loadMetadata, + loadDetections: async () => ({ + version: 2, + tracks: [], + groups: [], + sets: [], + }), + loadFrameMetadata, + saveDetections: async () => undefined, + saveMetadata, + saveAttributes: async () => undefined, + saveAttributeTrackFilters: async () => undefined, + openFromDisk: async () => ({ canceled: true, filePaths: [] }), + importAnnotationFile: async () => false, + }; +} + +function mountDatasetInfo({ + response, + loadFrameMetadata, + selectedCamera = 'port', + readOnlyMode = true, + metadata = defaultMetadata, +}: { + response?: FrameMetadataResponse; + loadFrameMetadata?: (datasetId: string, startFrame: number, endFrame: number) => + Promise; + selectedCamera?: string; + readOnlyMode?: boolean; + metadata?: DatasetMeta; +} = {}) { + const state = dummyState(); + state.datasetId = ref('dataset-id'); + state.selectedCamera = ref(selectedCamera); + state.time = { + ...state.time, + frame: ref(10), + }; + state.readOnlyMode = ref(readOnlyMode); + + const loader = loadFrameMetadata ?? ( + response === undefined + ? undefined + : vi.fn(async () => response) + ); + const loadMetadata = vi.fn(async () => metadata); + const saveMetadata = vi.fn(async () => undefined); + const api = apiWithMetadata({ loadMetadata, loadFrameMetadata: loader, saveMetadata }); + + const Root = defineComponent({ + components: { DatasetInfo }, + setup() { + provideApi(api); + provideAnnotator( + state, + dummyHandler(() => undefined), + {} as Parameters[2], + ); + return {}; + }, + template: '', + }); + + const wrapper = mount(Root, { + stubs: { + DatasetMetaEditorDialog: true, + }, + }); + return { + wrapper, + state, + loadFrameMetadata: loader, + loadMetadata, + saveMetadata, + }; +} + +describe('DatasetInfo', () => { + it('renders frame metadata above dataset info rows in source order', async () => { + const { wrapper } = mountDatasetInfo({ + response: { + cameras: { + port: { + 10: { + latitude: '58.10', + depth_m: '100', + note: ' raw text ', + }, + }, + }, + }, + }); + + await flushPromises(); + await nextTick(); + + const frameRows = wrapper.findAll('.frame-metadata-row').wrappers; + expect(frameRows.map((item) => item.find('.frame-metadata-key').text())) + .toEqual(['latitude', 'depth_m', 'note']); + expect(frameRows.map((item) => item.find('.frame-metadata-value').element.textContent)) + .toEqual(['58.10', '100', ' raw text ']); + + const text = wrapper.text(); + expect(text.indexOf('Frame Metadata')).toBeLessThan(text.indexOf('Dataset Info')); + expect(text.indexOf('Dataset Info')).toBeLessThan(text.indexOf('Custom Metadata')); + expect(wrapper.find('.dataset-info-section').text()).toContain('Mouss Set'); + expect(wrapper.find('.dataset-info-section').text()).toContain('image-sequence'); + expect(wrapper.find('.custom-metadata-section').text()).toContain('cruise'); + expect(wrapper.find('.custom-metadata-section').text()).toContain('2403'); + }); + + it('keeps frame metadata read-only without edit controls', async () => { + const { wrapper } = mountDatasetInfo({ + readOnlyMode: false, + response: { + cameras: { + port: { + 10: { + latitude: '58.10', + }, + }, + }, + }, + }); + + await flushPromises(); + await nextTick(); + + const frameSection = wrapper.find('.frame-metadata-section'); + expect(frameSection.find('v-text-field').exists()).toBe(false); + expect(frameSection.find('v-btn').exists()).toBe(false); + expect(wrapper.find('.custom-metadata-section').find('v-text-field').exists()).toBe(true); + expect(wrapper.find('.custom-metadata-section').find('v-btn').exists()).toBe(true); + }); + + it('shows the unsupported platform state when no load API is provided', async () => { + const { wrapper } = mountDatasetInfo(); + + await nextTick(); + + expect(wrapper.find('.frame-metadata-section').text()) + .toContain('Frame metadata is not supported on this platform.'); + }); + + it('shows the no-source state after an empty cameras response', async () => { + const { wrapper } = mountDatasetInfo({ response: { cameras: {} } }); + + await flushPromises(); + await nextTick(); + + expect(wrapper.find('.frame-metadata-section').text()).toContain('No frame metadata source found.'); + expect(wrapper.find('.frame-metadata-section').text()) + .toContain('Place a .txt or .csv telemetry file next to the imagery.'); + }); + + it('shows the no-current-frame state when the dataset has metadata but not this frame', async () => { + const { wrapper } = mountDatasetInfo({ + response: { + cameras: { + port: { + 11: { latitude: '58.11' }, + }, + }, + }, + }); + + await flushPromises(); + await nextTick(); + + expect(wrapper.find('.frame-metadata-section').text()) + .toContain('No frame metadata for the current frame.'); + }); + + it('follows the active multicam camera from the cached frame window', async () => { + const loadFrameMetadata = vi.fn(async () => ({ + cameras: { + port: { + 10: { latitude: '58.10' }, + }, + starboard: { + 10: { latitude: '59.10' }, + }, + }, + })); + const { wrapper, state } = mountDatasetInfo({ loadFrameMetadata }); + + await flushPromises(); + await nextTick(); + + expect(wrapper.find('.frame-metadata-section').text()).toContain('58.10'); + state.selectedCamera.value = 'starboard'; + await nextTick(); + + expect(wrapper.find('.frame-metadata-section').text()).toContain('59.10'); + expect(wrapper.find('.frame-metadata-section').text()).not.toContain('58.10'); + expect(loadFrameMetadata).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/dive-common/components/DatasetInfo.vue b/client/dive-common/components/DatasetInfo.vue index aa3f4e23a..5435d1541 100644 --- a/client/dive-common/components/DatasetInfo.vue +++ b/client/dive-common/components/DatasetInfo.vue @@ -2,9 +2,15 @@ import { computed, defineComponent, ref, watch, } from 'vue'; -import { useDatasetId, useReadOnlyMode } from 'vue-media-annotator/provides'; +import { + useDatasetId, + useReadOnlyMode, + useSelectedCamera, + useTime, +} from 'vue-media-annotator/provides'; import { useApi, DatasetMeta } from 'dive-common/apispec'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; +import { useFrameMetadataWindow } from 'dive-common/use'; import DatasetMetaEditorDialog from 'dive-common/components/DatasetMetaEditorDialog.vue'; export default defineComponent({ @@ -15,7 +21,9 @@ export default defineComponent({ setup() { const datasetId = useDatasetId(); const readOnlyMode = useReadOnlyMode(); - const { loadMetadata, saveMetadata } = useApi(); + const selectedCamera = useSelectedCamera(); + const time = useTime(); + const { loadMetadata, saveMetadata, loadFrameMetadata } = useApi(); const { prompt } = usePrompt(); const meta = ref(null); const customMeta = ref>({}); @@ -37,6 +45,29 @@ export default defineComponent({ watch(datasetId, fetchMetadata, { immediate: true }); + const frameMetadata = useFrameMetadataWindow({ + datasetId, + frame: time.frame, + selectedCamera, + loadFrameMetadata, + }); + + const frameMetadataEmptyState = computed(() => { + if (frameMetadata.unsupported.value) { + return 'Frame metadata is not supported on this platform.'; + } + if (frameMetadata.loading.value && !frameMetadata.currentEntries.value.length) { + return 'Loading frame metadata...'; + } + if (frameMetadata.error.value) { + return `Unable to load frame metadata: ${frameMetadata.error.value}`; + } + if (!frameMetadata.hasMetadataSource.value) { + return 'No frame metadata source found. Place a .txt or .csv telemetry file next to the imagery.'; + } + return 'No frame metadata for the current frame.'; + }); + const infoRows = computed(() => { const m = meta.value; if (!m) { @@ -144,6 +175,8 @@ export default defineComponent({ return { readOnlyMode, + frameMetadataEntries: frameMetadata.currentEntries, + frameMetadataEmptyState, infoRows, customMeta, customMetaKeys, @@ -166,138 +199,182 @@ export default defineComponent({