Author SHA1 Message Date
Codex 2c1f321627 Align local web integrations with updated lint rules and Cast types 2026-10-28 21:00:29 +03:00
Codex e8c1fd297d Prepare local13 release and align dependency lockfile 2026-10-28 20:46:38 +03:00
bo0tzzandCodex 8e3e3a48ae perf(server): only serialize exif for returned search rows (#32620)
(cherry picked from commit e51de38c40a29fd5eb1347cd991bbbafccfe62c6)
2026-10-28 20:41:41 +03:00
austinmdillowandCodex dc5553b239 feat: workflow filter by missing EXIF property (#32713)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit a1a80cb1c416d3a480963a78d7b9df329d867bf6)
2026-10-28 20:41:41 +03:00
renovate[bot]andCodex 32e02a05d2 chore(deps): update terraform cloudflare to v5 (#32794)
(cherry picked from commit d8aaa950099f6687a8f6861f63466242a748ac1b)
2026-10-28 20:41:41 +03:00
shenlongandCodex c581bce715 fix: include partner shared assets for people even without showInTimeline (#32675)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
(cherry picked from commit 21ed7454836073a646273c7475b347e043439a90)
2026-10-28 20:41:41 +03:00
Daniel DietzlerandCodex dd23264385 chore: deploy docs when terraform changes (#32795)
(cherry picked from commit 4ea9c1f7c520f1e1c6b6f22968abdb7f9b4aa29f)
2026-10-28 20:41:41 +03:00
Jason RasmussenandCodex 6dbe72b6a1 fix: gc time for long-lived queries (#32793)
(cherry picked from commit 079568691580605e92c5d3c3c9037cfbd657f374)
2026-10-28 20:41:41 +03:00
Daniel DietzlerandCodex 72f96701f0 feat: cluster group admin permission for regenerate people (#32787)
(cherry picked from commit 9f273a604d5bdbd9583758c3a79d2fc0141c7a77)
2026-10-28 20:41:41 +03:00
Jason RasmussenandCodex b4ae7f0dcf feat: passkey feature level (#32791)
(cherry picked from commit f5d33ff6618510917fb9f7bce705ec9583a207fb)
2026-10-28 20:41:41 +03:00
renovate[bot]andCodex 09bb2a9fb5 fix(deps): update typescript-projects (#32759)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit f26b9c83adb12848e94bcfc77e1b67be1756bdc9)
2026-10-28 20:41:41 +03:00
bo0tzzandCodex 8f2bfa099e fix(server): scope duplicate group resolution to the caller's assets (#32603)
(cherry picked from commit 7bb91afbe15e82502f8f6c92b61edb3865ce76f3)
2026-10-28 20:41:28 +03:00
bo0tzzandCodex 07083aec22 fix(server): anchor the folder view path match (#32612)
(cherry picked from commit c76e1517321df98df6e9b2f856493ac49ec05877)
2026-10-28 20:41:28 +03:00
bo0tzzandCodex 64d50dc4b7 perf(server): index asset (ownerId, updateId) for sync (#32624)
(cherry picked from commit 73603bcafe5d1e52434f736476d07c9578f3b888)
2026-10-28 20:41:28 +03:00
renovate[bot]andCodex 7fb7699c06 chore(deps): update dependency eslint-plugin-unicorn to v78 (#32588)
Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 72e2bc8b0bc4a7e0a84ac16c0bde7f5e538729fb)
2026-10-28 20:41:28 +03:00
Santo ShakilandCodex 3127dbec71 refactor(mobile): move the native core decoders into a codecs crate (#32765)
* refactor(mobile): move the native core decoders into a codecs crate

* make Format private

* rename the decode export and options for what they hold

* drop the unused ios and server groups and rename the target params

(cherry picked from commit 11bf875313782b8a9e10269bb6f85226b7bdcd72)
2026-10-28 20:40:53 +03:00
Horatiu George TodoranandCodex f418967d55 perf: RKNN half RAM model usage + no weight duplication in multi-core (#23958)
* perf(ml): share RKNN weights across NPU cores and halve model RAM

* perf(ml): default rknn threads to npu cores and cache shape setup

* perf(ml): serve rknn runs in arrival order

(cherry picked from commit e659fb62f2bb3f6b1db08ed643d3d054a539c415)
2026-10-28 20:40:53 +03:00
YarosandCodex 1f422ea4c4 fix(server): add album-shared assets to albums (#32609)
Co-authored-by: Jason Rasmussen <jason@rasm.me>
(cherry picked from commit 37c34343e1b98edf07dc3ff34c5da461de03c746)
2026-10-28 20:40:53 +03:00
231 changed files with 2410 additions and 1607 deletions
+1
View File
@@ -35,6 +35,7 @@ jobs:
filters: |
docs:
- 'docs/**'
- 'deployment/**'
open-api:
- 'open-api/immich-openapi-specs.json'
force-filters: |
+18 -28
View File
@@ -2,34 +2,24 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.10"
constraints = "4.52.10"
version = "5.28.1"
constraints = "5.28.1"
hashes = [
"h1:57oeYqz+pYEPAw2GvHARjcER0QYU0teS6DJzS26kCAY=",
"h1:6bnAgXSboppQbj79XmRtQLj/58d+b4EozydI8XrqwRE=",
"h1:6eP/jGOz1ioH17d3pygKhvsRa4AEKvedKqd+wgyiHYk=",
"h1:84gJm57BBSvtfwt7t9ZiU7EKKDQARjsztoDuqB5DfqU=",
"h1:99tME9L/Ug/dXElX3Ac7Kx+KD7nhWEEwOTqaQubW18w=",
"h1:TnMGVaPpIH5NZX324v+y6j1iW3ym0MqqNekxHlzXT3A=",
"h1:Z1L5jI4GiJxAAsfNyZHU4MGTluWEjjKtlB8Xn6cA0Dg=",
"h1:dB/WsY27MLla/cwjYggolJhVeHUeS59rlOObAWK2e24=",
"h1:gpffNocq9fHK+uQehZQK6YmL1aHeCGrVM1p53dcFP/0=",
"h1:jvfw1A2Uck4AjNmmWXwGH6P0sGQrnNY/KUVlqgyqYUM=",
"h1:ojvrl9uI1EPtAwKRHim9eSc4Lc88thU/2BFGdJLEwrc=",
"h1:rJZJK06NYCcSZhY5fwsRUSznSmAmRWVg4sO0auvbURo=",
"h1:y2m6i2SnQ3ZxiJHlMWNLHJrEbNqQT6OpDGyhFP+Nmds=",
"zh:06c09cc50d6c61fb5f1ca79e4b22d5f383c6a848f885ed542dadef44ab4bfd55",
"zh:205bdfdac49d6b25ec2ca7ecffd39bf47126340e1943066bdbbd64d6d72577d2",
"zh:341ed688772d8e2bd9a148c57d56d5dfbe3d625bc50296a505cbae3d29694562",
"zh:3790619bae55558ab4d93f53686f7cf55f35138f800ea07b0b9c20cce75b9c0d",
"zh:44284a5b67e34f1381bbc47b6e765cee581037e25cd3b459caec069493b63898",
"zh:493a173f3d5245db82782e8c1aba63e34c6fcf6b952b9288926b519bfe9d9725",
"zh:4eb7846381fee6eefdb7e0aaf80153ed71d35547752f96987218a9429645844f",
"zh:850b1d1da770f48def40223d754ff65c31fabb1a7f334ed6ebb40eba09c41572",
"zh:9a29a08a9fd40d7c1beefe8597617c2fc475d62ec6d5ca8fcc2cd1a056a67a2e",
"zh:9e57472f0a56863204f5b2eef125d4fb1222eca793c6a5ac9f4df6c1776edc48",
"zh:a9eff98931da596c2d335359841bfd668f4b6cde257e15546a558c7f5d72773b",
"zh:c9293fb3390c79e4f5072e019c0a2c4c7408a55a55e78ae37f739a1da75886ef",
"zh:c9e98abec7f7a1e2669dd9c065896217125d774640e4673c1303a942736b3eb4",
"h1:9YL6g+iEfg3OzX6qSZOYsictVO0fNP09D6jRwIZKwQ0=",
"h1:GjOKfCmvcyE06fUp5WrAzfCLkTQaCg2Ko/kFlq8OfpM=",
"h1:QTIohIPsVCqEmknrR2PdbVXyK1fjS805rMaZpEEpzFQ=",
"h1:SFb952CVUFUoFjfor+GUqAV6DQ6bnUfaFcWL2yJlFBo=",
"h1:aso3s6IuIQju5L/OLrywCfQl7aA5TF+sb5pZzo8JG20=",
"h1:cxGa9LRKCzwNacgnLjXnHUygkvAEd6pb8g8sRwzPSRE=",
"h1:kBW0qcDqNupOnsi7vvx/0TQRxXLtmDKAHwOwH5l03pc=",
"h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
"zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
"zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
"zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
"zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
"zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
"zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
"zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
"zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
]
}
@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.10"
version = "5.28.1"
}
}
}
+18 -28
View File
@@ -2,34 +2,24 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.10"
constraints = "4.52.10"
version = "5.28.1"
constraints = "5.28.1"
hashes = [
"h1:57oeYqz+pYEPAw2GvHARjcER0QYU0teS6DJzS26kCAY=",
"h1:6bnAgXSboppQbj79XmRtQLj/58d+b4EozydI8XrqwRE=",
"h1:6eP/jGOz1ioH17d3pygKhvsRa4AEKvedKqd+wgyiHYk=",
"h1:84gJm57BBSvtfwt7t9ZiU7EKKDQARjsztoDuqB5DfqU=",
"h1:99tME9L/Ug/dXElX3Ac7Kx+KD7nhWEEwOTqaQubW18w=",
"h1:TnMGVaPpIH5NZX324v+y6j1iW3ym0MqqNekxHlzXT3A=",
"h1:Z1L5jI4GiJxAAsfNyZHU4MGTluWEjjKtlB8Xn6cA0Dg=",
"h1:dB/WsY27MLla/cwjYggolJhVeHUeS59rlOObAWK2e24=",
"h1:gpffNocq9fHK+uQehZQK6YmL1aHeCGrVM1p53dcFP/0=",
"h1:jvfw1A2Uck4AjNmmWXwGH6P0sGQrnNY/KUVlqgyqYUM=",
"h1:ojvrl9uI1EPtAwKRHim9eSc4Lc88thU/2BFGdJLEwrc=",
"h1:rJZJK06NYCcSZhY5fwsRUSznSmAmRWVg4sO0auvbURo=",
"h1:y2m6i2SnQ3ZxiJHlMWNLHJrEbNqQT6OpDGyhFP+Nmds=",
"zh:06c09cc50d6c61fb5f1ca79e4b22d5f383c6a848f885ed542dadef44ab4bfd55",
"zh:205bdfdac49d6b25ec2ca7ecffd39bf47126340e1943066bdbbd64d6d72577d2",
"zh:341ed688772d8e2bd9a148c57d56d5dfbe3d625bc50296a505cbae3d29694562",
"zh:3790619bae55558ab4d93f53686f7cf55f35138f800ea07b0b9c20cce75b9c0d",
"zh:44284a5b67e34f1381bbc47b6e765cee581037e25cd3b459caec069493b63898",
"zh:493a173f3d5245db82782e8c1aba63e34c6fcf6b952b9288926b519bfe9d9725",
"zh:4eb7846381fee6eefdb7e0aaf80153ed71d35547752f96987218a9429645844f",
"zh:850b1d1da770f48def40223d754ff65c31fabb1a7f334ed6ebb40eba09c41572",
"zh:9a29a08a9fd40d7c1beefe8597617c2fc475d62ec6d5ca8fcc2cd1a056a67a2e",
"zh:9e57472f0a56863204f5b2eef125d4fb1222eca793c6a5ac9f4df6c1776edc48",
"zh:a9eff98931da596c2d335359841bfd668f4b6cde257e15546a558c7f5d72773b",
"zh:c9293fb3390c79e4f5072e019c0a2c4c7408a55a55e78ae37f739a1da75886ef",
"zh:c9e98abec7f7a1e2669dd9c065896217125d774640e4673c1303a942736b3eb4",
"h1:9YL6g+iEfg3OzX6qSZOYsictVO0fNP09D6jRwIZKwQ0=",
"h1:GjOKfCmvcyE06fUp5WrAzfCLkTQaCg2Ko/kFlq8OfpM=",
"h1:QTIohIPsVCqEmknrR2PdbVXyK1fjS805rMaZpEEpzFQ=",
"h1:SFb952CVUFUoFjfor+GUqAV6DQ6bnUfaFcWL2yJlFBo=",
"h1:aso3s6IuIQju5L/OLrywCfQl7aA5TF+sb5pZzo8JG20=",
"h1:cxGa9LRKCzwNacgnLjXnHUygkvAEd6pb8g8sRwzPSRE=",
"h1:kBW0qcDqNupOnsi7vvx/0TQRxXLtmDKAHwOwH5l03pc=",
"h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
"zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
"zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
"zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
"zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
"zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
"zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
"zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
"zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
]
}
+1 -1
View File
@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.10"
version = "5.28.1"
}
}
}
@@ -85,7 +85,9 @@ You do not need to redo any machine learning jobs after enabling hardware accele
- RKNPU driver V0.9.8 or later must be available in the host server
- You may confirm this by running `cat /sys/kernel/debug/rknpu/version` to check the version
- Optional: Configure your `.env` file, see [environment variables](/install/environment-variables) for RKNN specific settings
- In particular, setting `MACHINE_LEARNING_RKNN_THREADS` to 2 or 3 can _dramatically_ improve performance for RK3576 and RK3588 compared to the default of 1, at the expense of multiplying the amount of RAM each model uses by that amount.
- By default each model gets one context per NPU core (1 on RK3566/RK3568, 2 on RK3576, 3 on RK3588), so concurrent requests run on all cores at once. The contexts of a model share its weights, but each one needs its own working memory. Extra contexts therefore cost little for models that are mostly weights, such as CLIP, but for OCR detection each one can add close to the model's own memory again, more so at higher OCR resolutions. Set `MACHINE_LEARNING_RKNN_THREADS=1` to trade throughput for memory.
- Keep `MACHINE_LEARNING_WORKERS=1` unless you have a specific reason not to: each worker process still loads its own copy of the weights.
- Raise Immich job concurrency (Smart Search, Face Detection, OCR) a bit above the number of NPU cores, e.g. `4`–`6`, so a prepared image is always waiting when a core frees up.
## Setup
@@ -100,7 +102,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
You can confirm the device is being recognized and used by checking its utilization. There are many tools to display this, such as `nvtop` for NVIDIA or Intel, `intel_gpu_top` for Intel, and `radeontop` for AMD.
You can also check the logs of the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, or when you search with text in Immich, you should either see a log for `Available ORT providers` containing the relevant provider (e.g. `CUDAExecutionProvider` in the case of CUDA), or a `Loaded ANN model` log entry without errors in the case of ARM NN.
You can also check the logs of the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, or when you search with text in Immich, you should either see a log for `Available ORT providers` containing the relevant provider (e.g. `CUDAExecutionProvider` in the case of CUDA), a `Loaded ANN model` log entry without errors in the case of ARM NN, or a `Loaded RKNN model ... threads sharing its weights` log entry for RKNN.
#### Single Compose File
@@ -174,7 +176,7 @@ Note that you should increase job concurrencies to increase overall utilization
- Less heat generation
- Very slightly lower accuracy (RKNPU always uses FP16, while ARM NN by default uses higher precision FP32 unless `MACHINE_LEARNING_ANN_FP16_TURBO` is enabled)
- Varying speed (tested on RK3588):
- If `MACHINE_LEARNING_RKNN_THREADS` is at the default of 1, RKNPU will have substantially lower throughput for ML jobs than ARM NN in most cases, but similar latency (such as when searching)
- If `MACHINE_LEARNING_RKNN_THREADS` is set to 3, it will be somewhat faster than ARM NN at FP32, but somewhat slower than ARM NN if `MACHINE_LEARNING_ANN_FP16_TURBO` is enabled
- With a single context (`MACHINE_LEARNING_RKNN_THREADS=1`), RKNPU will have substantially lower throughput for ML jobs than ARM NN in most cases, but similar latency (such as when searching)
- With three contexts (the default on RK3588), it will be somewhat faster than ARM NN at FP32, but somewhat slower than ARM NN if `MACHINE_LEARNING_ANN_FP16_TURBO` is enabled
- When other tasks also use the GPU (like transcoding), RKNPU has a significant advantage over ARM NN as it uses the otherwise idle NPU instead of competing for GPU usage
- Lower RAM usage if `MACHINE_LEARNING_RKNN_THREADS` is at the default of 1, but significantly higher if greater than 1 (which is necessary for it to fully utilize the NPU and hence be comparable in speed to ARM NN)
- Lower RAM usage than earlier RKNN builds: the contexts of a model share its weights instead of each loading their own copy. Keep `MACHINE_LEARNING_WORKERS=1` and raise Immich ML job concurrency a bit above the core count so every NPU core stays busy.
+1 -1
View File
@@ -179,7 +179,7 @@ Redis (Sentinel) URL example JSON before encoding:
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | `4` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | Number of RKNN contexts per model, which share its weights | number of NPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
| `MACHINE_LEARNING_MODEL_REVISION` | Model revision to download; `main` selects the original exports and `v2` selects the newer exports | `main` | machine learning |
+1 -1
View File
@@ -40,7 +40,7 @@
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^75.0.0",
"eslint-plugin-unicorn": "^78.0.0",
"exiftool-vendored": "^39.0.0",
"globals": "^17.0.0",
"luxon": "^3.4.4",
+3 -9
View File
@@ -628,25 +628,19 @@ describe('/albums', () => {
]);
});
it('should not be able to remove foreign asset from foreign album', async () => {
it('should be able to remove foreign asset from shared album as an editor', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset1.id] });
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({
id: user1Asset1.id,
success: false,
error: 'no_permission',
}),
]);
expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]);
});
it('should be able to remove own asset from own album', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user1Albums[0].id}/assets`)
.delete(`/albums/${user1Albums[2].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset1.id] });
@@ -14,9 +14,7 @@ type DayDistributionStrategy = (
rng: SeededRandom,
) => MockTimelineAsset[];
/**
* Strategies for determining total asset count per month
*/
/** Strategies for determining total asset count per month */
export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = {
empty: null, // Special case - handled separately
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
@@ -25,9 +23,7 @@ export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStra
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
};
/**
* Strategies for distributing assets across days within a month
*/
/** Strategies for distributing assets across days within a month */
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// All assets on one day in the middle of the month
@@ -1,6 +1,4 @@
/**
* Generator functions for timeline model objects
*/
/** Generator functions for timeline model objects */
import { faker } from '@faker-js/faker';
import { AssetVisibility } from '@immich/sdk';
@@ -12,9 +10,7 @@ import { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './distribution-patterns';
import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config';
/**
* Generate a random aspect ratio based on weighted probabilities
*/
/** Generate a random aspect ratio based on weighted probabilities */
export function generateAspectRatio(rng: SeededRandom): string {
const random = rng.next();
let cumulative = 0;
@@ -89,9 +85,7 @@ export function generateAsset(
return asset;
}
/**
* Generate assets for a specific day
*/
/** Generate assets for a specific day */
export function generateDayAssets(
year: number,
month: number,
@@ -104,10 +98,10 @@ export function generateDayAssets(
}
/**
* Distribute assets evenly across consecutive days
*
* @returns Array of generated timeline assets
*/
Distribute assets evenly across consecutive days
@returns Array of generated timeline assets
*/
export function generateConsecutiveDays(
year: number,
month: number,
@@ -133,9 +127,7 @@ export function generateConsecutiveDays(
return assets;
}
/**
* Generate assets for a month with specified distribution pattern
*/
/** Generate assets for a month with specified distribution pattern */
export function generateMonthAssets(
year: number,
month: number,
@@ -173,9 +165,7 @@ export function generateMonthAssets(
return assets;
}
/**
* Main generator function for timeline data
*/
/** Main generator function for timeline data */
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
validateTimelineConfig(config);
+30 -34
View File
@@ -1,6 +1,4 @@
/**
* REST API output functions for converting timeline data to API response formats
*/
/** REST API output functions for converting timeline data to API response formats */
import {
AlbumUserRole,
@@ -19,9 +17,7 @@ import { signupDto } from 'src/fixtures.js';
import { parseTimeBucketKey } from 'src/ui/generators/timeline/utils.js';
import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
/**
* Convert timeline/asset models to columnar format (parallel arrays)
*/
/** Convert timeline/asset models to columnar format (parallel arrays) */
export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
const result: TimeBucketAssetResponseDto = {
id: [],
@@ -70,11 +66,11 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
}
/**
* Extract a single bucket from timeline data (mimics getTimeBucket API)
* Automatically handles both ISO timestamp and simple month formats
* Returns data in columnar format matching the actual API
* When albumId is provided, only returns assets from that album
*/
Extract a single bucket from timeline data (mimics getTimeBucket API)
Automatically handles both ISO timestamp and simple month formats
Returns data in columnar format matching the actual API
When albumId is provided, only returns assets from that album
*/
export function getTimeBucket(
timelineData: MockTimelineData,
timeBucket: string,
@@ -141,16 +137,16 @@ export type Changes = {
};
/**
* Helper function to determine if an asset should be included based on filter criteria
* @param asset - The asset to check
* @param isTrashed - Filter for trashed status (undefined means no filter)
* @param isArchived - Filter for archived status (undefined means no filter)
* @param isFavorite - Filter for favorite status (undefined means no filter)
* @param deletedAssetIds - Set of IDs for assets that have been deleted
* @param archivedAssetIds - Set of IDs for assets that have been archived
* @param favoritedAssetIds - Set of IDs for assets that have been favorited
* @returns true if the asset matches all filter criteria
*/
Helper function to determine if an asset should be included based on filter criteria
@param asset - The asset to check
@param isTrashed - Filter for trashed status (undefined means no filter)
@param isArchived - Filter for archived status (undefined means no filter)
@param isFavorite - Filter for favorite status (undefined means no filter)
@param deletedAssetIds - Set of IDs for assets that have been deleted
@param archivedAssetIds - Set of IDs for assets that have been archived
@param favoritedAssetIds - Set of IDs for assets that have been favorited
@returns true if the asset matches all filter criteria
*/
function shouldIncludeAsset(
asset: MockTimelineAsset,
isTrashed: boolean | undefined,
@@ -175,9 +171,9 @@ function shouldIncludeAsset(
return isFavorite === undefined || actuallyFavorited === isFavorite;
}
/**
* Get summary for all buckets (mimics getTimeBuckets API)
* When albumId is provided, only includes buckets that contain assets from that album
*/
Get summary for all buckets (mimics getTimeBuckets API)
When albumId is provided, only includes buckets that contain assets from that album
*/
export function getTimeBuckets(
timelineData: MockTimelineData,
isTrashed: boolean | undefined,
@@ -279,9 +275,9 @@ const createDefaultOwner = (ownerId: string) => {
};
/**
* Convert a TimelineAssetConfig to a full AssetResponseDto
* This matches the response from GET /api/assets/:id
*/
Convert a TimelineAssetConfig to a full AssetResponseDto
This matches the response from GET /api/assets/:id
*/
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
const now = new Date().toISOString();
@@ -292,7 +288,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
make: null,
model: null,
exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
exifImageHeight: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(3000 * asset.ratio),
exifImageHeight: Math.round(asset.ratio > 1 ? 4000 : 3000 / asset.ratio),
fileSizeInByte: asset.fileSizeInByte,
orientation: '1',
dateTimeOriginal: asset.fileCreatedAt,
@@ -348,9 +344,9 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
}
/**
* Get a single asset by ID from timeline data
* This matches the response from GET /api/assets/:id
*/
Get a single asset by ID from timeline data
This matches the response from GET /api/assets/:id
*/
export function getAsset(
timelineData: MockTimelineData,
assetId: string,
@@ -368,9 +364,9 @@ export function getAsset(
}
/**
* Get a mock album from timeline data
* This matches the response from GET /api/albums/:id
*/
Get a mock album from timeline data
This matches the response from GET /api/albums/:id
*/
export function getAlbum(
timelineData: MockTimelineData,
ownerId: string,
@@ -57,9 +57,7 @@ export type MonthSpec = {
pattern: DayPattern;
};
/**
* Configuration for timeline data generation
*/
/** Configuration for timeline data generation */
export type TimelineConfig = {
ownerId?: string;
months: MonthSpec[];
@@ -88,9 +86,7 @@ export type SerializedTimelineData = {
album: MockAlbum;
};
/**
* Validates a TimelineConfig object to ensure all values are within expected ranges
*/
/** Validates a TimelineConfig object to ensure all values are within expected ranges */
export function validateTimelineConfig(config: TimelineConfig): void {
if (!config.months || config.months.length === 0) {
throw new Error('TimelineConfig must contain at least one month');
@@ -146,9 +142,7 @@ export function validateTimelineConfig(config: TimelineConfig): void {
}
}
/**
* Create a default timeline configuration
*/
/** Create a default timeline configuration */
export function createDefaultTimelineConfig(): TimelineConfig {
const months: MonthSpec[] = [
// 2024 - Mix of patterns
+19 -30
View File
@@ -1,9 +1,7 @@
import { DateTime } from 'luxon';
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config.js';
/**
* Linear Congruential Generator for deterministic pseudo-random numbers
*/
/** Linear Congruential Generator for deterministic pseudo-random numbers */
export class SeededRandom {
private seed: number;
@@ -11,38 +9,32 @@ export class SeededRandom {
this.seed = seed;
}
/**
* Generate next random number in range [0, 1)
*/
/** Generate next random number in range [0, 1) */
next(): number {
// LCG parameters from Numerical Recipes
this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
return this.seed / 2_147_483_647;
}
/**
* Generate random integer in range [min, max)
*/
/** Generate random integer in range [min, max) */
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min)) + min;
}
/**
* Generate random boolean with given probability
*/
/** Generate random boolean with given probability */
nextBoolean(probability = 0.5): boolean {
return this.next() < probability;
}
}
/**
* Select random days using seed variation to avoid collisions.
*
* @param daysInMonth - Total number of days in the month
* @param numDays - Number of days to select
* @param rng - Random number generator instance
* @returns Array of selected day numbers, sorted in descending order
*/
Select random days using seed variation to avoid collisions.
@param daysInMonth - Total number of days in the month
@param numDays - Number of days to select
@param rng - Random number generator instance
@returns Array of selected day numbers, sorted in descending order
*/
export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
const selectedDays = new Set<number>();
const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit
@@ -64,9 +56,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
return [...selectedDays].toSorted((a, b) => b - a);
}
/**
* Select item from array using seeded random
*/
/** Select item from array using seeded random */
export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
if (arr.length === 0) {
throw new Error('Cannot select from empty array');
@@ -75,9 +65,7 @@ export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
return arr[index];
}
/**
* Select multiple random items from array using seeded random without duplicates
*/
/** Select multiple random items from array using seeded random without duplicates */
export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] {
if (arr.length === 0) {
throw new Error('Cannot select from empty array');
@@ -104,11 +92,11 @@ export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRand
}
/**
* Parse timeBucket parameter to extract year-month key
* Handles both formats:
* - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
* - Simple format: "2024-12-01" -> "2024-12-01"
*/
Parse timeBucket parameter to extract year-month key
Handles both formats:
- ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
- Simple format: "2024-12-01" -> "2024-12-01"
*/
export function parseTimeBucketKey(timeBucket: string): string {
if (!timeBucket) {
throw new Error('timeBucket parameter cannot be empty');
@@ -150,6 +138,7 @@ export function getMockAsset(
return null;
}
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
if (unit === 'day') {
return !date1.startOf('day').equals(date2.startOf('day'));
@@ -242,6 +242,7 @@ test.describe('OCR text interaction', () => {
return;
}
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const createTouch = (id: number, x: number, y: number) => {
return new Touch({
identifier: id,
@@ -150,10 +150,10 @@ test.describe('Timeline', () => {
});
test.describe('keyboard', () => {
/**
* This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
* scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
* as necessary downwards), then the asset should always be at the lowest row of the grid.
*/
This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
as necessary downwards), then the asset should always be at the lowest row of the grid.
*/
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
+5 -5
View File
@@ -270,13 +270,13 @@ export const utils = {
}
},
waitForWebsocketEvent: ({ event, id, total: count, timeout: ms }: WaitOptions): Promise<void> => {
waitForWebsocketEvent: ({ event, id, total: count, timeout: ms = 10_000 }: WaitOptions): Promise<void> => {
return new Promise<void>((resolve, reject) => {
if (!id && !count) {
reject(new Error('id or count must be provided for waitForWebsocketEvent'));
}
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000);
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms);
const type = id ? `id=${id}` : `count=${count}`;
console.log(`Waiting for ${event} [${type}]`);
const set = events[event];
@@ -550,7 +550,7 @@ export const utils = {
{
headers: asBearerAuth(accessToken),
fetch: (...args: Parameters<typeof fetch>) =>
// eslint-disable-next-line unicorn/no-invalid-argument-count, unicorn/prefer-await
// eslint-disable-next-line unicorn/prefer-await
fetch(...args).then((response) => {
setCookie = response.headers.getSetCookie();
return response;
@@ -660,10 +660,10 @@ export const utils = {
return !jobCounts.active && (!jobCounts.waiting || queues[queue].queueStatus.isPaused);
},
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms: number = 15_000) => {
// eslint-disable-next-line no-async-promise-executor
return new Promise<void>(async (resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 15_000);
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms);
while (true) {
const done = await utils.isQueueEmpty(accessToken, queue);
+1 -1
View File
@@ -70,7 +70,7 @@ class Settings(BaseSettings):
ann_fp16_turbo: bool = False
ann_tuning_level: int = 2
rknn: bool = True
rknn_threads: int = 1
rknn_threads: int | None = None
preload: PreloadModelData | None = None
max_batch_size: MaxBatchSize = MaxBatchSize()
model_organization: ModelOrganization = ModelOrganization.APP
@@ -100,8 +100,7 @@ SUPPORTED_PROVIDERS = [
"CPUExecutionProvider",
]
RKNN_SUPPORTED_SOCS = ["rk3566", "rk3568", "rk3576", "rk3588"]
RKNN_COREMASK_SUPPORTED_SOCS = ["rk3576", "rk3588"]
RKNN_NPU_CORES = {"rk3566": 1, "rk3568": 1, "rk3576": 2, "rk3588": 3}
WEBLATE_TO_FLORES200 = {
@@ -9,9 +9,10 @@ import orjson
from numpy.typing import NDArray
from immich_ml.config import log, settings
from immich_ml.models.constants import RKNN_NPU_CORES
from immich_ml.schemas import ModelInput, ModelTensor, SessionNode, Shape
from .rknnpool import RknnNode, RknnPoolExecutor, is_available, native_outputs, soc_name
from .rknnpool import RknnNode, RknnPoolExecutor, is_available, soc_name
is_available = is_available and settings.rknn
model_prefix = Path("rknpu") / soc_name if is_available and soc_name is not None else None
@@ -21,12 +22,6 @@ def model_path(model_dir: Path, variant: str = "") -> Path:
return (model_dir / model_prefix if model_prefix else model_dir) / variant / "model.rknn"
def run_inference(rknn_lite: Any, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[np.float32]]:
rknn_lite.rknn_runtime.set_inputs(inputs, None, data_format)
rknn_lite.rknn_runtime.run(False)
return native_outputs(rknn_lite)
def input_layout(compiled: tuple[int, ...], array: ModelTensor) -> str | None:
if array.ndim != 4:
return None # token ids carry no layout
@@ -39,11 +34,11 @@ def input_layout(compiled: tuple[int, ...], array: ModelTensor) -> str | None:
class RknnSession:
def __init__(self, model_path: Path) -> None:
self.tpe = settings.rknn_threads
self.tpe = settings.rknn_threads or RKNN_NPU_CORES.get(soc_name or "", 1)
log.info(f"Loading RKNN model from {model_path} with {self.tpe} threads.")
self.rknnpool = RknnPoolExecutor(model_path=model_path.as_posix(), tpes=self.tpe, func=run_inference)
log.info(f"Loaded RKNN model from {model_path} with {self.tpe} threads.")
self.rknnpool = RknnPoolExecutor(model_path=model_path.as_posix(), tpes=self.tpe)
log.info(f"Loaded RKNN model from {model_path} with {self.tpe} threads sharing its weights.")
# the shapes the binary was compiled for, among which the runtime routes itself
batch = self.rknnpool.inputs[0].shape[0]
declared = orjson.loads(self.rknnpool.custom_string) if self.rknnpool.custom_string else {}
@@ -77,7 +72,7 @@ class RknnSession:
input_feed: ModelInput,
run_options: Any = None,
) -> list[NDArray[np.float32]]:
inputs = [array if array.flags.c_contiguous else array.copy() for array in input_feed.values()]
inputs = list(input_feed.values())
return self.rknnpool.run(inputs, input_layout(self.rknnpool.inputs[0].shape, inputs[0]))
@@ -1,18 +1,15 @@
# This code is from leafqycc/rknn-multi-threaded
# Following Apache License 2.0
import ctypes
import logging
import threading
from concurrent.futures import ThreadPoolExecutor
from ctypes import byref, sizeof
from pathlib import Path
from typing import Any, Callable, NamedTuple
from queue import SimpleQueue
from typing import Any, NamedTuple
import numpy as np
from numpy.typing import NDArray
from immich_ml.config import log
from immich_ml.models.constants import RKNN_COREMASK_SUPPORTED_SOCS, RKNN_SUPPORTED_SOCS
from immich_ml.models.constants import RKNN_NPU_CORES
from immich_ml.schemas import ModelTensor
@@ -20,7 +17,7 @@ def get_soc(device_tree_path: Path | str) -> str | None:
try:
with Path(device_tree_path).open() as f:
device_compatible_str = f.read()
for soc in RKNN_SUPPORTED_SOCS:
for soc in RKNN_NPU_CORES:
if soc in device_compatible_str:
return soc
log.warning("Device is not supported for RKNN")
@@ -29,26 +26,34 @@ def get_soc(device_tree_path: Path | str) -> str | None:
return None
soc_name = None
is_available = False
try:
from rknnlite.api import RKNNLite, rknn_runtime
soc_name = get_soc("/proc/device-tree/compatible")
is_available = soc_name is not None
except ImportError:
log.debug("RKNN is not available")
class RknnNode(NamedTuple):
name: str
shape: tuple[int, ...]
_CUSTOM_STRING_QUERY = 7 # RKNN_QUERY_CUSTOM_STRING
# RKNN_QUERY_OUTPUT_ATTR, and RKNN_QUERY_CURRENT_OUTPUT_ATTR for the shape a dynamic binary last produced
_OUTPUT_QUERY, _CURRENT_OUTPUT_QUERY = 2, 15
_DTYPES = {0: np.float32, 1: np.float16} # rknn_tensor_type
# rknn_query_cmd
_IN_OUT_NUM, _INPUT_ATTR, _OUTPUT_ATTR, _CUSTOM_STRING = 0, 1, 2, 7
_INPUT_DYNAMIC_RANGE, _CURRENT_OUTPUT_ATTR = 13, 15
# rknn_tensor_format
_NCHW, _NHWC = 0, 1
# rknn_tensor_type, in enum order
_DTYPES: list[type[np.generic]] = [
np.float32,
np.float16,
np.int8,
np.uint8,
np.int16,
np.uint16,
np.int32,
np.uint32,
np.int64,
np.bool_,
]
_TYPES = {np.dtype(dtype): index for index, dtype in enumerate(_DTYPES)}
class _IoNum(ctypes.Structure):
_fields_ = [("n_input", ctypes.c_uint32), ("n_output", ctypes.c_uint32)]
class _CustomString(ctypes.Structure):
@@ -76,94 +81,194 @@ class _TensorAttr(ctypes.Structure): # rknn_tensor_attr
]
def native_outputs(rknn_lite: "RKNNLite") -> list[NDArray[Any]]:
"""The outputs in the precision the NPU computed them in, which RKNNLite would widen to fp32 on the CPU."""
runtime: Any = rknn_lite.rknn_runtime
count = runtime.get_in_out_num()[1]
buffers = (rknn_runtime.RKNNOutput * count)()
for index, buffer in enumerate(buffers):
buffer.index = index
if runtime.lib.rknn_outputs_get(runtime.context, count, buffers, None) != 0:
raise RuntimeError("RKNN inference failed!")
query = _CURRENT_OUTPUT_QUERY if runtime.is_dynamic_shape else _OUTPUT_QUERY
outputs = []
for buffer in buffers:
attr = _TensorAttr(index=buffer.index)
runtime.lib.rknn_query(runtime.context, query, ctypes.byref(attr), ctypes.sizeof(attr))
data = ctypes.cast(buffer.buf, ctypes.POINTER(ctypes.c_char * buffer.size)).contents
outputs.append(np.frombuffer(data, _DTYPES[attr.type]).reshape(attr.dims[: attr.n_dims]).copy())
runtime.lib.rknn_outputs_release(runtime.context, count, buffers)
return outputs
class _InputRange(ctypes.Structure):
_fields_ = [
("index", ctypes.c_uint32),
("shape_number", ctypes.c_uint32),
("fmt", ctypes.c_int),
("name", ctypes.c_char * 256),
("dyn_range", (ctypes.c_uint32 * 16) * 512),
("n_dims", ctypes.c_uint32),
]
def tensor_nodes(rknn_lite: "RKNNLite") -> tuple[list[RknnNode], list[RknnNode]]:
runtime = rknn_lite.rknn_runtime
n_inputs, n_outputs = runtime.get_in_out_num()
def node(index: int, is_output: bool) -> RknnNode:
attr = runtime.get_tensor_attr(index, is_output=is_output)
return RknnNode(attr.name.decode(), tuple(attr.dims[: attr.n_dims]))
return [node(i, False) for i in range(n_inputs)], [node(i, True) for i in range(n_outputs)]
class _Input(ctypes.Structure):
_fields_ = [
("index", ctypes.c_uint32),
("buf", ctypes.c_void_p),
("size", ctypes.c_uint32),
("pass_through", ctypes.c_uint8),
("type", ctypes.c_int),
("fmt", ctypes.c_int),
]
def custom_string(rknn_lite: "RKNNLite") -> str:
runtime: Any = rknn_lite.rknn_runtime
buffer = _CustomString()
# rknnlite declares argtypes on the shared handle, so pass plain values and let ctypes convert
runtime.lib.rknn_query(runtime.context, _CUSTOM_STRING_QUERY, ctypes.byref(buffer), ctypes.sizeof(buffer))
declared: str = buffer.string.decode()
return declared
class _Output(ctypes.Structure):
_fields_ = [
("want_float", ctypes.c_uint8),
("is_prealloc", ctypes.c_uint8),
("index", ctypes.c_uint32),
("buf", ctypes.c_void_p),
("size", ctypes.c_uint32),
]
def init_rknn(model_path: str) -> "RKNNLite":
if not is_available:
raise RuntimeError("rknn is not available!")
rknn_lite = RKNNLite()
rknn_lite.rknn_log.logger.setLevel(logging.ERROR)
ret = rknn_lite.load_rknn(model_path)
_Key = tuple[tuple[int, ...], ...]
_Spec = tuple[tuple[int, ...], np.dtype[Any]]
_context = ctypes.c_uint64 # rknn_context on 64-bit
_ctx_p = ctypes.POINTER(_context)
_u32 = ctypes.c_uint32
soc_name = None
is_available = False
try:
lib = ctypes.CDLL("librknnrt.so") # CDLL releases the GIL for each call, so contexts run in parallel
lib.rknn_init.argtypes = _ctx_p, ctypes.c_char_p, _u32, _u32, ctypes.c_void_p
lib.rknn_dup_context.argtypes = _ctx_p, _ctx_p
lib.rknn_destroy.argtypes = (_context,)
lib.rknn_query.argtypes = _context, ctypes.c_int, ctypes.c_void_p, _u32
lib.rknn_set_input_shapes.argtypes = _context, _u32, ctypes.POINTER(_TensorAttr)
lib.rknn_inputs_set.argtypes = _context, _u32, ctypes.POINTER(_Input)
lib.rknn_run.argtypes = _context, ctypes.c_void_p
lib.rknn_outputs_get.argtypes = _context, _u32, ctypes.POINTER(_Output), ctypes.c_void_p
lib.rknn_outputs_release.argtypes = _context, _u32, ctypes.POINTER(_Output)
soc_name = get_soc("/proc/device-tree/compatible")
is_available = soc_name is not None
except OSError:
log.debug("RKNN is not available")
def _check(ret: int, call: str) -> None:
if ret != 0:
raise RuntimeError("Failed to load RKNN model")
raise RuntimeError(f"{call} failed with RKNN error {ret}")
if soc_name in RKNN_COREMASK_SUPPORTED_SOCS:
ret = rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_AUTO)
else:
ret = rknn_lite.init_runtime() # Please do not set this parameter on other platforms.
if ret != 0:
raise RuntimeError("Failed to initialize RKNN runtime environment")
def _query(ctx: int, cmd: int, info: ctypes.Structure) -> None:
_check(lib.rknn_query(ctx, cmd, byref(info), sizeof(info)), "rknn_query")
return rknn_lite
def _attr(ctx: int, cmd: int, index: int) -> _TensorAttr:
attr = _TensorAttr(index=index)
_query(ctx, cmd, attr)
return attr
def _specs(attrs: list[_TensorAttr]) -> list[_Spec]:
return [(tuple(attr.dims[: attr.n_dims]), np.dtype(_DTYPES[attr.type])) for attr in attrs]
class RknnPoolExecutor:
def __init__(
self,
model_path: str,
tpes: int,
func: Callable[["RKNNLite", list[ModelTensor], str | None], list[NDArray[np.float32]]],
) -> None:
self.tpes = tpes
self.rknn_pool = [init_rknn(model_path) for _ in range(tpes)]
self.inputs, self.outputs = tensor_nodes(self.rknn_pool[0])
self.custom_string = custom_string(self.rknn_pool[0])
self.pool = ThreadPoolExecutor(max_workers=tpes)
self.func = func
self.num = 0
self.lock = threading.Lock()
"""One context per thread, all but the first duplicated from it so that they share its weights.
def run(self, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[np.float32]]:
with self.lock:
idx = self.num % self.tpes
self.num += 1
The model is loaded by path rather than from a buffer, so the runtime holds the only copy of it."""
fut = self.pool.submit(self.func, self.rknn_pool[idx], inputs, data_format)
return fut.result()
def __init__(self, model_path: str, tpes: int) -> None:
self.contexts: list[int] = []
self.executor = ThreadPoolExecutor(tpes, thread_name_prefix="rknn")
master = _context()
_check(lib.rknn_init(byref(master), model_path.encode(), 0, 0, None), "rknn_init")
self.contexts.append(master.value)
for _ in range(tpes - 1):
child = _context()
_check(lib.rknn_dup_context(byref(master), byref(child)), "rknn_dup_context")
self.contexts.append(child.value)
self.idle: SimpleQueue[int] = SimpleQueue()
for ctx in self.contexts:
self.idle.put(ctx)
io = _IoNum()
_query(master.value, _IN_OUT_NUM, io)
self.input_attrs = [_attr(master.value, _INPUT_ATTR, i) for i in range(io.n_input)]
self.output_attrs = [_attr(master.value, _OUTPUT_ATTR, i) for i in range(io.n_output)]
self.inputs = [RknnNode(a.name.decode(), tuple(a.dims[: a.n_dims])) for a in self.input_attrs]
self.outputs = [RknnNode(a.name.decode(), tuple(a.dims[: a.n_dims])) for a in self.output_attrs]
custom = _CustomString()
lib.rknn_query(master.value, _CUSTOM_STRING, byref(custom), sizeof(custom))
self.custom_string = custom.string.decode()
ranges = [_InputRange(index=index) for index in range(io.n_input)]
for rng in ranges:
lib.rknn_query(master.value, _INPUT_DYNAMIC_RANGE, byref(rng), sizeof(rng))
self.formats = [rng.fmt for rng in ranges]
self.compiled = [{tuple(rng.dyn_range[i][: rng.n_dims]) for i in range(rng.shape_number)} for rng in ranges]
self.dynamic = any(self.compiled)
self.shape_attrs: dict[_Key, ctypes.Array[_TensorAttr]] = {}
self.output_specs: dict[_Key, list[_Spec]] = {(): _specs(self.output_attrs)}
self.current: dict[int, _Key] = {}
def run(self, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[Any]]:
return self.executor.submit(self._next, inputs, data_format).result()
def _next(self, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[Any]]:
ctx = self.idle.get()
try:
return self._run(ctx, inputs, data_format)
finally:
self.idle.put(ctx)
def _run(self, ctx: int, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[Any]]:
fed: list[NDArray[Any]] = [
np.ascontiguousarray(array.transpose(0, 2, 3, 1) if data_format == "nchw" and array.ndim == 4 else array)
for array in inputs
]
key = self._set_shapes(ctx, fed) if self.dynamic else ()
feed = (_Input * len(fed))(
*(
_Input(index=i, buf=array.ctypes.data, size=array.nbytes, type=_TYPES[array.dtype], fmt=_NHWC)
for i, array in enumerate(fed)
)
)
_check(lib.rknn_inputs_set(ctx, len(fed), feed), "rknn_inputs_set")
_check(lib.rknn_run(ctx, None), "rknn_run")
specs = self.output_specs.get(key)
if specs is None:
attrs = [_attr(ctx, _CURRENT_OUTPUT_ATTR, i) for i in range(len(self.output_attrs))]
specs = self.output_specs[key] = _specs(attrs)
outputs = [np.empty(dims, dtype) for dims, dtype in specs]
buffers = (_Output * len(outputs))(
*(
_Output(is_prealloc=1, index=i, buf=array.ctypes.data, size=array.nbytes)
for i, array in enumerate(outputs)
)
)
_check(lib.rknn_outputs_get(ctx, len(outputs), buffers, None), "rknn_outputs_get")
lib.rknn_outputs_release(ctx, len(outputs), buffers)
return outputs
def _set_shapes(self, ctx: int, inputs: list[NDArray[Any]]) -> _Key:
key = tuple(
self._compiled_shape(fmt, compiled, array.shape)
for fmt, compiled, array in zip(self.formats, self.compiled, inputs)
)
if self.current.get(ctx) == key:
return key
attrs = self.shape_attrs.get(key)
if attrs is None:
attrs = self.shape_attrs[key] = (_TensorAttr * len(key))()
for i, (source, compiled, fmt, shape) in enumerate(zip(self.input_attrs, self.compiled, self.formats, key)):
attrs[i] = _TensorAttr.from_buffer_copy(source)
if compiled:
attrs[i].n_dims = len(shape)
attrs[i].dims[: len(shape)] = shape
attrs[i].fmt = fmt
_check(lib.rknn_set_input_shapes(ctx, len(key), attrs), "rknn_set_input_shapes")
self.current[ctx] = key
return key
@staticmethod
def _compiled_shape(fmt: int, compiled: set[tuple[int, ...]], fed: tuple[int, ...]) -> tuple[int, ...]:
shape = (fed[0], fed[3], fed[1], fed[2]) if len(fed) == 4 and fmt == _NCHW else fed
if compiled and shape not in compiled:
raise ValueError(f"binary was compiled for {sorted(compiled)}, fed {list(fed)}")
return shape
def release(self) -> None:
self.pool.shutdown()
for rknn_lite in self.rknn_pool:
rknn_lite.release()
self.executor.shutdown()
for ctx in reversed(self.contexts): # duplicates before the context whose weights they share
lib.rknn_destroy(ctx)
self.contexts = []
def __del__(self) -> None:
self.release()
+1 -1
View File
@@ -57,7 +57,7 @@ cuda = [
]
openvino = ["onnxruntime-openvino>=1.24.1,<2"]
armnn = ["onnxruntime>=1.23.2,<2"]
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
rknn = ["onnxruntime>=1.23.2,<2"]
rocm = ["onnxruntime-migraphx>=1.23.2,<2"]
[tool.uv]
+252 -4
View File
@@ -1,3 +1,4 @@
import ctypes
import json
import os
import pickle
@@ -63,8 +64,9 @@ from immich_ml.schemas import (
from immich_ml.sessions.ann import AnnSession
from immich_ml.sessions.ort import Device, GraphSpec, OrtSession, flush_denormals, fresh, prepared
from immich_ml.sessions.policy import ShapePolicy, batches, runs
from immich_ml.sessions.rknn import RknnSession, run_inference
from immich_ml.sessions.rknn import RknnSession
from immich_ml.sessions.rknn import model_path as rknn_model_path
from immich_ml.sessions.rknn.rknnpool import RknnPoolExecutor
class TestBase:
@@ -960,9 +962,11 @@ class TestRknnSession:
mocker.patch("immich_ml.sessions.rknn.is_available", True)
RknnSession(model_path)
rknn_session.assert_called_once_with(model_path=model_path.as_posix(), tpes=tpe, func=run_inference)
rknn_session.assert_called_once_with(model_path=model_path.as_posix(), tpes=tpe)
info.assert_has_calls([mock.call(f"Loaded RKNN model from {model_path} with {tpe} threads.")])
info.assert_has_calls(
[mock.call(f"Loaded RKNN model from {model_path} with {tpe} threads sharing its weights.")]
)
def test_run_rknn(self, rknn_session: mock.Mock, mocker: MockerFixture) -> None:
rknn_session.return_value.load.return_value = 123
@@ -974,7 +978,20 @@ class TestRknnSession:
session.run(None, input_feed)
rknn_session.return_value.run.assert_called_once_with([input1, input2], "nchw")
assert all(fed.flags.c_contiguous for fed in rknn_session.return_value.run.call_args.args[0])
@pytest.mark.parametrize(
("soc", "threads", "expected"),
[("rk3566", None, 1), ("rk3576", None, 2), ("rk3588", None, 3), ("rk3588", 2, 2)],
)
def test_uses_a_context_per_npu_core_unless_set(
self, soc: str, threads: int | None, expected: int, rknn_session: mock.Mock, mocker: MockerFixture
) -> None:
mocker.patch("immich_ml.sessions.rknn.soc_name", soc)
mocker.patch.object(settings, "rknn_threads", threads)
RknnSession(Path("ViT-B-32__openai"))
assert rknn_session.call_args.kwargs["tpes"] == expected
def test_run_rknn_rejects_a_shape_the_binary_was_not_compiled_for(
self, rknn_session: mock.Mock, mocker: MockerFixture
@@ -1011,6 +1028,237 @@ class TestRknnSession:
assert RknnSession(Path("buffalo_l")).shapes == (Shape(batch=1),)
class FakeRknn:
"""librknnrt for one input and one output, writing through the pointers as the runtime does."""
def __init__(self, ranges: list[tuple[int, ...]] | None = None) -> None:
self.ranges = ranges
self.contexts = 0
self.fed: list[tuple[int, int, int]] = [] # (context, type, fmt) per run
self.data: list[bytes] = []
self.buffers: list[int] = []
self.output_queries = 0
self.set_shapes: list[tuple[int, tuple[int, ...]]] = []
self.rknn_destroy = mock.Mock(return_value=0)
self.rknn_outputs_release = mock.Mock(return_value=0)
self.rknn_run = mock.Mock(return_value=0)
def rknn_init(self, ctx: Any, model: bytes, size: int, flag: int, extend: None) -> int:
self.contexts += 1
ctx._obj.value = self.contexts
return 0
def rknn_dup_context(self, ctx_in: Any, ctx_out: Any) -> int:
self.contexts += 1
ctx_out._obj.value = self.contexts
return 0
def rknn_query(self, ctx: int, cmd: int, info: Any, size: int) -> int:
obj = info._obj
if cmd == 0:
obj.n_input, obj.n_output = 1, 1
elif cmd == 1:
self._fill(obj, b"x", self.ranges[0] if self.ranges else (1, 48, 320, 3), 0)
obj.fmt = 1
elif cmd == 2:
self._fill(obj, b"logits", (1, 40, 97), 1)
elif cmd == 7:
obj.string = b'{"dims":[{}]}'
elif cmd == 13:
if self.ranges is None:
return -1
obj.shape_number, obj.n_dims, obj.fmt = len(self.ranges), 4, 1
for i, shape in enumerate(self.ranges):
obj.dyn_range[i][:4] = shape
elif cmd == 15:
self.output_queries += 1
width = dict(self.set_shapes)[ctx][2]
self._fill(obj, b"logits", (1, width // 8, 97), 1)
return 0
@staticmethod
def _fill(attr: Any, name: bytes, dims: tuple[int, ...], dtype: int) -> None:
attr.name, attr.n_dims, attr.type = name, len(dims), dtype
attr.dims[: len(dims)] = dims
def rknn_set_input_shapes(self, ctx: int, n: int, attrs: Any) -> int:
self.set_shapes.append((ctx, tuple(attrs[0].dims[: attrs[0].n_dims])))
return 0
def rknn_inputs_set(self, ctx: int, n: int, inputs: Any) -> int:
self.fed.append((ctx, inputs[0].type, inputs[0].fmt))
self.data.append(ctypes.string_at(inputs[0].buf, inputs[0].size))
self.buffers.append(inputs[0].buf)
return 0
def rknn_outputs_get(self, ctx: int, n: int, outputs: Any, extend: None) -> int:
assert outputs[0].is_prealloc and not outputs[0].want_float
np.frombuffer((ctypes.c_char * outputs[0].size).from_address(outputs[0].buf), np.uint8)[:] = 0
return 0
class TestRknnPool:
@pytest.fixture
def lib(self, mocker: MockerFixture) -> FakeRknn:
fake = FakeRknn()
mocker.patch("immich_ml.sessions.rknn.rknnpool.lib", fake, create=True)
return fake
@pytest.fixture
def dynamic_lib(self, mocker: MockerFixture) -> FakeRknn:
fake = FakeRknn(ranges=[(1, 48, 320, 3), (1, 48, 640, 3)])
mocker.patch("immich_ml.sessions.rknn.rknnpool.lib", fake, create=True)
return fake
def test_duplicates_one_context_per_thread(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 3)
assert pool.contexts == [1, 2, 3]
assert pool.inputs[0] == ("x", (1, 48, 320, 3))
assert pool.outputs[0] == ("logits", (1, 40, 97))
assert pool.custom_string == '{"dims":[{}]}'
assert not pool.dynamic
pool.release()
assert lib.rknn_destroy.call_args_list == [mock.call(3), mock.call(2), mock.call(1)]
def test_feeds_the_dtype_given_as_nhwc(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
image = np.arange(48 * 320 * 3, dtype=np.uint8).reshape(1, 48, 320, 3)
pool.run([image], "nhwc")
pool.run([image.transpose(0, 3, 1, 2).astype(np.float32)], "nchw")
assert lib.fed == [(1, 3, 1), (1, 0, 1)] # (context, rknn_tensor_type, rknn_tensor_format)
assert lib.data[0] == image.tobytes()
assert lib.data[1] == image.astype(np.float32).tobytes() # transposed back to channels last
def test_feeds_a_channels_first_view_of_channels_last_data_without_a_copy(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
image = np.zeros((1, 48, 320, 3), dtype=np.float32)
pool.run([image.transpose(0, 3, 1, 2)], "nchw")
assert lib.buffers == [image.ctypes.data]
def test_makes_every_input_contiguous(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
image = np.arange(48 * 640 * 3, dtype=np.uint8).reshape(1, 48, 640, 3)[:, :, ::2]
pool.run([image], "nhwc")
assert lib.data == [np.ascontiguousarray(image).tobytes()]
def test_returns_outputs_in_the_precision_computed(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
[output] = pool.run([np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc")
assert output.dtype == np.float16
assert output.shape == (1, 40, 97)
lib.rknn_outputs_release.assert_called_once()
def test_a_failed_init_destroys_nothing(self, lib: FakeRknn, mocker: MockerFixture) -> None:
mocker.patch.object(lib, "rknn_init", return_value=-6)
with pytest.raises(RuntimeError, match="rknn_init failed"):
RknnPoolExecutor("model.rknn", 3)
lib.rknn_destroy.assert_not_called()
def test_returns_the_context_when_a_run_fails(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 2)
lib.rknn_run.return_value = -1
with pytest.raises(RuntimeError, match="rknn_run failed"):
pool.run([np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc")
assert pool.idle.qsize() == 2
def test_runs_on_every_context_at_once(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 3)
barrier = threading.Barrier(3, timeout=5) # breaks unless all three are inside rknn_run together
lib.rknn_run.side_effect = lambda ctx, extend: barrier.wait() * 0
with ThreadPoolExecutor(3) as executor:
futures = [executor.submit(pool.run, [np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc") for _ in range(3)]
[future.result() for future in futures]
assert sorted(ctx for ctx, _, _ in lib.fed) == [1, 2, 3]
def test_a_waiting_run_is_not_overtaken_by_the_caller_that_just_ran(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
def run(ctx: int, extend: None) -> int:
time.sleep(0.01)
return 0
lib.rknn_run.side_effect = run
busy, waiting = np.zeros((1, 48, 320, 3), np.uint8), np.ones((1, 48, 320, 3), np.uint8)
caller = threading.Thread(target=lambda: [pool.run([busy], "nhwc") for _ in range(20)])
caller.start()
time.sleep(0.005)
pool.run([waiting], "nhwc")
caller.join(5)
assert [data[0] for data in lib.data].index(1) <= 2
def test_does_not_set_shapes_for_a_static_binary(self, lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
pool.run([np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc")
assert lib.set_shapes == []
def test_routes_a_dynamic_binary_to_the_shape_fed(self, dynamic_lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
[wide] = pool.run([np.zeros((1, 3, 48, 640), dtype=np.float32)], "nchw")
[narrow] = pool.run([np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc")
assert pool.dynamic
assert dynamic_lib.set_shapes == [(1, (1, 48, 640, 3)), (1, (1, 48, 320, 3))]
assert wide.shape == (1, 80, 97)
assert narrow.shape == (1, 40, 97)
def test_sets_the_shape_on_the_context_that_runs(self, dynamic_lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 2)
image = np.zeros((1, 48, 640, 3), dtype=np.uint8)
pool.run([image], "nhwc")
pool.run([image], "nhwc")
assert dynamic_lib.set_shapes == [(1, (1, 48, 640, 3)), (2, (1, 48, 640, 3))]
def test_sets_a_shape_again_only_when_it_changes(self, dynamic_lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
wide, narrow = np.zeros((1, 48, 640, 3), dtype=np.uint8), np.zeros((1, 48, 320, 3), dtype=np.uint8)
for image in (wide, wide, narrow, narrow, wide):
pool.run([image], "nhwc")
assert dynamic_lib.set_shapes == [(1, (1, 48, 640, 3)), (1, (1, 48, 320, 3)), (1, (1, 48, 640, 3))]
def test_queries_output_shapes_once_per_input_shape(self, dynamic_lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 2)
image = np.zeros((1, 48, 640, 3), dtype=np.uint8)
outputs = [pool.run([image], "nhwc")[0] for _ in range(4)]
assert dynamic_lib.output_queries == 1
assert all(output.shape == (1, 80, 97) for output in outputs)
def test_rejects_a_shape_the_binary_was_not_compiled_for(self, dynamic_lib: FakeRknn) -> None:
pool = RknnPoolExecutor("model.rknn", 1)
with pytest.raises(ValueError, match="compiled for"):
pool.run([np.zeros((1, 48, 480, 3), dtype=np.uint8)], "nhwc")
assert pool.idle.qsize() == 1
class TestCLIP:
embedding = np.random.rand(512).astype(np.float32)
cache_dir = Path("test_cache")
-53
View File
@@ -868,7 +868,6 @@ openvino = [
]
rknn = [
{ name = "onnxruntime" },
{ name = "rknn-toolkit-lite2" },
]
rocm = [
{ name = "onnxruntime-migraphx" },
@@ -943,7 +942,6 @@ requires-dist = [
{ name = "python-multipart", specifier = ">=0.0.6,<1.0" },
{ name = "rapidocr", specifier = ">=3.1.0" },
{ name = "rich", specifier = ">=13.4.2" },
{ name = "rknn-toolkit-lite2", marker = "extra == 'rknn'", specifier = ">=2.3.0,<3" },
{ name = "tokenizers", specifier = ">=0.15.0,<1.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.22.0,<1.0" },
]
@@ -2303,57 +2301,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "rknn-toolkit-lite2"
version = "2.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "psutil" },
{ name = "ruamel-yaml" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/db/64c756f3f06b219e92ff4f0fd4e000870ee49f214d505ff01c8b0275e26d/rknn_toolkit_lite2-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1e4ec691fed900c0e6fde5e7d8eeba17f806aa45092b63b361ee775e2c1b50e", size = 527458, upload-time = "2025-04-09T09:39:58.881Z" },
]
[[package]]
name = "ruamel-yaml"
version = "0.18.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ruamel-yaml-clib", marker = "python_full_version < '3.13' and platform_python_implementation == 'CPython'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ea/46/f44d8be06b85bc7c4d8c95d658be2b68f27711f279bf9dd0612a5e4794f5/ruamel.yaml-0.18.10.tar.gz", hash = "sha256:20c86ab29ac2153f80a428e1254a8adf686d3383df04490514ca3b79a362db58", size = 143447, upload-time = "2025-01-06T14:08:51.334Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/36/dfc1ebc0081e6d39924a2cc53654497f967a084a436bb64402dfce4254d9/ruamel.yaml-0.18.10-py3-none-any.whl", hash = "sha256:30f22513ab2301b3d2b577adc121c6471f28734d3d9728581245f1e76468b4f1", size = 117729, upload-time = "2025-01-06T14:08:47.471Z" },
]
[[package]]
name = "ruamel-yaml-clib"
version = "0.2.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315, upload-time = "2024-10-20T10:10:56.22Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433, upload-time = "2024-10-20T10:12:55.657Z" },
{ url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362, upload-time = "2024-10-20T10:12:57.155Z" },
{ url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118, upload-time = "2024-10-20T10:12:58.501Z" },
{ url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497, upload-time = "2024-10-20T10:13:00.211Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042, upload-time = "2024-10-21T11:26:46.038Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831, upload-time = "2024-10-21T11:26:47.487Z" },
{ url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692, upload-time = "2024-12-11T19:58:17.252Z" },
{ url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777, upload-time = "2024-10-20T10:13:01.395Z" },
{ url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523, upload-time = "2024-10-20T10:13:02.768Z" },
{ url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011, upload-time = "2024-10-20T10:13:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488, upload-time = "2024-10-20T10:13:05.906Z" },
{ url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066, upload-time = "2024-10-20T10:13:07.26Z" },
{ url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785, upload-time = "2024-10-20T10:13:08.504Z" },
{ url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017, upload-time = "2024-10-21T11:26:48.866Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270, upload-time = "2024-10-21T11:26:50.213Z" },
{ url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059, upload-time = "2024-12-11T19:58:18.846Z" },
{ url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583, upload-time = "2024-10-20T10:13:09.658Z" },
{ url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190, upload-time = "2024-10-20T10:13:10.66Z" },
]
[[package]]
name = "ruff"
version = "0.17.1"
@@ -28,12 +28,12 @@ object NativeImage {
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
/**
* Like [convert1010102], but decodes the JPEG XL image of [length] bytes at [address], stopped by
* Like [convert1010102], but decodes the image of [length] bytes at [address], stopped by
* [cancel], downscaled to cover [targetWidth] x [targetHeight] unless either is 0 and to fit
* within [maxPixelSize].
*/
@JvmStatic
external fun decodeJxl(
external fun decode(
address: Long,
length: Int,
targetWidth: Int,
@@ -43,7 +43,7 @@ object NativeImage {
outInfo: IntArray
): Long
/** Stops the [decodeJxl] given this one-byte direct buffer. */
/** Stops the [decode] given this one-byte direct buffer. */
@JvmStatic
external fun cancel(cancelled: ByteBuffer)
}
@@ -106,7 +106,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
// The fetch is done, so cancelling now stops the decode instead.
val cancelled = ByteBuffer.allocateDirect(1)
signal.setOnCancelListener { NativeImage.cancel(cancelled) }
val pointer = if (signal.isCanceled) 0L else NativeImage.decodeJxl(
val pointer = if (signal.isCanceled) 0L else NativeImage.decode(
buffer.pointer,
buffer.offset,
width?.toInt() ?: 0,
+4 -4
View File
@@ -9,7 +9,7 @@ enum NativeCore {
}
private static let coreThumbhash = symbol("native_core_thumbhash", as: NativeCoreThumbhashFn.self)
private static let coreJxlDecode = symbol("native_core_jxl_decode", as: NativeCoreJxlDecodeFn.self)
private static let coreDecodeImage = symbol("native_core_decode_image", as: NativeCoreDecodeImageFn.self)
private static let coreCancel = symbol("native_core_cancel", as: NativeCoreCancelFn.self)
static func thumbhash(_ hash: Data) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
@@ -23,11 +23,11 @@ enum NativeCore {
}
}
static func decodeJxl(_ data: Data, targetWidth: Int64?, targetHeight: Int64?, maxPixelSize: Int, cancelled: UnsafeMutablePointer<Bool>) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
guard let coreJxlDecode else { return nil }
static func decodeImage(_ data: Data, targetWidth: Int64?, targetHeight: Int64?, maxPixelSize: Int, cancelled: UnsafeMutablePointer<Bool>) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
guard let coreDecodeImage else { return nil }
var info = NativeCoreImageInfo()
return data.withUnsafeBytes { bytes in
guard let pointer = coreJxlDecode(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, false, UInt32(clamping: targetWidth ?? 0), UInt32(clamping: targetHeight ?? 0), UInt32(clamping: maxPixelSize), cancelled, &info)
guard let pointer = coreDecodeImage(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, false, UInt32(clamping: targetWidth ?? 0), UInt32(clamping: targetHeight ?? 0), UInt32(clamping: maxPixelSize), cancelled, &info)
else { return nil }
return (Int(info.width), Int(info.height), UnsafeMutableRawPointer(pointer))
}
@@ -80,7 +80,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
return request.completion(ImageProcessing.cancelledResult)
}
guard let (width, height, pointer) = NativeCore.decodeJxl(data, targetWidth: width, targetHeight: height, maxPixelSize: ImageProcessing.maxPixelSize, cancelled: request.decodeCancelled) else {
guard let (width, height, pointer) = NativeCore.decodeImage(data, targetWidth: width, targetHeight: height, maxPixelSize: ImageProcessing.maxPixelSize, cancelled: request.decodeCancelled) else {
registry.remove(requestId: request.id)
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
+1 -1
View File
@@ -3,5 +3,5 @@
// The function pointer type comes from the declaration, so nothing is retyped here.
typedef __typeof__(&native_core_thumbhash) NativeCoreThumbhashFn;
typedef __typeof__(&native_core_jxl_decode) NativeCoreJxlDecodeFn;
typedef __typeof__(&native_core_decode_image) NativeCoreDecodeImageFn;
typedef __typeof__(&native_core_cancel) NativeCoreCancelFn;
@@ -74,8 +74,8 @@ class TimelineFactory {
TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy));
TimelineService person(List<String> userIds, String personId) =>
TimelineService(_timelineRepository.person(userIds, personId, groupBy));
TimelineService person(String userId, String personId) =>
TimelineService(_timelineRepository.person(userId, personId, groupBy));
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssets(assets, type));
@@ -366,10 +366,10 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
origin: TimelineOrigin.place,
);
TimelineQuery person(List<String> userIds, String personId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPersonBucket(userIds, personId, groupBy: groupBy),
TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy),
assetSource: (offset, count) =>
_getPersonBucketAssets(userIds, personId, groupBy: groupBy, offset: offset, count: count),
_getPersonBucketAssets(userId, personId, groupBy: groupBy, offset: offset, count: count),
origin: TimelineOrigin.person,
);
@@ -430,7 +430,7 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
Expression<bool> _personAssetFilter(List<String> userIds, String personId) {
Expression<bool> _personAssetFilter(String userId, String personId) {
final faceAssetIds = _db.assetFaceEntity.selectOnly()
..addColumns([_db.assetFaceEntity.assetId])
..where(
@@ -438,22 +438,23 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
);
final partnerIds = _db.partnerEntity.selectOnly()
..addColumns([_db.partnerEntity.sharedById])
..where(_db.partnerEntity.sharedWithId.equals(userId));
final albumAssetIds = _db.remoteAlbumAssetEntity.selectOnly()..addColumns([_db.remoteAlbumAssetEntity.assetId]);
return _db.remoteAssetEntity.id.isInQuery(faceAssetIds) &
(_db.remoteAssetEntity.ownerId.isIn(userIds) | _db.remoteAssetEntity.id.isInQuery(albumAssetIds)) &
(_db.remoteAssetEntity.ownerId.equals(userId) |
_db.remoteAssetEntity.ownerId.isInQuery(partnerIds) |
_db.remoteAssetEntity.id.isInQuery(albumAssetIds)) &
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline);
}
Stream<List<Bucket>> _watchPersonBucket(
List<String> userIds,
String personId, {
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
Stream<List<Bucket>> _watchPersonBucket(String userId, String personId, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id.count()])
..where(_personAssetFilter(userIds, personId));
..where(_personAssetFilter(userId, personId));
return query.map((row) {
final count = row.read(_db.remoteAssetEntity.id.count())!;
@@ -466,7 +467,7 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..where(_personAssetFilter(userIds, personId))
..where(_personAssetFilter(userId, personId))
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
@@ -478,14 +479,14 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
}
Future<List<BaseAsset>> _getPersonBucketAssets(
List<String> userIds,
String userId,
String personId, {
required int offset,
required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
final query = _db.remoteAssetEntity.select()
..where((_) => _personAssetFilter(userIds, personId))
..where((_) => _personAssetFilter(userId, personId))
..orderBy(_assetDateOrder(groupBy))
..limit(count, offset: offset);
@@ -82,8 +82,7 @@ class _PersonPageState extends ConsumerState<PersonPage> {
throw Exception('User must be logged in to view person timeline');
}
final users = ref.watch(timelineUsersProvider).valueOrNull ?? [user.id];
final timelineService = ref.watch(timelineFactoryProvider).person(users, _person.id);
final timelineService = ref.watch(timelineFactoryProvider).person(user.id, _person.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.3.5-local.12+3030504
version: 3.3.5-local.13+3030505
environment:
sdk: '>=3.12.0 <4.0.0'
@@ -103,6 +103,22 @@ void main() {
});
group('person assets', () {
test('includes partner assets even when the partner is not shown in the timeline', () async {
final user = await ctx.newUser();
final partner = await ctx.newUser();
final stranger = await ctx.newUser();
await ctx.newPartner(sharedById: partner.id, sharedWithId: user.id, inTimeline: false);
final person = await ctx.newPerson(ownerId: user.id);
final partnerAsset = await ctx.newRemoteAsset(ownerId: partner.id);
final strangerAsset = await ctx.newRemoteAsset(ownerId: stranger.id);
await ctx.newFace(assetId: partnerAsset.id, personId: person.id);
await ctx.newFace(assetId: strangerAsset.id, personId: person.id);
final assets = await sut.person(user.id, person.id, .day).assetSource(0, 10);
expect(assets.map((a) => (a as RemoteAsset).id), [partnerAsset.id]);
});
test('does not duplicate an asset that has multiple face records for the same person', () async {
// Regression check for #26723: an INNER JOIN between remote_asset_entity and asset_face_entity
// fanned out one asset into N rows when N face records pointed at the same (asset, person) pair
@@ -113,7 +129,7 @@ void main() {
await ctx.newFace(assetId: asset.id, personId: person.id);
await ctx.newFace(assetId: asset.id, personId: person.id);
final query = sut.person([user.id], person.id, .day);
final query = sut.person(user.id, person.id, .day);
final buckets = await query.bucketSource().first;
expect(buckets, hasLength(1));
@@ -140,7 +156,7 @@ void main() {
await ctx.newFace(assetId: shiftedLater.id, personId: person.id);
await ctx.newFace(assetId: shiftedEarlier.id, personId: person.id);
final query = sut.person([user.id], person.id, .day);
final query = sut.person(user.id, person.id, .day);
final buckets = await query.bucketSource().first;
expect(buckets, hasLength(2));
+13 -6
View File
@@ -56,6 +56,18 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "codecs"
version = "0.1.0"
dependencies = [
"jxl",
"libc",
"moxcms",
"pic-scale",
"quick_cache",
"rayon",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -287,12 +299,7 @@ dependencies = [
name = "native_core"
version = "0.1.0"
dependencies = [
"jxl",
"libc",
"moxcms",
"pic-scale",
"quick_cache",
"rayon",
"codecs",
]
[[package]]
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "3"
members = ["crates/native_core", "crates/native_core_ffi"]
members = ["crates/codecs", "crates/native_core", "crates/native_core_ffi"]
[workspace.package]
version = "0.1.0"
+3 -2
View File
@@ -5,8 +5,9 @@ Rust code that ships inside the mobile app. Flutter compiles it from source thro
in `mobile/` sets one up, otherwise install [rustup](https://rustup.rs) once and the build
fetches the pinned toolchain itself.
- `crates/native_core`: the shared logic
- `crates/native_core_ffi`: the C ABI, cbindgen writes `include/native_core.h`
- `crates/codecs`: the image decoders, a feature each, behind one `decode`
- `crates/native_core`: the shared logic, a feature per part and a group per target
- `crates/native_core_ffi`: the C ABI on the `flutter` group, cbindgen writes `include/native_core.h`
- `native_core`: Flutter package with the build hook and the ffigen bindings
(`NOTICES` carries the licenses of the crates compiled into the core, update it with the dependencies)
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "codecs"
version.workspace = true
edition.workspace = true
license.workspace = true
[features]
default = ["jxl"]
jxl = ["dep:jxl", "dep:rayon"]
[dependencies]
jxl = { workspace = true, optional = true }
libc = { workspace = true }
moxcms = { workspace = true }
pic-scale = { workspace = true }
quick_cache = { workspace = true }
rayon = { workspace = true, optional = true }
[lints]
workspace = true
@@ -70,7 +70,7 @@ impl JxlParallelRunner for Cancellable<'_> {
}
impl<'a> Jxl<'a> {
pub fn open(mut input: &'a [u8], target: Target) -> Option<Self> {
pub fn open(mut input: &'a [u8], color_space: Target) -> Option<Self> {
let mut options = JxlDecoderOptions::default();
// Colour transforms need straight alpha; premultiplying happens last.
options.alpha_mode = AlphaMode::Unpremultiplied;
@@ -85,7 +85,7 @@ impl<'a> Jxl<'a> {
.ok()?;
let profile = decoder.output_color_profile()?;
if let Some(cicp) = cicp(profile)
&& (target == Target::Source || color::is_srgb(cicp))
&& (color_space == Target::Source || color::is_srgb(cicp))
{
if color::is_hdr(cicp) {
decoder
@@ -121,11 +121,11 @@ impl<'a> Jxl<'a> {
})
}
/// Decodes the first frame unless cancelled, downscaled to cover `target` pixels and to fit
/// within `max_pixel_size`.
/// Decodes the first frame unless cancelled, downscaled to cover `target_size` pixels and to
/// fit within `max_pixel_size`.
pub fn decode(
mut self,
target: Option<(usize, usize)>,
target_size: Option<(usize, usize)>,
max_pixel_size: usize,
cancelled: &AtomicBool,
) -> Option<Image> {
@@ -139,7 +139,7 @@ impl<'a> Jxl<'a> {
let pixel_bytes =
format.color_type.samples_per_pixel() * format.color_data_format?.bytes_per_sample();
let stride = w.checked_mul(pixel_bytes)?;
let size = resize::cover((w, h), target, max_pixel_size);
let size = resize::cover((w, h), target_size, max_pixel_size);
let samples = if color::is_hdr(self.cicp) {
let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?;
self.decode_frame(&mut rgba, stride, cancelled)?;
+49
View File
@@ -0,0 +1,49 @@
use std::sync::atomic::AtomicBool;
use color::Target;
use image::Image;
pub mod color;
pub mod image;
#[cfg(feature = "jxl")]
pub mod jxl;
mod resize;
enum Format {
#[cfg(feature = "jxl")]
Jxl,
}
pub struct Options {
pub color_space: Target,
/// Downscales to the smallest size that still fills this.
pub target_size: Option<(usize, usize)>,
/// Downscales to fit within this many pixels per side.
pub max_pixel_size: usize,
}
fn probe(data: &[u8]) -> Option<Format> {
#[cfg(feature = "jxl")]
if matches!(
::jxl::api::check_signature(data),
::jxl::api::JxlSignature::Codestream | ::jxl::api::JxlSignature::Container
) {
return Some(Format::Jxl);
}
None
}
/// Decodes the first frame of `data` unless cancelled.
pub fn decode(data: &[u8], options: &Options, cancelled: &AtomicBool) -> Option<Image> {
match probe(data)? {
#[cfg(feature = "jxl")]
Format::Jxl => jxl::Jxl::open(data, options.color_space)?.decode(
options.target_size,
options.max_pixel_size,
cancelled,
),
}
}
@@ -47,16 +47,16 @@ impl Lanczos for u16 {
}
}
/// The size of `size` scaled down to cover `target` and to fit within `max_pixel_size`, if that
/// is smaller.
/// The size of `size` scaled down to cover `target_size` and to fit within `max_pixel_size`, if
/// that is smaller.
pub(crate) fn cover(
(w, h): (usize, usize),
target: Option<(usize, usize)>,
target_size: Option<(usize, usize)>,
max_pixel_size: usize,
) -> Option<(usize, usize)> {
// Scales as side ratios, so the side that sets the scale lands exactly on its limit.
let fit = (max_pixel_size, w.max(h));
let (num, den) = match target {
let (num, den) = match target_size {
Some((tw, th)) => {
let cover = if tw * h >= th * w { (tw, w) } else { (th, h) };
if cover.0 * fit.1 < fit.0 * cover.1 {
@@ -1,11 +1,19 @@
#![cfg(feature = "jxl")]
use std::sync::atomic::AtomicBool;
use native_core::color::{CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
use native_core::image::{Image, Pixels, Samples};
use native_core::jxl::Jxl;
use codecs::Options;
use codecs::color::{CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
use codecs::image::{Image, Pixels, Samples};
use codecs::jxl::Jxl;
fn decode(data: &[u8], target: Target) -> Option<Image> {
Jxl::open(data, target)?.decode(None, 16384, &AtomicBool::new(false))
fn decode(data: &[u8], color_space: Target) -> Option<Image> {
let options = Options {
color_space,
target_size: None,
max_pixel_size: 16384,
};
codecs::decode(data, &options, &AtomicBool::new(false))
}
fn decode_srgb(data: &[u8]) -> Option<Pixels<u8>> {
+9 -6
View File
@@ -4,13 +4,16 @@ version.workspace = true
edition.workspace = true
license.workspace = true
[features]
convert = []
rotate = []
thumbhash = []
jxl = ["codecs/jxl"]
# One group per target.
flutter = ["convert", "rotate", "thumbhash", "jxl"]
[dependencies]
jxl = { workspace = true }
libc = { workspace = true }
moxcms = { workspace = true }
pic-scale = { workspace = true }
quick_cache = { workspace = true }
rayon = { workspace = true }
codecs = { path = "../codecs", default-features = false, optional = true }
[lints]
workspace = true
+5 -4
View File
@@ -1,9 +1,10 @@
pub mod color;
#[cfg(feature = "codecs")]
pub use codecs;
#[cfg(feature = "convert")]
pub mod convert;
pub mod image;
pub mod jxl;
mod resize;
#[cfg(feature = "rotate")]
pub mod rotate;
#[cfg(feature = "thumbhash")]
pub mod thumbhash;
pub fn version() -> &'static str {
@@ -1,3 +1,5 @@
#![cfg(feature = "convert")]
use native_core::convert::convert_1010102;
#[test]
@@ -1,3 +1,5 @@
#![cfg(feature = "rotate")]
use native_core::rotate::{Orientation, rotate};
#[test]
@@ -1,3 +1,5 @@
#![cfg(feature = "thumbhash")]
use native_core::thumbhash::decode;
// Expected pixels come from the old Swift decoder patched to round like the Java did (it truncated),
+1 -1
View File
@@ -10,7 +10,7 @@ name = "native_core_ffi"
crate-type = ["cdylib", "staticlib"]
[dependencies]
native_core = { path = "../native_core" }
native_core = { path = "../native_core", features = ["flutter"] }
libc = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
+1 -1
View File
@@ -3,7 +3,7 @@ use std::path::Path;
fn main() {
// Files that define exported items. A directory here would make Flutter rerun the hook every build.
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=src/jxl.rs");
println!("cargo:rerun-if-changed=src/decode.rs");
println!("cargo:rerun-if-changed=src/thumbhash.rs");
println!("cargo:rerun-if-changed=cbindgen.toml");
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
@@ -10,9 +10,9 @@ use jni::{
objects::{JByteBuffer, JClass, JIntArray},
sys::{jint, jlong},
};
use native_core::color::Target;
use native_core::image::{Image, Samples};
use native_core::jxl::Jxl;
use native_core::codecs::color::Target;
use native_core::codecs::image::{Image, Samples};
use native_core::codecs::{self, Options};
use super::guard;
@@ -30,7 +30,7 @@ pub struct NativeCoreImageInfo {
pub transfer: u8,
}
/// Decodes a JPEG XL image to premultiplied RGBA in sRGB, or with `keep_space`, in the image's own
/// Decodes an image to premultiplied RGBA in sRGB, or with `keep_space`, in the image's own
/// space when it has BT.709, BT.2020 or Display P3 primaries with the sRGB, PQ or HLG transfer.
/// Unless either is 0, it is downscaled to cover `target_width` x `target_height`, and it never
/// exceeds `max_pixel_size` pixels per side. `info` gets the size and space. The caller frees the
@@ -42,7 +42,7 @@ pub struct NativeCoreImageInfo {
/// `cancelled` must be null or valid for the call and only changed through `native_core_cancel`.
/// `info` must be null or a valid writable pointer, outside `data`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn native_core_jxl_decode(
pub unsafe extern "C" fn native_core_decode_image(
data: *const u8,
len: size_t,
keep_space: bool,
@@ -60,16 +60,19 @@ pub unsafe extern "C" fn native_core_jxl_decode(
let cancelled = unsafe { AtomicBool::from_ptr(cancelled) };
// SAFETY: the caller provides a buffer readable for len bytes.
let input = unsafe { slice::from_raw_parts(data, len) };
let target = if keep_space {
let color_space = if keep_space {
Target::Source
} else {
Target::Srgb
};
let cover = (target_width > 0 && target_height > 0)
let target_size = (target_width > 0 && target_height > 0)
.then_some((target_width as usize, target_height as usize));
let Some(Image { cicp, samples }) = Jxl::open(input, target)
.and_then(|image| image.decode(cover, max_pixel_size as usize, cancelled))
else {
let options = Options {
color_space,
target_size,
max_pixel_size: max_pixel_size as usize,
};
let Some(Image { cicp, samples }) = codecs::decode(input, &options, cancelled) else {
return ptr::null_mut();
};
let (width, height, bit_depth, pixels) = match samples {
@@ -93,7 +96,7 @@ pub unsafe extern "C" fn native_core_jxl_decode(
/// Asks the decode given `flag` to stop early.
///
/// # Safety
/// `flag` must be null or valid and otherwise only read by `native_core_jxl_decode`.
/// `flag` must be null or valid and otherwise only read by `native_core_decode_image`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn native_core_cancel(flag: *mut bool) {
if !flag.is_null() {
@@ -104,7 +107,7 @@ pub unsafe extern "C" fn native_core_cancel(flag: *mut bool) {
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_app_alextran_immich_NativeImage_decodeJxl<'caller>(
pub extern "system" fn Java_app_alextran_immich_NativeImage_decode<'caller>(
mut env: EnvUnowned<'caller>,
_class: JClass<'caller>,
data: jlong,
@@ -121,7 +124,7 @@ pub extern "system" fn Java_app_alextran_immich_NativeImage_decodeJxl<'caller>(
// SAFETY: Kotlin passes a live native buffer of `length` bytes and a direct buffer it only
// changes through `cancel`, and the output is valid.
let rgba = unsafe {
native_core_jxl_decode(
native_core_decode_image(
data as *const u8,
length as size_t,
false,
@@ -154,7 +157,7 @@ pub extern "system" fn Java_app_alextran_immich_NativeImage_cancel<'caller>(
) {
env.with_env(|env| -> jni::errors::Result<()> {
let flag = env.get_direct_buffer_address(&flag)?.cast::<bool>();
// SAFETY: the buffer is the live one-byte flag Kotlin passed to `decodeJxl`.
// SAFETY: the buffer is the live one-byte flag Kotlin passed to `decode`.
unsafe { native_core_cancel(flag) };
Ok(())
})
+1 -1
View File
@@ -9,7 +9,7 @@ mod android;
mod bitmap;
#[cfg(target_os = "android")]
mod convert;
mod jxl;
mod decode;
#[cfg(target_os = "android")]
mod rotate;
mod thumbhash;
+1
View File
@@ -11,6 +11,7 @@ const _manifests = [
'../.cargo/config.toml',
'../Cargo.toml',
'../Cargo.lock',
'../crates/codecs/Cargo.toml',
'../crates/native_core/Cargo.toml',
'$_crate/Cargo.toml',
'$_crate/rust-toolchain.toml',
+6 -3
View File
@@ -6343,7 +6343,7 @@
"state": "Added"
}
],
"x-immich-permission": "clusterGroup.read"
"x-immich-permission": "clusterGroup.admin"
}
},
"/cluster-groups/{id}/requests": {
@@ -25811,6 +25811,7 @@
"backup.delete",
"clusterGroup.read",
"clusterGroup.leave",
"clusterGroup.admin",
"clusterGroupRequest.create",
"clusterGroupRequest.read",
"clusterGroupRequest.delete",
@@ -28466,11 +28467,13 @@
"ServerFeatureLevel": {
"description": "Server feature level",
"enum": [
1
1,
2
],
"type": "integer",
"x-enum-varnames": [
"FeatureLevel"
"FeatureLevel",
"Passkeys"
]
},
"ServerFeaturesDto": {
+1 -1
View File
@@ -33,7 +33,7 @@
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^75.0.0",
"eslint-plugin-unicorn": "^78.0.0",
"globals": "^17.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.7.4",
+19 -19
View File
@@ -28,11 +28,11 @@ export const defaultQueueOptions = {
};
/**
* An in-memory queue that processes tasks in parallel with a given concurrency.
* @see {@link https://www.npmjs.com/package/fastq}
* @template T - The type of the worker task data.
* @template R - The type of the worker output data.
*/
An in-memory queue that processes tasks in parallel with a given concurrency.
@see {@link https://www.npmjs.com/package/fastq}
@template T - The type of the worker task data.
@template R - The type of the worker output data.
*/
export class Queue<T, R> {
private readonly queue: fastq.queueAsPromised<string, Task<T, R>>;
private readonly store = new Map<string, Task<T, R>>();
@@ -40,10 +40,10 @@ export class Queue<T, R> {
readonly worker: (data: T) => Promise<R>;
/**
* Create a new queue.
* @param worker - The worker function that processes the task.
* @param options - The queue options.
*/
Create a new queue.
@param worker - The worker function that processes the task.
@param options - The queue options.
*/
constructor(worker: (data: T) => Promise<R>, options?: QueueOptions) {
this.options = { ...defaultQueueOptions, ...options };
this.worker = worker;
@@ -65,21 +65,21 @@ export class Queue<T, R> {
}
/**
* Wait for the queue to be empty.
* @returns Promise<void> - The returned Promise will be resolved when all tasks in the queue have been processed by a worker.
* This promise could be ignored as it will not lead to a `unhandledRejection`.
*/
Wait for the queue to be empty.
@returns Promise<void> - The returned Promise will be resolved when all tasks in the queue have been processed by a worker.
This promise could be ignored as it will not lead to a `unhandledRejection`.
*/
drained(): Promise<void> {
return this.queue.drained();
}
/**
* Add a task at the end of the queue.
* @see {@link https://www.npmjs.com/package/fastq}
* @param data
* @returns Promise<void> - A Promise that will be fulfilled (rejected) when the task is completed successfully (unsuccessfully).
* This promise could be ignored as it will not lead to a `unhandledRejection`.
*/
Add a task at the end of the queue.
@see {@link https://www.npmjs.com/package/fastq}
@param data
@returns Promise<void> - A Promise that will be fulfilled (rejected) when the task is completed successfully (unsuccessfully).
This promise could be ignored as it will not lead to a `unhandledRejection`.
*/
async push(data: T): Promise<Task<T, R>> {
const id = uniqueId();
const task: Task<T, R> = { id, status: 'idle', error: undefined, count: 0, data, result: undefined };
+3 -3
View File
@@ -218,9 +218,9 @@ export const sha1 = (filepath: string) => {
};
/**
* Batches items and calls onBatch to process them
* when the batch size is reached or the debounce time has passed.
*/
Batches items and calls onBatch to process them
when the batch size is reached or the debounce time has passed.
*/
export class Batcher<T = unknown> {
private items: T[] = [];
private readonly batchSize: number;
+2 -2
View File
@@ -407,7 +407,7 @@
"matchType": {
"type": "string",
"title": "Match type",
"enum": ["contains", "startsWith", "exact", "regex"],
"enum": ["contains", "startsWith", "exact", "regex", "present", "missing"],
"default": "contains",
"description": "Type of pattern matching to perform",
"uiHint": {
@@ -424,7 +424,7 @@
}
}
},
"required": ["property", "pattern"]
"required": ["property"]
},
"uiHints": ["Filter"]
},
+21 -8
View File
@@ -3,13 +3,18 @@ import { AssetVisibility } from '@immich/sdk';
import type { Manifest } from '../dist/index.d.ts';
type MatchValueConfig = {
pattern: string;
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
pattern?: string;
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith' | 'missing' | 'present';
caseSensitive?: boolean;
};
const matchValueResult = (value: string, config: MatchValueConfig) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const matchValueResult = (value: string | null, config: MatchValueConfig) => {
const { pattern = '', matchType = 'contains', caseSensitive = false } = config;
if (value === null) {
return { workflow: { continue: matchType === 'missing' } };
}
const searchName = caseSensitive ? value : value.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
@@ -32,6 +37,15 @@ const matchValueResult = (value: string, config: MatchValueConfig) => {
return { workflow: { continue: regex.test(value) } };
}
case 'present': {
return { workflow: { continue: true } };
}
case 'missing': {
// value is not null at this point
return { workflow: { continue: false } };
}
default: {
return {};
}
@@ -135,11 +149,10 @@ const methods = wrapper<Manifest>({
},
assetExifFilter: ({ config, data }) => {
if (!data.asset.exifInfo || data.asset.exifInfo[config.property] === null) {
return { workflow: { continue: false } };
}
let value = data.asset.exifInfo?.[config.property];
return matchValueResult(String(data.asset.exifInfo[config.property]), config);
// Empty EXIF value is treated as missing to mirror behavior of the web UI
return matchValueResult(value === null || value === undefined || value === '' ? null : String(value), config);
},
assetDateFilter: ({ config, data }) => {
+3 -1
View File
@@ -8351,6 +8351,7 @@ export enum Permission {
BackupDelete = "backup.delete",
ClusterGroupRead = "clusterGroup.read",
ClusterGroupLeave = "clusterGroup.leave",
ClusterGroupAdmin = "clusterGroup.admin",
ClusterGroupRequestCreate = "clusterGroupRequest.create",
ClusterGroupRequestRead = "clusterGroupRequest.read",
ClusterGroupRequestDelete = "clusterGroupRequest.delete",
@@ -8823,7 +8824,8 @@ export enum ReleaseType {
Prerelease = "prerelease"
}
export enum ServerFeatureLevel {
FeatureLevel = 1
FeatureLevel = 1,
Passkeys = 2
}
export enum UserMetadataKey {
Preferences = "preferences",
+460 -445
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -50,16 +50,16 @@
"@nestjs/schedule": "^12.0.1",
"@nestjs/swagger": "^12.0.0",
"@nestjs/websockets": "^12.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "^1.10.0",
"@opentelemetry/context-async-hooks": "^3.0.0",
"@opentelemetry/exporter-prometheus": "^0.223.0",
"@opentelemetry/instrumentation-http": "^0.223.0",
"@opentelemetry/instrumentation-ioredis": "^0.71.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.70.0",
"@opentelemetry/instrumentation-pg": "^0.76.0",
"@opentelemetry/exporter-prometheus": "^0.300.0",
"@opentelemetry/instrumentation-http": "^0.300.0",
"@opentelemetry/instrumentation-ioredis": "^0.72.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.71.0",
"@opentelemetry/instrumentation-pg": "^0.77.0",
"@opentelemetry/resources": "^3.0.0",
"@opentelemetry/sdk-metrics": "^3.0.0",
"@opentelemetry/sdk-node": "^0.223.0",
"@opentelemetry/sdk-node": "^0.300.0",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@simplewebauthn/server": "14.0.3",
"@socket.io/redis-adapter": "^8.3.0",
@@ -156,7 +156,7 @@
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^75.0.0",
"eslint-plugin-unicorn": "^78.0.0",
"globals": "^17.0.0",
"mock-fs": "^5.2.0",
"pngjs": "^7.0.0",
+5 -7
View File
@@ -28,14 +28,12 @@ export async function configureExpress(
ssr,
}: {
/**
* Whether to allow swagger module to write to the specs.json
* This is not desirable when the API is not available
* @default true
*/
Whether to allow swagger module to write to the specs.json
This is not desirable when the API is not available
@default true
*/
permitSwaggerWrite?: boolean;
/**
* Service to use for server-side rendering
*/
/** Service to use for server-side rendering */
ssr: typeof ApiService | typeof MaintenanceWorkerService;
},
) {
@@ -102,7 +102,7 @@ export class PromptConfirmMoveQuestions {
message: 'Do you want to proceed? [Y/n]',
name: 'value',
})
value(value: string): boolean {
return ['yes', 'y'].includes((value || 'y').toLowerCase());
value(value: string = 'y'): boolean {
return ['yes', 'y'].includes(value.toLowerCase());
}
}
@@ -94,7 +94,7 @@ export class ClusterGroupController {
}
@Post(':id/regenerate-people')
@Authenticated({ permission: Permission.ClusterGroupRead })
@Authenticated({ permission: Permission.ClusterGroupAdmin })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Regenerate people of users in cluster group',
+11 -11
View File
@@ -32,11 +32,11 @@ export const UpdatedAtTrigger = (name: string) =>
export const DATABASE_PARAMETER_CHUNK_SIZE = 65_500;
/**
* Chunks an array or set into smaller collections of the same type and specified size.
*
* @param collection The collection to chunk.
* @param size The size of each chunk.
*/
Chunks an array or set into smaller collections of the same type and specified size.
@param collection The collection to chunk.
@param size The size of each chunk.
*/
function chunks<T>(collection: Array<T>, size: number): Array<Array<T>>;
function chunks<T>(collection: Set<T>, size: number): Array<Set<T>>;
function chunks<T>(collection: Array<T> | Set<T>, size: number): Array<Array<T>> | Array<Set<T>> {
@@ -60,12 +60,12 @@ function chunks<T>(collection: Array<T> | Set<T>, size: number): Array<Array<T>>
}
/**
* Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection,
* to overcome the maximum number of parameters allowed by the database driver.
*
* @param options.paramIndex The index of the function parameter to chunk. Defaults to 0.
* @param options.flatten Whether to flatten the results. Defaults to false.
*/
Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection,
to overcome the maximum number of parameters allowed by the database driver.
@param options.paramIndex The index of the function parameter to chunk. Defaults to 0.
@param options.flatten Whether to flatten the results. Defaults to false.
*/
export function Chunked(
options: { paramIndex?: number; chunkSize?: number; mergeFn?: (results: any) => any } = {},
): MethodDecorator {
+6 -6
View File
@@ -8,9 +8,9 @@ import { JsonParsed, isoDatetimeToDate, stringToBool } from 'src/validation.js';
export enum AssetMediaSize {
Original = 'original',
/**
* An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF.
* or otherwise the original image itself.
*/
An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF.
or otherwise the original image itself.
*/
FULLSIZE = 'fullsize',
PREVIEW = 'preview',
THUMBNAIL = 'thumbnail',
@@ -41,9 +41,9 @@ const AssetMediaBaseSchema = z.object({
duration: z.coerce.number().int().min(0).optional().describe('Duration in milliseconds (for videos)'),
filename: z.string().optional().describe('Filename'),
/**
* The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled
* in the controller. File parts never reach the request body, so they must be optional here
*/
The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled
in the controller. File parts never reach the request body, so they must be optional here
*/
[UploadFieldName.ASSET_DATA]: z
.any()
.optional()
+4 -4
View File
@@ -14,10 +14,10 @@ enum DatabaseSslMode {
const DatabaseSslModeSchema = z.enum(DatabaseSslMode).describe('Database SSL mode').meta({ id: 'DatabaseSslMode' });
const absolutePath = z.string().regex(/^\//, 'Must be an absolute path').optional();
/**
* Treat certain strings as booleans and coerce them to boolean
* Ideal for environment variables that are strings but should be treated as booleans
* @docs https://zod.dev/api?id=stringbool
*/
Treat certain strings as booleans and coerce them to boolean
Ideal for environment variables that are strings but should be treated as booleans
@docs https://zod.dev/api?id=stringbool
*/
const stringBool = z.stringbool();
const trustedProxiesSchema = z
+3 -3
View File
@@ -323,9 +323,9 @@ export const isFullyAlbumConfined = (filter: SearchFilter): boolean =>
isAlbumConfined(filter) || (!!filter.or?.length && filter.or.every((branch) => isAlbumConfined(branch)));
/**
* The structured shape and the deprecated flat search fields are mutually exclusive
* TODO(v4): remove together with the deprecated flat fields.
*/
The structured shape and the deprecated flat search fields are mutually exclusive
TODO(v4): remove together with the deprecated flat fields.
*/
const withShapeExclusivity = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) => {
const deprecatedFields = Object.keys(schema.shape).filter(
(field) => (schema.shape[field] as z.ZodType).meta()?.deprecated,
+10 -12
View File
@@ -59,9 +59,7 @@ export enum ChecksumAlgorithm {
}
export enum AssetFileType {
/**
* An full/large-size image extracted/converted from RAW photos
*/
/** An full/large-size image extracted/converted from RAW photos */
FullSize = 'fullsize',
Preview = 'preview',
Thumbnail = 'thumbnail',
@@ -195,6 +193,7 @@ export enum Permission {
ClusterGroupRead = 'clusterGroup.read',
ClusterGroupLeave = 'clusterGroup.leave',
ClusterGroupAdmin = 'clusterGroup.admin',
ClusterGroupRequestCreate = 'clusterGroupRequest.create',
ClusterGroupRequestRead = 'clusterGroupRequest.read',
ClusterGroupRequestDelete = 'clusterGroupRequest.delete',
@@ -362,9 +361,9 @@ export enum SharedLinkType {
Album = 'ALBUM',
/**
* Individual asset
* or group of assets that are not in an album
*/
Individual asset
or group of assets that are not in an album
*/
Individual = 'INDIVIDUAL',
}
@@ -818,9 +817,9 @@ export enum DvProfile {
}
/**
* Dolby Vision base-layer signal-compatibility ID from the DOVI configuration record.
* Identifies what the base HEVC/AVC layer renders as on a non-DV decoder.
*/
Dolby Vision base-layer signal-compatibility ID from the DOVI configuration record.
Identifies what the base HEVC/AVC layer renders as on a non-DV decoder.
*/
export enum DvSignalCompatibility {
None = 0,
Hdr10 = 1,
@@ -1227,9 +1226,7 @@ export enum AssetVisibility {
Archive = 'archive',
Timeline = 'timeline',
/**
* Video part of the LivePhotos and MotionPhotos
*/
/** Video part of the LivePhotos and MotionPhotos */
Hidden = 'hidden',
Locked = 'locked',
}
@@ -1344,6 +1341,7 @@ export const SearchOrderFieldSchema = z.enum(SearchOrderField).meta({ id: 'Searc
export enum ServerFeatureLevel {
FeatureLevel = 1,
Passkeys = 2,
}
export const ServerFeatureLevelSchema = z
+7 -15
View File
@@ -10,23 +10,15 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos
import { type DB } from 'src/schema/index.js';
import { getKyselyConfig } from 'src/utils/database.js';
/**
* Manages worker lifecycle
*/
/** Manages worker lifecycle */
class Workers {
/**
* Currently running workers
*/
/** Currently running workers */
workers: Partial<Record<ImmichWorker, { kill: (signal: NodeJS.Signals) => Promise<void> | void }>> = {};
/**
* Fail-safe in case anything dies during restart
*/
/** Fail-safe in case anything dies during restart */
restarting = false;
/**
* Boot all enabled workers
*/
/** Boot all enabled workers */
async bootstrap() {
const isMaintenanceMode = await this.isMaintenanceMode();
const { workers } = new ConfigRepository().getEnv();
@@ -92,9 +84,9 @@ class Workers {
}
/**
* Start an individual worker
* @param name Worker
*/
Start an individual worker
@param name Worker
*/
private startWorker(name: ImmichWorker) {
console.log(`Starting ${name} worker`);
@@ -43,9 +43,7 @@ export class MaintenanceWorkerController {
private databaseBackupService: DatabaseBackupService,
) {}
/**
* {@link _ServerController.getServerConfig }
*/
/** {@link _ServerController.getServerConfig } */
@Get('server/config')
getServerConfig(): ServerConfigDto {
return this.service.getSystemConfig();
@@ -61,18 +59,14 @@ export class MaintenanceWorkerController {
return this.service.getVersion();
}
/**
* {@link _DatabaseBackupController.listDatabaseBackups}
*/
/** {@link _DatabaseBackupController.listDatabaseBackups} */
@Get('admin/database-backups')
@MaintenanceRoute()
listDatabaseBackups(): Promise<DatabaseBackupListResponseDto> {
return this.databaseBackupService.listBackups();
}
/**
* {@link _DatabaseBackupController.downloadDatabaseBackup}
*/
/** {@link _DatabaseBackupController.downloadDatabaseBackup} */
@Get('admin/database-backups/:filename')
@MaintenanceRoute()
async downloadDatabaseBackup(
@@ -83,18 +77,14 @@ export class MaintenanceWorkerController {
await sendFile(res, next, () => this.databaseBackupService.downloadBackup(filename), this.logger);
}
/**
* {@link _DatabaseBackupController.deleteDatabaseBackup}
*/
/** {@link _DatabaseBackupController.deleteDatabaseBackup} */
@Delete('admin/database-backups')
@MaintenanceRoute()
async deleteDatabaseBackup(@Body() dto: DatabaseBackupDeleteDto): Promise<void> {
return this.databaseBackupService.deleteBackup(dto.backups);
}
/**
* {@link _DatabaseBackupController.uploadDatabaseBackup}
*/
/** {@link _DatabaseBackupController.uploadDatabaseBackup} */
@Post('admin/database-backups/upload')
@MaintenanceRoute()
@UseInterceptors(FileInterceptor('file'))
@@ -282,9 +282,7 @@ describe(MaintenanceWorkerService.name, () => {
describe.skip('setAction'); // just calls setStatus+runAction
/**
* Actions
*/
/** Actions */
describe('action: start', () => {
it('should not do anything', async () => {
@@ -33,9 +33,7 @@ import { getConfig } from 'src/utils/config.js';
import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance.js';
import { getExternalDomain } from 'src/utils/misc.js';
/**
* This service is available inside of maintenance mode to manage maintenance mode
*/
/** This service is available inside of maintenance mode to manage maintenance mode */
@Injectable()
export class MaintenanceWorkerService {
#secret: string | null = null;
@@ -87,9 +85,7 @@ export class MaintenanceWorkerService {
}
}
/**
* {@link _BaseService.configRepos}
*/
/** {@link _BaseService.configRepos} */
private get configRepos() {
return {
configRepo: this.configRepository,
@@ -98,25 +94,19 @@ export class MaintenanceWorkerService {
};
}
/**
* {@link _BaseService.prototype.getConfig}
*/
/** {@link _BaseService.prototype.getConfig} */
private getConfig(options: { withCache: boolean }) {
return getConfig(this.configRepos, options);
}
/**
* {@link _ServerService.getSystemConfig}
*/
/** {@link _ServerService.getSystemConfig} */
getSystemConfig() {
return {
maintenanceMode: true,
} as ServerConfigDto;
}
/**
* {@link _VersionService.getVersion}
*/
/** {@link _VersionService.getVersion} */
getVersion() {
return ServerVersionResponseDto.fromSemVer(serverVersion);
}
@@ -125,9 +115,7 @@ export class MaintenanceWorkerService {
return { res: 'pong' };
}
/**
* {@link _ApiService.ssr}
*/
/** {@link _ApiService.ssr} */
ssr(excludePaths: string[]) {
const { resourcePaths } = this.configRepository.getEnv();
@@ -158,9 +146,7 @@ export class MaintenanceWorkerService {
};
}
/**
* {@link _StorageService.detectMediaLocation}
*/
/** {@link _StorageService.detectMediaLocation} */
detectMediaLocation(): string {
const envData = this.configRepository.getEnv();
if (envData.storage.mediaLocation) {
+126 -3
View File
@@ -38,7 +38,48 @@ select
and "asset_file"."type" = $1
) as agg
) as "files",
to_json("asset_exif") as "exifInfo"
(
select
to_json(obj)
from
(
select
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as obj
) as "exifInfo"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
@@ -239,7 +280,48 @@ select
"asset_edit"."assetId" = "asset"."id"
) as agg
) as "edits",
to_json("asset_exif") as "exifInfo",
(
select
to_json(obj)
from
(
select
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as obj
) as "exifInfo",
(
select
to_json(obj)
@@ -449,7 +531,48 @@ where
select
"asset"."id",
"asset"."visibility",
to_json("asset_exif") as "exifInfo",
(
select
to_json(obj)
from
(
select
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as obj
) as "exifInfo",
(
select
coalesce(json_agg(agg), '[]')
+2 -1
View File
@@ -121,7 +121,8 @@ from
) as "asset2" on true
where
"asset"."visibility" in ('archive', 'timeline')
and "asset"."duplicateId" = $1::uuid
and "asset"."ownerId" = $1::uuid
and "asset"."duplicateId" = $2::uuid
and "asset"."deletedAt" is null
and "asset"."stackId" is null
group by
+84 -2
View File
@@ -134,7 +134,48 @@ select
"asset"."type",
"asset"."width",
"asset"."height",
to_json("asset_exif") as "exifInfo"
(
select
to_json(obj)
from
(
select
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as obj
) as "exifInfo"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
@@ -1420,7 +1461,48 @@ offset
-- SearchRepository.searchMetadataV3 (order-rating-withExif)
select
to_json("asset_exif") as "exifInfo",
(
select
to_json(obj)
from
(
select
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as obj
) as "exifInfo",
"asset"."id",
"asset"."updateId",
"asset"."createdAt",
+16 -16
View File
@@ -227,12 +227,12 @@ export class AlbumRepository {
}
/**
* Get asset IDs for the given album ID.
*
* @param albumId Album ID to get asset IDs for.
* @param assetIds Optional list of asset IDs to filter on.
* @returns Set of Asset IDs for the given album ID.
*/
Get asset IDs for the given album ID.
@param albumId Album ID to get asset IDs for.
@param assetIds Optional list of asset IDs to filter on.
@returns Set of Asset IDs for the given album ID.
*/
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@ChunkedSet({ paramIndex: 1 })
async getAssetIds(albumId: string, assetIds: string[]): Promise<Set<string>> {
@@ -348,13 +348,13 @@ export class AlbumRepository {
}
/**
* Makes sure all thumbnails for albums are updated by:
* - Removing thumbnails from albums without assets
* - Removing references of thumbnails to assets outside the album
* - Setting a thumbnail when none is set and the album contains assets
*
* @returns Amount of updated album thumbnails or undefined when unknown
*/
Makes sure all thumbnails for albums are updated by:
- Removing thumbnails from albums without assets
- Removing references of thumbnails to assets outside the album
- Setting a thumbnail when none is set and the album contains assets
@returns Amount of updated album thumbnails or undefined when unknown
*/
async updateThumbnails(): Promise<number | undefined> {
// Subquery for getting a new thumbnail.
@@ -399,9 +399,9 @@ export class AlbumRepository {
}
/**
* Get per-user asset contribution counts for a single album.
* Excludes deleted assets, orders by count desc.
*/
Get per-user asset contribution counts for a single album.
Excludes deleted assets, orders by count desc.
*/
@GenerateSql({ params: [DummyValue.UUID] })
getContributorCounts(id: string) {
return this.db
@@ -112,7 +112,7 @@ export class DuplicateRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async get(duplicateId: string): Promise<{ duplicateId: string; assets: MapAsset[] } | undefined> {
async get(userId: string, duplicateId: string): Promise<{ duplicateId: string; assets: MapAsset[] } | undefined> {
const result = await this.db
.selectFrom('asset')
.$call(withDefaultVisibility)
@@ -141,6 +141,7 @@ export class DuplicateRepository {
)
.select('asset.duplicateId')
.select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo<MapAsset[]>().as('assets'))
.where('asset.ownerId', '=', asUuid(userId))
.where('asset.duplicateId', '=', asUuid(duplicateId))
.where('asset.deletedAt', 'is', null)
.where('asset.stackId', 'is', null)
+7 -7
View File
@@ -73,10 +73,10 @@ export class MediaRepository {
}
/**
*
* @param input file path to the input image
* @returns ExtractResult if succeeded, or null if failed
*/
@param input file path to the input image
@returns ExtractResult if succeeded, or null if failed
*/
async extract(input: string): Promise<ExtractResult | null> {
for (const { tag, format } of [
{ tag: 'JpgFromRaw2', format: RawExtractedFormat.Jpeg },
@@ -305,9 +305,9 @@ export class MediaRepository {
}
/**
* Needed for accurate segments, especially when remuxing, seeking and/or VFR is involved.
* Scanning packets for keyframes in JS is much faster than -skip_frame nokey since it avoids decoding the video.
*/
Needed for accurate segments, especially when remuxing, seeking and/or VFR is involved.
Scanning packets for keyframes in JS is much faster than -skip_frame nokey since it avoids decoding the video.
*/
probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> {
const ffprobe = spawn(
'ffprobe',
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { MetricOptions } from '@opentelemetry/api';
import { MetricOptions, ValueType } from '@opentelemetry/api';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
@@ -9,7 +9,7 @@ import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'
import { PgInstrumentation } from '@opentelemetry/instrumentation-pg';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { AggregationType } from '@opentelemetry/sdk-metrics';
import { NodeSDK, contextBase } from '@opentelemetry/sdk-node';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { snakeCase, startCase } from 'lodash-es';
import { MetricService } from 'nestjs-otel';
@@ -68,7 +68,7 @@ export const bootstrapTelemetry = (port: number) => {
[ATTR_SERVICE_NAME]: `immich`,
[ATTR_SERVICE_VERSION]: serverVersion.toString(),
}),
metricReader: new PrometheusExporter({ port }),
metricReaders: [new PrometheusExporter({ port })],
contextManager: new AsyncLocalStorageContextManager(),
instrumentations: [
new HttpInstrumentation({
@@ -162,7 +162,7 @@ export class TelemetryRepository {
prefix: 'immich',
description: `The elapsed time in ${unit} for the ${startCase(className)} to ${propertyName.toLowerCase()}`,
unit,
valueType: contextBase.ValueType.DOUBLE,
valueType: ValueType.DOUBLE,
});
descriptor.value = function (...args: any[]) {
+7 -3
View File
@@ -1,3 +1,4 @@
import { trim } from 'lodash-es';
import { InjectKysely } from 'nestjs-kysely';
import type { Kysely } from 'kysely';
import { DummyValue, GenerateSql } from 'src/decorators.js';
@@ -5,6 +6,8 @@ import { AssetVisibility } from 'src/enum.js';
import { DB } from 'src/schema/index.js';
import { asUuid, withExif } from 'src/utils/database.js';
const escapeLikePattern = (value: string) => value.replaceAll(/[\\%_]/g, String.raw`\$&`);
export class ViewRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -28,7 +31,8 @@ export class ViewRepository {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getAssetsByOriginalPath(userId: string, partialPath: string) {
const normalizedPath = partialPath.replaceAll(/\/$/g, '');
const directory = trim(partialPath, '/');
const prefix = escapeLikePattern(directory ? `/${directory}/` : '/');
return this.db
.selectFrom('asset')
@@ -40,8 +44,8 @@ export class ViewRepository {
.where('fileCreatedAt', 'is not', null)
.where('fileModifiedAt', 'is not', null)
.where('localDateTime', 'is not', null)
.where('originalPath', 'like', `%${normalizedPath}/%`)
.where('originalPath', 'not like', `%${normalizedPath}/%/%`)
.where('originalPath', 'like', `${prefix}%`)
.where('originalPath', 'not like', `${prefix}%/%`)
.orderBy(
(eb) => eb.fn('regexp_replace', ['asset.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]),
'asc',
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE INDEX "asset_ownerId_updateId_idx" ON "asset" ("ownerId", "updateId");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "asset_ownerId_updateId_idx";`.execute(db);
}
+1
View File
@@ -101,3 +101,4 @@
1792213725597-AddAssetFaceAssetState
1792511451454-AddPasskeyTable
1792517020180-AddUpdateIdIndexes
1792568637987-AddAssetOwnerIdUpdateIdIndex
+1
View File
@@ -56,6 +56,7 @@ import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database.js';
})
@Index({ columns: ['originalPath', 'libraryId'] })
@Index({ columns: ['id', 'stackId'] })
@Index({ columns: ['ownerId', 'updateId'] })
@Index({
name: 'asset_originalFilename_trigram_idx',
using: 'gin',
+67
View File
@@ -888,6 +888,24 @@ describe(AlbumService.name, () => {
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]));
});
it('should allow adding assets shared via album sharing', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const asset = AssetFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([asset.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: true, id: asset.id },
]);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset.id]);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]), false);
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]));
});
it('should skip duplicate assets', async () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
@@ -917,6 +935,7 @@ describe(AlbumService.name, () => {
]);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]), false);
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]));
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]));
});
@@ -1185,6 +1204,33 @@ describe(AlbumService.name, () => {
);
});
it('should allow adding assets shared via album sharing', async () => {
const user = UserFactory.create();
const album1 = AlbumFactory.create();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.create();
const [asset1, asset2] = [AssetFactory.create({ ownerId: user.id }), AssetFactory.create({ ownerId: user.id })];
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id],
}),
).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: album1.id, assetId: asset1.id },
{ albumId: album1.id, assetId: asset2.id },
{ albumId: album2.id, assetId: asset1.id },
{ albumId: album2.id, assetId: asset2.id },
]);
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(owner.id, new Set([asset1.id, asset2.id]));
});
it('should skip some duplicate assets', async () => {
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
const album1 = AlbumFactory.create();
@@ -1282,6 +1328,10 @@ describe(AlbumService.name, () => {
new Set([asset1.id, asset2.id, asset3.id]),
false,
);
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(
owner.id,
new Set([asset1.id, asset2.id, asset3.id]),
);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
owner.id,
new Set([asset1.id, asset2.id, asset3.id]),
@@ -1348,6 +1398,23 @@ describe(AlbumService.name, () => {
expect(mocks.album.removeAssetIds).toHaveBeenCalledWith(album.id, [asset.id]);
});
it('should allow a shared user to remove assets shared via album sharing', async () => {
const user = UserFactory.create();
const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build();
const asset = AssetFactory.create();
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([asset.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
await expect(sut.removeAssets(AuthFactory.create(user), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: true, id: asset.id },
]);
expect(mocks.album.removeAssetIds).toHaveBeenCalledWith(album.id, [asset.id]);
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(user.id, new Set([asset.id]));
});
it('should skip assets not in the album', async () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
+1 -1
View File
@@ -73,7 +73,7 @@ export class ClusterGroupService extends BaseService {
}
async regeneratePeople(auth: AuthDto, id: string) {
await this.requireAccess({ auth, permission: Permission.ClusterGroupRead, ids: [id] });
await this.requireAccess({ auth, permission: Permission.ClusterGroupAdmin, ids: [id] });
await this.jobRepository.queue({
name: JobName.FacialRecognitionQueueAll,
@@ -233,6 +233,28 @@ describe(DuplicateService.name, () => {
]);
});
it('should only load the assets owned by the user', async () => {
const asset = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset as unknown as MapAsset],
});
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
const result = await sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: ['other-user-asset'], trashAssetIds: [asset.id] }],
});
expect(mocks.duplicateRepository.get).toHaveBeenCalledWith(authStub.admin.user.id, 'group-1');
expect(result[0].success).toBe(true);
expect(mocks.asset.updateAllExif).not.toHaveBeenCalled();
for (const [ids] of mocks.asset.updateAll.mock.calls) {
expect(ids).toEqual([asset.id]);
}
});
it('should skip when keepAssetIds contains non-member', async () => {
const asset = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
+1 -1
View File
@@ -112,7 +112,7 @@ export class DuplicateService extends BaseService {
private async resolveGroup(auth: AuthDto, group: DuplicateResolveGroupDto): Promise<BulkIdResponseDto> {
const { duplicateId, keepAssetIds, trashAssetIds } = group;
const duplicateGroup = await this.duplicateRepository.get(duplicateId);
const duplicateGroup = await this.duplicateRepository.get(auth.user.id, duplicateId);
if (!duplicateGroup) {
return { id: duplicateId, success: false, error: BulkIdErrorReason.NOT_FOUND };
}

Some files were not shown because too many files have changed in this diff Show More