mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fe63b70a6 |
@@ -230,7 +230,7 @@ The default value is `ultrafast`.
|
|||||||
|
|
||||||
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
|
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
|
||||||
|
|
||||||
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`.
|
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`.
|
||||||
|
|
||||||
The default value is `aac`.
|
The default value is `aac`.
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ The default configuration looks like this:
|
|||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
"accel": "disabled",
|
"accel": "disabled",
|
||||||
"accelDecode": false,
|
"accelDecode": false,
|
||||||
"acceptedAudioCodecs": ["aac", "mp3", "opus"],
|
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
|
||||||
"acceptedContainers": ["mov", "ogg", "webm"],
|
"acceptedContainers": ["mov", "ogg", "webm"],
|
||||||
"acceptedVideoCodecs": ["h264"],
|
"acceptedVideoCodecs": ["h264"],
|
||||||
"bframes": -1,
|
"bframes": -1,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||||
import { expect, test } from '@playwright/test';
|
import { Page, expect, test } from '@playwright/test';
|
||||||
import type { Socket } from 'socket.io-client';
|
|
||||||
import { utils } from 'src/utils';
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
|
function imageLocator(page: Page) {
|
||||||
|
return page.getByAltText('Image taken').locator('visible=true');
|
||||||
|
}
|
||||||
test.describe('Photo Viewer', () => {
|
test.describe('Photo Viewer', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
let asset: AssetMediaResponseDto;
|
let asset: AssetMediaResponseDto;
|
||||||
let rawAsset: AssetMediaResponseDto;
|
let rawAsset: AssetMediaResponseDto;
|
||||||
let websocket: Socket;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
utils.initSdk();
|
utils.initSdk();
|
||||||
@@ -15,11 +16,6 @@ test.describe('Photo Viewer', () => {
|
|||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
asset = await utils.createAsset(admin.accessToken);
|
asset = await utils.createAsset(admin.accessToken);
|
||||||
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
|
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
|
||||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(() => {
|
|
||||||
utils.disconnectWebsocket(websocket);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async ({ context, page }) => {
|
test.beforeEach(async ({ context, page }) => {
|
||||||
@@ -30,51 +26,31 @@ test.describe('Photo Viewer', () => {
|
|||||||
|
|
||||||
test('loads original photo when zoomed', async ({ page }) => {
|
test('loads original photo when zoomed', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
const box = await imageLocator(page).boundingBox();
|
||||||
await expect(preview).toHaveAttribute('src', /.+/);
|
expect(box).toBeTruthy();
|
||||||
|
const { x, y, width, height } = box!;
|
||||||
const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
|
await page.mouse.move(x + width / 2, y + height / 2);
|
||||||
|
|
||||||
const { width, height } = page.viewportSize()!;
|
|
||||||
await page.mouse.move(width / 2, height / 2);
|
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
||||||
await originalResponse;
|
|
||||||
|
|
||||||
const original = page.getByTestId('original').filter({ visible: true });
|
|
||||||
await expect(original).toHaveAttribute('src', /original/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||||
await page.goto(`/photos/${rawAsset.id}`);
|
await page.goto(`/photos/${rawAsset.id}`);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
const box = await imageLocator(page).boundingBox();
|
||||||
await expect(preview).toHaveAttribute('src', /.+/);
|
expect(box).toBeTruthy();
|
||||||
|
const { x, y, width, height } = box!;
|
||||||
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
|
await page.mouse.move(x + width / 2, y + height / 2);
|
||||||
|
|
||||||
const { width, height } = page.viewportSize()!;
|
|
||||||
await page.mouse.move(width / 2, height / 2);
|
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
||||||
await fullsizeResponse;
|
|
||||||
|
|
||||||
const original = page.getByTestId('original').filter({ visible: true });
|
|
||||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reloads photo when checksum changes', async ({ page }) => {
|
test('reloads photo when checksum changes', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
const initialSrc = await imageLocator(page).getAttribute('src');
|
||||||
await expect(preview).toHaveAttribute('src', /.+/);
|
|
||||||
const initialSrc = await preview.getAttribute('src');
|
|
||||||
|
|
||||||
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
|
||||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||||
await websocketEvent;
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
||||||
|
|
||||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -284,11 +284,7 @@ const createDefaultOwner = (ownerId: string) => {
|
|||||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||||
* This matches the response from GET /api/assets/:id
|
* This matches the response from GET /api/assets/:id
|
||||||
*/
|
*/
|
||||||
export function toAssetResponseDto(
|
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
|
||||||
asset: MockTimelineAsset,
|
|
||||||
owner?: UserResponseDto,
|
|
||||||
overrides?: Partial<Pick<AssetResponseDto, 'people' | 'unassignedFaces'>>,
|
|
||||||
): AssetResponseDto {
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
// Default owner if not provided
|
// Default owner if not provided
|
||||||
@@ -342,8 +338,8 @@ export function toAssetResponseDto(
|
|||||||
exifInfo,
|
exifInfo,
|
||||||
livePhotoVideoId: asset.livePhotoVideoId,
|
livePhotoVideoId: asset.livePhotoVideoId,
|
||||||
tags: [],
|
tags: [],
|
||||||
people: overrides?.people ?? [],
|
people: [],
|
||||||
unassignedFaces: overrides?.unassignedFaces ?? [],
|
unassignedFaces: [],
|
||||||
stack: asset.stack,
|
stack: asset.stack,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
hasMetadata: true,
|
hasMetadata: true,
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import {
|
|
||||||
type AssetFaceResponseDto,
|
|
||||||
type AssetFaceWithoutPersonResponseDto,
|
|
||||||
type AssetResponseDto,
|
|
||||||
type PersonWithFacesResponseDto,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import { BrowserContext } from '@playwright/test';
|
import { BrowserContext } from '@playwright/test';
|
||||||
import { randomThumbnail } from 'src/ui/generators/timeline';
|
import { randomThumbnail } from 'src/ui/generators/timeline';
|
||||||
|
|
||||||
@@ -131,117 +125,3 @@ export const setupFaceEditorMockApiRoutes = async (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MockFaceSpec = {
|
|
||||||
personId: string;
|
|
||||||
personName: string;
|
|
||||||
faceId: string;
|
|
||||||
boundingBoxX1: number;
|
|
||||||
boundingBoxY1: number;
|
|
||||||
boundingBoxX2: number;
|
|
||||||
boundingBoxY2: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createMockFaceData = (
|
|
||||||
faceSpecs: MockFaceSpec[],
|
|
||||||
imageWidth: number,
|
|
||||||
imageHeight: number,
|
|
||||||
): { people: PersonWithFacesResponseDto[]; unassignedFaces: AssetFaceWithoutPersonResponseDto[] } => {
|
|
||||||
const people: PersonWithFacesResponseDto[] = faceSpecs.map((spec) => ({
|
|
||||||
id: spec.personId,
|
|
||||||
name: spec.personName,
|
|
||||||
birthDate: null,
|
|
||||||
isHidden: false,
|
|
||||||
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
faces: [
|
|
||||||
{
|
|
||||||
id: spec.faceId,
|
|
||||||
imageWidth,
|
|
||||||
imageHeight,
|
|
||||||
boundingBoxX1: spec.boundingBoxX1,
|
|
||||||
boundingBoxY1: spec.boundingBoxY1,
|
|
||||||
boundingBoxX2: spec.boundingBoxX2,
|
|
||||||
boundingBoxY2: spec.boundingBoxY2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { people, unassignedFaces: [] };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setupFaceOverlayMockApiRoutes = async (
|
|
||||||
context: BrowserContext,
|
|
||||||
assetDto: AssetResponseDto,
|
|
||||||
faceSpecs: MockFaceSpec[],
|
|
||||||
) => {
|
|
||||||
const faceResponseMap = new Map<string, AssetFaceResponseDto>();
|
|
||||||
for (const spec of faceSpecs) {
|
|
||||||
faceResponseMap.set(spec.faceId, {
|
|
||||||
id: spec.faceId,
|
|
||||||
imageWidth: assetDto.width ?? 3000,
|
|
||||||
imageHeight: assetDto.height ?? 4000,
|
|
||||||
boundingBoxX1: spec.boundingBoxX1,
|
|
||||||
boundingBoxY1: spec.boundingBoxY1,
|
|
||||||
boundingBoxX2: spec.boundingBoxX2,
|
|
||||||
boundingBoxY2: spec.boundingBoxY2,
|
|
||||||
person: {
|
|
||||||
id: spec.personId,
|
|
||||||
name: spec.personName,
|
|
||||||
birthDate: null,
|
|
||||||
isHidden: false,
|
|
||||||
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.route(`**/api/assets/${assetDto.id}`, async (route, request) => {
|
|
||||||
if (request.method() !== 'GET') {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
json: assetDto,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await context.route(`**/api/faces?id=${assetDto.id}`, async (route, request) => {
|
|
||||||
if (request.method() !== 'GET') {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
json: [...faceResponseMap.values()],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await context.route('**/api/faces/*', async (route, request) => {
|
|
||||||
if (request.method() !== 'DELETE') {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
const url = new URL(request.url());
|
|
||||||
const faceId = url.pathname.split('/').at(-1);
|
|
||||||
if (faceId) {
|
|
||||||
faceResponseMap.delete(faceId);
|
|
||||||
}
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
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),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { faker } from '@faker-js/faker';
|
|
||||||
import type { AssetOcrResponseDto } from '@immich/sdk';
|
|
||||||
import { BrowserContext } from '@playwright/test';
|
|
||||||
|
|
||||||
export type MockOcrBox = {
|
|
||||||
text: string;
|
|
||||||
x1: number;
|
|
||||||
y1: number;
|
|
||||||
x2: number;
|
|
||||||
y2: number;
|
|
||||||
x3: number;
|
|
||||||
y3: number;
|
|
||||||
x4: number;
|
|
||||||
y4: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createMockOcrData = (assetId: string, boxes: MockOcrBox[]): AssetOcrResponseDto[] => {
|
|
||||||
return boxes.map((box) => ({
|
|
||||||
id: faker.string.uuid(),
|
|
||||||
assetId,
|
|
||||||
x1: box.x1,
|
|
||||||
y1: box.y1,
|
|
||||||
x2: box.x2,
|
|
||||||
y2: box.y2,
|
|
||||||
x3: box.x3,
|
|
||||||
y3: box.y3,
|
|
||||||
x4: box.x4,
|
|
||||||
y4: box.y4,
|
|
||||||
boxScore: 0.95,
|
|
||||||
textScore: 0.9,
|
|
||||||
text: box.text,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setupOcrMockApiRoutes = async (
|
|
||||||
context: BrowserContext,
|
|
||||||
ocrDataByAssetId: Map<string, AssetOcrResponseDto[]>,
|
|
||||||
) => {
|
|
||||||
await context.route('**/assets/*/ocr', async (route, request) => {
|
|
||||||
if (request.method() !== 'GET') {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
const url = new URL(request.url());
|
|
||||||
const segments = url.pathname.split('/');
|
|
||||||
const assetIdIndex = segments.indexOf('assets') + 1;
|
|
||||||
const assetId = segments[assetIdIndex];
|
|
||||||
|
|
||||||
const ocrData = ocrDataByAssetId.get(assetId) ?? [];
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
json: ocrData,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -64,9 +64,7 @@ test.describe('broken-asset responsiveness', () => {
|
|||||||
|
|
||||||
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
|
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
|
||||||
await context.route(
|
await context.route(
|
||||||
(url) =>
|
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
|
||||||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
|
|
||||||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
|
|
||||||
async (route) => {
|
async (route) => {
|
||||||
return route.fulfill({ status: 404 });
|
return route.fulfill({ status: 404 });
|
||||||
},
|
},
|
||||||
@@ -75,7 +73,7 @@ test.describe('broken-asset responsiveness', () => {
|
|||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
await page.waitForSelector('#immich-asset-viewer');
|
await page.waitForSelector('#immich-asset-viewer');
|
||||||
|
|
||||||
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
|
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
|
||||||
await expect(viewerBrokenAsset).toBeVisible();
|
await expect(viewerBrokenAsset).toBeVisible();
|
||||||
|
|
||||||
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
|
||||||
import {
|
|
||||||
createMockFaceData,
|
|
||||||
createMockPeople,
|
|
||||||
type MockFaceSpec,
|
|
||||||
setupFaceEditorMockApiRoutes,
|
|
||||||
setupFaceOverlayMockApiRoutes,
|
|
||||||
} from 'src/ui/mock-network/face-editor-network';
|
|
||||||
import { assetViewerUtils } from '../timeline/utils';
|
|
||||||
import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
|
||||||
|
|
||||||
const FACE_SPECS: MockFaceSpec[] = [
|
|
||||||
{
|
|
||||||
personId: 'person-alice',
|
|
||||||
personName: 'Alice Johnson',
|
|
||||||
faceId: 'face-alice',
|
|
||||||
boundingBoxX1: 1000,
|
|
||||||
boundingBoxY1: 500,
|
|
||||||
boundingBoxX2: 1500,
|
|
||||||
boundingBoxY2: 1200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
personId: 'person-bob',
|
|
||||||
personName: 'Bob Smith',
|
|
||||||
faceId: 'face-bob',
|
|
||||||
boundingBoxX1: 2000,
|
|
||||||
boundingBoxY1: 800,
|
|
||||||
boundingBoxX2: 2400,
|
|
||||||
boundingBoxY2: 1600,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
test.describe('face overlay bounding boxes', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(901);
|
|
||||||
const mockPeople = createMockPeople(4);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
const faceData = createMockFaceData(
|
|
||||||
FACE_SPECS,
|
|
||||||
fixture.primaryAssetDto.width ?? 3000,
|
|
||||||
fixture.primaryAssetDto.height ?? 4000,
|
|
||||||
);
|
|
||||||
const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData);
|
|
||||||
await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces, FACE_SPECS);
|
|
||||||
await setupFaceEditorMockApiRoutes(context, mockPeople, { requests: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('face overlay divs render with correct aria labels', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
|
||||||
const bobOverlay = page.getByLabel('Person: Bob Smith');
|
|
||||||
|
|
||||||
await expect(aliceOverlay).toBeVisible();
|
|
||||||
await expect(bobOverlay).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('face overlay shows border on hover', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
|
||||||
await expect(aliceOverlay).toBeVisible();
|
|
||||||
|
|
||||||
await expect(aliceOverlay).not.toHaveClass(/border-solid/);
|
|
||||||
|
|
||||||
await aliceOverlay.hover();
|
|
||||||
await expect(aliceOverlay).toHaveClass(/border-solid/);
|
|
||||||
await expect(aliceOverlay).toHaveClass(/border-white/);
|
|
||||||
await expect(aliceOverlay).toHaveClass(/border-3/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('face name tooltip appears on hover', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
|
||||||
await expect(aliceOverlay).toBeVisible();
|
|
||||||
|
|
||||||
await aliceOverlay.hover();
|
|
||||||
|
|
||||||
const nameTooltip = aliceOverlay.locator('div', { hasText: 'Alice Johnson' });
|
|
||||||
await expect(nameTooltip).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('face overlays hidden in face edit mode', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
|
||||||
await expect(aliceOverlay).toBeVisible();
|
|
||||||
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
await page.getByLabel('Tag people').click();
|
|
||||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
|
||||||
|
|
||||||
await expect(aliceOverlay).toBeHidden();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('zoom and face editor interaction', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(902);
|
|
||||||
const mockPeople = createMockPeople(4);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
const faceData = createMockFaceData(
|
|
||||||
FACE_SPECS,
|
|
||||||
fixture.primaryAssetDto.width ?? 3000,
|
|
||||||
fixture.primaryAssetDto.height ?? 4000,
|
|
||||||
);
|
|
||||||
const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData);
|
|
||||||
await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces, FACE_SPECS);
|
|
||||||
await setupFaceEditorMockApiRoutes(context, mockPeople, { requests: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('zoom is preserved when entering face edit mode', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const { width, height } = page.viewportSize()!;
|
|
||||||
await page.mouse.move(width / 2, height / 2);
|
|
||||||
await page.mouse.wheel(0, -1);
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
const zoomedTransform = await page.locator('[data-viewer-content] img[draggable="false"]').evaluate((element) => {
|
|
||||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
|
||||||
});
|
|
||||||
const isZoomed = zoomedTransform !== 'none' && zoomedTransform !== '';
|
|
||||||
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
await page.getByLabel('Tag people').click();
|
|
||||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
|
||||||
|
|
||||||
await expect(page.locator('#face-editor')).toBeVisible();
|
|
||||||
|
|
||||||
if (isZoomed) {
|
|
||||||
const afterTransform = await page.locator('[data-viewer-content] img[draggable="false"]').evaluate((element) => {
|
|
||||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
|
||||||
});
|
|
||||||
expect(afterTransform).not.toBe('none');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('face removal auto-close', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(903);
|
|
||||||
const singleFaceSpec: MockFaceSpec[] = [
|
|
||||||
{
|
|
||||||
personId: 'person-solo',
|
|
||||||
personName: 'Solo Person',
|
|
||||||
faceId: 'face-solo',
|
|
||||||
boundingBoxX1: 1000,
|
|
||||||
boundingBoxY1: 500,
|
|
||||||
boundingBoxX2: 1500,
|
|
||||||
boundingBoxY2: 1200,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
const faceData = createMockFaceData(
|
|
||||||
singleFaceSpec,
|
|
||||||
fixture.primaryAssetDto.width ?? 3000,
|
|
||||||
fixture.primaryAssetDto.height ?? 4000,
|
|
||||||
);
|
|
||||||
const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData);
|
|
||||||
await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces, singleFaceSpec);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('person side panel closes when last face is removed', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
|
|
||||||
const editPeopleButton = page.locator('#detail-panel').getByLabel('Edit people');
|
|
||||||
await expect(editPeopleButton).toBeVisible();
|
|
||||||
await editPeopleButton.click();
|
|
||||||
|
|
||||||
const personName = page.locator('text=Solo Person');
|
|
||||||
await expect(personName.first()).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
const deleteButton = page.getByLabel('Delete face');
|
|
||||||
await expect(deleteButton).toBeVisible();
|
|
||||||
await deleteButton.click();
|
|
||||||
|
|
||||||
const confirmButton = page.getByRole('button', { name: /confirm/i });
|
|
||||||
await expect(confirmButton).toBeVisible();
|
|
||||||
await confirmButton.click();
|
|
||||||
|
|
||||||
await expect(page.locator('text=Edit faces')).toBeHidden({ timeout: 5000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import type { AssetOcrResponseDto, 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 { createMockOcrData, setupOcrMockApiRoutes } from 'src/ui/mock-network/ocr-network';
|
|
||||||
import { assetViewerUtils } from '../timeline/utils';
|
|
||||||
import { setupAssetViewerFixture } from './utils';
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
|
||||||
|
|
||||||
const PRIMARY_OCR_BOXES = [
|
|
||||||
{ text: 'Hello World', x1: 0.1, y1: 0.1, x2: 0.4, y2: 0.1, x3: 0.4, y3: 0.15, x4: 0.1, y4: 0.15 },
|
|
||||||
{ text: 'Immich Photo', x1: 0.2, y1: 0.3, x2: 0.6, y2: 0.3, x3: 0.6, y3: 0.36, x4: 0.2, y4: 0.36 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SECONDARY_OCR_BOXES = [
|
|
||||||
{ text: 'Second Asset Text', x1: 0.15, y1: 0.2, x2: 0.55, y2: 0.2, x3: 0.55, y3: 0.26, x4: 0.15, y4: 0.26 },
|
|
||||||
];
|
|
||||||
|
|
||||||
test.describe('OCR bounding boxes', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(920);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
|
||||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
|
||||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
|
||||||
]);
|
|
||||||
|
|
||||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('OCR bounding boxes appear when clicking OCR button', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const ocrButton = page.getByLabel('Text recognition');
|
|
||||||
await expect(ocrButton).toBeVisible();
|
|
||||||
await ocrButton.click();
|
|
||||||
|
|
||||||
const ocrBoxes = page.locator('[data-viewer-content] .border-blue-500');
|
|
||||||
await expect(ocrBoxes).toHaveCount(2);
|
|
||||||
|
|
||||||
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
|
|
||||||
await expect(ocrBoxes.nth(1)).toContainText('Immich Photo');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('OCR bounding boxes toggle off on second click', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const ocrButton = page.getByLabel('Text recognition');
|
|
||||||
await ocrButton.click();
|
|
||||||
await expect(page.locator('[data-viewer-content] .border-blue-500').first()).toBeVisible();
|
|
||||||
|
|
||||||
await ocrButton.click();
|
|
||||||
await expect(page.locator('[data-viewer-content] .border-blue-500')).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('OCR with stacked assets', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(921);
|
|
||||||
let mockStack: MockStack;
|
|
||||||
let primaryAssetDto: AssetResponseDto;
|
|
||||||
let secondAssetDto: AssetResponseDto;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
|
||||||
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
|
||||||
secondAssetDto.originalFileName = 'second-ocr-asset.jpg';
|
|
||||||
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
|
||||||
|
|
||||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
|
||||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
|
||||||
[secondAssetDto.id, createMockOcrData(secondAssetDto.id, SECONDARY_OCR_BOXES)],
|
|
||||||
]);
|
|
||||||
|
|
||||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('different OCR boxes shown for different stacked assets', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const ocrButton = page.getByLabel('Text recognition');
|
|
||||||
await expect(ocrButton).toBeVisible();
|
|
||||||
await ocrButton.click();
|
|
||||||
|
|
||||||
const ocrBoxes = page.locator('[data-viewer-content] .border-blue-500');
|
|
||||||
await expect(ocrBoxes).toHaveCount(2);
|
|
||||||
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
|
|
||||||
|
|
||||||
const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
|
|
||||||
await expect(stackThumbnails).toHaveCount(2);
|
|
||||||
await stackThumbnails.nth(1).click();
|
|
||||||
|
|
||||||
// refreshOcr() clears showOverlay when switching assets, so re-enable it
|
|
||||||
await expect(ocrBoxes).toHaveCount(0);
|
|
||||||
await expect(ocrButton).toBeVisible();
|
|
||||||
await ocrButton.click();
|
|
||||||
|
|
||||||
await expect(ocrBoxes).toHaveCount(1);
|
|
||||||
await expect(ocrBoxes.first()).toContainText('Second Asset Text');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('OCR boxes and zoom', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(922);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
|
||||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
|
||||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
|
||||||
]);
|
|
||||||
|
|
||||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('OCR boxes scale with zoom', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const ocrButton = page.getByLabel('Text recognition');
|
|
||||||
await expect(ocrButton).toBeVisible();
|
|
||||||
await ocrButton.click();
|
|
||||||
|
|
||||||
const ocrBox = page.locator('[data-viewer-content] .border-blue-500').first();
|
|
||||||
await expect(ocrBox).toBeVisible();
|
|
||||||
|
|
||||||
const initialBox = await ocrBox.boundingBox();
|
|
||||||
expect(initialBox).toBeTruthy();
|
|
||||||
|
|
||||||
const { width, height } = page.viewportSize()!;
|
|
||||||
await page.mouse.move(width / 2, height / 2);
|
|
||||||
await page.mouse.wheel(0, -3);
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
const zoomedBox = await ocrBox.boundingBox();
|
|
||||||
expect(zoomedBox).toBeTruthy();
|
|
||||||
|
|
||||||
expect(zoomedBox!.width).toBeGreaterThan(initialBox!.width);
|
|
||||||
expect(zoomedBox!.height).toBeGreaterThan(initialBox!.height);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
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 {
|
|
||||||
createMockPeople,
|
|
||||||
FaceCreateCapture,
|
|
||||||
MockPerson,
|
|
||||||
setupFaceEditorMockApiRoutes,
|
|
||||||
} from 'src/ui/mock-network/face-editor-network';
|
|
||||||
import { assetViewerUtils } from '../timeline/utils';
|
|
||||||
import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
|
||||||
test.describe('stack face-tag selection preservation', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(910);
|
|
||||||
let mockStack: MockStack;
|
|
||||||
let primaryAssetDto: AssetResponseDto;
|
|
||||||
let secondAssetDto: AssetResponseDto;
|
|
||||||
let mockPeople: MockPerson[];
|
|
||||||
let faceCreateCapture: FaceCreateCapture;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
|
||||||
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
|
||||||
secondAssetDto.originalFileName = 'second-stacked-asset.jpg';
|
|
||||||
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
|
||||||
mockPeople = createMockPeople(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
faceCreateCapture = { requests: [] };
|
|
||||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
|
||||||
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selected stacked asset is preserved after tagging a face', 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 stackThumbnails = stackSlideshow.locator('[data-asset]');
|
|
||||||
await expect(stackThumbnails).toHaveCount(2);
|
|
||||||
|
|
||||||
await stackThumbnails.nth(1).click();
|
|
||||||
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
await expect(page.locator('#detail-panel')).toContainText('second-stacked-asset.jpg');
|
|
||||||
|
|
||||||
await page.getByLabel('Tag people').click();
|
|
||||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
|
||||||
|
|
||||||
await page.locator('#face-selector').getByText(mockPeople[0].name).click();
|
|
||||||
|
|
||||||
const confirmButton = page.getByRole('button', { name: /confirm/i });
|
|
||||||
await expect(confirmButton).toBeVisible();
|
|
||||||
await confirmButton.click();
|
|
||||||
|
|
||||||
await expect(page.locator('#face-selector')).toBeHidden();
|
|
||||||
|
|
||||||
expect(faceCreateCapture.requests).toHaveLength(1);
|
|
||||||
expect(faceCreateCapture.requests[0].assetId).toBe(secondAssetDto.id);
|
|
||||||
|
|
||||||
await expect(page.locator('#detail-panel')).toContainText('second-stacked-asset.jpg');
|
|
||||||
|
|
||||||
const selectedThumbnail = stackSlideshow.locator(`[data-asset="${secondAssetDto.id}"]`);
|
|
||||||
await expect(selectedThumbnail).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('primary asset stays selected after tagging a face without switching', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
await expect(page.locator('#detail-panel')).toContainText(primaryAssetDto.originalFileName);
|
|
||||||
|
|
||||||
await page.getByLabel('Tag people').click();
|
|
||||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
|
||||||
|
|
||||||
await page.locator('#face-selector').getByText(mockPeople[0].name).click();
|
|
||||||
|
|
||||||
const confirmButton = page.getByRole('button', { name: /confirm/i });
|
|
||||||
await expect(confirmButton).toBeVisible();
|
|
||||||
await confirmButton.click();
|
|
||||||
|
|
||||||
await expect(page.locator('#face-selector')).toBeHidden();
|
|
||||||
|
|
||||||
expect(faceCreateCapture.requests).toHaveLength(1);
|
|
||||||
expect(faceCreateCapture.requests[0].assetId).toBe(primaryAssetDto.id);
|
|
||||||
|
|
||||||
await expect(page.locator('#detail-panel')).toContainText(primaryAssetDto.originalFileName);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -64,6 +64,14 @@ class OrtSession:
|
|||||||
def _providers_default(self) -> list[str]:
|
def _providers_default(self) -> list[str]:
|
||||||
available_providers = set(ort.get_available_providers())
|
available_providers = set(ort.get_available_providers())
|
||||||
log.debug(f"Available ORT providers: {available_providers}")
|
log.debug(f"Available ORT providers: {available_providers}")
|
||||||
|
if (openvino := "OpenVINOExecutionProvider") in available_providers:
|
||||||
|
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
||||||
|
log.debug(f"Available OpenVINO devices: {device_ids}")
|
||||||
|
|
||||||
|
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
|
||||||
|
if not gpu_devices:
|
||||||
|
log.warning("No GPU device found in OpenVINO. Falling back to CPU.")
|
||||||
|
available_providers.remove(openvino)
|
||||||
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
|
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -94,19 +102,12 @@ class OrtSession:
|
|||||||
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
|
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
|
||||||
}
|
}
|
||||||
case "OpenVINOExecutionProvider":
|
case "OpenVINOExecutionProvider":
|
||||||
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
openvino_dir = self.model_path.parent / "openvino"
|
||||||
# Check for available devices, preferring GPU over CPU
|
device = f"GPU.{settings.device_id}"
|
||||||
gpu_devices = [d for d in device_ids if d.startswith("GPU")]
|
|
||||||
if gpu_devices:
|
|
||||||
device_type = f"GPU.{settings.device_id}"
|
|
||||||
log.debug(f"OpenVINO: Using GPU device {device_type}")
|
|
||||||
else:
|
|
||||||
device_type = "CPU"
|
|
||||||
log.debug("OpenVINO: No GPU found, using CPU")
|
|
||||||
options = {
|
options = {
|
||||||
"device_type": device_type,
|
"device_type": device,
|
||||||
"precision": settings.openvino_precision.value,
|
"precision": settings.openvino_precision.value,
|
||||||
"cache_dir": (self.model_path.parent / "openvino").as_posix(),
|
"cache_dir": openvino_dir.as_posix(),
|
||||||
}
|
}
|
||||||
case "CoreMLExecutionProvider":
|
case "CoreMLExecutionProvider":
|
||||||
options = {
|
options = {
|
||||||
@@ -138,14 +139,12 @@ class OrtSession:
|
|||||||
sess_options.enable_cpu_mem_arena = settings.model_arena
|
sess_options.enable_cpu_mem_arena = settings.model_arena
|
||||||
|
|
||||||
# avoid thread contention between models
|
# avoid thread contention between models
|
||||||
# Set inter_op threads
|
|
||||||
if settings.model_inter_op_threads > 0:
|
if settings.model_inter_op_threads > 0:
|
||||||
sess_options.inter_op_num_threads = settings.model_inter_op_threads
|
sess_options.inter_op_num_threads = settings.model_inter_op_threads
|
||||||
# these defaults work well for CPU, but bottleneck GPU
|
# these defaults work well for CPU, but bottleneck GPU
|
||||||
elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
||||||
sess_options.inter_op_num_threads = 1
|
sess_options.inter_op_num_threads = 1
|
||||||
|
|
||||||
# Set intra_op threads
|
|
||||||
if settings.model_intra_op_threads > 0:
|
if settings.model_intra_op_threads > 0:
|
||||||
sess_options.intra_op_num_threads = settings.model_intra_op_threads
|
sess_options.intra_op_num_threads = settings.model_intra_op_threads
|
||||||
elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
||||||
|
|||||||
@@ -204,6 +204,13 @@ class TestOrtSession:
|
|||||||
|
|
||||||
assert session.providers == self.OV_EP
|
assert session.providers == self.OV_EP
|
||||||
|
|
||||||
|
@pytest.mark.ov_device_ids(["CPU"])
|
||||||
|
@pytest.mark.providers(OV_EP)
|
||||||
|
def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], ov_device_ids: list[str]) -> None:
|
||||||
|
session = OrtSession("ViT-B-32__openai")
|
||||||
|
|
||||||
|
assert session.providers == self.CPU_EP
|
||||||
|
|
||||||
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
|
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
|
||||||
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
|
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
|
||||||
session = OrtSession("ViT-B-32__openai")
|
session = OrtSession("ViT-B-32__openai")
|
||||||
@@ -249,8 +256,7 @@ class TestOrtSession:
|
|||||||
{"arena_extend_strategy": "kSameAsRequested"},
|
{"arena_extend_strategy": "kSameAsRequested"},
|
||||||
]
|
]
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
|
def test_sets_provider_options_for_openvino(self) -> None:
|
||||||
def test_sets_provider_options_for_openvino(self, ov_device_ids: list[str]) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
||||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||||
|
|
||||||
@@ -264,8 +270,7 @@ class TestOrtSession:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
|
def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None:
|
||||||
def test_sets_openvino_to_fp16_if_enabled(self, ov_device_ids: list[str], mocker: MockerFixture) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
||||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||||
mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
|
mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
|
||||||
@@ -280,19 +285,6 @@ class TestOrtSession:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["CPU"])
|
|
||||||
def test_sets_provider_options_for_openvino_cpu(self, ov_device_ids: list[str]) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
|
||||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
||||||
|
|
||||||
assert session.provider_options == [
|
|
||||||
{
|
|
||||||
"device_type": "CPU",
|
|
||||||
"precision": "FP32",
|
|
||||||
"cache_dir": "/cache/ViT-B-32__openai/openvino",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_sets_provider_options_for_cuda(self) -> None:
|
def test_sets_provider_options_for_cuda(self) -> None:
|
||||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||||
|
|
||||||
@@ -349,23 +341,6 @@ class TestOrtSession:
|
|||||||
assert session.sess_options.inter_op_num_threads == 1
|
assert session.sess_options.inter_op_num_threads == 1
|
||||||
assert session.sess_options.intra_op_num_threads == 2
|
assert session.sess_options.intra_op_num_threads == 2
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["CPU"])
|
|
||||||
def test_sets_default_sess_options_if_openvino_cpu(self, ov_device_ids: list[str]) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
|
||||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
||||||
|
|
||||||
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
|
|
||||||
assert session.sess_options.inter_op_num_threads == 0
|
|
||||||
assert session.sess_options.intra_op_num_threads == 0
|
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
|
|
||||||
def test_sets_default_sess_options_if_openvino_gpu(self, ov_device_ids: list[str]) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
|
||||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
||||||
|
|
||||||
assert session.sess_options.inter_op_num_threads == 0
|
|
||||||
assert session.sess_options.intra_op_num_threads == 0
|
|
||||||
|
|
||||||
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
|
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
|
||||||
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
|
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import app.alextran.immich.core.ImmichPlugin
|
|||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.ImageHeaderParser
|
import com.bumptech.glide.load.ImageHeaderParser
|
||||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||||
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -82,14 +81,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
}
|
}
|
||||||
if (hasSpecialFormatColumn()) {
|
if (hasSpecialFormatColumn()) {
|
||||||
add(SPECIAL_FORMAT_COLUMN)
|
add(SPECIAL_FORMAT_COLUMN)
|
||||||
} else {
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
// fallback to mimetype and xmp for playback style detection on older Android versions
|
// Fallback: read XMP from MediaStore to detect Motion Photos
|
||||||
// both only needed if special format column is not available
|
// only needed if SPECIAL_FORMAT column isn't available
|
||||||
add(MediaStore.MediaColumns.MIME_TYPE)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
add(MediaStore.MediaColumns.XMP)
|
add(MediaStore.MediaColumns.XMP)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
|
|
||||||
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||||
@@ -135,7 +131,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
||||||
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
||||||
val mimeTypeColumn = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)
|
|
||||||
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
|
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
|
||||||
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
||||||
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
||||||
@@ -182,20 +177,19 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
|
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
|
||||||
|
|
||||||
val playbackStyle = detectPlaybackStyle(
|
val playbackStyle = detectPlaybackStyle(
|
||||||
numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c
|
numericId, rawMediaType, specialFormatColumn, xmpColumn, c
|
||||||
)
|
)
|
||||||
|
|
||||||
val isFlipped = orientation == 90 || orientation == 270
|
|
||||||
val asset = PlatformAsset(
|
val asset = PlatformAsset(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
assetType,
|
assetType,
|
||||||
createdAt,
|
createdAt,
|
||||||
modifiedAt,
|
modifiedAt,
|
||||||
if (isFlipped) height else width,
|
width,
|
||||||
if (isFlipped) width else height,
|
height,
|
||||||
duration,
|
duration,
|
||||||
0L,
|
orientation.toLong(),
|
||||||
isFavorite,
|
isFavorite,
|
||||||
playbackStyle = playbackStyle,
|
playbackStyle = playbackStyle,
|
||||||
)
|
)
|
||||||
@@ -206,14 +200,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects the playback style for an asset using _special_format (SDK Extension 21+)
|
* Detects the playback style for an asset using _special_format (API 33+)
|
||||||
* or XMP / MIME / RIFF header fallbacks.
|
* or XMP / MIME / RIFF header fallbacks (pre-33).
|
||||||
*/
|
*/
|
||||||
@SuppressLint("NewApi")
|
@SuppressLint("NewApi")
|
||||||
private fun detectPlaybackStyle(
|
private fun detectPlaybackStyle(
|
||||||
assetId: Long,
|
assetId: Long,
|
||||||
rawMediaType: Int,
|
rawMediaType: Int,
|
||||||
mimeTypeColumn: Int,
|
|
||||||
specialFormatColumn: Int,
|
specialFormatColumn: Int,
|
||||||
xmpColumn: Int,
|
xmpColumn: Int,
|
||||||
cursor: Cursor
|
cursor: Cursor
|
||||||
@@ -238,55 +231,45 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
return PlatformAssetPlaybackStyle.UNKNOWN
|
return PlatformAssetPlaybackStyle.UNKNOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
val mimeType = if (mimeTypeColumn != -1) cursor.getString(mimeTypeColumn) else null
|
// Pre-API 33 fallback
|
||||||
|
|
||||||
// GIFs are always animated and cannot be motion photos; no I/O needed
|
|
||||||
if (mimeType == "image/gif") {
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
|
||||||
}
|
|
||||||
|
|
||||||
val uri = ContentUris.withAppendedId(
|
val uri = ContentUris.withAppendedId(
|
||||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||||
assetId
|
assetId
|
||||||
)
|
)
|
||||||
|
|
||||||
// Only WebP needs a stream check to distinguish static vs animated;
|
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
|
||||||
// WebP files are not used as motion photos, so skip XMP detection
|
val xmp: String? = if (xmpColumn != -1) {
|
||||||
if (mimeType == "image/webp") {
|
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
val glide = Glide.get(ctx)
|
|
||||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
|
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xmp != null && "Camera:MotionPhoto" in xmp) {
|
||||||
|
return PlatformAssetPlaybackStyle.LIVE_PHOTO
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
|
val glide = Glide.get(ctx)
|
||||||
val type = ImageHeaderParserUtils.getType(
|
val type = ImageHeaderParserUtils.getType(
|
||||||
listOf(DefaultImageHeaderParser()),
|
glide.registry.imageHeaderParsers,
|
||||||
stream,
|
stream,
|
||||||
glide.arrayPool
|
glide.arrayPool
|
||||||
)
|
)
|
||||||
// Also check for GIF just in case MIME type is incorrect; Doesn't hurt performance
|
if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) {
|
||||||
if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP || type == ImageHeaderParser.ImageType.GIF) {
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
|
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
|
||||||
}
|
}
|
||||||
// if mimeType is webp but not animated, its just an image.
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Read XMP from cursor (API 30+)
|
|
||||||
val xmp: String? = if (xmpColumn != -1) {
|
|
||||||
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
|
|
||||||
} else {
|
|
||||||
// if xmp column is not available, we are on API 29 or below
|
|
||||||
// theoretically there were motion photos but the Camera:MotionPhoto xmp tag
|
|
||||||
// was only added in Android 11, so we should not have to worry about parsing XMP on older versions
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xmp != null && "Camera:MotionPhoto" in xmp) {
|
|
||||||
return PlatformAssetPlaybackStyle.LIVE_PHOTO
|
|
||||||
}
|
|
||||||
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE
|
return PlatformAssetPlaybackStyle.IMAGE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import 'package:isar/isar.dart';
|
|||||||
// ignore: import_rule_photo_manager
|
// ignore: import_rule_photo_manager
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
const int targetVersion = 24;
|
const int targetVersion = 23;
|
||||||
|
|
||||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||||
@@ -105,10 +105,6 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
|||||||
await _populateLocalAssetPlaybackStyle(drift);
|
await _populateLocalAssetPlaybackStyle(drift);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (version < 24 && Store.isBetaTimelineEnabled) {
|
|
||||||
await _applyLocalAssetOrientation(drift);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (version < 22 && !Store.isBetaTimelineEnabled) {
|
if (version < 22 && !Store.isBetaTimelineEnabled) {
|
||||||
await Store.put(StoreKey.needBetaMigration, true);
|
await Store.put(StoreKey.needBetaMigration, true);
|
||||||
}
|
}
|
||||||
@@ -440,18 +436,6 @@ Future<void> _populateLocalAssetPlaybackStyle(Drift db) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applyLocalAssetOrientation(Drift db) {
|
|
||||||
final query = db.localAssetEntity.update()
|
|
||||||
..where((filter) => (filter.orientation.equals(90) | (filter.orientation.equals(270))));
|
|
||||||
return query.write(
|
|
||||||
LocalAssetEntityCompanion.custom(
|
|
||||||
width: db.localAssetEntity.height,
|
|
||||||
height: db.localAssetEntity.width,
|
|
||||||
orientation: const Variable(0),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
|
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
|
||||||
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
|
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
|
||||||
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
|
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
|
||||||
|
|||||||
-3
@@ -26,7 +26,6 @@ class AudioCodec {
|
|||||||
static const mp3 = AudioCodec._(r'mp3');
|
static const mp3 = AudioCodec._(r'mp3');
|
||||||
static const aac = AudioCodec._(r'aac');
|
static const aac = AudioCodec._(r'aac');
|
||||||
static const libopus = AudioCodec._(r'libopus');
|
static const libopus = AudioCodec._(r'libopus');
|
||||||
static const opus = AudioCodec._(r'opus');
|
|
||||||
static const pcmS16le = AudioCodec._(r'pcm_s16le');
|
static const pcmS16le = AudioCodec._(r'pcm_s16le');
|
||||||
|
|
||||||
/// List of all possible values in this [enum][AudioCodec].
|
/// List of all possible values in this [enum][AudioCodec].
|
||||||
@@ -34,7 +33,6 @@ class AudioCodec {
|
|||||||
mp3,
|
mp3,
|
||||||
aac,
|
aac,
|
||||||
libopus,
|
libopus,
|
||||||
opus,
|
|
||||||
pcmS16le,
|
pcmS16le,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -77,7 +75,6 @@ class AudioCodecTypeTransformer {
|
|||||||
case r'mp3': return AudioCodec.mp3;
|
case r'mp3': return AudioCodec.mp3;
|
||||||
case r'aac': return AudioCodec.aac;
|
case r'aac': return AudioCodec.aac;
|
||||||
case r'libopus': return AudioCodec.libopus;
|
case r'libopus': return AudioCodec.libopus;
|
||||||
case r'opus': return AudioCodec.opus;
|
|
||||||
case r'pcm_s16le': return AudioCodec.pcmS16le;
|
case r'pcm_s16le': return AudioCodec.pcmS16le;
|
||||||
default:
|
default:
|
||||||
if (!allowNull) {
|
if (!allowNull) {
|
||||||
|
|||||||
@@ -17260,7 +17260,6 @@
|
|||||||
"mp3",
|
"mp3",
|
||||||
"aac",
|
"aac",
|
||||||
"libopus",
|
"libopus",
|
||||||
"opus",
|
|
||||||
"pcm_s16le"
|
"pcm_s16le"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@@ -7324,7 +7324,6 @@ export enum AudioCodec {
|
|||||||
Mp3 = "mp3",
|
Mp3 = "mp3",
|
||||||
Aac = "aac",
|
Aac = "aac",
|
||||||
Libopus = "libopus",
|
Libopus = "libopus",
|
||||||
Opus = "opus",
|
|
||||||
PcmS16Le = "pcm_s16le"
|
PcmS16Le = "pcm_s16le"
|
||||||
}
|
}
|
||||||
export enum VideoContainer {
|
export enum VideoContainer {
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||||||
targetVideoCodec: VideoCodec.H264,
|
targetVideoCodec: VideoCodec.H264,
|
||||||
acceptedVideoCodecs: [VideoCodec.H264],
|
acceptedVideoCodecs: [VideoCodec.H264],
|
||||||
targetAudioCodec: AudioCodec.Aac,
|
targetAudioCodec: AudioCodec.Aac,
|
||||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus],
|
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
|
||||||
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
|
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
|
||||||
targetResolution: '720',
|
targetResolution: '720',
|
||||||
maxBitrate: '0',
|
maxBitrate: '0',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Duration } from 'luxon';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { SemVer } from 'semver';
|
import { SemVer } from 'semver';
|
||||||
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
||||||
|
|
||||||
export const ErrorMessages = {
|
export const ErrorMessages = {
|
||||||
InconsistentMediaLocation:
|
InconsistentMediaLocation:
|
||||||
@@ -201,11 +201,3 @@ export const endpointTags: Record<ApiTag, string> = {
|
|||||||
[ApiTag.Workflows]:
|
[ApiTag.Workflows]:
|
||||||
'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.',
|
'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AUDIO_ENCODER: Record<AudioCodec, string> = {
|
|
||||||
[AudioCodec.Aac]: 'aac',
|
|
||||||
[AudioCodec.Mp3]: 'mp3',
|
|
||||||
[AudioCodec.Libopus]: 'libopus',
|
|
||||||
[AudioCodec.Opus]: 'libopus',
|
|
||||||
[AudioCodec.PcmS16le]: 'pcm_s16le',
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform, Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsInt,
|
IsInt,
|
||||||
@@ -92,16 +92,6 @@ export class SystemConfigFFmpegDto {
|
|||||||
targetAudioCodec!: AudioCodec;
|
targetAudioCodec!: AudioCodec;
|
||||||
|
|
||||||
@ValidateEnum({ enum: AudioCodec, name: 'AudioCodec', each: true, description: 'Accepted audio codecs' })
|
@ValidateEnum({ enum: AudioCodec, name: 'AudioCodec', each: true, description: 'Accepted audio codecs' })
|
||||||
@Transform(({ value }) => {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
const libopusIndex = value.indexOf('libopus');
|
|
||||||
if (libopusIndex !== -1) {
|
|
||||||
value[libopusIndex] = 'opus';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
})
|
|
||||||
acceptedAudioCodecs!: AudioCodec[];
|
acceptedAudioCodecs!: AudioCodec[];
|
||||||
|
|
||||||
@ValidateEnum({ enum: VideoContainer, name: 'VideoContainer', each: true, description: 'Accepted containers' })
|
@ValidateEnum({ enum: VideoContainer, name: 'VideoContainer', each: true, description: 'Accepted containers' })
|
||||||
|
|||||||
+1
-3
@@ -409,9 +409,7 @@ export enum VideoCodec {
|
|||||||
export enum AudioCodec {
|
export enum AudioCodec {
|
||||||
Mp3 = 'mp3',
|
Mp3 = 'mp3',
|
||||||
Aac = 'aac',
|
Aac = 'aac',
|
||||||
/** @deprecated Use `Opus` instead */
|
LibOpus = 'libopus',
|
||||||
Libopus = 'libopus',
|
|
||||||
Opus = 'opus',
|
|
||||||
PcmS16le = 'pcm_s16le',
|
PcmS16le = 'pcm_s16le',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { Kysely, sql } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
await sql`
|
|
||||||
UPDATE system_metadata
|
|
||||||
SET value = jsonb_set(
|
|
||||||
value,
|
|
||||||
'{ffmpeg,acceptedAudioCodecs}',
|
|
||||||
(
|
|
||||||
SELECT jsonb_agg(
|
|
||||||
CASE
|
|
||||||
WHEN elem = 'libopus' THEN 'opus'
|
|
||||||
ELSE elem
|
|
||||||
END
|
|
||||||
)
|
|
||||||
FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WHERE key = 'system-config'
|
|
||||||
AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'libopus';
|
|
||||||
`.execute(db);
|
|
||||||
|
|
||||||
await sql`
|
|
||||||
UPDATE system_metadata
|
|
||||||
SET value = jsonb_set(
|
|
||||||
value,
|
|
||||||
'{ffmpeg,targetAudioCodec}',
|
|
||||||
'"opus"'::jsonb
|
|
||||||
)
|
|
||||||
WHERE key = 'system-config'
|
|
||||||
AND value->'ffmpeg'->>'targetAudioCodec' = 'libopus';
|
|
||||||
`.execute(db);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await sql`
|
|
||||||
UPDATE system_metadata
|
|
||||||
SET value = jsonb_set(
|
|
||||||
value,
|
|
||||||
'{ffmpeg,acceptedAudioCodecs}',
|
|
||||||
(
|
|
||||||
SELECT jsonb_agg(
|
|
||||||
CASE
|
|
||||||
WHEN elem = 'opus' THEN 'libopus'
|
|
||||||
ELSE elem
|
|
||||||
END
|
|
||||||
)
|
|
||||||
FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem
|
|
||||||
)
|
|
||||||
)
|
|
||||||
WHERE key = 'system-config'
|
|
||||||
AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'opus';
|
|
||||||
`.execute(db);
|
|
||||||
|
|
||||||
await sql`
|
|
||||||
UPDATE system_metadata
|
|
||||||
SET value = jsonb_set(
|
|
||||||
value,
|
|
||||||
'{ffmpeg,targetAudioCodec}',
|
|
||||||
'"libopus"'::jsonb
|
|
||||||
)
|
|
||||||
WHERE key = 'system-config'
|
|
||||||
AND value->'ffmpeg'->>'targetAudioCodec' = 'opus';
|
|
||||||
`.execute(db);
|
|
||||||
}
|
|
||||||
@@ -2571,50 +2571,6 @@ describe(MediaService.name, () => {
|
|||||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should skip transcoding for accepted audio codecs with optimal policy if video is fine', () => {
|
|
||||||
const acceptedCodecs = [
|
|
||||||
{ codec: 'aac', probeStub: probeStub.audioStreamAac },
|
|
||||||
{ codec: 'mp3', probeStub: probeStub.audioStreamMp3 },
|
|
||||||
{ codec: 'opus', probeStub: probeStub.audioStreamOpus },
|
|
||||||
];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({
|
|
||||||
ffmpeg: {
|
|
||||||
targetVideoCodec: VideoCodec.Hevc,
|
|
||||||
transcode: TranscodePolicy.Optimal,
|
|
||||||
targetResolution: '1080p',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(acceptedCodecs)('should skip $codec', async ({ probeStub }) => {
|
|
||||||
mocks.media.probe.mockResolvedValue(probeStub);
|
|
||||||
await sut.handleVideoConversion({ id: 'video-id' });
|
|
||||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use libopus audio encoder when target audio is opus', async () => {
|
|
||||||
mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac);
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({
|
|
||||||
ffmpeg: {
|
|
||||||
targetAudioCodec: AudioCodec.Opus,
|
|
||||||
transcode: TranscodePolicy.All,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await sut.handleVideoConversion({ id: 'video-id' });
|
|
||||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
|
||||||
'/original/path.ext',
|
|
||||||
expect.any(String),
|
|
||||||
expect.objectContaining({
|
|
||||||
inputOptions: expect.any(Array),
|
|
||||||
outputOptions: expect.arrayContaining(['-c:a libopus']),
|
|
||||||
twoPass: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fail if hwaccel is enabled for an unsupported codec', async () => {
|
it('should fail if hwaccel is enabled for an unsupported codec', async () => {
|
||||||
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||||||
threads: 0,
|
threads: 0,
|
||||||
preset: 'ultrafast',
|
preset: 'ultrafast',
|
||||||
targetAudioCodec: AudioCodec.Aac,
|
targetAudioCodec: AudioCodec.Aac,
|
||||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus],
|
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
|
||||||
targetResolution: '720',
|
targetResolution: '720',
|
||||||
targetVideoCodec: VideoCodec.H264,
|
targetVideoCodec: VideoCodec.H264,
|
||||||
acceptedVideoCodecs: [VideoCodec.H264],
|
acceptedVideoCodecs: [VideoCodec.H264],
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { AUDIO_ENCODER } from 'src/constants';
|
|
||||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||||
import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum';
|
import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum';
|
||||||
import {
|
import {
|
||||||
@@ -118,7 +117,7 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
|
|
||||||
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
||||||
const videoCodec = [TranscodeTarget.All, TranscodeTarget.Video].includes(target) ? this.getVideoCodec() : 'copy';
|
const videoCodec = [TranscodeTarget.All, TranscodeTarget.Video].includes(target) ? this.getVideoCodec() : 'copy';
|
||||||
const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioEncoder() : 'copy';
|
const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioCodec() : 'copy';
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
`-c:v ${videoCodec}`,
|
`-c:v ${videoCodec}`,
|
||||||
@@ -306,8 +305,8 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
return [options];
|
return [options];
|
||||||
}
|
}
|
||||||
|
|
||||||
getAudioEncoder(): string {
|
getAudioCodec(): string {
|
||||||
return AUDIO_ENCODER[this.config.targetAudioCodec];
|
return this.config.targetAudioCodec;
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideoCodec(): string {
|
getVideoCodec(): string {
|
||||||
|
|||||||
Vendored
-8
@@ -221,14 +221,6 @@ export const probeStub = {
|
|||||||
...probeStubDefault,
|
...probeStubDefault,
|
||||||
audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100 }],
|
audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100 }],
|
||||||
}),
|
}),
|
||||||
audioStreamMp3: Object.freeze<VideoInfo>({
|
|
||||||
...probeStubDefault,
|
|
||||||
audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100 }],
|
|
||||||
}),
|
|
||||||
audioStreamOpus: Object.freeze<VideoInfo>({
|
|
||||||
...probeStubDefault,
|
|
||||||
audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100 }],
|
|
||||||
}),
|
|
||||||
audioStreamUnknown: Object.freeze<VideoInfo>({
|
audioStreamUnknown: Object.freeze<VideoInfo>({
|
||||||
...probeStubDefault,
|
...probeStubDefault,
|
||||||
audioStreams: [
|
audioStreams: [
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
|
||||||
|
|
||||||
export function loadImage(src: string, onLoad: () => void, onError: () => void, onStart?: () => void) {
|
|
||||||
let destroyed = false;
|
|
||||||
|
|
||||||
const handleLoad = () => !destroyed && onLoad();
|
|
||||||
const handleError = () => !destroyed && onError();
|
|
||||||
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.addEventListener('load', handleLoad);
|
|
||||||
img.addEventListener('error', handleError);
|
|
||||||
|
|
||||||
onStart?.();
|
|
||||||
img.src = src;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
destroyed = true;
|
|
||||||
img.removeEventListener('load', handleLoad);
|
|
||||||
img.removeEventListener('error', handleError);
|
|
||||||
cancelImageUrl(src);
|
|
||||||
img.remove();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LoadImageFunction = typeof loadImage;
|
|
||||||
@@ -1,42 +1,35 @@
|
|||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { createZoomImageWheel } from '@zoom-image/core';
|
import { createZoomImageWheel } from '@zoom-image/core';
|
||||||
|
|
||||||
export const zoomImageAction = (
|
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
||||||
node: HTMLElement,
|
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
|
||||||
options?: { disablePointer?: boolean; zoomTarget?: HTMLElement },
|
|
||||||
) => {
|
|
||||||
const zoomInstance = createZoomImageWheel(node, {
|
|
||||||
maxZoom: 10,
|
|
||||||
initialState: assetViewerManager.zoomState,
|
|
||||||
zoomTarget: options?.zoomTarget,
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubscribes = [
|
const unsubscribes = [
|
||||||
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
||||||
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
||||||
];
|
];
|
||||||
|
|
||||||
const stopPointerIfDisabled = (event: Event) => {
|
const onInteractionStart = (event: Event) => {
|
||||||
if (options?.disablePointer) {
|
if (options?.disabled) {
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
}
|
}
|
||||||
|
assetViewerManager.cancelZoomAnimation();
|
||||||
};
|
};
|
||||||
|
|
||||||
node.addEventListener('pointerdown', stopPointerIfDisabled, { capture: true });
|
node.addEventListener('wheel', onInteractionStart, { capture: true });
|
||||||
|
node.addEventListener('pointerdown', onInteractionStart, { capture: true });
|
||||||
|
|
||||||
node.style.overflow = 'visible';
|
node.style.overflow = 'visible';
|
||||||
return {
|
return {
|
||||||
update(newOptions?: { disablePointer?: boolean; zoomTarget?: HTMLElement }) {
|
update(newOptions?: { disabled?: boolean }) {
|
||||||
options = newOptions;
|
options = newOptions;
|
||||||
if (newOptions?.zoomTarget !== undefined) {
|
|
||||||
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
for (const unsubscribe of unsubscribes) {
|
for (const unsubscribe of unsubscribes) {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
}
|
}
|
||||||
node.removeEventListener('pointerdown', stopPointerIfDisabled, { capture: true });
|
node.removeEventListener('wheel', onInteractionStart, { capture: true });
|
||||||
|
node.removeEventListener('pointerdown', onInteractionStart, { capture: true });
|
||||||
zoomInstance.cleanup();
|
zoomInstance.cleanup();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { thumbhash } from '$lib/actions/thumbhash';
|
|
||||||
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
|
||||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
|
||||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
|
||||||
import ImageLayer from '$lib/components/ImageLayer.svelte';
|
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
|
||||||
import { getAssetUrls } from '$lib/utils';
|
|
||||||
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
|
||||||
import { scaleToCover, scaleToFit } from '$lib/utils/container-utils';
|
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
|
||||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
|
||||||
import { untrack, type Snippet } from 'svelte';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
asset: AssetResponseDto;
|
|
||||||
sharedLink?: SharedLinkResponseDto;
|
|
||||||
objectFit?: 'contain' | 'cover';
|
|
||||||
container: {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
onUrlChange?: (url: string) => void;
|
|
||||||
onImageReady?: () => void;
|
|
||||||
onError?: () => void;
|
|
||||||
ref?: HTMLDivElement;
|
|
||||||
imgRef?: HTMLImageElement;
|
|
||||||
backdrop?: Snippet;
|
|
||||||
overlays?: Snippet;
|
|
||||||
};
|
|
||||||
|
|
||||||
let {
|
|
||||||
ref = $bindable(),
|
|
||||||
// eslint-disable-next-line no-useless-assignment
|
|
||||||
imgRef = $bindable(),
|
|
||||||
asset,
|
|
||||||
sharedLink,
|
|
||||||
objectFit = 'contain',
|
|
||||||
container,
|
|
||||||
onUrlChange,
|
|
||||||
onImageReady,
|
|
||||||
onError,
|
|
||||||
backdrop,
|
|
||||||
overlays,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const afterThumbnail = (loader: AdaptiveImageLoader) => {
|
|
||||||
if (assetViewerManager.zoom > 1) {
|
|
||||||
loader.trigger('original');
|
|
||||||
} else {
|
|
||||||
loader.trigger('preview');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildQualityList = () => {
|
|
||||||
const assetUrls = getAssetUrls(asset, sharedLink);
|
|
||||||
const qualityList: QualityList = [
|
|
||||||
{
|
|
||||||
quality: 'thumbnail',
|
|
||||||
url: assetUrls.thumbnail,
|
|
||||||
onAfterLoad: afterThumbnail,
|
|
||||||
onAfterError: afterThumbnail,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
quality: 'preview',
|
|
||||||
url: assetUrls.preview,
|
|
||||||
onAfterError: (loader) => loader.trigger('original'),
|
|
||||||
},
|
|
||||||
{ quality: 'original', url: assetUrls.original },
|
|
||||||
];
|
|
||||||
return qualityList;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loaderKey = $derived(`${asset.id}:${asset.thumbhash}:${sharedLink?.id}`);
|
|
||||||
|
|
||||||
const adaptiveImageLoader = $derived.by(() => {
|
|
||||||
void loaderKey;
|
|
||||||
|
|
||||||
return untrack(
|
|
||||||
() =>
|
|
||||||
new AdaptiveImageLoader(buildQualityList(), {
|
|
||||||
onImageReady,
|
|
||||||
onError,
|
|
||||||
onUrlChange,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect.pre(() => {
|
|
||||||
const loader = adaptiveImageLoader;
|
|
||||||
untrack(() => assetViewerManager.resetZoomState());
|
|
||||||
return () => loader.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
const imageDimensions = $derived.by(() => {
|
|
||||||
const { width, height } = asset;
|
|
||||||
if (width && width > 0 && height && height > 0) {
|
|
||||||
return { width, height };
|
|
||||||
}
|
|
||||||
return { width: 1, height: 1 };
|
|
||||||
});
|
|
||||||
|
|
||||||
const { width, height, left, top } = $derived.by(() => {
|
|
||||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
|
||||||
const { width, height } = scaleFn(imageDimensions, container);
|
|
||||||
return {
|
|
||||||
width: width + 'px',
|
|
||||||
height: height + 'px',
|
|
||||||
left: (container.width - width) / 2 + 'px',
|
|
||||||
top: (container.height - height) / 2 + 'px',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const { status } = $derived(adaptiveImageLoader);
|
|
||||||
const alt = $derived(status.urls.preview ? $getAltText(toTimelineAsset(asset)) : '');
|
|
||||||
|
|
||||||
const show = $derived.by(() => {
|
|
||||||
const { quality, started, hasError, urls } = status;
|
|
||||||
return {
|
|
||||||
alphaBackground: !hasError && started,
|
|
||||||
spinner: !asset.thumbhash && !started,
|
|
||||||
brokenAsset: hasError,
|
|
||||||
thumbhash: quality.thumbnail !== 'success' && quality.preview !== 'success' && quality.original !== 'success',
|
|
||||||
thumbnail: quality.thumbnail !== 'error' && quality.preview !== 'success' && quality.original !== 'success',
|
|
||||||
preview: quality.preview !== 'error' && quality.original !== 'success',
|
|
||||||
original: quality.original !== 'error' && urls.original !== undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
assetViewerManager.imageLoaderStatus = status;
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (assetViewerManager.zoom > 1 && status.quality.original !== 'success') {
|
|
||||||
untrack(() => void adaptiveImageLoader.trigger('original'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let thumbnailElement = $state<HTMLImageElement>();
|
|
||||||
let previewElement = $state<HTMLImageElement>();
|
|
||||||
let originalElement = $state<HTMLImageElement>();
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const quality = status.quality;
|
|
||||||
imgRef =
|
|
||||||
(quality.original === 'success' ? originalElement : undefined) ??
|
|
||||||
(quality.preview === 'success' ? previewElement : undefined) ??
|
|
||||||
(quality.thumbnail === 'success' ? thumbnailElement : undefined);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
|
||||||
{@render backdrop?.()}
|
|
||||||
|
|
||||||
<div class="absolute inset-0" style:left style:top style:width style:height>
|
|
||||||
{#if show.alphaBackground}
|
|
||||||
<AlphaBackground />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if show.thumbhash}
|
|
||||||
{#if asset.thumbhash}
|
|
||||||
<!-- Thumbhash / spinner layer -->
|
|
||||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
|
|
||||||
{:else if show.spinner}
|
|
||||||
<DelayedLoadingSpinner />
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if show.thumbnail}
|
|
||||||
<ImageLayer
|
|
||||||
{adaptiveImageLoader}
|
|
||||||
{width}
|
|
||||||
{height}
|
|
||||||
quality="thumbnail"
|
|
||||||
src={status.urls.thumbnail}
|
|
||||||
alt=""
|
|
||||||
role="presentation"
|
|
||||||
bind:ref={thumbnailElement}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if show.brokenAsset}
|
|
||||||
<BrokenAsset class="text-xl h-full w-full absolute" />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if show.preview}
|
|
||||||
<ImageLayer
|
|
||||||
{adaptiveImageLoader}
|
|
||||||
{alt}
|
|
||||||
{width}
|
|
||||||
{height}
|
|
||||||
{overlays}
|
|
||||||
quality="preview"
|
|
||||||
src={status.urls.preview}
|
|
||||||
bind:ref={previewElement}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if show.original}
|
|
||||||
<ImageLayer
|
|
||||||
{adaptiveImageLoader}
|
|
||||||
{alt}
|
|
||||||
{width}
|
|
||||||
{height}
|
|
||||||
{overlays}
|
|
||||||
quality="original"
|
|
||||||
src={status.urls.original}
|
|
||||||
bind:ref={originalElement}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ClassValue } from 'svelte/elements';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
class?: ClassValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { class: className = '' }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="absolute h-full w-full bg-gray-300 dark:bg-gray-700 {className}"></div>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="delayed-spinner absolute flex h-full items-center justify-center">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes delayedVisibility {
|
|
||||||
to {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.delayed-spinner {
|
|
||||||
visibility: hidden;
|
|
||||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isFirefox } from '$lib/utils/asset-utils';
|
|
||||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||||
import { onDestroy, untrack } from 'svelte';
|
import { onDestroy, untrack } from 'svelte';
|
||||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
let { src, onStart, onLoad, onError, ref = $bindable(), ...rest }: Props = $props();
|
let { src, onStart, onLoad, onError, ref = $bindable(), ...rest }: Props = $props();
|
||||||
|
|
||||||
let capturedSource: string | undefined = $state();
|
let capturedSource: string | undefined = $state();
|
||||||
let loaded = $state(false);
|
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -34,25 +32,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const completeLoad = () => {
|
|
||||||
if (destroyed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loaded = true;
|
|
||||||
onLoad?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
if (destroyed || !src) {
|
if (destroyed || !src) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
onLoad?.();
|
||||||
if (isFirefox && ref) {
|
|
||||||
ref.decode().then(completeLoad, completeLoad);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
completeLoad();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
@@ -65,13 +49,6 @@
|
|||||||
|
|
||||||
{#if capturedSource}
|
{#if capturedSource}
|
||||||
{#key capturedSource}
|
{#key capturedSource}
|
||||||
<img
|
<img bind:this={ref} src={capturedSource} {...rest} onload={handleLoad} onerror={handleError} />
|
||||||
bind:this={ref}
|
|
||||||
src={capturedSource}
|
|
||||||
{...rest}
|
|
||||||
style:visibility={isFirefox && !loaded ? 'hidden' : undefined}
|
|
||||||
onload={handleLoad}
|
|
||||||
onerror={handleError}
|
|
||||||
/>
|
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Image from '$lib/components/Image.svelte';
|
|
||||||
import type { AdaptiveImageLoader, ImageQuality } from '$lib/utils/adaptive-image-loader.svelte';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
adaptiveImageLoader: AdaptiveImageLoader;
|
|
||||||
quality: ImageQuality;
|
|
||||||
src: string | undefined;
|
|
||||||
alt?: string;
|
|
||||||
role?: string;
|
|
||||||
ref?: HTMLImageElement;
|
|
||||||
width: string;
|
|
||||||
height: string;
|
|
||||||
overlays?: Snippet;
|
|
||||||
};
|
|
||||||
|
|
||||||
let {
|
|
||||||
adaptiveImageLoader,
|
|
||||||
quality,
|
|
||||||
src,
|
|
||||||
alt = '',
|
|
||||||
role,
|
|
||||||
ref = $bindable(),
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
overlays,
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#key adaptiveImageLoader}
|
|
||||||
<div class="absolute top-0" style:width style:height>
|
|
||||||
<Image
|
|
||||||
{src}
|
|
||||||
onStart={() => adaptiveImageLoader.onStart(quality)}
|
|
||||||
onLoad={() => adaptiveImageLoader.onLoad(quality)}
|
|
||||||
onError={() => adaptiveImageLoader.onError(quality)}
|
|
||||||
bind:ref
|
|
||||||
class="h-full w-full bg-transparent"
|
|
||||||
{alt}
|
|
||||||
{role}
|
|
||||||
draggable={false}
|
|
||||||
data-testid={quality}
|
|
||||||
/>
|
|
||||||
{@render overlays?.()}
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ClassValue } from 'svelte/elements';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
class?: ClassValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { class: className }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="delayed inline-flex items-center gap-1 {className}">
|
|
||||||
{#each [0, 1, 2] as i (i)}
|
|
||||||
<span class="dot block size-1.5 rounded-full bg-white shadow-[0_0_3px_rgba(0,0,0,0.6)]" style:--delay="{i * 0.25}s"
|
|
||||||
></span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.delayed {
|
|
||||||
visibility: hidden;
|
|
||||||
animation: delayed-visibility 0s linear 0.4s forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes delayed-visibility {
|
|
||||||
to {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
animation: dot-stream 1.6s var(--delay, 0s) ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dot-stream {
|
|
||||||
0%,
|
|
||||||
80%,
|
|
||||||
100% {
|
|
||||||
opacity: 0.3;
|
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
options={[
|
options={[
|
||||||
{ value: AudioCodec.Aac, text: 'AAC' },
|
{ value: AudioCodec.Aac, text: 'AAC' },
|
||||||
{ value: AudioCodec.Mp3, text: 'MP3' },
|
{ value: AudioCodec.Mp3, text: 'MP3' },
|
||||||
{ value: AudioCodec.Opus, text: 'Opus' },
|
{ value: AudioCodec.Libopus, text: 'Opus' },
|
||||||
{ value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' },
|
{ value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' },
|
||||||
]}
|
]}
|
||||||
isEdited={!isEqual(
|
isEdited={!isEqual(
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
options={[
|
options={[
|
||||||
{ value: AudioCodec.Aac, text: 'aac' },
|
{ value: AudioCodec.Aac, text: 'aac' },
|
||||||
{ value: AudioCodec.Mp3, text: 'mp3' },
|
{ value: AudioCodec.Mp3, text: 'mp3' },
|
||||||
{ value: AudioCodec.Opus, text: 'opus' },
|
{ value: AudioCodec.Libopus, text: 'opus' },
|
||||||
]}
|
]}
|
||||||
name="acodec"
|
name="acodec"
|
||||||
isEdited={configToEdit.ffmpeg.targetAudioCodec !== config.ffmpeg.targetAudioCodec}
|
isEdited={configToEdit.ffmpeg.targetAudioCodec !== config.ffmpeg.targetAudioCodec}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { loadImage } from '$lib/actions/image-loader.svelte';
|
|
||||||
import { getAssetUrls } from '$lib/utils';
|
|
||||||
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
|
||||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
|
||||||
|
|
||||||
type AssetCursor = {
|
|
||||||
current: AssetResponseDto;
|
|
||||||
nextAsset?: AssetResponseDto;
|
|
||||||
previousAsset?: AssetResponseDto;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class PreloadManager {
|
|
||||||
private nextPreloader: AdaptiveImageLoader | undefined;
|
|
||||||
private previousPreloader: AdaptiveImageLoader | undefined;
|
|
||||||
|
|
||||||
private startPreloader(
|
|
||||||
asset: AssetResponseDto | undefined,
|
|
||||||
sharedlink: SharedLinkResponseDto | undefined,
|
|
||||||
): AdaptiveImageLoader | undefined {
|
|
||||||
if (!asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const urls = getAssetUrls(asset, sharedlink);
|
|
||||||
const afterThumbnail = (loader: AdaptiveImageLoader) => loader.trigger('preview');
|
|
||||||
const qualityList: QualityList = [
|
|
||||||
{
|
|
||||||
quality: 'thumbnail',
|
|
||||||
url: urls.thumbnail,
|
|
||||||
onAfterLoad: afterThumbnail,
|
|
||||||
onAfterError: afterThumbnail,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
quality: 'preview',
|
|
||||||
url: urls.preview,
|
|
||||||
onAfterError: (loader) => loader.trigger('original'),
|
|
||||||
},
|
|
||||||
{ quality: 'original', url: urls.original },
|
|
||||||
];
|
|
||||||
const loader = new AdaptiveImageLoader(qualityList, undefined, loadImage);
|
|
||||||
loader.start();
|
|
||||||
return loader;
|
|
||||||
}
|
|
||||||
|
|
||||||
private destroyPreviousPreloader() {
|
|
||||||
this.previousPreloader?.destroy();
|
|
||||||
this.previousPreloader = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private destroyNextPreloader() {
|
|
||||||
this.nextPreloader?.destroy();
|
|
||||||
this.nextPreloader = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelBeforeNavigation(direction: 'previous' | 'next') {
|
|
||||||
switch (direction) {
|
|
||||||
case 'next': {
|
|
||||||
this.destroyPreviousPreloader();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'previous': {
|
|
||||||
this.destroyNextPreloader();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAfterNavigation(oldCursor: AssetCursor, newCursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) {
|
|
||||||
const movedForward = newCursor.current.id === oldCursor.nextAsset?.id;
|
|
||||||
const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id;
|
|
||||||
|
|
||||||
if (!movedBackward) {
|
|
||||||
this.destroyPreviousPreloader();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!movedForward) {
|
|
||||||
this.destroyNextPreloader();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (movedForward) {
|
|
||||||
this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink);
|
|
||||||
} else if (movedBackward) {
|
|
||||||
this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink);
|
|
||||||
} else {
|
|
||||||
this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink);
|
|
||||||
this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initializePreloads(cursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) {
|
|
||||||
if (cursor.nextAsset) {
|
|
||||||
this.nextPreloader = this.startPreloader(cursor.nextAsset, sharedlink);
|
|
||||||
}
|
|
||||||
if (cursor.previousAsset) {
|
|
||||||
this.previousPreloader = this.startPreloader(cursor.previousAsset, sharedlink);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.destroyNextPreloader();
|
|
||||||
this.destroyPreviousPreloader();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const preloadManager = new PreloadManager();
|
|
||||||
@@ -34,9 +34,7 @@
|
|||||||
type PersonResponseDto,
|
type PersonResponseDto,
|
||||||
type StackResponseDto,
|
type StackResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui';
|
import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
|
||||||
import LoadingDots from '$lib/components/LoadingDots.svelte';
|
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
|
||||||
import {
|
import {
|
||||||
mdiArrowLeft,
|
mdiArrowLeft,
|
||||||
mdiArrowRight,
|
mdiArrowRight,
|
||||||
@@ -106,16 +104,7 @@
|
|||||||
<ActionButton action={Close} />
|
<ActionButton action={Close} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
|
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
|
||||||
{#if assetViewerManager.isImageLoading}
|
|
||||||
<Tooltip text={$t('loading')}>
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<div {...props} role="status" aria-label={$t('loading')}>
|
|
||||||
<LoadingDots class="me-1" />
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</Tooltip>
|
|
||||||
{/if}
|
|
||||||
<ActionButton action={Cast} />
|
<ActionButton action={Cast} />
|
||||||
<ActionButton action={Actions.Share} />
|
<ActionButton action={Actions.Share} />
|
||||||
<ActionButton action={Actions.Offline} />
|
<ActionButton action={Actions.Offline} />
|
||||||
|
|||||||
@@ -5,17 +5,15 @@
|
|||||||
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
||||||
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
||||||
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
|
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
|
||||||
import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte';
|
|
||||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||||
import { getAssetActions } from '$lib/services/asset.service';
|
import { getAssetActions } from '$lib/services/asset.service';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
|
||||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
@@ -38,7 +36,6 @@
|
|||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { CommandPaletteDefaultProvider } from '@immich/ui';
|
import { CommandPaletteDefaultProvider } from '@immich/ui';
|
||||||
import { onDestroy, onMount, untrack } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||||
@@ -95,20 +92,20 @@
|
|||||||
stopProgress: stopSlideshowProgress,
|
stopProgress: stopSlideshowProgress,
|
||||||
slideshowNavigation,
|
slideshowNavigation,
|
||||||
slideshowState,
|
slideshowState,
|
||||||
|
slideshowTransition,
|
||||||
slideshowRepeat,
|
slideshowRepeat,
|
||||||
} = slideshowStore;
|
} = slideshowStore;
|
||||||
const stackThumbnailSize = 60;
|
const stackThumbnailSize = 60;
|
||||||
const stackSelectedThumbnailSize = 65;
|
const stackSelectedThumbnailSize = 65;
|
||||||
|
|
||||||
let stack: StackResponseDto | undefined = $state();
|
const asset = $derived(cursor.current);
|
||||||
let selectedStackAsset: AssetResponseDto | undefined = $state();
|
|
||||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
|
||||||
|
|
||||||
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
|
|
||||||
const nextAsset = $derived(cursor.nextAsset);
|
const nextAsset = $derived(cursor.nextAsset);
|
||||||
const previousAsset = $derived(cursor.previousAsset);
|
const previousAsset = $derived(cursor.previousAsset);
|
||||||
let sharedLink = getSharedLink();
|
let sharedLink = getSharedLink();
|
||||||
|
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||||
let fullscreenElement = $state<Element>();
|
let fullscreenElement = $state<Element>();
|
||||||
|
let unsubscribes: (() => void)[] = [];
|
||||||
|
let stack: StackResponseDto | null = $state(null);
|
||||||
|
|
||||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||||
let slideshowStartAssetId = $state<string>();
|
let slideshowStartAssetId = $state<string>();
|
||||||
@@ -117,43 +114,38 @@
|
|||||||
playOriginalVideo = value;
|
playOriginalVideo = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectStackedAsset = async (id: string) => {
|
|
||||||
selectedStackAsset = await assetCacheManager.getAsset({ id });
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshStack = async () => {
|
const refreshStack = async () => {
|
||||||
if (authManager.isSharedLink || !withStacked) {
|
if (authManager.isSharedLink) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cursor.current.stack) {
|
if (asset.stack) {
|
||||||
stack = undefined;
|
stack = await getStack({ id: asset.stack.id });
|
||||||
selectedStackAsset = undefined;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stack = await getStack({ id: cursor.current.stack.id });
|
if (!stack?.assets.some(({ id }) => id === asset.id)) {
|
||||||
const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
|
stack = null;
|
||||||
if (primaryAsset) {
|
|
||||||
await selectStackedAsset(primaryAsset.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
untrack(() => {
|
||||||
|
imageManager.preload(stack?.assets[1]);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFavorite = async () => {
|
const handleFavorite = async () => {
|
||||||
if (!album || !album.isActivityEnabled) {
|
if (album && album.isActivityEnabled) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await activityManager.toggleLike();
|
await activityManager.toggleLike();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
syncAssetViewerOpenClass(true);
|
syncAssetViewerOpenClass(true);
|
||||||
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
unsubscribes.push(
|
||||||
|
slideshowState.subscribe((value) => {
|
||||||
if (value === SlideshowState.PlaySlideshow) {
|
if (value === SlideshowState.PlaySlideshow) {
|
||||||
slideshowHistory.reset();
|
slideshowHistory.reset();
|
||||||
slideshowHistory.queue(toTimelineAsset(asset));
|
slideshowHistory.queue(toTimelineAsset(asset));
|
||||||
@@ -161,53 +153,42 @@
|
|||||||
} else if (value === SlideshowState.StopSlideshow) {
|
} else if (value === SlideshowState.StopSlideshow) {
|
||||||
handlePromiseError(handleStopSlideshow());
|
handlePromiseError(handleStopSlideshow());
|
||||||
}
|
}
|
||||||
});
|
}),
|
||||||
|
slideshowNavigation.subscribe((value) => {
|
||||||
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
|
|
||||||
if (value === SlideshowNavigation.Shuffle) {
|
if (value === SlideshowNavigation.Shuffle) {
|
||||||
slideshowHistory.reset();
|
slideshowHistory.reset();
|
||||||
slideshowHistory.queue(toTimelineAsset(asset));
|
slideshowHistory.queue(toTimelineAsset(asset));
|
||||||
}
|
}
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
return () => {
|
|
||||||
slideshowStateUnsubscribe();
|
|
||||||
slideshowNavigationUnsubscribe();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
for (const unsubscribe of unsubscribes) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
activityManager.reset();
|
activityManager.reset();
|
||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
syncAssetViewerOpenClass(false);
|
syncAssetViewerOpenClass(false);
|
||||||
preloadManager.destroy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeViewer = () => {
|
const closeViewer = () => {
|
||||||
onClose?.(asset);
|
onClose?.(asset);
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshPreservingSelection = async () => {
|
|
||||||
const id = asset.id;
|
|
||||||
assetCacheManager.invalidateAsset(id);
|
|
||||||
if (selectedStackAsset) {
|
|
||||||
await selectStackedAsset(id);
|
|
||||||
} else {
|
|
||||||
const asset = await assetCacheManager.getAsset({ id });
|
|
||||||
assetViewingStore.setAsset(asset);
|
|
||||||
}
|
|
||||||
onAssetChange?.(asset);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeEditor = async () => {
|
const closeEditor = async () => {
|
||||||
if (editManager.hasAppliedEdits) {
|
if (editManager.hasAppliedEdits) {
|
||||||
await refreshPreservingSelection();
|
const refreshedAsset = await getAssetInfo({ id: asset.id });
|
||||||
|
onAssetChange?.(refreshedAsset);
|
||||||
|
assetViewingStore.setAsset(refreshedAsset);
|
||||||
}
|
}
|
||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
const tracker = new InvocationTracker();
|
const tracker = new InvocationTracker();
|
||||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
|
||||||
|
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
||||||
if (!order) {
|
if (!order) {
|
||||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||||
@@ -216,19 +197,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
preloadManager.cancelBeforeNavigation(order);
|
e?.stopPropagation();
|
||||||
|
imageManager.cancel(asset);
|
||||||
if (tracker.isActive()) {
|
if (tracker.isActive()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void tracker.invoke(async () => {
|
void tracker.invoke(async () => {
|
||||||
const isShuffle =
|
|
||||||
$slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle;
|
|
||||||
|
|
||||||
let hasNext: boolean;
|
let hasNext: boolean;
|
||||||
|
|
||||||
if (isShuffle) {
|
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
||||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||||
if (!hasNext) {
|
if (!hasNext) {
|
||||||
const asset = await onRandom?.();
|
const asset = await onRandom?.();
|
||||||
@@ -242,22 +220,17 @@
|
|||||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasNext) {
|
if (hasNext) {
|
||||||
$restartSlideshowProgress = true;
|
$restartSlideshowProgress = true;
|
||||||
return;
|
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
||||||
}
|
// Loop back to starting asset
|
||||||
|
|
||||||
if ($slideshowRepeat && slideshowStartAssetId) {
|
|
||||||
await setAssetId(slideshowStartAssetId);
|
await setAssetId(slideshowStartAssetId);
|
||||||
$restartSlideshowProgress = true;
|
$restartSlideshowProgress = true;
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
await handleStopSlideshow();
|
await handleStopSlideshow();
|
||||||
|
}
|
||||||
|
}
|
||||||
}, $t('error_while_navigating'));
|
}, $t('error_while_navigating'));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -301,10 +274,12 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
|
||||||
|
previewStackedAsset = isMouseOver ? asset : undefined;
|
||||||
|
};
|
||||||
const handlePreAction = (action: Action) => {
|
const handlePreAction = (action: Action) => {
|
||||||
preAction?.(action);
|
preAction?.(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAction = async (action: Action) => {
|
const handleAction = async (action: Action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case AssetAction.DELETE:
|
case AssetAction.DELETE:
|
||||||
@@ -313,7 +288,7 @@
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||||
stack = action.stack ?? undefined;
|
stack = action.stack;
|
||||||
if (stack) {
|
if (stack) {
|
||||||
cursor.current = stack.assets[0];
|
cursor.current = stack.assets[0];
|
||||||
}
|
}
|
||||||
@@ -367,50 +342,27 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshOcr = async () => {
|
|
||||||
ocrManager.clear();
|
|
||||||
if (sharedLink) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ocrManager.getAssetOcr(asset.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
await refreshStack();
|
await refreshStack();
|
||||||
await refreshOcr();
|
ocrManager.clear();
|
||||||
|
if (!sharedLink) {
|
||||||
|
if (previewStackedAsset) {
|
||||||
|
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
||||||
|
}
|
||||||
|
await ocrManager.getAssetOcr(asset.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
cursor.current;
|
asset;
|
||||||
untrack(() => handlePromiseError(refresh()));
|
untrack(() => handlePromiseError(refresh()));
|
||||||
});
|
imageManager.preload(cursor.nextAsset);
|
||||||
|
imageManager.preload(cursor.previousAsset);
|
||||||
$effect(() => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
||||||
previewStackedAsset;
|
|
||||||
untrack(() => handlePromiseError(refreshOcr()));
|
|
||||||
});
|
|
||||||
|
|
||||||
let lastCursor = $state<AssetCursor>();
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (cursor.current.id === lastCursor?.current.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lastCursor) {
|
|
||||||
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
|
|
||||||
}
|
|
||||||
if (!lastCursor) {
|
|
||||||
preloadManager.initializePreloads(cursor, sharedLink);
|
|
||||||
}
|
|
||||||
lastCursor = cursor;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewerKind = $derived.by(() => {
|
const viewerKind = $derived.by(() => {
|
||||||
if (previewStackedAsset) {
|
if (previewStackedAsset) {
|
||||||
return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
return previewStackedAsset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
|
||||||
}
|
}
|
||||||
if (asset.type === AssetTypeEnum.Video) {
|
if (asset.type === AssetTypeEnum.Video) {
|
||||||
return 'VideoViewer';
|
return 'VideoViewer';
|
||||||
@@ -451,27 +403,6 @@
|
|||||||
assetViewerManager.isShowDetailPanel &&
|
assetViewerManager.isShowDetailPanel &&
|
||||||
!assetViewerManager.isShowEditor,
|
!assetViewerManager.isShowEditor,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSwipe = (event: SwipeCustomEvent) => {
|
|
||||||
if (assetViewerManager.zoom > 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ocrManager.showOverlay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.detail.direction === 'left') {
|
|
||||||
navigateAsset('next');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.detail.direction === 'right') {
|
|
||||||
navigateAsset('previous');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let containerWidth = $state(0);
|
|
||||||
let containerHeight = $state(0);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
||||||
@@ -483,8 +414,6 @@
|
|||||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||||
use:focusTrap
|
use:focusTrap
|
||||||
bind:this={assetViewerHtmlElement}
|
bind:this={assetViewerHtmlElement}
|
||||||
bind:clientWidth={containerWidth}
|
|
||||||
bind:clientHeight={containerHeight}
|
|
||||||
>
|
>
|
||||||
<!-- Top navigation bar -->
|
<!-- Top navigation bar -->
|
||||||
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||||
@@ -519,15 +448,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
|
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
|
||||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Asset Viewer -->
|
<!-- Asset Viewer -->
|
||||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||||
{#if viewerKind === 'StackVideoViewer'}
|
{#if viewerKind === 'StackPhotoViewer'}
|
||||||
|
<PhotoViewer
|
||||||
|
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||||
|
onPreviousAsset={() => navigateAsset('previous')}
|
||||||
|
onNextAsset={() => navigateAsset('next')}
|
||||||
|
haveFadeTransition={false}
|
||||||
|
{sharedLink}
|
||||||
|
/>
|
||||||
|
{:else if viewerKind === 'StackVideoViewer'}
|
||||||
<VideoViewer
|
<VideoViewer
|
||||||
asset={previewStackedAsset!}
|
asset={previewStackedAsset!}
|
||||||
cacheKey={previewStackedAsset!.thumbhash}
|
cacheKey={previewStackedAsset!.thumbhash}
|
||||||
@@ -558,10 +495,11 @@
|
|||||||
<CropArea {asset} />
|
<CropArea {asset} />
|
||||||
{:else if viewerKind === 'PhotoViewer'}
|
{:else if viewerKind === 'PhotoViewer'}
|
||||||
<PhotoViewer
|
<PhotoViewer
|
||||||
cursor={{ ...cursor, current: asset }}
|
{cursor}
|
||||||
|
onPreviousAsset={() => navigateAsset('previous')}
|
||||||
|
onNextAsset={() => navigateAsset('next')}
|
||||||
{sharedLink}
|
{sharedLink}
|
||||||
{onSwipe}
|
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
|
||||||
onTagFace={refreshPreservingSelection}
|
|
||||||
/>
|
/>
|
||||||
{:else if viewerKind === 'VideoViewer'}
|
{:else if viewerKind === 'VideoViewer'}
|
||||||
<VideoViewer
|
<VideoViewer
|
||||||
@@ -595,55 +533,9 @@
|
|||||||
<OcrButton />
|
<OcrButton />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
|
||||||
{@const stackedAssets = stack.assets}
|
|
||||||
<div
|
|
||||||
id="stack-slideshow"
|
|
||||||
class="absolute bottom-0 max-w-[calc(100%-5rem)] col-span-4 col-start-1 pointer-events-none"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
role="presentation"
|
|
||||||
class="relative inline-flex flex-row flex-nowrap max-w-full overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
|
|
||||||
onmouseleave={() => (previewStackedAsset = undefined)}
|
|
||||||
>
|
|
||||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
|
||||||
<div
|
|
||||||
class={['inline-block px-1 relative transition-all pb-2']}
|
|
||||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
|
||||||
>
|
|
||||||
<Thumbnail
|
|
||||||
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
|
||||||
brokenAssetClass="text-xs"
|
|
||||||
dimmed={stackedAsset.id !== asset.id}
|
|
||||||
asset={toTimelineAsset(stackedAsset)}
|
|
||||||
onClick={async () => {
|
|
||||||
await selectStackedAsset(stackedAsset.id);
|
|
||||||
previewStackedAsset = undefined;
|
|
||||||
}}
|
|
||||||
onMouseEvent={async ({ isMouseOver }) => {
|
|
||||||
if (isMouseOver) {
|
|
||||||
previewStackedAsset = stackedAsset;
|
|
||||||
previewStackedAsset = await assetCacheManager.getAsset({ id: stackedAsset.id });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
readonly
|
|
||||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
|
||||||
showStackedIcon={false}
|
|
||||||
disableLinkMouseOver
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="w-full flex place-items-center place-content-center">
|
|
||||||
<div class={['w-2 h-2 rounded-full flex mt-0.5', { 'bg-white': stackedAsset.id === asset.id }]}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset}
|
||||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||||
</div>
|
</div>
|
||||||
@@ -658,7 +550,7 @@
|
|||||||
>
|
>
|
||||||
{#if showDetailPanel}
|
{#if showDetailPanel}
|
||||||
<div class="w-90 h-full">
|
<div class="w-90 h-full">
|
||||||
<DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
|
<DetailPanel {asset} currentAlbum={album} />
|
||||||
</div>
|
</div>
|
||||||
{:else if assetViewerManager.isShowEditor}
|
{:else if assetViewerManager.isShowEditor}
|
||||||
<div class="w-100 h-full">
|
<div class="w-100 h-full">
|
||||||
@@ -668,6 +560,42 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||||
|
{@const stackedAssets = stack.assets}
|
||||||
|
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
||||||
|
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||||
|
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||||
|
<div
|
||||||
|
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
|
||||||
|
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||||
|
>
|
||||||
|
<Thumbnail
|
||||||
|
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
||||||
|
brokenAssetClass="text-xs"
|
||||||
|
dimmed={stackedAsset.id !== asset.id}
|
||||||
|
asset={toTimelineAsset(stackedAsset)}
|
||||||
|
onClick={() => {
|
||||||
|
cursor.current = stackedAsset;
|
||||||
|
previewStackedAsset = undefined;
|
||||||
|
}}
|
||||||
|
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||||
|
readonly
|
||||||
|
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||||
|
showStackedIcon={false}
|
||||||
|
disableLinkMouseOver
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if stackedAsset.id === asset.id}
|
||||||
|
<div class="w-full flex place-items-center place-content-center">
|
||||||
|
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
|
{#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:fly={{ duration: 150 }}
|
||||||
|
|||||||
@@ -20,7 +20,13 @@
|
|||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { getParentPath } from '$lib/utils/tree-utils';
|
import { getParentPath } from '$lib/utils/tree-utils';
|
||||||
import { AssetMediaSize, getAllAlbums, 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 { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiCalendar,
|
mdiCalendar,
|
||||||
@@ -46,10 +52,9 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
currentAlbum?: AlbumResponseDto | null;
|
currentAlbum?: AlbumResponseDto | null;
|
||||||
onRefreshPeople?: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
|
let { asset, currentAlbum = null }: Props = $props();
|
||||||
|
|
||||||
let showAssetPath = $state(false);
|
let showAssetPath = $state(false);
|
||||||
let showEditFaces = $state(false);
|
let showEditFaces = $state(false);
|
||||||
@@ -115,6 +120,11 @@
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefreshPeople = async () => {
|
||||||
|
asset = await getAssetInfo({ id: asset.id });
|
||||||
|
showEditFaces = false;
|
||||||
|
};
|
||||||
|
|
||||||
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
||||||
// Remove the last part of the path to get the parent path
|
// Remove the last part of the path to get the parent path
|
||||||
return Route.folders({ path: getParentPath(asset.originalPath) });
|
return Route.folders({ path: getParentPath(asset.originalPath) });
|
||||||
@@ -565,6 +575,6 @@
|
|||||||
assetId={asset.id}
|
assetId={asset.id}
|
||||||
assetType={asset.type}
|
assetType={asset.type}
|
||||||
onClose={() => (showEditFaces = false)}
|
onClose={() => (showEditFaces = false)}
|
||||||
onRefresh={() => void onRefreshPeople?.()}
|
onRefresh={handleRefreshPeople}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||||
@@ -16,10 +17,9 @@
|
|||||||
containerWidth: number;
|
containerWidth: number;
|
||||||
containerHeight: number;
|
containerHeight: number;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
onTagFace?: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
|
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
|
||||||
|
|
||||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||||
let canvas: Canvas | undefined = $state();
|
let canvas: Canvas | undefined = $state();
|
||||||
@@ -81,20 +81,15 @@
|
|||||||
await getPeople();
|
await getPeople();
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageContentMetrics = $derived.by(() => {
|
|
||||||
const natural = getNaturalSize(htmlElement);
|
|
||||||
const container = { width: containerWidth, height: containerHeight };
|
|
||||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
|
|
||||||
return {
|
|
||||||
contentWidth,
|
|
||||||
contentHeight,
|
|
||||||
offsetX: (containerWidth - contentWidth) / 2,
|
|
||||||
offsetY: (containerHeight - contentHeight) / 2,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const { offsetX, offsetY } = imageContentMetrics;
|
const metrics = getContentMetrics(htmlElement);
|
||||||
|
|
||||||
|
const imageBoundingBox = {
|
||||||
|
top: metrics.offsetY,
|
||||||
|
left: metrics.offsetX,
|
||||||
|
width: metrics.contentWidth,
|
||||||
|
height: metrics.contentHeight,
|
||||||
|
};
|
||||||
|
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return;
|
return;
|
||||||
@@ -110,8 +105,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
faceRect.set({
|
faceRect.set({
|
||||||
top: offsetY + 200,
|
top: imageBoundingBox.top + 200,
|
||||||
left: offsetX + 200,
|
left: imageBoundingBox.left + 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
faceRect.setCoords();
|
faceRect.setCoords();
|
||||||
@@ -219,13 +214,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
const metrics = getContentMetrics(htmlElement);
|
||||||
const natural = getNaturalSize(htmlElement);
|
const natural = getNaturalSize(htmlElement);
|
||||||
|
|
||||||
const scaleX = natural.width / contentWidth;
|
const scaleX = natural.width / metrics.contentWidth;
|
||||||
const scaleY = natural.height / contentHeight;
|
const scaleY = natural.height / metrics.contentHeight;
|
||||||
const imageX = (left - offsetX) * scaleX;
|
const imageX = (left - metrics.offsetX) * scaleX;
|
||||||
const imageY = (top - offsetY) * scaleY;
|
const imageY = (top - metrics.offsetY) * scaleY;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
imageWidth: natural.width,
|
imageWidth: natural.width,
|
||||||
@@ -263,7 +258,7 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await onTagFace?.();
|
await assetViewingStore.setAssetId(assetId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, 'Error tagging face');
|
handleError(error, 'Error tagging face');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,57 +1,66 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import { thumbhash } from '$lib/actions/thumbhash';
|
|
||||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||||
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
|
||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||||
|
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||||
|
import { assetViewerFadeDuration } from '$lib/constants';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||||
|
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||||
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||||
import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
|
import { type ContentMetrics, getContentMetrics } from '$lib/utils/container-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { toastManager } from '@immich/ui';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
|
import { LoadingSpinner, toastManager } from '@immich/ui';
|
||||||
import { onDestroy, untrack } from 'svelte';
|
import { onDestroy, untrack } from 'svelte';
|
||||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
import type { AssetCursor } from './asset-viewer.svelte';
|
import type { AssetCursor } from './asset-viewer.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cursor: AssetCursor;
|
cursor: AssetCursor;
|
||||||
element?: HTMLDivElement;
|
element?: HTMLDivElement | undefined;
|
||||||
sharedLink?: SharedLinkResponseDto;
|
haveFadeTransition?: boolean;
|
||||||
onReady?: () => void;
|
sharedLink?: SharedLinkResponseDto | undefined;
|
||||||
onError?: () => void;
|
onPreviousAsset?: (() => void) | null;
|
||||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
onNextAsset?: (() => void) | null;
|
||||||
onTagFace?: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe, onTagFace }: Props = $props();
|
let {
|
||||||
|
cursor,
|
||||||
|
element = $bindable(),
|
||||||
|
haveFadeTransition = true,
|
||||||
|
sharedLink = undefined,
|
||||||
|
onPreviousAsset = null,
|
||||||
|
onNextAsset = null,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const { slideshowState, slideshowLook } = slideshowStore;
|
const { slideshowState, slideshowLook } = slideshowStore;
|
||||||
const asset = $derived(cursor.current);
|
const asset = $derived(cursor.current);
|
||||||
|
|
||||||
|
let imageLoaded: boolean = $state(false);
|
||||||
|
let originalImageLoaded: boolean = $state(false);
|
||||||
|
let imageError: boolean = $state(false);
|
||||||
let visibleImageReady: boolean = $state(false);
|
let visibleImageReady: boolean = $state(false);
|
||||||
|
|
||||||
let previousAssetId: string | undefined;
|
let loader = $state<HTMLImageElement>();
|
||||||
|
|
||||||
$effect.pre(() => {
|
$effect.pre(() => {
|
||||||
const id = asset.id;
|
void asset.id;
|
||||||
if (id === previousAssetId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
previousAssetId = id;
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
assetViewerManager.resetZoomState();
|
assetViewerManager.resetZoomState();
|
||||||
visibleImageReady = false;
|
|
||||||
$boundingBoxesArray = [];
|
$boundingBoxesArray = [];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -60,30 +69,25 @@
|
|||||||
$boundingBoxesArray = [];
|
$boundingBoxesArray = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
let containerWidth = $state(0);
|
|
||||||
let containerHeight = $state(0);
|
|
||||||
|
|
||||||
const container = $derived({
|
|
||||||
width: containerWidth,
|
|
||||||
height: containerHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
const overlayMetrics = $derived.by((): ContentMetrics => {
|
const overlayMetrics = $derived.by((): ContentMetrics => {
|
||||||
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
||||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef);
|
||||||
const scaled = scaleToFit(natural, container);
|
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contentWidth: scaled.width,
|
contentWidth: contentWidth * currentZoom,
|
||||||
contentHeight: scaled.height,
|
contentHeight: contentHeight * currentZoom,
|
||||||
offsetX: 0,
|
offsetX: offsetX * currentZoom + currentPositionX,
|
||||||
offsetY: 0,
|
offsetY: offsetY * currentZoom + currentPositionY,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
||||||
|
|
||||||
|
let isOcrActive = $derived(ocrManager.showOverlay);
|
||||||
|
|
||||||
const onCopy = async () => {
|
const onCopy = async () => {
|
||||||
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
||||||
@@ -99,11 +103,18 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onZoom = () => {
|
const onZoom = () => {
|
||||||
assetViewerManager.zoom = assetViewerManager.zoom > 1 ? 1 : 2;
|
const targetZoom = assetViewerManager.zoom > 1 ? 1 : 2;
|
||||||
|
assetViewerManager.animatedZoom(targetZoom);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isFaceEditMode.value && assetViewerManager.zoom > 1) {
|
||||||
|
onZoom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// TODO move to action + command palette
|
// TODO move to action + command palette
|
||||||
const onCopyShortcut = (event: KeyboardEvent) => {
|
const onCopyShortcut = (event: KeyboardEvent) => {
|
||||||
if (globalThis.getSelection()?.type === 'Range') {
|
if (globalThis.getSelection()?.type === 'Range') {
|
||||||
@@ -114,15 +125,29 @@
|
|||||||
handlePromiseError(onCopy());
|
handlePromiseError(onCopy());
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentPreviewUrl = $state<string>();
|
const onSwipe = (event: SwipeCustomEvent) => {
|
||||||
|
if (assetViewerManager.zoom > 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const onUrlChange = (url: string) => {
|
if (ocrManager.showOverlay) {
|
||||||
currentPreviewUrl = url;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onNextAsset && event.detail.direction === 'left') {
|
||||||
|
onNextAsset();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onPreviousAsset && event.detail.direction === 'right') {
|
||||||
|
onPreviousAsset();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (currentPreviewUrl) {
|
if (imageLoaderUrl) {
|
||||||
void cast(currentPreviewUrl);
|
void cast(imageLoaderUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,11 +165,37 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const blurredSlideshow = $derived(
|
const onload = () => {
|
||||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
imageLoaded = true;
|
||||||
|
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onerror = () => {
|
||||||
|
imageError = imageLoaded = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
onDestroy(() => imageManager.cancel(asset, targetImageSize));
|
||||||
|
|
||||||
|
let imageLoaderUrl = $derived(
|
||||||
|
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
let adaptiveImage = $state<HTMLDivElement | undefined>();
|
let containerWidth = $state(0);
|
||||||
|
let containerHeight = $state(0);
|
||||||
|
|
||||||
|
let lastUrl: string | undefined;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
||||||
|
untrack(() => {
|
||||||
|
imageLoaded = false;
|
||||||
|
originalImageLoaded = false;
|
||||||
|
imageError = false;
|
||||||
|
visibleImageReady = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lastUrl = imageLoaderUrl;
|
||||||
|
});
|
||||||
|
|
||||||
const faceToNameMap = $derived.by(() => {
|
const faceToNameMap = $derived.by(() => {
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
@@ -158,6 +209,29 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const faces = $derived(Array.from(faceToNameMap.keys()));
|
const faces = $derived(Array.from(faceToNameMap.keys()));
|
||||||
|
|
||||||
|
const handleImageMouseMove = (event: MouseEvent) => {
|
||||||
|
$boundingBoxesArray = [];
|
||||||
|
if (!assetViewerManager.imgRef || !element || isFaceEditMode.value || ocrManager.showOverlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRect = element.getBoundingClientRect();
|
||||||
|
const mouseX = event.clientX - containerRect.left;
|
||||||
|
const mouseY = event.clientY - containerRect.top;
|
||||||
|
|
||||||
|
const faceBoxes = getBoundingBox(faces, overlayMetrics);
|
||||||
|
|
||||||
|
for (const [index, box] of faceBoxes.entries()) {
|
||||||
|
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
|
||||||
|
$boundingBoxesArray.push(faces[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageMouseLeave = () => {
|
||||||
|
$boundingBoxesArray = [];
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AssetViewerEvents {onCopy} {onZoom} />
|
<AssetViewerEvents {onCopy} {onZoom} />
|
||||||
@@ -170,7 +244,12 @@
|
|||||||
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{#if imageError}
|
||||||
|
<div id="broken-asset" class="h-full w-full">
|
||||||
|
<BrokenAsset class="text-xl h-full w-full" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
class="relative h-full w-full select-none"
|
class="relative h-full w-full select-none"
|
||||||
@@ -178,77 +257,74 @@
|
|||||||
bind:clientHeight={containerHeight}
|
bind:clientHeight={containerHeight}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
ondblclick={onZoom}
|
ondblclick={onZoom}
|
||||||
use:zoomImageAction={{ disablePointer: isFaceEditMode.value, zoomTarget: adaptiveImage }}
|
onmousemove={handleImageMouseMove}
|
||||||
{...useSwipe((event) => onSwipe?.(event))}
|
onmouseleave={handleImageMouseLeave}
|
||||||
>
|
>
|
||||||
<AdaptiveImage
|
{#if !imageLoaded}
|
||||||
{asset}
|
<div id="spinner" class="flex h-full items-center justify-center">
|
||||||
{sharedLink}
|
<LoadingSpinner />
|
||||||
{container}
|
</div>
|
||||||
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
|
{:else if !imageError}
|
||||||
{onUrlChange}
|
|
||||||
onImageReady={() => {
|
|
||||||
visibleImageReady = true;
|
|
||||||
onReady?.();
|
|
||||||
}}
|
|
||||||
onError={() => {
|
|
||||||
onError?.();
|
|
||||||
onReady?.();
|
|
||||||
}}
|
|
||||||
bind:imgRef={assetViewerManager.imgRef}
|
|
||||||
bind:ref={adaptiveImage}
|
|
||||||
>
|
|
||||||
{#snippet backdrop()}
|
|
||||||
{#if blurredSlideshow}
|
|
||||||
<canvas
|
|
||||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
|
|
||||||
class="absolute top-0 left-0 inset-s-0 h-dvh w-dvw"
|
|
||||||
></canvas>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
{#snippet overlays()}
|
|
||||||
{#if !isFaceEditMode.value && !ocrManager.showOverlay}
|
|
||||||
{#each getBoundingBox(faces, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
|
||||||
{@const face = faces[index]}
|
|
||||||
{@const name = faceToNameMap.get(face)}
|
|
||||||
{@const isActive = $boundingBoxesArray.includes(face)}
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
class={[
|
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||||
'absolute pointer-events-auto outline-none rounded-lg',
|
{...useSwipe(onSwipe)}
|
||||||
isActive && 'border-solid border-white border-3',
|
class="h-full w-full"
|
||||||
]}
|
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||||
|
>
|
||||||
|
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||||
|
<img
|
||||||
|
src={imageLoaderUrl}
|
||||||
|
alt=""
|
||||||
|
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (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;"
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||||
aria-label="{$t('person')}: {name || $t('unknown')}"
|
></div>
|
||||||
onmouseenter={() => ($boundingBoxesArray = [face])}
|
{#if faceToNameMap.get($boundingBoxesArray[index])}
|
||||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
|
||||||
>
|
|
||||||
{#if isActive && name}
|
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
|
||||||
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
|
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
|
||||||
style="top: {boundingbox.height + 4}px; right: 0;"
|
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
|
||||||
|
boundingbox.width}px; transform: translateX(-100%);"
|
||||||
>
|
>
|
||||||
{name}
|
{faceToNameMap.get($boundingBoxesArray[index])}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||||
<OcrBoundingBox {ocrBox} />
|
<OcrBoundingBox {ocrBox} />
|
||||||
{/each}
|
{/each}
|
||||||
{/snippet}
|
</div>
|
||||||
</AdaptiveImage>
|
|
||||||
|
|
||||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
{#if isFaceEditMode.value}
|
||||||
<FaceEditor
|
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||||
htmlElement={assetViewerManager.imgRef}
|
{/if}
|
||||||
{containerWidth}
|
|
||||||
{containerHeight}
|
|
||||||
assetId={asset.id}
|
|
||||||
{onTagFace}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes delayedVisibility {
|
||||||
|
to {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#broken-asset,
|
||||||
|
#spinner {
|
||||||
|
visibility: hidden;
|
||||||
|
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||||
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
||||||
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
@@ -178,10 +179,7 @@
|
|||||||
|
|
||||||
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
|
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
|
||||||
|
|
||||||
onRefresh();
|
await assetViewingStore.setAssetId(assetId);
|
||||||
if (peopleWithFaces.length === 0) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('error_delete_face'));
|
handleError(error, $t('error_delete_face'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { getAssetMediaUrl } from '$lib/utils';
|
import { getAssetMediaUrl } from '$lib/utils';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
import { AssetMediaSize } from '@immich/sdk';
|
||||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
@@ -44,7 +44,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !imageLoaded}
|
{#if !imageLoaded}
|
||||||
<DelayedLoadingSpinner />
|
<div id="spinner" class="flex h-full items-center justify-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
{:else if imageLoaded}
|
{:else if imageLoaded}
|
||||||
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
||||||
<img
|
<img
|
||||||
@@ -55,3 +57,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes delayedVisibility {
|
||||||
|
to {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#spinner {
|
||||||
|
visibility: hidden;
|
||||||
|
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getAssetMediaUrl } from '$lib/utils';
|
||||||
|
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||||
|
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
|
type AllAssetMediaSize = AssetMediaSize | 'all';
|
||||||
|
|
||||||
|
class ImageManager {
|
||||||
|
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.src = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
|
||||||
|
for (const size of sizes) {
|
||||||
|
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||||
|
if (url) {
|
||||||
|
cancelImageUrl(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imageManager = new ImageManager();
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { ImageLoaderStatus } from '$lib/utils/adaptive-image-loader.svelte';
|
|
||||||
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
||||||
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
||||||
import { PersistedLocalStorage } from '$lib/utils/persisted';
|
import { PersistedLocalStorage } from '$lib/utils/persisted';
|
||||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
|
||||||
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
|
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
|
||||||
|
|
||||||
@@ -22,26 +22,13 @@ export type Events = {
|
|||||||
|
|
||||||
export class AssetViewerManager extends BaseEventManager<Events> {
|
export class AssetViewerManager extends BaseEventManager<Events> {
|
||||||
#zoomState = $state(createDefaultZoomState());
|
#zoomState = $state(createDefaultZoomState());
|
||||||
|
#animationFrameId: number | null = null;
|
||||||
|
|
||||||
imgRef = $state<HTMLImageElement | undefined>();
|
imgRef = $state<HTMLImageElement | undefined>();
|
||||||
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
|
|
||||||
#isImageLoading = $derived.by(() => {
|
|
||||||
const quality = this.imageLoaderStatus?.quality;
|
|
||||||
if (!quality) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const previewOrOriginalReady = quality.preview === 'success' || quality.original === 'success';
|
|
||||||
const loadingOriginal = this.zoom > 1 && quality.original !== 'success';
|
|
||||||
return !previewOrOriginalReady || loadingOriginal;
|
|
||||||
});
|
|
||||||
isShowActivityPanel = $state(false);
|
isShowActivityPanel = $state(false);
|
||||||
isPlayingMotionPhoto = $state(false);
|
isPlayingMotionPhoto = $state(false);
|
||||||
isShowEditor = $state(false);
|
isShowEditor = $state(false);
|
||||||
|
|
||||||
get isImageLoading() {
|
|
||||||
return this.#isImageLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isShowDetailPanel() {
|
get isShowDetailPanel() {
|
||||||
return isShowDetailPanel.current;
|
return isShowDetailPanel.current;
|
||||||
}
|
}
|
||||||
@@ -60,6 +47,7 @@ export class AssetViewerManager extends BaseEventManager<Events> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set zoom(zoom: number) {
|
set zoom(zoom: number) {
|
||||||
|
this.cancelZoomAnimation();
|
||||||
this.zoomState = { ...this.zoomState, currentZoom: zoom };
|
this.zoomState = { ...this.zoomState, currentZoom: zoom };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +72,35 @@ export class AssetViewerManager extends BaseEventManager<Events> {
|
|||||||
this.#zoomState = state;
|
this.#zoomState = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelZoomAnimation() {
|
||||||
|
if (this.#animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.#animationFrameId);
|
||||||
|
this.#animationFrameId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animatedZoom(targetZoom: number, duration = 300) {
|
||||||
|
this.cancelZoomAnimation();
|
||||||
|
|
||||||
|
const startZoom = this.#zoomState.currentZoom;
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
const frame = (currentTime: number) => {
|
||||||
|
const elapsed = currentTime - startTime;
|
||||||
|
const linearProgress = Math.min(elapsed / duration, 1);
|
||||||
|
const easedProgress = cubicOut(linearProgress);
|
||||||
|
const interpolatedZoom = startZoom + (targetZoom - startZoom) * easedProgress;
|
||||||
|
|
||||||
|
this.zoomState = { ...this.#zoomState, currentZoom: interpolatedZoom };
|
||||||
|
|
||||||
|
this.#animationFrameId = linearProgress < 1 ? requestAnimationFrame(frame) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.#animationFrameId = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
resetZoomState() {
|
resetZoomState() {
|
||||||
|
this.cancelZoomAnimation();
|
||||||
this.zoomState = createDefaultZoomState();
|
this.zoomState = createDefaultZoomState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,14 +186,6 @@ export const getAssetUrl = ({
|
|||||||
return getAssetMediaUrl({ id, size, cacheKey });
|
return getAssetMediaUrl({ id, size, cacheKey });
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getAssetUrls(asset: AssetResponseDto, sharedLink?: SharedLinkResponseDto) {
|
|
||||||
return {
|
|
||||||
thumbnail: getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail }),
|
|
||||||
preview: getAssetUrl({ asset, sharedLink })!,
|
|
||||||
original: getAssetUrl({ asset, sharedLink, forceOriginal: true })!,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const forceUseOriginal = (asset: AssetResponseDto) => {
|
const forceUseOriginal = (asset: AssetResponseDto) => {
|
||||||
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
|
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,304 +0,0 @@
|
|||||||
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
|
||||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
|
||||||
|
|
||||||
vi.mock('$lib/utils/sw-messaging', () => ({
|
|
||||||
cancelImageUrl: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createQualityList(overrides?: {
|
|
||||||
onAfterLoad?: Record<string, (loader: AdaptiveImageLoader) => void>;
|
|
||||||
onAfterError?: Record<string, (loader: AdaptiveImageLoader) => void>;
|
|
||||||
}): QualityList {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
quality: 'thumbnail',
|
|
||||||
url: '/thumbnail.jpg',
|
|
||||||
onAfterLoad: overrides?.onAfterLoad?.thumbnail,
|
|
||||||
onAfterError: overrides?.onAfterError?.thumbnail,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
quality: 'preview',
|
|
||||||
url: '/preview.jpg',
|
|
||||||
onAfterLoad: overrides?.onAfterLoad?.preview,
|
|
||||||
onAfterError: overrides?.onAfterError?.preview,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
quality: 'original',
|
|
||||||
url: '/original.jpg',
|
|
||||||
onAfterLoad: overrides?.onAfterLoad?.original,
|
|
||||||
onAfterError: overrides?.onAfterError?.original,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AdaptiveImageLoader', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('constructor', () => {
|
|
||||||
it('initializes with thumbnail URL set', () => {
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList());
|
|
||||||
expect(loader.status.urls.thumbnail).toBe('/thumbnail.jpg');
|
|
||||||
expect(loader.status.urls.preview).toBeUndefined();
|
|
||||||
expect(loader.status.urls.original).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('initializes all qualities as unloaded', () => {
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList());
|
|
||||||
expect(loader.status.quality.thumbnail).toBe('unloaded');
|
|
||||||
expect(loader.status.quality.preview).toBe('unloaded');
|
|
||||||
expect(loader.status.quality.original).toBe('unloaded');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('onStart', () => {
|
|
||||||
it('sets started to true', () => {
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList());
|
|
||||||
expect(loader.status.started).toBe(false);
|
|
||||||
loader.onStart('thumbnail');
|
|
||||||
expect(loader.status.started).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is a no-op after destroy', () => {
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList());
|
|
||||||
loader.destroy();
|
|
||||||
loader.onStart('thumbnail');
|
|
||||||
expect(loader.status.started).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('onLoad', () => {
|
|
||||||
it('sets quality to success and calls callbacks', () => {
|
|
||||||
const onUrlChange = vi.fn();
|
|
||||||
const onImageReady = vi.fn();
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList(), { onUrlChange, onImageReady });
|
|
||||||
|
|
||||||
loader.onLoad('thumbnail');
|
|
||||||
|
|
||||||
expect(loader.status.quality.thumbnail).toBe('success');
|
|
||||||
expect(onUrlChange).toHaveBeenCalledWith('/thumbnail.jpg');
|
|
||||||
expect(onImageReady).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onAfterLoad callback', () => {
|
|
||||||
const onAfterLoad = vi.fn();
|
|
||||||
const qualityList = createQualityList({ onAfterLoad: { thumbnail: onAfterLoad } });
|
|
||||||
const loader = new AdaptiveImageLoader(qualityList);
|
|
||||||
|
|
||||||
loader.onLoad('thumbnail');
|
|
||||||
|
|
||||||
expect(onAfterLoad).toHaveBeenCalledWith(loader);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores load if URL is not set', () => {
|
|
||||||
const onImageReady = vi.fn();
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList(), { onImageReady });
|
|
||||||
|
|
||||||
loader.onLoad('preview');
|
|
||||||
|
|
||||||
expect(loader.status.quality.preview).toBe('unloaded');
|
|
||||||
expect(onImageReady).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores load if a higher quality is already loaded', () => {
|
|
||||||
const onUrlChange = vi.fn();
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList(), { onUrlChange });
|
|
||||||
|
|
||||||
loader.onLoad('thumbnail');
|
|
||||||
loader.trigger('preview');
|
|
||||||
loader.onLoad('preview');
|
|
||||||
|
|
||||||
onUrlChange.mockClear();
|
|
||||||
loader.onLoad('thumbnail');
|
|
||||||
|
|
||||||
expect(onUrlChange).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is a no-op after destroy', () => {
|
|
||||||
const onImageReady = vi.fn();
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList(), { onImageReady });
|
|
||||||
|
|
||||||
loader.destroy();
|
|
||||||
loader.onLoad('thumbnail');
|
|
||||||
|
|
||||||
expect(onImageReady).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('onError', () => {
|
|
||||||
it('sets quality to error and clears URL', () => {
|
|
||||||
const onError = vi.fn();
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList(), { onError });
|
|
||||||
|
|
||||||
loader.onError('thumbnail');
|
|
||||||
|
|
||||||
expect(loader.status.quality.thumbnail).toBe('error');
|
|
||||||
expect(loader.status.urls.thumbnail).toBeUndefined();
|
|
||||||
expect(loader.status.hasError).toBe(true);
|
|
||||||
expect(onError).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onAfterError callback', () => {
|
|
||||||
const onAfterError = vi.fn();
|
|
||||||
const qualityList = createQualityList({ onAfterError: { thumbnail: onAfterError } });
|
|
||||||
const loader = new AdaptiveImageLoader(qualityList);
|
|
||||||
|
|
||||||
loader.onError('thumbnail');
|
|
||||||
|
|
||||||
expect(onAfterError).toHaveBeenCalledWith(loader);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is a no-op after destroy', () => {
|
|
||||||
const onError = vi.fn();
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList(), { onError });
|
|
||||||
|
|
||||||
loader.destroy();
|
|
||||||
loader.onError('thumbnail');
|
|
||||||
|
|
||||||
expect(onError).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('trigger', () => {
|
|
||||||
it('sets the URL for the quality', () => {
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList());
|
|
||||||
|
|
||||||
loader.trigger('preview');
|
|
||||||
|
|
||||||
expect(loader.status.urls.preview).toBe('/preview.jpg');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns true if URL is already set', () => {
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList());
|
|
||||||
|
|
||||||
expect(loader.trigger('thumbnail')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when triggering a new quality', () => {
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList());
|
|
||||||
|
|
||||||
expect(loader.trigger('preview')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears hasError when triggering', () => {
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList());
|
|
||||||
|
|
||||||
loader.onError('thumbnail');
|
|
||||||
expect(loader.status.hasError).toBe(true);
|
|
||||||
|
|
||||||
loader.trigger('preview');
|
|
||||||
expect(loader.status.hasError).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls imageLoader when provided', () => {
|
|
||||||
const imageLoader = vi.fn(() => vi.fn());
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader);
|
|
||||||
|
|
||||||
loader.trigger('preview');
|
|
||||||
|
|
||||||
expect(imageLoader).toHaveBeenCalledWith(
|
|
||||||
'/preview.jpg',
|
|
||||||
expect.any(Function),
|
|
||||||
expect.any(Function),
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false after destroy', () => {
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList());
|
|
||||||
|
|
||||||
loader.destroy();
|
|
||||||
|
|
||||||
expect(loader.trigger('preview')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onAfterError if URL is empty', () => {
|
|
||||||
const onAfterError = vi.fn();
|
|
||||||
const qualityList = createQualityList({ onAfterError: { preview: onAfterError } });
|
|
||||||
(qualityList[1] as { url: string }).url = '';
|
|
||||||
const loader = new AdaptiveImageLoader(qualityList);
|
|
||||||
|
|
||||||
expect(loader.trigger('preview')).toBe(false);
|
|
||||||
expect(onAfterError).toHaveBeenCalledWith(loader);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('start', () => {
|
|
||||||
it('throws if no imageLoader is provided', () => {
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList());
|
|
||||||
expect(() => loader.start()).toThrow('Start requires imageLoader to be specified');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls imageLoader with thumbnail URL', () => {
|
|
||||||
const imageLoader = vi.fn(() => vi.fn());
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader);
|
|
||||||
|
|
||||||
loader.start();
|
|
||||||
|
|
||||||
expect(imageLoader).toHaveBeenCalledWith(
|
|
||||||
'/thumbnail.jpg',
|
|
||||||
expect.any(Function),
|
|
||||||
expect.any(Function),
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('destroy', () => {
|
|
||||||
it('cancels all image URLs when no imageLoader', () => {
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList());
|
|
||||||
|
|
||||||
loader.destroy();
|
|
||||||
|
|
||||||
expect(cancelImageUrl).toHaveBeenCalledWith('/thumbnail.jpg');
|
|
||||||
expect(cancelImageUrl).toHaveBeenCalledWith('/preview.jpg');
|
|
||||||
expect(cancelImageUrl).toHaveBeenCalledWith('/original.jpg');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls destroy functions when imageLoader is provided', () => {
|
|
||||||
const destroyFn = vi.fn();
|
|
||||||
const imageLoader = vi.fn(() => destroyFn);
|
|
||||||
const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader);
|
|
||||||
|
|
||||||
loader.start();
|
|
||||||
loader.destroy();
|
|
||||||
|
|
||||||
expect(destroyFn).toHaveBeenCalledOnce();
|
|
||||||
expect(cancelImageUrl).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('progressive loading flow', () => {
|
|
||||||
it('thumbnail load triggers preview via onAfterLoad', () => {
|
|
||||||
const triggerSpy = vi.fn();
|
|
||||||
const qualityList = createQualityList({
|
|
||||||
onAfterLoad: {
|
|
||||||
thumbnail: (loader) => {
|
|
||||||
triggerSpy();
|
|
||||||
loader.trigger('preview');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const loader = new AdaptiveImageLoader(qualityList);
|
|
||||||
|
|
||||||
loader.onLoad('thumbnail');
|
|
||||||
|
|
||||||
expect(triggerSpy).toHaveBeenCalledOnce();
|
|
||||||
expect(loader.status.urls.preview).toBe('/preview.jpg');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('thumbnail error triggers preview via onAfterError', () => {
|
|
||||||
const qualityList = createQualityList({
|
|
||||||
onAfterError: {
|
|
||||||
thumbnail: (loader) => loader.trigger('preview'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const loader = new AdaptiveImageLoader(qualityList);
|
|
||||||
|
|
||||||
loader.onError('thumbnail');
|
|
||||||
|
|
||||||
expect(loader.status.urls.preview).toBe('/preview.jpg');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
|
|
||||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
|
||||||
|
|
||||||
export type ImageQuality = 'thumbnail' | 'preview' | 'original';
|
|
||||||
|
|
||||||
export type ImageStatus = 'unloaded' | 'success' | 'error';
|
|
||||||
|
|
||||||
export type ImageLoaderStatus = {
|
|
||||||
urls: Record<ImageQuality, string | undefined>;
|
|
||||||
quality: Record<ImageQuality, ImageStatus>;
|
|
||||||
started: boolean;
|
|
||||||
hasError: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ImageLoaderCallbacks = {
|
|
||||||
onUrlChange?: (url: string) => void;
|
|
||||||
onImageReady?: () => void;
|
|
||||||
onError?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type QualityConfig = {
|
|
||||||
url: string;
|
|
||||||
quality: ImageQuality;
|
|
||||||
onAfterLoad?: (loader: AdaptiveImageLoader) => void;
|
|
||||||
onAfterError?: (loader: AdaptiveImageLoader) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type QualityList = [
|
|
||||||
QualityConfig & { quality: 'thumbnail' },
|
|
||||||
QualityConfig & { quality: 'preview' },
|
|
||||||
QualityConfig & { quality: 'original' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export class AdaptiveImageLoader {
|
|
||||||
private destroyFunctions: (() => void)[] = [];
|
|
||||||
private qualityConfigs: Record<ImageQuality, QualityConfig>;
|
|
||||||
private highestLoadedQualityIndex = -1;
|
|
||||||
private destroyed = false;
|
|
||||||
|
|
||||||
status = $state<ImageLoaderStatus>({
|
|
||||||
started: false,
|
|
||||||
hasError: false,
|
|
||||||
urls: { thumbnail: undefined, preview: undefined, original: undefined },
|
|
||||||
quality: { thumbnail: 'unloaded', preview: 'unloaded', original: 'unloaded' },
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly qualityList: QualityList,
|
|
||||||
private readonly callbacks?: ImageLoaderCallbacks,
|
|
||||||
private readonly imageLoader?: LoadImageFunction,
|
|
||||||
) {
|
|
||||||
this.qualityConfigs = {
|
|
||||||
thumbnail: qualityList[0],
|
|
||||||
preview: qualityList[1],
|
|
||||||
original: qualityList[2],
|
|
||||||
};
|
|
||||||
this.status.urls.thumbnail = qualityList[0].url;
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
|
||||||
if (!this.imageLoader) {
|
|
||||||
throw new Error('Start requires imageLoader to be specified');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.destroyFunctions.push(
|
|
||||||
this.imageLoader(
|
|
||||||
this.qualityList[0].url,
|
|
||||||
() => this.onLoad('thumbnail'),
|
|
||||||
() => this.onError('thumbnail'),
|
|
||||||
() => this.onStart('thumbnail'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
onStart(_: ImageQuality) {
|
|
||||||
if (this.destroyed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.status.started = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad(quality: ImageQuality) {
|
|
||||||
if (this.destroyed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = this.qualityConfigs[quality];
|
|
||||||
|
|
||||||
if (!this.status.urls[quality]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = this.qualityList.indexOf(config);
|
|
||||||
if (index <= this.highestLoadedQualityIndex) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.highestLoadedQualityIndex = index;
|
|
||||||
this.status.quality[quality] = 'success';
|
|
||||||
this.callbacks?.onUrlChange?.(this.qualityConfigs[quality].url);
|
|
||||||
this.callbacks?.onImageReady?.();
|
|
||||||
|
|
||||||
config.onAfterLoad?.(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
onError(quality: ImageQuality) {
|
|
||||||
if (this.destroyed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = this.qualityConfigs[quality];
|
|
||||||
|
|
||||||
this.status.hasError = true;
|
|
||||||
this.status.quality[quality] = 'error';
|
|
||||||
this.status.urls[quality] = undefined;
|
|
||||||
this.callbacks?.onError?.();
|
|
||||||
|
|
||||||
config.onAfterError?.(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger(quality: ImageQuality) {
|
|
||||||
if (this.destroyed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = this.qualityConfigs[quality].url;
|
|
||||||
if (!url) {
|
|
||||||
this.qualityConfigs[quality].onAfterError?.(this);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.status.urls[quality]) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status.hasError = false;
|
|
||||||
this.status.urls[quality] = url;
|
|
||||||
if (this.imageLoader) {
|
|
||||||
this.destroyFunctions.push(
|
|
||||||
this.imageLoader(
|
|
||||||
url,
|
|
||||||
() => this.onLoad(quality),
|
|
||||||
() => this.onError(quality),
|
|
||||||
() => this.onStart(quality),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.destroyed = true;
|
|
||||||
if (this.imageLoader) {
|
|
||||||
for (const destroy of this.destroyFunctions) {
|
|
||||||
destroy();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const config of Object.values(this.qualityConfigs)) {
|
|
||||||
cancelImageUrl(config.url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -224,8 +224,6 @@ const supportedImageMimeTypes = new Set([
|
|||||||
'image/webp',
|
'image/webp',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const isFirefox = typeof navigator !== 'undefined' && navigator.userAgent.includes('Firefox');
|
|
||||||
|
|
||||||
async function addSupportedMimeTypes(): Promise<void> {
|
async function addSupportedMimeTypes(): Promise<void> {
|
||||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
||||||
if (isSafari) {
|
if (isSafari) {
|
||||||
|
|||||||
@@ -5,19 +5,6 @@ export interface ContentMetrics {
|
|||||||
offsetY: number;
|
offsetY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scaleToCover = (
|
|
||||||
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.max(scaleX, scaleY);
|
|
||||||
return {
|
|
||||||
width: dimensions.width * scale,
|
|
||||||
height: dimensions.height * scale,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const scaleToFit = (
|
export const scaleToFit = (
|
||||||
dimensions: { width: number; height: number },
|
dimensions: { width: number; height: number },
|
||||||
container: { width: number; height: number },
|
container: { width: number; height: number },
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { scaleToFit } from '$lib/utils/container-utils';
|
|
||||||
|
|
||||||
describe('scaleToFit', () => {
|
|
||||||
const tests = [
|
|
||||||
{
|
|
||||||
name: 'landscape image in square container',
|
|
||||||
dimensions: { width: 2000, height: 1000 },
|
|
||||||
container: { width: 500, height: 500 },
|
|
||||||
expected: { width: 500, height: 250 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'portrait image in square container',
|
|
||||||
dimensions: { width: 1000, height: 2000 },
|
|
||||||
container: { width: 500, height: 500 },
|
|
||||||
expected: { width: 250, height: 500 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'square image in square container',
|
|
||||||
dimensions: { width: 1000, height: 1000 },
|
|
||||||
container: { width: 500, height: 500 },
|
|
||||||
expected: { width: 500, height: 500 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'landscape image in landscape container',
|
|
||||||
dimensions: { width: 1600, height: 900 },
|
|
||||||
container: { width: 800, height: 600 },
|
|
||||||
expected: { width: 800, height: 450 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'portrait image in portrait container',
|
|
||||||
dimensions: { width: 900, height: 1600 },
|
|
||||||
container: { width: 600, height: 800 },
|
|
||||||
expected: { width: 450, height: 800 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'image matches container exactly',
|
|
||||||
dimensions: { width: 500, height: 300 },
|
|
||||||
container: { width: 500, height: 300 },
|
|
||||||
expected: { width: 500, height: 300 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'image smaller than container scales up',
|
|
||||||
dimensions: { width: 100, height: 50 },
|
|
||||||
container: { width: 400, height: 400 },
|
|
||||||
expected: { width: 400, height: 200 },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { name, dimensions, container, expected } of tests) {
|
|
||||||
it(`should handle ${name}`, () => {
|
|
||||||
expect(scaleToFit(dimensions, container)).toEqual(expected);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user