mirror of
https://github.com/immich-app/immich.git
synced 2026-02-03 10:38:01 -08:00
Compare commits
3 Commits
docs/mise-
...
fix/create
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61fcf798e | ||
|
|
be67147766 | ||
|
|
8ebba759d3 |
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -591,9 +591,9 @@ jobs:
|
||||
- name: Lint with ruff
|
||||
run: |
|
||||
uv run ruff check --output-format=github immich_ml
|
||||
- name: Format with ruff
|
||||
- name: Check black formatting
|
||||
run: |
|
||||
uv run ruff format --check immich_ml
|
||||
uv run black --check immich_ml
|
||||
- name: Run mypy type checking
|
||||
run: |
|
||||
uv run mypy --strict immich_ml/
|
||||
|
||||
@@ -88,7 +88,7 @@ The easiest option is to have both extensions installed during the migration:
|
||||
<details>
|
||||
<summary>Migration steps (automatic)</summary>
|
||||
1. Ensure you still have pgvecto.rs installed
|
||||
2. Install `pgvector` (`>= 0.7, < 0.9`). The easiest way to do this is on Debian/Ubuntu by adding the [PostgreSQL Apt repository][pg-apt] and then running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`)
|
||||
2. Install `pgvector` (`>= 0.7.0, < 1.0.0`). The easiest way to do this is on Debian/Ubuntu by adding the [PostgreSQL Apt repository][pg-apt] and then running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`)
|
||||
3. [Install VectorChord][vchord-install]
|
||||
4. Add `shared_preload_libraries= 'vchord.so, vectors.so'` to your `postgresql.conf`, making sure to include _both_ `vchord.so` and `vectors.so`. You may include other libraries here as well if needed
|
||||
5. Restart the Postgres database
|
||||
|
||||
@@ -90,13 +90,10 @@ To see local changes to `@immich/ui` in Immich, do the following:
|
||||
|
||||
#### Setup
|
||||
|
||||
1. [Install mise](https://mise.jdx.dev/installing-mise.html).
|
||||
2. Change to the immich (root) directory and trust the mise config with `mise trust`.
|
||||
3. Install tools with mise: `mise install`.
|
||||
4. Change to the `mobile/` directory.
|
||||
5. Run `flutter pub get` to install the dependencies.
|
||||
6. Run `make translation` to generate the translation file.
|
||||
7. Run `flutter run` to start the app.
|
||||
1. Setup Flutter toolchain using FVM.
|
||||
2. Run `flutter pub get` to install the dependencies.
|
||||
3. Run `make translation` to generate the translation file.
|
||||
4. Run `fvm flutter run` to start the app.
|
||||
|
||||
#### Translation
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { generateMemoriesFromTimeline, generateMemory } from './memory/model-objects';
|
||||
export type { MemoryConfig, MemoryYearConfig } from './memory/model-objects';
|
||||
@@ -1,84 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { MemoryType, type MemoryResponseDto, type OnThisDayDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { toAssetResponseDto } from 'src/generators/timeline/rest-response';
|
||||
import type { MockTimelineAsset } from 'src/generators/timeline/timeline-config';
|
||||
import { SeededRandom, selectRandomMultiple } from 'src/generators/timeline/utils';
|
||||
|
||||
export type MemoryConfig = {
|
||||
id?: string;
|
||||
ownerId: string;
|
||||
year: number;
|
||||
memoryAt: string;
|
||||
isSaved?: boolean;
|
||||
};
|
||||
|
||||
export type MemoryYearConfig = {
|
||||
year: number;
|
||||
assetCount: number;
|
||||
};
|
||||
|
||||
export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]): MemoryResponseDto {
|
||||
const now = new Date().toISOString();
|
||||
const memoryId = config.id ?? faker.string.uuid();
|
||||
|
||||
return {
|
||||
id: memoryId,
|
||||
assets: assets.map((asset) => toAssetResponseDto(asset)),
|
||||
data: { year: config.year } as OnThisDayDto,
|
||||
memoryAt: config.memoryAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isSaved: config.isSaved ?? false,
|
||||
ownerId: config.ownerId,
|
||||
type: MemoryType.OnThisDay,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateMemoriesFromTimeline(
|
||||
timelineAssets: MockTimelineAsset[],
|
||||
ownerId: string,
|
||||
memoryConfigs: MemoryYearConfig[],
|
||||
seed: number = 42,
|
||||
): MemoryResponseDto[] {
|
||||
const rng = new SeededRandom(seed);
|
||||
const memories: MemoryResponseDto[] = [];
|
||||
const usedAssetIds = new Set<string>();
|
||||
|
||||
for (const config of memoryConfigs) {
|
||||
const yearAssets = timelineAssets.filter((asset) => {
|
||||
const assetYear = DateTime.fromISO(asset.fileCreatedAt).year;
|
||||
return assetYear === config.year && !usedAssetIds.has(asset.id);
|
||||
});
|
||||
|
||||
if (yearAssets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const countToSelect = Math.min(config.assetCount, yearAssets.length);
|
||||
const selectedAssets = selectRandomMultiple(yearAssets, countToSelect, rng);
|
||||
|
||||
for (const asset of selectedAssets) {
|
||||
usedAssetIds.add(asset.id);
|
||||
}
|
||||
|
||||
selectedAssets.sort(
|
||||
(a, b) => DateTime.fromISO(b.fileCreatedAt).diff(DateTime.fromISO(a.fileCreatedAt)).milliseconds,
|
||||
);
|
||||
|
||||
const memoryAt = DateTime.now().set({ year: config.year }).toISO()!;
|
||||
|
||||
memories.push(
|
||||
generateMemory(
|
||||
{
|
||||
ownerId,
|
||||
year: config.year,
|
||||
memoryAt,
|
||||
},
|
||||
selectedAssets,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return memories;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { MemoryResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
|
||||
export type MemoryChanges = {
|
||||
memoryDeletions: string[];
|
||||
assetRemovals: Map<string, string[]>;
|
||||
};
|
||||
|
||||
export const setupMemoryMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
memories: MemoryResponseDto[],
|
||||
changes: MemoryChanges,
|
||||
) => {
|
||||
await context.route('**/api/memories*', async (route, request) => {
|
||||
const url = new URL(request.url());
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === '/api/memories' && request.method() === 'GET') {
|
||||
const activeMemories = memories
|
||||
.filter((memory) => !changes.memoryDeletions.includes(memory.id))
|
||||
.map((memory) => {
|
||||
const removedAssets = changes.assetRemovals.get(memory.id) ?? [];
|
||||
return {
|
||||
...memory,
|
||||
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
|
||||
};
|
||||
})
|
||||
.filter((memory) => memory.assets.length > 0);
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: activeMemories,
|
||||
});
|
||||
}
|
||||
|
||||
const memoryMatch = pathname.match(/\/api\/memories\/([^/]+)$/);
|
||||
if (memoryMatch && request.method() === 'GET') {
|
||||
const memoryId = memoryMatch[1];
|
||||
const memory = memories.find((m) => m.id === memoryId);
|
||||
|
||||
if (!memory || changes.memoryDeletions.includes(memoryId)) {
|
||||
return route.fulfill({ status: 404 });
|
||||
}
|
||||
|
||||
const removedAssets = changes.assetRemovals.get(memoryId) ?? [];
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
...memory,
|
||||
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (/\/api\/memories\/([^/]+)$/.test(pathname) && request.method() === 'DELETE') {
|
||||
const memoryId = pathname.split('/').pop()!;
|
||||
changes.memoryDeletions.push(memoryId);
|
||||
return route.fulfill({ status: 204 });
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
});
|
||||
};
|
||||
@@ -1,289 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { MemoryResponseDto } from '@immich/sdk';
|
||||
import { test } from '@playwright/test';
|
||||
import { generateMemoriesFromTimeline } from 'src/generators/memory';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} from 'src/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
|
||||
import { MemoryChanges, setupMemoryMockApiRoutes } from 'src/mock-network/memory-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
|
||||
import { memoryAssetViewerUtils, memoryGalleryUtils, memoryViewerUtils } from 'src/web/specs/memory/utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
let memories: MemoryResponseDto[];
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
const memoryChanges: MemoryChanges = {
|
||||
memoryDeletions: [],
|
||||
assetRemovals: new Map(),
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
|
||||
timelineRestData = generateTimelineData({
|
||||
...createDefaultTimelineConfig(),
|
||||
ownerId: adminUserId,
|
||||
});
|
||||
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
|
||||
memories = generateMemoriesFromTimeline(
|
||||
assets,
|
||||
adminUserId,
|
||||
[
|
||||
{ year: 2024, assetCount: 3 },
|
||||
{ year: 2023, assetCount: 2 },
|
||||
{ year: 2022, assetCount: 4 },
|
||||
],
|
||||
42,
|
||||
);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
memoryChanges.memoryDeletions = [];
|
||||
memoryChanges.assetRemovals.clear();
|
||||
});
|
||||
|
||||
test.describe('Asset viewer navigation from gallery', () => {
|
||||
test('shows both prev/next buttons for middle asset within a memory', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const middleAsset = firstMemory.assets[1];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, middleAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, middleAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
|
||||
test('shows next button when at last asset of first memory (next memory exists)', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!;
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirstMemory);
|
||||
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
});
|
||||
|
||||
test('shows prev button when at first asset of last memory (prev memory exists)', async ({ page }) => {
|
||||
const lastMemory = memories.at(-1)!;
|
||||
const firstAssetOfLastMemory = lastMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfLastMemory);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
|
||||
test('can navigate from last asset of memory to first asset of next memory', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
|
||||
await memoryAssetViewerUtils.clickNextButton(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
|
||||
await memoryAssetViewerUtils.expectCurrentAssetId(page, firstAssetOfSecond.id);
|
||||
});
|
||||
|
||||
test('can navigate from first asset of memory to last asset of previous memory', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
|
||||
await memoryAssetViewerUtils.clickPreviousButton(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
});
|
||||
|
||||
test('hides prev button at very first asset (first memory, first asset, no prev memory)', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const veryFirstAsset = firstMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, veryFirstAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonNotVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
|
||||
test('hides next button at very last asset (last memory, last asset, no next memory)', async ({ page }) => {
|
||||
const lastMemory = memories.at(-1)!;
|
||||
const veryLastAsset = lastMemory.assets.at(-1)!;
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, veryLastAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectNextButtonNotVisible(page);
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Keyboard navigation', () => {
|
||||
test('ArrowLeft navigates to previous asset across memory boundary', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
});
|
||||
|
||||
test('ArrowRight navigates to next asset across memory boundary', async ({ page }) => {
|
||||
const firstMemory = memories[0];
|
||||
const secondMemory = memories[1];
|
||||
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||
const firstAssetOfSecond = secondMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => {
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
let memories: MemoryResponseDto[];
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
const memoryChanges: MemoryChanges = {
|
||||
memoryDeletions: [],
|
||||
assetRemovals: new Map(),
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
|
||||
timelineRestData = generateTimelineData({
|
||||
...createDefaultTimelineConfig(),
|
||||
ownerId: adminUserId,
|
||||
});
|
||||
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
|
||||
memories = generateMemoriesFromTimeline(
|
||||
assets,
|
||||
adminUserId,
|
||||
[
|
||||
{ year: 2024, assetCount: 2 },
|
||||
{ year: 2023, assetCount: 1 },
|
||||
{ year: 2022, assetCount: 2 },
|
||||
],
|
||||
123,
|
||||
);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
memoryChanges.memoryDeletions = [];
|
||||
memoryChanges.assetRemovals.clear();
|
||||
});
|
||||
|
||||
test('single asset memory shows both prev/next when surrounded by other memories', async ({ page }) => {
|
||||
const singleAssetMemory = memories[1];
|
||||
const singleAsset = singleAssetMemory.assets[0];
|
||||
|
||||
await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id);
|
||||
await memoryGalleryUtils.clickThumbnail(page, singleAsset.id);
|
||||
|
||||
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||
await memoryAssetViewerUtils.waitForAssetLoad(page, singleAsset);
|
||||
|
||||
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
|
||||
function getAssetIdFromUrl(url: URL): string | null {
|
||||
const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
|
||||
if (pathMatch) {
|
||||
return pathMatch[1];
|
||||
}
|
||||
return url.searchParams.get('id');
|
||||
}
|
||||
|
||||
export const memoryViewerUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#memory-viewer');
|
||||
},
|
||||
|
||||
async waitForMemoryLoad(page: Page) {
|
||||
await expect(this.locator(page)).toBeVisible();
|
||||
await expect(page.locator('#memory-viewer img').first()).toBeVisible();
|
||||
},
|
||||
|
||||
async openMemoryPage(page: Page) {
|
||||
await page.goto('/memory');
|
||||
await this.waitForMemoryLoad(page);
|
||||
},
|
||||
|
||||
async openMemoryPageWithAsset(page: Page, assetId: string) {
|
||||
await page.goto(`/memory?id=${assetId}`);
|
||||
await this.waitForMemoryLoad(page);
|
||||
},
|
||||
};
|
||||
|
||||
export const memoryGalleryUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#gallery-memory');
|
||||
},
|
||||
|
||||
thumbnailWithAssetId(page: Page, assetId: string) {
|
||||
return page.locator(`#gallery-memory [data-thumbnail-focus-container][data-asset="${assetId}"]`);
|
||||
},
|
||||
|
||||
async scrollToGallery(page: Page) {
|
||||
const showGalleryButton = page.getByLabel('Show gallery');
|
||||
if (await showGalleryButton.isVisible()) {
|
||||
await showGalleryButton.click();
|
||||
}
|
||||
await expect(this.locator(page)).toBeInViewport();
|
||||
},
|
||||
|
||||
async clickThumbnail(page: Page, assetId: string) {
|
||||
await this.scrollToGallery(page);
|
||||
await this.thumbnailWithAssetId(page, assetId).click();
|
||||
},
|
||||
|
||||
async getAllThumbnails(page: Page) {
|
||||
await this.scrollToGallery(page);
|
||||
return page.locator('#gallery-memory [data-thumbnail-focus-container]');
|
||||
},
|
||||
};
|
||||
|
||||
export const memoryAssetViewerUtils = {
|
||||
locator(page: Page) {
|
||||
return page.locator('#immich-asset-viewer');
|
||||
},
|
||||
|
||||
async waitForViewerOpen(page: Page) {
|
||||
await expect(this.locator(page)).toBeVisible();
|
||||
},
|
||||
|
||||
async waitForAssetLoad(page: Page, asset: AssetResponseDto) {
|
||||
const viewer = this.locator(page);
|
||||
const imgLocator = viewer.locator(`img[draggable="false"][src*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
|
||||
const videoLocator = viewer.locator(`video[poster*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
|
||||
|
||||
await imgLocator.or(videoLocator).waitFor({ timeout: 10_000 });
|
||||
},
|
||||
|
||||
nextButton(page: Page) {
|
||||
return page.getByLabel('View next asset');
|
||||
},
|
||||
|
||||
previousButton(page: Page) {
|
||||
return page.getByLabel('View previous asset');
|
||||
},
|
||||
|
||||
async expectNextButtonVisible(page: Page) {
|
||||
await expect(this.nextButton(page)).toBeVisible();
|
||||
},
|
||||
|
||||
async expectNextButtonNotVisible(page: Page) {
|
||||
await expect(this.nextButton(page)).toHaveCount(0);
|
||||
},
|
||||
|
||||
async expectPreviousButtonVisible(page: Page) {
|
||||
await expect(this.previousButton(page)).toBeVisible();
|
||||
},
|
||||
|
||||
async expectPreviousButtonNotVisible(page: Page) {
|
||||
await expect(this.previousButton(page)).toHaveCount(0);
|
||||
},
|
||||
|
||||
async clickNextButton(page: Page) {
|
||||
await this.nextButton(page).click();
|
||||
},
|
||||
|
||||
async clickPreviousButton(page: Page) {
|
||||
await this.previousButton(page).click();
|
||||
},
|
||||
|
||||
async closeViewer(page: Page) {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(this.locator(page)).not.toBeVisible();
|
||||
},
|
||||
|
||||
getCurrentAssetId(page: Page): string | null {
|
||||
const url = new URL(page.url());
|
||||
return getAssetIdFromUrl(url);
|
||||
},
|
||||
|
||||
async expectCurrentAssetId(page: Page, expectedAssetId: string) {
|
||||
await expect.poll(() => this.getCurrentAssetId(page)).toBe(expectedAssetId);
|
||||
},
|
||||
};
|
||||
@@ -41,6 +41,7 @@ types = [
|
||||
"types-ujson>=5.10.0.20240515",
|
||||
]
|
||||
lint = [
|
||||
"black>=23.3.0",
|
||||
"mypy>=1.3.0",
|
||||
"ruff>=0.0.272",
|
||||
{ include-group = "types" },
|
||||
@@ -92,5 +93,9 @@ target-version = "py311"
|
||||
select = ["E", "F", "I"]
|
||||
per-file-ignores = { "test_main.py" = ["F403"] }
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
target-version = ['py311']
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = ["providers", "ov_device_ids"]
|
||||
|
||||
50
machine-learning/uv.lock
generated
50
machine-learning/uv.lock
generated
@@ -85,6 +85,43 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "25.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pytokens" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ad/7ac0d0e1e0612788dbc48e62aef8a8e8feffac7eb3d787db4e43b8462fa8/black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", size = 1877003, upload-time = "2025-12-08T01:43:29.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/dd/a237e9f565f3617a88b49284b59cbca2a4f56ebe68676c1aad0ce36a54a7/black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", size = 1712639, upload-time = "2025-12-08T01:52:46.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/80/e187079df1ea4c12a0c63282ddd8b81d5107db6d642f7d7b75a6bcd6fc21/black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", size = 1758143, upload-time = "2025-12-08T01:45:29.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/b5/3096ccee4f29dc2c3aac57274326c4d2d929a77e629f695f544e159bfae4/black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", size = 1420698, upload-time = "2025-12-08T01:45:53.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/39/f81c0ffbc25ffbe61c7d0385bf277e62ffc3e52f5ee668d7369d9854fadf/black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", size = 1229317, upload-time = "2025-12-08T01:46:35.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.7.0"
|
||||
@@ -924,6 +961,7 @@ rknn = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "black" },
|
||||
{ name = "httpx" },
|
||||
{ name = "locust" },
|
||||
{ name = "mypy" },
|
||||
@@ -939,6 +977,7 @@ dev = [
|
||||
{ name = "types-ujson" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "black" },
|
||||
{ name = "mypy" },
|
||||
{ name = "ruff" },
|
||||
{ name = "types-pyyaml" },
|
||||
@@ -992,6 +1031,7 @@ provides-extras = ["cpu", "cuda", "openvino", "armnn", "rknn", "rocm"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "black", specifier = ">=23.3.0" },
|
||||
{ name = "httpx", specifier = ">=0.24.1" },
|
||||
{ name = "locust", specifier = ">=2.15.1" },
|
||||
{ name = "mypy", specifier = ">=1.3.0" },
|
||||
@@ -1007,6 +1047,7 @@ dev = [
|
||||
{ name = "types-ujson", specifier = ">=5.10.0.20240515" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "black", specifier = ">=23.3.0" },
|
||||
{ name = "mypy", specifier = ">=1.3.0" },
|
||||
{ name = "ruff", specifier = ">=0.0.272" },
|
||||
{ name = "types-pyyaml", specifier = ">=6.0.12.20241230" },
|
||||
@@ -2191,6 +2232,15 @@ client = [
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytokens"
|
||||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "311"
|
||||
|
||||
174
server/src/dtos/asset-response.dto.spec.ts
Normal file
174
server/src/dtos/asset-response.dto.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { faceStub } from 'test/fixtures/face.stub';
|
||||
import { personStub } from 'test/fixtures/person.stub';
|
||||
|
||||
describe('mapAsset', () => {
|
||||
describe('peopleWithFaces', () => {
|
||||
it('should transform all faces when a person has multiple faces in the same image', () => {
|
||||
const face1 = {
|
||||
...faceStub.primaryFace1,
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const face2 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'assetFaceId-second',
|
||||
boundingBoxX1: 300,
|
||||
boundingBoxY1: 400,
|
||||
boundingBoxX2: 400,
|
||||
boundingBoxY2: 500,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const asset = {
|
||||
...assetStub.withCropEdit,
|
||||
faces: [face1, face2],
|
||||
exifInfo: {
|
||||
exifImageWidth: 1000,
|
||||
exifImageHeight: 800,
|
||||
},
|
||||
};
|
||||
|
||||
const result = mapAsset(asset as any);
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(1);
|
||||
expect(result.people![0].faces).toHaveLength(2);
|
||||
|
||||
// Verify that both faces have been transformed (bounding boxes adjusted for crop)
|
||||
const firstFace = result.people![0].faces[0];
|
||||
const secondFace = result.people![0].faces[1];
|
||||
|
||||
// After crop (x: 216, y: 1512), the coordinates should be adjusted
|
||||
// Faces outside the crop area will be clamped
|
||||
expect(firstFace.boundingBoxX1).toBe(-116); // 100 - 216 = -116
|
||||
expect(firstFace.boundingBoxY1).toBe(-1412); // 100 - 1512 = -1412
|
||||
expect(firstFace.boundingBoxX2).toBe(-16); // 200 - 216 = -16
|
||||
expect(firstFace.boundingBoxY2).toBe(-1312); // 200 - 1512 = -1312
|
||||
|
||||
expect(secondFace.boundingBoxX1).toBe(84); // 300 - 216
|
||||
expect(secondFace.boundingBoxY1).toBe(-1112); // 400 - 1512 = -1112
|
||||
expect(secondFace.boundingBoxX2).toBe(184); // 400 - 216
|
||||
expect(secondFace.boundingBoxY2).toBe(-1012); // 500 - 1512 = -1012
|
||||
});
|
||||
|
||||
it('should transform unassigned faces with edits and dimensions', () => {
|
||||
const unassignedFace = {
|
||||
...faceStub.noPerson1,
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const asset = {
|
||||
...assetStub.withCropEdit,
|
||||
faces: [unassignedFace],
|
||||
exifInfo: {
|
||||
exifImageWidth: 1000,
|
||||
exifImageHeight: 800,
|
||||
},
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: { x: 50, y: 50, width: 500, height: 400 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = mapAsset(asset as any);
|
||||
|
||||
expect(result.unassignedFaces).toBeDefined();
|
||||
expect(result.unassignedFaces).toHaveLength(1);
|
||||
|
||||
// Verify that unassigned face has been transformed
|
||||
const face = result.unassignedFaces![0];
|
||||
expect(face.boundingBoxX1).toBe(50); // 100 - 50
|
||||
expect(face.boundingBoxY1).toBe(50); // 100 - 50
|
||||
expect(face.boundingBoxX2).toBe(150); // 200 - 50
|
||||
expect(face.boundingBoxY2).toBe(150); // 200 - 50
|
||||
});
|
||||
|
||||
it('should handle multiple people each with multiple faces', () => {
|
||||
const person1Face1 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'face-1-1',
|
||||
person: personStub.withName,
|
||||
personId: personStub.withName.id,
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const person1Face2 = {
|
||||
...faceStub.primaryFace1,
|
||||
id: 'face-1-2',
|
||||
person: personStub.withName,
|
||||
personId: personStub.withName.id,
|
||||
boundingBoxX1: 300,
|
||||
boundingBoxY1: 300,
|
||||
boundingBoxX2: 400,
|
||||
boundingBoxY2: 400,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const person2Face1 = {
|
||||
...faceStub.mergeFace1,
|
||||
id: 'face-2-1',
|
||||
person: personStub.mergePerson,
|
||||
personId: personStub.mergePerson.id,
|
||||
boundingBoxX1: 500,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 600,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const asset = {
|
||||
...assetStub.withCropEdit,
|
||||
faces: [person1Face1, person1Face2, person2Face1],
|
||||
exifInfo: {
|
||||
exifImageWidth: 1000,
|
||||
exifImageHeight: 800,
|
||||
},
|
||||
edits: [],
|
||||
};
|
||||
|
||||
const result = mapAsset(asset as any);
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(2);
|
||||
|
||||
const person1 = result.people!.find((p) => p.id === personStub.withName.id);
|
||||
const person2 = result.people!.find((p) => p.id === personStub.mergePerson.id);
|
||||
|
||||
expect(person1).toBeDefined();
|
||||
expect(person1!.faces).toHaveLength(2);
|
||||
// No edits, so coordinates should be unchanged
|
||||
expect(person1!.faces[0].boundingBoxX1).toBe(100);
|
||||
expect(person1!.faces[0].boundingBoxY1).toBe(100);
|
||||
expect(person1!.faces[1].boundingBoxX1).toBe(300);
|
||||
expect(person1!.faces[1].boundingBoxY1).toBe(300);
|
||||
|
||||
expect(person2).toBeDefined();
|
||||
expect(person2!.faces).toHaveLength(1);
|
||||
expect(person2!.faces[0].boundingBoxX1).toBe(500);
|
||||
expect(person2!.faces[0].boundingBoxY1).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -205,7 +205,7 @@ const peopleWithFaces = (
|
||||
if (face.person) {
|
||||
const existingPersonEntry = result.find((item) => item.id === face.person!.id);
|
||||
if (existingPersonEntry) {
|
||||
existingPersonEntry.faces.push(face);
|
||||
existingPersonEntry.faces.push(mapFacesWithoutPerson(face, edits, assetDimensions));
|
||||
} else {
|
||||
result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face, edits, assetDimensions)] });
|
||||
}
|
||||
@@ -275,7 +275,9 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
tags: entity.tags?.map((tag) => mapTag(tag)),
|
||||
people: peopleWithFaces(entity.faces, entity.edits, assetDimensions),
|
||||
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
|
||||
unassignedFaces: entity.faces
|
||||
?.filter((face) => !face.person)
|
||||
.map((a) => mapFacesWithoutPerson(a, entity.edits, assetDimensions)),
|
||||
checksum: hexOrBufferToBase64(entity.checksum)!,
|
||||
stack: withStack ? mapStack(entity) : undefined,
|
||||
isOffline: entity.isOffline,
|
||||
|
||||
@@ -44,6 +44,7 @@ import { getDimensions } from 'src/utils/asset.util';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
import { Point, transformPoints } from 'src/utils/transform';
|
||||
|
||||
@Injectable()
|
||||
export class PersonService extends BaseService {
|
||||
@@ -634,15 +635,50 @@ export class PersonService extends BaseService {
|
||||
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
|
||||
]);
|
||||
|
||||
const asset = await this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true });
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found');
|
||||
}
|
||||
|
||||
const edits = asset.edits || [];
|
||||
|
||||
let p1: Point = { x: dto.x, y: dto.y };
|
||||
let p2: Point = { x: dto.x + dto.width, y: dto.y + dto.height };
|
||||
|
||||
// the coordinates received from the client are based on the edited preview image
|
||||
// we need to convert them to the coordinate space of the original unedited image
|
||||
if (edits.length > 0) {
|
||||
if (!asset.width || !asset.height || !asset.exifInfo?.exifImageWidth || !asset.exifInfo?.exifImageHeight) {
|
||||
throw new BadRequestException('Asset does not have valid dimensions');
|
||||
}
|
||||
|
||||
// convert from preview to full dimensions
|
||||
const scaleFactor = asset.width / dto.imageWidth;
|
||||
p1 = { x: p1.x * scaleFactor, y: p1.y * scaleFactor };
|
||||
p2 = { x: p2.x * scaleFactor, y: p2.y * scaleFactor };
|
||||
|
||||
const {
|
||||
points: [invertedP1, invertedP2],
|
||||
} = transformPoints([p1, p2], edits, { width: asset.width, height: asset.height }, { inverse: true });
|
||||
|
||||
// make sure p1 is top-left and p2 is bottom-right
|
||||
p1 = { x: Math.min(invertedP1.x, invertedP2.x), y: Math.min(invertedP1.y, invertedP2.y) };
|
||||
p2 = { x: Math.max(invertedP1.x, invertedP2.x), y: Math.max(invertedP1.y, invertedP2.y) };
|
||||
|
||||
// now coordinates are in original image space
|
||||
dto.imageHeight = asset.exifInfo.exifImageHeight;
|
||||
dto.imageWidth = asset.exifInfo.exifImageWidth;
|
||||
}
|
||||
|
||||
await this.personRepository.createAssetFace({
|
||||
personId: dto.personId,
|
||||
assetId: dto.assetId,
|
||||
imageHeight: dto.imageHeight,
|
||||
imageWidth: dto.imageWidth,
|
||||
boundingBoxX1: dto.x,
|
||||
boundingBoxX2: dto.x + dto.width,
|
||||
boundingBoxY1: dto.y,
|
||||
boundingBoxY2: dto.y + dto.height,
|
||||
boundingBoxX1: Math.round(p1.x),
|
||||
boundingBoxX2: Math.round(p2.x),
|
||||
boundingBoxY1: Math.round(p1.y),
|
||||
boundingBoxY2: Math.round(p2.y),
|
||||
sourceType: SourceType.Manual,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export const createAffineMatrix = (
|
||||
);
|
||||
};
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
export type Point = { x: number; y: number };
|
||||
|
||||
type TransformState = {
|
||||
points: Point[];
|
||||
@@ -73,29 +73,33 @@ type TransformState = {
|
||||
* Transforms an array of points through a series of edit operations (crop, rotate, mirror).
|
||||
* Points should be in absolute pixel coordinates relative to the starting dimensions.
|
||||
*/
|
||||
const transformPoints = (
|
||||
export const transformPoints = (
|
||||
points: Point[],
|
||||
edits: AssetEditActionItem[],
|
||||
startingDimensions: ImageDimensions,
|
||||
{ inverse = false } = {},
|
||||
): TransformState => {
|
||||
let currentWidth = startingDimensions.width;
|
||||
let currentHeight = startingDimensions.height;
|
||||
let transformedPoints = [...points];
|
||||
|
||||
// Handle crop first
|
||||
const crop = edits.find((edit) => edit.action === 'crop');
|
||||
if (crop) {
|
||||
const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters;
|
||||
transformedPoints = transformedPoints.map((p) => ({
|
||||
x: p.x - cropX,
|
||||
y: p.y - cropY,
|
||||
}));
|
||||
currentWidth = cropWidth;
|
||||
currentHeight = cropHeight;
|
||||
// Handle crop first if not inverting
|
||||
if (!inverse) {
|
||||
const crop = edits.find((edit) => edit.action === 'crop');
|
||||
if (crop) {
|
||||
const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters;
|
||||
transformedPoints = transformedPoints.map((p) => ({
|
||||
x: p.x - cropX,
|
||||
y: p.y - cropY,
|
||||
}));
|
||||
currentWidth = cropWidth;
|
||||
currentHeight = cropHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rotate and mirror transforms
|
||||
for (const edit of edits) {
|
||||
const editSequence = inverse ? edits.toReversed() : edits;
|
||||
for (const edit of editSequence) {
|
||||
let matrix: Matrix = identity();
|
||||
if (edit.action === 'rotate') {
|
||||
const angleDegrees = edit.parameters.angle;
|
||||
@@ -105,7 +109,7 @@ const transformPoints = (
|
||||
|
||||
matrix = compose(
|
||||
translate(newWidth / 2, newHeight / 2),
|
||||
rotate(angleRadians),
|
||||
rotate(inverse ? -angleRadians : angleRadians),
|
||||
translate(-currentWidth / 2, -currentHeight / 2),
|
||||
);
|
||||
|
||||
@@ -125,6 +129,18 @@ const transformPoints = (
|
||||
transformedPoints = transformedPoints.map((p) => applyToPoint(matrix, p));
|
||||
}
|
||||
|
||||
// Handle crop last if inverting
|
||||
if (inverse) {
|
||||
const crop = edits.find((edit) => edit.action === 'crop');
|
||||
if (crop) {
|
||||
const { x: cropX, y: cropY } = crop.parameters;
|
||||
transformedPoints = transformedPoints.map((p) => ({
|
||||
x: p.x + cropX,
|
||||
y: p.y + cropY,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
points: transformedPoints,
|
||||
currentWidth,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Stats } from 'node:fs';
|
||||
import { Writable } from 'node:stream';
|
||||
import { AssetFace } from 'src/database';
|
||||
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditActionListDto } from 'src/dtos/editing.dto';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetType,
|
||||
@@ -280,6 +281,11 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
||||
const result = await this.get(TagRepository).upsertAssetIds(tagsAssets);
|
||||
return { tagsAssets, result };
|
||||
}
|
||||
|
||||
async newEdits(assetId: string, dto: AssetEditActionListDto) {
|
||||
const edits = await this.get(AssetEditRepository).replaceAll(assetId, dto.edits);
|
||||
return { edits };
|
||||
}
|
||||
}
|
||||
|
||||
export class SyncTestContext extends MediumTestContext<SyncService> {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto';
|
||||
import { AssetFaceCreateDto } from 'src/dtos/person.dto';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
@@ -15,7 +19,7 @@ let defaultDatabase: Kysely<DB>;
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(PersonService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [AccessRepository, DatabaseRepository, PersonRepository],
|
||||
real: [AccessRepository, DatabaseRepository, PersonRepository, AssetRepository, AssetEditRepository],
|
||||
mock: [LoggingRepository, StorageRepository],
|
||||
});
|
||||
};
|
||||
@@ -77,4 +81,685 @@ describe(PersonService.name, () => {
|
||||
expect(storageMock.unlink).toHaveBeenCalledWith(person2.thumbnailPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFace', () => {
|
||||
it('should store and retrieve the face as-is when there are no edits', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id });
|
||||
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 200 });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
|
||||
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const dto: AssetFaceCreateDto = {
|
||||
imageWidth: 200,
|
||||
imageHeight: 200,
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 150,
|
||||
height: 150,
|
||||
personId: person.id,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
await sut.createFace(auth, dto);
|
||||
|
||||
// retrieve an asset's faces
|
||||
const faces = sut.getFacesById(auth, { id: asset.id });
|
||||
|
||||
await expect(faces).resolves.toHaveLength(1);
|
||||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 50,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly transform the coordinates when the asset is edited (Crop)', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id });
|
||||
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 200 });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
|
||||
|
||||
await ctx.newEdits(asset.id, {
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 150,
|
||||
height: 200,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const dto: AssetFaceCreateDto = {
|
||||
imageWidth: 150,
|
||||
imageHeight: 200,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
personId: person.id,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
await sut.createFace(auth, dto);
|
||||
|
||||
// retrieve an asset's faces
|
||||
const faces = sut.getFacesById(auth, { id: asset.id });
|
||||
|
||||
await expect(faces).resolves.toHaveLength(1);
|
||||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 0,
|
||||
boundingBoxY1: 0,
|
||||
boundingBoxX2: 100,
|
||||
boundingBoxY2: 100,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// remove edits and verify the stored coordinates map to the original image
|
||||
await ctx.get(AssetEditRepository).replaceAll(asset.id, []);
|
||||
|
||||
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
|
||||
|
||||
await expect(facesAfterRemovingEdits).resolves.toHaveLength(1);
|
||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 50,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 150,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it.only('should properly transform the coordinates when the asset is edited (Rotate 90)', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id });
|
||||
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 100 });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 });
|
||||
|
||||
await ctx.newEdits(asset.id, {
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Rotate,
|
||||
parameters: {
|
||||
angle: 90,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const dto: AssetFaceCreateDto = {
|
||||
imageWidth: 200,
|
||||
imageHeight: 100,
|
||||
x: 25,
|
||||
y: 50,
|
||||
width: 50,
|
||||
height: 50,
|
||||
personId: person.id,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
await sut.createFace(auth, dto);
|
||||
|
||||
const faces = sut.getFacesById(auth, { id: asset.id });
|
||||
await expect(faces).resolves.toHaveLength(1);
|
||||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 25,
|
||||
boundingBoxY1: 50,
|
||||
boundingBoxX2: 75,
|
||||
boundingBoxY2: 100,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// remove edits and verify the stored coordinates map to the original image
|
||||
await ctx.get(AssetEditRepository).replaceAll(asset.id, []);
|
||||
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
|
||||
|
||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 25,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 75,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly transform the coordinates when the asset is edited (Mirror Horizontal)', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id });
|
||||
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 100 });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 });
|
||||
|
||||
await ctx.newEdits(asset.id, {
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Mirror,
|
||||
parameters: {
|
||||
axis: MirrorAxis.Horizontal,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const dto: AssetFaceCreateDto = {
|
||||
imageWidth: 200,
|
||||
imageHeight: 100,
|
||||
x: 50,
|
||||
y: 25,
|
||||
width: 100,
|
||||
height: 50,
|
||||
personId: person.id,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
await sut.createFace(auth, dto);
|
||||
|
||||
const faces = sut.getFacesById(auth, { id: asset.id });
|
||||
await expect(faces).resolves.toHaveLength(1);
|
||||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 25,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 75,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// remove edits and verify the stored coordinates map to the original image
|
||||
await ctx.get(AssetEditRepository).replaceAll(asset.id, []);
|
||||
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
|
||||
|
||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 25,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 75,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly transform the coordinates when the asset is edited (Crop + Rotate)', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id });
|
||||
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 200 });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
|
||||
|
||||
await ctx.newEdits(asset.id, {
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: {
|
||||
x: 50,
|
||||
y: 0,
|
||||
width: 150,
|
||||
height: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AssetEditAction.Rotate,
|
||||
parameters: {
|
||||
angle: 90,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const dto: AssetFaceCreateDto = {
|
||||
imageWidth: 200,
|
||||
imageHeight: 150,
|
||||
x: 50,
|
||||
y: 25,
|
||||
width: 100,
|
||||
height: 75,
|
||||
personId: person.id,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
await sut.createFace(auth, dto);
|
||||
|
||||
const faces = sut.getFacesById(auth, { id: asset.id });
|
||||
await expect(faces).resolves.toHaveLength(1);
|
||||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 25,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 100,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// remove edits and verify the stored coordinates map to the original image
|
||||
await ctx.get(AssetEditRepository).replaceAll(asset.id, []);
|
||||
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
|
||||
|
||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 75,
|
||||
boundingBoxY1: 50,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 150,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly transform the coordinates when the asset is edited (Crop + Mirror)', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id });
|
||||
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 });
|
||||
|
||||
await ctx.newEdits(asset.id, {
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: {
|
||||
x: 50,
|
||||
y: 0,
|
||||
width: 150,
|
||||
height: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AssetEditAction.Mirror,
|
||||
parameters: {
|
||||
axis: MirrorAxis.Horizontal,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const dto: AssetFaceCreateDto = {
|
||||
imageWidth: 150,
|
||||
imageHeight: 100,
|
||||
x: 25,
|
||||
y: 25,
|
||||
width: 75,
|
||||
height: 50,
|
||||
personId: person.id,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
await sut.createFace(auth, dto);
|
||||
|
||||
const faces = sut.getFacesById(auth, { id: asset.id });
|
||||
await expect(faces).resolves.toHaveLength(1);
|
||||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 25,
|
||||
boundingBoxY1: 25,
|
||||
boundingBoxX2: 100,
|
||||
boundingBoxY2: 75,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// remove edits and verify the stored coordinates map to the original image
|
||||
await ctx.get(AssetEditRepository).replaceAll(asset.id, []);
|
||||
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
|
||||
|
||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 25,
|
||||
boundingBoxX2: 175,
|
||||
boundingBoxY2: 75,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly transform the coordinates when the asset is edited (Rotate + Mirror)', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id });
|
||||
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 200 });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 150 });
|
||||
|
||||
await ctx.newEdits(asset.id, {
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Rotate,
|
||||
parameters: {
|
||||
angle: 90,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AssetEditAction.Mirror,
|
||||
parameters: {
|
||||
axis: MirrorAxis.Horizontal,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const dto: AssetFaceCreateDto = {
|
||||
imageWidth: 200,
|
||||
imageHeight: 150,
|
||||
x: 50,
|
||||
y: 25,
|
||||
width: 100,
|
||||
height: 75,
|
||||
personId: person.id,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
await sut.createFace(auth, dto);
|
||||
|
||||
const faces = sut.getFacesById(auth, { id: asset.id });
|
||||
await expect(faces).resolves.toHaveLength(1);
|
||||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 25,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 100,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// remove edits and verify the stored coordinates map to the original image
|
||||
await ctx.get(AssetEditRepository).replaceAll(asset.id, []);
|
||||
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
|
||||
|
||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 75,
|
||||
boundingBoxY1: 50,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 150,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly transform the coordinates when the asset is edited (Crop + Rotate + Mirror)', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id });
|
||||
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 150 });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
|
||||
|
||||
await ctx.newEdits(asset.id, {
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: {
|
||||
x: 50,
|
||||
y: 25,
|
||||
width: 150,
|
||||
height: 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AssetEditAction.Rotate,
|
||||
parameters: {
|
||||
angle: 270,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AssetEditAction.Mirror,
|
||||
parameters: {
|
||||
axis: MirrorAxis.Horizontal,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const dto: AssetFaceCreateDto = {
|
||||
imageWidth: 150,
|
||||
imageHeight: 150,
|
||||
x: 25,
|
||||
y: 50,
|
||||
width: 75,
|
||||
height: 50,
|
||||
personId: person.id,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
await sut.createFace(auth, dto);
|
||||
|
||||
const faces = sut.getFacesById(auth, { id: asset.id });
|
||||
await expect(faces).resolves.toHaveLength(1);
|
||||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 25,
|
||||
boundingBoxY1: 50,
|
||||
boundingBoxX2: 100,
|
||||
boundingBoxY2: 100,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// remove edits and verify the stored coordinates map to the original image
|
||||
await ctx.get(AssetEditRepository).replaceAll(asset.id, []);
|
||||
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
|
||||
|
||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 75,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 150,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly transform the coordinates with multiple crops in sequence', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id });
|
||||
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
|
||||
|
||||
await ctx.newEdits(asset.id, {
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 150,
|
||||
height: 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: {
|
||||
x: 25,
|
||||
y: 25,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const dto: AssetFaceCreateDto = {
|
||||
imageWidth: 100,
|
||||
imageHeight: 100,
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
personId: person.id,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
await sut.createFace(auth, dto);
|
||||
|
||||
const faces = sut.getFacesById(auth, { id: asset.id });
|
||||
await expect(faces).resolves.toHaveLength(1);
|
||||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 10,
|
||||
boundingBoxY1: 10,
|
||||
boundingBoxX2: 90,
|
||||
boundingBoxY2: 90,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// remove edits and verify the stored coordinates map to the original image
|
||||
await ctx.get(AssetEditRepository).replaceAll(asset.id, []);
|
||||
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
|
||||
|
||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 85,
|
||||
boundingBoxY1: 85,
|
||||
boundingBoxX2: 165,
|
||||
boundingBoxY2: 165,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly transform the coordinates with multiple mirrors in sequence', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { person } = await ctx.newPerson({ ownerId: user.id });
|
||||
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 100 });
|
||||
|
||||
await ctx.newEdits(asset.id, {
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Mirror,
|
||||
parameters: {
|
||||
axis: MirrorAxis.Horizontal,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: AssetEditAction.Mirror,
|
||||
parameters: {
|
||||
axis: MirrorAxis.Vertical,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const dto: AssetFaceCreateDto = {
|
||||
imageWidth: 100,
|
||||
imageHeight: 100,
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 80,
|
||||
personId: person.id,
|
||||
assetId: asset.id,
|
||||
};
|
||||
|
||||
await sut.createFace(auth, dto);
|
||||
|
||||
const faces = sut.getFacesById(auth, { id: asset.id });
|
||||
await expect(faces).resolves.toHaveLength(1);
|
||||
await expect(faces).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 10,
|
||||
boundingBoxY1: 10,
|
||||
boundingBoxX2: 90,
|
||||
boundingBoxY2: 90,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// remove edits and verify the stored coordinates map to the original image
|
||||
await ctx.get(AssetEditRepository).replaceAll(asset.id, []);
|
||||
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
|
||||
|
||||
await expect(facesAfterRemovingEdits).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: 10,
|
||||
boundingBoxY1: 10,
|
||||
boundingBoxX2: 90,
|
||||
boundingBoxY2: 90,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,11 +68,7 @@
|
||||
let currentMemoryAssetFull = $derived.by(async () =>
|
||||
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
|
||||
);
|
||||
let currentTimelineAssets = $derived([
|
||||
...(current?.previousMemory?.assets ?? []),
|
||||
...(current?.memory.assets ?? []),
|
||||
...(current?.nextMemory?.assets ?? []),
|
||||
]);
|
||||
let currentTimelineAssets = $derived(current?.memory.assets || []);
|
||||
|
||||
let isSaved = $derived(current?.memory.isSaved);
|
||||
let viewerHeight = $state(0);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { changePinCode } from '@immich/sdk';
|
||||
import { Button, Heading, modalManager, Text, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let currentPinCode = $state('');
|
||||
let newPinCode = $state('');
|
||||
@@ -37,23 +38,27 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<form autocomplete="off" onsubmit={handleSubmit}>
|
||||
<div class="flex flex-col gap-6 place-items-center place-content-center">
|
||||
<Heading>{$t('change_pin_code')}</Heading>
|
||||
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
|
||||
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
|
||||
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
|
||||
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
|
||||
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
|
||||
</button>
|
||||
</div>
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
<form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
|
||||
<div class="flex flex-col gap-6 place-items-center place-content-center">
|
||||
<Heading>{$t('change_pin_code')}</Heading>
|
||||
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
|
||||
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
|
||||
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
|
||||
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
|
||||
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
|
||||
{$t('clear')}
|
||||
</Button>
|
||||
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
|
||||
{$t('save')}
|
||||
</Button>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
|
||||
{$t('clear')}
|
||||
</Button>
|
||||
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
|
||||
{$t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<OnEvents {onUserPinCodeReset} />
|
||||
|
||||
<section class="my-4 sm:ms-8">
|
||||
<section>
|
||||
{#if hasPinCode}
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
<PinCodeChangeForm />
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="sm:ms-8 flex flex-col gap-6">
|
||||
<div class="ms-8 mt-4 flex flex-col gap-6">
|
||||
<Field label={$t('theme_selection')} description={$t('theme_selection_description')}>
|
||||
<Switch checked={themeManager.theme.system} onCheckedChange={(checked) => themeManager.setSystem(checked)} />
|
||||
</Field>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="sm:ms-8 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('password')} required>
|
||||
<PasswordInput bind:value={password} autocomplete="current-password" />
|
||||
</Field>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk';
|
||||
import { Button, modalManager, Text, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import DeviceCard from './device-card.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -51,39 +50,33 @@
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="sm:ms-8 flex flex-col gap-4">
|
||||
{#if currentSession}
|
||||
<div class="mb-6">
|
||||
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
|
||||
{$t('current_device')}
|
||||
</Text>
|
||||
<DeviceCard session={currentSession} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if otherSessions.length > 0}
|
||||
<div class="mb-6">
|
||||
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
|
||||
{$t('other_devices')}
|
||||
</Text>
|
||||
{#each otherSessions as session, index (session.id)}
|
||||
<DeviceCard {session} onDelete={() => handleDelete(session)} />
|
||||
{#if index !== otherSessions.length - 1}
|
||||
<hr class="my-3" />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}
|
||||
>{$t('log_out_all_devices')}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if currentSession}
|
||||
<div class="mb-6">
|
||||
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
|
||||
{$t('current_device')}
|
||||
</Text>
|
||||
<DeviceCard session={currentSession} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if otherSessions.length > 0}
|
||||
<div class="mb-6">
|
||||
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
|
||||
{$t('other_devices')}
|
||||
</Text>
|
||||
{#each otherSessions as session, index (session.id)}
|
||||
<DeviceCard {session} onDelete={() => handleDelete(session)} />
|
||||
{#if index !== otherSessions.length - 1}
|
||||
<hr class="my-3" />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}>{$t('log_out_all_devices')}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="sm:ms-8 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('archive_size')}
|
||||
|
||||
@@ -67,9 +67,9 @@
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="sm:ms-4 md:ms-8 flex flex-col">
|
||||
<div class="ms-4 mt-4 flex flex-col">
|
||||
<SettingAccordion key="albums" title={$t('albums')} subtitle={$t('albums_feature_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('albums_default_sort_order')} description={$t('albums_default_sort_order_description')}>
|
||||
<Select
|
||||
options={[
|
||||
@@ -83,7 +83,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="folders" title={$t('folders')} subtitle={$t('folders_feature_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={foldersEnabled} />
|
||||
</Field>
|
||||
@@ -97,7 +97,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="memories" title={$t('time_based_memories')} subtitle={$t('photos_from_previous_years')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={memoriesEnabled} />
|
||||
</Field>
|
||||
@@ -109,7 +109,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="people" title={$t('people')} subtitle={$t('people_feature_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={peopleEnabled} />
|
||||
</Field>
|
||||
@@ -123,7 +123,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="rating" title={$t('rating')} subtitle={$t('rating_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={ratingsEnabled} />
|
||||
</Field>
|
||||
@@ -131,7 +131,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="shared-links" title={$t('shared_links')} subtitle={$t('shared_links_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={sharedLinksEnabled} />
|
||||
</Field>
|
||||
@@ -145,7 +145,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="tags" title={$t('tags')} subtitle={$t('tag_feature_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={tagsEnabled} />
|
||||
</Field>
|
||||
@@ -159,7 +159,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="cast" title={$t('cast')} subtitle={$t('cast_description')}>
|
||||
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('gcast_enabled')} description={$t('gcast_enabled_description')}>
|
||||
<Switch bind:checked={gCastEnabled} />
|
||||
</Field>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="sm:ms-8 flex flex-col gap-6">
|
||||
<div class="ms-4 mt-4 flex flex-col gap-6">
|
||||
<Field label={$t('enable')} description={$t('notification_toggle_setting_description')}>
|
||||
<Switch bind:checked={emailNotificationsEnabled} />
|
||||
</Field>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="sm:ms-8 flex justify-end">
|
||||
<div class="flex justify-end">
|
||||
{#if loading}
|
||||
<div class="flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<OnEvents {onApiKeyCreate} {onApiKeyUpdate} {onApiKeyDelete} />
|
||||
|
||||
<section class="my-4">
|
||||
<div class="sm:ms-8 flex flex-col gap-2" in:fade={{ duration: 500 }}>
|
||||
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
|
||||
<div class="mb-2 flex justify-end">
|
||||
<Button leadingIcon={Create.icon} shape="round" size="small" onclick={() => Create.onAction(Create)}>
|
||||
{Create.title}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={preventDefault(bubble('submit'))}>
|
||||
<div class="sm:ms-8 flex flex-col gap-4">
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('user_id')} disabled>
|
||||
<Input bind:value={editedUser.id} />
|
||||
</Field>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div class="sm:ms-8" in:fade={{ duration: 500 }}>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
{#if $isPurchased}
|
||||
<!-- BADGE TOGGLE -->
|
||||
<div class="mb-4">
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
</TableRow>
|
||||
{/snippet}
|
||||
|
||||
<section class="my-4 w-full">
|
||||
<section class="my-6 w-full">
|
||||
<Heading size="tiny">{$t('photos_and_videos')}</Heading>
|
||||
<Table striped spacing="small" class="mt-4" size="small">
|
||||
<TableHeader>
|
||||
|
||||
Reference in New Issue
Block a user