From e053f10c62463518c486c4ceed1e7ed390ce1b25 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sat, 4 Jul 2026 18:23:05 -0400 Subject: [PATCH 01/35] Classify upload files into semantic roles and preserve frame-metadata sidecars validate_files now returns per-role buckets (media, annotations, datasetConfig, frameMetadata) plus an explicit upload list and ignored[] entries with reasons, instead of overloading a single annotations bucket. Declared telemetry sidecars (.meta.csv/.meta.txt) are recognized by name: on image-sequence datasets they become a frameMetadata role; on other media types they are ignored with a clear reason. At most one annotation source (CSV/YAML/JSON) is accepted so a package cannot silently overwrite annotations at import. process_items now leaves a declared .meta.csv in the dataset folder (marks it processed, never imports, moves, or removes it) so an uploaded sidecar is no longer deleted and the annotation import is not aborted; the sweep skips already-processed items. The zip validation consumer reads the new roles shape. --- server/dive_server/crud_dataset.py | 128 +++++++++++++++----- server/dive_server/crud_rpc.py | 38 +++++- server/dive_tasks/utils.py | 4 +- server/dive_utils/frame_metadata.py | 15 +++ server/tests/test_process_items.py | 129 ++++++++++++++++++++ server/tests/test_validate_files.py | 176 ++++++++++++++++++++++++++++ 6 files changed, 453 insertions(+), 37 deletions(-) create mode 100644 server/dive_utils/frame_metadata.py create mode 100644 server/tests/test_process_items.py create mode 100644 server/tests/test_validate_files.py diff --git a/server/dive_server/crud_dataset.py b/server/dive_server/crud_dataset.py index c0f7dc24f..e1f1fbfc9 100644 --- a/server/dive_server/crud_dataset.py +++ b/server/dive_server/crud_dataset.py @@ -16,7 +16,16 @@ from dive_server import crud, crud_annotation from dive_tasks import tasks -from dive_utils import TRUTHY_META_VALUES, asbool, calibration_format, constants, fromMeta, models, types +from dive_utils import ( + TRUTHY_META_VALUES, + asbool, + calibration_format, + constants, + frame_metadata, + fromMeta, + models, + types, +) from dive_utils.serializers import kwcoco @@ -963,9 +972,9 @@ def _source_calibration_items_in_folder_root(folder_id: str): Camera media lives in child folders; only direct items on folder_id are considered. """ for cal_item in Item().find({'folderId': _mongo_id(folder_id)}, sort=[('created', -1)]): - if _item_has_source_calibration_marker(cal_item) and constants.stereoCalibrationRegex.search( - cal_item['name'] - ): + if _item_has_source_calibration_marker( + cal_item + ) and constants.stereoCalibrationRegex.search(cal_item['name']): yield cal_item @@ -1348,55 +1357,114 @@ def create_multicam( def validate_files(files: List[str]): """ - Given a collection of filenames, guess based on regular expressions - if the collection represents a valid dataset, and if so, which files - represent which type of data + Given a collection of filenames, classify each into a semantic upload role. + + The response is authoritative for what the client uploads: accepted files are + listed under ``roles`` and concatenated into ``upload`` in a stable order, while + any file that is not needed is returned in ``ignored`` with a visible reason. No + selected file is silently discarded. """ + # Partition frame-metadata sidecars first so a .meta.csv/.meta.txt never trips the + # generic csv/txt classification below. + frame_meta = [f for f in files if frame_metadata.is_frame_metadata_source_name(f)] + frame_meta_set = set(frame_meta) + + videos = [f for f in files if constants.videoRegex.search(f) and f not in frame_meta_set] + images = [f for f in files if constants.imageRegex.search(f) and f not in frame_meta_set] + large_images = [ + f for f in files if constants.largeImageRegEx.search(f) and f not in frame_meta_set + ] + media = images + videos + large_images + + # Dataset config JSON follows the same meta/config filename contract as the client's + # JsonMetaRegEx; annotation JSON is every other .json. + dataset_config = [ + f for f in files if constants.jsonRegex.search(f) and constants.metaRegex.search(f) + ] + dataset_config_set = set(dataset_config) + + annotation_csvs = [f for f in files if constants.csvRegex.search(f) and f not in frame_meta_set] + annotation_ymls = [f for f in files if constants.ymlRegex.search(f)] + annotation_jsons = [ + f for f in files if constants.jsonRegex.search(f) and f not in dataset_config_set + ] + annotations = annotation_csvs + annotation_ymls + annotation_jsons + + if len(videos): + mediatype = constants.VideoType + elif len(images): + mediatype = constants.ImageSequenceType + elif len(large_images): + mediatype = constants.LargeImageType + else: + mediatype = "" + ok = True message = "" - mediatype = "" - videos = [f for f in files if constants.videoRegex.search(f)] - csvs = [f for f in files if constants.csvRegex.search(f)] - images = [f for f in files if constants.imageRegex.search(f)] - large_images = [f for f in files if constants.largeImageRegEx.search(f)] - ymls = [f for f in files if constants.ymlRegex.search(f)] - jsons = [f for f in files if constants.jsonRegex.search(f)] if len(videos) and (len(images) or len(large_images)): ok = False message = "Do not upload images and videos in the same batch." elif len(large_images) and len(images): ok = False message = "Do not upload images and tile images in the same batch." - elif len(csvs) > 1: + elif len(annotation_csvs) > 1: ok = False message = "Can only upload a single CSV Annotation per import" - elif len(jsons) > 2: + elif len(dataset_config) > 1: ok = False - message = ( - "Can only upload a single JSON Annotation and single configuration JSON per import" - ) - elif len(csvs) == 1 and len(ymls): + message = "Can only upload a single configuration JSON per import" + elif len(annotation_jsons) > 1: + ok = False + message = "Can only upload a single annotation JSON per import" + elif len(annotation_csvs) and len(annotation_ymls): + ok = False + message = "Cannot mix annotation import types" + elif len(annotation_ymls) > 1: + ok = False + message = "Can only upload a single YAML Annotation per import" + elif len(annotation_csvs) + len(annotation_ymls) + len(annotation_jsons) > 1: + # Multiple annotation sources across formats (e.g. CSV + JSON) would silently + # overwrite each other at import, so only one annotation source is allowed. ok = False message = "Cannot mix annotation import types" - elif len(videos) > 1 and (len(csvs) or len(ymls) or len(jsons)): + elif len(videos) > 1 and (len(annotations) or len(dataset_config)): ok = False message = "Annotation upload is not supported when multiple videos are uploaded" - elif (not len(videos)) and (not len(images)) and (not len(large_images)): + elif not (len(videos) or len(images) or len(large_images)): ok = False message = "No supported media-type files found" - elif len(videos): - mediatype = constants.VideoType - elif len(images): - mediatype = constants.ImageSequenceType - elif len(large_images): - mediatype = constants.LargeImageType + + # Frame metadata is only read for image sequences (Decision D4). On any other media + # type the sidecar is preserved as ignored-with-reason rather than uploaded. + frame_metadata_role = frame_meta if mediatype == constants.ImageSequenceType else [] + + roles = { + "media": media, + "annotations": annotations, + "datasetConfig": dataset_config, + "frameMetadata": frame_metadata_role, + } + upload = media + annotations + dataset_config + frame_metadata_role + + accepted = set(upload) + ignored = [] + for f in files: + if f in accepted: + continue + if f in frame_meta_set: + ignored.append( + {"name": f, "reason": "Frame metadata is not supported for this media type"} + ) + else: + ignored.append({"name": f, "reason": "Unsupported side file"}) return { "ok": ok, "message": message, "type": mediatype, - "media": images + videos + large_images, - "annotations": csvs + ymls + jsons, + "roles": roles, + "upload": upload, + "ignored": ignored, } diff --git a/server/dive_server/crud_rpc.py b/server/dive_server/crud_rpc.py index d91292e4f..38bcc8583 100644 --- a/server/dive_server/crud_rpc.py +++ b/server/dive_server/crud_rpc.py @@ -19,7 +19,15 @@ from dive_server import crud, crud_annotation, crud_dataset from dive_tasks import tasks from dive_tasks.multicam_pipeline import is_stereo_or_multicam_pipeline, pipeline_requires_input -from dive_utils import TRUTHY_META_VALUES, asbool, constants, fromMeta, models, types +from dive_utils import ( + TRUTHY_META_VALUES, + asbool, + constants, + frame_metadata, + fromMeta, + models, + types, +) from dive_utils.constants import TrainingModelExtensions from dive_utils.serializers import dive, kpf, kwcoco, viame @@ -645,10 +653,18 @@ def process_items( unprocessed_items = Folder().childItems( folder, filters={ - "$or": [ - {"lowerName": {"$regex": constants.csvRegex}}, - {"lowerName": {"$regex": constants.jsonRegex}}, - {"lowerName": {"$regex": constants.ymlRegex}}, + "$and": [ + { + "$or": [ + {"lowerName": {"$regex": constants.csvRegex}}, + {"lowerName": {"$regex": constants.jsonRegex}}, + {"lowerName": {"$regex": constants.ymlRegex}}, + ] + }, + # Frame-metadata sidecars are marked processed but stay in the dataset + # folder; excluding them here keeps a left-in-place sidecar from being + # re-swept on a later postprocess. + {f'meta.{constants.ProcessedMarker}': {'$ne': True}}, ] }, # Processing order: oldest to newest @@ -664,6 +680,18 @@ def process_items( if file is None: raise RestException('Item had no associated files') + if frame_metadata.is_frame_metadata_source_name(file["name"]): + # Declared telemetry sidecar: keep it in the dataset folder for read-time + # discovery. Mark it processed so it is not re-swept, but never import it as + # annotations, move it, or remove it. + item['meta'][constants.ProcessedMarker] = True + Item().save(item) + aggregate_warnings.append( + f'{file["name"]} was stored as frame metadata, not annotations; ' + 'it stays in the dataset folder.' + ) + continue + try: image_map = None if fromMeta(folder, constants.TypeMarker) == 'image-sequence': diff --git a/server/dive_tasks/utils.py b/server/dive_tasks/utils.py index a19053a51..94671bd33 100644 --- a/server/dive_tasks/utils.py +++ b/server/dive_tasks/utils.py @@ -246,8 +246,8 @@ def upload_zipped_flat_media_files( root_folderId = folderId default_fps = gc.getFolder(root_folderId).get(f"meta.{constants.FPSMarker}", -1) if validation.get('ok', False): - manager.write(f"Annotations: {validation['annotations']}\n") - manager.write(f"Media: {validation['media']}\n") + manager.write(f"Annotations: {validation['roles']['annotations']}\n") + manager.write(f"Media: {validation['roles']['media']}\n") dataset_type = validation['type'] manager.write(f"Type: {dataset_type}\n") if create_subfolder != '': diff --git a/server/dive_utils/frame_metadata.py b/server/dive_utils/frame_metadata.py new file mode 100644 index 000000000..af90dc60d --- /dev/null +++ b/server/dive_utils/frame_metadata.py @@ -0,0 +1,15 @@ +"""Frame-metadata sidecar name predicate. + +DIVE reserves ``*.meta.csv`` / ``*.meta.txt`` (case-insensitive) as declared +frame-metadata sidecar files. This module is the Python mirror of the shared +TypeScript predicate; it classifies by name only and never parses telemetry. +""" + +import re + +FRAME_METADATA_SOURCE_NAME_RE = re.compile(r'\.meta\.(csv|txt)$', re.IGNORECASE) + + +def is_frame_metadata_source_name(name: str) -> bool: + """A telemetry sidecar is declared by name: it ends in ``.meta.csv``/``.meta.txt``.""" + return FRAME_METADATA_SOURCE_NAME_RE.search(name) is not None diff --git a/server/tests/test_process_items.py b/server/tests/test_process_items.py new file mode 100644 index 000000000..ef131e5eb --- /dev/null +++ b/server/tests/test_process_items.py @@ -0,0 +1,129 @@ +from unittest.mock import patch + +import pytest + +from dive_server.crud_rpc import process_items +from dive_utils import constants + +VIAME_HEADER = ( + '# 1: Detection or Track-id, 2: Video or Image Identifier, 3: Unique Frame Identifier, ' + '4-7: Img-bbox(TL_x,TL_y,BR_x,BR_y), 8: Detection or Length Confidence, ' + '9: Fish Length, 10-11+: Repeated Species' +) + + +def _viame_csv(filename='image_0001.jpg'): + """A minimal, well-formed VIAME annotation CSV with the DIVE comment header.""" + return f'{VIAME_HEADER}\n0,{filename},0,10,10,50,50,1.0,-1,fish,0.9\n' + + +def _download_side_effect(bytes_by_file_id): + def download(file, headers=False): + return lambda: [bytes_by_file_id[file['_id']]] + + return download + + +def _childfiles_side_effect(file_by_item_id): + def child_files(item): + return iter([file_by_item_id[item['_id']]]) + + return child_files + + +@pytest.mark.parametrize( + 'dataset_type', + [constants.VideoType, constants.LargeImageType, constants.ImageSequenceType], +) +@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_meta_csv_is_kept_in_place_for_every_media_type( + folder_cls, item_cls, file_cls, get_auxiliary_folder, save_annotations, dataset_type +): + folder = {'_id': 'ds', 'meta': {'type': dataset_type, 'fps': 5}} + item = {'_id': 'item-id', 'name': 'nav.meta.csv', 'meta': {}} + file = {'_id': 'file-id', 'name': 'nav.meta.csv', 'exts': ['meta', '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'}) + + # Declared by name: marked processed and left in the dataset folder, never imported as + # annotations, moved, or removed. The bytes are never even downloaded. + assert len(warnings) == 1 + assert 'nav.meta.csv' in warnings[0] + 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() + save_annotations.assert_not_called() + file_cls.return_value.download.assert_not_called() + + +@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_meta_csv_marked_processed_is_not_reswept( + folder_cls, item_cls, file_cls, get_auxiliary_folder +): + folder = {'_id': 'ds', 'meta': {'type': constants.ImageSequenceType, 'fps': 5}} + item = {'_id': 'item-id', 'name': 'navigation.meta.csv', 'meta': {}} + file = {'_id': 'file-id', 'name': 'navigation.meta.csv', 'exts': ['meta', 'csv']} + + # Emulate the ProcessedMarker "$ne: True" query filter: a marked sidecar is no longer + # listed, so a later process_items call never re-adjudicates it. + def child_items(_folder, filters=None, sort=None): + if item['meta'].get(constants.ProcessedMarker) is True: + return [] + return [item] + + folder_cls.return_value.childItems.side_effect = child_items + 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 item['meta'][constants.ProcessedMarker] is True + item_cls.return_value.save.assert_called_once_with(item) + + # Second pass: the marked sidecar is excluded, so it is not re-saved or re-warned. + second_warnings = process_items(folder, {'_id': 'user-id'}) + assert second_warnings == [] + item_cls.return_value.save.assert_called_once() + + +@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_plain_annotation_csv_still_imports( + folder_cls, item_cls, file_cls, get_auxiliary_folder, valid_images, save_annotations +): + # The keep-in-place guard must not intercept an ordinary annotation CSV: it is still + # moved to the auxiliary folder and its tracks saved. + folder = {'_id': 'ds', 'meta': {'type': constants.ImageSequenceType, 'fps': 5}} + item = {'_id': 'item-id', 'name': 'annotations.csv', 'meta': {}} + file = {'_id': 'file-id', 'name': 'annotations.csv', 'exts': ['csv']} + + folder_cls.return_value.childItems.return_value = [item] + item_cls.return_value.childFiles.side_effect = _childfiles_side_effect({'item-id': file}) + file_cls.return_value.download.side_effect = _download_side_effect( + {'file-id': _viame_csv().encode()} + ) + get_auxiliary_folder.return_value = {'_id': 'aux-id'} + valid_images.return_value = [{'name': 'image_0001.jpg'}, {'name': 'image_0002.jpg'}] + + warnings = process_items(folder, {'_id': 'user-id'}) + + assert warnings == [] + item_cls.return_value.move.assert_called_once_with(item, {'_id': 'aux-id'}) + save_annotations.assert_called_once() + # An imported annotation is not tagged as a kept-in-place sidecar. + item_cls.return_value.save.assert_not_called() diff --git a/server/tests/test_validate_files.py b/server/tests/test_validate_files.py new file mode 100644 index 000000000..78ab10543 --- /dev/null +++ b/server/tests/test_validate_files.py @@ -0,0 +1,176 @@ +from dive_server.crud_dataset import validate_files +from dive_utils import constants + +FRAME_METADATA_UNSUPPORTED_REASON = "Frame metadata is not supported for this media type" +UNSUPPORTED_SIDE_FILE_REASON = "Unsupported side file" + + +def _ignored_names(result): + return [entry["name"] for entry in result["ignored"]] + + +def _ignored_reason(result, name): + return next(entry["reason"] for entry in result["ignored"] if entry["name"] == name) + + +def test_image_sequence_with_annotation_csv_and_meta_csv(): + result = validate_files(['image_0001.jpg', 'tracks.csv', 'nav.meta.csv']) + + assert result['ok'] is True + assert result['type'] == constants.ImageSequenceType + assert 'tracks.csv' in result['roles']['annotations'] + assert 'nav.meta.csv' in result['roles']['frameMetadata'] + # The annotation CSV is not misclassified as a sidecar, and the sidecar is not an annotation. + assert 'nav.meta.csv' not in result['roles']['annotations'] + assert 'tracks.csv' not in result['roles']['frameMetadata'] + assert 'tracks.csv' in result['upload'] + assert 'nav.meta.csv' in result['upload'] + assert result['ignored'] == [] + + +def test_image_sequence_with_meta_csv_and_meta_txt(): + result = validate_files(['image_0001.jpg', 'nav.meta.csv', 'nav.meta.txt']) + + assert result['ok'] is True + assert result['type'] == constants.ImageSequenceType + assert result['roles']['frameMetadata'] == ['nav.meta.csv', 'nav.meta.txt'] + assert 'nav.meta.csv' in result['upload'] + assert 'nav.meta.txt' in result['upload'] + assert result['roles']['annotations'] == [] + assert result['ignored'] == [] + + +def test_image_sequence_with_yaml_annotation(): + result = validate_files(['image_0001.jpg', 'annotations.yml']) + + assert result['ok'] is True + assert result['type'] == constants.ImageSequenceType + assert 'annotations.yml' in result['roles']['annotations'] + assert 'annotations.yml' in result['upload'] + + +def test_image_sequence_with_plain_txt_is_ignored(): + result = validate_files(['image_0001.jpg', 'notes.txt']) + + assert result['ok'] is True + assert result['type'] == constants.ImageSequenceType + assert 'notes.txt' in _ignored_names(result) + assert _ignored_reason(result, 'notes.txt') == UNSUPPORTED_SIDE_FILE_REASON + assert 'notes.txt' not in result['upload'] + # The rest of the package is unaffected. + assert result['roles']['media'] == ['image_0001.jpg'] + + +def test_image_sequence_with_unsupported_extension_is_ignored(): + result = validate_files(['image_0001.jpg', 'weird.xyz']) + + assert result['ok'] is True + assert 'weird.xyz' in _ignored_names(result) + assert _ignored_reason(result, 'weird.xyz') == UNSUPPORTED_SIDE_FILE_REASON + assert 'weird.xyz' not in result['upload'] + + +def test_two_plain_annotation_csvs_are_rejected(): + result = validate_files(['image_0001.jpg', 'a.csv', 'b.csv']) + + assert result['ok'] is False + assert result['message'] == "Can only upload a single CSV Annotation per import" + + +def test_image_sequence_with_config_json_is_dataset_config(): + result = validate_files(['image_0001.jpg', 'meta.json']) + + assert result['ok'] is True + assert 'meta.json' in result['roles']['datasetConfig'] + assert 'meta.json' not in result['roles']['annotations'] + assert 'meta.json' in result['upload'] + + +def test_annotation_json_and_config_json_are_distinguished(): + result = validate_files(['image_0001.jpg', 'tracks.json', 'config.json']) + + assert result['ok'] is True + assert 'config.json' in result['roles']['datasetConfig'] + assert 'tracks.json' in result['roles']['annotations'] + assert 'config.json' not in result['roles']['annotations'] + + +def test_video_with_meta_csv_ignores_sidecar(): + result = validate_files(['movie.mp4', 'nav.meta.csv']) + + assert result['ok'] is True + assert result['type'] == constants.VideoType + assert result['roles']['frameMetadata'] == [] + assert 'nav.meta.csv' in _ignored_names(result) + assert _ignored_reason(result, 'nav.meta.csv') == FRAME_METADATA_UNSUPPORTED_REASON + assert 'nav.meta.csv' not in result['upload'] + + +def test_large_image_with_meta_csv_ignores_sidecar(): + result = validate_files(['mosaic.tif', 'nav.meta.csv']) + + assert result['ok'] is True + assert result['type'] == constants.LargeImageType + assert result['roles']['frameMetadata'] == [] + assert 'nav.meta.csv' in _ignored_names(result) + assert _ignored_reason(result, 'nav.meta.csv') == FRAME_METADATA_UNSUPPORTED_REASON + assert 'nav.meta.csv' not in result['upload'] + + +def test_upload_order_is_media_then_annotations_then_config_then_frame_metadata(): + result = validate_files(['image_0001.jpg', 'tracks.csv', 'meta.json', 'nav.meta.csv']) + + assert result['ok'] is True + assert result['upload'] == ['image_0001.jpg', 'tracks.csv', 'meta.json', 'nav.meta.csv'] + + +def test_images_and_videos_mixed_is_rejected(): + result = validate_files(['image_0001.jpg', 'movie.mp4']) + + assert result['ok'] is False + assert result['message'] == "Do not upload images and videos in the same batch." + + +def test_csv_and_yaml_mixed_is_rejected(): + result = validate_files(['image_0001.jpg', 'tracks.csv', 'config.yml']) + + assert result['ok'] is False + assert result['message'] == "Cannot mix annotation import types" + + +def test_csv_and_annotation_json_mixed_is_rejected(): + # Two annotation sources of different formats would silently overwrite at import. + result = validate_files(['image_0001.jpg', 'tracks.csv', 'tracks.json']) + + assert result['ok'] is False + assert result['message'] == "Cannot mix annotation import types" + + +def test_yaml_and_annotation_json_mixed_is_rejected(): + result = validate_files(['image_0001.jpg', 'tracks.yml', 'tracks.json']) + + assert result['ok'] is False + assert result['message'] == "Cannot mix annotation import types" + + +def test_multiple_videos_with_config_json_is_rejected(): + # A single dataset-config JSON cannot apply to a multi-video (subfolder) upload. + result = validate_files(['a.mp4', 'b.mp4', 'config.json']) + + assert result['ok'] is False + assert result['message'] == "Annotation upload is not supported when multiple videos are uploaded" + + +def test_multiple_videos_without_annotations_is_allowed(): + result = validate_files(['a.mp4', 'b.mp4']) + + assert result['ok'] is True + assert result['type'] == constants.VideoType + assert result['roles']['media'] == ['a.mp4', 'b.mp4'] + + +def test_no_media_is_rejected(): + result = validate_files(['tracks.csv']) + + assert result['ok'] is False + assert result['message'] == "No supported media-type files found" From 08ef80757db7e570db336d210e66478d93f3c2a2 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sat, 4 Jul 2026 18:23:12 -0400 Subject: [PATCH 02/35] Upload exactly the server-validated package from the browser The browser no longer pre-classifies a selection into media/annotation/config buckets and then uploads from those buckets regardless of validation. The full selection is sent to validate_files, and buildValidatedUploadPackage maps the server's authoritative upload list back to the original File objects. Pending uploads are read-only summaries showing role counts and ignored-with-reason files. Ordinary and multicam upload both send the validated package, so a selected .meta.csv (or any validated non-media file) is never silently dropped; web multicam surfaces ignored files before navigating. --- .../web-girder/api/dataset.service.ts | 22 +- .../web-girder/multicamFileRegistry.spec.ts | 141 +++++++- .../web-girder/multicamFileRegistry.ts | 27 ++ .../platform/web-girder/uploadPackage.spec.ts | 141 ++++++++ client/platform/web-girder/uploadPackage.ts | 65 ++++ client/platform/web-girder/views/Upload.vue | 319 +++++++----------- .../web-girder/views/UploadGirder.vue | 24 +- 7 files changed, 521 insertions(+), 218 deletions(-) create mode 100644 client/platform/web-girder/uploadPackage.spec.ts create mode 100644 client/platform/web-girder/uploadPackage.ts diff --git a/client/platform/web-girder/api/dataset.service.ts b/client/platform/web-girder/api/dataset.service.ts index c2503d493..ffb78f455 100644 --- a/client/platform/web-girder/api/dataset.service.ts +++ b/client/platform/web-girder/api/dataset.service.ts @@ -1,7 +1,7 @@ import type { GirderModel } from '@girder/components/src'; import { - DatasetMetaMutable, FrameImage, SaveAttributeArgs, SaveAttributeTrackFilterArgs, + DatasetMetaMutable, DatasetType, FrameImage, SaveAttributeArgs, SaveAttributeTrackFilterArgs, } from 'dive-common/apispec'; import { calibrationFileMarker, jsonCalibrationFileMarker } from 'dive-common/constants'; import { parentDatasetId } from 'dive-common/compositeDatasetId'; @@ -173,12 +173,26 @@ async function saveMetadata(datasetId: string, metadata: DatasetMetaMutable) { return girderRest.patch(`/dive_dataset/${folderId}`, metadata); } -interface ValidationResponse { - ok: boolean; - type: 'video' | 'image-sequence'; +export interface ValidatedUploadRoleMap { media: string[]; annotations: string[]; + datasetConfig: string[]; + frameMetadata: string[]; +} + +export interface IgnoredUploadFile { + name: string; + reason: string; +} + +export interface ValidationResponse { + ok: boolean; + // Empty string when validation fails (no single media type could be determined). + type: DatasetType | ''; message: string; + roles: ValidatedUploadRoleMap; + upload: string[]; + ignored: IgnoredUploadFile[]; } function validateUploadGroup(names: string[]) { diff --git a/client/platform/web-girder/multicamFileRegistry.spec.ts b/client/platform/web-girder/multicamFileRegistry.spec.ts index a535b4a3d..792d73666 100644 --- a/client/platform/web-girder/multicamFileRegistry.spec.ts +++ b/client/platform/web-girder/multicamFileRegistry.spec.ts @@ -6,10 +6,39 @@ import { import { clearMulticamFileRegistry, getCalibrationFile, + getCameraPackageFiles, + getExplicitAnnotationFiles, getLastCalibration, saveCalibration, + stashAnnotationFile, stashCalibrationFile, } from './multicamFileRegistry'; +import { buildValidatedUploadPackage } from './uploadPackage'; +import type { ValidationResponse } from './api/dataset.service'; + +function file(name: string): File { + return new File([name], name, { type: 'application/octet-stream' }); +} + +function fileWithPath(name: string, relPath: string): File { + const f = file(name); + Object.defineProperty(f, 'webkitRelativePath', { value: relPath, configurable: true }); + return f; +} + +function validation(overrides: Partial): ValidationResponse { + return { + ok: true, + type: 'image-sequence', + message: '', + roles: { + media: [], annotations: [], datasetConfig: [], frameMetadata: [], + }, + upload: [], + ignored: [], + ...overrides, + }; +} describe('multicamFileRegistry calibration', () => { const storage = new Map(); @@ -30,10 +59,10 @@ describe('multicamFileRegistry calibration', () => { }); it('resolves calibration by basename when stashed with a path-like key', () => { - const file = new File(['{}'], 'stereo-cal.json', { type: 'application/json' }); - stashCalibrationFile('folder/stereo-cal.json', file); - expect(getCalibrationFile('stereo-cal.json')).toBe(file); - expect(getCalibrationFile('folder/stereo-cal.json')).toBe(file); + const cal = new File(['{}'], 'stereo-cal.json', { type: 'application/json' }); + stashCalibrationFile('folder/stereo-cal.json', cal); + expect(getCalibrationFile('stereo-cal.json')).toBe(cal); + expect(getCalibrationFile('folder/stereo-cal.json')).toBe(cal); }); it('does not restore last calibration from localStorage without a session File', async () => { @@ -43,9 +72,109 @@ describe('multicamFileRegistry calibration', () => { }); it('restores last calibration when the File is still in the registry', async () => { - const file = new File(['{}'], 'cal.json', { type: 'application/json' }); - stashCalibrationFile('cal.json', file); + const cal = new File(['{}'], 'cal.json', { type: 'application/json' }); + stashCalibrationFile('cal.json', cal); await saveCalibration('cal.json'); await expect(getLastCalibration()).resolves.toBe('cal.json'); }); }); + +describe('multicam camera package construction', () => { + beforeEach(() => { + clearMulticamFileRegistry(); + }); + + it('flattens folder files before validation so names match uploaded names', () => { + const folderFiles = [ + fileWithPath('img001.png', 'cam1/img001.png'), + fileWithPath('img002.png', 'cam1/img002.png'), + ]; + const cameraFiles = getCameraPackageFiles(folderFiles); + // The server validates and Girder uploads flat basenames, not folder paths. + expect(cameraFiles.map((f) => f.name)).toEqual(['img001.png', 'img002.png']); + expect( + cameraFiles.every((f) => !f.webkitRelativePath || f.webkitRelativePath === f.name), + ).toBe(true); + }); + + it('getExplicitAnnotationFiles returns [] without a key or stashed file', () => { + expect(getExplicitAnnotationFiles(undefined)).toEqual([]); + expect(getExplicitAnnotationFiles('cam1/missing.csv')).toEqual([]); + }); + + it('appends the explicit track file to the validation input', () => { + const folderFiles = [file('img001.png')]; + const track = file('tracks.csv'); + stashAnnotationFile('cam1/tracks.csv', track); + const cameraFiles = getCameraPackageFiles(folderFiles, 'cam1/tracks.csv'); + expect(cameraFiles.map((f) => f.name)).toEqual(['img001.png', 'tracks.csv']); + expect(cameraFiles).toContain(track); + }); + + it('deduplicates the explicit track file by name', () => { + const track = file('tracks.csv'); + stashAnnotationFile('cam1/tracks.csv', track); + // The same File object is also part of the camera folder selection. + const folderFiles = [file('img001.png'), track]; + const cameraFiles = getCameraPackageFiles(folderFiles, 'cam1/tracks.csv'); + expect(cameraFiles.map((f) => f.name)).toEqual(['img001.png', 'tracks.csv']); + expect(cameraFiles.filter((f) => f.name === 'tracks.csv')).toHaveLength(1); + }); + + it('dedupes an explicit track file that shares a name with a folder file (distinct object)', () => { + // Realistic case: the explicit picker yields a DISTINCT File object from the + // same-named file already in the folder selection. Dedup must still send one name. + const explicitTrack = file('tracks.csv'); + stashAnnotationFile('cam1/tracks.csv', explicitTrack); + const folderTrack = file('tracks.csv'); + const folderFiles = [file('img001.png'), folderTrack]; + const cameraFiles = getCameraPackageFiles(folderFiles, 'cam1/tracks.csv'); + expect(cameraFiles.map((f) => f.name)).toEqual(['img001.png', 'tracks.csv']); + // The folder copy wins; the explicit distinct object is not appended. + expect(cameraFiles).toContain(folderTrack); + expect(cameraFiles).not.toContain(explicitTrack); + }); + + it('uploads validated camera-folder annotations and sidecars, not just media', () => { + // tracks.csv and nav.meta.csv are auto-detected in the camera folder; both + // must ride along with the camera, which the old media-only path dropped. + const folderFiles = [file('img001.png'), file('tracks.csv'), file('nav.meta.csv')]; + const cameraFiles = getCameraPackageFiles(folderFiles); + const resp = validation({ + roles: { + media: ['img001.png'], + annotations: ['tracks.csv'], + datasetConfig: [], + frameMetadata: ['nav.meta.csv'], + }, + upload: ['img001.png', 'tracks.csv', 'nav.meta.csv'], + }); + const { uploadFiles } = buildValidatedUploadPackage(cameraFiles, resp); + expect(uploadFiles.map((f) => f.name)).toEqual(['img001.png', 'tracks.csv', 'nav.meta.csv']); + }); + + it('uploads the explicit track file only when validation accepts it', () => { + const folderFiles = [file('img001.png')]; + const track = file('tracks.csv'); + stashAnnotationFile('cam1/tracks.csv', track); + const cameraFiles = getCameraPackageFiles(folderFiles, 'cam1/tracks.csv'); + + // Accepted: the server lists the track file in upload. + const accepted = validation({ + roles: { + media: ['img001.png'], annotations: ['tracks.csv'], datasetConfig: [], frameMetadata: [], + }, + upload: ['img001.png', 'tracks.csv'], + }); + expect(buildValidatedUploadPackage(cameraFiles, accepted).uploadFiles).toContain(track); + + // Omitted by validation: the track file is present in the input but not uploaded. + const omitted = validation({ + roles: { + media: ['img001.png'], annotations: [], datasetConfig: [], frameMetadata: [], + }, + upload: ['img001.png'], + }); + expect(buildValidatedUploadPackage(cameraFiles, omitted).uploadFiles).not.toContain(track); + }); +}); diff --git a/client/platform/web-girder/multicamFileRegistry.ts b/client/platform/web-girder/multicamFileRegistry.ts index 3fb568501..c9e11fefc 100644 --- a/client/platform/web-girder/multicamFileRegistry.ts +++ b/client/platform/web-girder/multicamFileRegistry.ts @@ -99,6 +99,33 @@ export function getAnnotationFile(key: string): File | undefined { return annotationFilesByKey.get(key); } +/** + * Explicit annotation File objects chosen for a camera, as an array (0 or 1 + * entries), suitable for concatenating into a camera upload package. + */ +export function getExplicitAnnotationFiles(key?: string): File[] { + const file = key ? getAnnotationFile(key) : undefined; + return file ? [file] : []; +} + +/** + * Assemble the complete file package for one multicam camera: the camera folder's + * files plus any explicit annotation file chosen for that camera. The explicit + * annotation file is deduplicated by name, so a file that is both present in the + * folder selection and picked explicitly (a distinct File object from a separate + * picker) is only sent once — otherwise the server would validate a duplicate name. + * + * `flattenUploadFiles` is applied here, before validation, so the names sent to + * the server for validation match the names uploaded to Girder. + */ +export function getCameraPackageFiles(folderFiles: File[], annotationKey?: string): File[] { + const flattened = flattenUploadFiles(folderFiles); + const flattenedNames = new Set(flattened.map((file) => file.name)); + const explicit = getExplicitAnnotationFiles(annotationKey) + .filter((file) => !flattenedNames.has(file.name)); + return [...flattened, ...explicit]; +} + function calibrationLookupKeys(key: string): string[] { const keys = new Set(); if (key) { diff --git a/client/platform/web-girder/uploadPackage.spec.ts b/client/platform/web-girder/uploadPackage.spec.ts new file mode 100644 index 000000000..7712fa774 --- /dev/null +++ b/client/platform/web-girder/uploadPackage.spec.ts @@ -0,0 +1,141 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- Vitest is only used in tests +import { describe, expect, it } from 'vitest'; + +import { buildValidatedUploadPackage } from './uploadPackage'; +import type { ValidationResponse } from './api/dataset.service'; + +function file(name: string): File { + return new File([name], name, { type: 'application/octet-stream' }); +} + +function validation(overrides: Partial): ValidationResponse { + return { + ok: true, + type: 'image-sequence', + message: '', + roles: { + media: [], + annotations: [], + datasetConfig: [], + frameMetadata: [], + }, + upload: [], + ignored: [], + ...overrides, + }; +} + +describe('buildValidatedUploadPackage', () => { + it('keeps both an annotation CSV and a frame-metadata sidecar in uploadFiles', () => { + const selected = [file('img001.png'), file('annotations.csv'), file('nav.meta.csv')]; + const resp = validation({ + roles: { + media: ['img001.png'], + annotations: ['annotations.csv'], + datasetConfig: [], + frameMetadata: ['nav.meta.csv'], + }, + upload: ['img001.png', 'annotations.csv', 'nav.meta.csv'], + }); + + const { uploadFiles, roles, ignored } = buildValidatedUploadPackage(selected, resp); + + expect(uploadFiles.map((f) => f.name)).toEqual([ + 'img001.png', + 'annotations.csv', + 'nav.meta.csv', + ]); + // The original File objects are preserved. + expect(uploadFiles).toEqual([selected[0], selected[1], selected[2]]); + expect(roles).toBe(resp.roles); + expect(ignored).toBe(resp.ignored); + }); + + it('excludes ignored unsupported files from uploadFiles', () => { + const selected = [file('img001.png'), file('notes.txt')]; + const resp = validation({ + roles: { + media: ['img001.png'], + annotations: [], + datasetConfig: [], + frameMetadata: [], + }, + upload: ['img001.png'], + ignored: [{ name: 'notes.txt', reason: 'Unsupported side file' }], + }); + + const { uploadFiles, ignored } = buildValidatedUploadPackage(selected, resp); + + expect(uploadFiles.map((f) => f.name)).toEqual(['img001.png']); + expect(uploadFiles.some((f) => f.name === 'notes.txt')).toBe(false); + expect(ignored).toEqual([{ name: 'notes.txt', reason: 'Unsupported side file' }]); + }); + + it('does not upload files that validation omitted from the upload list', () => { + const selected = [file('img001.png'), file('img002.png'), file('extra.csv')]; + const resp = validation({ + roles: { + media: ['img001.png', 'img002.png'], + annotations: [], + datasetConfig: [], + frameMetadata: [], + }, + // extra.csv is neither uploaded nor ignored: it simply is not consumed. + upload: ['img001.png', 'img002.png'], + }); + + const { uploadFiles } = buildValidatedUploadPackage(selected, resp); + + expect(uploadFiles.map((f) => f.name)).toEqual(['img001.png', 'img002.png']); + expect(uploadFiles.some((f) => f.name === 'extra.csv')).toBe(false); + }); + + it('preserves duplicate names only as many times as validation lists them', () => { + const first = file('dup.png'); + const second = file('dup.png'); + const selected = [first, second]; + const resp = validation({ + roles: { + media: ['dup.png', 'dup.png'], + annotations: [], + datasetConfig: [], + frameMetadata: [], + }, + upload: ['dup.png', 'dup.png'], + }); + + const { uploadFiles } = buildValidatedUploadPackage(selected, resp); + + expect(uploadFiles).toEqual([first, second]); + }); + + it('throws when validation references a file that was not selected', () => { + const selected = [file('img001.png')]; + const resp = validation({ + roles: { + media: ['img001.png'], + annotations: ['ghost.csv'], + datasetConfig: [], + frameMetadata: [], + }, + upload: ['img001.png', 'ghost.csv'], + }); + + expect(() => buildValidatedUploadPackage(selected, resp)).toThrow(/ghost\.csv/); + }); + + it('throws when validation lists a name more times than it was selected', () => { + const selected = [file('img001.png')]; + const resp = validation({ + roles: { + media: ['img001.png', 'img001.png'], + annotations: [], + datasetConfig: [], + frameMetadata: [], + }, + upload: ['img001.png', 'img001.png'], + }); + + expect(() => buildValidatedUploadPackage(selected, resp)).toThrow(/img001\.png/); + }); +}); diff --git a/client/platform/web-girder/uploadPackage.ts b/client/platform/web-girder/uploadPackage.ts new file mode 100644 index 000000000..60a860d9d --- /dev/null +++ b/client/platform/web-girder/uploadPackage.ts @@ -0,0 +1,65 @@ +/** + * Pure browser-side helper that turns a validated upload response into the exact + * list of File objects to send to Girder. + * + * The server validation response is the single authority for which files upload. + * This helper never re-classifies files: it only maps the server's `upload` name + * list back onto the user's original File objects, preserving order and duplicate + * handling, and passes the role map and ignored list straight through. + */ + +import type { + IgnoredUploadFile, + ValidatedUploadRoleMap, + ValidationResponse, +} from './api/dataset.service'; + +export interface ValidatedUploadPackage { + uploadFiles: File[]; + roles: ValidatedUploadRoleMap; + ignored: IgnoredUploadFile[]; +} + +/** + * Build the validated upload package from the user's selection and the server + * validation response. + * + * - `uploadFiles` is drawn from `validation.upload`, in order, mapped back to the + * original File objects. A name listed more times than it was selected, or a + * name that was never selected, throws a client Error. + * - Files the server placed in `ignored` are never in `validation.upload`, so they + * are naturally excluded from `uploadFiles`. + * - `roles` and `ignored` are passed through unchanged. + */ +export function buildValidatedUploadPackage( + selectedFiles: File[], + validation: ValidationResponse, +): ValidatedUploadPackage { + const filesByName = new Map(); + selectedFiles.forEach((file) => { + const existing = filesByName.get(file.name); + if (existing) { + existing.push(file); + } else { + filesByName.set(file.name, [file]); + } + }); + + const uploadFiles = validation.upload.map((name) => { + // Consume selected files in order so duplicate names are preserved only as + // many times as validation lists them. + const file = filesByName.get(name)?.shift(); + if (!file) { + throw new Error( + `Upload validation referenced "${name}", which is not among the selected files.`, + ); + } + return file; + }); + + return { + uploadFiles, + roles: validation.roles, + ignored: validation.ignored, + }; +} diff --git a/client/platform/web-girder/views/Upload.vue b/client/platform/web-girder/views/Upload.vue index 98b734234..07819dea5 100644 --- a/client/platform/web-girder/views/Upload.vue +++ b/client/platform/web-girder/views/Upload.vue @@ -5,9 +5,7 @@ import { import { useRouter } from 'vue-router/composables'; import { - ImageSequenceType, VideoType, DefaultVideoFPS, FPSOptions, - inputAnnotationFileTypes, websafeVideoTypes, otherVideoTypes, - websafeImageTypes, otherImageTypes, JsonMetaRegEx, getLargeImageFileAccept, LargeImageType, + ImageSequenceType, VideoType, DefaultVideoFPS, FPSOptions, LargeImageType, } from 'dive-common/constants'; import { @@ -27,12 +25,16 @@ import { validateUploadGroup, waitForFolderDatasetReady, } from 'platform/web-girder/api'; +import type { + IgnoredUploadFile, + ValidatedUploadRoleMap, +} from 'platform/web-girder/api'; +import { buildValidatedUploadPackage } from 'platform/web-girder/uploadPackage'; import { clearMulticamFileRegistry, - getAnnotationFile, getCalibrationFile, + getCameraPackageFiles, getFilesForSourceKey, - flattenUploadFiles, removeCameraFolderFiles, renameCameraFolderFiles, stashCameraFolderFiles, @@ -63,15 +65,19 @@ export interface PendingUpload { createSubFolders: boolean; name: string; files: InteralFiles[]; - meta: null | File; - annotationFile: null | File; - mediaList: File[]; + uploadFiles: File[]; + roles: ValidatedUploadRoleMap; + ignored: IgnoredUploadFile[]; type: DatasetType | 'zip'; fps: number; uploading: boolean; skipTranscoding?: boolean; } +const emptyRoleMap = (): ValidatedUploadRoleMap => ({ + media: [], annotations: [], datasetConfig: [], frameMetadata: [], +}); + interface GirderUpload { formatSize: (a: number) => string; totalProgress: number; @@ -81,10 +87,9 @@ interface GirderUpload { name: string; fps: number; type: DatasetType; - mediaList: File[]; - meta?: File | null; - annotationFile?: File | null; + uploadFiles: File[]; skipTranscoding?: boolean; + parentFolderId?: string; }) => Promise<{ folder: { _id: string }; jobIds: string[] }>; } @@ -174,9 +179,9 @@ export default defineComponent({ createSubFolders: false, name: defaultFilename, files: [], //Will be set in the GirderUpload Component - meta: null, - annotationFile: null, - mediaList: allFiles, + uploadFiles: allFiles, + roles: emptyRoleMap(), + ignored: [], type: 'zip', fps, uploading: false, @@ -184,135 +189,62 @@ export default defineComponent({ }; const addPendingUpload = async ( - name: string, allFiles: File[], - meta: File | null, - annotationFile: File | null, - mediaList: File[], suggestedFps?: number, // suggested FPS for large/images ) => { - const resp = (await validateUploadGroup(allFiles.map((f) => f.name))).data; - if (!resp.ok) { - if (resp.message) { - preUploadErrorMessage.value = resp.message; + const validation = (await validateUploadGroup(allFiles.map((f) => f.name))).data; + if (!validation.ok) { + // Block: surface the reason and do not create an uploadable pending row. + if (validation.message) { + preUploadErrorMessage.value = validation.message; } - throw new Error(resp.message); + throw new Error(validation.message || 'Upload validation failed'); } + // Server validation is the single authority for what uploads; the browser + // never re-classifies. uploadFiles is the original File objects, in the + // server's upload order. + const { uploadFiles, roles, ignored } = buildValidatedUploadPackage(allFiles, validation); const fps = suggestedFps || clientSettings.annotationFPS || DefaultVideoFPS; - const defaultFilename = resp.media[0]; - const validFiles = resp.media.concat(resp.annotations); - // mapping needs to be done for the mixin upload functions - const internalFiles = allFiles - .filter((f) => validFiles.includes(f.name)); - let createSubFolders = false; - if (resp.type === 'video') { - if (resp.media.length > 1) { - createSubFolders = true; - } - } + const defaultFilename = roles.media[0] ?? uploadFiles[0]?.name ?? ''; + // Only a multi-video upload fans out into per-video subfolders. + const createSubFolders = validation.type === VideoType && roles.media.length > 1; pendingUploads.value.push({ createSubFolders, name: - internalFiles.length > 1 + uploadFiles.length > 1 ? defaultFilename.replace(fileSuffixRegex, '') : defaultFilename, files: [], //Will be set in the GirderUpload Component - meta, - annotationFile, - mediaList, - type: resp.type, + uploadFiles, + roles, + ignored, + type: validation.type, fps, uploading: false, skipTranscoding: true, }); }; /** - * Processes the imported media files to distinguish between - * Media Files - Default files that aren't the Annotation or Meta - * Annotation File - CSV or JSON file, or more in the future - * Meta File - Right now a json file which has 'meta' or 'config in the name - */ - const processImport = (files: { - canceled: boolean; filePaths: string[]; fileList?: File[]; - }) => { - //Check for auto files for meta and annotations - const output: { - annotationFile: null | File; - metaFile: null | File; - mediaList: File[]; - fullList: File[]; - } = { - annotationFile: null, - metaFile: null, - mediaList: [], - fullList: [], - }; - const jsonFiles: [string, number][] = []; - const csvFiles: [string, number][] = []; - if (files.fileList) { - files.filePaths.forEach((item, index) => { - if (item.indexOf('.json') !== -1) { - jsonFiles.push([item, index]); - } else if (item.indexOf('.csv') !== -1) { - csvFiles.push([item, index]); - } - }); - output.mediaList = files.fileList.filter((item) => ( - item.name.indexOf('.json') === -1 && item.name.indexOf('.csv') === -1)); - const metaIndex = jsonFiles.findIndex((item) => (JsonMetaRegEx.test(item[0]))); - if (metaIndex !== -1) { - output.metaFile = files.fileList[jsonFiles[metaIndex][1]]; - jsonFiles.splice(metaIndex, 1); //remove chosen meta from list - } - if (jsonFiles.length === 1 && csvFiles.length === 0) { // only remaining json file - output.annotationFile = files.fileList[jsonFiles[0][1]]; - } else if (csvFiles.length) { // Prefer First CSV if both found - output.annotationFile = files.fileList[csvFiles[0][1]]; - } else if (jsonFiles.length > 1) { //multiple jsons, filter out additional meta/configs - const filtered = jsonFiles.filter((item) => (!JsonMetaRegEx.test(item[0]) && (item[0].indexOf('.json') !== -1))); - if (filtered.length) { // take first filtered JSON file - output.annotationFile = files.fileList[filtered[0][1]]; - } - } - output.fullList = [...output.mediaList]; - if (output.annotationFile) { - output.fullList.push(output.annotationFile); - } - if (output.metaFile) { - output.fullList.push(output.metaFile); - } - } - return output; - }; - /** - * Initial opening of file dialog + * Initial opening of file dialog. The complete selection is sent to server + * validation; the browser no longer pre-classifies files into buckets. */ const openImport = async (dstype: DatasetType | 'zip') => { const ret = await openFromDisk(dstype); - if (!ret.canceled && ret.fileList) { - const processed = processImport(ret); - if (processed?.fullList?.length === 0) return; - if (processed && processed.fullList) { - const name = processed.fullList.length === 1 ? processed.fullList[0].name : ''; - preUploadErrorMessage.value = null; - try { - if (dstype !== 'zip') { - const suggestedFps = dstype === 'image-sequence' || dstype === 'large-image' ? 1 : undefined; - await addPendingUpload( - name, - processed.fullList, - processed.metaFile, - processed.annotationFile, - processed.mediaList, - suggestedFps, - ); - } else { - addPendingZipUpload(name, processed.fullList); - } - } catch (err) { - preUploadErrorMessage.value = err.response?.data?.message || err; - } + if (ret.canceled || !ret.fileList || ret.fileList.length === 0) { + return; + } + const allFiles = ret.fileList; + preUploadErrorMessage.value = null; + try { + if (dstype === 'zip') { + const name = allFiles.length === 1 ? allFiles[0].name : ''; + addPendingZipUpload(name, allFiles); + } else { + const suggestedFps = dstype === 'image-sequence' || dstype === 'large-image' ? 1 : undefined; + await addPendingUpload(allFiles, suggestedFps); } + } catch (err) { + preUploadErrorMessage.value = err.response?.data?.message || err; } }; const openMultiCamDialog = (args: { stereo: boolean; openType: 'image-sequence' | 'video' }) => { @@ -320,19 +252,6 @@ export default defineComponent({ multiCamOpenType.value = args.openType; importMultiCamDialog.value = true; }; - const filterFileUpload = (type: DatasetType | 'meta' | 'annotation') => { - if (type === 'meta') { - return '.json'; - } if (type === 'annotation') { - return inputAnnotationFileTypes.map((item) => `.${item}`).join(','); - } if (type === 'video') { - return websafeVideoTypes.concat(otherVideoTypes); - } if (type === 'large-image') { - return getLargeImageFileAccept(); - } - return websafeImageTypes.concat(otherImageTypes); - }; - const multiCamImportCheck = (sourcePath: string): MediaImportResponse => { const files = getFilesForSourceKey(sourcePath) ?? []; return { @@ -404,33 +323,42 @@ export default defineComponent({ .filter((name) => args.sourceList[name]) .map((name) => [name, args.sourceList[name]] as const); const totalCameras = cameraEntries.length; + // Collect files the server accepted-but-ignored per camera so they can be + // surfaced before navigation — no selected file is silently dropped. + const ignoredAcrossCameras: { camera: string; name: string; reason: string }[] = []; for (let i = 0; i < cameraEntries.length; i += 1) { const [cameraName, source] = cameraEntries[i]; - const files = flattenUploadFiles(getFilesForSourceKey(source.sourcePath) ?? []); - if (!files?.length) { + const folderFiles = getFilesForSourceKey(source.sourcePath) ?? []; + if (!folderFiles.length) { throw new Error(`No media files found for camera "${cameraName}"`); } + // Complete camera package: folder files plus the explicit track file + // (deduped by File identity), flattened before validation so the names + // the server validates match the names uploaded to Girder. + const cameraFiles = getCameraPackageFiles(folderFiles, source.trackFile); // eslint-disable-next-line no-await-in-loop -- validate then upload each camera sequentially - const validation = (await validateUploadGroup(files.map((f) => f.name))).data; + const validation = (await validateUploadGroup(cameraFiles.map((f) => f.name))).data; if (!validation.ok) { throw new Error(validation.message || `Invalid files for camera "${cameraName}"`); } if (validation.type !== args.type) { throw new Error(`Camera "${cameraName}" must use ${args.type} media`); } - const mediaList = files.filter((file) => validation.media.includes(file.name)); - const annotationFile = source.trackFile - ? getAnnotationFile(source.trackFile) - : undefined; + // Server validation is the authority: upload exactly what it accepted, + // which includes validated camera-folder sidecars and annotation/config + // files, not just validation.roles.media. + const { uploadFiles } = buildValidatedUploadPackage(cameraFiles, validation); + validation.ignored.forEach((entry) => ignoredAcrossCameras.push({ + camera: cameraName, name: entry.name, reason: entry.reason, + })); trackMulticamCameraUploadProgress(i, totalCameras, cameraName); // eslint-disable-next-line no-await-in-loop const { folder, jobIds } = await uploadComponent.uploadCameraDataset({ name: cameraName, fps, type: args.type, - mediaList, - annotationFile: annotationFile ?? null, + uploadFiles, skipTranscoding: true, parentFolderId: datasetFolder._id, }); @@ -495,6 +423,19 @@ export default defineComponent({ }); multicamLinked = true; + if (ignoredAcrossCameras.length) { + await prompt({ + title: 'Some files were not uploaded', + text: [ + 'These selected files were not needed for the dataset and were left out:', + ...ignoredAcrossCameras.map( + (entry) => `${entry.camera}: ${entry.name} — ${entry.reason}`, + ), + ], + positiveButton: 'OK', + }); + } + setMulticamImportProgress(100, 'Opening viewer…'); clearMulticamFileRegistry(); await router.push({ name: 'viewer', params: { id: parentFolder._id } }); @@ -554,6 +495,21 @@ export default defineComponent({ const getFilenameInputValue = (pendingUpload: PendingUpload) => ( pendingUpload.createSubFolders && pendingUpload.type !== 'zip' ? 'default' : pendingUpload.name ); + /** Summary lines describing what the validated package will upload (D2). */ + const roleSummaryLines = (pendingUpload: PendingUpload): string[] => { + const { roles } = pendingUpload; + const lines: string[] = []; + const pushLine = (count: number, noun: string) => { + if (count > 0) { + lines.push(`${count} ${noun}${count === 1 ? '' : 's'}`); + } + }; + pushLine(roles.media.length, 'media file'); + pushLine(roles.annotations.length, 'annotation file'); + pushLine(roles.datasetConfig.length, 'configuration file'); + pushLine(roles.frameMetadata.length, 'frame metadata file'); + return lines; + }; const remove = (pendingUpload: PendingUpload) => { const index = pendingUploads.value.indexOf(pendingUpload); pendingUploads.value.splice(index, 1); @@ -607,9 +563,7 @@ export default defineComponent({ //methods close, openImport, - processImport, openMultiCamDialog, - filterFileUpload, multiCamImportCheck, multiCamImport, registerSubfolderCameras, @@ -620,6 +574,7 @@ export default defineComponent({ getFilenameInputValue, getFilenameInputStateDisabled, getFilenameInputStateHint, + roleSummaryLines, addPendingUpload, remove, abort, @@ -736,9 +691,9 @@ export default defineComponent({ type="number" required label="FPS" - :append-icon="pendingUpload.annotationFile + :append-icon="pendingUpload.roles.annotations.length ? 'mdi-alert' : 'mdi-chevron-down'" - :hint="pendingUpload.annotationFile + :hint="pendingUpload.roles.annotations.length ? 'should match annotation fps' : 'annotation fps'" persistent-hint @change="clientSettings.annotationFPS = $event" @@ -758,54 +713,30 @@ export default defineComponent({ - + - - - - - - - - - +
+ {{ line }} +
+
+
+ Ignored (not uploaded): +
+
+ {{ ignoredFile.name }} ({{ ignoredFile.reason }}) +
+
diff --git a/client/platform/web-girder/views/UploadGirder.vue b/client/platform/web-girder/views/UploadGirder.vue index 9a48d0ea3..0f02374ec 100644 --- a/client/platform/web-girder/views/UploadGirder.vue +++ b/client/platform/web-girder/views/UploadGirder.vue @@ -89,14 +89,11 @@ export default Vue.extend({ }; }, async uploadPending(pendingUpload, uploaded) { - const { - name, createSubFolders, meta, annotationFile, mediaList, - } = pendingUpload; - //Combine the files for uploading - let files = mediaList.map((item) => this.convertFileToInternal(item)); - files.push(this.convertFileToInternal(meta)); - files.push(this.convertFileToInternal(annotationFile)); - files = files.filter((item) => item !== null); + const { name, createSubFolders, uploadFiles } = pendingUpload; + // The validated package is the only source of files to upload. + const files = uploadFiles + .map((item) => this.convertFileToInternal(item)) + .filter((item) => item !== null); // eslint-disable-next-line no-param-reassign pendingUpload.files = files; const fps = parseInt(pendingUpload.fps, 10); @@ -173,13 +170,12 @@ export default Vue.extend({ * Upload a single camera dataset folder (used by multicam import). */ async uploadCameraDataset({ - name, fps, type, mediaList, meta = null, annotationFile = null, skipTranscoding = true, - parentFolderId = null, + name, fps, type, uploadFiles, skipTranscoding = true, parentFolderId = null, }) { - let files = mediaList.map((item) => this.convertFileToInternal(item)); - files.push(this.convertFileToInternal(meta)); - files.push(this.convertFileToInternal(annotationFile)); - files = files.filter((item) => item !== null); + // The validated package is the only source of files to upload for the camera. + const files = uploadFiles + .map((item) => this.convertFileToInternal(item)) + .filter((item) => item !== null); const folder = await this.createUploadFolder(name, parseInt(fps, 10), type, parentFolderId); if (!folder) { throw new Error(`Failed to create folder for camera ${name}`); From 49558882558e25d88445c04a560059be7d618401 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sat, 4 Jul 2026 19:53:34 -0400 Subject: [PATCH 03/35] Add shared frame-metadata parser, resolver, and conformance corpus One TypeScript implementation in client/dive-common/frameMetadata parses a telemetry sidecar (a hand-rolled delimiter tokenizer, browser-safe), and resolveCameras joins rows to frames by filename value-match with a first-wins per-camera merge and an explicit per-camera column order. apispec.ts carries the resolved-payload contract (ResolvedFrameMetadata, the sources listing response, SingleCameraFrameMetadataKey) consumed by both platforms. The desktop-only serializer parser is relocated here; the in-repo conformance corpus is its regression suite. --- client/dive-common/apispec.ts | 50 ++ .../components/DatasetInfo.spec.ts | 256 +++++++++ client/dive-common/components/DatasetInfo.vue | 347 +++++++---- client/dive-common/constants.ts | 11 + client/dive-common/frameMetadata/naming.ts | 15 + .../dive-common/frameMetadata/parser.spec.ts | 539 ++++++++++++++++++ client/dive-common/frameMetadata/parser.ts | 432 ++++++++++++++ .../dive-common/frameMetadata/resolve.spec.ts | 137 +++++ client/dive-common/frameMetadata/resolve.ts | 115 ++++ docs/DataFormats.md | 74 ++- docs/Frame-Metadata-Architecture.md | 177 ++++++ docs/UI-DatasetInfo.md | 82 ++- mkdocs.yml | 1 + .../all_comments.expected.json | 7 + .../all_comments.txt | 3 + testdata/frame-metadata-conformance/basic.csv | 3 + .../basic.expected.json | 7 + .../bom_telemetry.csv | 2 + .../bom_telemetry.expected.json | 6 + .../comment_block.csv | 3 + .../comment_block.expected.json | 6 + .../comment_comma_over_tsv.expected.json | 12 + .../comment_comma_over_tsv.txt | 3 + .../comment_prose_header.csv | 3 + .../comment_prose_header.expected.json | 12 + .../frame-metadata-conformance/double_ext.csv | 2 + .../double_ext.expected.json | 6 + .../frame-metadata-conformance/dup_rows.csv | 3 + .../dup_rows.expected.json | 6 + .../hash_header.csv | 2 + .../hash_header.expected.json | 6 + .../huge_field.expected.json | 4 + .../frame-metadata-conformance/huge_field.txt | 2 + .../leading_empty_row.csv | 3 + .../leading_empty_row.expected.json | 12 + .../nav_whitespace.meta.expected.json | 6 + .../nav_whitespace.meta.txt | 2 + .../frame-metadata-conformance/no_join.csv | 2 + .../no_join.expected.json | 4 + .../nul_bytes.expected.json | 4 + .../frame-metadata-conformance/nul_bytes.txt | Bin 0 -> 29 bytes .../numeric_collision.csv | 3 + .../numeric_collision.expected.json | 66 +++ .../pandas_index.csv | 3 + .../pandas_index.expected.json | 7 + testdata/frame-metadata-conformance/proto.csv | 2 + .../proto.expected.json | 6 + .../source_names.expected.json | 11 + .../tab_comma_units.expected.json | 10 + .../tab_comma_units.txt | 2 + .../trailing_comma.csv | 2 + .../trailing_comma.expected.json | 6 + .../frame-metadata-conformance/two_column.csv | 3 + .../two_column.expected.json | 7 + .../viame_annotations.csv | 2 + .../viame_annotations.expected.json | 18 + .../frame-metadata-conformance/wide_row.csv | 2 + .../wide_row.expected.json | 31 + .../windows_paths.csv | 2 + .../windows_paths.expected.json | 6 + 60 files changed, 2422 insertions(+), 134 deletions(-) create mode 100644 client/dive-common/components/DatasetInfo.spec.ts create mode 100644 client/dive-common/frameMetadata/naming.ts create mode 100644 client/dive-common/frameMetadata/parser.spec.ts create mode 100644 client/dive-common/frameMetadata/parser.ts create mode 100644 client/dive-common/frameMetadata/resolve.spec.ts create mode 100644 client/dive-common/frameMetadata/resolve.ts create mode 100644 docs/Frame-Metadata-Architecture.md create mode 100644 testdata/frame-metadata-conformance/all_comments.expected.json create mode 100644 testdata/frame-metadata-conformance/all_comments.txt create mode 100644 testdata/frame-metadata-conformance/basic.csv create mode 100644 testdata/frame-metadata-conformance/basic.expected.json create mode 100644 testdata/frame-metadata-conformance/bom_telemetry.csv create mode 100644 testdata/frame-metadata-conformance/bom_telemetry.expected.json create mode 100644 testdata/frame-metadata-conformance/comment_block.csv create mode 100644 testdata/frame-metadata-conformance/comment_block.expected.json create mode 100644 testdata/frame-metadata-conformance/comment_comma_over_tsv.expected.json create mode 100644 testdata/frame-metadata-conformance/comment_comma_over_tsv.txt create mode 100644 testdata/frame-metadata-conformance/comment_prose_header.csv create mode 100644 testdata/frame-metadata-conformance/comment_prose_header.expected.json create mode 100644 testdata/frame-metadata-conformance/double_ext.csv create mode 100644 testdata/frame-metadata-conformance/double_ext.expected.json create mode 100644 testdata/frame-metadata-conformance/dup_rows.csv create mode 100644 testdata/frame-metadata-conformance/dup_rows.expected.json create mode 100644 testdata/frame-metadata-conformance/hash_header.csv create mode 100644 testdata/frame-metadata-conformance/hash_header.expected.json create mode 100644 testdata/frame-metadata-conformance/huge_field.expected.json create mode 100644 testdata/frame-metadata-conformance/huge_field.txt create mode 100644 testdata/frame-metadata-conformance/leading_empty_row.csv create mode 100644 testdata/frame-metadata-conformance/leading_empty_row.expected.json create mode 100644 testdata/frame-metadata-conformance/nav_whitespace.meta.expected.json create mode 100644 testdata/frame-metadata-conformance/nav_whitespace.meta.txt create mode 100644 testdata/frame-metadata-conformance/no_join.csv create mode 100644 testdata/frame-metadata-conformance/no_join.expected.json create mode 100644 testdata/frame-metadata-conformance/nul_bytes.expected.json create mode 100644 testdata/frame-metadata-conformance/nul_bytes.txt create mode 100644 testdata/frame-metadata-conformance/numeric_collision.csv create mode 100644 testdata/frame-metadata-conformance/numeric_collision.expected.json create mode 100644 testdata/frame-metadata-conformance/pandas_index.csv create mode 100644 testdata/frame-metadata-conformance/pandas_index.expected.json create mode 100644 testdata/frame-metadata-conformance/proto.csv create mode 100644 testdata/frame-metadata-conformance/proto.expected.json create mode 100644 testdata/frame-metadata-conformance/source_names.expected.json create mode 100644 testdata/frame-metadata-conformance/tab_comma_units.expected.json create mode 100644 testdata/frame-metadata-conformance/tab_comma_units.txt create mode 100644 testdata/frame-metadata-conformance/trailing_comma.csv create mode 100644 testdata/frame-metadata-conformance/trailing_comma.expected.json create mode 100644 testdata/frame-metadata-conformance/two_column.csv create mode 100644 testdata/frame-metadata-conformance/two_column.expected.json create mode 100644 testdata/frame-metadata-conformance/viame_annotations.csv create mode 100644 testdata/frame-metadata-conformance/viame_annotations.expected.json create mode 100644 testdata/frame-metadata-conformance/wide_row.csv create mode 100644 testdata/frame-metadata-conformance/wide_row.expected.json create mode 100644 testdata/frame-metadata-conformance/windows_paths.csv create mode 100644 testdata/frame-metadata-conformance/windows_paths.expected.json diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index 78c40b8ec..4b651863c 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -125,6 +125,47 @@ interface FrameImage { id?: string; } +/** + * Frame metadata: declared `.meta.csv` / `.meta.txt` sidecars, resolved by the one shared + * TypeScript resolver (`dive-common/frameMetadata/resolve.ts`) on both platforms. Nothing derived + * is persisted; the sidecar the user dropped is the only stored form. + * + * The resolved payload is compact: each camera holds its payload `columns` order once and each + * frame is a row of cell values aligned to that order (never a `{field: value}` object, so + * numeric-named headers stay in file order). The panel materializes a per-frame `{column: value}` + * record lazily from a single frame's row (W-12 memory posture, no size cap). + */ +interface ResolvedFrameMetadata { + /** camera key -> (frame number -> row of cell values aligned to `columns[camera]`). */ + cameras: Record>; + /** camera key -> matched sidecar filenames in precedence order (winner first). */ + sources: Record; + /** camera key -> payload column names in file / precedence order. */ + columns: Record; +} + +/** One sidecar item as listed by the server sources endpoint (no parsing). */ +interface FrameMetadataSourceItem { + itemId: string; + name: string; +} + +/** + * Response of `GET /dive_dataset/:id/frame_metadata_sources`: which sidecar items exist per + * camera, in precedence order (camera folder -> clone root -> dataset folder -> parent root), + * deduped by folder. Cameras are keyed `singleCam` (SingleCameraFrameMetadataKey) or the multicam + * camera names; non-image-sequence datasets return empty `cameras`. + */ +interface FrameMetadataSourcesResponse { + cameras: Record; +} + +/** + * Camera key for single-camera datasets on the frame-metadata contract; must match the key the + * server sources endpoint and the desktop resolver emit for single-camera media. + */ +export const SingleCameraFrameMetadataKey = 'singleCam'; + export interface MultiCamImportFolderArgs { datasetName?: string; // Girder parent folder name (required on web) defaultDisplay: string; // In multicam the default camera to display @@ -266,6 +307,12 @@ interface Api { loadMetadata(datasetId: string): Promise; loadDetections(datasetId: string, revision?: number, set?: string): Promise; + /** Web: which sidecar items exist per camera (server sources endpoint, no parsing). */ + loadFrameMetadataSources?(datasetId: string): Promise; + /** Web: download one sidecar item's raw text by item id, for the shared TypeScript resolver. */ + downloadItemText?(itemId: string): Promise; + /** Desktop: the resolver runs in the backend and returns the resolved per-camera payload. */ + loadFrameMetadata?(datasetId: string): Promise; saveDetections(datasetId: string, args: SaveDetectionsArgs): Promise; saveMetadata(datasetId: string, metadata: DatasetMetaMutable): Promise; @@ -512,6 +559,9 @@ export { SubType, PipelineParamType, FrameImage, + ResolvedFrameMetadata, + FrameMetadataSourceItem, + FrameMetadataSourcesResponse, MultiTrackRecord, MultiGroupRecord, Pipe, diff --git a/client/dive-common/components/DatasetInfo.spec.ts b/client/dive-common/components/DatasetInfo.spec.ts new file mode 100644 index 000000000..df2420dcb --- /dev/null +++ b/client/dive-common/components/DatasetInfo.spec.ts @@ -0,0 +1,256 @@ +// @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 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..df8812a5e 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,39 @@ export default defineComponent({ watch(datasetId, fetchMetadata, { immediate: true }); + const frameMetadata = useFrameMetadataWindow({ + datasetId, + frame: time.frame, + selectedCamera, + loadFrameMetadata, + }); + + const frameMetadataEmptyState = computed(() => { + 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.'; + }); + + // Name the sidecar(s) that fed the active camera's frame metadata; a single + // dataset can match several telemetry files (precedence order, winner first). + const frameMetadataSourceLabel = computed(() => { + const files = frameMetadata.currentSources.value; + if (!files.length) { + return null; + } + const [first, ...rest] = files; + return rest.length + ? `Source: ${first} (+${rest.length} more matching files)` + : `Source: ${first}`; + }); + const infoRows = computed(() => { const m = meta.value; if (!m) { @@ -144,6 +185,9 @@ export default defineComponent({ return { readOnlyMode, + frameMetadataEntries: frameMetadata.currentEntries, + frameMetadataEmptyState, + frameMetadataSourceLabel, infoRows, customMeta, customMetaKeys, @@ -166,138 +210,189 @@ export default defineComponent({