Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0c2cc6e
Multicam web features phase 2 (#1660)
BryonLewis May 18, 2026
d629197
Revert "Multicam web features phase 2 (#1660)" (#1661)
BryonLewis May 18, 2026
74915b2
Bump idna from 3.13 to 3.15 in /server (#1664)
dependabot[bot] May 21, 2026
eacaf0b
discover stereo/multicam pipelines
BryonLewis May 21, 2026
27e0a88
support for multicam arguments when running tasks
BryonLewis May 21, 2026
a9bf84a
add multicam logic for running multicam/stereo tasks
BryonLewis May 21, 2026
d2c94ce
updating tests
BryonLewis May 21, 2026
f30e403
update base planning document
BryonLewis May 21, 2026
cdcc6dc
manual docs deployment, or on changes in main (#1666)
BryonLewis May 22, 2026
284423c
fix docker image references (#1667)
BryonLewis May 22, 2026
b2ba532
Merge branch 'main' into multicam-web-features-phase-4
BryonLewis May 22, 2026
7c180ed
fix pipeline categories and display
BryonLewis May 22, 2026
4861ef9
pipeline menu styling updates
BryonLewis May 22, 2026
2ed12d6
linting
BryonLewis May 24, 2026
02be184
docuemtnation updates
BryonLewis May 24, 2026
e2d7201
stereoscopic support
BryonLewis May 25, 2026
3fb82bc
Merge branch 'multicam-web-features-phase-4' of github.com:Kitware/di…
BryonLewis May 25, 2026
a6e5ab3
revert common stereo changes
BryonLewis May 25, 2026
feb5b5b
handling task locations
BryonLewis May 26, 2026
3679232
update to allow video folder importing for stereoscopic video
BryonLewis May 28, 2026
b3e261f
stereoscopic calibration file imports
BryonLewis May 28, 2026
d78765f
multicam import status
BryonLewis May 28, 2026
4604295
linting and tests
BryonLewis May 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 1 addition & 19 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Electron Build and Docs Deployment
name: Electron Build
on:
release:
types: [published]
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion WEB_MULTICAM_PLAN.MD
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Bring stereo/multicam parity to the Girder/web platform by modeling multicam as
- [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] **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`
Expand Down
5 changes: 5 additions & 0 deletions client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>;
/** 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
</v-alert>
<v-row
no-gutters
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default defineComponent({
<v-radio
v-if="enableSubfolderImport"
value="subfolders"
label="Parent folder: each immediate subfolder is a camera"
label="Parent folder: subfolders or separate video files per camera"
/>
<v-radio
v-if="dataType === 'image-sequence'"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { describe, expect, it } from 'vitest';
import {
applyParentPathToAssignments,
groupFilesByImmediateSubfolder,
groupParentFolderByCamera,
groupRootLevelVideoFiles,
isValidCameraName,
isVideoFileName,
organizeSubfolderCameras,
orderSubfolderCameraNames,
preferLeftSubfolderFirst,
Expand Down Expand Up @@ -115,8 +118,63 @@ describe('organizeSubfolderCameras', () => {
});

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);
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { fileVideoTypes } from 'dive-common/constants';

/** Assign immediate child folders to multicam cameras (one camera per subfolder). */

export interface SubfolderCameraAssignment {
Expand Down Expand Up @@ -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(', ')})`,
};
}

Expand Down Expand Up @@ -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<string, File[]> {
const groups = new Map<string, File[]>();
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<string, File[]> {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import {
applyParentPathToAssignments,
commonPathPrefix,
groupFilesByImmediateSubfolder,
groupParentFolderByCamera,
isValidCameraName,
organizeSubfolderCameras,
pickDefaultMulticamCamera,
Expand Down Expand Up @@ -51,7 +51,7 @@ export function useImportMultiCamDialog(
openFromDisk,
getLastCalibration,
saveCalibration,
listImmediateSubfolders,
listParentFolderCameras,
resolveMulticamCameraSourcePath,
} = useApi();
const importType: Ref<MulticamImportType> = ref('');
Expand Down Expand Up @@ -249,7 +249,7 @@ export function useImportMultiCamDialog(
return;
}
const useDesktopDiscovery = !ret.fileList?.length && !!ret.filePaths?.[0]
&& !!listImmediateSubfolders;
&& !!listParentFolderCameras;
if (!ret.fileList?.length && !useDesktopDiscovery) {
return;
}
Expand All @@ -258,16 +258,21 @@ export function useImportMultiCamDialog(
let parentPath = '';
let grouped: Map<string, File[]> | 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, {
Expand All @@ -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),
Expand All @@ -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: '' });
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading