mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 13:15:22 -04:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5194643571 | |||
| 0560f98c2d | |||
| 49ad411d50 | |||
| 2478cc40f4 | |||
| 44eeb1e088 | |||
| a868ae3ad0 | |||
| acac0d4f37 | |||
| 8c40a28fef | |||
| b2081eda1e | |||
| 9670c853c6 | |||
| cc2dacb308 | |||
| 15fc6b18f3 | |||
| a284e38890 | |||
| 05010c3a84 | |||
| 4da3d68a67 | |||
| 20c639e52a | |||
| 6deb97d5bc | |||
| b282d83e95 | |||
| 5bc08f8654 | |||
| f54924d46a | |||
| dffe4d1d5c | |||
| 7f47cdd645 | |||
| 625b30c50a | |||
| 8619d14eca | |||
| 062546c168 | |||
| ea668d6b22 | |||
| f06af2c600 |
@@ -24,8 +24,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
# sha is pinning to a commit instead of a tag since the action does not tag versions
|
||||
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
|
||||
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.10.14",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
terragrunt = "0.98.0"
|
||||
terragrunt = "0.99.4"
|
||||
opentofu = "1.11.4"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
|
||||
@@ -155,7 +155,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
container_name: immich_prometheus
|
||||
ports:
|
||||
- 9090:9090
|
||||
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
||||
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
user: '1000:1000'
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
@@ -67,7 +67,8 @@ graph TD
|
||||
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
||||
D --> E[Smart Search]
|
||||
D --> F[Face Detection]
|
||||
D --> G[Video Transcoding]
|
||||
E --> H[Duplicate Detection]
|
||||
F --> I[Facial Recognition]
|
||||
D --> G[OCR]
|
||||
D --> H[Video Transcoding]
|
||||
E --> I[Duplicate Detection]
|
||||
F --> J[Facial Recognition]
|
||||
```
|
||||
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich-e2e-redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.10.14",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
async function ensureDetailPanelVisible(page: Page) {
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const isVisible = await page.locator('#detail-panel').isVisible();
|
||||
if (!isVisible) {
|
||||
await page.keyboard.press('i');
|
||||
await page.waitForSelector('#detail-panel');
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Asset Viewer stack', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let assetOne: AssetMediaResponseDto;
|
||||
let assetTwo: AssetMediaResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
|
||||
|
||||
assetOne = await utils.createAsset(admin.accessToken);
|
||||
assetTwo = await utils.createAsset(admin.accessToken);
|
||||
await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
|
||||
|
||||
const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
|
||||
const tagOne = tags.find((tag) => tag.value === 'test/1')!;
|
||||
const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
|
||||
await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
|
||||
await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
|
||||
});
|
||||
|
||||
test('stack slideshow is visible', async ({ page, context }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto(`/photos/${assetOne.id}`);
|
||||
|
||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||
await expect(stackAssets.first()).toBeVisible();
|
||||
await expect(stackAssets.nth(1)).toBeVisible();
|
||||
});
|
||||
|
||||
test('tags of primary asset are visible', async ({ page, context }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto(`/photos/${assetOne.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/1');
|
||||
});
|
||||
|
||||
test('tags of second asset are visible', async ({ page, context }) => {
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto(`/photos/${assetOne.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||
await stackAssets.nth(1).click();
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline';
|
||||
|
||||
export type MockStack = {
|
||||
id: string;
|
||||
primaryAssetId: string;
|
||||
assets: AssetResponseDto[];
|
||||
brokenAssetIds: Set<string>;
|
||||
assetMap: Map<string, AssetResponseDto>;
|
||||
};
|
||||
|
||||
export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
|
||||
const assetId = faker.string.uuid();
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: assetId,
|
||||
deviceAssetId: `device-${assetId}`,
|
||||
ownerId,
|
||||
owner: {
|
||||
id: ownerId,
|
||||
email: 'admin@immich.cloud',
|
||||
name: 'Admin',
|
||||
profileImagePath: '',
|
||||
profileChangedAt: now,
|
||||
avatarColor: 'blue' as never,
|
||||
},
|
||||
libraryId: `library-${ownerId}`,
|
||||
deviceId: `device-${ownerId}`,
|
||||
type: AssetTypeEnum.Image,
|
||||
originalPath: `/original/${assetId}.jpg`,
|
||||
originalFileName: `${assetId}.jpg`,
|
||||
originalMimeType: 'image/jpeg',
|
||||
thumbhash: null,
|
||||
fileCreatedAt: now,
|
||||
fileModifiedAt: now,
|
||||
localDateTime: now,
|
||||
updatedAt: now,
|
||||
createdAt: now,
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
isTrashed: false,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
duration: '0:00:00.00000',
|
||||
exifInfo: {
|
||||
make: null,
|
||||
model: null,
|
||||
exifImageWidth: 3000,
|
||||
exifImageHeight: 4000,
|
||||
fileSizeInByte: null,
|
||||
orientation: null,
|
||||
dateTimeOriginal: now,
|
||||
modifyDate: null,
|
||||
timeZone: null,
|
||||
lensModel: null,
|
||||
fNumber: null,
|
||||
focalLength: null,
|
||||
iso: null,
|
||||
exposureTime: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
city: null,
|
||||
country: null,
|
||||
state: null,
|
||||
description: null,
|
||||
},
|
||||
livePhotoVideoId: null,
|
||||
tags: [],
|
||||
people: [],
|
||||
unassignedFaces: [],
|
||||
stack: null,
|
||||
isOffline: false,
|
||||
hasMetadata: true,
|
||||
duplicateId: null,
|
||||
resized: true,
|
||||
checksum: faker.string.alphanumeric({ length: 28 }),
|
||||
width: 3000,
|
||||
height: 4000,
|
||||
isEdited: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMockStack = (
|
||||
primaryAssetDto: AssetResponseDto,
|
||||
additionalAssets: AssetResponseDto[],
|
||||
brokenAssetIds?: Set<string>,
|
||||
): MockStack => {
|
||||
const stackId = faker.string.uuid();
|
||||
const allAssets = [primaryAssetDto, ...additionalAssets];
|
||||
const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id));
|
||||
const assetMap = new Map(allAssets.map((a) => [a.id, a]));
|
||||
|
||||
primaryAssetDto.stack = {
|
||||
id: stackId,
|
||||
assetCount: allAssets.length,
|
||||
primaryAssetId: primaryAssetDto.id,
|
||||
};
|
||||
|
||||
return {
|
||||
id: stackId,
|
||||
primaryAssetId: primaryAssetDto.id,
|
||||
assets: allAssets,
|
||||
brokenAssetIds: resolvedBrokenIds,
|
||||
assetMap,
|
||||
};
|
||||
};
|
||||
|
||||
export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => {
|
||||
await context.route('**/api/stacks/*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
const stackResponse: StackResponseDto = {
|
||||
id: mockStack.id,
|
||||
primaryAssetId: mockStack.primaryAssetId,
|
||||
assets: mockStack.assets,
|
||||
};
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: stackResponse,
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
const url = new URL(request.url());
|
||||
const segments = url.pathname.split('/');
|
||||
const assetId = segments.at(-1);
|
||||
if (assetId && mockStack.assetMap.has(assetId)) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: mockStack.assetMap.get(assetId),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||
const match = request.url().match(pattern);
|
||||
if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) {
|
||||
return route.fallback();
|
||||
}
|
||||
if (mockStack.brokenAssetIds.has(match.groups.assetId)) {
|
||||
return route.fulfill({ status: 404 });
|
||||
}
|
||||
const asset = mockStack.assetMap.get(match.groups.assetId)!;
|
||||
const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000);
|
||||
const body =
|
||||
match.groups.size === 'preview'
|
||||
? await randomPreview(match.groups.assetId, ratio)
|
||||
: await randomThumbnail(match.groups.assetId, ratio);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { randomThumbnail } from 'src/ui/generators/timeline';
|
||||
|
||||
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
|
||||
const MINIMAL_MP4_BASE64 =
|
||||
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' +
|
||||
'3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' +
|
||||
'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' +
|
||||
'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' +
|
||||
'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' +
|
||||
'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' +
|
||||
'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' +
|
||||
'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' +
|
||||
'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' +
|
||||
'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' +
|
||||
'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' +
|
||||
'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' +
|
||||
'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' +
|
||||
'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' +
|
||||
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' +
|
||||
'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' +
|
||||
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' +
|
||||
'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' +
|
||||
'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' +
|
||||
'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' +
|
||||
'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' +
|
||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' +
|
||||
'2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' +
|
||||
'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' +
|
||||
'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' +
|
||||
'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0';
|
||||
|
||||
export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64');
|
||||
|
||||
export type MockPerson = {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string | null;
|
||||
isHidden: boolean;
|
||||
thumbnailPath: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const createMockPeople = (count: number): MockPerson[] => {
|
||||
const names = [
|
||||
'Alice Johnson',
|
||||
'Bob Smith',
|
||||
'Charlie Brown',
|
||||
'Diana Prince',
|
||||
'Eve Adams',
|
||||
'Frank Castle',
|
||||
'Grace Lee',
|
||||
'Hank Pym',
|
||||
'Iris West',
|
||||
'Jack Ryan',
|
||||
];
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `person-${index}`,
|
||||
name: names[index % names.length],
|
||||
birthDate: null,
|
||||
isHidden: false,
|
||||
thumbnailPath: `/upload/thumbs/person-${index}.jpeg`,
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
}));
|
||||
};
|
||||
|
||||
export type FaceCreateCapture = {
|
||||
requests: Array<{
|
||||
assetId: string;
|
||||
personId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const setupFaceEditorMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
mockPeople: MockPerson[],
|
||||
faceCreateCapture: FaceCreateCapture,
|
||||
) => {
|
||||
await context.route('**/api/people?*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
hasNextPage: false,
|
||||
hidden: 0,
|
||||
people: mockPeople,
|
||||
total: mockPeople.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/faces', async (route, request) => {
|
||||
if (request.method() !== 'POST') {
|
||||
return route.fallback();
|
||||
}
|
||||
|
||||
const body = request.postDataJSON();
|
||||
faceCreateCapture.requests.push(body);
|
||||
|
||||
return route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'text/plain',
|
||||
body: 'OK',
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/people/*/thumbnail', async (route) => {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: await randomThumbnail('person-thumb', 1),
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { sleep } from 'src/ui/specs/timeline/utils';
|
||||
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
|
||||
|
||||
export class TimelineTestContext {
|
||||
slowBucket = false;
|
||||
@@ -135,6 +136,14 @@ export const setupTimelineMockApiRoutes = async (
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await context.route('**/api/assets/*/video/playback*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'video/mp4' },
|
||||
body: MINIMAL_MP4_BUFFER,
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/albums/**', async (route, request) => {
|
||||
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
||||
if (albumsMatch) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
||||
import {
|
||||
createMockStack,
|
||||
createMockStackAsset,
|
||||
MockStack,
|
||||
setupBrokenAssetMockApiRoutes,
|
||||
} from 'src/ui/mock-network/broken-asset-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('broken-asset responsiveness', () => {
|
||||
const fixture = setupAssetViewerFixture(889);
|
||||
let mockStack: MockStack;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
|
||||
const brokenAssets = [
|
||||
createMockStackAsset(fixture.adminUserId),
|
||||
createMockStackAsset(fixture.adminUserId),
|
||||
createMockStackAsset(fixture.adminUserId),
|
||||
];
|
||||
|
||||
mockStack = createMockStack(primaryAssetDto, brokenAssets);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||
});
|
||||
|
||||
test('broken asset in stack strip hides icon at small size', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
|
||||
await expect(brokenAssets.first()).toBeVisible();
|
||||
await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size);
|
||||
|
||||
for (const brokenAsset of await brokenAssets.all()) {
|
||||
await expect(brokenAsset.locator('svg')).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('broken asset in stack strip uses text-xs class', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
|
||||
await expect(brokenAssets.first()).toBeVisible();
|
||||
|
||||
for (const brokenAsset of await brokenAssets.all()) {
|
||||
const messageSpan = brokenAsset.locator('span');
|
||||
await expect(messageSpan).toHaveClass(/text-xs/);
|
||||
}
|
||||
});
|
||||
|
||||
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
|
||||
await context.route(
|
||||
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
|
||||
async (route) => {
|
||||
return route.fulfill({ status: 404 });
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
|
||||
await expect(viewerBrokenAsset).toBeVisible();
|
||||
|
||||
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
||||
|
||||
const messageSpan = viewerBrokenAsset.locator('span');
|
||||
await expect(messageSpan).toHaveClass(/text-base/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline';
|
||||
import {
|
||||
createMockPeople,
|
||||
FaceCreateCapture,
|
||||
MockPerson,
|
||||
setupFaceEditorMockApiRoutes,
|
||||
} from 'src/ui/mock-network/face-editor-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
const waitForSelectorTransition = async (page: Page) => {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const selector = document.querySelector('#face-selector') as HTMLElement | null;
|
||||
if (!selector) {
|
||||
return false;
|
||||
}
|
||||
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 1000, polling: 50 },
|
||||
);
|
||||
};
|
||||
|
||||
const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.keyboard.press('i');
|
||||
await page.locator('#detail-panel').waitFor({ state: 'visible' });
|
||||
await page.getByLabel('Tag people').click();
|
||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||
await waitForSelectorTransition(page);
|
||||
};
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('face-editor', () => {
|
||||
const fixture = setupAssetViewerFixture(777);
|
||||
const rng = new SeededRandom(777);
|
||||
let mockPeople: MockPerson[];
|
||||
let faceCreateCapture: FaceCreateCapture;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
mockPeople = createMockPeople(8);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
faceCreateCapture = { requests: [] };
|
||||
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
|
||||
});
|
||||
|
||||
type ScreenRect = { top: number; left: number; width: number; height: number };
|
||||
|
||||
const getFaceBoxRect = async (page: Page): Promise<ScreenRect> => {
|
||||
const dataEl = page.locator('#face-editor-data');
|
||||
await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/);
|
||||
await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/);
|
||||
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
|
||||
await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/);
|
||||
const canvasBox = await page.locator('#face-editor').boundingBox();
|
||||
if (!canvasBox) {
|
||||
throw new Error('Canvas element not found');
|
||||
}
|
||||
const left = Number(await dataEl.getAttribute('data-face-left'));
|
||||
const top = Number(await dataEl.getAttribute('data-face-top'));
|
||||
const width = Number(await dataEl.getAttribute('data-face-width'));
|
||||
const height = Number(await dataEl.getAttribute('data-face-height'));
|
||||
return {
|
||||
top: canvasBox.y + top,
|
||||
left: canvasBox.x + left,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
const getSelectorRect = async (page: Page): Promise<ScreenRect> => {
|
||||
const box = await page.locator('#face-selector').boundingBox();
|
||||
if (!box) {
|
||||
throw new Error('Face selector element not found');
|
||||
}
|
||||
return { top: box.y, left: box.x, width: box.width, height: box.height };
|
||||
};
|
||||
|
||||
const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => {
|
||||
const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left));
|
||||
const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
|
||||
return overlapX * overlapY;
|
||||
};
|
||||
|
||||
const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => {
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const centerX = faceBox.left + faceBox.width / 2;
|
||||
const centerY = faceBox.top + faceBox.height / 2;
|
||||
await page.mouse.move(centerX, centerY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
test('Face editor opens with person list', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeVisible();
|
||||
await expect(page.locator('#face-editor')).toBeVisible();
|
||||
|
||||
for (const person of mockPeople) {
|
||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Search filters people by name', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const searchInput = page.locator('#face-selector input');
|
||||
await searchInput.fill('Alice');
|
||||
|
||||
await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible();
|
||||
await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden();
|
||||
|
||||
await searchInput.clear();
|
||||
|
||||
for (const person of mockPeople) {
|
||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Search with no results shows empty message', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const searchInput = page.locator('#face-selector input');
|
||||
await searchInput.fill('Nonexistent Person XYZ');
|
||||
|
||||
for (const person of mockPeople) {
|
||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden();
|
||||
}
|
||||
});
|
||||
|
||||
test('Selecting a person shows confirmation dialog', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const personToTag = mockPeople[0];
|
||||
await page.locator('#face-selector').getByText(personToTag.name).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Confirming tag calls createFace API and closes editor', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const personToTag = mockPeople[0];
|
||||
await page.locator('#face-selector').getByText(personToTag.name).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByRole('button', { name: /confirm/i }).click();
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeHidden();
|
||||
await expect(page.locator('#face-editor')).toBeHidden();
|
||||
|
||||
expect(faceCreateCapture.requests).toHaveLength(1);
|
||||
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
|
||||
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
|
||||
});
|
||||
|
||||
test('Cancel button closes face editor', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeVisible();
|
||||
await expect(page.locator('#face-editor')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeHidden();
|
||||
await expect(page.locator('#face-editor')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Selector does not overlap face box on initial open', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box down', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, 0, 150);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box right', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, 200, 0);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, -300, -300);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, 300, 300);
|
||||
|
||||
const faceBox = await getFaceBoxRect(page);
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||
|
||||
expect(overlap).toBe(0);
|
||||
});
|
||||
|
||||
test('Selector stays within viewport bounds', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const viewportSize = page.viewportSize()!;
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
|
||||
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
||||
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
||||
});
|
||||
|
||||
test('Selector stays within viewport after dragging to edge', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
await dragFaceBox(page, -400, -400);
|
||||
|
||||
const viewportSize = page.viewportSize()!;
|
||||
const selectorBox = await getSelectorRect(page);
|
||||
|
||||
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
||||
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
||||
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
||||
});
|
||||
|
||||
test('Face box is draggable on the canvas', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const beforeDrag = await getFaceBoxRect(page);
|
||||
await dragFaceBox(page, 100, 50);
|
||||
const afterDrag = await getFaceBoxRect(page);
|
||||
|
||||
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
|
||||
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
||||
import {
|
||||
createMockStack,
|
||||
createMockStackAsset,
|
||||
MockStack,
|
||||
setupBrokenAssetMockApiRoutes,
|
||||
} from 'src/ui/mock-network/broken-asset-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { enableTagsPreference, ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer stack', () => {
|
||||
const fixture = setupAssetViewerFixture(888);
|
||||
let mockStack: MockStack;
|
||||
let primaryAssetDto: AssetResponseDto;
|
||||
let secondAssetDto: AssetResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
primaryAssetDto.tags = [
|
||||
{
|
||||
id: faker.string.uuid(),
|
||||
name: '1',
|
||||
value: 'test/1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
||||
secondAssetDto.tags = [
|
||||
{
|
||||
id: faker.string.uuid(),
|
||||
name: '2',
|
||||
value: 'test/2',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||
});
|
||||
|
||||
test('stack slideshow is visible', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const stackAssets = stackSlideshow.locator('[data-asset]');
|
||||
await expect(stackAssets).toHaveCount(mockStack.assets.length);
|
||||
});
|
||||
|
||||
test('tags of primary asset are visible', async ({ context, page }) => {
|
||||
await enableTagsPreference(context);
|
||||
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/1');
|
||||
});
|
||||
|
||||
test('tags of second asset are visible', async ({ context, page }) => {
|
||||
await enableTagsPreference(context);
|
||||
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||
await stackAssets.nth(1).click();
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext, Page, test } from '@playwright/test';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
SeededRandom,
|
||||
selectRandom,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
toAssetResponseDto,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
export type AssetViewerTestFixture = {
|
||||
adminUserId: string;
|
||||
timelineRestData: TimelineData;
|
||||
assets: TimelineAssetConfig[];
|
||||
testContext: TimelineTestContext;
|
||||
changes: Changes;
|
||||
primaryAsset: TimelineAssetConfig;
|
||||
primaryAssetDto: AssetResponseDto;
|
||||
};
|
||||
|
||||
export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture {
|
||||
const rng = new SeededRandom(seed);
|
||||
const testContext = new TimelineTestContext();
|
||||
|
||||
const fixture: AssetViewerTestFixture = {
|
||||
adminUserId: undefined!,
|
||||
timelineRestData: undefined!,
|
||||
assets: [],
|
||||
testContext,
|
||||
changes: {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
},
|
||||
primaryAsset: undefined!,
|
||||
primaryAssetDto: undefined!,
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.fail(
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
||||
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
||||
);
|
||||
utils.initSdk();
|
||||
fixture.adminUserId = faker.string.uuid();
|
||||
testContext.adminId = fixture.adminUserId;
|
||||
fixture.timelineRestData = generateTimelineData({
|
||||
...createDefaultTimelineConfig(),
|
||||
ownerId: fixture.adminUserId,
|
||||
});
|
||||
for (const timeBucket of fixture.timelineRestData.buckets.values()) {
|
||||
fixture.assets.push(...timeBucket);
|
||||
}
|
||||
|
||||
fixture.primaryAsset = selectRandom(
|
||||
fixture.assets.filter((a) => a.isImage),
|
||||
rng,
|
||||
);
|
||||
fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, fixture.adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
fixture.testContext.slowBucket = false;
|
||||
fixture.changes.albumAdditions = [];
|
||||
fixture.changes.assetDeletions = [];
|
||||
fixture.changes.assetArchivals = [];
|
||||
fixture.changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
return fixture;
|
||||
}
|
||||
|
||||
export async function ensureDetailPanelVisible(page: Page) {
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const isVisible = await page.locator('#detail-panel').isVisible();
|
||||
if (!isVisible) {
|
||||
await page.keyboard.press('i');
|
||||
await page.waitForSelector('#detail-panel');
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableTagsPreference(context: BrowserContext) {
|
||||
await context.route('**/users/me/preferences', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
albums: { defaultAssetOrder: 'desc' },
|
||||
folders: { enabled: false, sidebarWeb: false },
|
||||
memories: { enabled: true, duration: 5 },
|
||||
people: { enabled: true, sidebarWeb: false },
|
||||
sharedLinks: { enabled: true, sidebarWeb: false },
|
||||
ratings: { enabled: false },
|
||||
tags: { enabled: true, sidebarWeb: false },
|
||||
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
|
||||
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
|
||||
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
|
||||
cast: { gCastEnabled: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
+4
-4
@@ -871,8 +871,8 @@
|
||||
"current_pin_code": "Current PIN code",
|
||||
"current_server_address": "Current server address",
|
||||
"custom_date": "Custom date",
|
||||
"custom_locale": "Custom Locale",
|
||||
"custom_locale_description": "Format dates and numbers based on the language and the region",
|
||||
"custom_locale": "Custom locale",
|
||||
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
|
||||
"custom_url": "Custom URL",
|
||||
"cutoff_date_description": "Keep photos from the last…",
|
||||
"cutoff_day": "{count, plural, one {day} other {days}}",
|
||||
@@ -895,8 +895,6 @@
|
||||
"deduplication_criteria_2": "Count of EXIF data",
|
||||
"deduplication_info": "Deduplication Info",
|
||||
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
|
||||
"default_locale": "Default Locale",
|
||||
"default_locale_description": "Format dates and numbers based on your browser locale",
|
||||
"delete": "Delete",
|
||||
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
||||
"delete_action_prompt": "{count} deleted",
|
||||
@@ -2338,6 +2336,8 @@
|
||||
"url": "URL",
|
||||
"usage": "Usage",
|
||||
"use_biometric": "Use biometric",
|
||||
"use_browser_locale": "Use browser locale",
|
||||
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
|
||||
"use_current_connection": "Use current connection",
|
||||
"use_custom_date_range": "Use custom date range instead",
|
||||
"user": "User",
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hashes for inline <script> elements in app.html
|
||||
* and updates the script-src CSP directive in svelte.config.js.
|
||||
*
|
||||
* SvelteKit's CSP hash mode only hashes inline content it generates itself,
|
||||
* not the template content from app.html. This script fills that gap.
|
||||
*
|
||||
* Run this script whenever the inline scripts in app.html change.
|
||||
*
|
||||
* Usage: node misc/update-csp-hashes.mjs
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = join(scriptDirectory, '..');
|
||||
const appHtmlPath = join(repoRoot, 'web', 'src', 'app.html');
|
||||
const configPath = join(repoRoot, 'web', 'svelte.config.js');
|
||||
|
||||
const appHtml = readFileSync(appHtmlPath, 'utf-8');
|
||||
const scriptRegex = /<script[^>]*>([\s\S]*?)<\/script>/gi;
|
||||
|
||||
const hashes = [];
|
||||
let match;
|
||||
while ((match = scriptRegex.exec(appHtml)) !== null) {
|
||||
const content = match[1];
|
||||
const hash = createHash('sha256').update(content).digest('base64');
|
||||
hashes.push(`sha256-${hash}`);
|
||||
const preview = content.trim().slice(0, 60).replaceAll('\n', ' ');
|
||||
console.log(`Found: ${preview}...`);
|
||||
console.log(` Hash: sha256-${hash}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (hashes.length === 0) {
|
||||
console.log('No inline <script> elements found in app.html');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let config = readFileSync(configPath, 'utf-8');
|
||||
|
||||
const scriptSrcRegex = /'script-src':\s*\[[\s\S]*?\]/;
|
||||
const scriptSrcMatch = config.match(scriptSrcRegex);
|
||||
if (!scriptSrcMatch) {
|
||||
console.error("Could not find 'script-src' directive in svelte.config.js");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existingEntries = [];
|
||||
const entryRegex = /'([^']+)'/g;
|
||||
let entryMatch;
|
||||
while ((entryMatch = entryRegex.exec(scriptSrcMatch[0])) !== null) {
|
||||
const value = entryMatch[1];
|
||||
if (value === 'script-src' || value.startsWith('sha256-')) {
|
||||
continue;
|
||||
}
|
||||
existingEntries.push(value);
|
||||
}
|
||||
|
||||
const allEntries = [...existingEntries, ...hashes];
|
||||
const formatted = allEntries.map((entry) => ` '${entry}'`).join(',\n');
|
||||
const newScriptSrc = `'script-src': [\n${formatted},\n ]`;
|
||||
|
||||
config = config.replace(scriptSrcRegex, newScriptSrc);
|
||||
writeFileSync(configPath, config);
|
||||
|
||||
console.log(`Updated svelte.config.js with ${hashes.length} script hash(es)`);
|
||||
@@ -16,8 +16,8 @@ config_roots = [
|
||||
[tools]
|
||||
node = "24.13.1"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.30.0"
|
||||
terragrunt = "0.98.0"
|
||||
pnpm = "10.30.3"
|
||||
terragrunt = "0.99.4"
|
||||
opentofu = "1.11.4"
|
||||
java = "21.0.2"
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.database.Cursor
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.ext.SdkExtensions
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
@@ -78,15 +79,22 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
add(MediaStore.MediaColumns.IS_FAVORITE)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (hasSpecialFormatColumn()) {
|
||||
add(SPECIAL_FORMAT_COLUMN)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Fallback: read XMP from MediaStore to detect Motion Photos
|
||||
// only needed if SPECIAL_FORMAT column isn't available
|
||||
add(MediaStore.MediaColumns.XMP)
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||
|
||||
// _special_format requires S Extensions 21+
|
||||
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
|
||||
private fun hasSpecialFormatColumn(): Boolean =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 21
|
||||
}
|
||||
|
||||
protected fun getCursor(
|
||||
|
||||
@@ -18,11 +18,10 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
|
||||
@@ -53,7 +52,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
||||
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
|
||||
|
||||
double _snapOffset = 0.0;
|
||||
|
||||
@@ -79,7 +77,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_proxyScrollController.dispose();
|
||||
_scaleBoundarySub?.cancel();
|
||||
_eventSubscription?.cancel();
|
||||
_videoScaleStateNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -249,17 +246,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||
|
||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||
_isZoomed =
|
||||
scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleState == PhotoViewScaleState.covering ||
|
||||
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
|
||||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
|
||||
_isZoomed = switch (scaleState) {
|
||||
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
|
||||
_ => false,
|
||||
};
|
||||
_viewer.setZoomed(_isZoomed);
|
||||
|
||||
if (scaleState != PhotoViewScaleState.initial) {
|
||||
if (_dragStart == null) _viewer.setControls(false);
|
||||
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -334,31 +328,36 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
);
|
||||
}
|
||||
|
||||
final Size childSize;
|
||||
if (displayAsset.width != null && displayAsset.height != null) {
|
||||
final r = displayAsset.width! / displayAsset.height!;
|
||||
final w = math.min(context.width, context.height * r);
|
||||
childSize = Size(w, w / r);
|
||||
} else {
|
||||
childSize = Size(context.height, context.height);
|
||||
}
|
||||
|
||||
return PhotoView.customChild(
|
||||
key: Key(displayAsset.heroTag),
|
||||
childSize: childSize,
|
||||
filterQuality: FilterQuality.low,
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onDragCancel: _onDragCancel,
|
||||
onTapUp: _onTapUp,
|
||||
heroAttributes: heroAttributes,
|
||||
filterQuality: FilterQuality.high,
|
||||
basePosition: Alignment.center,
|
||||
disableScaleGestures: true,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
tightMode: true,
|
||||
disableScaleGestures: showingDetails,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
enablePanAlways: true,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
child: NativeVideoViewer(
|
||||
key: _NativeVideoViewerKey(displayAsset.heroTag),
|
||||
asset: displayAsset,
|
||||
scaleStateNotifier: _videoScaleStateNotifier,
|
||||
disableScaleGestures: showingDetails,
|
||||
image: Image(
|
||||
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
image: getFullImageProvider(displayAsset, size: childSize),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
|
||||
@@ -18,8 +18,8 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widge
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
|
||||
@@ -9,11 +9,10 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
@@ -26,7 +25,6 @@ import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
@@ -51,21 +49,10 @@ bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
|
||||
class NativeVideoViewer extends HookConsumerWidget {
|
||||
static final log = Logger('NativeVideoViewer');
|
||||
final BaseAsset asset;
|
||||
final bool showControls;
|
||||
final int playbackDelayFactor;
|
||||
final Widget image;
|
||||
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
|
||||
final bool disableScaleGestures;
|
||||
|
||||
const NativeVideoViewer({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.image,
|
||||
this.showControls = true,
|
||||
this.playbackDelayFactor = 1,
|
||||
this.scaleStateNotifier,
|
||||
this.disableScaleGestures = false,
|
||||
});
|
||||
const NativeVideoViewer({super.key, required this.asset, required this.image, this.playbackDelayFactor = 1});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -144,7 +131,6 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||
|
||||
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
||||
final aspectRatio = useState<double?>(null);
|
||||
|
||||
useMemoized(() async {
|
||||
if (!context.mounted || aspectRatio.value != null) {
|
||||
return null;
|
||||
@@ -320,20 +306,6 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||
}
|
||||
|
||||
Size? videoContextSize(double? videoAspectRatio, BuildContext? context) {
|
||||
Size? videoContextSize;
|
||||
if (videoAspectRatio == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
final contextAspectRatio = context.width / context.height;
|
||||
if (videoAspectRatio > contextAspectRatio) {
|
||||
videoContextSize = Size(context.width, context.width / aspectRatio.value!);
|
||||
} else {
|
||||
videoContextSize = Size(context.height * aspectRatio.value!, context.height);
|
||||
}
|
||||
return videoContextSize;
|
||||
}
|
||||
|
||||
ref.listen(currentAssetNotifier, (_, value) {
|
||||
final playerController = controller.value;
|
||||
if (playerController != null && value != asset) {
|
||||
@@ -414,29 +386,18 @@ class NativeVideoViewer extends HookConsumerWidget {
|
||||
}
|
||||
});
|
||||
|
||||
return SizedBox(
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
|
||||
if (!isVisible.value || controller.value == null) Center(child: image),
|
||||
if (aspectRatio.value != null && !isCasting && isCurrent)
|
||||
Visibility.maintain(
|
||||
visible: isVisible.value,
|
||||
child: PhotoView.customChild(
|
||||
enableRotation: false,
|
||||
disableScaleGestures: disableScaleGestures,
|
||||
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
|
||||
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
|
||||
childSize: videoContextSize(aspectRatio.value, context),
|
||||
child: NativeVideoPlayerView(onViewReady: initController),
|
||||
),
|
||||
),
|
||||
if (showControls) const Center(child: VideoViewerControls()),
|
||||
],
|
||||
),
|
||||
return Stack(
|
||||
children: [
|
||||
// This remains under the video to avoid flickering
|
||||
// For motion videos, this is the image portion of the asset
|
||||
Center(child: image),
|
||||
if (aspectRatio.value != null && !isCasting)
|
||||
Visibility.maintain(
|
||||
visible: isVisible.value,
|
||||
child: NativeVideoPlayerView(onViewReady: initController),
|
||||
),
|
||||
const Center(child: VideoViewerControls()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ class DriftMemoryCard extends StatelessWidget {
|
||||
child: NativeVideoViewer(
|
||||
key: ValueKey(asset.id),
|
||||
asset: asset,
|
||||
showControls: false,
|
||||
playbackDelayFactor: 2,
|
||||
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
|
||||
),
|
||||
|
||||
@@ -420,7 +420,11 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
|
||||
Widget _buildChild() {
|
||||
return widget.hasCustomChild
|
||||
? widget.customChild!
|
||||
? SizedBox(
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
height: scaleBoundaries.childSize.height * scale,
|
||||
child: widget.customChild!,
|
||||
)
|
||||
: Image(
|
||||
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
|
||||
image: widget.imageProvider!,
|
||||
@@ -428,7 +432,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||
filterQuality: widget.filterQuality,
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
fit: BoxFit.cover,
|
||||
fit: BoxFit.contain,
|
||||
isAntiAlias: widget.filterQuality == FilterQuality.high,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.10.14",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"version": "2.5.6",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937",
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
|
||||
Generated
+888
-905
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -136,7 +136,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.10.14",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -566,6 +566,8 @@ describe(AssetService.name, () => {
|
||||
.file({ type: AssetFileType.Thumbnail })
|
||||
.file({ type: AssetFileType.Preview })
|
||||
.file({ type: AssetFileType.FullSize })
|
||||
.file({ type: AssetFileType.Preview, isEdited: true })
|
||||
.file({ type: AssetFileType.Thumbnail, isEdited: true })
|
||||
.build();
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export const getAssetFiles = (files: AssetFile[]) => ({
|
||||
|
||||
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
||||
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||
editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||
editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }),
|
||||
});
|
||||
|
||||
export const addAssets = async (
|
||||
|
||||
@@ -404,6 +404,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
||||
.$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) =>
|
||||
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))),
|
||||
)
|
||||
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
||||
.$if(!!options.withExif, withExifInner)
|
||||
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
|
||||
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
|
||||
|
||||
@@ -88,4 +88,24 @@ describe(SearchService.name, () => {
|
||||
expect(result).toEqual({ total: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('withStacked option', () => {
|
||||
it('should exclude stacked assets when withStacked is false', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
const { asset: primaryAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||
const { asset: stackedAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||
const { asset: unstackedAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
await ctx.newStack({ ownerId: user.id }, [primaryAsset.id, stackedAsset.id]);
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
const response = await sut.searchMetadata(auth, { withStacked: false });
|
||||
|
||||
expect(response.assets.items.length).toBe(1);
|
||||
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
type Config = IntersectionObserverActionProperties & {
|
||||
observer?: IntersectionObserver;
|
||||
};
|
||||
type TrackedProperties = {
|
||||
root?: Element | Document | null;
|
||||
threshold?: number | number[];
|
||||
top?: string;
|
||||
right?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
};
|
||||
type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown;
|
||||
type OnSeparateCallback = (element: HTMLElement) => unknown;
|
||||
type IntersectionObserverActionProperties = {
|
||||
key?: string;
|
||||
disabled?: boolean;
|
||||
/** Function to execute when the element leaves the viewport */
|
||||
onSeparate?: OnSeparateCallback;
|
||||
/** Function to execute when the element enters the viewport */
|
||||
onIntersect?: OnIntersectCallback;
|
||||
|
||||
root?: Element | Document | null;
|
||||
threshold?: number | number[];
|
||||
top?: string;
|
||||
right?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
};
|
||||
type TaskKey = HTMLElement | string;
|
||||
|
||||
function isEquivalent(a: TrackedProperties, b: TrackedProperties) {
|
||||
return (
|
||||
a?.bottom === b?.bottom &&
|
||||
a?.top === b?.top &&
|
||||
a?.left === b?.left &&
|
||||
a?.right == b?.right &&
|
||||
a?.threshold === b?.threshold &&
|
||||
a?.root === b?.root
|
||||
);
|
||||
}
|
||||
|
||||
const elementToConfig = new Map<TaskKey, Config>();
|
||||
|
||||
const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => {
|
||||
if (!target.isConnected) {
|
||||
elementToConfig.get(key)?.observer?.unobserve(target);
|
||||
return;
|
||||
}
|
||||
const {
|
||||
root,
|
||||
threshold,
|
||||
top = '0px',
|
||||
right = '0px',
|
||||
bottom = '0px',
|
||||
left = '0px',
|
||||
onSeparate,
|
||||
onIntersect,
|
||||
} = properties;
|
||||
const rootMargin = `${top} ${right} ${bottom} ${left}`;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
// This IntersectionObserver is limited to observing a single element, the one the
|
||||
// action is attached to. If there are multiple entries, it means that this
|
||||
// observer is being notified of multiple events that have occurred quickly together,
|
||||
// and the latest element is the one we are interested in.
|
||||
|
||||
entries.sort((a, b) => a.time - b.time);
|
||||
|
||||
const latestEntry = entries.pop();
|
||||
if (latestEntry?.isIntersecting) {
|
||||
onIntersect?.(latestEntry);
|
||||
} else {
|
||||
onSeparate?.(target);
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
threshold,
|
||||
root,
|
||||
},
|
||||
);
|
||||
observer.observe(target);
|
||||
elementToConfig.set(key, { ...properties, observer });
|
||||
};
|
||||
|
||||
function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
|
||||
if (properties.disabled) {
|
||||
const config = elementToConfig.get(key);
|
||||
const { observer } = config || {};
|
||||
observer?.unobserve(element);
|
||||
elementToConfig.delete(key);
|
||||
} else {
|
||||
elementToConfig.set(key, properties);
|
||||
observe(key, element, properties);
|
||||
}
|
||||
}
|
||||
|
||||
function _intersectionObserver(
|
||||
key: HTMLElement | string,
|
||||
element: HTMLElement,
|
||||
properties: IntersectionObserverActionProperties,
|
||||
) {
|
||||
configure(key, element, properties);
|
||||
return {
|
||||
update(properties: IntersectionObserverActionProperties) {
|
||||
const config = elementToConfig.get(key);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
if (isEquivalent(config, properties)) {
|
||||
return;
|
||||
}
|
||||
|
||||
configure(key, element, properties);
|
||||
},
|
||||
destroy: () => {
|
||||
const config = elementToConfig.get(key);
|
||||
const { observer } = config || {};
|
||||
observer?.unobserve(element);
|
||||
elementToConfig.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors an element's visibility in the viewport and calls functions when it enters or leaves (based on a threshold).
|
||||
* @param element
|
||||
* @param properties One or multiple configurations for the IntersectionObserver(s)
|
||||
* @returns
|
||||
*/
|
||||
export function intersectionObserver(
|
||||
element: HTMLElement,
|
||||
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
|
||||
) {
|
||||
// svelte doesn't allow multiple use:action directives of the same kind on the same element,
|
||||
// so accept an array when multiple configurations are needed.
|
||||
if (Array.isArray(properties)) {
|
||||
if (!properties.every((p) => p.key)) {
|
||||
throw new Error('Multiple configurations must specify key');
|
||||
}
|
||||
const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p));
|
||||
return {
|
||||
update: (properties: IntersectionObserverActionProperties[]) => {
|
||||
for (const [i, props] of properties.entries()) {
|
||||
observers[i].update(props);
|
||||
}
|
||||
},
|
||||
destroy: () => {
|
||||
for (const observer of observers) {
|
||||
observer.destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
return _intersectionObserver(properties.key || element, element, properties);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
export type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
|
||||
|
||||
let observer: ResizeObserver;
|
||||
let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
|
||||
|
||||
/**
|
||||
* Installs a resizeObserver on the given element - when the element changes
|
||||
* size, invokes a callback function with the width/height. Intended as a
|
||||
* replacement for bind:clientWidth and bind:clientHeight in svelte4 which use
|
||||
* an iframe to measure the size of the element, which can be bad for
|
||||
* performance and memory usage. In svelte5, they adapted bind:clientHeight and
|
||||
* bind:clientWidth to use an internal resize observer.
|
||||
*
|
||||
* TODO: When svelte5 is ready, go back to bind:clientWidth and
|
||||
* bind:clientHeight.
|
||||
*/
|
||||
export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) {
|
||||
if (!observer) {
|
||||
callbacks = new WeakMap();
|
||||
observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const onResize = callbacks.get(entry.target as HTMLElement);
|
||||
if (onResize) {
|
||||
onResize({
|
||||
target: entry.target as HTMLElement,
|
||||
width: entry.borderBoxSize[0].inlineSize,
|
||||
height: entry.borderBoxSize[0].blockSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
callbacks.set(element, onResize);
|
||||
observer.observe(element);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
callbacks.delete(element);
|
||||
observer.unobserve(element);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
onDestroy(() => {
|
||||
destroyed = true;
|
||||
if (capturedSource !== undefined) {
|
||||
imageManager.cancelPreloadUrl(capturedSource);
|
||||
cancelImageUrl(capturedSource);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { getQueueName } from '$lib/utils';
|
||||
import { QueueName, type SystemConfigJobDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -30,6 +29,27 @@
|
||||
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto {
|
||||
return jobName in configToEdit.job;
|
||||
}
|
||||
|
||||
const queueTitles: Record<QueueName, string> = $derived({
|
||||
[QueueName.ThumbnailGeneration]: $t('admin.thumbnail_generation_job'),
|
||||
[QueueName.MetadataExtraction]: $t('admin.metadata_extraction_job'),
|
||||
[QueueName.Sidecar]: $t('admin.sidecar_job'),
|
||||
[QueueName.SmartSearch]: $t('admin.machine_learning_smart_search'),
|
||||
[QueueName.DuplicateDetection]: $t('admin.machine_learning_duplicate_detection'),
|
||||
[QueueName.FaceDetection]: $t('admin.face_detection'),
|
||||
[QueueName.FacialRecognition]: $t('admin.machine_learning_facial_recognition'),
|
||||
[QueueName.VideoConversion]: $t('admin.video_conversion_job'),
|
||||
[QueueName.StorageTemplateMigration]: $t('admin.storage_template_migration'),
|
||||
[QueueName.Migration]: $t('admin.migration_job'),
|
||||
[QueueName.BackgroundTask]: $t('admin.background_task_job'),
|
||||
[QueueName.Search]: $t('search'),
|
||||
[QueueName.Library]: $t('external_libraries'),
|
||||
[QueueName.Notifications]: $t('notifications'),
|
||||
[QueueName.BackupDatabase]: $t('admin.backup_database'),
|
||||
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
|
||||
[QueueName.Workflow]: $t('workflows'),
|
||||
[QueueName.Editor]: $t('editor'),
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -41,7 +61,7 @@
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
{disabled}
|
||||
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })}
|
||||
label={$t('admin.job_concurrency', { values: { job: queueTitles[queueName] } })}
|
||||
description=""
|
||||
bind:value={configToEdit.job[queueName].concurrency}
|
||||
required={true}
|
||||
@@ -50,7 +70,7 @@
|
||||
{:else}
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })}
|
||||
label={$t('admin.job_concurrency', { values: { job: queueTitles[queueName] } })}
|
||||
description=""
|
||||
value={1}
|
||||
disabled={true}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getGlobalActions } from '$lib/services/app.service';
|
||||
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
|
||||
@@ -36,6 +37,7 @@
|
||||
import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiArrowRight,
|
||||
mdiCompare,
|
||||
mdiDotsVertical,
|
||||
mdiImageSearch,
|
||||
@@ -84,7 +86,7 @@
|
||||
const Close: ActionItem = $derived({
|
||||
title: $t('go_back'),
|
||||
type: $t('assets'),
|
||||
icon: mdiArrowLeft,
|
||||
icon: languageManager.rtl ? mdiArrowRight : mdiArrowLeft,
|
||||
$if: () => !!onClose,
|
||||
onAction: () => onClose?.(),
|
||||
shortcuts: [{ key: 'Escape' }],
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
getStack,
|
||||
type AlbumResponseDto,
|
||||
@@ -105,7 +104,6 @@
|
||||
const asset = $derived(cursor.current);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
||||
let sharedLink = getSharedLink();
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let fullscreenElement = $state<Element>();
|
||||
@@ -147,7 +145,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
syncAssetViewerOpenClass(true);
|
||||
unsubscribes.push(
|
||||
slideshowState.subscribe((value) => {
|
||||
@@ -166,8 +164,6 @@
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await onAlbumAddAssets();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -180,18 +176,6 @@
|
||||
syncAssetViewerOpenClass(false);
|
||||
});
|
||||
|
||||
const onAlbumAddAssets = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
|
||||
} catch (error) {
|
||||
console.error('Error getting album that asset belong to', error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
onClose?.(asset);
|
||||
};
|
||||
@@ -363,7 +347,6 @@
|
||||
|
||||
const refresh = async () => {
|
||||
await refreshStack();
|
||||
await onAlbumAddAssets();
|
||||
ocrManager.clear();
|
||||
if (!sharedLink) {
|
||||
if (previewStackedAsset) {
|
||||
@@ -441,7 +424,7 @@
|
||||
</script>
|
||||
|
||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
||||
<OnEvents {onAssetReplace} {onAssetUpdate} {onAlbumAddAssets} />
|
||||
<OnEvents {onAssetReplace} {onAssetUpdate} />
|
||||
|
||||
<svelte:document bind:fullscreenElement />
|
||||
|
||||
@@ -586,7 +569,7 @@
|
||||
>
|
||||
{#if showDetailPanel}
|
||||
<div class="w-90 h-full">
|
||||
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
</div>
|
||||
{:else if assetViewerManager.isShowEditor}
|
||||
<div class="w-100 h-full">
|
||||
|
||||
@@ -17,9 +17,16 @@
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getParentPath } from '$lib/utils/tree-utils';
|
||||
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
AssetMediaSize,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendar,
|
||||
@@ -38,16 +45,16 @@
|
||||
import { slide } from 'svelte/transition';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import PersonSidePanel from '../faces-page/person-side-panel.svelte';
|
||||
import OnEvents from '../OnEvents.svelte';
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
albums?: AlbumResponseDto[];
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
}
|
||||
|
||||
let { asset, albums = [], currentAlbum = null }: Props = $props();
|
||||
let { asset, currentAlbum = null }: Props = $props();
|
||||
|
||||
let showAssetPath = $state(false);
|
||||
let showEditFaces = $state(false);
|
||||
@@ -74,14 +81,33 @@
|
||||
let previousId: string | undefined = $state();
|
||||
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
|
||||
|
||||
const refreshAlbums = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await getAllAlbums({ assetId: asset.id });
|
||||
} catch (error) {
|
||||
handleError(error, 'Error getting asset album membership');
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
let albums = $derived(refreshAlbums());
|
||||
|
||||
$effect(() => {
|
||||
if (!previousId) {
|
||||
previousId = asset.id;
|
||||
return;
|
||||
}
|
||||
if (asset.id !== previousId) {
|
||||
showEditFaces = false;
|
||||
previousId = asset.id;
|
||||
|
||||
if (asset.id === previousId) {
|
||||
return;
|
||||
}
|
||||
|
||||
showEditFaces = false;
|
||||
previousId = asset.id;
|
||||
});
|
||||
|
||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||
@@ -119,6 +145,8 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents onAlbumAddAssets={() => (albums = refreshAlbums())} />
|
||||
|
||||
<section class="relative p-2">
|
||||
<div class="flex place-items-center gap-2">
|
||||
<IconButton
|
||||
@@ -502,37 +530,39 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if albums.length > 0}
|
||||
<section class="px-6 py-6 dark:text-immich-dark-fg">
|
||||
<div class="pb-4">
|
||||
<Text size="small" color="muted">{$t('appears_in')}</Text>
|
||||
</div>
|
||||
{#each albums as album (album.id)}
|
||||
<a href={Route.viewAlbum(album)}>
|
||||
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
|
||||
<div>
|
||||
<img
|
||||
alt={album.albumName}
|
||||
class="h-12.5 w-12.5 rounded object-cover"
|
||||
src={album.albumThumbnailAssetId &&
|
||||
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{#await albums then albums}
|
||||
{#if albums.length > 0}
|
||||
<section class="px-6 py-6 dark:text-immich-dark-fg">
|
||||
<div class="pb-4">
|
||||
<Text size="small" color="muted">{$t('appears_in')}</Text>
|
||||
</div>
|
||||
{#each albums as album (album.id)}
|
||||
<a href={Route.viewAlbum(album)}>
|
||||
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
|
||||
<div>
|
||||
<img
|
||||
alt={album.albumName}
|
||||
class="h-12.5 w-12.5 rounded object-cover"
|
||||
src={album.albumThumbnailAssetId &&
|
||||
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-auto mt-auto">
|
||||
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
|
||||
<div class="flex flex-col gap-0 text-sm">
|
||||
<div>
|
||||
<AlbumListItemDetails {album} />
|
||||
<div class="mb-auto mt-auto">
|
||||
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
|
||||
<div class="flex flex-col gap-0 text-sm">
|
||||
<div>
|
||||
<AlbumListItemDetails {album} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
{#if $preferences?.tags?.enabled}
|
||||
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
></div>
|
||||
{/if}
|
||||
</Button>
|
||||
<span class="text-sm text-white text-left">{ratio.label}</span>
|
||||
<span class="text-sm text-white">{ratio.label}</span>
|
||||
</HStack>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -23,10 +25,12 @@
|
||||
let canvas: Canvas | undefined = $state();
|
||||
let faceRect: Rect | undefined = $state();
|
||||
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
||||
let scrollableListEl: HTMLDivElement | undefined = $state();
|
||||
let page = $state(1);
|
||||
let candidates = $state<PersonResponseDto[]>([]);
|
||||
|
||||
let searchTerm = $state('');
|
||||
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
|
||||
|
||||
let filteredCandidates = $derived(
|
||||
searchTerm
|
||||
@@ -78,17 +82,13 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
|
||||
const offsetArea = {
|
||||
width: (containerWidth - actualWidth) / 2,
|
||||
height: (containerHeight - actualHeight) / 2,
|
||||
};
|
||||
const metrics = getContentMetrics(htmlElement);
|
||||
|
||||
const imageBoundingBox = {
|
||||
top: offsetArea.height,
|
||||
left: offsetArea.width,
|
||||
width: containerWidth - offsetArea.width * 2,
|
||||
height: containerHeight - offsetArea.height * 2,
|
||||
top: metrics.offsetY,
|
||||
left: metrics.offsetX,
|
||||
width: metrics.contentWidth,
|
||||
height: metrics.contentHeight,
|
||||
};
|
||||
|
||||
if (!canvas) {
|
||||
@@ -113,32 +113,6 @@
|
||||
positionFaceSelector();
|
||||
});
|
||||
|
||||
const getContainedSize = (
|
||||
img: HTMLImageElement | HTMLVideoElement,
|
||||
): { actualWidth: number; actualHeight: number } => {
|
||||
if (img instanceof HTMLImageElement) {
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
let actualWidth = img.height * ratio;
|
||||
let actualHeight = img.height;
|
||||
if (actualWidth > img.width) {
|
||||
actualWidth = img.width;
|
||||
actualHeight = img.width / ratio;
|
||||
}
|
||||
return { actualWidth, actualHeight };
|
||||
} else if (img instanceof HTMLVideoElement) {
|
||||
const ratio = img.videoWidth / img.videoHeight;
|
||||
let actualWidth = img.clientHeight * ratio;
|
||||
let actualHeight = img.clientHeight;
|
||||
if (actualWidth > img.clientWidth) {
|
||||
actualWidth = img.clientWidth;
|
||||
actualHeight = img.clientWidth / ratio;
|
||||
}
|
||||
return { actualWidth, actualHeight };
|
||||
}
|
||||
|
||||
return { actualWidth: 0, actualHeight: 0 };
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
isFaceEditMode.value = false;
|
||||
};
|
||||
@@ -157,69 +131,80 @@
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_LIST_HEIGHT = 250;
|
||||
|
||||
const positionFaceSelector = () => {
|
||||
if (!faceRect || !faceSelectorEl) {
|
||||
if (!faceRect || !faceSelectorEl || !scrollableListEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = faceRect.getBoundingRect();
|
||||
const gap = 15;
|
||||
const padding = faceRect.padding ?? 0;
|
||||
const rawBox = faceRect.getBoundingRect();
|
||||
const faceBox = {
|
||||
left: rawBox.left - padding,
|
||||
top: rawBox.top - padding,
|
||||
width: rawBox.width + padding * 2,
|
||||
height: rawBox.height + padding * 2,
|
||||
};
|
||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||
const selectorHeight = faceSelectorEl.offsetHeight;
|
||||
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
||||
const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
|
||||
const selectorHeight = listHeight + chromeHeight;
|
||||
|
||||
const spaceAbove = rect.top;
|
||||
const spaceBelow = containerHeight - (rect.top + rect.height);
|
||||
const spaceLeft = rect.left;
|
||||
const spaceRight = containerWidth - (rect.left + rect.width);
|
||||
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
|
||||
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
|
||||
|
||||
let top, left;
|
||||
const overlapArea = (position: { top: number; left: number }) => {
|
||||
const selectorRight = position.left + selectorWidth;
|
||||
const selectorBottom = position.top + selectorHeight;
|
||||
const faceRight = faceBox.left + faceBox.width;
|
||||
const faceBottom = faceBox.top + faceBox.height;
|
||||
|
||||
if (
|
||||
spaceBelow >= selectorHeight ||
|
||||
(spaceBelow >= spaceAbove && spaceBelow >= spaceLeft && spaceBelow >= spaceRight)
|
||||
) {
|
||||
top = rect.top + rect.height + 15;
|
||||
left = rect.left;
|
||||
} else if (
|
||||
spaceAbove >= selectorHeight ||
|
||||
(spaceAbove >= spaceBelow && spaceAbove >= spaceLeft && spaceAbove >= spaceRight)
|
||||
) {
|
||||
top = rect.top - selectorHeight - 15;
|
||||
left = rect.left;
|
||||
} else if (
|
||||
spaceRight >= selectorWidth ||
|
||||
(spaceRight >= spaceLeft && spaceRight >= spaceAbove && spaceRight >= spaceBelow)
|
||||
) {
|
||||
top = rect.top;
|
||||
left = rect.left + rect.width + 15;
|
||||
} else {
|
||||
top = rect.top;
|
||||
left = rect.left - selectorWidth - 15;
|
||||
const overlapX = Math.max(0, Math.min(selectorRight, faceRight) - Math.max(position.left, faceBox.left));
|
||||
const overlapY = Math.max(0, Math.min(selectorBottom, faceBottom) - Math.max(position.top, faceBox.top));
|
||||
return overlapX * overlapY;
|
||||
};
|
||||
|
||||
const faceBottom = faceBox.top + faceBox.height;
|
||||
const faceRight = faceBox.left + faceBox.width;
|
||||
|
||||
const positions = [
|
||||
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
|
||||
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
|
||||
{ top: clampTop(faceBox.top), left: clampLeft(faceRight + gap) },
|
||||
{ top: clampTop(faceBox.top), left: clampLeft(faceBox.left - selectorWidth - gap) },
|
||||
];
|
||||
|
||||
let bestPosition = positions[0];
|
||||
let leastOverlap = Infinity;
|
||||
|
||||
for (const position of positions) {
|
||||
const overlap = overlapArea(position);
|
||||
if (overlap < leastOverlap) {
|
||||
leastOverlap = overlap;
|
||||
bestPosition = position;
|
||||
if (overlap === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (left + selectorWidth > containerWidth) {
|
||||
left = containerWidth - selectorWidth - 15;
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = 15;
|
||||
}
|
||||
|
||||
if (top + selectorHeight > containerHeight) {
|
||||
top = containerHeight - selectorHeight - 15;
|
||||
}
|
||||
|
||||
if (top < 0) {
|
||||
top = 15;
|
||||
}
|
||||
|
||||
faceSelectorEl.style.top = `${top}px`;
|
||||
faceSelectorEl.style.left = `${left}px`;
|
||||
faceSelectorEl.style.top = `${bestPosition.top}px`;
|
||||
faceSelectorEl.style.left = `${bestPosition.left}px`;
|
||||
scrollableListEl.style.height = `${listHeight}px`;
|
||||
faceBoxPosition = { left: faceBox.left, top: faceBox.top, width: faceBox.width, height: faceBox.height };
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (faceRect) {
|
||||
faceRect.on('moving', positionFaceSelector);
|
||||
faceRect.on('scaling', positionFaceSelector);
|
||||
const rect = faceRect;
|
||||
if (rect) {
|
||||
rect.on('moving', positionFaceSelector);
|
||||
rect.on('scaling', positionFaceSelector);
|
||||
return () => {
|
||||
rect.off('moving', positionFaceSelector);
|
||||
rect.off('scaling', positionFaceSelector);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -229,48 +214,22 @@
|
||||
}
|
||||
|
||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
|
||||
const metrics = getContentMetrics(htmlElement);
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
|
||||
const offsetArea = {
|
||||
width: (containerWidth - actualWidth) / 2,
|
||||
height: (containerHeight - actualHeight) / 2,
|
||||
const scaleX = natural.width / metrics.contentWidth;
|
||||
const scaleY = natural.height / metrics.contentHeight;
|
||||
const imageX = (left - metrics.offsetX) * scaleX;
|
||||
const imageY = (top - metrics.offsetY) * scaleY;
|
||||
|
||||
return {
|
||||
imageWidth: natural.width,
|
||||
imageHeight: natural.height,
|
||||
x: Math.floor(imageX),
|
||||
y: Math.floor(imageY),
|
||||
width: Math.floor(width * scaleX),
|
||||
height: Math.floor(height * scaleY),
|
||||
};
|
||||
|
||||
const x1Coeff = (left - offsetArea.width) / actualWidth;
|
||||
const y1Coeff = (top - offsetArea.height) / actualHeight;
|
||||
const x2Coeff = (left + width - offsetArea.width) / actualWidth;
|
||||
const y2Coeff = (top + height - offsetArea.height) / actualHeight;
|
||||
|
||||
// transpose to the natural image location
|
||||
if (htmlElement instanceof HTMLImageElement) {
|
||||
const x1 = x1Coeff * htmlElement.naturalWidth;
|
||||
const y1 = y1Coeff * htmlElement.naturalHeight;
|
||||
const x2 = x2Coeff * htmlElement.naturalWidth;
|
||||
const y2 = y2Coeff * htmlElement.naturalHeight;
|
||||
|
||||
return {
|
||||
imageWidth: htmlElement.naturalWidth,
|
||||
imageHeight: htmlElement.naturalHeight,
|
||||
x: Math.floor(x1),
|
||||
y: Math.floor(y1),
|
||||
width: Math.floor(x2 - x1),
|
||||
height: Math.floor(y2 - y1),
|
||||
};
|
||||
} else if (htmlElement instanceof HTMLVideoElement) {
|
||||
const x1 = x1Coeff * htmlElement.videoWidth;
|
||||
const y1 = y1Coeff * htmlElement.videoHeight;
|
||||
const x2 = x2Coeff * htmlElement.videoWidth;
|
||||
const y2 = y2Coeff * htmlElement.videoHeight;
|
||||
|
||||
return {
|
||||
imageWidth: htmlElement.videoWidth,
|
||||
imageHeight: htmlElement.videoHeight,
|
||||
x: Math.floor(x1),
|
||||
y: Math.floor(y1),
|
||||
width: Math.floor(x2 - x1),
|
||||
height: Math.floor(y2 - y1),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const tagFace = async (person: PersonResponseDto) => {
|
||||
@@ -308,13 +267,20 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="absolute start-0 top-0">
|
||||
<div
|
||||
id="face-editor-data"
|
||||
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
|
||||
data-face-left={faceBoxPosition.left}
|
||||
data-face-top={faceBoxPosition.top}
|
||||
data-face-width={faceBoxPosition.width}
|
||||
data-face-height={faceBoxPosition.height}
|
||||
>
|
||||
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
|
||||
|
||||
<div
|
||||
id="face-selector"
|
||||
bind:this={faceSelectorEl}
|
||||
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800"
|
||||
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
|
||||
>
|
||||
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
||||
|
||||
@@ -322,7 +288,7 @@
|
||||
<Input placeholder={$t('search_people')} bind:value={searchTerm} size="tiny" />
|
||||
</div>
|
||||
|
||||
<div class="h-62.5 overflow-y-auto mt-2">
|
||||
<div bind:this={scrollableListEl} class="h-62.5 overflow-y-auto mt-2">
|
||||
{#if filteredCandidates.length > 0}
|
||||
<div class="mt-2 rounded-lg">
|
||||
{#each filteredCandidates as person (person.id)}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
import { calculateBoundingBoxMatrix, getOcrBoundingBoxesAtSize, type Point } from '$lib/utils/ocr-utils';
|
||||
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
|
||||
import {
|
||||
EquirectangularAdapter,
|
||||
Viewer,
|
||||
@@ -127,9 +127,11 @@
|
||||
markersPlugin.clearMarkers();
|
||||
}
|
||||
|
||||
const boxes = getOcrBoundingBoxesAtSize(ocrData, {
|
||||
width: viewer.state.textureData.panoData.croppedWidth,
|
||||
height: viewer.state.textureData.panoData.croppedHeight,
|
||||
const boxes = getOcrBoundingBoxes(ocrData, {
|
||||
contentWidth: viewer.state.textureData.panoData.croppedWidth,
|
||||
contentHeight: viewer.state.textureData.panoData.croppedHeight,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
|
||||
for (const [index, box] of boxes.entries()) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { type ContentMetrics, getContentMetrics } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
@@ -52,6 +53,7 @@
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let originalImageLoaded: boolean = $state(false);
|
||||
let imageError: boolean = $state(false);
|
||||
let visibleImageReady: boolean = $state(false);
|
||||
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
@@ -67,11 +69,23 @@
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
|
||||
let ocrBoxes = $derived(
|
||||
ocrManager.showOverlay && assetViewerManager.imgRef
|
||||
? getOcrBoundingBoxes(ocrManager.data, assetViewerManager.zoomState, assetViewerManager.imgRef)
|
||||
: [],
|
||||
);
|
||||
const overlayMetrics = $derived.by((): ContentMetrics => {
|
||||
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
|
||||
const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef);
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
|
||||
return {
|
||||
contentWidth: contentWidth * currentZoom,
|
||||
contentHeight: contentHeight * currentZoom,
|
||||
offsetX: offsetX * currentZoom + currentPositionX,
|
||||
offsetY: offsetY * currentZoom + currentPositionY,
|
||||
};
|
||||
});
|
||||
|
||||
let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
||||
|
||||
let isOcrActive = $derived(ocrManager.showOverlay);
|
||||
|
||||
@@ -159,7 +173,7 @@
|
||||
imageError = imageLoaded = true;
|
||||
};
|
||||
|
||||
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
|
||||
onDestroy(() => imageManager.cancel(asset, targetImageSize));
|
||||
|
||||
let imageLoaderUrl = $derived(
|
||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
|
||||
@@ -176,6 +190,7 @@
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
visibleImageReady = false;
|
||||
});
|
||||
}
|
||||
lastUrl = imageLoaderUrl;
|
||||
@@ -226,14 +241,14 @@
|
||||
<img
|
||||
bind:this={assetViewerManager.imgRef}
|
||||
src={imageLoaderUrl}
|
||||
onload={() => (visibleImageReady = true)}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
/>
|
||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
|
||||
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox (boundingbox.id)}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import ProgressBar from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
|
||||
import { ProgressBarStatus } from '$lib/constants';
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
import SlideshowSettingsModal from '$lib/modals/SlideshowSettingsModal.svelte';
|
||||
import { SlideshowNavigation, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { AssetTypeEnum } from '@immich/sdk';
|
||||
@@ -199,7 +200,7 @@
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiChevronLeft}
|
||||
icon={languageManager.rtl ? mdiChevronRight : mdiChevronLeft}
|
||||
onclick={onPrevious}
|
||||
aria-label={$t('previous')}
|
||||
/>
|
||||
@@ -207,7 +208,7 @@
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiChevronRight}
|
||||
icon={languageManager.rtl ? mdiChevronLeft : mdiChevronRight}
|
||||
onclick={onNext}
|
||||
aria-label={$t('next')}
|
||||
/>
|
||||
|
||||
@@ -15,15 +15,18 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-broken-asset
|
||||
class={[
|
||||
'flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4',
|
||||
'@container flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4',
|
||||
className,
|
||||
]}
|
||||
style:width
|
||||
style:height
|
||||
>
|
||||
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
|
||||
<div class="hidden @min-[75px]:block">
|
||||
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full min-w-6 min-h-6" />
|
||||
</div>
|
||||
{#if !hideMessage}
|
||||
<span class="text-center">{$t('error_loading_image')}</span>
|
||||
<span class="text-center text-xs @min-[100px]:text-sm @min-[150px]:text-base">{$t('error_loading_image')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
||||
import { resizeObserver } from '$lib/actions/resize-observer';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import MemoryPhotoViewer from '$lib/components/memory-page/memory-photo-viewer.svelte';
|
||||
import MemoryVideoViewer from '$lib/components/memory-page/memory-video-viewer.svelte';
|
||||
@@ -55,6 +53,7 @@
|
||||
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
import { Tween } from 'svelte/motion';
|
||||
|
||||
let memoryGallery: HTMLElement | undefined = $state();
|
||||
@@ -236,6 +235,22 @@
|
||||
galleryFirstLoad = false;
|
||||
};
|
||||
|
||||
const galleryObserver: Attachment<HTMLElement> = (element) => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
handleGalleryScrollsIntoView();
|
||||
} else {
|
||||
handleGalleryScrollsOutOfView();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -200px 0px' },
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
};
|
||||
|
||||
const loadFromParams = (page: Page | NavigationTarget | null) => {
|
||||
const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
|
||||
return memoryStore.getMemoryAsset(assetId);
|
||||
@@ -362,7 +377,8 @@
|
||||
id="memory-viewer"
|
||||
class="w-full bg-immich-dark-gray"
|
||||
bind:this={memoryWrapper}
|
||||
use:resizeObserver={({ height, width }) => ((viewport.height = height), (viewport.width = width))}
|
||||
bind:clientHeight={viewport.height}
|
||||
bind:clientWidth={viewport.width}
|
||||
>
|
||||
{#if current}
|
||||
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
||||
@@ -644,15 +660,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="gallery-memory"
|
||||
use:intersectionObserver={{
|
||||
onIntersect: handleGalleryScrollsIntoView,
|
||||
onSeparate: handleGalleryScrollsOutOfView,
|
||||
bottom: '-200px',
|
||||
}}
|
||||
bind:this={memoryGallery}
|
||||
>
|
||||
<div id="gallery-memory" {@attach galleryObserver} bind:this={memoryGallery}>
|
||||
<GalleryViewer
|
||||
assets={currentTimelineAssets}
|
||||
{viewerAssets}
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
|
||||
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
||||
});
|
||||
const isAllUserOwned = $derived($user && selectedAssets.every((asset) => asset.ownerId === $user.id));
|
||||
|
||||
const handleLink: OnLink = ({ still, motion }) => {
|
||||
timelineManager.removeAssets([motion.id]);
|
||||
@@ -133,7 +132,7 @@
|
||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||
<ActionButton action={Actions.AddToAlbum} />
|
||||
|
||||
{#if isAllUserOwned}
|
||||
{#if assetInteraction.isAllUserOwned}
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
style:top={position.top + 'px'}
|
||||
style:left={position.left + 'px'}
|
||||
style:inset-inline-start={position.left + 'px'}
|
||||
style:width={position.width + 'px'}
|
||||
style:height={position.height + 'px'}
|
||||
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||
{@const absoluteWidth = dayGroup.left}
|
||||
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
@@ -64,7 +63,8 @@
|
||||
]}
|
||||
data-group
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
|
||||
style:inset-inline-start={dayGroup.start + 'px'}
|
||||
style:top={dayGroup.top + 'px'}
|
||||
onmouseenter={() => (hoveredDayGroup = dayGroup.groupTitle)}
|
||||
onmouseleave={() => (hoveredDayGroup = null)}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import Month from '$lib/components/timeline/Month.svelte';
|
||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
||||
@@ -260,8 +259,6 @@
|
||||
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
invisible = false;
|
||||
@@ -620,7 +617,7 @@
|
||||
<section
|
||||
id="asset-grid"
|
||||
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
||||
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||
tabindex="-1"
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={timelineManager.viewportWidth}
|
||||
@@ -634,7 +631,7 @@
|
||||
style:height={timelineManager.totalViewerHeight + 'px'}
|
||||
>
|
||||
<section
|
||||
use:resizeObserver={topSectionResizeObserver}
|
||||
bind:clientHeight={timelineManager.topSectionHeight}
|
||||
class:invisible
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
<SettingsLanguageSelector showSettingDescription />
|
||||
|
||||
<Field label={$t('default_locale')} description={$t('default_locale_description')}>
|
||||
<Field label={$t('use_browser_locale')} description={$t('use_browser_locale_description')}>
|
||||
<Switch checked={$locale == 'default'} onCheckedChange={handleToggleLocaleBrowser} />
|
||||
<Text size="small" class="mt-2 font-mono text-sm">{selectedDate}</Text>
|
||||
</Field>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
|
||||
vi.mock('$lib/utils/sw-messaging', () => ({
|
||||
cancelImageUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/utils', () => ({
|
||||
getAssetMediaUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ImageManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('preload', () => {
|
||||
it('creates an Image with the correct URL', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
|
||||
const asset = assetFactory.build();
|
||||
|
||||
imageManager.preload(asset);
|
||||
|
||||
expect(getAssetMediaUrl).toHaveBeenCalledWith({
|
||||
id: asset.id,
|
||||
size: AssetMediaSize.Preview,
|
||||
cacheKey: asset.thumbhash,
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing for undefined asset', () => {
|
||||
imageManager.preload(undefined);
|
||||
expect(getAssetMediaUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when getAssetMediaUrl returns falsy', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('');
|
||||
const asset = assetFactory.build();
|
||||
|
||||
imageManager.preload(asset);
|
||||
|
||||
expect(getAssetMediaUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the specified size', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
|
||||
const asset = assetFactory.build();
|
||||
|
||||
imageManager.preload(asset, AssetMediaSize.Thumbnail);
|
||||
|
||||
expect(getAssetMediaUrl).toHaveBeenCalledWith({
|
||||
id: asset.id,
|
||||
size: AssetMediaSize.Thumbnail,
|
||||
cacheKey: asset.thumbhash,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('calls cancelImageUrl with the correct URL', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
|
||||
const asset = assetFactory.build();
|
||||
|
||||
imageManager.cancel(asset, AssetMediaSize.Preview);
|
||||
|
||||
expect(cancelImageUrl).toHaveBeenCalledWith('/api/assets/123/media');
|
||||
});
|
||||
|
||||
it('does nothing for undefined asset', () => {
|
||||
imageManager.cancel(undefined);
|
||||
expect(getAssetMediaUrl).not.toHaveBeenCalled();
|
||||
expect(cancelImageUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels all sizes when size is "all"', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockImplementation(({ size }) => `/api/assets/123/${size}`);
|
||||
const asset = assetFactory.build();
|
||||
|
||||
imageManager.cancel(asset, 'all');
|
||||
|
||||
expect(getAssetMediaUrl).toHaveBeenCalledTimes(Object.values(AssetMediaSize).length);
|
||||
for (const size of Object.values(AssetMediaSize)) {
|
||||
expect(cancelImageUrl).toHaveBeenCalledWith(`/api/assets/123/${size}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not call cancelImageUrl when URL is falsy', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('');
|
||||
const asset = assetFactory.build();
|
||||
|
||||
imageManager.cancel(asset, AssetMediaSize.Preview);
|
||||
|
||||
expect(cancelImageUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,12 +32,6 @@ class ImageManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancelPreloadUrl(url: string | undefined) {
|
||||
if (url) {
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageManager = new ImageManager();
|
||||
|
||||
@@ -21,7 +21,7 @@ export class DayGroup {
|
||||
intersecting = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.intersecting));
|
||||
|
||||
#top: number = $state(0);
|
||||
#left: number = $state(0);
|
||||
#start: number = $state(0);
|
||||
#row = $state(0);
|
||||
#col = $state(0);
|
||||
#deferredLayout = false;
|
||||
@@ -41,12 +41,12 @@ export class DayGroup {
|
||||
this.#top = value;
|
||||
}
|
||||
|
||||
get left() {
|
||||
return this.#left;
|
||||
get start() {
|
||||
return this.#start;
|
||||
}
|
||||
|
||||
set left(value: number) {
|
||||
this.#left = value;
|
||||
set start(value: number) {
|
||||
this.#start = value;
|
||||
}
|
||||
|
||||
get row() {
|
||||
|
||||
@@ -39,7 +39,7 @@ export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthG
|
||||
if (fitsInCurrentRow) {
|
||||
dayGroup.row = dayGroupRow;
|
||||
dayGroup.col = dayGroupCol++;
|
||||
dayGroup.left = cumulativeWidth;
|
||||
dayGroup.start = cumulativeWidth;
|
||||
dayGroup.top = cumulativeHeight;
|
||||
|
||||
cumulativeWidth += dayGroup.width + timelineManager.gap;
|
||||
@@ -53,7 +53,7 @@ export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthG
|
||||
// Position at start of new row
|
||||
dayGroup.row = dayGroupRow;
|
||||
dayGroup.col = dayGroupCol;
|
||||
dayGroup.left = 0;
|
||||
dayGroup.start = 0;
|
||||
dayGroup.top = cumulativeHeight;
|
||||
|
||||
dayGroupCol++;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { user as userStore } from '$lib/stores/user.store';
|
||||
import { setSharedLink } from '$lib/utils';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||
|
||||
describe('AssetService', () => {
|
||||
describe('getAssetActions', () => {
|
||||
it('should allow shared link downloads if the user owns the asset and shared link downloads are disabled', () => {
|
||||
const ownerId = 'owner';
|
||||
const user = userAdminFactory.build({ id: ownerId });
|
||||
const asset = assetFactory.build({ ownerId });
|
||||
userStore.set(user);
|
||||
setSharedLink(sharedLinkFactory.build({ allowDownload: false }));
|
||||
const assetActions = getAssetActions(() => '', asset);
|
||||
expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(true);
|
||||
});
|
||||
|
||||
it('should not allow shared link downloads if the user does not own the asset and shared link downloads are disabled', () => {
|
||||
const ownerId = 'owner';
|
||||
const user = userAdminFactory.build({ id: 'non-owner' });
|
||||
const asset = assetFactory.build({ ownerId });
|
||||
userStore.set(user);
|
||||
setSharedLink(sharedLinkFactory.build({ allowDownload: false }));
|
||||
const assetActions = getAssetActions(() => '', asset);
|
||||
expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it('should allow shared link downloads if shared link downloads are enabled regardless of user', () => {
|
||||
const asset = assetFactory.build();
|
||||
setSharedLink(sharedLinkFactory.build({ allowDownload: true }));
|
||||
const assetActions = getAssetActions(() => '', asset);
|
||||
expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -106,7 +106,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
title: $t('share'),
|
||||
icon: mdiShareVariantOutline,
|
||||
type: $t('assets'),
|
||||
$if: () => !!(get(authUser) && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
|
||||
$if: () => !!(currentAuthUser && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
|
||||
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
|
||||
};
|
||||
|
||||
@@ -129,7 +129,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
|
||||
const SharedLinkDownload: ActionItem = {
|
||||
...Download,
|
||||
$if: () => !currentAuthUser && sharedLink && sharedLink.allowDownload,
|
||||
$if: () => isOwner || !!sharedLink?.allowDownload,
|
||||
};
|
||||
|
||||
const PlayMotionPhoto: ActionItem = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface Faces {
|
||||
id: string;
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
boundingBoxX1: number;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
AssetMediaSize,
|
||||
AssetTypeEnum,
|
||||
MemoryType,
|
||||
QueueName,
|
||||
finishOAuth,
|
||||
getAssetOriginalPath,
|
||||
getAssetPlaybackPath,
|
||||
@@ -144,33 +143,6 @@ export const downloadRequest = <TBody = unknown>(options: DownloadRequestOptions
|
||||
});
|
||||
};
|
||||
|
||||
export const getQueueName = derived(t, ($t) => {
|
||||
return (name: QueueName) => {
|
||||
const names: Record<QueueName, string> = {
|
||||
[QueueName.ThumbnailGeneration]: $t('admin.thumbnail_generation_job'),
|
||||
[QueueName.MetadataExtraction]: $t('admin.metadata_extraction_job'),
|
||||
[QueueName.Sidecar]: $t('admin.sidecar_job'),
|
||||
[QueueName.SmartSearch]: $t('admin.machine_learning_smart_search'),
|
||||
[QueueName.DuplicateDetection]: $t('admin.machine_learning_duplicate_detection'),
|
||||
[QueueName.FaceDetection]: $t('admin.face_detection'),
|
||||
[QueueName.FacialRecognition]: $t('admin.machine_learning_facial_recognition'),
|
||||
[QueueName.VideoConversion]: $t('admin.video_conversion_job'),
|
||||
[QueueName.StorageTemplateMigration]: $t('admin.storage_template_migration'),
|
||||
[QueueName.Migration]: $t('admin.migration_job'),
|
||||
[QueueName.BackgroundTask]: $t('admin.background_task_job'),
|
||||
[QueueName.Search]: $t('search'),
|
||||
[QueueName.Library]: $t('external_libraries'),
|
||||
[QueueName.Notifications]: $t('notifications'),
|
||||
[QueueName.BackupDatabase]: $t('admin.backup_database'),
|
||||
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
|
||||
[QueueName.Workflow]: $t('workflows'),
|
||||
[QueueName.Editor]: $t('editor'),
|
||||
};
|
||||
|
||||
return names[name];
|
||||
};
|
||||
});
|
||||
|
||||
let _sharedLink: SharedLinkResponseDto | undefined;
|
||||
|
||||
export const setSharedLink = (sharedLink: SharedLinkResponseDto) => (_sharedLink = sharedLink);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getContentMetrics, getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||
|
||||
const mockImage = (props: {
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): HTMLImageElement => props as unknown as HTMLImageElement;
|
||||
|
||||
const mockVideo = (props: {
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
}): HTMLVideoElement => {
|
||||
const element = Object.create(HTMLVideoElement.prototype);
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
Object.defineProperty(element, key, { value, writable: true, configurable: true });
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
describe('scaleToFit', () => {
|
||||
it('should return full width when image is wider than container', () => {
|
||||
expect(scaleToFit({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({ width: 800, height: 400 });
|
||||
});
|
||||
|
||||
it('should return full height when image is taller than container', () => {
|
||||
expect(scaleToFit({ width: 1000, height: 2000 }, { width: 800, height: 600 })).toEqual({ width: 300, height: 600 });
|
||||
});
|
||||
|
||||
it('should return exact fit when aspect ratios match', () => {
|
||||
expect(scaleToFit({ width: 1600, height: 900 }, { width: 800, height: 450 })).toEqual({ width: 800, height: 450 });
|
||||
});
|
||||
|
||||
it('should handle square images in landscape container', () => {
|
||||
expect(scaleToFit({ width: 500, height: 500 }, { width: 800, height: 600 })).toEqual({ width: 600, height: 600 });
|
||||
});
|
||||
|
||||
it('should handle square images in portrait container', () => {
|
||||
expect(scaleToFit({ width: 500, height: 500 }, { width: 400, height: 600 })).toEqual({ width: 400, height: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentMetrics', () => {
|
||||
it('should compute zero offsets when aspect ratios match', () => {
|
||||
const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 });
|
||||
expect(getContentMetrics(img)).toEqual({
|
||||
contentWidth: 800,
|
||||
contentHeight: 450,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should compute horizontal letterbox offsets for tall image', () => {
|
||||
const img = mockImage({ naturalWidth: 1000, naturalHeight: 2000, width: 800, height: 600 });
|
||||
const metrics = getContentMetrics(img);
|
||||
expect(metrics.contentWidth).toBe(300);
|
||||
expect(metrics.contentHeight).toBe(600);
|
||||
expect(metrics.offsetX).toBe(250);
|
||||
expect(metrics.offsetY).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute vertical letterbox offsets for wide image', () => {
|
||||
const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 });
|
||||
const metrics = getContentMetrics(img);
|
||||
expect(metrics.contentWidth).toBe(800);
|
||||
expect(metrics.contentHeight).toBe(400);
|
||||
expect(metrics.offsetX).toBe(0);
|
||||
expect(metrics.offsetY).toBe(100);
|
||||
});
|
||||
|
||||
it('should use clientWidth/clientHeight for video elements', () => {
|
||||
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
|
||||
const metrics = getContentMetrics(video);
|
||||
expect(metrics.contentWidth).toBe(800);
|
||||
expect(metrics.contentHeight).toBe(450);
|
||||
expect(metrics.offsetX).toBe(0);
|
||||
expect(metrics.offsetY).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNaturalSize', () => {
|
||||
it('should return naturalWidth/naturalHeight for images', () => {
|
||||
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000, width: 800, height: 600 });
|
||||
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
|
||||
});
|
||||
|
||||
it('should return videoWidth/videoHeight for videos', () => {
|
||||
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
|
||||
expect(getNaturalSize(video)).toEqual({ width: 1920, height: 1080 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface ContentMetrics {
|
||||
contentWidth: number;
|
||||
contentHeight: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
export const scaleToFit = (
|
||||
dimensions: { width: number; height: number },
|
||||
container: { width: number; height: number },
|
||||
): { width: number; height: number } => {
|
||||
const scaleX = container.width / dimensions.width;
|
||||
const scaleY = container.height / dimensions.height;
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
return {
|
||||
width: dimensions.width * scale,
|
||||
height: dimensions.height * scale,
|
||||
};
|
||||
};
|
||||
|
||||
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
|
||||
if (element instanceof HTMLVideoElement) {
|
||||
return { width: element.clientWidth, height: element.clientHeight };
|
||||
}
|
||||
return { width: element.width, height: element.height };
|
||||
};
|
||||
|
||||
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
|
||||
if (element instanceof HTMLVideoElement) {
|
||||
return { width: element.videoWidth, height: element.videoHeight };
|
||||
}
|
||||
return { width: element.naturalWidth, height: element.naturalHeight };
|
||||
};
|
||||
|
||||
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
|
||||
const natural = getNaturalSize(element);
|
||||
const client = getElementSize(element);
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
|
||||
return {
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
offsetX: (client.width - contentWidth) / 2,
|
||||
offsetY: (client.height - contentHeight) / 2,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
|
||||
describe('getOcrBoundingBoxes', () => {
|
||||
it('should scale normalized coordinates by display dimensions', () => {
|
||||
const ocrData: OcrBoundingBox[] = [
|
||||
{
|
||||
id: 'box1',
|
||||
assetId: 'asset1',
|
||||
x1: 0.1,
|
||||
y1: 0.2,
|
||||
x2: 0.9,
|
||||
y2: 0.2,
|
||||
x3: 0.9,
|
||||
y3: 0.8,
|
||||
x4: 0.1,
|
||||
y4: 0.8,
|
||||
boxScore: 0.95,
|
||||
textScore: 0.9,
|
||||
text: 'hello',
|
||||
},
|
||||
];
|
||||
const metrics: ContentMetrics = { contentWidth: 1000, contentHeight: 500, offsetX: 0, offsetY: 0 };
|
||||
|
||||
const boxes = getOcrBoundingBoxes(ocrData, metrics);
|
||||
|
||||
expect(boxes).toHaveLength(1);
|
||||
expect(boxes[0].id).toBe('box1');
|
||||
expect(boxes[0].text).toBe('hello');
|
||||
expect(boxes[0].confidence).toBe(0.9);
|
||||
expect(boxes[0].points).toEqual([
|
||||
{ x: 100, y: 100 },
|
||||
{ x: 900, y: 100 },
|
||||
{ x: 900, y: 400 },
|
||||
{ x: 100, y: 400 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should apply offsets for letterboxed images', () => {
|
||||
const ocrData: OcrBoundingBox[] = [
|
||||
{
|
||||
id: 'box1',
|
||||
assetId: 'asset1',
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 1,
|
||||
y2: 0,
|
||||
x3: 1,
|
||||
y3: 1,
|
||||
x4: 0,
|
||||
y4: 1,
|
||||
boxScore: 0.9,
|
||||
textScore: 0.8,
|
||||
text: 'test',
|
||||
},
|
||||
];
|
||||
const metrics: ContentMetrics = { contentWidth: 600, contentHeight: 400, offsetX: 100, offsetY: 50 };
|
||||
|
||||
const boxes = getOcrBoundingBoxes(ocrData, metrics);
|
||||
|
||||
expect(boxes[0].points).toEqual([
|
||||
{ x: 100, y: 50 },
|
||||
{ x: 700, y: 50 },
|
||||
{ x: 700, y: 450 },
|
||||
{ x: 100, y: 450 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
||||
expect(getOcrBoundingBoxes([], metrics)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle multiple boxes', () => {
|
||||
const ocrData: OcrBoundingBox[] = [
|
||||
{
|
||||
id: 'a',
|
||||
assetId: 'asset1',
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 0.5,
|
||||
y2: 0,
|
||||
x3: 0.5,
|
||||
y3: 0.5,
|
||||
x4: 0,
|
||||
y4: 0.5,
|
||||
boxScore: 0.9,
|
||||
textScore: 0.8,
|
||||
text: 'first',
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
assetId: 'asset1',
|
||||
x1: 0.5,
|
||||
y1: 0.5,
|
||||
x2: 1,
|
||||
y2: 0.5,
|
||||
x3: 1,
|
||||
y3: 1,
|
||||
x4: 0.5,
|
||||
y4: 1,
|
||||
boxScore: 0.9,
|
||||
textScore: 0.7,
|
||||
text: 'second',
|
||||
},
|
||||
];
|
||||
const metrics: ContentMetrics = { contentWidth: 200, contentHeight: 200, offsetX: 0, offsetY: 0 };
|
||||
|
||||
const boxes = getOcrBoundingBoxes(ocrData, metrics);
|
||||
|
||||
expect(boxes).toHaveLength(2);
|
||||
expect(boxes[0].text).toBe('first');
|
||||
expect(boxes[1].text).toBe('second');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,5 @@
|
||||
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
|
||||
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
let width = img.height * ratio;
|
||||
let height = img.height;
|
||||
if (width > img.width) {
|
||||
width = img.width;
|
||||
height = img.width / ratio;
|
||||
}
|
||||
return { width, height };
|
||||
};
|
||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||
|
||||
export type Point = {
|
||||
x: number;
|
||||
@@ -66,53 +55,17 @@ export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[];
|
||||
return { matrix, width, height };
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert normalized OCR coordinates to screen coordinates
|
||||
* OCR coordinates are normalized (0-1) and represent the 4 corners of a rotated rectangle
|
||||
*/
|
||||
export const getOcrBoundingBoxes = (
|
||||
ocrData: OcrBoundingBox[],
|
||||
zoom: ZoomImageWheelState,
|
||||
photoViewer: HTMLImageElement | null,
|
||||
): OcrBox[] => {
|
||||
if (photoViewer === null || !photoViewer.naturalWidth || !photoViewer.naturalHeight) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const clientHeight = photoViewer.clientHeight;
|
||||
const clientWidth = photoViewer.clientWidth;
|
||||
const { width, height } = getContainedSize(photoViewer);
|
||||
|
||||
const offset = {
|
||||
x: ((clientWidth - width) / 2) * zoom.currentZoom + zoom.currentPositionX,
|
||||
y: ((clientHeight - height) / 2) * zoom.currentZoom + zoom.currentPositionY,
|
||||
};
|
||||
|
||||
return getOcrBoundingBoxesAtSize(
|
||||
ocrData,
|
||||
{ width: width * zoom.currentZoom, height: height * zoom.currentZoom },
|
||||
offset,
|
||||
);
|
||||
};
|
||||
|
||||
export const getOcrBoundingBoxesAtSize = (
|
||||
ocrData: OcrBoundingBox[],
|
||||
targetSize: { width: number; height: number },
|
||||
offset?: Point,
|
||||
) => {
|
||||
export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentMetrics): OcrBox[] => {
|
||||
const boxes: OcrBox[] = [];
|
||||
|
||||
for (const ocr of ocrData) {
|
||||
// Convert normalized coordinates (0-1) to actual pixel positions
|
||||
// OCR provides 4 corners of a potentially rotated rectangle
|
||||
const points = [
|
||||
{ x: ocr.x1, y: ocr.y1 },
|
||||
{ x: ocr.x2, y: ocr.y2 },
|
||||
{ x: ocr.x3, y: ocr.y3 },
|
||||
{ x: ocr.x4, y: ocr.y4 },
|
||||
].map((point) => ({
|
||||
x: targetSize.width * point.x + (offset?.x ?? 0),
|
||||
y: targetSize.height * point.y + (offset?.y ?? 0),
|
||||
x: point.x * metrics.contentWidth + metrics.offsetX,
|
||||
y: point.y * metrics.contentHeight + metrics.offsetY,
|
||||
}));
|
||||
|
||||
boxes.push({
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Faces } from '$lib/stores/people.store';
|
||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
|
||||
const makeFace = (overrides: Partial<Faces> = {}): Faces => ({
|
||||
id: 'face-1',
|
||||
imageWidth: 4000,
|
||||
imageHeight: 3000,
|
||||
boundingBoxX1: 1000,
|
||||
boundingBoxY1: 750,
|
||||
boundingBoxX2: 2000,
|
||||
boundingBoxY2: 1500,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('getBoundingBox', () => {
|
||||
it('should scale face coordinates to display dimensions', () => {
|
||||
const face = makeFace();
|
||||
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
||||
|
||||
const boxes = getBoundingBox([face], metrics);
|
||||
|
||||
expect(boxes).toHaveLength(1);
|
||||
expect(boxes[0]).toEqual({
|
||||
id: 'face-1',
|
||||
top: Math.round(600 * (750 / 3000)),
|
||||
left: Math.round(800 * (1000 / 4000)),
|
||||
width: Math.round(800 * (2000 / 4000) - 800 * (1000 / 4000)),
|
||||
height: Math.round(600 * (1500 / 3000) - 600 * (750 / 3000)),
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply offsets for letterboxed display', () => {
|
||||
const face = makeFace({
|
||||
imageWidth: 1000,
|
||||
imageHeight: 1000,
|
||||
boundingBoxX1: 0,
|
||||
boundingBoxY1: 0,
|
||||
boundingBoxX2: 1000,
|
||||
boundingBoxY2: 1000,
|
||||
});
|
||||
const metrics: ContentMetrics = { contentWidth: 600, contentHeight: 600, offsetX: 100, offsetY: 0 };
|
||||
|
||||
const boxes = getBoundingBox([face], metrics);
|
||||
|
||||
expect(boxes[0]).toEqual({
|
||||
id: 'face-1',
|
||||
top: 0,
|
||||
left: 100,
|
||||
width: 600,
|
||||
height: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle zoom by pre-scaled metrics', () => {
|
||||
const face = makeFace({
|
||||
imageWidth: 1000,
|
||||
imageHeight: 1000,
|
||||
boundingBoxX1: 0,
|
||||
boundingBoxY1: 0,
|
||||
boundingBoxX2: 500,
|
||||
boundingBoxY2: 500,
|
||||
});
|
||||
const metrics: ContentMetrics = {
|
||||
contentWidth: 1600,
|
||||
contentHeight: 1200,
|
||||
offsetX: -200,
|
||||
offsetY: -100,
|
||||
};
|
||||
|
||||
const boxes = getBoundingBox([face], metrics);
|
||||
|
||||
expect(boxes[0]).toEqual({
|
||||
id: 'face-1',
|
||||
top: -100,
|
||||
left: -200,
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array for empty faces', () => {
|
||||
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
||||
expect(getBoundingBox([], metrics)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle multiple faces', () => {
|
||||
const faces = [
|
||||
makeFace({ id: 'face-1', boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 1000, boundingBoxY2: 1000 }),
|
||||
makeFace({ id: 'face-2', boundingBoxX1: 2000, boundingBoxY1: 1500, boundingBoxX2: 3000, boundingBoxY2: 2500 }),
|
||||
];
|
||||
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
||||
|
||||
const boxes = getBoundingBox(faces, metrics);
|
||||
|
||||
expect(boxes).toHaveLength(2);
|
||||
expect(boxes[0].left).toBeLessThan(boxes[1].left);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +1,39 @@
|
||||
import type { Faces } from '$lib/stores/people.store';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
|
||||
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
let width = img.height * ratio;
|
||||
let height = img.height;
|
||||
if (width > img.width) {
|
||||
width = img.width;
|
||||
height = img.width / ratio;
|
||||
}
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export interface boundingBox {
|
||||
export interface BoundingBox {
|
||||
id: string;
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const getBoundingBox = (
|
||||
faces: Faces[],
|
||||
zoom: ZoomImageWheelState,
|
||||
photoViewer: HTMLImageElement | undefined,
|
||||
): boundingBox[] => {
|
||||
const boxes: boundingBox[] = [];
|
||||
|
||||
if (!photoViewer) {
|
||||
return boxes;
|
||||
}
|
||||
const clientHeight = photoViewer.clientHeight;
|
||||
const clientWidth = photoViewer.clientWidth;
|
||||
|
||||
const { width, height } = getContainedSize(photoViewer);
|
||||
export const getBoundingBox = (faces: Faces[], metrics: ContentMetrics): BoundingBox[] => {
|
||||
const boxes: BoundingBox[] = [];
|
||||
|
||||
for (const face of faces) {
|
||||
/*
|
||||
*
|
||||
* Create the coordinates of the box based on the displayed image.
|
||||
* The coordinates must take into account margins due to the 'object-fit: contain;' css property of the photo-viewer.
|
||||
*
|
||||
*/
|
||||
const scaleX = metrics.contentWidth / face.imageWidth;
|
||||
const scaleY = metrics.contentHeight / face.imageHeight;
|
||||
|
||||
const coordinates = {
|
||||
x1:
|
||||
(width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX1 +
|
||||
((clientWidth - width) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionX,
|
||||
x2:
|
||||
(width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX2 +
|
||||
((clientWidth - width) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionX,
|
||||
y1:
|
||||
(height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY1 +
|
||||
((clientHeight - height) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionY,
|
||||
y2:
|
||||
(height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY2 +
|
||||
((clientHeight - height) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionY,
|
||||
x1: scaleX * face.boundingBoxX1 + metrics.offsetX,
|
||||
x2: scaleX * face.boundingBoxX2 + metrics.offsetX,
|
||||
y1: scaleY * face.boundingBoxY1 + metrics.offsetY,
|
||||
y2: scaleY * face.boundingBoxY2 + metrics.offsetY,
|
||||
};
|
||||
|
||||
boxes.push({
|
||||
id: face.id,
|
||||
top: Math.round(coordinates.y1),
|
||||
left: Math.round(coordinates.x1),
|
||||
width: Math.round(coordinates.x2 - coordinates.x1),
|
||||
height: Math.round(coordinates.y2 - coordinates.y1),
|
||||
});
|
||||
}
|
||||
|
||||
return boxes;
|
||||
};
|
||||
|
||||
|
||||
@@ -61,8 +61,6 @@
|
||||
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
||||
});
|
||||
|
||||
const isAllUserOwned = $derived($user && selectedAssets.every((asset) => asset.ownerId === $user.id));
|
||||
|
||||
const handleEscape = () => {
|
||||
if ($showAssetViewer) {
|
||||
return;
|
||||
@@ -135,7 +133,7 @@
|
||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||
<ActionButton action={Actions.AddToAlbum} />
|
||||
|
||||
{#if isAllUserOwned}
|
||||
{#if assetInteraction.isAllUserOwned}
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { lang, locale } from '$lib/stores/preferences.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { parseUtcDate } from '$lib/utils/date-time';
|
||||
@@ -69,10 +69,6 @@
|
||||
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
||||
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
|
||||
|
||||
const isAllUserOwned = $derived(
|
||||
$user && assetInteraction.selectedAssets.every((asset) => asset.ownerId === $user.id),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
// we want this to *only* be reactive on `terms`
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
@@ -344,7 +340,7 @@
|
||||
onclick={handleSelectAll}
|
||||
/>
|
||||
<ActionButton action={Actions.AddToAlbum} />
|
||||
{#if isAllUserOwned}
|
||||
{#if assetInteraction.isAllUserOwned}
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => {
|
||||
@@ -364,10 +360,8 @@
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||
{#if assetInteraction.isAllUserOwned}
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
{/if}
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
{#if $preferences.tags.enabled}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
||||
|
||||
@@ -22,6 +22,31 @@ const config = {
|
||||
fallback: 'index.html',
|
||||
precompress: true,
|
||||
}),
|
||||
csp: {
|
||||
mode: 'hash',
|
||||
directives: {
|
||||
'default-src': ['self'],
|
||||
'script-src': [
|
||||
'self',
|
||||
'https://www.gstatic.com',
|
||||
'wasm-unsafe-eval',
|
||||
'sha256-h5wSYKWbmHcoYTdkHNNguMswVNCphpvwW+uxooXhF/Y=',
|
||||
],
|
||||
'style-src': ['self', 'unsafe-inline'],
|
||||
'img-src': ['self', 'data:', 'blob:'],
|
||||
'connect-src': [
|
||||
'self',
|
||||
'blob:',
|
||||
'https://pay.futo.org',
|
||||
'https://static.immich.cloud',
|
||||
'https://tiles.immich.cloud',
|
||||
],
|
||||
'worker-src': ['self', 'blob:'],
|
||||
'frame-src': ['none'],
|
||||
'object-src': ['none'],
|
||||
'base-uri': ['self'],
|
||||
},
|
||||
},
|
||||
alias: {
|
||||
$lib: 'src/lib',
|
||||
'$lib/*': 'src/lib/*',
|
||||
|
||||
Reference in New Issue
Block a user