mirror of
https://github.com/immich-app/immich.git
synced 2026-07-16 05:34:30 +03:00
Compare commits
5 Commits
v3.0.2
...
feat/ml-hw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54ada4db71 | ||
|
|
d90bb66daa | ||
|
|
557189d7a8 | ||
|
|
19313e75fd | ||
|
|
352c8086f9 |
43
.github/workflows/test.yml
vendored
43
.github/workflows/test.yml
vendored
@@ -636,6 +636,49 @@ jobs:
|
||||
- name: Run ci-unit
|
||||
run: mise run ci-unit
|
||||
|
||||
ml-hwaccel-tests:
|
||||
name: HW Accel Test ML (${{ matrix.name }})
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == true }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: nvidia-ampere
|
||||
runner: pokedex-dgpu-nvidia-ampere
|
||||
device: cuda
|
||||
expected-ep: CUDAExecutionProvider
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./machine-learning
|
||||
env:
|
||||
IMMICH_HWACCEL_EXPECTED_EP: ${{ matrix.expected-ep }}
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@3bca63ca3c15020293b36b51737a3ee2c773340b # use-mise-action-v3.1.0
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Install
|
||||
run: mise run install --extra ${{ matrix.device }}
|
||||
|
||||
- name: Run hardware tests
|
||||
run: mise run test-hardware
|
||||
|
||||
github-files-formatting:
|
||||
name: .github Files Formatting
|
||||
needs: pre-job
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
/web/ @danieldietzler
|
||||
/machine-learning/ @mertalev
|
||||
/e2e/ @danieldietzler
|
||||
/mobile/ @shenlong-tanwen @santoshakil
|
||||
/mobile/ @shenlong-tanwen @santoshakil @agg23
|
||||
|
||||
@@ -431,8 +431,8 @@
|
||||
"transcoding_realtime_description": "Permet au transcodage d'être réalisé en temps réel durant la diffusion du flux de la vidéo. Active la bascule automatique entre résolutions, mais peut entraîner une latence importante de lecture et des microcoupures en fonction des capacités du serveur.",
|
||||
"transcoding_realtime_enabled": "Activer la transcodification en temps réel",
|
||||
"transcoding_realtime_enabled_description": "Si désactivé, le serveur refusera de démarrer de nouvelles sessions de transcodifications en temps réel.",
|
||||
"transcoding_realtime_resolutions": "Définitions",
|
||||
"transcoding_realtime_resolutions_description": "Les définitions proposées pour le transcodage en temps réel. Des définitions supérieures peuvent causer des soucis pendant la lecture si le serveur ne peut pas les transcoder suffisamment rapidement.",
|
||||
"transcoding_realtime_resolutions": "Résolutions",
|
||||
"transcoding_realtime_resolutions_description": "Les résolutions proposées pour le transcodage en temps réel. Des résolutions supérieures peuvent causer des soucis pendant la lecture si le serveur ne peut pas les transcoder suffisamment rapidement.",
|
||||
"transcoding_realtime_video_codecs": "Codecs vidéo",
|
||||
"transcoding_realtime_video_codecs_description": "Les codecs vidéo proposés pour le transcodage en temps réel. Les clients choisissent la meilleure option qu'ils supportent durant la lecture. AV1 est plus efficace que HEVC, qui est plus efficace que H.264. En utilisant l'accélération matérielle, sélectionnez seulement les codecs que l'accélérateur peut encoder. En utilisant le transcodage logiciel, notez que H.264 est plus rapide que AV1, qui est plus rapide que HEVC.",
|
||||
"transcoding_reference_frames": "Trames de référence",
|
||||
|
||||
@@ -14,6 +14,9 @@ run = "uv run pytest --cov=immich_ml --cov-report term-missing"
|
||||
[tasks.format]
|
||||
run = "uv run ruff format immich_ml"
|
||||
|
||||
[tasks.test-hardware]
|
||||
run = "uv run pytest -m gpu test_hardware.py"
|
||||
|
||||
[tasks.check]
|
||||
run = "uv run mypy --strict immich_ml/"
|
||||
|
||||
|
||||
@@ -92,4 +92,4 @@ select = ["E", "F", "I"]
|
||||
per-file-ignores = { "test_main.py" = ["F403"] }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = ["providers", "ov_device_ids"]
|
||||
markers = ["providers", "ov_device_ids", "gpu"]
|
||||
|
||||
35
machine-learning/test_hardware.py
Normal file
35
machine-learning/test_hardware.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from immich_ml.models import from_model_type
|
||||
from immich_ml.schemas import ModelTask, ModelType
|
||||
|
||||
pytestmark = pytest.mark.gpu
|
||||
|
||||
EXPECTED_EP = os.environ.get("IMMICH_HWACCEL_EXPECTED_EP")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def require_hwaccel_run() -> None:
|
||||
if not EXPECTED_EP:
|
||||
pytest.skip("IMMICH_HWACCEL_EXPECTED_EP unset; not a hardware run")
|
||||
|
||||
|
||||
MODELS = [
|
||||
pytest.param("ViT-B-32__openai", ModelType.VISUAL, ModelTask.SEARCH, id="clip-visual"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_name, model_type, model_task", MODELS)
|
||||
def test_hwaccel_engaged(model_name: str, model_type: ModelType, model_task: ModelTask) -> None:
|
||||
model = from_model_type(model_name, model_type, model_task)
|
||||
model.load()
|
||||
|
||||
# a CPU fallback returns the same output, so the provider list is the only fallback signal
|
||||
assert EXPECTED_EP in model.session.providers, (
|
||||
f"expected {EXPECTED_EP}, session resolved to {model.session.providers}"
|
||||
)
|
||||
|
||||
model.predict(Image.new("RGB", (224, 224)))
|
||||
@@ -7,6 +7,16 @@ import { preferencesFactory } from '@test-data/factories/preferences-factory';
|
||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||
import AssetViewerNavBar from './AssetViewerNavBar.svelte';
|
||||
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
|
||||
return {
|
||||
featureFlagsManager: {
|
||||
init: vi.fn(),
|
||||
loadFeatureFlags: vi.fn(),
|
||||
value: { smartSearch: true, trash: true },
|
||||
} as never,
|
||||
};
|
||||
});
|
||||
|
||||
describe('AssetViewerNavBar component', () => {
|
||||
const additionalProps = {
|
||||
preAction: () => {},
|
||||
@@ -24,15 +34,6 @@ describe('AssetViewerNavBar component', () => {
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
|
||||
return {
|
||||
featureFlagsManager: {
|
||||
init: vi.fn(),
|
||||
loadFeatureFlags: vi.fn(),
|
||||
value: { smartSearch: true, trash: true },
|
||||
} as never,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -4,16 +4,14 @@ import { renderWithTooltips } from '$tests/helpers';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import DeleteAction from './DeleteAction.svelte';
|
||||
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
|
||||
});
|
||||
|
||||
let asset: AssetResponseDto;
|
||||
|
||||
describe('DeleteAction component', () => {
|
||||
beforeEach(() => {
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
|
||||
});
|
||||
});
|
||||
|
||||
describe('given an asset which is not trashed yet', () => {
|
||||
beforeEach(() => {
|
||||
asset = assetFactory.build({ isTrashed: false });
|
||||
|
||||
@@ -4,6 +4,11 @@ import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
|
||||
import { getTabbable } from '$lib/utils/focus-util';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
|
||||
vi.mock('$lib/utils/navigation', () => ({
|
||||
currentUrlReplaceAssetId: vi.fn(),
|
||||
isSharedLinkRoute: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.defineProperty(globalThis, 'matchMedia', {
|
||||
writable: true,
|
||||
@@ -26,10 +31,6 @@ vi.hoisted(() => {
|
||||
describe('Thumbnail component', () => {
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
||||
vi.mock('$lib/utils/navigation', () => ({
|
||||
currentUrlReplaceAssetId: vi.fn(),
|
||||
isSharedLinkRoute: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should only contain a single tabbable element (the container)', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('RecentAlbums component', () => {
|
||||
sdkMock.getAllAlbums.mockResolvedValueOnce([...albums]);
|
||||
render(RecentAlbums);
|
||||
|
||||
expect(sdkMock.getAllAlbums).toBeCalledTimes(1);
|
||||
expect(sdkMock.getAllAlbums).toHaveBeenCalledOnce();
|
||||
|
||||
// wtf
|
||||
await tick();
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('FormatMessage component', () => {
|
||||
|
||||
it('throws an error when locale is empty', async () => {
|
||||
await locale.set(undefined);
|
||||
expect(() => render(FormatMessage, { key: '' as Translations })).toThrowError();
|
||||
expect(() => render(FormatMessage, { key: '' as Translations })).toThrow();
|
||||
await locale.set('en');
|
||||
});
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('should load months in viewport', () => {
|
||||
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
||||
expect(sdkMock.getTimeBuckets).toHaveBeenCalledOnce();
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -133,13 +133,13 @@ describe('TimelineManager', () => {
|
||||
it('loads a month', async () => {
|
||||
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
|
||||
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||
});
|
||||
|
||||
it('ignores invalid months', async () => {
|
||||
await timelineManager.loadTimelineMonth({ year: 2023, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
||||
expect(sdkMock.getTimeBucket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels month loading', async () => {
|
||||
@@ -147,7 +147,7 @@ describe('TimelineManager', () => {
|
||||
void timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
|
||||
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
||||
month?.cancel();
|
||||
expect(abortSpy).toBeCalledTimes(1);
|
||||
expect(abortSpy).toHaveBeenCalledOnce();
|
||||
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
|
||||
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||
});
|
||||
@@ -157,10 +157,10 @@ describe('TimelineManager', () => {
|
||||
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
|
||||
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
|
||||
]);
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
|
||||
|
||||
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('allows loading a canceled month', async () => {
|
||||
@@ -283,7 +283,7 @@ describe('TimelineManager', () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
||||
timelineManager.upsertAssets([asset]);
|
||||
|
||||
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
||||
expect(updateAssetsSpy).toHaveBeenCalledWith([asset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
|
||||
@@ -642,8 +642,8 @@ describe('TimelineManager', () => {
|
||||
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
||||
const previous = await timelineManager.getLaterAsset(a);
|
||||
expect(previous).toEqual(b);
|
||||
expect(loadTimelineMonthSpy).toBeCalledTimes(0);
|
||||
expect(previousMonthSpy).toBeCalledTimes(0);
|
||||
expect(loadTimelineMonthSpy).not.toHaveBeenCalled();
|
||||
expect(previousMonthSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips removed assets', async () => {
|
||||
|
||||
@@ -2,17 +2,15 @@ import type { ServerConfigDto } from '@immich/sdk';
|
||||
import { asUrl } from '$lib/services/shared-link.service';
|
||||
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||
|
||||
describe('SharedLinkService', () => {
|
||||
beforeAll(() => {
|
||||
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
|
||||
serverConfigManager: {
|
||||
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
|
||||
init: vi.fn(),
|
||||
loadServerConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
});
|
||||
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
|
||||
serverConfigManager: {
|
||||
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
|
||||
init: vi.fn(),
|
||||
loadServerConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('SharedLinkService', () => {
|
||||
describe('asUrl', () => {
|
||||
it('should properly encode characters in slug', () => {
|
||||
expect(asUrl(sharedLinkFactory.build({ slug: 'foo/bar' }))).toBe('http://localhost:2283/s/foo%2Fbar');
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { getAlbumDateRange, getShortDateRange } from './date-time';
|
||||
|
||||
vitest.mock('$lib/stores/preferences.store', () => ({
|
||||
locale: writable('en'),
|
||||
}));
|
||||
|
||||
describe('getShortDateRange', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('TZ', 'UTC');
|
||||
@@ -41,10 +45,6 @@ describe('getShortDateRange', () => {
|
||||
describe('getAlbumDate', () => {
|
||||
beforeAll(() => {
|
||||
process.env.TZ = 'UTC';
|
||||
|
||||
vitest.mock('$lib/stores/preferences.store', () => ({
|
||||
locale: writable('en'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should work with only a start date', () => {
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('Executor Queue test', function () {
|
||||
// The last task will be executed after 200ms and will finish at 400ms
|
||||
void eq.addTask(() => timeoutPromiseBuilder(200, 'T4'));
|
||||
|
||||
expect(finished).not.toBeCalled();
|
||||
expect(finished).not.toHaveBeenCalled();
|
||||
expect(started).toHaveBeenCalledTimes(3);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
Reference in New Issue
Block a user