diff --git a/lightly_studio_view/e2e/general/export-annotations.e2e-test.ts b/lightly_studio_view/e2e/general/export-annotations.e2e-test.ts
index 8958b4f6d3..7fd28404e2 100644
--- a/lightly_studio_view/e2e/general/export-annotations.e2e-test.ts
+++ b/lightly_studio_view/e2e/general/export-annotations.e2e-test.ts
@@ -14,15 +14,6 @@ test.describe('Export Annotations', () => {
// Switch to the correct export type
await page.getByTestId('export-type-select').click();
await page.getByRole('option', { name: 'Image Object Detections (COCO)' }).click();
- await expect(page.getByTestId('submit-button-annotations-coco')).toHaveAttribute(
- 'href',
- /\/api\/collections\/.*\/export\/annotations\?ts=\d+/
- );
-
- // Remove target to avoid popup and keep navigation in the same page context
- await page
- .getByTestId('submit-button-annotations-coco')
- .evaluate((el: HTMLAnchorElement) => el.removeAttribute('target'));
// Click and wait for the download event deterministically
const [download] = await Promise.all([
@@ -76,15 +67,6 @@ test.describe('Export Annotations', () => {
// Switch to the YOLO export type
await page.getByTestId('export-type-select').click();
await page.getByRole('option', { name: 'Image Object Detections (YOLO)' }).click();
- await expect(page.getByTestId('submit-button-annotations-yolo')).toHaveAttribute(
- 'href',
- /\/api\/collections\/.*\/export\/annotations\?ts=\d+&export_format=object_detection_yolo/
- );
-
- // Remove target to avoid popup and keep navigation in the same page context
- await page
- .getByTestId('submit-button-annotations-yolo')
- .evaluate((el: HTMLAnchorElement) => el.removeAttribute('target'));
// Click and wait for the download event deterministically
const [download] = await Promise.all([
diff --git a/lightly_studio_view/e2e/general/export-captions.e2e-test.ts b/lightly_studio_view/e2e/general/export-captions.e2e-test.ts
index 3a53108aa6..6c70771b57 100644
--- a/lightly_studio_view/e2e/general/export-captions.e2e-test.ts
+++ b/lightly_studio_view/e2e/general/export-captions.e2e-test.ts
@@ -30,15 +30,6 @@ test.describe('Export Captions', () => {
// Switch to the correct export type
await page.getByTestId('export-type-select').click();
await page.getByRole('option', { name: 'Image Captions' }).click();
- await expect(page.getByTestId('submit-button-captions')).toHaveAttribute(
- 'href',
- /\/api\/collections\/.*\/export\/captions\?ts=\d+/
- );
-
- // Remove target to avoid popup and keep navigation in the same page context
- await page
- .getByTestId('submit-button-captions')
- .evaluate((el: HTMLAnchorElement) => el.removeAttribute('target'));
// Click and wait for the download event deterministically
const [download] = await Promise.all([
diff --git a/lightly_studio_view/e2e/general/export-segmentation-masks.e2e-test.ts b/lightly_studio_view/e2e/general/export-segmentation-masks.e2e-test.ts
index 88376bea49..17f5fc5f81 100644
--- a/lightly_studio_view/e2e/general/export-segmentation-masks.e2e-test.ts
+++ b/lightly_studio_view/e2e/general/export-segmentation-masks.e2e-test.ts
@@ -14,15 +14,6 @@ test.describe('Export Segmentation Masks', () => {
// Switch to the correct export type
await page.getByTestId('export-type-select').click();
await page.getByRole('option', { name: 'Image Segmentation Mask (COCO)' }).click();
- await expect(page.getByTestId('submit-button-instance-segmentations')).toHaveAttribute(
- 'href',
- /\/api\/collections\/.*\/export\/annotations\?ts=\d+&export_format=segmentation_mask_coco/
- );
-
- // Remove target to avoid popup and keep navigation in the same page context
- await page
- .getByTestId('submit-button-instance-segmentations')
- .evaluate((el: HTMLAnchorElement) => el.removeAttribute('target'));
// Click and wait for the download event deterministically
const [download] = await Promise.all([
diff --git a/lightly_studio_view/src/lib/components/ExportSamples/ExportAnnotationError/ExportAnnotationError.svelte b/lightly_studio_view/src/lib/components/ExportSamples/ExportAnnotationError/ExportAnnotationError.svelte
new file mode 100644
index 0000000000..30f11ee4f5
--- /dev/null
+++ b/lightly_studio_view/src/lib/components/ExportSamples/ExportAnnotationError/ExportAnnotationError.svelte
@@ -0,0 +1,25 @@
+
+
+{#if error}
+
+{/if}
diff --git a/lightly_studio_view/src/lib/components/ExportSamples/ExportAnnotationError/ExportAnnotationError.test.ts b/lightly_studio_view/src/lib/components/ExportSamples/ExportAnnotationError/ExportAnnotationError.test.ts
new file mode 100644
index 0000000000..96753bd6b5
--- /dev/null
+++ b/lightly_studio_view/src/lib/components/ExportSamples/ExportAnnotationError/ExportAnnotationError.test.ts
@@ -0,0 +1,21 @@
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/svelte';
+import '@testing-library/jest-dom';
+import ExportAnnotationError from './ExportAnnotationError.svelte';
+
+describe('ExportAnnotationError', () => {
+ it('renders nothing when error is empty', () => {
+ const { container } = render(ExportAnnotationError, { props: { error: '' } });
+
+ expect(container.querySelector('[data-testid="alert-destructive"]')).toBeNull();
+ });
+
+ it('renders the error alert when error is provided', () => {
+ const { container } = render(ExportAnnotationError, {
+ props: { error: 'Export failed: something went wrong' }
+ });
+
+ expect(container.querySelector('[data-testid="alert-destructive"]')).toBeInTheDocument();
+ expect(container.textContent).toContain('Export failed: something went wrong');
+ });
+});
diff --git a/lightly_studio_view/src/lib/components/ExportSamples/ExportDownloadButton/ExportDownloadButton.svelte b/lightly_studio_view/src/lib/components/ExportSamples/ExportDownloadButton/ExportDownloadButton.svelte
new file mode 100644
index 0000000000..90570c883a
--- /dev/null
+++ b/lightly_studio_view/src/lib/components/ExportSamples/ExportDownloadButton/ExportDownloadButton.svelte
@@ -0,0 +1,29 @@
+
+
+
diff --git a/lightly_studio_view/src/lib/components/ExportSamples/ExportDownloadButton/ExportDownloadButton.test.ts b/lightly_studio_view/src/lib/components/ExportSamples/ExportDownloadButton/ExportDownloadButton.test.ts
new file mode 100644
index 0000000000..23d2be360c
--- /dev/null
+++ b/lightly_studio_view/src/lib/components/ExportSamples/ExportDownloadButton/ExportDownloadButton.test.ts
@@ -0,0 +1,46 @@
+import { describe, it, expect, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/svelte';
+import '@testing-library/jest-dom';
+import ExportDownloadButton from './ExportDownloadButton.svelte';
+
+describe('ExportDownloadButton', () => {
+ it('renders the Download button with the given testId', () => {
+ render(ExportDownloadButton, { props: { onclick: vi.fn(), testId: 'test-btn' } });
+
+ expect(screen.getByTestId('test-btn')).toBeInTheDocument();
+ expect(screen.getByTestId('test-btn')).toHaveTextContent('Download');
+ });
+
+ it('does not show loading spinner by default', () => {
+ const { container } = render(ExportDownloadButton, {
+ props: { onclick: vi.fn(), testId: 'test-btn' }
+ });
+
+ expect(container.querySelector('[data-testid="loading-spinner"]')).toBeNull();
+ });
+
+ it('shows loading spinner when isLoading is true', () => {
+ const { container } = render(ExportDownloadButton, {
+ props: { onclick: vi.fn(), testId: 'test-btn', isLoading: true }
+ });
+
+ expect(container.querySelector('[data-testid="loading-spinner"]')).toBeInTheDocument();
+ });
+
+ it('disables the button when disabled is true', () => {
+ render(ExportDownloadButton, {
+ props: { onclick: vi.fn(), testId: 'test-btn', disabled: true }
+ });
+
+ expect(screen.getByTestId('test-btn')).toBeDisabled();
+ });
+
+ it('calls onclick when clicked', async () => {
+ const onclick = vi.fn();
+ render(ExportDownloadButton, { props: { onclick, testId: 'test-btn' } });
+
+ await fireEvent.click(screen.getByTestId('test-btn'));
+
+ expect(onclick).toHaveBeenCalledOnce();
+ });
+});
diff --git a/lightly_studio_view/src/lib/components/ExportSamples/ExportSamples.svelte b/lightly_studio_view/src/lib/components/ExportSamples/ExportSamples.svelte
index a873ef85a8..fca163917f 100644
--- a/lightly_studio_view/src/lib/components/ExportSamples/ExportSamples.svelte
+++ b/lightly_studio_view/src/lib/components/ExportSamples/ExportSamples.svelte
@@ -14,14 +14,17 @@
import { useAnnotationCollections } from '$lib/hooks/useAnnotationCollections/useAnnotationCollections';
import AnnotationSourceSelect from '$lib/components/AnnotationSourceSelect/AnnotationSourceSelect.svelte';
import { exportCollection } from '$lib/services/exportCollection';
+ import { exportAnnotations, exportCaptions } from '$lib/services/exportAnnotations';
+ import { ExportFormat } from '$lib/api/lightly_studio_local';
import type { ExportFilter } from '$lib/services/types';
import { useExportSamplesCount } from './useExportSamplesCount/useExportSamplesCount';
import { PUBLIC_LIGHTLY_STUDIO_API_URL } from '$env/static/public';
import * as Dialog from '$lib/components/ui/dialog';
- import { Loader2 } from '@lucide/svelte';
import * as Alert from '$lib/components/ui/alert/index.js';
import { fade } from 'svelte/transition';
import { useExportDialog } from '$lib/hooks/useExportDialog/useExportDialog';
+ import ExportAnnotationError from './ExportAnnotationError/ExportAnnotationError.svelte';
+ import ExportDownloadButton from './ExportDownloadButton/ExportDownloadButton.svelte';
import { useImageFilters } from '$lib/hooks/useImageFilters/useImageFilters';
const { isExportDialogOpen, openExportDialog, closeExportDialog } = useExportDialog();
@@ -71,11 +74,6 @@
const effectiveAnnotationCollectionId = $derived(
selectedAnnotationCollectionId ?? annotationSources[0]?.id
);
- const annotationCollectionParam = $derived(
- effectiveAnnotationCollectionId
- ? `&annotation_collection_id=${effectiveAnnotationCollectionId}`
- : ''
- );
//
// Sample export
@@ -122,9 +120,8 @@
})
);
- let errorMessage = $derived.by(() => {
- return $statError ? $statError : '';
- });
+ let exportError = $state('');
+ const errorMessage = $derived.by(() => exportError || ($statError ?? ''));
// Disable submit button if neither a tag nor a collection filter is set
const isSubmitDisabled = $derived.by(() => {
@@ -144,45 +141,51 @@
collectionFilter: $imageFilter
});
if (response.error) {
- errorMessage = `Export failed: ${response.error}`;
+ exportError = `Export failed: ${response.error}`;
}
};
//
- // COCO object detection export
+ // YouTube-VIS video Segmentation mask export
//
- const exportObjectDetectionCocoURL = $derived(
- `${PUBLIC_LIGHTLY_STUDIO_API_URL}api/collections/${collectionId}/export/annotations?ts=${Date.now()}&export_format=object_detection_coco${annotationCollectionParam}`
- );
+ const exportYoutubeVisSegmentationMaskURL = `${PUBLIC_LIGHTLY_STUDIO_API_URL}api/collections/${collectionId}/export/youtube-vis?ts=${Date.now()}&export_format=youtube_vis_segmentation`;
//
- // YOLO object detection export
+ // Annotation / caption export
//
- const exportObjectDetectionYoloURL = $derived(
- `${PUBLIC_LIGHTLY_STUDIO_API_URL}api/collections/${collectionId}/export/annotations?ts=${Date.now()}&export_format=object_detection_yolo${annotationCollectionParam}`
- );
+ let annotationExportError = $state('');
+ let isAnnotationExporting = $state(false);
- //
- // Segmentation mask export
- //
- const exportSegmentationMaskURL = $derived(
- `${PUBLIC_LIGHTLY_STUDIO_API_URL}api/collections/${collectionId}/export/annotations?ts=${Date.now()}&export_format=segmentation_mask_coco${annotationCollectionParam}`
- );
+ $effect(() => {
+ if (exportType) {
+ annotationExportError = '';
+ isAnnotationExporting = false;
+ }
+ });
- //
- // YouTube-VIS video Segmentation mask export
- //
- const exportYoutubeVisSegmentationMaskURL = `${PUBLIC_LIGHTLY_STUDIO_API_URL}api/collections/${collectionId}/export/youtube-vis?ts=${Date.now()}&export_format=youtube_vis_segmentation`;
- // Semantic segmentation export
- //
- const exportPascalVocURL = $derived(
- `${PUBLIC_LIGHTLY_STUDIO_API_URL}api/collections/${collectionId}/export/annotations?ts=${Date.now()}&export_format=pascal_voc${annotationCollectionParam}`
- );
+ const handleAnnotationExport = async (export_format: ExportFormat) => {
+ annotationExportError = '';
+ isAnnotationExporting = true;
+ const response = await exportAnnotations({
+ collection_id: collectionId,
+ annotation_collection_id: effectiveAnnotationCollectionId ?? null,
+ export_format
+ });
+ isAnnotationExporting = false;
+ if (response.error) {
+ annotationExportError = response.error;
+ }
+ };
- //
- // Caption export
- //
- const exportCaptionsURL = `${PUBLIC_LIGHTLY_STUDIO_API_URL}api/collections/${collectionId}/export/captions?ts=${Date.now()}`;
+ const handleCaptionExport = async () => {
+ annotationExportError = '';
+ isAnnotationExporting = true;
+ const response = await exportCaptions(collectionId);
+ isAnnotationExporting = false;
+ if (response.error) {
+ annotationExportError = response.error;
+ }
+ };
{/if}
-
+ disabled={isSubmitDisabled || $isLoading}
+ isLoading={$isLoading}
+ testId="submit-button-samples"
+ />
@@ -374,14 +367,15 @@
{/if}
-
+
+
+
+ handleAnnotationExport(ExportFormat.OBJECT_DETECTION_COCO)}
+ disabled={isAnnotationExporting}
+ isLoading={isAnnotationExporting}
+ testId="submit-button-annotations-coco"
+ />
@@ -401,14 +395,15 @@
{/if}
-
+
+
+
+ handleAnnotationExport(ExportFormat.OBJECT_DETECTION_YOLO)}
+ disabled={isAnnotationExporting}
+ isLoading={isAnnotationExporting}
+ testId="submit-button-annotations-yolo"
+ />
@@ -428,14 +423,15 @@
{/if}
-
+
+
+
+ handleAnnotationExport(ExportFormat.SEGMENTATION_MASK_COCO)}
+ disabled={isAnnotationExporting}
+ isLoading={isAnnotationExporting}
+ testId="submit-button-instance-segmentations"
+ />
{#if isVideoCollection}
@@ -471,14 +467,14 @@
{/if}
-
+
+
+ handleAnnotationExport(ExportFormat.PASCAL_VOC)}
+ disabled={isAnnotationExporting}
+ isLoading={isAnnotationExporting}
+ testId="submit-button-semantic-segmentations"
+ />
@@ -488,14 +484,14 @@
The captions will be exported in COCO format.
-
+
+
+
diff --git a/lightly_studio_view/src/lib/services/exportAnnotations.test.ts b/lightly_studio_view/src/lib/services/exportAnnotations.test.ts
new file mode 100644
index 0000000000..3f77b9776b
--- /dev/null
+++ b/lightly_studio_view/src/lib/services/exportAnnotations.test.ts
@@ -0,0 +1,114 @@
+import { exportAnnotations, exportCaptions } from './exportAnnotations';
+import * as clientModule from '$lib/api/lightly_studio_local/client.gen';
+import { ExportFormat } from '$lib/api/lightly_studio_local';
+import { vi } from 'vitest';
+
+vi.mock('$lib/utils', () => ({
+ triggerDownloadUrl: vi.fn()
+}));
+
+import { triggerDownloadUrl } from '$lib/utils';
+
+const BASE_URL = 'http://localhost:8001/';
+
+describe('exportAnnotations', () => {
+ beforeEach(() => {
+ vi.mocked(clientModule.client).getConfig = vi.fn().mockReturnValue({ baseUrl: BASE_URL });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.mocked(triggerDownloadUrl).mockClear();
+ });
+
+ it('constructs URL with collection_id and annotation_collection_id, triggers download', async () => {
+ const result = await exportAnnotations({
+ collection_id: 'col1',
+ annotation_collection_id: 'ann1'
+ });
+
+ expect(result).toEqual({});
+ expect(triggerDownloadUrl).toHaveBeenCalledWith(
+ 'http://localhost:8001/api/collections/col1/export/annotations?annotation_collection_id=ann1'
+ );
+ });
+
+ it('includes export_format in URL query when provided', async () => {
+ await exportAnnotations({
+ collection_id: 'col1',
+ annotation_collection_id: 'ann1',
+ export_format: ExportFormat.OBJECT_DETECTION_YOLO
+ });
+
+ expect(triggerDownloadUrl).toHaveBeenCalledWith(
+ `http://localhost:8001/api/collections/col1/export/annotations?annotation_collection_id=ann1&export_format=${ExportFormat.OBJECT_DETECTION_YOLO}`
+ );
+ });
+
+ it('omits annotation_collection_id from URL when null', async () => {
+ await exportAnnotations({
+ collection_id: 'col1',
+ annotation_collection_id: null
+ });
+
+ expect(triggerDownloadUrl).toHaveBeenCalledWith(
+ 'http://localhost:8001/api/collections/col1/export/annotations'
+ );
+ });
+
+ it('strips trailing slash from baseUrl before constructing URL', async () => {
+ vi.mocked(clientModule.client).getConfig = vi
+ .fn()
+ .mockReturnValue({ baseUrl: 'http://localhost:8001/' });
+
+ await exportAnnotations({ collection_id: 'col1', annotation_collection_id: null });
+
+ const url = vi.mocked(triggerDownloadUrl).mock.calls[0][0];
+ expect(url).not.toContain('//api');
+ });
+
+ it('returns error when URL construction throws', async () => {
+ vi.mocked(clientModule.client).getConfig = vi.fn().mockImplementation(() => {
+ throw new Error('config error');
+ });
+
+ const result = await exportAnnotations({
+ collection_id: 'col1',
+ annotation_collection_id: null
+ });
+
+ expect(result.error).toBeDefined();
+ expect(triggerDownloadUrl).not.toHaveBeenCalled();
+ });
+});
+
+describe('exportCaptions', () => {
+ beforeEach(() => {
+ vi.mocked(clientModule.client).getConfig = vi.fn().mockReturnValue({ baseUrl: BASE_URL });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.mocked(triggerDownloadUrl).mockClear();
+ });
+
+ it('constructs URL with collection_id and triggers download', async () => {
+ const result = await exportCaptions('col1');
+
+ expect(result).toEqual({});
+ expect(triggerDownloadUrl).toHaveBeenCalledWith(
+ 'http://localhost:8001/api/collections/col1/export/captions'
+ );
+ });
+
+ it('returns error when URL construction throws', async () => {
+ vi.mocked(clientModule.client).getConfig = vi.fn().mockImplementation(() => {
+ throw new Error('config error');
+ });
+
+ const result = await exportCaptions('col1');
+
+ expect(result.error).toBeDefined();
+ expect(triggerDownloadUrl).not.toHaveBeenCalled();
+ });
+});
diff --git a/lightly_studio_view/src/lib/services/exportAnnotations.ts b/lightly_studio_view/src/lib/services/exportAnnotations.ts
new file mode 100644
index 0000000000..a1eb152f7d
--- /dev/null
+++ b/lightly_studio_view/src/lib/services/exportAnnotations.ts
@@ -0,0 +1,45 @@
+import { client } from '$lib/api/lightly_studio_local/client.gen';
+import type { ExportFormat } from '$lib/api/lightly_studio_local';
+import { triggerDownloadUrl } from '$lib/utils';
+
+type ExportResult = { error?: string };
+
+export const exportAnnotations = async ({
+ collection_id,
+ annotation_collection_id,
+ export_format
+}: {
+ collection_id: string;
+ annotation_collection_id: string | null;
+ export_format?: ExportFormat;
+}): Promise => {
+ try {
+ const baseUrl = (client.getConfig().baseUrl ?? '').replace(/\/$/, '');
+ const url = new URL(
+ `${baseUrl}/api/collections/${encodeURIComponent(collection_id)}/export/annotations`
+ );
+ if (annotation_collection_id) {
+ url.searchParams.set('annotation_collection_id', annotation_collection_id);
+ }
+ if (export_format) {
+ url.searchParams.set('export_format', export_format);
+ }
+ triggerDownloadUrl(url.toString());
+ return {};
+ } catch (e) {
+ return { error: 'Export failed: ' + String(e) };
+ }
+};
+
+export const exportCaptions = async (collection_id: string): Promise => {
+ try {
+ const baseUrl = (client.getConfig().baseUrl ?? '').replace(/\/$/, '');
+ const url = new URL(
+ `${baseUrl}/api/collections/${encodeURIComponent(collection_id)}/export/captions`
+ );
+ triggerDownloadUrl(url.toString());
+ return {};
+ } catch (e) {
+ return { error: 'Export failed: ' + String(e) };
+ }
+};
diff --git a/lightly_studio_view/src/lib/utils/triggerDownloadBlob.ts b/lightly_studio_view/src/lib/utils/triggerDownloadBlob.ts
index 78edc24df9..ee954d915b 100644
--- a/lightly_studio_view/src/lib/utils/triggerDownloadBlob.ts
+++ b/lightly_studio_view/src/lib/utils/triggerDownloadBlob.ts
@@ -10,3 +10,16 @@ export const triggerDownloadBlob = (fileName: string, blob: Blob): void => {
// remove from memory
window.URL.revokeObjectURL(url);
};
+
+/**
+ * Triggers a browser download by navigating directly to a URL.
+ * Unlike triggerDownloadBlob, the response is never buffered in JS memory —
+ * the browser's download manager streams the file directly to disk.
+ */
+export const triggerDownloadUrl = (url: string): void => {
+ const link = document.createElement('a');
+ link.href = url;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+};