Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): use wasm for justified layout calculation #15524

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 18 additions & 13 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
"@testing-library/svelte": "^5.2.6",
"@testing-library/user-event": "^14.5.2",
"@types/dom-to-image": "^2.6.7",
"@types/justified-layout": "^4.1.4",
"@types/lodash-es": "^4.17.12",
"@types/luxon": "^3.4.2",
"@typescript-eslint/eslint-plugin": "^8.20.0",
Expand All @@ -61,11 +60,13 @@
"tslib": "^2.6.2",
"typescript": "^5.7.3",
"vite": "^6.0.0",
"vite-plugin-wasm": "^3.4.1",
"vitest": "^3.0.0"
},
"type": "module",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/justified-layout-wasm": "^0.1.2",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.15.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
Expand All @@ -77,7 +78,6 @@
"dom-to-image": "^2.6.0",
"handlebars": "^4.7.8",
"intl-messageformat": "^10.7.11",
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"luxon": "^3.4.4",
"socket.io-client": "~4.7.5",
Expand Down
30 changes: 18 additions & 12 deletions web/src/lib/components/photos-page/asset-date-group.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
{#each dateGroups as dateGroup, groupIndex (dateGroup.date)}
{@const display =
dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === $assetStore.pendingScrollAssetId)}
{@const geometry = dateGroup.geometry}

<div
id="date-group"
Expand All @@ -118,7 +119,7 @@
data-display={display}
data-date-group={dateGroup.date}
style:height={dateGroup.height + 'px'}
style:width={dateGroup.geometry.containerWidth + 'px'}
style:width={geometry.containerWidth + 'px'}
style:overflow={'clip'}
>
{#if !display}
Expand Down Expand Up @@ -149,7 +150,7 @@
<!-- Date group title -->
<div
class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
style:width={dateGroup.geometry.containerWidth + 'px'}
style:width={geometry.containerWidth + 'px'}
>
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
<div
Expand All @@ -174,11 +175,16 @@
<!-- Image grid -->
<div
class="relative overflow-clip"
style:height={dateGroup.geometry.containerHeight + 'px'}
style:width={dateGroup.geometry.containerWidth + 'px'}
style:height={geometry.containerHeight + 'px'}
style:width={geometry.containerWidth + 'px'}
>
{#each dateGroup.assets as asset, index (asset.id)}
{@const box = dateGroup.geometry.boxes[index]}
{#each dateGroup.assets as asset, i (asset.id)}
<!-- getting these together here in this order is very cache-efficient -->
{@const top = geometry.getTop(i)}
{@const left = geometry.getLeft(i)}
{@const width = geometry.getWidth(i)}
{@const height = geometry.getHeight(i)}

<!-- update ASSET_GRID_PADDING-->
<div
use:intersectionObserver={{
Expand All @@ -190,10 +196,10 @@
}}
data-asset-id={asset.id}
class="absolute"
style:width={box.width + 'px'}
style:height={box.height + 'px'}
style:top={box.top + 'px'}
style:left={box.left + 'px'}
style:top={top + 'px'}
style:left={left + 'px'}
style:width={width + 'px'}
style:height={height + 'px'}
>
<Thumbnail
{dateGroup}
Expand All @@ -215,8 +221,8 @@
selected={assetInteraction.selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
disabled={$assetStore.albumAssets.has(asset.id)}
thumbnailWidth={box.width}
thumbnailHeight={box.height}
thumbnailWidth={width}
thumbnailHeight={height}
/>
</div>
{/each}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,11 @@
import type { Viewport } from '$lib/stores/assets.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, getAssetRatio } from '$lib/utils/asset-utils';
import { archiveAssets, cancelMultiselect, getJustifiedLayoutFromAssets } from '$lib/utils/asset-utils';
import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { calculateWidth } from '$lib/utils/timeline-util';
import { type AssetResponseDto } from '@immich/sdk';
import justifiedLayout from 'justified-layout';
import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import ShowShortcuts from '../show-shortcuts.svelte';
Expand Down Expand Up @@ -310,23 +308,12 @@
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));

let geometry = $derived(
(() => {
const justifiedLayoutResult = justifiedLayout(
assets.map((asset) => getAssetRatio(asset)),
{
boxSpacing: 2,
containerWidth: Math.floor(viewport.width),
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
},
);

return {
...justifiedLayoutResult,
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
};
})(),
getJustifiedLayoutFromAssets(assets, {
spacing: 2,
rowWidth: Math.floor(viewport.width),
heightTolerance: 0.15,
rowHeight: 235,
}),
);

$effect(() => {
Expand Down Expand Up @@ -364,11 +351,15 @@

{#if assets.length > 0}
<div class="relative" style="height: {geometry.containerHeight}px;width: {geometry.containerWidth}px ">
{#each assets as asset, i (i)}
{#each assets as asset, i}
{@const top = geometry.getTop(i)}
{@const left = geometry.getLeft(i)}
{@const width = geometry.getWidth(i)}
{@const height = geometry.getHeight(i)}

<div
class="absolute"
style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i]
.top}px; left: {geometry.boxes[i].left}px"
style="width: {width}px; height: {height}px; top: {top}px; left: {left}px"
title={showAssetName ? asset.originalFileName : ''}
>
<Thumbnail
Expand All @@ -387,8 +378,8 @@
{asset}
selected={assetInteraction.selectedAssets.has(asset)}
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
thumbnailWidth={geometry.boxes[i].width}
thumbnailHeight={geometry.boxes[i].height}
thumbnailWidth={width}
thumbnailHeight={height}
/>
{#if showAssetName}
<div
Expand Down
36 changes: 13 additions & 23 deletions web/src/lib/stores/assets.store.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { locale } from '$lib/stores/preferences.store';
import { getKey } from '$lib/utils';
import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/asset-utils';
import { generateId } from '$lib/utils/generate-id';
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { calculateWidth, fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util';
import { fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util';
import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import createJustifiedLayout from 'justified-layout';
import { throttle } from 'lodash-es';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
Expand All @@ -16,13 +15,6 @@ import { websocketEvents } from './websocket';
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'>;

const LAYOUT_OPTIONS = {
boxSpacing: 2,
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
};

export interface Viewport {
width: number;
height: number;
Expand Down Expand Up @@ -469,32 +461,30 @@ export class AssetStore {
assetGroup.heightActual = false;
}
}

const viewPortWidth = this.viewport.width;
if (!bucket.isBucketHeightActual) {
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
const rows = Math.ceil(unwrappedWidth / this.viewport.width);
const rows = Math.ceil(unwrappedWidth / viewPortWidth);
const height = 51 + rows * THUMBNAIL_HEIGHT;
bucket.bucketHeight = height;
}

const layoutOptions = {
spacing: 2,
heightTolerance: 0.15,
rowHeight: 235,
rowWidth: Math.floor(viewPortWidth),
};
for (const assetGroup of bucket.dateGroups) {
if (!assetGroup.heightActual) {
const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10);
const rows = Math.ceil(unwrappedWidth / this.viewport.width);
const rows = Math.ceil(unwrappedWidth / viewPortWidth);
const height = rows * THUMBNAIL_HEIGHT;
assetGroup.height = height;
}

const layoutResult = createJustifiedLayout(
assetGroup.assets.map((g) => getAssetRatio(g)),
{
...LAYOUT_OPTIONS,
containerWidth: Math.floor(this.viewport.width),
},
);
assetGroup.geometry = {
...layoutResult,
containerWidth: calculateWidth(layoutResult.boxes),
};
assetGroup.geometry = getJustifiedLayoutFromAssets(assetGroup.assets, layoutOptions);
}
}

Expand Down
11 changes: 11 additions & 0 deletions web/src/lib/utils/asset-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { downloadRequest, getKey, withError } from '$lib/utils';
import { createAlbum } from '$lib/utils/album-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { getFormatter } from '$lib/utils/i18n';
import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm';
import {
addAssetsToAlbum as addAssets,
createStack,
Expand Down Expand Up @@ -587,3 +588,13 @@ export const copyImageToClipboard = async (source: HTMLImageElement | string) =>
const blob = source instanceof HTMLImageElement ? await imgToBlob(source) : await urlToBlob(source);
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
};

export function getJustifiedLayoutFromAssets(assets: AssetResponseDto[], options: LayoutOptions) {
const aspectRatios = new Float32Array(assets.length);
// eslint-disable-next-line unicorn/no-for-loop
for (let i = 0; i < assets.length; i++) {
const { width, height } = getAssetRatio(assets[i]);
aspectRatios[i] = width / height;
}
return new JustifiedLayout(aspectRatios, options);
}
Loading
Loading