From 9ee253e75a87b511747aaaa3d8c943ebaab9fe19 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 16 Jul 2026 17:12:54 -0400 Subject: [PATCH 1/6] Support declared frame metadata items by marker Add a frameMetadata item marker as the arbitrary-name counterpart of the reserved sidecar basenames. Discovery lists marked items alongside name-declared ones, and postprocess accepts frameMetadataNames to mark uploaded files by direct name lookup (the sweep regexes never list a declared .txt). Marked items short-circuit the annotation classifier so an interrupted marking pass can never parse or remove a sidecar. --- server/dive_server/crud_dataset.py | 7 +- server/dive_server/crud_rpc.py | 98 +++++++++-- server/dive_server/views_rpc.py | 21 ++- server/dive_utils/constants.py | 4 + server/tests/test_frame_metadata_sources.py | 34 ++++ server/tests/test_process_items.py | 179 ++++++++++++++++++++ 6 files changed, 321 insertions(+), 22 deletions(-) diff --git a/server/dive_server/crud_dataset.py b/server/dive_server/crud_dataset.py index 4522e90af..7faddca56 100644 --- a/server/dive_server/crud_dataset.py +++ b/server/dive_server/crud_dataset.py @@ -420,13 +420,16 @@ def _frame_metadata_source_items( ) -> List[Dict[str, str]]: """List a folder's declared sidecar items as {itemId, name}, name-sorted (no download). - Only identity is resolved here: the server classifies sidecars by name and never reads, - parses, or joins them. The client downloads and parses the bytes at read time. + A sidecar is declared either by the reserved basename or by the explicit-import item + marker. Only identity is resolved here: the server classifies sidecars by declaration + and never reads, parses, or joins them. The client downloads and parses the bytes at + read time. """ items = [ item for item in Folder().childItems(folder) if frame_metadata.is_frame_metadata_source_name(item['name']) + or asbool(fromMeta(item, constants.FrameMetadataMarker)) ] return [ {'itemId': str(item['_id']), 'name': item['name']} diff --git a/server/dive_server/crud_rpc.py b/server/dive_server/crud_rpc.py index a80aea7d1..50e0d1d4e 100644 --- a/server/dive_server/crud_rpc.py +++ b/server/dive_server/crud_rpc.py @@ -652,16 +652,59 @@ def resolve_imported_dataset_info(existing: types.DatasetInfo, meta: dict, addit return {**meta, 'datasetInfo': resolved} +def _declare_frame_metadata_items( + folder: types.GirderModel, + frameMetadataNames: List[str], +) -> List[str]: + """Mark the named dataset-folder items as declared frame-metadata sidecars. + + This is the arbitrary-name counterpart of the reserved-basename convention: the client + states the user's intent by name, and each matching item is marked with the frame + metadata marker and left in place, processed, so the annotation sweep never parses it. + Names are looked up directly rather than through the sweep regexes because a declared + ``.txt`` sidecar is never swept. Failures are loud: a declaration that cannot be + honored must not fall through to annotation classification. + """ + dataset_type = fromMeta(folder, constants.TypeMarker) + if dataset_type not in (constants.ImageSequenceType, constants.MultiType): + raise RestException('Frame metadata is only supported for image-sequence datasets') + warnings: List[str] = [] + for name in dict.fromkeys(frameMetadataNames): + if not name.lower().endswith(('.csv', '.txt')): + raise RestException( + f'{name} is not a supported frame metadata file type (.csv or .txt)' + ) + items = list(Folder().childItems(folder, filters={'name': name})) + if not items: + raise RestException(f'{name} was not found in the dataset folder') + for item in items: + Item().setMetadata( + item, + {constants.FrameMetadataMarker: True, constants.ProcessedMarker: True}, + ) + warnings.append( + f'{name} was stored as frame metadata, not annotations; ' + 'it stays in the dataset folder.' + ) + return warnings + + def process_items( folder: types.GirderModel, user: types.GirderUserModel, additive=False, additivePrepend='', set='', + frameMetadataNames: Optional[List[str]] = None, ): """ Discover unprocessed items in a dataset and process them by type in order of creation """ + # Declared-by-name sidecars are marked before the sweep so they drop out of + # unprocessed_items via the ProcessedMarker filter below. + aggregate_warnings = ( + _declare_frame_metadata_items(folder, frameMetadataNames) if frameMetadataNames else [] + ) unprocessed_items = list( Folder().childItems( folder, @@ -693,6 +736,7 @@ def process_items( for item in unprocessed_items if constants.csvRegex.search(item['name']) and not frame_metadata.is_frame_metadata_source_name(item['name']) + and not asbool(fromMeta(item, constants.FrameMetadataMarker)) ) if len(annotation_csv_names) >= 2: raise RestException( @@ -705,32 +749,47 @@ def process_items( # a declared sidecar is classified by name and never joined here. image_map = None if fromMeta(folder, constants.TypeMarker) == constants.ImageSequenceType and any( - not frame_metadata.is_frame_metadata_source_name(item['name']) for item in unprocessed_items + not frame_metadata.is_frame_metadata_source_name(item['name']) + and not asbool(fromMeta(item, constants.FrameMetadataMarker)) + for item in unprocessed_items ): image_map = crud.valid_image_names_dict(crud.valid_images(folder, user)) auxiliary = None - aggregate_warnings = [] for item in unprocessed_items: file: Optional[types.GirderModel] = next(Item().childFiles(item), None) if file is None: raise RestException('Item had no associated files') - try: - results, warnings = _get_data_by_type(file, image_map=image_map) - if warnings: - aggregate_warnings += warnings - except Exception as e: - Item().remove(item) - if isinstance(e, ValueError): - hint = ( - ' If this file is frame metadata rather than annotations, rename it to ' - 'frame-metadata.csv and re-upload.' - if constants.csvRegex.search(file['name']) - else '' - ) - raise RestException(f'Failed to import {file["name"]}: {e}{hint}') from e - raise RestException(f'{file["name"]} was not a supported file type: {e}') from e + # A marker-declared sidecar must never reach the annotation classifier, even if a + # prior marking pass was interrupted before ProcessedMarker was saved. + results: Optional[GetDataReturnType] + warnings: Optional[List[str]] + if asbool(fromMeta(item, constants.FrameMetadataMarker)): + results = { + 'annotations': None, + 'meta': None, + 'attributes': None, + 'type': crud.FileType.FRAME_METADATA, + } + warnings = None + else: + try: + results, warnings = _get_data_by_type(file, image_map=image_map) + if warnings: + aggregate_warnings += warnings + except Exception as e: + Item().remove(item) + if isinstance(e, ValueError): + hint = ( + ' If this file is frame metadata rather than annotations, import it ' + "with the Import button's Frame Metadata option, or rename it to " + 'frame-metadata.csv and re-upload.' + if constants.csvRegex.search(file['name']) + else '' + ) + raise RestException(f'Failed to import {file["name"]}: {e}{hint}') from e + raise RestException(f'{file["name"]} was not a supported file type: {e}') from e if results is None: Item().remove(item) @@ -786,6 +845,7 @@ def postprocess( additive=False, additivePrepend='', set='', + frameMetadataNames: Optional[List[str]] = None, ) -> dict: """ Post-processing to be run after media/annotation import @@ -921,7 +981,9 @@ def postprocess( Folder().save(dsFolder) - aggregate_warnings = process_items(dsFolder, user, additive, additivePrepend, set) + aggregate_warnings = process_items( + dsFolder, user, additive, additivePrepend, set, frameMetadataNames + ) return {'folder': dsFolder, 'warnings': aggregate_warnings, 'job_ids': created_job_ids} diff --git a/server/dive_server/views_rpc.py b/server/dive_server/views_rpc.py index 911d10419..cb35107e6 100644 --- a/server/dive_server/views_rpc.py +++ b/server/dive_server/views_rpc.py @@ -207,10 +207,27 @@ def run_training(self, body, pipelineName, config, annotatedFramesOnly, forceTra default='', required=False, ) + .jsonParam( + "frameMetadataNames", + "JSON list of uploaded filenames to store as declared frame metadata sidecars", + paramType="formData", + requireArray=True, + default=None, + required=False, + ) ) - def postprocess(self, folder, skipJobs, skipTranscoding, additive, additivePrepend, set): + def postprocess( + self, folder, skipJobs, skipTranscoding, additive, additivePrepend, set, frameMetadataNames + ): return crud_rpc.postprocess( - self.getCurrentUser(), folder, skipJobs, skipTranscoding, additive, additivePrepend, set + self.getCurrentUser(), + folder, + skipJobs, + skipTranscoding, + additive, + additivePrepend, + set, + frameMetadataNames, ) @access.user diff --git a/server/dive_utils/constants.py b/server/dive_utils/constants.py index cb782614b..cee50c0ff 100644 --- a/server/dive_utils/constants.py +++ b/server/dive_utils/constants.py @@ -117,6 +117,10 @@ JsonCalibrationItemIdMarker = "jsonCalibrationItemId" CalibrationOriginalNameMarker = "calibrationOriginalName" CalibrationConversionErrorMarker = "calibrationConversionError" +# Girder item meta: declared frame-metadata sidecar. Set by the explicit import +# affordances so arbitrary-named sidecars are equivalent to the reserved +# frame-metadata.csv naming convention for discovery and import classification. +FrameMetadataMarker = "frameMetadata" # Girder item meta: original stereoscopic calibration upload (npz, yml, etc.) CalibrationFileMarker = "calibrationFile" # Girder item meta: JSON camera-rig used for calibration display diff --git a/server/tests/test_frame_metadata_sources.py b/server/tests/test_frame_metadata_sources.py index d20a20e48..efbbddbde 100644 --- a/server/tests/test_frame_metadata_sources.py +++ b/server/tests/test_frame_metadata_sources.py @@ -164,6 +164,40 @@ def test_sources_single_camera_no_sidecars_returns_empty(get_clone_root, folder_ assert result == {'cameras': {}} +@patch('dive_server.crud_dataset.Folder') +@patch('dive_server.crud_dataset.crud.getCloneRoot') +def test_sources_include_marker_declared_items(get_clone_root, folder_cls): + dataset = _dataset_folder() + user = {'_id': 'user-id'} + get_clone_root.return_value = dataset + # An arbitrary-named sidecar declared by explicit import carries the item marker; an + # undeclared arbitrary-named CSV is still invisible to discovery. + marked = { + '_id': 'nav_2024.csv-id', + 'name': 'nav_2024.csv', + 'meta': {constants.FrameMetadataMarker: True, constants.ProcessedMarker: True}, + } + unmarked = {'_id': 'notes.csv-id', 'name': 'notes.csv', 'meta': {}} + folder_cls.return_value.childItems.return_value = [ + _source_item('image_0001.jpg'), + unmarked, + marked, + _source_item('frame-metadata.csv'), + ] + + result = crud_dataset.load_frame_metadata_sources(dataset, user) + + # Marker- and name-declared sidecars list together, name-sorted. + assert result == { + 'cameras': { + 'singleCam': [ + _descriptor('frame-metadata.csv'), + _descriptor('nav_2024.csv'), + ], + }, + } + + @pytest.mark.parametrize('dataset_type', [constants.VideoType, constants.LargeImageType]) @patch('dive_server.crud_dataset.Folder') @patch('dive_server.crud_dataset.crud.getCloneRoot') diff --git a/server/tests/test_process_items.py b/server/tests/test_process_items.py index 53da7913f..033693aa3 100644 --- a/server/tests/test_process_items.py +++ b/server/tests/test_process_items.py @@ -167,6 +167,185 @@ def test_two_plain_csvs_in_folder_side_door_guard( get_auxiliary_folder.assert_not_called() +def _wire_declared_lookup(folder_cls, item_cls, all_items): + """Emulate the two childItems query shapes plus a mutating setMetadata. + + The declaration pass looks items up by exact name; the sweep uses the $and regex/marker + filter, emulated here as "unswept csv/json/yml items not yet marked processed". + """ + + def child_items(_folder, filters=None, sort=None): + if filters and 'name' in filters: + return [item for item in all_items if item['name'] == filters['name']] + return [ + item + for item in all_items + if item['name'].lower().rsplit('.', 1)[-1] in ('csv', 'json', 'yml', 'yaml') + and item['meta'].get(constants.ProcessedMarker) is not True + ] + + def set_metadata(item, metadata): + item['meta'].update(metadata) + return item + + folder_cls.return_value.childItems.side_effect = child_items + item_cls.return_value.setMetadata.side_effect = set_metadata + + +@patch('dive_server.crud_rpc.crud_annotation.save_annotations') +@patch('dive_server.crud_rpc.crud.get_or_create_auxiliary_folder') +@patch('dive_server.crud_rpc.File') +@patch('dive_server.crud_rpc.Item') +@patch('dive_server.crud_rpc.Folder') +def test_declared_frame_metadata_names_are_marked_and_kept( + folder_cls, item_cls, file_cls, get_auxiliary_folder, save_annotations +): + # Arbitrary-named sidecars declared through frameMetadataNames are marked and left in + # place — including .txt, which the sweep regexes never even list. + folder = {'_id': 'ds', 'meta': {'type': constants.ImageSequenceType, 'fps': 5}} + item_csv = {'_id': 'a', 'name': 'nav_2024.csv', 'meta': {}} + item_txt = {'_id': 'b', 'name': 'nav_2024.txt', 'meta': {}} + _wire_declared_lookup(folder_cls, item_cls, [item_csv, item_txt]) + + warnings = process_items( + folder, {'_id': 'user-id'}, frameMetadataNames=['nav_2024.csv', 'nav_2024.txt'] + ) + + assert len(warnings) == 2 + assert all('stays in the dataset folder' in warning for warning in warnings) + for item in (item_csv, item_txt): + assert item['meta'][constants.FrameMetadataMarker] is True + assert item['meta'][constants.ProcessedMarker] is True + item_cls.return_value.move.assert_not_called() + item_cls.return_value.remove.assert_not_called() + save_annotations.assert_not_called() + file_cls.return_value.download.assert_not_called() + + +@patch('dive_server.crud_rpc.Item') +@patch('dive_server.crud_rpc.Folder') +def test_declared_name_missing_from_folder_raises(folder_cls, item_cls): + folder = {'_id': 'ds', 'meta': {'type': constants.ImageSequenceType, 'fps': 5}} + _wire_declared_lookup(folder_cls, item_cls, []) + + with pytest.raises(RestException, match='was not found in the dataset folder'): + process_items(folder, {'_id': 'user-id'}, frameMetadataNames=['nav_2024.csv']) + item_cls.return_value.setMetadata.assert_not_called() + + +@patch('dive_server.crud_rpc.Item') +@patch('dive_server.crud_rpc.Folder') +def test_declared_name_unsupported_extension_raises(folder_cls, item_cls): + folder = {'_id': 'ds', 'meta': {'type': constants.ImageSequenceType, 'fps': 5}} + item = {'_id': 'a', 'name': 'nav.json', 'meta': {}} + _wire_declared_lookup(folder_cls, item_cls, [item]) + + with pytest.raises(RestException, match='not a supported frame metadata file type'): + process_items(folder, {'_id': 'user-id'}, frameMetadataNames=['nav.json']) + item_cls.return_value.setMetadata.assert_not_called() + + +@pytest.mark.parametrize('dataset_type', [constants.VideoType, constants.LargeImageType]) +@patch('dive_server.crud_rpc.Item') +@patch('dive_server.crud_rpc.Folder') +def test_declared_names_rejected_for_unsupported_media_type(folder_cls, item_cls, dataset_type): + folder = {'_id': 'ds', 'meta': {'type': dataset_type, 'fps': 5}} + item = {'_id': 'a', 'name': 'nav_2024.csv', 'meta': {}} + _wire_declared_lookup(folder_cls, item_cls, [item]) + + with pytest.raises(RestException, match='only supported for image-sequence'): + process_items(folder, {'_id': 'user-id'}, frameMetadataNames=['nav_2024.csv']) + item_cls.return_value.setMetadata.assert_not_called() + + +@patch('dive_server.crud_rpc.Item') +@patch('dive_server.crud_rpc.Folder') +def test_declared_names_allowed_for_multicam_parent(folder_cls, item_cls): + # A declaration on the multicam parent folder is the shared-sidecar path. + folder = {'_id': 'parent', 'meta': {'type': constants.MultiType, 'fps': 5}} + item = {'_id': 'a', 'name': 'nav_2024.csv', 'meta': {}} + _wire_declared_lookup(folder_cls, item_cls, [item]) + + warnings = process_items(folder, {'_id': 'user-id'}, frameMetadataNames=['nav_2024.csv']) + + assert len(warnings) == 1 + assert item['meta'][constants.FrameMetadataMarker] is True + assert item['meta'][constants.ProcessedMarker] is True + + +@patch('dive_server.crud_rpc.crud_annotation.save_annotations') +@patch('dive_server.crud_rpc.crud.get_or_create_auxiliary_folder') +@patch('dive_server.crud_rpc.File') +@patch('dive_server.crud_rpc.Item') +@patch('dive_server.crud_rpc.Folder') +def test_marker_declared_item_short_circuits_sweep( + folder_cls, item_cls, file_cls, get_auxiliary_folder, save_annotations +): + # A marked-but-unprocessed item (e.g. a marking pass interrupted before ProcessedMarker + # saved) must take the keep-in-place branch, never the annotation classifier. + folder = {'_id': 'ds', 'meta': {'type': constants.ImageSequenceType, 'fps': 5}} + item = { + '_id': 'item-id', + 'name': 'nav_2024.csv', + 'meta': {constants.FrameMetadataMarker: True}, + } + file = {'_id': 'file-id', 'name': 'nav_2024.csv', 'exts': ['csv']} + + folder_cls.return_value.childItems.return_value = [item] + item_cls.return_value.childFiles.side_effect = _childfiles_side_effect({'item-id': file}) + + warnings = process_items(folder, {'_id': 'user-id'}) + + assert len(warnings) == 1 + assert 'stays in the dataset folder' in warnings[0] + assert item['meta'][constants.ProcessedMarker] is True + item_cls.return_value.save.assert_called_once_with(item) + item_cls.return_value.move.assert_not_called() + item_cls.return_value.remove.assert_not_called() + file_cls.return_value.download.assert_not_called() + save_annotations.assert_not_called() + + +@patch('dive_server.crud_rpc.crud_annotation.save_annotations') +@patch('dive_server.crud_rpc.crud.valid_images') +@patch('dive_server.crud_rpc.crud.get_or_create_auxiliary_folder') +@patch('dive_server.crud_rpc.File') +@patch('dive_server.crud_rpc.Item') +@patch('dive_server.crud_rpc.Folder') +def test_marked_csv_does_not_trip_two_csv_guard( + folder_cls, item_cls, file_cls, get_auxiliary_folder, valid_images, save_annotations +): + # A marker-declared sidecar CSV alongside one plain annotation CSV is not "two + # annotation CSVs": the annotation imports and the sidecar stays put. + folder = {'_id': 'ds', 'meta': {'type': constants.ImageSequenceType, 'fps': 5}} + marked = { + '_id': 'nav', + 'name': 'nav_2024.csv', + 'meta': {constants.FrameMetadataMarker: True}, + } + plain = {'_id': 'ann', 'name': 'annotations.csv', 'meta': {}} + file_marked = {'_id': 'f-nav', 'name': 'nav_2024.csv', 'exts': ['csv']} + file_plain = {'_id': 'f-ann', 'name': 'annotations.csv', 'exts': ['csv']} + + folder_cls.return_value.childItems.return_value = [marked, plain] + item_cls.return_value.childFiles.side_effect = _childfiles_side_effect( + {'nav': file_marked, 'ann': file_plain} + ) + file_cls.return_value.download.side_effect = _download_side_effect( + {'f-ann': _viame_csv().encode()} + ) + get_auxiliary_folder.return_value = {'_id': 'aux-id'} + valid_images.return_value = [{'name': 'image_0001.jpg'}] + + warnings = process_items(folder, {'_id': 'user-id'}) + + assert len(warnings) == 1 + assert 'nav_2024.csv' in warnings[0] + item_cls.return_value.move.assert_called_once_with(plain, {'_id': 'aux-id'}) + save_annotations.assert_called_once() + assert marked['meta'][constants.ProcessedMarker] is True + + @patch('dive_server.crud_rpc.crud_annotation.save_annotations') @patch('dive_server.crud_rpc.crud.valid_images') @patch('dive_server.crud_rpc.crud.get_or_create_auxiliary_folder') From 6689521d35b8e7fbf2e1f02ca8b41e1ce6ef6563 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 16 Jul 2026 17:13:03 -0400 Subject: [PATCH 2/6] Add frame metadata section to the viewer Import menu The Import card gains a Frame Metadata section, present when the platform provides importFrameMetadataFile. The picked file may have any name; the pick itself declares its role. A module-level invalidation signal lets the import refresh live Frame Info panels through the session cache. --- client/dive-common/apispec.ts | 7 +- .../components/ImportAnnotations.vue | 70 +++++++++++++++++++ client/dive-common/constants.ts | 7 ++ .../dive-common/use/useFrameMetadata.spec.ts | 41 ++++++++++- client/dive-common/use/useFrameMetadata.ts | 20 ++++++ 5 files changed, 143 insertions(+), 2 deletions(-) diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index 2de12986b..45c0b5a98 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -323,7 +323,7 @@ interface Api { saveAttributeTrackFilters(datasetId: string, args: SaveAttributeTrackFilterArgs): Promise; // Non-Endpoint shared functions - openFromDisk(datasetType: DatasetType | 'bulk' | 'calibration' | 'annotation' | 'text' | 'zip' | 'transform', directory?: boolean): + openFromDisk(datasetType: DatasetType | 'bulk' | 'calibration' | 'annotation' | 'frameMetadata' | 'text' | 'zip' | 'transform', directory?: boolean): Promise<{canceled?: boolean; filePaths: string[]; fileList?: File[]; root?: string}>; /** Desktop: immediate child directory names under a parent folder (multicam subfolder import). */ listImmediateSubfolders?(parentPath: string): Promise; @@ -363,6 +363,11 @@ interface Api { }): Promise; importAnnotationFile(id: string, path: string, file?: File, additive?: boolean, additivePrepend?: string, set?: string): Promise; + /** + * Store an arbitrary-named frame metadata sidecar with the dataset (declared by user + * intent rather than the reserved basename). Resolves with any import warnings. + */ + importFrameMetadataFile?(id: string, path: string, file?: File): Promise; // Desktop-only calibration persistence functions getLastCalibration?(): Promise; saveCalibration?(path: string): Promise<{ savedPath: string; updatedDatasetIds: string[] }>; diff --git a/client/dive-common/components/ImportAnnotations.vue b/client/dive-common/components/ImportAnnotations.vue index 7e4bc1351..fbe305946 100644 --- a/client/dive-common/components/ImportAnnotations.vue +++ b/client/dive-common/components/ImportAnnotations.vue @@ -5,6 +5,7 @@ import { import { useApi } from 'dive-common/apispec'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { clientSettings } from 'dive-common/store/settings'; +import { invalidateFrameMetadata } from 'dive-common/use/useFrameMetadata'; import clearLengthAttributes from 'dive-common/utils/clearLengthAttributes'; import warpAnnotationsAcrossCameras from 'dive-common/utils/warpAnnotationsAcrossCameras'; import { cloneDeep } from 'lodash'; @@ -219,6 +220,42 @@ export default defineComponent({ processing.value = false; } }; + // Frame metadata import works with any filename: the user's pick declares the file's + // role, so no reserved basename is needed on this path. + const frameMetadataSupported = computed(() => !!api.importFrameMetadataFile); + const openFrameMetadataUpload = async () => { + if (!api.importFrameMetadataFile) return; + try { + const ret = await openFromDisk('frameMetadata'); + if (ret.canceled || !ret.filePaths.length) return; + menuOpen.value = false; + processing.value = true; + const result = await api.importFrameMetadataFile( + props.datasetId, + ret.filePaths[0], + ret.fileList?.[0], + ); + processing.value = false; + if (result === false) { + await prompt({ + title: 'Frame Metadata Import Failed', + text: ['Could not import the frame metadata file.'], + positiveButton: 'OK', + }); + return; + } + // The Frame Info panel reads through a session cache; drop it so the new + // sidecar shows up without reloading the viewer. + invalidateFrameMetadata(); + } catch (error) { + processing.value = false; + prompt({ + title: 'Frame Metadata Import Failed', + text: [getResponseError(error)], + positiveButton: 'OK', + }); + } + }; const openCalibrationUpload = async () => { if (!api.importCalibrationFile) return; try { @@ -357,6 +394,8 @@ export default defineComponent({ }; return { openUpload, + frameMetadataSupported, + openFrameMetadataUpload, openCalibrationUpload, openRegistrationUpload, applyLastCalibration, @@ -533,6 +572,37 @@ export default defineComponent({ +