From 84d20e3c6105a963d92bf8fc36d921f1db9832ff Mon Sep 17 00:00:00 2001 From: Jannik Stehle Date: Thu, 20 Aug 2026 13:19:31 +0200 Subject: [PATCH 1/2] perf: dynamic tile preview dimensions Derive the requested tile preview dimensions from the current tile size setting and the device pixel ratio. This way we avoid fetching large previews for small tile sizes. Also reduce the max tile dimension from 1000 to 768 px because this feels sufficient, even on the largest tile size setting, and optimize the requested preview sizes in the right sidebar and the photo roll. --- .../SideBar/Details/FileDetails.vue | 2 +- .../src/components/Spaces/SpaceHeader.vue | 2 +- .../src/components/PhotoRoll.vue | 2 +- .../components/FilesList/ResourceTiles.vue | 3 +- .../SideBar/Spaces/Details/SpaceDetails.vue | 2 +- .../composables/resources/useLoadPreview.ts | 7 +- .../src/composables/viewMode/useTileSize.ts | 41 ++++++++- packages/web-pkg/src/constants.ts | 5 +- .../FilesList/ResourceTiles.spec.ts | 16 ++-- .../resources/useLoadPreview.spec.ts | 83 ++++++++++++++++++- 10 files changed, 143 insertions(+), 20 deletions(-) diff --git a/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue b/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue index 49d0612b75c..ce37b1a64b8 100644 --- a/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue +++ b/packages/web-app-files/src/components/SideBar/Details/FileDetails.vue @@ -358,7 +358,7 @@ watch( preview.value = await loadPreview({ space: unref(space), resource: unref(resource), - dimensions: ImageDimension.Preview, + dimensions: ImageDimension.Medium, cancelRunning: true, updateStore: false }) diff --git a/packages/web-app-files/src/components/Spaces/SpaceHeader.vue b/packages/web-app-files/src/components/Spaces/SpaceHeader.vue index db0a713f453..36e6c8ad6a7 100644 --- a/packages/web-app-files/src/components/Spaces/SpaceHeader.vue +++ b/packages/web-app-files/src/components/Spaces/SpaceHeader.vue @@ -284,7 +284,7 @@ watch( imageContent.value = await loadPreview({ space, resource: space.spaceImageData ? buildSpaceImageResource(space) : space, - dimensions: ImageDimension.Tile, + dimensions: ImageDimension.Preview, // request full preview because image can be expanded processor: ProcessorType.enum.fit, cancelRunning: true, updateStore: false diff --git a/packages/web-app-preview/src/components/PhotoRoll.vue b/packages/web-app-preview/src/components/PhotoRoll.vue index e6fbd3023e8..b055d45bb97 100644 --- a/packages/web-app-preview/src/components/PhotoRoll.vue +++ b/packages/web-app-preview/src/components/PhotoRoll.vue @@ -38,7 +38,7 @@ const onItemVisible = (item: MediaFile) => { resource: item.resource, space: getMatchingSpace(item.resource), processor: ProcessorType.enum.fit, - dimensions: ImageDimension.Tile + dimensions: ImageDimension.Small }) } diff --git a/packages/web-pkg/src/components/FilesList/ResourceTiles.vue b/packages/web-pkg/src/components/FilesList/ResourceTiles.vue index 68c0d049200..c768ca4f3aa 100644 --- a/packages/web-pkg/src/components/FilesList/ResourceTiles.vue +++ b/packages/web-pkg/src/components/FilesList/ResourceTiles.vue @@ -294,7 +294,7 @@ const updateViewWidth = () => { const gapSizePixels = computed(() => { return parseFloat(getComputedStyle(document.documentElement).fontSize) }) -const { calculateTileSizePixels } = useTileSize() +const { calculateTileSizePixels, setRenderedTileSize } = useTileSize() const maxTilesAll = computed(() => { const viewSizes = [...Array(FolderViewModeConstants.tilesSizeMax).keys()].map((i) => i + 1) return [ @@ -328,6 +328,7 @@ watch( tileSizePixels, (px: number | undefined) => { if (px && !isNaN(px)) { + setRenderedTileSize(px) document.documentElement.style.setProperty(`--oc-size-tiles-actual`, `${px}px`) } }, diff --git a/packages/web-pkg/src/components/SideBar/Spaces/Details/SpaceDetails.vue b/packages/web-pkg/src/components/SideBar/Spaces/Details/SpaceDetails.vue index e58a35122dd..63e29a5bba1 100644 --- a/packages/web-pkg/src/components/SideBar/Spaces/Details/SpaceDetails.vue +++ b/packages/web-pkg/src/components/SideBar/Spaces/Details/SpaceDetails.vue @@ -157,7 +157,7 @@ watch( resource: unref(resource).spaceImageData ? buildSpaceImageResource(unref(resource)) : unref(resource), - dimensions: ImageDimension.Tile, + dimensions: ImageDimension.Medium, processor: ProcessorType.enum.fit, cancelRunning: true, updateStore: false diff --git a/packages/web-pkg/src/composables/resources/useLoadPreview.ts b/packages/web-pkg/src/composables/resources/useLoadPreview.ts index 1ff6c30b4f7..a39b900a583 100644 --- a/packages/web-pkg/src/composables/resources/useLoadPreview.ts +++ b/packages/web-pkg/src/composables/resources/useLoadPreview.ts @@ -7,7 +7,7 @@ import { Resource, SpaceResource } from '@opencloud-eu/web-client' -import { FolderViewModeConstants } from '../viewMode' +import { FolderViewModeConstants, useTileSize } from '../viewMode' import { usePreviewService } from '../previewService' import { ProcessorType } from '../../services' import { useResourcesStore, useSpacesStore } from '../piniaStores' @@ -44,8 +44,9 @@ export const useLoadPreview = (viewMode?: Ref) => { const defaultProcessor = computed(() => unref(isTilesView) ? ProcessorType.enum.fit : ProcessorType.enum.thumbnail ) - const defaultDimensions = computed(() => - unref(isTilesView) ? ImageDimension.Tile : ImageDimension.Thumbnail + const { previewDimensions } = useTileSize() + const defaultDimensions = computed<[number, number]>(() => + unref(isTilesView) ? unref(previewDimensions) : ImageDimension.Thumbnail ) const loadPreviewTask = useTask(function* ( diff --git a/packages/web-pkg/src/composables/viewMode/useTileSize.ts b/packages/web-pkg/src/composables/viewMode/useTileSize.ts index ef40e8a00a9..d16f9468e73 100644 --- a/packages/web-pkg/src/composables/viewMode/useTileSize.ts +++ b/packages/web-pkg/src/composables/viewMode/useTileSize.ts @@ -1,9 +1,18 @@ -import { ref, unref } from 'vue' +import { computed, readonly, ref, unref } from 'vue' // sizes in pixels const BASE_SIZE = 140 const STEP_SIZE = 84 +// tile previews are snapped up to this step to keep the amount of distinct +// requests (and hence cache misses) low while resizing +const PREVIEW_SIZE_STEP = 64 +const PREVIEW_PIXEL_RATIO_MAX = 1.5 +const PREVIEW_SIZE_MAX = 768 +const PREVIEW_SIZE_MIN = 320 + +const renderedTileSizePixels = ref(0) + export const useTileSize = () => { const baseSizePixels = ref(BASE_SIZE) const stepSizePixels = ref(STEP_SIZE) @@ -16,8 +25,36 @@ export const useTileSize = () => { return calculateTileSizePixels(viewSize) / fontSize } + const setRenderedTileSize = (pixels: number) => { + renderedTileSizePixels.value = pixels + } + + /** + * Preview dimensions for tiles, derived from the rendered tile size and the device pixel ratio. + * Falls back to the maximum as long as no tile has been rendered. + */ + const previewDimensions = computed<[number, number]>(() => { + const cssPixels = unref(renderedTileSizePixels) + if (!cssPixels) { + return [PREVIEW_SIZE_MAX, PREVIEW_SIZE_MAX] + } + + // capped because serving the full ratio on high density displays costs a lot of + // bandwidth for a barely visible gain + const pixelRatio = Math.min(window.devicePixelRatio || 1, PREVIEW_PIXEL_RATIO_MAX) + const devicePixels = cssPixels * pixelRatio + const size = Math.min( + Math.max(Math.ceil(devicePixels / PREVIEW_SIZE_STEP) * PREVIEW_SIZE_STEP, PREVIEW_SIZE_MIN), + PREVIEW_SIZE_MAX + ) + return [size, size] + }) + return { calculateTileSizePixels, - calculateTileSizeRem + calculateTileSizeRem, + renderedTileSizePixels: readonly(renderedTileSizePixels), + setRenderedTileSize, + previewDimensions } } diff --git a/packages/web-pkg/src/constants.ts b/packages/web-pkg/src/constants.ts index 324f2ce0623..cb874728330 100644 --- a/packages/web-pkg/src/constants.ts +++ b/packages/web-pkg/src/constants.ts @@ -1,6 +1,9 @@ export abstract class ImageDimension { static readonly Thumbnail: [number, number] = [36, 36] - static readonly Tile: [number, number] = [1000, 1000] + static readonly Small: [number, number] = [320, 320] + static readonly Medium: [number, number] = [448, 448] + /** @deprecated use `previewDimensions` of `useTileSize` instead */ + static readonly Tile: [number, number] = [512, 512] static readonly Preview: [number, number] = [1200, 1200] static readonly Avatar: number = 64 } diff --git a/packages/web-pkg/tests/unit/components/FilesList/ResourceTiles.spec.ts b/packages/web-pkg/tests/unit/components/FilesList/ResourceTiles.spec.ts index d4484c3f13c..d5854c835bb 100644 --- a/packages/web-pkg/tests/unit/components/FilesList/ResourceTiles.spec.ts +++ b/packages/web-pkg/tests/unit/components/FilesList/ResourceTiles.spec.ts @@ -11,12 +11,16 @@ import { OcFilterChip } from '@opencloud-eu/design-system/components' import { useResourcesStore } from '../../../../src' vi.mock('../../../../src/helpers/contextMenuDropdown') -vi.mock('../../../../src/composables/viewMode', async (importOriginal) => ({ - ...(await importOriginal()), - useTileSize: vi.fn().mockReturnValue({ - calculateTileSizePixels: vi.fn().mockImplementation((viewSize: number) => 100 * viewSize) - }) -})) +vi.mock('../../../../src/composables/viewMode', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + useTileSize: vi.fn().mockImplementation(() => ({ + ...original.useTileSize(), + calculateTileSizePixels: vi.fn().mockImplementation((viewSize: number) => 100 * viewSize) + })) + } +}) const mockUseEmbedMode = vi.fn().mockReturnValue({ isEnabled: computed(() => false) }) vi.mock('../../../../src/composables/embedMode', () => ({ diff --git a/packages/web-pkg/tests/unit/composables/resources/useLoadPreview.spec.ts b/packages/web-pkg/tests/unit/composables/resources/useLoadPreview.spec.ts index 5fd0bb9bb54..bac98b63ac2 100644 --- a/packages/web-pkg/tests/unit/composables/resources/useLoadPreview.spec.ts +++ b/packages/web-pkg/tests/unit/composables/resources/useLoadPreview.spec.ts @@ -5,7 +5,7 @@ import { buildSpaceImageResource, Resource, SpaceResource } from '@opencloud-eu/ import { useLoadPreview } from '../../../../src/composables/resources' import { usePreviewService } from '../../../../src/composables/previewService' import { PreviewService, ProcessorType } from '../../../../src/services' -import { FolderViewModeConstants, ImageDimension } from '../../../../src' +import { FolderViewModeConstants, ImageDimension, useTileSize } from '../../../../src' import { useSpacesStore } from '../../../../src/composables/piniaStores' vi.mock('../../../../src/composables/previewService/usePreviewService') @@ -15,6 +15,11 @@ vi.mock('@opencloud-eu/web-client', async (importOriginal) => ({ })) describe('useLoadPreview', () => { + afterEach(() => { + window.devicePixelRatio = 1 + useTileSize().setRenderedTileSize(0) + }) + describe('loadPreview', () => { it('returns a loaded preview for a given file', () => { const loadedPreview = 'blob:image' @@ -107,14 +112,86 @@ describe('useLoadPreview', () => { } }) }) - it('uses tile default dimensions in tiles view', () => { + it('uses the maximum tile dimensions in tiles view while no tile has been rendered', () => { + getWrapper({ + setup: async ({ loadPreview }, { previewService }) => { + const space = mock() + const resource = mock({ isInVault: false }) + await loadPreview({ space, resource }) + expect(previewService.loadPreview).toHaveBeenCalledWith( + expect.objectContaining({ dimensions: [768, 768] }), + expect.anything(), + expect.anything(), + expect.anything() + ) + }, + viewMode: FolderViewModeConstants.name.tiles + }) + }) + it('derives the tile dimensions from the rendered tile size', () => { + window.devicePixelRatio = 1 + useTileSize().setRenderedTileSize(400) + getWrapper({ + setup: async ({ loadPreview }, { previewService }) => { + const space = mock() + const resource = mock({ isInVault: false }) + await loadPreview({ space, resource }) + expect(previewService.loadPreview).toHaveBeenCalledWith( + expect.objectContaining({ dimensions: [448, 448] }), + expect.anything(), + expect.anything(), + expect.anything() + ) + }, + viewMode: FolderViewModeConstants.name.tiles + }) + }) + it('does not go below the minimum tile dimensions', () => { + window.devicePixelRatio = 1 + useTileSize().setRenderedTileSize(150) + getWrapper({ + setup: async ({ loadPreview }, { previewService }) => { + const space = mock() + const resource = mock({ isInVault: false }) + await loadPreview({ space, resource }) + expect(previewService.loadPreview).toHaveBeenCalledWith( + expect.objectContaining({ dimensions: [320, 320] }), + expect.anything(), + expect.anything(), + expect.anything() + ) + }, + viewMode: FolderViewModeConstants.name.tiles + }) + }) + it('caps the device pixel ratio the tile dimensions are scaled with', () => { + window.devicePixelRatio = 3 + useTileSize().setRenderedTileSize(300) + getWrapper({ + setup: async ({ loadPreview }, { previewService }) => { + const space = mock() + const resource = mock({ isInVault: false }) + await loadPreview({ space, resource }) + expect(previewService.loadPreview).toHaveBeenCalledWith( + expect.objectContaining({ dimensions: [512, 512] }), + expect.anything(), + expect.anything(), + expect.anything() + ) + }, + viewMode: FolderViewModeConstants.name.tiles + }) + }) + it('caps the tile dimensions for large tiles', () => { + window.devicePixelRatio = 2 + useTileSize().setRenderedTileSize(600) getWrapper({ setup: async ({ loadPreview }, { previewService }) => { const space = mock() const resource = mock({ isInVault: false }) await loadPreview({ space, resource }) expect(previewService.loadPreview).toHaveBeenCalledWith( - expect.objectContaining({ dimensions: ImageDimension.Tile }), + expect.objectContaining({ dimensions: [768, 768] }), expect.anything(), expect.anything(), expect.anything() From 3e242fd4054ef590160504496a520676d2215d13 Mon Sep 17 00:00:00 2001 From: Jannik Stehle Date: Thu, 20 Aug 2026 17:01:27 +0200 Subject: [PATCH 2/2] perf: use 32x32 for thumbnails Using 36x36 like before resulted in the server delivering 64x64 images, because it chooses the next biggest resolution, not the closest. --- packages/web-pkg/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-pkg/src/constants.ts b/packages/web-pkg/src/constants.ts index cb874728330..767d41cb9ca 100644 --- a/packages/web-pkg/src/constants.ts +++ b/packages/web-pkg/src/constants.ts @@ -1,5 +1,5 @@ export abstract class ImageDimension { - static readonly Thumbnail: [number, number] = [36, 36] + static readonly Thumbnail: [number, number] = [32, 32] static readonly Small: [number, number] = [320, 320] static readonly Medium: [number, number] = [448, 448] /** @deprecated use `previewDimensions` of `useTileSize` instead */