diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index 4492f0c33..45d0abb14 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -196,6 +196,48 @@ interface DatasetMeta extends DatasetMetaMutable { originalFps?: Readonly; subType: Readonly; // In future this could have stuff like IR/EO multiCamMedia: Readonly; + /** Stereo calibration / camera file currently associated with the dataset (desktop). */ + calibration?: Readonly; +} + +interface CameraCalibration { + cx?: number + cy?: number + fx?: number + fy?: number + k1?: number + k2?: number + k3?: number + p1?: number + p2?: number + rmsError?: number +} + +interface DatasetStereoCalibration { + R: number[] + T: number[] + gridHeight?: number + gridWidth?: number + imageHeight?: number + imageWidth?: number + squareSize?: number + rmsError?: number + calibrations: Record +} + +interface DatasetCalibrationResult { + /** Parsed calibration parameters from the JSON camera-rig file. */ + calibration?: DatasetStereoCalibration + /** Source calibration item (calibrationFile). Used by pipelines and download. */ + itemId?: string; + /** JSON camera-rig item (jsonCalibrationFile). Used for display parameters. */ + jsonItemId?: string; + /** Source calibration filename. */ + originalName?: string; + /** JSON camera-rig filename. */ + jsonPath?: string; + /** Alias for jsonPath (legacy). */ + path?: string; } interface Api { @@ -203,6 +245,7 @@ interface Api { runPipeline(itemId: string, pipeline: Pipe, pipelineParams?: PipelineParams): Promise; deleteTrainedPipeline(pipeline: Pipe): Promise; exportTrainedPipeline(path: string, pipeline: Pipe): Promise; + getDatasetCalibration(datasetId: string): Promise; getTrainingConfigurations(): Promise; runTraining( @@ -257,6 +300,14 @@ interface Api { // Desktop-only calibration persistence functions getLastCalibration?(): Promise; saveCalibration?(path: string): Promise<{ savedPath: string; updatedDatasetIds: string[] }>; + /** Desktop: set the stereo camera/calibration file for a single dataset. */ + importCalibrationFile?(datasetId: string, path: string): Promise<{ calibration: string }>; + /** Desktop: copy the dataset's current camera/calibration file out to destPath. */ + exportCalibrationFile?(datasetId: string, destPath: string): Promise<{ exportedPath: string }>; + /** Download/export the dataset's current calibration file (platform-specific). */ + downloadCalibration?(datasetId: string): Promise; + /** Remove the calibration file currently associated with the dataset. */ + deleteCalibration?(datasetId: string): Promise; } const ApiSymbol = Symbol('api'); @@ -378,6 +429,9 @@ export { DatasetMetaMutableKeys, DatasetType, DiveParam, + CameraCalibration, + DatasetStereoCalibration, + DatasetCalibrationResult, SubType, PipelineParamType, FrameImage, diff --git a/client/dive-common/components/AnnotationVisibilityMenu.vue b/client/dive-common/components/AnnotationVisibilityMenu.vue index 5354fa10b..ea11bc788 100644 --- a/client/dive-common/components/AnnotationVisibilityMenu.vue +++ b/client/dive-common/components/AnnotationVisibilityMenu.vue @@ -9,6 +9,9 @@ import { import { VisibleAnnotationTypes } from 'vue-media-annotator/layers'; +import OutlinedLabeledGroup from './OutlinedLabeledGroup.vue'; +import ToolbarExpandToggle from './ToolbarExpandToggle.vue'; + interface ButtonData { id: string; icon: string; @@ -20,6 +23,10 @@ interface ButtonData { export default defineComponent({ name: 'AnnotationVisibilityMenu', + components: { + OutlinedLabeledGroup, + ToolbarExpandToggle, + }, props: { visibleModes: { type: Array as PropType<(VisibleAnnotationTypes)[]>, @@ -117,6 +124,14 @@ export default defineComponent({ }, ])); + const primaryViewButtons = computed( + () => viewButtons.value.filter((button) => button.id !== 'tooltip'), + ); + + const advancedVisibilityActive = computed( + () => isVisible('tooltip') || isVisible('TrackTail'), + ); + const updateTailSettings = (type: 'before' | 'after', event: Event) => { const value = Number.parseFloat((event.target as HTMLInputElement).value); const settings = { ...props.tailSettings, [type]: value }; @@ -131,6 +146,8 @@ export default defineComponent({ isExpanded, layoutKey, viewButtons, + primaryViewButtons, + advancedVisibilityActive, isVisible, toggleVisible, toggleExpanded, @@ -142,7 +159,7 @@ export default defineComponent({ @@ -254,29 +267,27 @@ export default defineComponent({ - - - - mdi-eye - - - Visibility + - - diff --git a/client/dive-common/components/CalibrationDialog.vue b/client/dive-common/components/CalibrationDialog.vue new file mode 100644 index 000000000..80ea05c5e --- /dev/null +++ b/client/dive-common/components/CalibrationDialog.vue @@ -0,0 +1,291 @@ + + + diff --git a/client/dive-common/components/CalibrationMenu.vue b/client/dive-common/components/CalibrationMenu.vue new file mode 100644 index 000000000..d21878bc5 --- /dev/null +++ b/client/dive-common/components/CalibrationMenu.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/client/dive-common/components/EditorMenu.vue b/client/dive-common/components/EditorMenu.vue index 740c38409..4366737c6 100644 --- a/client/dive-common/components/EditorMenu.vue +++ b/client/dive-common/components/EditorMenu.vue @@ -14,6 +14,8 @@ import Recipe from 'vue-media-annotator/recipe'; import SegmentationPointClick from 'dive-common/recipes/segmentationpointclick'; import AnnotationVisibilityMenu from './AnnotationVisibilityMenu.vue'; +import OutlinedLabeledGroup from './OutlinedLabeledGroup.vue'; +import ToolbarExpandToggle from './ToolbarExpandToggle.vue'; interface ButtonData { id: string; @@ -30,6 +32,8 @@ export default defineComponent({ name: 'EditorMenu', components: { AnnotationVisibilityMenu, + OutlinedLabeledGroup, + ToolbarExpandToggle, }, props: { editingTrack: { @@ -306,99 +310,93 @@ export default defineComponent({ - - diff --git a/client/dive-common/components/ImportMultiCamDialog/useImportMultiCamDialog.ts b/client/dive-common/components/ImportMultiCamDialog/useImportMultiCamDialog.ts index 6ae083c3a..df5478d47 100644 --- a/client/dive-common/components/ImportMultiCamDialog/useImportMultiCamDialog.ts +++ b/client/dive-common/components/ImportMultiCamDialog/useImportMultiCamDialog.ts @@ -67,6 +67,8 @@ export function useImportMultiCamDialog( const pendingImportPayloads: Ref> = ref({}); const globList: Ref> = ref({}); const calibrationFile = ref(''); + const lastCalibrationPath = ref(''); + const calibrationAutoDiscoveryFailed = ref(false); const datasetName = ref(''); const subfolderOriginalNames: Ref> = ref({}); const cameraOrder: Ref = ref([]); @@ -78,11 +80,24 @@ export function useImportMultiCamDialog( if (props.stereo && getLastCalibration) { const lastCalibration = await getLastCalibration(); if (lastCalibration) { - calibrationFile.value = lastCalibration; + lastCalibrationPath.value = lastCalibration; } } }); + const lastCalibrationFileName = computed(() => { + if (!lastCalibrationPath.value) { + return ''; + } + return lastCalibrationPath.value.replace(/^.*[\\/]/, ''); + }); + + const showLastCalibrationSuggestion = computed( + () => props.stereo + && !calibrationFile.value + && !!lastCalibrationPath.value, + ); + const orderedCameraKeys = computed(() => { const keys = Object.keys(folderList.value); const ordered = cameraOrder.value.filter((key) => keys.includes(key)); @@ -108,6 +123,7 @@ export function useImportMultiCamDialog( subfolderLayoutLabel.value = ''; datasetName.value = ''; calibrationFile.value = ''; + calibrationAutoDiscoveryFailed.value = false; subfolderOriginalNames.value = {}; cameraOrder.value = []; defaultDisplay.value = props.stereo ? 'left' : 'center'; @@ -467,6 +483,8 @@ export function useImportMultiCamDialog( const path = ret.filePaths[0]; if (folder === 'calibration') { calibrationFile.value = path; + calibrationAutoDiscoveryFailed.value = false; + lastCalibrationPath.value = path; if (saveCalibration) { saveCalibration(path); } @@ -591,6 +609,14 @@ export function useImportMultiCamDialog( calibrationFile.value = ''; } + function applyLastCalibration() { + if (!lastCalibrationPath.value) { + return; + } + calibrationFile.value = lastCalibrationPath.value; + calibrationAutoDiscoveryFailed.value = false; + } + function subfolderSourceDisplayLabel( sourcePath: string, folderName: string, @@ -622,8 +648,10 @@ export function useImportMultiCamDialog( discoveredPath = await findParentFolderCalibrationFile(parentPath); } if (!discoveredPath) { + calibrationAutoDiscoveryFailed.value = true; return; } + calibrationAutoDiscoveryFailed.value = false; calibrationFile.value = discoveredPath; if (discoveredFile && stashCalibrationFile) { stashCalibrationFile(discoveredPath, discoveredFile); @@ -638,6 +666,10 @@ export function useImportMultiCamDialog( globList, filteredImages, calibrationFile, + lastCalibrationFileName, + calibrationAutoDiscoveryFailed, + showLastCalibrationSuggestion, + applyLastCalibration, defaultDisplay, displayKeys, displayKeysKey, diff --git a/client/dive-common/components/MultiCamToolbar.vue b/client/dive-common/components/MultiCamToolbar.vue index c89225e82..670c70e28 100644 --- a/client/dive-common/components/MultiCamToolbar.vue +++ b/client/dive-common/components/MultiCamToolbar.vue @@ -17,6 +17,9 @@ import { import { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; import { Mousetrap } from 'vue-media-annotator/types'; +import OutlinedLabeledGroup from './OutlinedLabeledGroup.vue'; +import ToolbarExpandToggle from './ToolbarExpandToggle.vue'; + interface ToolbarButton { id: string; icon: string; @@ -29,6 +32,10 @@ interface ToolbarButton { export default defineComponent({ name: 'MultiCamToolbar', + components: { + OutlinedLabeledGroup, + ToolbarExpandToggle, + }, setup() { const selectedCamera = useSelectedCamera(); const selectedTrackId = useSelectedTrackId(); @@ -298,7 +305,7 @@ export default defineComponent({ mdi-image-multiple - - mdi-chevron-right - + @@ -376,23 +379,22 @@ export default defineComponent({ - + - diff --git a/client/dive-common/components/OutlinedLabeledGroup.vue b/client/dive-common/components/OutlinedLabeledGroup.vue new file mode 100644 index 000000000..38aa6c292 --- /dev/null +++ b/client/dive-common/components/OutlinedLabeledGroup.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/client/dive-common/components/RunPipelineToast.vue b/client/dive-common/components/RunPipelineToast.vue index 53c8bbcbe..e24c141f8 100644 --- a/client/dive-common/components/RunPipelineToast.vue +++ b/client/dive-common/components/RunPipelineToast.vue @@ -35,6 +35,8 @@ export default defineComponent({ return 'mdi-vector-polygon'; case 'TRACK': return 'mdi-gesture'; + case 'CALIBRATION': + return 'mdi-checkerboard'; default: return type; } diff --git a/client/dive-common/components/ToolbarExpandToggle.vue b/client/dive-common/components/ToolbarExpandToggle.vue new file mode 100644 index 000000000..cf394dbbc --- /dev/null +++ b/client/dive-common/components/ToolbarExpandToggle.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index 42314697d..6a656d3a3 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -1283,6 +1283,7 @@ export default defineComponent({ showConfidenceFirst, showTrackAttributesFirst, attributes, + datasetId, /* Attribute editing for bottom panel */ editIndividual, editingAttribute, @@ -1334,6 +1335,7 @@ export default defineComponent({ class="title pl-3 flex-row" style="white-space:nowrap;overflow:hidden;text-overflow: ellipsis;" > + {{ datasetName }} + + + Menus for Advanced Tools/Settings - - @@ -1883,4 +1887,26 @@ html { color: white; } +.camera-select { + width: 100px; + max-width: 100px; + flex: 0 0 auto; + font-size: 0.9em; +} + +.camera-select .v-select__selections { + flex-wrap: nowrap; + min-width: 0; +} + +.camera-select .v-select__selection--comma { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.camera-select fieldset { + height: 33px; + margin-top: 4px; +} diff --git a/client/dive-common/components/toolbarGroup.scss b/client/dive-common/components/toolbarGroup.scss new file mode 100644 index 000000000..8a49117fb --- /dev/null +++ b/client/dive-common/components/toolbarGroup.scss @@ -0,0 +1,16 @@ +.toolbar-group-host { + display: inline-flex; + align-items: center; + vertical-align: middle; + margin-top: -9px; +} + +.toolbar-group-activator.v-btn.v-size--small { + height: 28px !important; + min-height: 28px !important; +} + +.toolbar-group-activator.mode-button { + border: 1px solid grey; + min-width: 36px; +} diff --git a/client/dive-common/constants.ts b/client/dive-common/constants.ts index a8a1d09bd..620750528 100644 --- a/client/dive-common/constants.ts +++ b/client/dive-common/constants.ts @@ -170,8 +170,10 @@ const zipFileTypes = [ ]; const stereoPipelineMarker = 'measurement'; -/** Girder item meta key marking a stereoscopic calibration file in the dataset folder. */ +/** Girder item meta key marking the original stereoscopic calibration upload (pipeline input). */ const calibrationFileMarker = 'calibrationFile'; +/** Girder item meta key marking the JSON camera-rig used for calibration display. */ +const jsonCalibrationFileMarker = 'jsonCalibrationFile'; /** Legacy common_stereo category key; never shown in the run-pipeline menu. */ const hiddenPipelineCategories = ['stereo']; /** Pipeline name/category substrings hidden from the web run-pipeline menu. */ @@ -213,6 +215,7 @@ export { zipFileTypes, stereoPipelineMarker, calibrationFileMarker, + jsonCalibrationFileMarker, hiddenPipelineCategories, webExcludedPipelineTerms, multiCamPipelineMarkers, diff --git a/client/dive-common/store/settings.ts b/client/dive-common/store/settings.ts index 3b9030569..83dbdd47a 100644 --- a/client/dive-common/store/settings.ts +++ b/client/dive-common/store/settings.ts @@ -69,6 +69,9 @@ interface AnnotationSettings { delaySeconds: number; }; stereoSettings: { + // When importing a new camera file, optionally drop length measurements + // computed against the previous calibration. + clearLengthOnCameraFileLoad: boolean; // Recompute length attributes when a line's vertices are modified on a // detection that is linked across both cameras. updateLengthsOnModify: boolean; @@ -154,6 +157,7 @@ const defaultSettings: AnnotationSettings = { delaySeconds: 60, }, stereoSettings: { + clearLengthOnCameraFileLoad: true, updateLengthsOnModify: true, autoComputeOtherCamera: false, loading: false, diff --git a/client/dive-common/utils/clearLengthAttributes.ts b/client/dive-common/utils/clearLengthAttributes.ts new file mode 100644 index 000000000..0a82556fe --- /dev/null +++ b/client/dive-common/utils/clearLengthAttributes.ts @@ -0,0 +1,46 @@ +import type { CameraStore, Track } from 'vue-media-annotator/index'; + +export const LENGTH_ATTRIBUTE_KEY = 'length'; +export const LENGTH_METHOD_ATTRIBUTE_KEY = 'length_method'; + +/** + * Remove the per-track and per-detection 'length' attribute (and, by default, + * the companion 'length_method' lock) across all cameras in the store. Used + * when a new stereo camera/calibration file is loaded, because existing length + * measurements were computed against the previous calibration and are no longer + * valid. + * + * Mutations go through the Track API so change-tracking and reactivity fire; the + * caller is responsible for persisting the result (e.g. handler.save()). + * + * @param cameraStore the camera store to clear + * @param clearMethod also clear the 'length_method' attribute (default true) + * @returns the number of attribute values cleared. + */ +export default function clearLengthAttributes( + cameraStore: CameraStore, + clearMethod = true, +): number { + const keys = clearMethod + ? [LENGTH_ATTRIBUTE_KEY, LENGTH_METHOD_ATTRIBUTE_KEY] + : [LENGTH_ATTRIBUTE_KEY]; + let cleared = 0; + cameraStore.camMap.value.forEach((camera) => { + camera.trackStore.annotationMap.forEach((annotation) => { + const track = annotation as Track; + keys.forEach((key) => { + if (track.attributes && track.attributes[key] !== undefined) { + track.setAttribute(key, undefined); + cleared += 1; + } + track.features.forEach((feature, frame) => { + if (feature && feature.attributes && feature.attributes[key] !== undefined) { + track.setFeatureAttribute(frame, key, undefined); + cleared += 1; + } + }); + }); + }); + }); + return cleared; +} diff --git a/client/package-lock.json b/client/package-lock.json index 01a1adfd1..0d84b5716 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -14,6 +14,7 @@ "@mdi/font": "^6.2.95", "@sentry/browser": "^5.24.2", "@sentry/integrations": "^5.24.2", + "archiver": "^7.0.1", "axios": "^1.17.0", "csv-stringify": "^5.6.0", "d3": "^5.12.0", @@ -2108,16 +2109,6 @@ "node": ">=16.4" } }, - "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/@electron/universal/node_modules/fs-extra": { "version": "11.3.4", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", @@ -2755,7 +2746,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -2773,7 +2763,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2786,7 +2775,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2799,14 +2787,12 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -2824,7 +2810,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -2840,7 +2825,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -3450,7 +3434,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -5449,6 +5432,18 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -5584,7 +5579,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5594,7 +5588,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5871,6 +5864,182 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -6124,6 +6293,12 @@ "node": ">=8" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-exit-hook": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", @@ -6235,6 +6410,20 @@ "dequal": "^2.0.3" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -6291,14 +6480,104 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "devOptional": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.2.tgz", + "integrity": "sha512-h530JsrkYi8518ZfR57GHaLoI5YzXkGGEV0Y+mf4KYPBn4OnNajiznwkDq7FgE+Vnmyss9Utnzi44y7sowiAXA==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -6399,10 +6678,9 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -6866,7 +7144,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6909,6 +7186,62 @@ "node": ">=0.10.0" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -7020,9 +7353,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/cors": { "version": "2.8.5", @@ -7049,6 +7380,71 @@ "buffer": "^5.1.0" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -7071,7 +7467,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -7086,7 +7481,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7096,7 +7490,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7109,7 +7502,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7119,7 +7511,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7904,7 +8295,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/editorconfig": { @@ -8198,7 +8588,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -9139,16 +9528,33 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -9282,6 +9688,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -9546,7 +9958,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", @@ -9563,7 +9974,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -10021,7 +10431,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -10312,7 +10721,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -10327,8 +10735,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "5.3.1", @@ -10405,7 +10812,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "devOptional": true, "license": "ISC" }, "node_modules/ini": { @@ -10641,7 +11047,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10791,6 +11196,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -10916,7 +11333,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -11025,13 +11441,6 @@ "node": ">=10" } }, - "node_modules/jake/node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -11385,6 +11794,54 @@ "dev": true, "license": "MIT" }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/lerc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", @@ -11758,7 +12215,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -11999,7 +12455,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12229,7 +12684,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/pako": { @@ -12317,7 +12771,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -12334,7 +12787,6 @@ "version": "10.2.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", - "dev": true, "license": "ISC", "engines": { "node": "14 || >=16.14" @@ -12560,6 +13012,21 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -12809,6 +13276,27 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -13151,7 +13639,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -13655,11 +14142,21 @@ "dev": true, "license": "MIT" }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -13669,7 +14166,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -13685,7 +14181,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -13779,7 +14274,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -13793,7 +14287,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -13922,6 +14415,18 @@ "node": ">=18" } }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -13932,6 +14437,15 @@ "node": ">=18" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/temp-file": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", @@ -14068,6 +14582,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -14643,7 +15166,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/utils-merge": { @@ -15567,7 +16089,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -15752,6 +16273,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zstddec": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", diff --git a/client/package.json b/client/package.json index 580e19025..602997c67 100644 --- a/client/package.json +++ b/client/package.json @@ -36,6 +36,7 @@ "@mdi/font": "^6.2.95", "@sentry/browser": "^5.24.2", "@sentry/integrations": "^5.24.2", + "archiver": "^7.0.1", "axios": "^1.17.0", "csv-stringify": "^5.6.0", "d3": "^5.12.0", diff --git a/client/platform/desktop/backend/ipcService.ts b/client/platform/desktop/backend/ipcService.ts index 9abb45046..91a5664d1 100644 --- a/client/platform/desktop/backend/ipcService.ts +++ b/client/platform/desktop/backend/ipcService.ts @@ -3,12 +3,13 @@ import http from 'http'; import fs from 'fs'; import path from 'path'; import { - app, ipcMain, shell, dialog, BrowserWindow, + app, ipcMain, dialog, BrowserWindow, } from 'electron'; import { MultiCamImportArgs } from 'dive-common/apispec'; import type { Pipe } from 'dive-common/apispec'; import { DesktopJobUpdate, RunPipeline, RunTraining, Settings, ExportDatasetArgs, + ExportMulticamEverythingArgs, DesktopMediaImportResponse, ExportTrainedPipeline, ConversionArgs, @@ -110,7 +111,9 @@ export default function register() { event.returnValue = getDiveVersion(); }); ipcMain.handle('desktop:get-app-path', (_, name: Electron.Name) => app.getPath(name)); - ipcMain.handle('desktop:open-path', (_, targetPath: string) => shell.openPath(targetPath)); + ipcMain.handle('desktop:open-path', async (_, targetPath: string) => ( + common.openPathInFileManager(targetPath) + )); ipcMain.on('update-settings', async (_, s: Settings) => { settings.set(s); }); @@ -124,6 +127,11 @@ export default function register() { return ret; }); + ipcMain.handle('export-multicam-everything', async (_, args: ExportMulticamEverythingArgs) => { + const ret = await common.exportMulticamEverything(settings.get(), args); + return ret; + }); + ipcMain.handle('autodiscover-data', async () => { const ret = await common.autodiscoverData(settings.get()); return ret; @@ -218,15 +226,32 @@ export default function register() { ipcMain.handle('get-last-calibration', async () => common.getLastCalibrationPath(settings.get())); - ipcMain.handle('save-calibration', async (_, { path }: { path: string }) => { - const savedPath = await common.saveLastCalibration(settings.get(), path); + ipcMain.handle('save-calibration', async (_, { path: sourcePath }: { path: string }) => { + const savedPath = await common.saveLastCalibration(settings.get(), sourcePath); const updatedIds = await common.applyCalibrationToUncalibratedStereoDatasets( settings.get(), savedPath, + path.basename(sourcePath), ); return { savedPath, updatedDatasetIds: updatedIds }; }); + ipcMain.handle('import-calibration', async (_, { id, path }: { id: string; path: string }) => { + const calibration = await common.setDatasetCalibration(settings.get(), id, path); + return { calibration }; + }); + + ipcMain.handle('export-calibration', async (_, { id, destPath }: { id: string; destPath: string }) => { + const exportedPath = await common.exportDatasetCalibration(settings.get(), id, destPath); + return { exportedPath }; + }); + + ipcMain.handle('get-dataset-calibration', async (_, { datasetId }: { datasetId: string }) => common.getDatasetCalibration(settings.get(), datasetId)); + + ipcMain.handle('delete-calibration', async (_, { datasetId }: { datasetId: string }) => { + await common.deleteDatasetCalibration(settings.get(), datasetId); + }); + ipcMain.handle('finalize-import', async (event, args: DesktopMediaImportResponse) => common.finalizeMediaImport(settings.get(), args)); ipcMain.handle('convert', async (event, args: ConversionArgs) => { diff --git a/client/platform/desktop/backend/native/calibrationConvert.ts b/client/platform/desktop/backend/native/calibrationConvert.ts new file mode 100644 index 000000000..88f38a8f6 --- /dev/null +++ b/client/platform/desktop/backend/native/calibrationConvert.ts @@ -0,0 +1,120 @@ +/** + * Stereo camera calibration file handling for the desktop backend. + * + * VIAME ships a `convert_cam_format.py` tool (installed under + * `/configs/`) that reads any supported stereo calibration format + * (npz, opencv yml, matlab mat, zed, CamCAL, ...) and writes KWIVER's JSON + * camera-rig format. We normalize every imported calibration to that JSON so the + * measurement pipeline always consumes a single, consistent format, while still + * keeping the user's original file alongside the media. + */ +import npath from 'path'; +import { spawn } from 'child_process'; +import fs from 'fs-extra'; + +import { Settings } from 'platform/desktop/constants'; +import { observeChild } from 'platform/desktop/backend/native/processManager'; + +const ConvertToolRelativePath = npath.join('configs', 'convert_cam_format.py'); + +/** + * Run VIAME's convert_cam_format.py to convert a calibration file to the + * KWIVER-compatible JSON camera-rig format. + * @returns true if the JSON file was produced, false if conversion was + * unavailable (e.g. VIAME not configured) or failed. + */ +async function convertCalibrationToJson( + settings: Settings, + sourcePath: string, + destJsonPath: string, +): Promise { + const isWin = process.platform === 'win32'; + const setupScript = npath.join(settings.viamePath, isWin ? 'setup_viame.bat' : 'setup_viame.sh'); + const toolPath = npath.join(settings.viamePath, ConvertToolRelativePath); + if (!(await fs.pathExists(setupScript)) || !(await fs.pathExists(toolPath))) { + return false; + } + const sourceCmd = isWin ? `call "${setupScript}"` : `. "${setupScript}"`; + const command = `${sourceCmd} && python "${toolPath}" "${sourcePath}" "${destJsonPath}"`; + const child = observeChild(spawn(command, { shell: isWin ? true : '/bin/bash' })); + const exitCode: number | null = await new Promise((resolve) => { + child.on('exit', (code) => resolve(code)); + }); + const produced = await fs.pathExists(destJsonPath); + return exitCode === 0 && produced; +} + +/** + * True when a file can be parsed as JSON. Used to tell a real KWIVER JSON + * camera-rig from a binary calibration file (e.g. an .npz) that was merely + * named ".json". + */ +async function isValidJson(filePath: string): Promise { + try { + await fs.readJSON(filePath); + return true; + } catch (err) { + return false; + } +} + +/** + * True when the file starts with the ZIP local-file-header magic ("PK\x03\x04"). + * NumPy .npz archives are ZIP files, so this catches an .npz mislabeled .json. + */ +async function looksLikeZip(filePath: string): Promise { + const fd = await fs.open(filePath, 'r'); + try { + const buf = Buffer.alloc(2); + await fs.read(fd, buf, 0, 2, 0); + return buf[0] === 0x50 && buf[1] === 0x4b; + } finally { + await fs.close(fd); + } +} + +/** + * Store a stereo calibration / camera file alongside a dataset's media and + * normalize it to the JSON camera-rig format. + * + * The original file is always copied into the dataset directory (preserved). + * Non-JSON formats — including files named ".json" that are actually binary + * (e.g. an .npz copied to last_calibration.json) — are converted to JSON via + * VIAME; the JSON copy is used for pipelines when conversion succeeds, + * otherwise the original is used. + * + * @returns absolute path of the calibration file the dataset should reference. + */ +async function prepareDatasetCalibration( + settings: Settings, + projectDirAbsPath: string, + sourcePath: string, +): Promise { + const originalDest = npath.join(projectDirAbsPath, npath.basename(sourcePath)); + if (npath.resolve(originalDest) !== npath.resolve(sourcePath)) { + await fs.copy(sourcePath, originalDest, { overwrite: true }); + } + const ext = npath.extname(originalDest).toLowerCase(); + // A .json that actually parses as JSON is already the format we want. + if (ext === '.json' && await isValidJson(originalDest)) { + return originalDest; + } + const base = npath.basename(originalDest, npath.extname(originalDest)); + // convert_cam_format.py detects the input format by extension, so a binary + // file mislabeled ".json" must be given its true extension first. + let convertSource = originalDest; + if (ext === '.json' && await looksLikeZip(originalDest)) { + convertSource = npath.join(projectDirAbsPath, `${base}.npz`); + if (npath.resolve(convertSource) !== npath.resolve(originalDest)) { + await fs.copy(originalDest, convertSource, { overwrite: true }); + } + } + const jsonDest = npath.join(projectDirAbsPath, `${base}.json`); + const converted = await convertCalibrationToJson(settings, convertSource, jsonDest); + return converted ? jsonDest : originalDest; +} + +export { + convertCalibrationToJson, + prepareDatasetCalibration, +}; diff --git a/client/platform/desktop/backend/native/common.ts b/client/platform/desktop/backend/native/common.ts index 6d37a69ea..9a3303c7d 100644 --- a/client/platform/desktop/backend/native/common.ts +++ b/client/platform/desktop/backend/native/common.ts @@ -3,7 +3,11 @@ */ import npath from 'path'; +import os from 'os'; import fs from 'fs-extra'; +import { spawn } from 'child_process'; +import { createWriteStream } from 'fs'; +import archiver from 'archiver'; import { shell } from 'electron'; import mime from 'mime-types'; import moment from 'moment'; @@ -25,6 +29,9 @@ import { Pipe, PipeMetadata, PipelineParamType, + DatasetCalibrationResult, + DatasetStereoCalibration, + CameraCalibration, } from 'dive-common/apispec'; import * as viameSerializers from 'platform/desktop/backend/serializers/viame'; import * as nistSerializers from 'platform/desktop/backend/serializers/nist'; @@ -38,13 +45,14 @@ import { websafeImageTypes, websafeVideoTypes, otherImageTypes, otherVideoTypes, fileVideoTypes, MultiType, JsonMetaRegEx, largeImageDesktopTypes, } from 'dive-common/constants'; +import { orderedMultiCamCameraNames } from 'dive-common/multicamDisplay'; import { pickStereoCalibrationFileName } from 'dive-common/stereoParentFolder'; import { JsonMeta, Settings, JsonMetaCurrentVersion, DesktopMetadata, RunTraining, ExportDatasetArgs, DesktopMediaImportResponse, - ExportConfigurationArgs, JobsFolderName, JobsOutputFolderName, ProjectsFolderName, + ExportConfigurationArgs, ExportMulticamEverythingArgs, JobsFolderName, JobsOutputFolderName, ProjectsFolderName, PipelinesFolderName, ConversionArgs, - JobType, LastCalibrationFileName, + JobType, LastCalibrationBaseName, } from 'platform/desktop/constants'; import { cleanString, filterByGlob, makeid, strNumericCompare, @@ -55,6 +63,7 @@ import { upgrade } from './migrations'; // TODO: Check to Refactor this // eslint-disable-next-line import/no-cycle import { getMultiCamUrls, transcodeMultiCam } from './multiCamUtils'; +import { prepareDatasetCalibration } from './calibrationConvert'; import { splitExt } from './utils'; const AuxFolderName = 'auxiliary'; @@ -1415,6 +1424,19 @@ async function finalizeMediaImport( jsonMeta.id = `${cleanString(jsonMeta.name).substr(0, 20)}_${makeid(10)}`; const projectDirAbsPath = await _initializeProjectDir(settings, jsonMeta); + // Store any stereo calibration / camera file alongside the media and normalize + // it to the VIAME JSON camera-rig format (keeping the original). + if (jsonMeta.multiCam?.calibration) { + const calibrationSourcePath = npath.resolve(jsonMeta.multiCam.calibration); + jsonMeta.multiCam.calibrationSourcePath = calibrationSourcePath; + jsonMeta.multiCam.calibrationOriginalName = realCalibrationName(calibrationSourcePath); + jsonMeta.multiCam.calibration = await prepareDatasetCalibration( + settings, + projectDirAbsPath, + calibrationSourcePath, + ); + } + // Filter all parts of the input based on glob pattern if (globPattern && jsonMeta.type === 'image-sequence') { const searchPath = jsonMeta.imageListPath || jsonMeta.originalBasePath; @@ -1529,6 +1551,170 @@ async function openLink(url: string) { shell.openExternal(url); } +/** + * Open a file or folder in the system file manager. + * Returns an empty string on success or an error message on failure. + * + * shell.openPath can hang indefinitely on Linux (never resolving its promise), + * which breaks ipcMain.handle callers. Files use showItemInFolder; directories + * use a detached platform opener so the IPC handler always replies promptly. + */ +async function openPathInFileManager(targetPath: string): Promise { + if (!targetPath?.trim()) { + return 'No path specified'; + } + const resolved = npath.resolve(targetPath.trim()); + if (!(await fs.pathExists(resolved))) { + return `Path does not exist: ${resolved}`; + } + + const stat = await fs.stat(resolved); + if (stat.isFile()) { + shell.showItemInFolder(resolved); + return ''; + } + + if (process.platform === 'linux') { + spawn('xdg-open', [resolved], { detached: true, stdio: 'ignore' }).unref(); + return ''; + } + if (process.platform === 'win32') { + spawn('explorer', [resolved], { detached: true, stdio: 'ignore' }).unref(); + return ''; + } + if (process.platform === 'darwin') { + spawn('open', [resolved], { detached: true, stdio: 'ignore' }).unref(); + return ''; + } + + return shell.openPath(resolved); +} + +function buildExportMetaJson(meta: JsonMeta): Record { + const output: Record = { ...meta }; + if (meta.type === 'image-sequence') { + const files = meta.transcodedImageFiles?.length + ? meta.transcodedImageFiles + : meta.originalImageFiles?.map((filePath) => npath.basename(filePath)) ?? []; + output.imageData = files.map((filename) => ({ filename })); + } else if (meta.type === 'video') { + const filename = meta.transcodedVideoFile || meta.originalVideoFile; + if (filename) { + output.video = { filename }; + } + } + return output; +} + +async function writeDatasetExportContents( + settings: Settings, + destDir: string, + datasetId: string, + excludeBelowThreshold: boolean, + typeFilter: Set, +): Promise { + const projectDirInfo = await getValidatedProjectDir(settings, datasetId); + const meta = await loadJsonMetadata(projectDirInfo.metaFileAbsPath); + const data = await loadAnnotationFile(projectDirInfo.trackFileAbsPath); + const serializeOptions = { + excludeBelowThreshold, + header: true, + }; + + await fs.ensureDir(destDir); + await fs.writeJSON(npath.join(destDir, 'meta.json'), buildExportMetaJson(meta), { spaces: 2 }); + await dive.serializeFile( + npath.join(destDir, 'annotations.dive.json'), + data, + meta, + typeFilter, + serializeOptions, + ); + await viameSerializers.serializeFile( + npath.join(destDir, 'annotations.viame.csv'), + data, + meta, + typeFilter, + serializeOptions, + ); +} + +async function zipDirectory(sourceDir: string, destZipPath: string): Promise { + await new Promise((resolve, reject) => { + const output = createWriteStream(destZipPath); + const archive = archiver('zip', { zlib: { level: 9 } }); + output.on('close', () => resolve()); + output.on('error', reject); + archive.on('error', reject); + archive.pipe(output); + archive.directory(sourceDir, false); + archive.finalize(); + }); +} + +async function exportMulticamEverything( + settings: Settings, + args: ExportMulticamEverythingArgs, +): Promise { + const parentId = args.id.split('/')[0]; + const parentDirInfo = await getValidatedProjectDir(settings, parentId); + const parentMeta = await loadJsonMetadata(parentDirInfo.metaFileAbsPath); + if (parentMeta.type !== MultiType || !parentMeta.multiCam) { + throw new Error('Everything export is only available for multi-camera datasets.'); + } + + const cameraNames = orderedMultiCamCameraNames({ + cameras: parentMeta.multiCam.cameras, + defaultDisplay: parentMeta.multiCam.defaultDisplay, + }); + if (!cameraNames.length) { + throw new Error('Multi-camera dataset does not list any cameras.'); + } + + const tempDir = await fs.mkdtemp(npath.join(os.tmpdir(), 'dive-export-')); + try { + const datasetDir = npath.join(tempDir, parentMeta.name); + await fs.ensureDir(datasetDir); + await fs.writeJSON( + npath.join(datasetDir, 'multiCam.json'), + parentMeta.multiCam, + { spaces: 2 }, + ); + await writeDatasetExportContents( + settings, + datasetDir, + parentId, + args.exclude, + args.typeFilter, + ); + + const calibrationPath = parentMeta.multiCam.calibration + ?? await findParentFolderCalibrationFile(parentDirInfo.basePath); + if (calibrationPath && await fs.pathExists(calibrationPath)) { + const calibrationName = parentMeta.multiCam.calibrationOriginalName + ?? npath.basename(calibrationPath); + await fs.copy(calibrationPath, npath.join(datasetDir, calibrationName)); + } + + for (let i = 0; i < cameraNames.length; i += 1) { + const cameraName = cameraNames[i]; + // eslint-disable-next-line no-await-in-loop + await writeDatasetExportContents( + settings, + npath.join(datasetDir, cameraName), + `${parentId}/${cameraName}`, + args.exclude, + args.typeFilter, + ); + } + + await zipDirectory(tempDir, args.path); + } finally { + await fs.remove(tempDir); + } + return args.path; +} + async function exportDataset(settings: Settings, args: ExportDatasetArgs) { const projectDirInfo = await getValidatedProjectDir(settings, args.id); const meta = await loadJsonMetadata(projectDirInfo.metaFileAbsPath); @@ -1563,38 +1749,65 @@ async function exportConfiguration(settings: Settings, args: ExportConfiguration } /** - * Get path to last_calibration.json if it exists + * The user's calibration filename, unless it is DIVE's internal + * `last_calibration.*` backup (which carries no meaningful original name). + */ +function realCalibrationName(name?: string | null): string | undefined { + if (!name) return undefined; + const base = npath.basename(name); + if (base.toLowerCase().startsWith(`${LastCalibrationBaseName}.`)) return undefined; + return base; +} + +/** + * Get path to the saved "last used" calibration (last_calibration.*) if it + * exists. The stored file keeps the source's real extension, so we match on the + * basename rather than a fixed filename. * @returns path to last calibration file or null if it doesn't exist */ async function getLastCalibrationPath(settings: Settings): Promise { - const calibrationPath = npath.join(settings.dataPath, LastCalibrationFileName); - if (await fs.pathExists(calibrationPath)) { - return calibrationPath; - } - return null; + if (!(await fs.pathExists(settings.dataPath))) return null; + const entries = await fs.readdir(settings.dataPath); + const match = entries.find( + (f) => npath.basename(f, npath.extname(f)) === LastCalibrationBaseName, + ); + return match ? npath.join(settings.dataPath, match) : null; } /** - * Save a calibration file as the last used calibration + * Save a calibration file as the last used calibration, preserving the source + * file's extension (e.g. last_calibration.npz) so its real format is retained. * @param settings app settings * @param sourcePath path to the source calibration file * @returns path to the saved calibration file */ async function saveLastCalibration(settings: Settings, sourcePath: string): Promise { - const destPath = npath.join(settings.dataPath, LastCalibrationFileName); + const ext = npath.extname(sourcePath) || '.json'; + // Remove any prior backup with a different extension to avoid stale duplicates. + if (await fs.pathExists(settings.dataPath)) { + const entries = await fs.readdir(settings.dataPath); + await Promise.all(entries + .filter((f) => npath.basename(f, npath.extname(f)) === LastCalibrationBaseName) + .map((f) => fs.remove(npath.join(settings.dataPath, f)))); + } + const destPath = npath.join(settings.dataPath, `${LastCalibrationBaseName}${ext}`); await fs.copy(sourcePath, destPath, { overwrite: true }); return destPath; } /** - * Apply calibration to all stereo datasets that don't already have calibration set + * Apply calibration to all stereo datasets that don't already have calibration set. + * The source is copied into each dataset and normalized to JSON, and the user's + * original filename is recorded for display. * @param settings app settings * @param calibrationPath path to the calibration file to apply + * @param originalName user's original calibration filename (for display) * @returns list of dataset IDs that were updated */ async function applyCalibrationToUncalibratedStereoDatasets( settings: Settings, calibrationPath: string, + originalName?: string, ): Promise { const datasets = await autodiscoverData(settings); const updatedIds: string[] = []; @@ -1609,7 +1822,16 @@ async function applyCalibrationToUncalibratedStereoDatasets( // eslint-disable-next-line no-await-in-loop const fullMeta = await loadJsonMetadata(projectDirInfo.metaFileAbsPath); if (fullMeta.multiCam) { - fullMeta.multiCam.calibration = calibrationPath; + const calibrationSourcePath = npath.resolve(calibrationPath); + // eslint-disable-next-line no-await-in-loop + fullMeta.multiCam.calibration = await prepareDatasetCalibration( + settings, + projectDirInfo.basePath, + calibrationSourcePath, + ); + fullMeta.multiCam.calibrationSourcePath = calibrationSourcePath; + fullMeta.multiCam.calibrationOriginalName = realCalibrationName(originalName) + ?? realCalibrationName(calibrationSourcePath); // eslint-disable-next-line no-await-in-loop await _saveAsJson(projectDirInfo.metaFileAbsPath, fullMeta); updatedIds.push(meta.id); @@ -1642,6 +1864,185 @@ async function datasetHasCalibrationFile(settings: Settings, datasetId: string): } } +/** + * Get the calibration / camera file path currently associated with a dataset. + */ +async function getDatasetCalibrationPath( + settings: Settings, + datasetId: string, +): Promise { + const projectDirInfo = await getValidatedProjectDir(settings, datasetId); + const fullMeta = await loadJsonMetadata(projectDirInfo.metaFileAbsPath); + return fullMeta.multiCam?.calibration ?? null; +} + +/** + * Set the stereo camera/calibration file for a single dataset. The source file is + * copied into the dataset's project directory and recorded in multiCam.calibration. + * @returns absolute path of the calibration file now associated with the dataset + */ +async function setDatasetCalibration( + settings: Settings, + datasetId: string, + sourcePath: string, +): Promise { + // A calibration belongs to the parent multicam dataset; the viewer may pass a + // per-camera child id (e.g. "/left"). + const parentId = datasetId.split('/')[0]; + const projectDirInfo = await getValidatedProjectDir(settings, parentId); + const fullMeta = await loadJsonMetadata(projectDirInfo.metaFileAbsPath); + if (!fullMeta.multiCam) { + throw new Error(`Dataset ${parentId} is not a multi-camera/stereo dataset; cannot set a calibration file.`); + } + const calibrationPath = await prepareDatasetCalibration( + settings, + projectDirInfo.basePath, + sourcePath, + ); + fullMeta.multiCam.calibration = calibrationPath; + fullMeta.multiCam.calibrationSourcePath = npath.resolve(sourcePath); + fullMeta.multiCam.calibrationOriginalName = realCalibrationName(sourcePath); + await _saveAsJson(projectDirInfo.metaFileAbsPath, fullMeta); + return calibrationPath; +} + +/** + * Copy a dataset's current camera/calibration file out to destPath. + * @returns the destination path written + */ +async function exportDatasetCalibration( + settings: Settings, + datasetId: string, + destPath: string, +): Promise { + const calibrationPath = await getDatasetCalibrationPath(settings, datasetId.split('/')[0]); + if (!calibrationPath) { + throw new Error(`Dataset ${datasetId} has no camera/calibration file to export.`); + } + if (!(await fs.pathExists(calibrationPath))) { + throw new Error(`Calibration file for dataset ${datasetId} no longer exists on disk: ${calibrationPath}`); + } + await fs.copy(calibrationPath, destPath, { overwrite: true }); + return destPath; +} + +function optionalCalibrationNumber( + data: Record, + key: string, +): number | undefined { + const value = data[key]; + return value === undefined || value === null ? undefined : value as number; +} + +function parseCameraCalibration( + data: Record, + side: 'left' | 'right', +): CameraCalibration { + const calib: CameraCalibration = {}; + const fields: [string, keyof CameraCalibration][] = [ + [`cx_${side}`, 'cx'], + [`cy_${side}`, 'cy'], + [`fx_${side}`, 'fx'], + [`fy_${side}`, 'fy'], + [`k1_${side}`, 'k1'], + [`k2_${side}`, 'k2'], + [`k3_${side}`, 'k3'], + [`p1_${side}`, 'p1'], + [`p2_${side}`, 'p2'], + ]; + fields.forEach(([jsonKey, field]) => { + const value = optionalCalibrationNumber(data, jsonKey); + if (value !== undefined) { + calib[field] = value; + } + }); + const rmsError = optionalCalibrationNumber(data, `rms_error_${side}`); + if (rmsError !== undefined) { + calib.rmsError = rmsError; + } + return calib; +} + +/** + * Parse a KWIVER/VIAME JSON camera-rig file into the shared + * DatasetStereoCalibration shape. Mirrors the web server parser + * (server/dive_server/crud_dataset.py:get_calibration). + */ +function parseStereoCalibrationJson(data: Record): DatasetStereoCalibration { + const result: DatasetStereoCalibration = { + R: data.R as number[], + T: data.T as number[], + calibrations: { + left: parseCameraCalibration(data, 'left'), + right: parseCameraCalibration(data, 'right'), + }, + }; + const optionalFields: [string, keyof DatasetStereoCalibration][] = [ + ['grid_height', 'gridHeight'], + ['grid_width', 'gridWidth'], + ['image_height', 'imageHeight'], + ['image_width', 'imageWidth'], + ['square_size_mm', 'squareSize'], + ['rms_error_stereo', 'rmsError'], + ]; + optionalFields.forEach(([jsonKey, field]) => { + const value = optionalCalibrationNumber(data, jsonKey); + if (value !== undefined) { + result[field] = value; + } + }); + return result; +} + +/** + * Read the calibration file currently associated with a dataset and return its + * parsed parameters (when JSON) plus the file name, for display in the viewer. + */ +async function getDatasetCalibration( + settings: Settings, + datasetId: string, +): Promise { + const projectDirInfo = await getValidatedProjectDir(settings, datasetId.split('/')[0]); + const fullMeta = await loadJsonMetadata(projectDirInfo.metaFileAbsPath); + const calibrationPath = fullMeta.multiCam?.calibration; + if (!calibrationPath || !(await fs.pathExists(calibrationPath))) { + return null; + } + const result: DatasetCalibrationResult = { + path: npath.basename(calibrationPath), + originalName: realCalibrationName(fullMeta.multiCam?.calibrationOriginalName), + }; + if (npath.extname(calibrationPath).toLowerCase() === '.json') { + try { + const data = await fs.readJSON(calibrationPath); + result.calibration = parseStereoCalibrationJson(data); + } catch (err) { + console.error(`Failed to parse calibration JSON for dataset ${datasetId}:`, err); + } + } + return result; +} + +/** + * Remove the calibration file associated with a dataset and clear the reference + * in its metadata. The original (pre-conversion) file, if any, is left in place. + */ +async function deleteDatasetCalibration(settings: Settings, datasetId: string): Promise { + const parentId = datasetId.split('/')[0]; + const projectDirInfo = await getValidatedProjectDir(settings, parentId); + const fullMeta = await loadJsonMetadata(projectDirInfo.metaFileAbsPath); + const calibrationPath = fullMeta.multiCam?.calibration; + if (calibrationPath && await fs.pathExists(calibrationPath)) { + await fs.remove(calibrationPath); + } + if (fullMeta.multiCam) { + fullMeta.multiCam.calibration = undefined; + fullMeta.multiCam.calibrationSourcePath = undefined; + fullMeta.multiCam.calibrationOriginalName = undefined; + await _saveAsJson(projectDirInfo.metaFileAbsPath, fullMeta); + } +} + export { ProjectsFolderName, JobsFolderName, @@ -1653,6 +2054,7 @@ export { checkDataset, exportConfiguration, exportDataset, + exportMulticamEverything, finalizeMediaImport, getPipelineList, deleteTrainedPipeline, @@ -1665,6 +2067,7 @@ export { loadAnnotationFile, loadDetections, openLink, + openPathInFileManager, ingestDataFiles, saveDetections, saveMetadata, @@ -1681,4 +2084,9 @@ export { saveLastCalibration, applyCalibrationToUncalibratedStereoDatasets, datasetHasCalibrationFile, + getDatasetCalibrationPath, + setDatasetCalibration, + exportDatasetCalibration, + getDatasetCalibration, + deleteDatasetCalibration, }; diff --git a/client/platform/desktop/backend/native/interactive.ts b/client/platform/desktop/backend/native/interactive.ts index 6911f0c18..3df410405 100644 --- a/client/platform/desktop/backend/native/interactive.ts +++ b/client/platform/desktop/backend/native/interactive.ts @@ -213,7 +213,9 @@ export class InteractiveServiceManager extends EventEmitter { const command = `${viameConstants.setupScriptAbs} && ${pyCommand}`; + // eslint-disable-next-line no-console console.log('[Interactive] Starting interactive service...'); + // eslint-disable-next-line no-console console.log(`[Interactive] Command: ${command}`); const stderrLines: string[] = []; @@ -239,6 +241,7 @@ export class InteractiveServiceManager extends EventEmitter { this.process.stderr.on('data', (data: Buffer) => { const message = data.toString().trim(); if (message) { + // eslint-disable-next-line no-console console.log(`[Interactive] ${message}`); stderrLines.push(message); if (stderrLines.length > maxStderrLines) { @@ -274,6 +277,7 @@ export class InteractiveServiceManager extends EventEmitter { }; this.process.on('exit', (code, signal) => { + // eslint-disable-next-line no-console console.log(`[Interactive] Process exited with code ${code}, signal ${signal}`); this.cleanup(); if (this.isStarting) { @@ -632,6 +636,7 @@ export class InteractiveServiceManager extends EventEmitter { if (!this.process) { return; } + // eslint-disable-next-line no-console console.log('[Interactive] Shutting down interactive service...'); await new Promise((resolve) => { const reqId = this.generateRequestId(); @@ -641,6 +646,7 @@ export class InteractiveServiceManager extends EventEmitter { } const timeoutId = setTimeout(() => { if (this.process) { + // eslint-disable-next-line no-console console.log('[Interactive] Force killing interactive service...'); this.process.kill('SIGTERM'); } diff --git a/client/platform/desktop/backend/native/viame.ts b/client/platform/desktop/backend/native/viame.ts index 9c83fdbef..ace6cb1ff 100644 --- a/client/platform/desktop/backend/native/viame.ts +++ b/client/platform/desktop/backend/native/viame.ts @@ -386,7 +386,7 @@ async function runPipeline( if (calibrationFile) { const calibrationPath = npath.join(jobWorkDir, calibrationFile); const savedPath = await common.saveLastCalibration(settings, calibrationPath); - await common.applyCalibrationToUncalibratedStereoDatasets(settings, savedPath); + await common.applyCalibrationToUncalibratedStereoDatasets(settings, savedPath, calibrationFile); } } diff --git a/client/platform/desktop/constants.ts b/client/platform/desktop/constants.ts index 3d5f00508..5b24868ff 100644 --- a/client/platform/desktop/constants.ts +++ b/client/platform/desktop/constants.ts @@ -13,7 +13,9 @@ export const ProjectsFolderName = 'DIVE_Projects'; export const JobsFolderName = 'DIVE_Jobs'; export const JobsOutputFolderName = 'DIVE_Jobs_Output'; export const PipelinesFolderName = 'DIVE_Pipelines'; -export const LastCalibrationFileName = 'last_calibration.json'; +// Basename (without extension) of the saved "most recently used" calibration. +// The stored file keeps the source file's real extension (e.g. last_calibration.npz). +export const LastCalibrationBaseName = 'last_calibration'; export interface Settings { // version a schema version @@ -50,6 +52,11 @@ export interface MultiCamDesktop { cameras: Record; //Calibration file in .npz format used for stereo or other cameras calibration?: string; + // Name of the user's original calibration file (preserved for display, since + // `calibration` may point at a converted/normalized copy). + calibrationOriginalName?: string; + // Absolute path of the calibration file at import (before project copy/conversion). + calibrationSourcePath?: string; // Default Display Key for showing multiCam defaultDisplay: string; } @@ -276,3 +283,10 @@ export interface ExportConfigurationArgs { id: string; path: string; } + +export interface ExportMulticamEverythingArgs { + id: string; + exclude: boolean; + path: string; + typeFilter: Set; +} diff --git a/client/platform/desktop/frontend/api.ts b/client/platform/desktop/frontend/api.ts index 57de79ca3..7d6acd865 100644 --- a/client/platform/desktop/frontend/api.ts +++ b/client/platform/desktop/frontend/api.ts @@ -4,6 +4,7 @@ import type { DatasetMetaMutable, DatasetType, MultiCamImportArgs, Pipe, Pipelines, PipelineParams, SaveAttributeArgs, SaveAttributeTrackFilterArgs, SaveDetectionsArgs, TrainingConfigs, + DatasetCalibrationResult, SegmentationPredictRequest, SegmentationPredictResponse, SegmentationStatusResponse, SegmentationStereoSegmentRequest, SegmentationStereoSegmentResponse, } from 'dive-common/apispec'; @@ -16,6 +17,7 @@ import { import { DesktopMetadata, NvidiaSmiReply, RunPipeline, RunTraining, ExportTrainedPipeline, ExportDatasetArgs, ExportConfigurationArgs, + ExportMulticamEverythingArgs, DesktopMediaImportResponse, ConversionArgs, JobType, DesktopJob, } from 'platform/desktop/constants'; @@ -268,6 +270,32 @@ async function exportConfiguration(id: string): Promise { return ''; } +async function exportMulticamEverything( + id: string, + exclude: boolean, + typeFilter: readonly string[], +): Promise { + const parentId = id.split('/')[0]; + const location = await window.diveDesktop.showSaveDialog({ + title: 'Export Multicamera Dataset', + defaultPath: joinPath( + await window.diveDesktop.getAppPath('home'), + `${parentId}.zip`, + ), + filters: [{ name: 'Zip archive', extensions: ['zip'] }], + }); + if (!location.canceled && location.filePath) { + const args: ExportMulticamEverythingArgs = { + id: parentId, + exclude, + path: location.filePath, + typeFilter: new Set(typeFilter), + }; + return window.diveDesktop.invoke('export-multicam-everything', args); + } + return ''; +} + async function cancelJob(job: DesktopJob): Promise { return window.diveDesktop.invoke('cancel-job', job); } @@ -507,7 +535,7 @@ function getTileURL(itemId: string, x: number, y: number, level: number, query: async function loadMetadata(id: string) { const client = await getClient(); const { data } = await client.get(`dataset/${id}/meta`); - return data; + return { ...data, calibration: data.multiCam?.calibration ?? null }; } async function loadDetections(datasetId: string) { @@ -548,6 +576,34 @@ function saveCalibration(path: string): Promise<{ savedPath: string; updatedData return window.diveDesktop.invoke('save-calibration', { path }); } +function importCalibrationFile(datasetId: string, path: string): Promise<{ calibration: string }> { + return window.diveDesktop.invoke('import-calibration', { id: datasetId, path }); +} + +function exportCalibrationFile(datasetId: string, destPath: string): Promise<{ exportedPath: string }> { + return window.diveDesktop.invoke('export-calibration', { id: datasetId, destPath }); +} + +function getDatasetCalibration(datasetId: string): Promise { + return window.diveDesktop.invoke('get-dataset-calibration', { datasetId }); +} + +async function downloadCalibration(datasetId: string): Promise { + const calibration = await getDatasetCalibration(datasetId); + const defaultName = calibration?.path ?? `calibration_${datasetId}.json`; + const location = await window.diveDesktop.showSaveDialog({ + title: 'Export Camera File', + defaultPath: joinPath(await window.diveDesktop.getAppPath('home'), defaultName), + }); + if (!location.canceled && location.filePath) { + await exportCalibrationFile(datasetId, location.filePath); + } +} + +function deleteCalibration(datasetId: string): Promise { + return window.diveDesktop.invoke('delete-calibration', { datasetId }); +} + export { /* Standard Specification APIs */ loadMetadata, @@ -568,6 +624,7 @@ export { /* Nonstandard APIs */ exportDataset, exportConfiguration, + exportMulticamEverything, finalizeImport, convert, importMedia, @@ -586,6 +643,11 @@ export { cancelJob, getLastCalibration, saveCalibration, + importCalibrationFile, + exportCalibrationFile, + getDatasetCalibration, + downloadCalibration, + deleteCalibration, /* Segmentation APIs */ segmentationInitialize, segmentationPredict, diff --git a/client/platform/desktop/frontend/components/DatasetSourceInfo.vue b/client/platform/desktop/frontend/components/DatasetSourceInfo.vue new file mode 100644 index 000000000..154331978 --- /dev/null +++ b/client/platform/desktop/frontend/components/DatasetSourceInfo.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/client/platform/desktop/frontend/components/Export.vue b/client/platform/desktop/frontend/components/Export.vue index f05f1e516..561f216a2 100644 --- a/client/platform/desktop/frontend/components/Export.vue +++ b/client/platform/desktop/frontend/components/Export.vue @@ -3,11 +3,18 @@ import { defineComponent, reactive, computed, toRef, watch, ref, } from 'vue'; -import { usePendingSaveCount, useHandler, useTrackFilters } from 'vue-media-annotator/provides'; +import { + usePendingSaveCount, useHandler, useTrackFilters, useSelectedCamera, +} from 'vue-media-annotator/provides'; import AutosavePrompt from 'dive-common/components/AutosavePrompt.vue'; -import { loadMetadata, exportDataset, exportConfiguration } from 'platform/desktop/frontend/api'; +import { MultiType } from 'dive-common/constants'; +import { + loadMetadata, exportDataset, exportConfiguration, exportCalibrationFile, exportMulticamEverything, +} from 'platform/desktop/frontend/api'; import type { JsonMeta } from 'platform/desktop/constants'; +type ExportType = 'dataset' | 'configuration' | 'trackJSON' | 'coco' | 'everything'; + export default defineComponent({ name: 'Export', @@ -18,9 +25,13 @@ export default defineComponent({ type: String, required: true, }, - small: { - type: Boolean, - default: false, + buttonOptions: { + type: Object, + default: () => ({}), + }, + menuOptions: { + type: Object, + default: () => ({}), }, }, @@ -35,14 +46,18 @@ export default defineComponent({ outPath: '', }); const savePrompt = ref(false); + const pendingExportType = ref('dataset'); const pendingSaveCount = usePendingSaveCount(); const { save } = useHandler(); const { checkedTypes } = useTrackFilters(); + const selectedCamera = useSelectedCamera(); + + const parentId = computed(() => props.id.split('/')[0]); watch(toRef(data, 'menuOpen'), async (newval) => { if (newval) { - data.meta = await loadMetadata(props.id); + data.meta = await loadMetadata(parentId.value); } else { data.err = null; data.outPath = ''; @@ -54,29 +69,68 @@ export default defineComponent({ ? Object.keys(data.meta.confidenceFilters || {}) : [])); - async function doExport({ type, forceSave = false }: { type: 'dataset' | 'configuration' | 'trackJSON' | 'coco'; forceSave?: boolean}) { + const isMulticamDataset = computed(() => data.meta?.type === MultiType); + + const activeCameraName = computed(() => { + if (selectedCamera.value) { + return selectedCamera.value; + } + const parts = props.id.split('/'); + return parts.length > 1 ? parts[1] : null; + }); + + const calibrationFile = computed(() => data.meta?.multiCam?.calibration ?? null); + const cameraFileSupported = computed( + () => data.meta?.subType === 'stereo' && !!calibrationFile.value, + ); + + async function exportCameraFile() { + if (!calibrationFile.value) return; + const calName = calibrationFile.value.replace(/^.*[\\/]/, ''); + const location = await window.diveDesktop.showSaveDialog({ + title: 'Export Camera File', + defaultPath: calName, + }); + if (location.canceled || !location.filePath) return; + try { + data.err = null; + await exportCalibrationFile(parentId.value, location.filePath); + data.outPath = location.filePath; + } catch (err) { + data.err = err; + throw err; + } + } + + async function doExport({ type, forceSave = false }: { type: ExportType; forceSave?: boolean}) { if (pendingSaveCount.value > 0 && forceSave) { await save(); savePrompt.value = false; } else if (pendingSaveCount.value > 0) { + pendingExportType.value = type; savePrompt.value = true; return; } try { + const typeFilter = data.excludeUncheckedTypes ? checkedTypes.value : []; if (type === 'dataset') { - const typeFilter = data.excludeUncheckedTypes ? checkedTypes.value : []; data.err = null; data.outPath = await exportDataset(props.id, data.excludeBelowThreshold, typeFilter); } else if (type === 'trackJSON') { - const typeFilter = data.excludeUncheckedTypes ? checkedTypes.value : []; data.err = null; data.outPath = await exportDataset(props.id, data.excludeBelowThreshold, typeFilter, 'json'); } else if (type === 'coco') { - const typeFilter = data.excludeUncheckedTypes ? checkedTypes.value : []; data.err = null; data.outPath = await exportDataset(props.id, data.excludeBelowThreshold, typeFilter, 'coco'); } else if (type === 'configuration') { data.outPath = await exportConfiguration(props.id); + } else if (type === 'everything') { + data.err = null; + data.outPath = await exportMulticamEverything( + parentId.value, + data.excludeBelowThreshold, + typeFilter, + ); } } catch (err) { data.err = err; @@ -87,9 +141,15 @@ export default defineComponent({ return { data, doExport, + exportCameraFile, + cameraFileSupported, + calibrationFile, savePrompt, + pendingExportType, thresholds, checkedTypes, + isMulticamDataset, + activeCameraName, }; }, }); @@ -100,33 +160,31 @@ export default defineComponent({ v-model="data.menuOpen" :close-on-content-click="false" :nudge-width="280" - offset-y + v-bind="menuOptions" max-width="280" > + + diff --git a/client/platform/desktop/frontend/components/Recent.vue b/client/platform/desktop/frontend/components/Recent.vue index 3b95275d9..ae0fad5a2 100644 --- a/client/platform/desktop/frontend/components/Recent.vue +++ b/client/platform/desktop/frontend/components/Recent.vue @@ -27,6 +27,7 @@ import { } from '../store/settings'; import { setOrGetConversionJob, cpuJobQueue, queuedCpuJobs } from '../store/jobs'; import BrowserLink from './BrowserLink.vue'; +import DatasetSourceInfo from './DatasetSourceInfo.vue'; import NavigationBar from './NavigationBar.vue'; import ImportDialog from './ImportDialog.vue'; import BulkImportDialog from './BulkImportDialog.vue'; @@ -34,6 +35,7 @@ import BulkImportDialog from './BulkImportDialog.vue'; export default defineComponent({ components: { BrowserLink, + DatasetSourceInfo, ImportButton, ImportDialog, BulkImportDialog, @@ -544,12 +546,18 @@ export default defineComponent({ > {{ item.name }} -
- {{ - item.imageListPath - || item.originalBasePath - || 'Data imported from several locations' - }} +
+ + + {{ + item.imageListPath + || item.originalBasePath + || 'Data imported from several locations' + }} +
diff --git a/client/platform/desktop/frontend/components/ViewerLoader.vue b/client/platform/desktop/frontend/components/ViewerLoader.vue index 27da7c806..74405d6cc 100644 --- a/client/platform/desktop/frontend/components/ViewerLoader.vue +++ b/client/platform/desktop/frontend/components/ViewerLoader.vue @@ -5,6 +5,7 @@ import { import Viewer from 'dive-common/components/Viewer.vue'; import RunPipelineMenu from 'dive-common/components/RunPipelineMenu.vue'; import ImportAnnotations from 'dive-common//components/ImportAnnotations.vue'; +import CalibrationMenu from 'dive-common/components/CalibrationMenu.vue'; import SidebarContext from 'dive-common/components/SidebarContext.vue'; import context from 'dive-common/store/context'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; @@ -26,6 +27,7 @@ import { } from 'platform/desktop/frontend/api'; import Export from './Export.vue'; import JobTab from './JobTab.vue'; +import DatasetSourceInfo from './DatasetSourceInfo.vue'; import { datasets } from '../store/dataset'; import { settings } from '../store/settings'; import { runningJobs } from '../store/jobs'; @@ -55,10 +57,12 @@ export default defineComponent({ components: { Export, JobTab, + DatasetSourceInfo, RunPipelineMenu, SidebarContext, Viewer, ImportAnnotations, + CalibrationMenu, ...context.getComponents(), }, props: { @@ -1270,6 +1274,29 @@ export default defineComponent({ }); } + async function applyCalibrationAfterImport() { + try { + const hasStereo = await loadStereoMetadata(); + if (!hasStereo) return; + const result = await stereoEnable(undefined, stereoCalibrationFile); + if (!result.success) return; + stereoEnabled.value = true; + await ensureStereoFrame(getViewerFrame()); + } catch (err) { + console.warn('[Stereo] Failed to apply calibration after import:', err); + } + } + + function onCalibrationImported(calibrationPath: string) { + const dataset = datasets.value[props.id]; + if (dataset) { + dataset.calibration = calibrationPath; + } + stereoCalibrationFile = calibrationPath; + if (!stereoServiceWanted()) return; + applyCalibrationAfterImport(); + } + return { datasets, viewerRef, @@ -1295,6 +1322,7 @@ export default defineComponent({ handleStereoAnnotationReset, handleStereoSegmentationFinalize, handleStereoTrackLinked, + onCalibrationImported, }; }, }); @@ -1313,6 +1341,9 @@ export default defineComponent({ @stereo-segmentation-finalize="handleStereoSegmentationFinalize" @stereo-track-linked="handleStereoTrackLinked" > + + +