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} +
+ +
+ {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(); +};