diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..a349c6571 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,26 @@ +name: Publish Docs +on: + workflow_dispatch: + release: + types: [published] + push: + branches: + - main + paths: + - 'docs/**' + - 'mkdocs.yml' + +jobs: + docs: + name: Deploy docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: ${{ github.event_name == 'release' && github.event.release.target_commitish || github.ref }} + - name: Deploy docs + uses: mhausenblas/mkdocs-deploy-gh-pages@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CONFIG_FILE: mkdocs.yml + EXTRA_PACKAGES: build-base diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8c29fe59..47681c303 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Electron Build and Docs Deployment +name: Electron Build on: release: types: [published] @@ -43,21 +43,3 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: asset_paths: '["./client/dist_electron/DIVE-Desktop*"]' - - docs: - name: Deploy docs - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - # "ref" specifies the branch to check out. - # "github.event.release.target_commitish" is a global variable and specifies the branch the release targeted - ref: ${{ github.event.release.target_commitish }} - - # Deploy docs - - name: Deploy docs - uses: mhausenblas/mkdocs-deploy-gh-pages@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CONFIG_FILE: mkdocs.yml - EXTRA_PACKAGES: build-base diff --git a/WEB_MULTICAM_PLAN.MD b/WEB_MULTICAM_PLAN.MD index c39a30740..0fc753ae9 100644 --- a/WEB_MULTICAM_PLAN.MD +++ b/WEB_MULTICAM_PLAN.MD @@ -2,23 +2,39 @@ Bring stereo/multicam parity to the Girder/web platform by modeling multicam as a Girder parent folder of type `multi` containing one DIVE child folder per camera, adding server endpoints + metadata to expose `multiCamMedia`, reproducing the desktop `ImportMultiCamDialog` upload flow on the web, and removing the existing "not supported on web" guards. +## Phase status + +| Phase | Scope | Status | +|-------|--------|--------| +| **1** | Server data model + read path | **Done** (merged) | +| **2** | Web viewer read-only multicam | **Done** (merged) | +| **3** | Web upload + `POST /dive_dataset/multicam` | **Done** (merged) | +| **4** | Pipelines + calibration | **Done** — merge `multicam-web-features-phase-4` → `multicam-web-feature` | +| **5** | Export, clone, integration tests | **Next** | + ## Implementation Checklist -- [x] **Server: constants & models** — Add `MultiType` + multiCam/subType/calibration markers and pydantic models (`server/dive_utils/constants.py`, `server/dive_utils/models.py`) -- [x] **Server: verify_dataset** — Relax `verify_dataset` to accept `type=multi`; keep fps mirrored from default-display camera -- [x] **Server: get_dataset / multiCamMedia** — Extend `crud_dataset.get_dataset` to embed `multiCamMedia` by fanning out to child `get_media` calls -- [x] **Server: create multicam** — Implement `POST /dive_dataset/multicam` and `crud_dataset.create_multicam` to move child folders + write parent meta + attach calibration -- [ ] **Server: clone & export** — Extend `createSoftClone` and `export_datasets_zipstream` to recurse into child cameras; include calibration -- [ ] **Server: pipelines** — Add multicam/stereo pipeline dispatch in `crud_rpc` that fans inputs/outputs per camera and passes calibration for stereo -- [x] **Web API** — Add `createMulticamDataset`, `uploadCalibration` helpers in `client/platform/web-girder/api/dataset.service.ts` -- [x] **Web store** — Remove the `multi is not supported` guard in `useDataset.ts` and attach `multiCamMedia` -- [x] **Web API: ID rewrite** — Rewrite `${parentId}/${camera}` to child folder id inside `getDataset` / `getDatasetMedia` -- [x] **Web upload** — Replace `multiCamImportCheck` / `multiCamImport` stubs; orchestrate per-camera uploads + call create_multicam endpoint -- [x] **Web calibration shims** — Provide `getLastCalibration` / `saveCalibration` shims so `ImportMultiCamDialog` works without desktop -- [x] **Web ViewerLoader** — Wire `subTypeList` + `camNumbers` into `RunPipelineMenu` -- [ ] **Web export & clone** — Drop multi-guard in `Export.vue` and verify `Clone.vue` handles parent folders -- [x] **dive-common Viewer** — Drop `'diveDesktop' in window` gate so MultiCamToolbar/camera dropdown work on web -- [ ] **Tests & docs** — Server multicam integration test, success-path frontend test, update `docs/Multicamera-data.md` / `docs/Web-Version.md` +### Phases 1–4 (complete) + +- [x] **Server: constants & models** — `MultiType`, multiCam/subType/calibration markers, pydantic models (`server/dive_utils/constants.py`, `server/dive_utils/types.py`) +- [x] **Server: verify_dataset** — Accept `type=multi`; validate `multiCam` cameras + `fps` on parent +- [x] **Server: get_dataset / multiCamMedia** — `get_multi_cam_media` embedded in `get_dataset` responses +- [x] **Server: create multicam** — `POST /dive_dataset/multicam` + `crud_dataset.create_multicam` (move children, parent meta, calibration) +- [x] **Server: pipelines** — `crud_rpc` dispatches stereo/multicam jobs; `dive_tasks/multicam_pipeline.py` + `tasks.run_pipeline` fan out per-camera media, detections, and calibration for measurement pipelines; `pipeline_discovery.py` includes measurement / 2-cam / 3-cam and excludes web-inapplicable pipes (e.g. seagis) +- [x] **Web API** — `createMulticamDataset`, calibration upload, `multicamResolve` for `${parentId}/${camera}` (`client/platform/web-girder/api/`) +- [x] **Web store** — `useDataset.ts` loads `multi` + `multiCamMedia`; browse path resolves multicam root +- [x] **Web upload** — `multiCamImport` / `multiCamImportCheck` in `Upload.vue`; stereoscopic + multicam import from upload dialog +- [x] **Web calibration shims** — `getLastCalibration` / `saveCalibration` for `ImportMultiCamDialog` on web +- [x] **Web ViewerLoader / Home** — `subTypeList` + `cameraNumbers` on `RunPipelineMenu`; pipeline menu filters by stereo vs multicam and camera count (`pipelineMenuFilters.ts`) +- [x] **dive-common Viewer** — MultiCam toolbar / camera dropdown on web (no `diveDesktop` gate); data browser stereo/multicam icons (`multicamDisplay.ts`) +- [x] **Tests (unit)** — `server/tests/test_create_multicam.py`, `test_multicam_dataset.py`, `test_multicam_pipeline.py`, `test_pipeline_discovery.py`; frontend success paths in `webGirderStoreComposables.spec.ts` +- [x] **Docs (user-facing)** — `docs/Multicamera-data.md` (web vs desktop matrix), `docs/Web-Version.md` (stereo/multicam upload) + +### Phase 5 (remaining) + +- [ ] **Server: clone & export** — Extend `createSoftClone` to recurse child camera folders and rewrite `multiCam.cameras[*].folderId`; extend `export_datasets_zipstream` for `type=multi` (per-camera paths, `multiCam.json`, calibration) +- [ ] **Web export & clone** — Remove `MultiType` throw in `Export.vue`; wire export to new server zip behavior; verify or restrict clone for multicam parents (`createSoftClone` today is shallow — cloned parents still reference original child folder IDs) +- [ ] **Tests (integration)** — Multicam create + full export path in `server/tests/integration/test_download_extract.py` --- @@ -28,13 +44,14 @@ Bring stereo/multicam parity to the Girder/web platform by modeling multicam as - Allow a Girder user to upload N cameras (stereo = exactly 2, multicam = 2 or 3) in a single import flow and produce a viewable, annotatable multicam dataset that uses the existing `dive-common` multicam viewer code path. - Persist a stereo calibration file (`.npz`) per parent dataset and surface it to pipelines. -- Let multicam datasets be cloned, exported, and run through multicam/stereo pipelines from the web UI. -- Reach feature parity with the desktop multicam behavior described in [docs/Multicamera-data.md](docs/Multicamera-data.md). +- Let multicam datasets run through multicam/stereo pipelines from the web UI. **Done (Phase 4).** +- Let multicam datasets be cloned and exported from the web UI. **Phase 5.** +- Reach feature parity with the desktop multicam behavior described in [docs/Multicamera-data.md](docs/Multicamera-data.md) except where noted below. -**Non-Goals (Phase 1)** +**Non-Goals / deferred** -- Glob/keyword pattern import (desktop's `MultiCamImportKeywordArgs`). Phase 2. -- Per-camera revision divergence (we will rev each camera folder independently, no cross-camera revision linking). +- Glob/keyword pattern import (desktop's `MultiCamImportKeywordArgs`) — web shows an explicit error in `Upload.vue`; still desktop-only. +- Per-camera revision divergence (revisions remain per child folder; no cross-camera revision linking). - Mixing camera media types (all cameras must be either `image-sequence` or all `video`, matching desktop). ## 2. Data Model @@ -46,93 +63,63 @@ flowchart TD Parent["Parent Foldermeta.annotate=truemeta.type=multimeta.subType=stereo|multicammeta.multiCam={cameras, defaultDisplay, calibrationItemId}"] Left["Child: leftmeta.type=video|image-sequence(full DIVE dataset)"] Right["Child: rightmeta.type=video|image-sequence(full DIVE dataset)"] - CalFile[".npz calibration file(Girder item under parent aux)"] + CalFile[".npz calibration file(Girder item under parent)"] Parent --> Left Parent --> Right Parent --> CalFile ``` -Key meta on the parent (new): +Key meta on the parent: - `type = "multi"` - `subType = "stereo" | "multicam"` -- `multiCam = { defaultDisplay: str, cameras: { [name]: { folderId: str, type: "video"|"image-sequence" } }, calibrationItemId?: str }` -- `fps`: copied from `defaultDisplay` camera (kept for compatibility with `verify_dataset`) +- `multiCam = { defaultDisplay, cameras: { [name]: { folderId, type } }, cameraOrder?, calibrationItemId? }` +- `fps`: copied from `defaultDisplay` camera (compatibility with `verify_dataset`) - `annotate = true` -Child folders are unchanged from today: standard DIVE datasets, named after the camera (`left`, `right`, `camera1`, ...). They are uniquely identified by their own ObjectId. Annotations are stored against the child folder ID. +Child folders are standard DIVE datasets, named after the camera (`left`, `right`, `camera1`, ...). Annotations are stored against the child folder ID. -ID composition: the frontend already uses `${baseId}/${camera}` (see [client/dive-common/components/Viewer.vue](client/dive-common/components/Viewer.vue) lines 766-789). We will keep this composite ID convention but make the web `loadMetadata` / `loadDetections` resolve `${parentId}/${cameraName}` to the actual child folder ObjectId on the way to Girder. +ID composition: the frontend uses `${baseId}/${camera}` ([client/dive-common/components/Viewer.vue](client/dive-common/components/Viewer.vue)). Web resolves this via [client/platform/web-girder/api/multicamResolve.ts](client/platform/web-girder/api/multicamResolve.ts) in `getDataset` / `getDatasetMedia`. ## 3. Server Changes (Girder / Python) -### 3.1 Constants & Models - -- [server/dive_utils/constants.py](server/dive_utils/constants.py): add `MultiType = "multi"`, `MultiCamMarker = "multiCam"`, `SubTypeMarker = "subType"`, `CalibrationMarker = "calibrationItemId"`, plus a `npzRegex`. -- [server/dive_utils/models.py](server/dive_utils/models.py): - - Add `MultiCamCamera`, `MultiCamMediaCamera`, and `MultiCamMeta` pydantic models matching the frontend `MultiCamMedia` shape in [client/dive-common/apispec.ts](client/dive-common/apispec.ts) lines 150-157. - - Extend `GirderMetadataStatic` with `subType: Optional[Literal['stereo','multicam']]` and `multiCamMedia: Optional[MultiCamMediaResponse]`. - - Extend `DatasetSourceMedia` (or add a sibling response model) so `get_media` can return per-camera arrays when type is `multi`. +### 3.1 Constants & Models — done -### 3.2 CRUD: relaxations +- [server/dive_utils/constants.py](server/dive_utils/constants.py): `MultiType`, `MultiCamMarker`, `SubTypeMarker`, calibration markers, `StereoPipelineMarker` (`measurement`), `MultiCamPipelineMarkers` (`2-cam`, `3-cam`), `stereoCalibrationRegex`. +- [server/dive_utils/types.py](server/dive_utils/types.py): multicam job / media types used by RPC and Celery tasks. -- [server/dive_server/crud.py](server/dive_server/crud.py) `verify_dataset` (lines 99-110): allow `MultiType` (no `fps` requirement at parent — fps lives on the children, but mirror it on the parent on creation for backwards-compatibility with callers that read it). -- `getCloneRoot` already follows `ForeignMediaIdMarker`; we will set `ForeignMediaIdMarker` on a cloned parent and clone each child folder individually, preserving the parent/child shape. - -### 3.3 New endpoint: create multicam - -Add to [server/dive_server/views_dataset.py](server/dive_server/views_dataset.py): - -``` -POST /dive_dataset/multicam - body: { - parentFolderId: str, - name: str, - fps: number, - type: "video" | "image-sequence", - subType: "stereo" | "multicam", - defaultDisplay: str, - cameras: { [name]: { folderId: str } }, # each child folder must already exist & contain media - calibrationFileId?: str, # optional, for stereo - } -``` +### 3.2 CRUD: relaxations — done -Implemented in `crud_dataset.create_multicam(...)`: +- [server/dive_server/crud.py](server/dive_server/crud.py) `verify_dataset`: accepts `MultiType` and validates `multiCam` structure. -1. Create parent folder under `parentFolderId`. -2. Write parent meta (DatasetMarker, type=multi, subType, multiCam, fps copied from default camera). -3. For each `cameras[name].folderId`, `Folder().move(child, parentFolder)`, rename to `name`, validate that it is a valid DIVE dataset with the same `type`, same frame count (or video duration), and same fps. -4. If `calibrationFileId` is given (only for `stereo`), validate `.npz`, move the item into the parent's auxiliary folder, store its id under `meta.multiCam.calibrationItemId`. +### 3.3 Create multicam endpoint — done -This flow assumes the cameras have already been uploaded as ordinary single-camera DIVE datasets via the existing upload (`UploadGirder.vue`), which matches the chosen "full upload flow" UX (Section 4.2): upload N child datasets via the existing path inside the dialog, then call this endpoint to link them. +`POST /dive_dataset/multicam` in [server/dive_server/views_dataset.py](server/dive_server/views_dataset.py) → `crud_dataset.create_multicam(...)`. -### 3.4 Media: expose multiCamMedia +### 3.4 Media: multiCamMedia — done -In [server/dive_server/crud_dataset.py](server/dive_server/crud_dataset.py): - -- `get_dataset` (lines 96-107): when `type == multi`, include `multiCamMedia` built by iterating `meta.multiCam.cameras` and calling the existing `get_media` for each child folder, then mapping into the `MultiCamMedia` shape the frontend expects. -- `get_media` (lines 110-176): for `multi`, return an empty `imageData` (the per-camera media is keyed in `multiCamMedia`); or alternatively raise and require the client to call `/media` on each child id. Recommendation: have `get_meta` embed `multiCamMedia` (cleanest, single round trip). +- `get_dataset` embeds `multiCamMedia` via `get_multi_cam_media` when `type == multi`. ### 3.5 Pipelines, postprocess, export, clone -- **Pipelines** ([server/dive_server/crud_rpc.py](server/dive_server/crud_rpc.py)): a multicam pipeline run is dispatched on the parent folder; the job needs to fan out per-camera inputs (mirrors desktop's `writeMultiCamStereoPipelineArgs` in [client/platform/desktop/backend/native/multiCamUtils.ts](client/platform/desktop/backend/native/multiCamUtils.ts) lines 78-139). New helper `crud_rpc.run_multicam_pipeline(parent, pipeline)` that walks `meta.multiCam.cameras`, materializes per-camera input lists, includes the calibration file when `subType == stereo`, and writes outputs back to each child folder. -- **Postprocess**: child folders already postprocess themselves. Adding a multicam parent only needs to set `ConfidenceFiltersMarker` and skip the existing image/video-specific branches. -- **Export** ([server/dive_server/crud_dataset.py](server/dive_server/crud_dataset.py) `export_datasets_zipstream`): when a parent of type `multi` is exported, recurse into each child folder and place its export under `//`. Include `multiCam.json` at the parent level. Include the calibration file when present. -- **Clone** (`createSoftClone`, lines 25-53): when source is `multi`, also clone each child folder (recursively, preserving names) and rewrite `meta.multiCam.cameras[name].folderId` on the cloned parent to point at the cloned children. Calibration item is referenced by id; we either copy it or share by reference (recommend copy). +| Area | Status | Notes | +|------|--------|--------| +| **Pipelines** | **Done** | `crud_rpc` builds multicam job args; `dive_tasks/tasks.py` downloads per-camera media + optional detections; `multicam_pipeline.py` builds KWIVER `-s` settings; stereo measurement jobs attach calibration via `resolve_stereo_calibration_item_id` | +| **Pipeline discovery** | **Done** | `pipeline_discovery.py` allowlist matches desktop; seagis and other web-inapplicable pipes filtered | +| **Postprocess** | **Done** | Child folders postprocess independently (unchanged) | +| **Export** | **Phase 5** | `export_datasets_zipstream` does not recurse multicam children yet | +| **Clone** | **Phase 5** | `createSoftClone` does not clone child cameras or rewrite `multiCam.cameras`; UI clone may produce broken multicam datasets until fixed | ## 4. Frontend Changes (web-girder) -### 4.1 API + Store +### 4.1 API + Store — done -- [client/platform/web-girder/api/dataset.service.ts](client/platform/web-girder/api/dataset.service.ts): - - Add `createMulticamDataset(args)` POSTing to the new endpoint. - - Add `uploadCalibration(parentFolderId, file)`. - - `getDatasetMedia` already returns child media; add `getDatasetMultiCamMedia(parentFolderId)` thin wrapper that returns the per-camera media map from `getDataset` meta. -- [client/platform/web-girder/store/useDataset.ts](client/platform/web-girder/store/useDataset.ts) lines 31-33: remove the throw; when `dsMeta.type === MultiType`, attach `multiCamMedia` from the static metadata response and skip the single-camera `videoUrl`/`imageData` assignment. +- `createMulticamDataset`, calibration helpers, `${parentId}/${camera}` resolution in [client/platform/web-girder/api/dataset.service.ts](client/platform/web-girder/api/dataset.service.ts). +- [client/platform/web-girder/store/useDataset.ts](client/platform/web-girder/store/useDataset.ts) loads `multi` + `multiCamMedia`. -### 4.2 Upload UX — full flow +### 4.2 Upload UX — done -Replace the `multiCamImportCheck` and `multiCamImport` TODOs in [client/platform/web-girder/views/Upload.vue](client/platform/web-girder/views/Upload.vue) lines 247-259. +Stereoscopic / MultiCam paths in [client/platform/web-girder/views/Upload.vue](client/platform/web-girder/views/Upload.vue) orchestrate per-camera upload then `POST /dive_dataset/multicam`. ```mermaid sequenceDiagram @@ -149,69 +136,53 @@ sequenceDiagram W->>G: (optional) upload + import per-camera annotation file end W->>G: (optional) upload calibration npz - W->>G: POST dive_dataset/multicam {cameras, defaultDisplay, subType, calibrationFileId} + W->>G: POST dive_dataset/multicam G-->>W: parent folder id W->>U: navigate to /viewer/ ``` -UI work: - -- Reuse [client/dive-common/components/ImportMultiCamDialog.vue](client/dive-common/components/ImportMultiCamDialog.vue) by giving it a web-flavored `importMedia` and a web `openFromDisk` — the dialog only depends on `useApi().openFromDisk`, `getLastCalibration`, `saveCalibration`. Provide web shims for the last two (no-ops or local-storage backed). -- The dialog currently calls `openFromDisk('image-sequence', directory: true)`; on web that already opens a directory picker via [client/platform/web-girder/utils.ts](client/platform/web-girder/utils.ts). Need to confirm directory-mode actually returns `webkitdirectory` `File[]`. -- After the dialog emits `begin-multicam-import`, drive an orchestrator in Upload.vue that uploads each camera using the existing `UploadGirder.vue` mixin code (loop, not concurrent at first), then calls the new endpoint. - -### 4.3 Viewer + sidebar/toolbar - -- [client/platform/web-girder/views/ViewerLoader.vue](client/platform/web-girder/views/ViewerLoader.vue): - - Wire `subTypeList` and `camNumbers` props on `RunPipelineMenu` (mirrors desktop [client/platform/desktop/frontend/components/ViewerLoader.vue](client/platform/desktop/frontend/components/ViewerLoader.vue) lines 46-47). - - Read `datasetMeta.subType` and `Object.keys(datasetMeta.multiCamMedia?.cameras ?? {}).length`. -- [client/dive-common/components/Viewer.vue](client/dive-common/components/Viewer.vue) line 1028-1033: `showMultiCamToolbar` is currently gated on `'diveDesktop' in window`. Remove that condition so the toolbar/camera dropdown work on web too. - -### 4.4 Export + Clone + Pipelines +### 4.3 Viewer + pipelines UI — done -- [client/platform/web-girder/views/Export.vue](client/platform/web-girder/views/Export.vue) line 211: replace the throw with a multicam export URL that points to the new server zip behavior; the existing UI can stay the same since the server zip handles per-camera. -- [client/platform/web-girder/views/Clone.vue](client/platform/web-girder/views/Clone.vue): the server clone already recurses; verify the UI's "include annotations" path still works against a parent. -- `RunPipelineMenu` will already filter stereo/multicam pipelines correctly once `subTypeList` and `cameraNumbers` are passed (see [client/dive-common/components/RunPipelineMenu.vue](client/dive-common/components/RunPipelineMenu.vue) lines 148-203). +- [client/platform/web-girder/views/ViewerLoader.vue](client/platform/web-girder/views/ViewerLoader.vue) and [Home.vue](client/platform/web-girder/views/Home.vue): `subTypeList`, `cameraNumbers` → `RunPipelineMenu`. +- [client/dive-common/components/Viewer.vue](client/dive-common/components/Viewer.vue): MultiCam toolbar on web when `multiCamList.length > 1`. +- [client/dive-common/pipelineMenuFilters.ts](client/dive-common/pipelineMenuFilters.ts): measurement for all-stereo selection; 2-cam / 3-cam for matching multicam count; `webExcludedPipelineTerms` includes `seagis`. -### 4.5 ID resolution for `${baseId}/${camera}` +### 4.4 Export + Clone — Phase 5 -The frontend in [client/dive-common/components/Viewer.vue](client/dive-common/components/Viewer.vue) line 770 calls `loadMetadata(\`${baseMulticamDatasetId}/${camera}\`)`. The desktop intercepts that string; the web Girder REST client cannot, because the URL would 404. +- [client/platform/web-girder/views/Export.vue](client/platform/web-girder/views/Export.vue) L211: still throws `Cannot export multicamera dataset` (documented limitation in user docs). +- [client/platform/web-girder/views/Clone.vue](client/platform/web-girder/views/Clone.vue): no multicam-specific guard; server clone is not multicam-aware yet — treat as unsupported until Phase 5. -Two clean options, pick one in implementation: +### 4.5 ID resolution — done -- **(Preferred) Rewrite at the API service**: in [client/platform/web-girder/api/dataset.service.ts](client/platform/web-girder/api/dataset.service.ts), make `getDataset` and `getDatasetMedia` detect a `/` in the id, look up the parent meta from a tiny client cache (or fetch parent), resolve `cameraName -> folderId`, and call the real endpoint with the resolved id. This keeps `Viewer.vue` unchanged. -- Add an explicit `cameraFolderId` map prop to `Viewer.vue`. More invasive; affects desktop too. +Implemented in [client/platform/web-girder/api/multicamResolve.ts](client/platform/web-girder/api/multicamResolve.ts) (option 1 from original plan). -The plan uses option 1. +## 5. dive-common Touch Points — done -## 5. dive-common Touch Points +- `useApi()` web shims for calibration + multicam import. +- Viewer multicam path unchanged for desktop; web uses resolved folder IDs. -- [client/dive-common/apispec.ts](client/dive-common/apispec.ts): no API surface changes needed; `MultiCamImportArgs` already exists. Just make sure `useApi()` for web provides shim `getLastCalibration`/`saveCalibration` (currently desktop-only, see lines 233-234). -- [client/dive-common/components/Viewer.vue](client/dive-common/components/Viewer.vue) line 1028: drop `'diveDesktop' in window` gate (also covers MultiCamToolbar). +## 6. Stereo Calibration — done -## 6. Stereo Calibration - -- Stored as a Girder Item in the parent folder's auxiliary folder (`crud.get_or_create_auxiliary_folder`). -- Referenced by `meta.multiCam.calibrationItemId`. -- Download URL surfaced as `multiCamMedia.calibrationUrl` so pipeline jobs and the desktop importer can fetch it. -- Upload in the web dialog reuses the existing single-file upload pattern from `Upload.vue`'s annotation upload code. +- Calibration item under parent folder; `meta.multiCam.calibrationItemId` (and item `calibrationFile` marker). +- Pipeline jobs: `calibration_item_id` on measurement pipelines; worker downloads `.npz` and sets KWIVER `measurer:calibration_file` / `calibration_reader:file`. ## 7. Permissions, Revisions, Sets -- Parent folder permissions propagate to children via standard Girder semantics. Validate during `create_multicam` that the user has WRITE on each child being moved. -- Annotation revisions remain per-camera; the UI already supports this via per-camera `loadDetections` calls in [client/dive-common/components/Viewer.vue](client/dive-common/components/Viewer.vue) lines 783-789. -- Annotation sets work per-camera; we will surface the parent's "current set" route param but loadDetections is called against each camera independently. +- WRITE validated on child folders during `create_multicam`. +- Revisions and sets remain per-camera (viewer loads detections per `${parentId}/${camera}`). ## 8. Migration / Backward Compatibility -- No data migration needed; existing single-camera datasets are unaffected (the `multi` type is new). -- Tests: - - Server: extend [server/tests/integration/test_download_extract.py](server/tests/integration/test_download_extract.py) with a multicam create+download path. - - Frontend: extend [client/platform/web-girder/store/webGirderStoreComposables.spec.ts](client/platform/web-girder/store/webGirderStoreComposables.spec.ts) (which already mocks a `multi` failure case at line 270) to a success case. +- No migration; existing single-camera datasets unchanged. +- **Tests** + - [x] Server unit: create multicam, multicam media, pipeline helpers, pipeline discovery + - [x] Frontend: `webGirderStoreComposables.spec.ts` success paths for `multi` / stereo / 3-cam + - [ ] Integration: multicam create + export in `server/tests/integration/test_download_extract.py` -## 9. Documentation +## 9. Documentation — done (user); Phase 5 may add export/clone -- Update [docs/Multicamera-data.md](docs/Multicamera-data.md) to add a "Web Version" section and a flowchart for the upload/import process. Cross-link from [docs/Web-Version.md](docs/Web-Version.md). +- [docs/Multicamera-data.md](docs/Multicamera-data.md) — web vs desktop capability table, web import steps +- [docs/Web-Version.md](docs/Web-Version.md) — stereo/multicam upload section ## 10. Phased Rollout @@ -223,31 +194,57 @@ flowchart LR P2[Phase 2: Web viewer read-only multicam] P3[Phase 3: Web upload + create endpoint] P4[Phase 4: Pipelines + calibration] - P5[Phase 5: Export, clone, docs] + P5[Phase 5: Export, clone, integration tests] P1 --> P2 --> P3 --> P4 --> P5 + style P1 fill:#9f9,stroke:#333 + style P2 fill:#9f9,stroke:#333 + style P3 fill:#9f9,stroke:#333 + style P4 fill:#9f9,stroke:#333 + style P5 fill:#ff9,stroke:#333 ``` ## 11. Open Risks -- The aggregate media controller in `dive-common` assumes synchronized frame counts across cameras; the create endpoint must reject mismatched lengths up front (matches desktop validation in [client/dive-common/components/ImportMultiCamDialog.vue](client/dive-common/components/ImportMultiCamDialog.vue) lines 152-175). -- `webkitdirectory` directory uploads behave differently across browsers; the upload orchestrator must handle the file-list-based fallback the same way the existing `Upload.vue` does today. -- Removing the `diveDesktop` gate in `Viewer.vue` could change behavior for desktop users; we will keep the multicam-toolbar behind `multiCamList.length > 1` only, which it already checks. +- Synchronized frame counts across cameras are enforced at create time (matches desktop `ImportMultiCamDialog` validation). +- `webkitdirectory` behavior varies by browser; upload orchestrator uses the same file-list fallbacks as single-camera upload. +- **Clone before Phase 5:** cloning a multicam parent may reference original child folder IDs — do not rely on clone for multicam until server work lands. +- **Export:** explicitly blocked in web UI until Phase 5; per-camera annotation export may still be available from the viewer for the active camera. + +## 12. Current State (as of Phase 4 complete) + +### Shipped on web + +| Capability | Status | +|------------|--------| +| Import stereo (2 cameras + `.npz` calibration) | ✔️ | +| Import multicam (2 or 3 cameras) | ✔️ | +| View / annotate / MultiCamera Tools | ✔️ | +| Run measurement pipelines (stereo) | ✔️ | +| Run 2-cam / 3-cam pipelines (multicam) | ✔️ | +| Run standard single-camera pipelines on one view | ✔️ | +| Data browser stereo / multicam indicators | ✔️ | +| Seagis pipelines hidden on web | ✔️ | + +### Still outstanding (Phase 5) -## 12. Current State (Reference) +| Capability | Status | +|------------|--------| +| Export full multicam dataset as one `.zip` | ❌ (`Export.vue` guard + server zip) | +| Multicam-aware clone (child folders + meta rewrite) | ❌ | +| Glob / keyword multicam import | ❌ (by design; desktop-only) | -The following guards and stubs exist today and are targeted by this plan: +### Key implementation files (reference) -| Location | Issue | -|----------|--------| -| [client/platform/web-girder/store/useDataset.ts](client/platform/web-girder/store/useDataset.ts) L31-33 | `throw new Error('multi is not supported on web yet')` | -| [client/platform/web-girder/views/Upload.vue](client/platform/web-girder/views/Upload.vue) L247-259 | `multiCamImportCheck` / `multiCamImport` are TODO stubs | -| [client/platform/web-girder/views/Export.vue](client/platform/web-girder/views/Export.vue) L211 | `Cannot export multicamera dataset` | -| [client/dive-common/components/Viewer.vue](client/dive-common/components/Viewer.vue) L1028-1033 | MultiCam toolbar gated on `'diveDesktop' in window` | -| [server/dive_server/crud.py](server/dive_server/crud.py) L99-110 | `verify_dataset` rejects non image-sequence/video/large-image types | -| Server generally | No `multi` type, no `multiCam` metadata, no multicam create endpoint | +| Area | Files | +|------|--------| +| Server create / media | `server/dive_server/crud_dataset.py`, `views_dataset.py` | +| Server pipelines / jobs | `server/dive_server/crud_rpc.py`, `server/dive_tasks/tasks.py`, `server/dive_tasks/multicam_pipeline.py`, `server/dive_tasks/pipeline_discovery.py` | +| Web upload | `client/platform/web-girder/views/Upload.vue`, `multicamFileRegistry.ts` | +| Web viewer / pipelines UI | `ViewerLoader.vue`, `pipelineMenuFilters.ts`, `multicamDisplay.ts` | +| Shared viewer | `client/dive-common/components/Viewer.vue`, `MultiCamToolbar.vue` | Desktop reference implementation: - Import: [client/platform/desktop/backend/native/multiCamImport.ts](client/platform/desktop/backend/native/multiCamImport.ts) -- Media URLs: [client/platform/desktop/backend/native/multiCamUtils.ts](client/platform/desktop/backend/native/multiCamUtils.ts) +- Pipeline args: [client/platform/desktop/backend/native/multiCamUtils.ts](client/platform/desktop/backend/native/multiCamUtils.ts) - User docs: [docs/Multicamera-data.md](docs/Multicamera-data.md) diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index b8769690e..3ca8f69fd 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -230,6 +230,11 @@ interface Api { Promise<{canceled?: boolean; filePaths: string[]; fileList?: File[]; root?: string}>; /** Desktop: immediate child directory names under a parent folder (multicam subfolder import). */ listImmediateSubfolders?(parentPath: string): Promise; + /** Desktop: subfolders or root-level video files under a parent folder (multicam import). */ + listParentFolderCameras?( + parentPath: string, + mediaType: 'image-sequence' | 'video', + ): Promise<{ name: string; sourcePath: string }[]>; /** Desktop: folder path for image-sequence, or first video file inside the folder for video. */ resolveMulticamCameraSourcePath?( subfolderPath: string, diff --git a/client/dive-common/components/ImportMultiCamDialog/ImportMultiCamSubfolders.vue b/client/dive-common/components/ImportMultiCamDialog/ImportMultiCamSubfolders.vue index 34acd79e8..0e410be92 100644 --- a/client/dive-common/components/ImportMultiCamDialog/ImportMultiCamSubfolders.vue +++ b/client/dive-common/components/ImportMultiCamDialog/ImportMultiCamSubfolders.vue @@ -55,8 +55,9 @@ export default defineComponent({ dense class="mb-3" > - Choose a parent folder containing one subfolder per camera (2 or 3 subfolders). - Each subfolder name becomes the camera name (letters and numbers only). + Choose a parent folder with either one subfolder per camera (2 or 3 subfolders) + or separate video files in the folder (2 or 3 videos). Names come from the subfolder + or video file name (letters and numbers only). { }); it('rejects wrong folder count', () => { - expect(organizeSubfolderCameras(['only']).error).toMatch(/Expected 2 or 3/); - expect(organizeSubfolderCameras(['a', 'b', 'c', 'd']).error).toMatch(/Expected 2 or 3/); + expect(organizeSubfolderCameras(['only']).error).toMatch(/Expected 2 or 3 cameras/); + expect(organizeSubfolderCameras(['a', 'b', 'c', 'd']).error).toMatch(/Expected 2 or 3 cameras/); + }); +}); + +describe('isVideoFileName', () => { + it('recognizes common video extensions', () => { + expect(isVideoFileName('left.mp4')).toBe(true); + expect(isVideoFileName('right.MOV')).toBe(true); + expect(isVideoFileName('notes.txt')).toBe(false); + }); +}); + +describe('groupRootLevelVideoFiles', () => { + const mk = (path: string) => ({ webkitRelativePath: path, name: path.split('/').pop() } as File); + + it('groups videos directly under the parent folder by file stem', () => { + const groups = groupRootLevelVideoFiles([ + mk('stereo/left.mp4'), + mk('stereo/right.mp4'), + mk('stereo/readme.txt'), + ], 'stereo'); + expect([...groups.keys()].sort()).toEqual(['left', 'right']); + expect(groups.get('left')?.length).toBe(1); + expect(groups.get('right')?.length).toBe(1); + }); +}); + +describe('groupParentFolderByCamera', () => { + const mk = (path: string) => ({ webkitRelativePath: path, name: path.split('/').pop() } as File); + + it('prefers subfolders when at least two exist', () => { + const groups = groupParentFolderByCamera([ + mk('set/left/a.mp4'), + mk('set/right/b.mp4'), + mk('set/left_cam.mp4'), + ], { allowRootLevelVideos: true }, 'set'); + expect([...groups.keys()].sort()).toEqual(['left', 'right']); + }); + + it('falls back to root-level videos when there are no subfolders', () => { + const groups = groupParentFolderByCamera([ + mk('stereo/left.mp4'), + mk('stereo/right.mp4'), + ], { allowRootLevelVideos: true }, 'stereo'); + expect([...groups.keys()].sort()).toEqual(['left', 'right']); + const organized = organizeSubfolderCameras([...groups.keys()], { preferLeftForStereo: true }); + expect(organized.error).toBeNull(); + expect(organized.assignments.map((a) => a.cameraName)).toEqual(['left', 'right']); + }); + + it('does not use root-level videos when subfolder import is disabled', () => { + const groups = groupParentFolderByCamera([ + mk('stereo/left.mp4'), + mk('stereo/right.mp4'), + ], undefined, 'stereo'); + expect(groups.size).toBe(0); }); }); diff --git a/client/dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout.ts b/client/dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout.ts index c9fc58a8e..bdbc2af8a 100644 --- a/client/dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout.ts +++ b/client/dive-common/components/ImportMultiCamDialog/multicamSubfolderLayout.ts @@ -1,3 +1,5 @@ +import { fileVideoTypes } from 'dive-common/constants'; + /** Assign immediate child folders to multicam cameras (one camera per subfolder). */ export interface SubfolderCameraAssignment { @@ -162,7 +164,7 @@ export function organizeSubfolderCameras( if (unique.length < 2 || unique.length > 3) { return { ...empty, - error: `Expected 2 or 3 camera subfolders, found ${unique.length} (${unique.join(', ')})`, + error: `Expected 2 or 3 cameras (subfolders or video files), found ${unique.length} (${unique.join(', ')})`, }; } @@ -261,3 +263,62 @@ export function groupFilesByImmediateSubfolder( return groups; } + +export function isVideoFileName(fileName: string): boolean { + const parts = fileName.split('.'); + if (parts.length < 2) { + return false; + } + const ext = parts.pop()?.toLowerCase() ?? ''; + return fileVideoTypes.includes(ext); +} + +/** + * Group video files that sit directly in the selected parent folder (one camera per file). + * Camera keys are the file stem (basename without extension). + */ +export function groupRootLevelVideoFiles( + fileList: File[], + root = '', +): Map { + const groups = new Map(); + const paths = fileList.map((file) => file.webkitRelativePath || file.name); + const effectiveRoot = root || commonPathPrefix(paths); + + fileList.forEach((file, index) => { + const rel = paths[index]; + const path = stripPathPrefix(rel, effectiveRoot); + const parts = path.split('/').filter(Boolean); + if (parts.length !== 1 || !isVideoFileName(parts[0])) { + return; + } + const stem = parts[0].replace(/\.[^.]+$/, ''); + const existing = groups.get(stem) ?? []; + existing.push(file); + groups.set(stem, existing); + }); + + return groups; +} + +/** + * Group a parent-folder selection by camera: prefer immediate subfolders; for video imports, + * fall back to separate video files in the parent folder when there are not enough subfolders. + */ +export function groupParentFolderByCamera( + fileList: File[], + options?: { allowRootLevelVideos?: boolean }, + root = '', +): Map { + const subfolderGroups = groupFilesByImmediateSubfolder(fileList, root); + if (subfolderGroups.size >= 2) { + return subfolderGroups; + } + if (options?.allowRootLevelVideos) { + const videoGroups = groupRootLevelVideoFiles(fileList, root); + if (videoGroups.size >= 2) { + return videoGroups; + } + } + return subfolderGroups; +} diff --git a/client/dive-common/components/ImportMultiCamDialog/useImportMultiCamDialog.ts b/client/dive-common/components/ImportMultiCamDialog/useImportMultiCamDialog.ts index 5d4c2f680..1c95dfb85 100644 --- a/client/dive-common/components/ImportMultiCamDialog/useImportMultiCamDialog.ts +++ b/client/dive-common/components/ImportMultiCamDialog/useImportMultiCamDialog.ts @@ -17,7 +17,7 @@ import { import { applyParentPathToAssignments, commonPathPrefix, - groupFilesByImmediateSubfolder, + groupParentFolderByCamera, isValidCameraName, organizeSubfolderCameras, pickDefaultMulticamCamera, @@ -51,7 +51,7 @@ export function useImportMultiCamDialog( openFromDisk, getLastCalibration, saveCalibration, - listImmediateSubfolders, + listParentFolderCameras, resolveMulticamCameraSourcePath, } = useApi(); const importType: Ref = ref(''); @@ -249,7 +249,7 @@ export function useImportMultiCamDialog( return; } const useDesktopDiscovery = !ret.fileList?.length && !!ret.filePaths?.[0] - && !!listImmediateSubfolders; + && !!listParentFolderCameras; if (!ret.fileList?.length && !useDesktopDiscovery) { return; } @@ -258,16 +258,21 @@ export function useImportMultiCamDialog( let parentPath = ''; let grouped: Map | undefined; let folderNames: string[] = []; + let desktopCameras: { name: string; sourcePath: string }[] | undefined; + const mediaType = props.dataType === VideoType ? 'video' : 'image-sequence'; if (ret.fileList?.length) { const paths = ret.fileList.map((f) => f.webkitRelativePath || f.name); parentPath = ret.root || commonPathPrefix(paths); - grouped = groupFilesByImmediateSubfolder(ret.fileList, parentPath); + grouped = groupParentFolderByCamera(ret.fileList, { + allowRootLevelVideos: props.dataType === VideoType, + }, parentPath); folderNames = [...grouped.keys()]; } else { const [firstPath] = ret.filePaths; parentPath = firstPath; - folderNames = await listImmediateSubfolders!(parentPath); + desktopCameras = await listParentFolderCameras!(parentPath, mediaType); + folderNames = desktopCameras.map((camera) => camera.name); } const organized = organizeSubfolderCameras(folderNames, { @@ -286,9 +291,19 @@ export function useImportMultiCamDialog( let { assignments } = organized; if (useDesktopDiscovery) { - assignments = applyParentPathToAssignments(parentPath, assignments); + if (desktopCameras?.length) { + assignments = assignments.map((assignment) => { + const discovered = desktopCameras?.find( + (camera) => camera.name === assignment.folderName, + ); + return discovered + ? { ...assignment, sourcePath: discovered.sourcePath } + : assignment; + }); + } else { + assignments = applyParentPathToAssignments(parentPath, assignments); + } if (resolveMulticamCameraSourcePath) { - const mediaType = props.dataType === VideoType ? 'video' : 'image-sequence'; assignments = await Promise.all(assignments.map(async (assignment) => ({ ...assignment, sourcePath: await resolveMulticamCameraSourcePath(assignment.sourcePath, mediaType), @@ -313,7 +328,7 @@ export function useImportMultiCamDialog( for (let i = 0; i < registryPayload.length; i += 1) { const { cameraName, sourcePath, files } = registryPayload[i]; if (grouped && !files.length) { - throw new Error(`Subfolder "${organized.assignments[i].folderName}" has no media files`); + throw new Error(`Camera "${organized.assignments[i].folderName}" has no media files`); } Vue.set(subfolderOriginalNames.value, cameraName, organized.assignments[i].folderName); Vue.set(folderList.value, cameraName, { sourcePath, trackFile: '' }); @@ -350,7 +365,7 @@ export function useImportMultiCamDialog( } Vue.set(folderList.value, newKey, { - sourcePath: (importType.value === 'subfolders' && !listImmediateSubfolders) ? newKey : sourcePath, + sourcePath: (importType.value === 'subfolders' && !listParentFolderCameras) ? newKey : sourcePath, trackFile: entry.trackFile, }); Vue.delete(folderList.value, oldKey); diff --git a/client/dive-common/components/MultiCamToolbar.vue b/client/dive-common/components/MultiCamToolbar.vue index b3f2a2abf..c89225e82 100644 --- a/client/dive-common/components/MultiCamToolbar.vue +++ b/client/dive-common/components/MultiCamToolbar.vue @@ -109,6 +109,13 @@ export default defineComponent({ // Can link when there are cameras without the track const canLink = computed(() => linkableCameras.value.length > 0); + // When only one camera can be linked, skip the picker menu + const singleLinkableCamera = computed( + () => (linkableCameras.value.length === 1 ? linkableCameras.value[0] : null), + ); + + const useLinkMenu = computed(() => canLink.value && linkableCameras.value.length > 1); + // The opposite camera (first camera that isn't the currently selected one) const oppositeCamera = computed(() => cameras.value.find( (cam) => cam !== selectedCamera.value, @@ -171,6 +178,12 @@ export default defineComponent({ } }; + const linkToAvailableCamera = () => { + if (singleLinkableCamera.value) { + startLinkingToCamera(singleLinkableCamera.value); + } + }; + const toggleExpanded = () => { isExpanded.value = !isExpanded.value; }; @@ -188,17 +201,26 @@ export default defineComponent({ ]; if (canLink.value) { - buttons.push({ - id: 'link', - icon: 'mdi-link-variant-plus', - tooltip: 'Link track to camera', - menu: { - items: linkableCameras.value.map((cam) => ({ - label: `Link to ${cam}`, - action: () => startLinkingToCamera(cam), - })), - }, - }); + if (singleLinkableCamera.value) { + buttons.push({ + id: 'link', + icon: 'mdi-link-variant-plus', + tooltip: `Link track to ${singleLinkableCamera.value}`, + action: linkToAvailableCamera, + }); + } else { + buttons.push({ + id: 'link', + icon: 'mdi-link-variant-plus', + tooltip: 'Link track to camera', + menu: { + items: linkableCameras.value.map((cam) => ({ + label: `Link to ${cam}`, + action: () => startLinkingToCamera(cam), + })), + }, + }); + } } else { buttons.push({ id: 'unlink', @@ -251,6 +273,8 @@ export default defineComponent({ currentCameraHasTrack, currentCameraHasDetection, linkableCameras, + singleLinkableCamera, + useLinkMenu, canUnlink, canLink, oppositeCamera, @@ -258,6 +282,7 @@ export default defineComponent({ deleteTrackFromCamera, unlinkCurrentCamera, startLinkingToCamera, + linkToAvailableCamera, editOnCamera, editOnOppositeCamera, isExpanded, @@ -386,7 +411,7 @@ export default defineComponent({ @@ -416,6 +441,22 @@ export default defineComponent({ + + + + mdi-link-variant-plus + + + Link track to {{ singleLinkableCamera }} + , default: null, }, + /** Case-insensitive substrings; matching categories/pipes are omitted from the menu. */ + excludePipelineTerms: { + type: Array as PropType, + default: () => ([]), + }, }, setup(props) { const { prompt } = usePrompt(); const { runPipeline, getPipelineList } = useApi(); const unsortedPipelines = ref({} as Pipelines); - const camNumberStringArray = computed(() => props.cameraNumbers.map((v) => v.toString())); const { request: _runPipelineRequest, reset: dismissLaunchDialog, @@ -131,36 +138,13 @@ export default defineComponent({ unsortedPipelines.value = await getPipelineList(); }); - const pipelines = computed(() => { - const sortedPipelines = {} as Pipelines; - Object.entries(unsortedPipelines.value).forEach(([name, category]) => { - category.pipes.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName > bName) { - return 1; - } - if (aName < bName) { - return -1; - } - return 0; - }); - // Filter out unsupported pipelines based on subTypeList - // measurement can only be operated on stereo subtypes - if (props.subTypeList.every((item) => item === 'stereo') && (name === stereoPipelineMarker)) { - sortedPipelines[name] = category; - } else if (props.subTypeList.every((item) => item === 'multicam') && (multiCamPipelineMarkers.includes(name))) { - const pipelineExpectedCameraCount = name.split('-')[0]; - if (camNumberStringArray.value.includes(pipelineExpectedCameraCount)) { - sortedPipelines[name] = category; - } - } - if (name !== stereoPipelineMarker && !multiCamPipelineMarkers.includes(name)) { - sortedPipelines[name] = category; - } - }); - return sortedPipelines; - }); + const pipelines = computed(() => filterPipelinesForDatasets( + unsortedPipelines.value, + props.subTypeList, + props.cameraNumbers, + props.typeList, + props.excludePipelineTerms, + )); const pipelinesNotRunnable = computed(() => ( props.selectedDatasetIds.length < 1 || pipelines.value === null @@ -188,7 +172,7 @@ export default defineComponent({ if (props.cameraNumbers.length === 1 && props.cameraNumbers[0] > 1 && (!multiCamPipelineMarkers.includes(pipeline.type) && stereoPipelineMarker !== pipeline.type)) { - const cameraNames = props.selectedDatasetIds.map((item) => item.substring(0, item.lastIndexOf('/'))); + const cameraNames = props.selectedDatasetIds.map((item) => parentDatasetId(item)); const result = await prompt({ title: `Running Single Camera Pipeline on ${cameraNames[0]}`, text: ['Running a single pipeline on multi-camera data can produce conflicting track Ids', @@ -202,7 +186,7 @@ export default defineComponent({ } if (multiCamPipelineMarkers.includes(pipeline.type) || stereoPipelineMarker === pipeline.type) { - datasetIds = props.selectedDatasetIds.map((item) => item.substring(0, item.lastIndexOf('/'))); + datasetIds = props.selectedDatasetIds.map((item) => parentDatasetId(item)); } selectedPipeline.value = pipeline; const frameRange = props.timeFilter; @@ -254,20 +238,6 @@ export default defineComponent({ selectedPipeline.value = null; // reset selected pipeline state } - function pipeTypeDisplay(pipeType: string) { - switch (pipeType) { - case 'trained': - return 'trained'; - case 'utility': - case 'generate': - return 'utilities'; - case 'transcode': - return 'transcoders'; - default: - return `${pipeType}s`; - } - } - return { jobState, pipelines, @@ -275,7 +245,7 @@ export default defineComponent({ includesLargeImage, successMessage, dismissLaunchDialog, - pipeTypeDisplay, + pipeTypeDisplay: pipelineTypeDisplay, runPipelineOnSelectedItem, pipelinesCurrentlyRunning, singlePipelineValue, @@ -297,6 +267,8 @@ export default defineComponent({ @@ -389,69 +361,72 @@ export default defineComponent({ target="_blank" >docs for more information about these options. - - - - + - - - {{ pipeTypeDisplay(pipeType) }} - + + - mdi-menu-right - - - + {{ pipeTypeDisplay(pipeType) }} + + mdi-menu-right + + + - - - - - - {{ pipeline.name }} - - {{ pipeline.metadata?.diveParams?.length ?? 0 > 0 ? 'mdi-application-cog-outline' : 'mdi-play-outline' }} - - - - - - - - - - + + + + + {{ pipeline.name }} + + {{ pipeline.metadata?.diveParams?.length ?? 0 > 0 ? 'mdi-application-cog-outline' : 'mdi-play-outline' }} + + + + + + + + + + + @@ -479,6 +454,24 @@ export default defineComponent({ diff --git a/client/platform/web-girder/views/DataShared.vue b/client/platform/web-girder/views/DataShared.vue index 5bb5ca7e2..40f241363 100644 --- a/client/platform/web-girder/views/DataShared.vue +++ b/client/platform/web-girder/views/DataShared.vue @@ -109,8 +109,27 @@ export default defineComponent({ > - - + + + + + {{ getMultiCamIcon(multiCamSubType(item)) }} + + + {{ getMultiCamTooltip(multiCamSubType(item)) }} + + mdi-folder{{ item.public ? '' : '-key' }} {{ item.name }} @@ -118,22 +137,6 @@ export default defineComponent({ {{ item.type }} - - - - {{ getMultiCamIcon(multiCamSubType(item)) }} - - - {{ getMultiCamTooltip(multiCamSubType(item)) }} - .filename { + display: inline-flex; + align-items: center; cursor: pointer; opacity: 0.8; diff --git a/client/platform/web-girder/views/Home.vue b/client/platform/web-girder/views/Home.vue index 6ea5609f3..0f5c80bcd 100644 --- a/client/platform/web-girder/views/Home.vue +++ b/client/platform/web-girder/views/Home.vue @@ -8,6 +8,9 @@ import { GirderFileManager, GirderMarkdown, } from '@girder/components/src'; import RunPipelineMenu from 'dive-common/components/RunPipelineMenu.vue'; +import type { SubType } from 'dive-common/apispec'; +import { getMultiCamCameraCount } from 'dive-common/pipelineMenuFilters'; +import { webExcludedPipelineTerms } from 'dive-common/constants'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { useConfig } from '../store/useConfig'; import { useJobs } from '../store/useJobs'; @@ -79,13 +82,31 @@ export default defineComponent({ return results; }); - const selectedViameFolderIds = computed(() => selected.value.filter( + const selectedViameFolders = computed(() => selected.value.filter( ({ _modelType, meta }) => _modelType === 'folder' && meta && meta.annotate, - ).map(({ _id }) => _id)); + )); - const selectedViameFolderNames = computed(() => selected.value.filter( - ({ _modelType, meta }) => _modelType === 'folder' && meta && meta.annotate, - ).map(({ name }) => name)); + const selectedViameFolderIds = computed(() => selectedViameFolders.value.map(({ _id }) => _id)); + + const selectedViameFolderNames = computed(() => selectedViameFolders.value.map(({ name }) => name)); + + const pipelineTargetFolders = computed(() => ( + locationIsViameFolder.value && location.value + ? [location.value] + : selectedViameFolders.value + )); + + const subTypeList = computed((): SubType[] => pipelineTargetFolders.value.map( + (item) => item.meta?.subType ?? null, + )); + + const cameraNumbers = computed(() => pipelineTargetFolders.value.map( + (item) => getMultiCamCameraCount(item.meta), + )); + + const datasetTypeList = computed(() => pipelineTargetFolders.value.map( + (item) => item.meta?.type ?? null, + )); const selectedFileIds = computed(() => selected.value.filter( (element) => element._modelType === 'item', @@ -122,6 +143,9 @@ export default defineComponent({ runningPipelines, selectedViameFolderIds, selectedViameFolderNames, + subTypeList, + cameraNumbers, + datasetTypeList, selectedFileIds, includesLargeImage, locationInputs, @@ -131,6 +155,7 @@ export default defineComponent({ prompt, clearSelected, eventBus, + webExcludedPipelineTerms, }; }, methods: { @@ -205,10 +230,14 @@ export default defineComponent({ buttonOptions: { ...buttonOptions, disabled: includesLargeImage }, menuOptions, + subTypeList, + cameraNumbers, + typeList: datasetTypeList, }" :selected-dataset-ids="locationInputs" :selected-dataset-name="locationInputNames" :running-pipelines="runningPipelines" + :exclude-pipeline-terms="webExcludedPipelineTerms" /> import { - defineComponent, Ref, ref, computed, + defineComponent, Ref, ref, computed, onBeforeUnmount, } from 'vue'; import { useRouter } from 'vue-router/composables'; @@ -24,6 +24,7 @@ import { createMulticamDataset, uploadCalibrationItem, validateUploadGroup, + waitForFolderDatasetReady, } from 'platform/web-girder/api'; import { clearMulticamFileRegistry, @@ -35,6 +36,10 @@ import { renameCameraFolderFiles, stashCameraFolderFiles, } from 'platform/web-girder/multicamFileRegistry'; +import { + isAllowedStereoCalibrationFilename, + stereoCalibrationAllowedExtensionsLabel, +} from 'platform/web-girder/multicamCalibration'; import { openFromDisk } from 'platform/web-girder/utils'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { getResponseError } from 'vue-media-annotator/utils'; @@ -79,13 +84,32 @@ interface GirderUpload { meta?: File | null; annotationFile?: File | null; skipTranscoding?: boolean; - }) => Promise<{ _id: string }>; + }) => Promise<{ folder: { _id: string }; jobIds: string[] }>; } function isMultiCamFolderArgs(args: MultiCamImportArgs): args is MultiCamImportFolderArgs { return 'sourceList' in args; } +const MULTICAM_PROGRESS_START = 2; +const MULTICAM_PROGRESS_END = 98; +/** Share of each camera's progress bar allocated to Girder file upload vs server processing. */ +const MULTICAM_CAMERA_UPLOAD_WEIGHT = 0.72; + +interface MulticamImportProgress { + percent: number; + message: string; +} + +function multicamCameraSlotPercent( + cameraIndex: number, + totalCameras: number, + subFraction: number, +): number { + const span = (MULTICAM_PROGRESS_END - MULTICAM_PROGRESS_START) / totalCameras; + return MULTICAM_PROGRESS_START + (cameraIndex * span) + (subFraction * span); +} + export default defineComponent({ components: { ImportButton, ImportMultiCamDialog, UploadGirder }, props: { @@ -101,7 +125,44 @@ export default defineComponent({ const multiCamOpenType = ref('image-sequence'); const importMultiCamDialog = ref(false); const multicamImporting = ref(false); + const multicamImportProgress = ref(null); const girderUpload: Ref = ref(null); + let multicamUploadProgressTimer: ReturnType | null = null; + + const clearMulticamUploadProgressTimer = () => { + if (multicamUploadProgressTimer !== null) { + clearInterval(multicamUploadProgressTimer); + multicamUploadProgressTimer = null; + } + }; + + const setMulticamImportProgress = (percent: number, message: string) => { + multicamImportProgress.value = { + percent: Math.max(0, Math.min(100, Math.round(percent))), + message, + }; + }; + + const trackMulticamCameraUploadProgress = ( + cameraIndex: number, + totalCameras: number, + cameraName: string, + ) => { + clearMulticamUploadProgressTimer(); + multicamUploadProgressTimer = setInterval(() => { + const uploadPct = girderUpload.value?.totalProgressPercent ?? 0; + setMulticamImportProgress( + multicamCameraSlotPercent( + cameraIndex, + totalCameras, + (uploadPct / 100) * MULTICAM_CAMERA_UPLOAD_WEIGHT, + ), + `Uploading ${cameraName} (${cameraIndex + 1} of ${totalCameras})`, + ); + }, 250); + }; + + onBeforeUnmount(clearMulticamUploadProgressTimer); const isDesktopMode = navigator.userAgent.includes('Electron'); const { prompt } = usePrompt(); const router = useRouter(); @@ -319,13 +380,17 @@ export default defineComponent({ } multicamImporting.value = true; + multicamImportProgress.value = { percent: 0, message: 'Preparing import…' }; preUploadErrorMessage.value = null; try { const datasetName = args.datasetName?.trim(); if (!datasetName) { throw new Error('Dataset name is required'); } - const fps = clientSettings.annotationFPS || DefaultVideoFPS; + const fps = args.type === VideoType + ? DefaultVideoFPS + : (clientSettings.annotationFPS || 1); + setMulticamImportProgress(MULTICAM_PROGRESS_START, 'Creating dataset folder…'); const { data: datasetFolder } = await createGirderFolder({ folderId: props.location._id, name: datasetName, @@ -338,6 +403,7 @@ export default defineComponent({ const cameraEntries = cameraOrder .filter((name) => args.sourceList[name]) .map((name) => [name, args.sourceList[name]] as const); + const totalCameras = cameraEntries.length; for (let i = 0; i < cameraEntries.length; i += 1) { const [cameraName, source] = cameraEntries[i]; @@ -357,8 +423,9 @@ export default defineComponent({ const annotationFile = source.trackFile ? getAnnotationFile(source.trackFile) : undefined; + trackMulticamCameraUploadProgress(i, totalCameras, cameraName); // eslint-disable-next-line no-await-in-loop - const folder = await uploadComponent.uploadCameraDataset({ + const { folder, jobIds } = await uploadComponent.uploadCameraDataset({ name: cameraName, fps, type: args.type, @@ -367,19 +434,54 @@ export default defineComponent({ skipTranscoding: true, parentFolderId: datasetFolder._id, }); + clearMulticamUploadProgressTimer(); + setMulticamImportProgress( + multicamCameraSlotPercent(i, totalCameras, MULTICAM_CAMERA_UPLOAD_WEIGHT), + `Processing ${cameraName} (${i + 1} of ${totalCameras})`, + ); + // eslint-disable-next-line no-await-in-loop -- finalize only after post-process marks folder as a dataset + await waitForFolderDatasetReady(folder._id, { + onProgress: (fraction) => { + const processShare = 1 - MULTICAM_CAMERA_UPLOAD_WEIGHT; + setMulticamImportProgress( + multicamCameraSlotPercent( + i, + totalCameras, + MULTICAM_CAMERA_UPLOAD_WEIGHT + fraction * processShare, + ), + `Processing ${cameraName} (${i + 1} of ${totalCameras})`, + ); + }, + }, jobIds); + setMulticamImportProgress( + multicamCameraSlotPercent(i + 1, totalCameras, 0), + totalCameras > 1 && i + 1 < totalCameras + ? `Finished ${cameraName}, starting next camera…` + : `Finished ${cameraName}`, + ); cameras[cameraName] = { folderId: folder._id }; } + setMulticamImportProgress(92, 'Finalizing multicam dataset…'); let calibrationFileId: string | undefined; if (args.calibrationFile) { + setMulticamImportProgress(94, 'Uploading calibration…'); const calFile = getCalibrationFile(args.calibrationFile); if (!calFile) { - throw new Error('Calibration file was not found'); + throw new Error( + 'Calibration file was not found. Use "Choose calibration" in the import dialog to select the file again.', + ); + } + if (stereo.value && !isAllowedStereoCalibrationFilename(calFile.name)) { + throw new Error( + `Stereoscopic calibration must be ${stereoCalibrationAllowedExtensionsLabel()}.`, + ); } calibrationFileId = await uploadCalibrationItem(datasetFolder._id, calFile); } const subType = stereo.value ? 'stereo' : 'multicam'; + setMulticamImportProgress(97, 'Linking cameras…'); const { data: parentFolder } = await createMulticamDataset({ parentFolderId: datasetFolder._id, name: datasetName, @@ -392,6 +494,7 @@ export default defineComponent({ calibrationFileId, }); + setMulticamImportProgress(100, 'Opening viewer…'); clearMulticamFileRegistry(); await router.push({ name: 'viewer', params: { id: parentFolder._id } }); close(); @@ -399,7 +502,9 @@ export default defineComponent({ preUploadErrorMessage.value = err.response?.data?.message || err.message || String(err); await errorHandler({ err, name: 'Multicam import' }); } finally { + clearMulticamUploadProgressTimer(); multicamImporting.value = false; + multicamImportProgress.value = null; } }; // Filter to show how many files are left to upload @@ -488,6 +593,7 @@ export default defineComponent({ importMultiCamDialog, girderUpload, multicamImporting, + multicamImportProgress, uploading, clientSettings, //methods @@ -528,12 +634,19 @@ export default defineComponent({ outlined > - Importing multicam dataset + {{ multicamImportProgress?.message ?? 'Importing multicam dataset' }} + + {{ multicamImportProgress.percent }}% + { uploaded.push({ @@ -151,9 +152,11 @@ export default Vue.extend({ results: data.results, }); try { - await postProcess(folder._id, false, skipTranscoding); + const { data: postprocessResult } = await postProcess(folder._id, false, skipTranscoding); + jobIds = postprocessResult.job_ids ?? []; } catch (err) { this.$emit('error', { err, name }); + throw err; } }; // Sets the files used by the fileUploader mixin @@ -164,6 +167,7 @@ export default Vue.extend({ postUpload, uploadCls: GirderUploadManager, }); + return { folder, jobIds }; }, /** * Upload a single camera dataset folder (used by multicam import). @@ -180,8 +184,8 @@ export default Vue.extend({ if (!folder) { throw new Error(`Failed to create folder for camera ${name}`); } - await this.uploadFiles(name, folder, files, [], skipTranscoding); - return folder; + const { folder: uploadedFolder, jobIds } = await this.uploadFiles(name, folder, files, [], skipTranscoding); + return { folder: uploadedFolder, jobIds }; }, }, }); diff --git a/client/platform/web-girder/views/ViewerLoader.vue b/client/platform/web-girder/views/ViewerLoader.vue index 185b3b9d2..a70168efc 100644 --- a/client/platform/web-girder/views/ViewerLoader.vue +++ b/client/platform/web-girder/views/ViewerLoader.vue @@ -12,11 +12,13 @@ import context from 'dive-common/store/context'; import { useBrand } from 'platform/web-girder/store/useBrand'; import { useConfig } from 'platform/web-girder/store/useConfig'; import { useDataset } from 'platform/web-girder/store/useDataset'; +import { reportHandledPromiseRejection } from 'platform/web-girder/reportHandledPromiseRejection'; import { useLocation } from 'platform/web-girder/store/useLocation'; import { useJobs } from 'platform/web-girder/store/useJobs'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import type { DatasetType, SubType } from 'dive-common/apispec'; -import { useApi } from 'dive-common/apispec'; +import { getMultiCamCameraCount } from 'dive-common/pipelineMenuFilters'; +import { webExcludedPipelineTerms } from 'dive-common/constants'; import { convertLargeImage } from 'platform/web-girder/api/rpc.service'; import { useRouter } from 'vue-router/composables'; import JobsTab from './JobsTab.vue'; @@ -96,13 +98,12 @@ export default defineComponent({ }, setup(props) { - const { loadMetadata } = useApi(); const { prompt } = usePrompt(); const router = useRouter(); const viewerRef = ref(); const { brandData } = useBrand(); const { pipelinesEnabled } = useConfig(); - const { meta: datasetMeta } = useDataset(); + const { meta: datasetMeta, loadDataset } = useDataset(); const jobs = useJobs(); const { locationRoute } = useLocation(); const revisionNum = computed(() => { @@ -112,22 +113,19 @@ export default defineComponent({ }); const currentJob = computed(() => jobs.getDatasetCompleteJobs(props.id)); - const typeList: Ref = ref([]); - const subTypeList = computed((): SubType[] => { - const subType = datasetMeta.value?.subType; - return subType ? [subType] : []; - }); - const cameraNumbers = computed(() => { - const count = Object.keys(datasetMeta.value?.multiCamMedia?.cameras ?? {}).length; - return [count > 0 ? count : 1]; + const typeList = computed((): DatasetType[] => { + const t = datasetMeta.value?.type; + return t ? [t as DatasetType] : []; }); + const subTypeList = computed((): SubType[] => [datasetMeta.value?.subType ?? null]); + const cameraNumbers = computed(() => [getMultiCamCameraCount(datasetMeta.value)]); const timeFilter: Ref<[number, number] | null> = ref(null); - const findType = async () => { - const meta = await loadMetadata(props.id); - typeList.value = [meta.type as DatasetType]; - }; - findType(); + watch(() => props.id, (datasetId) => { + loadDataset(datasetId).catch((reason) => { + reportHandledPromiseRejection('ViewerLoader: loadDataset', reason); + }); + }, { immediate: true }); watch( () => viewerRef.value?.trackFilters?.timeFilters?.value, @@ -256,6 +254,7 @@ export default defineComponent({ cameraNumbers, timeFilter, pipelinesEnabled, + webExcludedPipelineTerms, }; }, }); @@ -303,6 +302,7 @@ export default defineComponent({ :running-pipelines="runningPipelines" :read-only-mode="revisionNum !== undefined" :time-filter="timeFilter" + :exclude-pipeline-terms="webExcludedPipelineTerms" /> null, }); const virtualListItems = computed(() => { diff --git a/client/src/components/Tracks/TrackList.vue b/client/src/components/Tracks/TrackList.vue index 39d38677f..10a5b8dad 100644 --- a/client/src/components/Tracks/TrackList.vue +++ b/client/src/components/Tracks/TrackList.vue @@ -1,5 +1,5 @@