Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
132af2fc74 | ||
|
|
9e5bdda89c | ||
|
|
79ea67f6be | ||
|
|
79b175465e | ||
|
|
c76bd08f73 | ||
|
|
f2e05e9d98 | ||
|
|
c8a957a116 | ||
|
|
f7697264ed | ||
|
|
648dea2072 | ||
|
|
895b755f53 | ||
|
|
568a9d977e | ||
|
|
3c383e46d1 | ||
|
|
477fcc2338 | ||
|
|
ff52f491fb | ||
|
|
c3933169de | ||
|
|
2c1f321627 | ||
|
|
e8c1fd297d | ||
|
|
8e3e3a48ae | ||
|
|
dc5553b239 | ||
|
|
32e02a05d2 | ||
|
|
c581bce715 | ||
|
|
dd23264385 | ||
|
|
6dbe72b6a1 | ||
|
|
72f96701f0 | ||
|
|
b4ae7f0dcf | ||
|
|
09bb2a9fb5 | ||
|
|
8f2bfa099e | ||
|
|
07083aec22 | ||
|
|
64d50dc4b7 | ||
|
|
7fb7699c06 | ||
|
|
3127dbec71 | ||
|
|
f418967d55 | ||
|
|
1f422ea4c4 |
@@ -35,6 +35,7 @@ jobs:
|
|||||||
filters: |
|
filters: |
|
||||||
docs:
|
docs:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
|
- 'deployment/**'
|
||||||
open-api:
|
open-api:
|
||||||
- 'open-api/immich-openapi-specs.json'
|
- 'open-api/immich-openapi-specs.json'
|
||||||
force-filters: |
|
force-filters: |
|
||||||
|
|||||||
+18
-28
@@ -2,34 +2,24 @@
|
|||||||
# Manual edits may be lost in future updates.
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||||
version = "4.52.10"
|
version = "5.28.1"
|
||||||
constraints = "4.52.10"
|
constraints = "5.28.1"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:57oeYqz+pYEPAw2GvHARjcER0QYU0teS6DJzS26kCAY=",
|
"h1:9YL6g+iEfg3OzX6qSZOYsictVO0fNP09D6jRwIZKwQ0=",
|
||||||
"h1:6bnAgXSboppQbj79XmRtQLj/58d+b4EozydI8XrqwRE=",
|
"h1:GjOKfCmvcyE06fUp5WrAzfCLkTQaCg2Ko/kFlq8OfpM=",
|
||||||
"h1:6eP/jGOz1ioH17d3pygKhvsRa4AEKvedKqd+wgyiHYk=",
|
"h1:QTIohIPsVCqEmknrR2PdbVXyK1fjS805rMaZpEEpzFQ=",
|
||||||
"h1:84gJm57BBSvtfwt7t9ZiU7EKKDQARjsztoDuqB5DfqU=",
|
"h1:SFb952CVUFUoFjfor+GUqAV6DQ6bnUfaFcWL2yJlFBo=",
|
||||||
"h1:99tME9L/Ug/dXElX3Ac7Kx+KD7nhWEEwOTqaQubW18w=",
|
"h1:aso3s6IuIQju5L/OLrywCfQl7aA5TF+sb5pZzo8JG20=",
|
||||||
"h1:TnMGVaPpIH5NZX324v+y6j1iW3ym0MqqNekxHlzXT3A=",
|
"h1:cxGa9LRKCzwNacgnLjXnHUygkvAEd6pb8g8sRwzPSRE=",
|
||||||
"h1:Z1L5jI4GiJxAAsfNyZHU4MGTluWEjjKtlB8Xn6cA0Dg=",
|
"h1:kBW0qcDqNupOnsi7vvx/0TQRxXLtmDKAHwOwH5l03pc=",
|
||||||
"h1:dB/WsY27MLla/cwjYggolJhVeHUeS59rlOObAWK2e24=",
|
"h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
|
||||||
"h1:gpffNocq9fHK+uQehZQK6YmL1aHeCGrVM1p53dcFP/0=",
|
"zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
|
||||||
"h1:jvfw1A2Uck4AjNmmWXwGH6P0sGQrnNY/KUVlqgyqYUM=",
|
"zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
|
||||||
"h1:ojvrl9uI1EPtAwKRHim9eSc4Lc88thU/2BFGdJLEwrc=",
|
"zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
|
||||||
"h1:rJZJK06NYCcSZhY5fwsRUSznSmAmRWVg4sO0auvbURo=",
|
"zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
|
||||||
"h1:y2m6i2SnQ3ZxiJHlMWNLHJrEbNqQT6OpDGyhFP+Nmds=",
|
"zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
|
||||||
"zh:06c09cc50d6c61fb5f1ca79e4b22d5f383c6a848f885ed542dadef44ab4bfd55",
|
"zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
|
||||||
"zh:205bdfdac49d6b25ec2ca7ecffd39bf47126340e1943066bdbbd64d6d72577d2",
|
"zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
|
||||||
"zh:341ed688772d8e2bd9a148c57d56d5dfbe3d625bc50296a505cbae3d29694562",
|
"zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
|
||||||
"zh:3790619bae55558ab4d93f53686f7cf55f35138f800ea07b0b9c20cce75b9c0d",
|
|
||||||
"zh:44284a5b67e34f1381bbc47b6e765cee581037e25cd3b459caec069493b63898",
|
|
||||||
"zh:493a173f3d5245db82782e8c1aba63e34c6fcf6b952b9288926b519bfe9d9725",
|
|
||||||
"zh:4eb7846381fee6eefdb7e0aaf80153ed71d35547752f96987218a9429645844f",
|
|
||||||
"zh:850b1d1da770f48def40223d754ff65c31fabb1a7f334ed6ebb40eba09c41572",
|
|
||||||
"zh:9a29a08a9fd40d7c1beefe8597617c2fc475d62ec6d5ca8fcc2cd1a056a67a2e",
|
|
||||||
"zh:9e57472f0a56863204f5b2eef125d4fb1222eca793c6a5ac9f4df6c1776edc48",
|
|
||||||
"zh:a9eff98931da596c2d335359841bfd668f4b6cde257e15546a558c7f5d72773b",
|
|
||||||
"zh:c9293fb3390c79e4f5072e019c0a2c4c7408a55a55e78ae37f739a1da75886ef",
|
|
||||||
"zh:c9e98abec7f7a1e2669dd9c065896217125d774640e4673c1303a942736b3eb4",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
cloudflare = {
|
cloudflare = {
|
||||||
source = "cloudflare/cloudflare"
|
source = "cloudflare/cloudflare"
|
||||||
version = "4.52.10"
|
version = "5.28.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-28
@@ -2,34 +2,24 @@
|
|||||||
# Manual edits may be lost in future updates.
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||||
version = "4.52.10"
|
version = "5.28.1"
|
||||||
constraints = "4.52.10"
|
constraints = "5.28.1"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:57oeYqz+pYEPAw2GvHARjcER0QYU0teS6DJzS26kCAY=",
|
"h1:9YL6g+iEfg3OzX6qSZOYsictVO0fNP09D6jRwIZKwQ0=",
|
||||||
"h1:6bnAgXSboppQbj79XmRtQLj/58d+b4EozydI8XrqwRE=",
|
"h1:GjOKfCmvcyE06fUp5WrAzfCLkTQaCg2Ko/kFlq8OfpM=",
|
||||||
"h1:6eP/jGOz1ioH17d3pygKhvsRa4AEKvedKqd+wgyiHYk=",
|
"h1:QTIohIPsVCqEmknrR2PdbVXyK1fjS805rMaZpEEpzFQ=",
|
||||||
"h1:84gJm57BBSvtfwt7t9ZiU7EKKDQARjsztoDuqB5DfqU=",
|
"h1:SFb952CVUFUoFjfor+GUqAV6DQ6bnUfaFcWL2yJlFBo=",
|
||||||
"h1:99tME9L/Ug/dXElX3Ac7Kx+KD7nhWEEwOTqaQubW18w=",
|
"h1:aso3s6IuIQju5L/OLrywCfQl7aA5TF+sb5pZzo8JG20=",
|
||||||
"h1:TnMGVaPpIH5NZX324v+y6j1iW3ym0MqqNekxHlzXT3A=",
|
"h1:cxGa9LRKCzwNacgnLjXnHUygkvAEd6pb8g8sRwzPSRE=",
|
||||||
"h1:Z1L5jI4GiJxAAsfNyZHU4MGTluWEjjKtlB8Xn6cA0Dg=",
|
"h1:kBW0qcDqNupOnsi7vvx/0TQRxXLtmDKAHwOwH5l03pc=",
|
||||||
"h1:dB/WsY27MLla/cwjYggolJhVeHUeS59rlOObAWK2e24=",
|
"h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
|
||||||
"h1:gpffNocq9fHK+uQehZQK6YmL1aHeCGrVM1p53dcFP/0=",
|
"zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
|
||||||
"h1:jvfw1A2Uck4AjNmmWXwGH6P0sGQrnNY/KUVlqgyqYUM=",
|
"zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
|
||||||
"h1:ojvrl9uI1EPtAwKRHim9eSc4Lc88thU/2BFGdJLEwrc=",
|
"zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
|
||||||
"h1:rJZJK06NYCcSZhY5fwsRUSznSmAmRWVg4sO0auvbURo=",
|
"zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
|
||||||
"h1:y2m6i2SnQ3ZxiJHlMWNLHJrEbNqQT6OpDGyhFP+Nmds=",
|
"zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
|
||||||
"zh:06c09cc50d6c61fb5f1ca79e4b22d5f383c6a848f885ed542dadef44ab4bfd55",
|
"zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
|
||||||
"zh:205bdfdac49d6b25ec2ca7ecffd39bf47126340e1943066bdbbd64d6d72577d2",
|
"zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
|
||||||
"zh:341ed688772d8e2bd9a148c57d56d5dfbe3d625bc50296a505cbae3d29694562",
|
"zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
|
||||||
"zh:3790619bae55558ab4d93f53686f7cf55f35138f800ea07b0b9c20cce75b9c0d",
|
|
||||||
"zh:44284a5b67e34f1381bbc47b6e765cee581037e25cd3b459caec069493b63898",
|
|
||||||
"zh:493a173f3d5245db82782e8c1aba63e34c6fcf6b952b9288926b519bfe9d9725",
|
|
||||||
"zh:4eb7846381fee6eefdb7e0aaf80153ed71d35547752f96987218a9429645844f",
|
|
||||||
"zh:850b1d1da770f48def40223d754ff65c31fabb1a7f334ed6ebb40eba09c41572",
|
|
||||||
"zh:9a29a08a9fd40d7c1beefe8597617c2fc475d62ec6d5ca8fcc2cd1a056a67a2e",
|
|
||||||
"zh:9e57472f0a56863204f5b2eef125d4fb1222eca793c6a5ac9f4df6c1776edc48",
|
|
||||||
"zh:a9eff98931da596c2d335359841bfd668f4b6cde257e15546a558c7f5d72773b",
|
|
||||||
"zh:c9293fb3390c79e4f5072e019c0a2c4c7408a55a55e78ae37f739a1da75886ef",
|
|
||||||
"zh:c9e98abec7f7a1e2669dd9c065896217125d774640e4673c1303a942736b3eb4",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
cloudflare = {
|
cloudflare = {
|
||||||
source = "cloudflare/cloudflare"
|
source = "cloudflare/cloudflare"
|
||||||
version = "4.52.10"
|
version = "5.28.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ The mobile app is written in [Dart](https://dart.dev/) using [Flutter](https://f
|
|||||||
<MobileArchitecture className="p-4 dark:bg-immich-dark-primary my-4" />
|
<MobileArchitecture className="p-4 dark:bg-immich-dark-primary my-4" />
|
||||||
|
|
||||||
The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture.
|
The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture.
|
||||||
Currently, it uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers).
|
Currently, it uses [Drift](https://drift.simonbinder.eu/) (SQLite) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers).
|
||||||
Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory.
|
Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory.
|
||||||
The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes!
|
The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes!
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- 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
|
- 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
|
- 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
|
## 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 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
|
#### Single Compose File
|
||||||
|
|
||||||
@@ -174,7 +176,7 @@ Note that you should increase job concurrencies to increase overall utilization
|
|||||||
- Less heat generation
|
- 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)
|
- 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):
|
- 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)
|
- 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)
|
||||||
- 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 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
|
- 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.
|
||||||
|
|||||||
@@ -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__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_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` | 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_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 |
|
| `MACHINE_LEARNING_MODEL_REVISION` | Model revision to download; `main` selects the original exports and `v2` selects the newer exports | `main` | machine learning |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@
|
|||||||
"eslint": "^10.0.0",
|
"eslint": "^10.0.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^75.0.0",
|
"eslint-plugin-unicorn": "^78.0.0",
|
||||||
"exiftool-vendored": "^39.0.0",
|
"exiftool-vendored": "^39.0.0",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
|
|||||||
@@ -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)
|
const { status, body } = await request(app)
|
||||||
.delete(`/albums/${user1Albums[0].id}/assets`)
|
.delete(`/albums/${user1Albums[0].id}/assets`)
|
||||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||||
.send({ ids: [user1Asset1.id] });
|
.send({ ids: [user1Asset1.id] });
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual([
|
expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]);
|
||||||
expect.objectContaining({
|
|
||||||
id: user1Asset1.id,
|
|
||||||
success: false,
|
|
||||||
error: 'no_permission',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to remove own asset from own album', async () => {
|
it('should be able to remove own asset from own album', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.delete(`/albums/${user1Albums[0].id}/assets`)
|
.delete(`/albums/${user1Albums[2].id}/assets`)
|
||||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||||
.send({ ids: [user1Asset1.id] });
|
.send({ ids: [user1Asset1.id] });
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ type DayDistributionStrategy = (
|
|||||||
rng: SeededRandom,
|
rng: SeededRandom,
|
||||||
) => MockTimelineAsset[];
|
) => 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> = {
|
export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = {
|
||||||
empty: null, // Special case - handled separately
|
empty: null, // Special case - handled separately
|
||||||
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
|
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
|
'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> = {
|
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
|
||||||
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||||
// All assets on one day in the middle of the month
|
// 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 { faker } from '@faker-js/faker';
|
||||||
import { AssetVisibility } from '@immich/sdk';
|
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 type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
|
||||||
import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } 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 {
|
export function generateAspectRatio(rng: SeededRandom): string {
|
||||||
const random = rng.next();
|
const random = rng.next();
|
||||||
let cumulative = 0;
|
let cumulative = 0;
|
||||||
@@ -89,9 +85,7 @@ export function generateAsset(
|
|||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Generate assets for a specific day */
|
||||||
* Generate assets for a specific day
|
|
||||||
*/
|
|
||||||
export function generateDayAssets(
|
export function generateDayAssets(
|
||||||
year: number,
|
year: number,
|
||||||
month: number,
|
month: number,
|
||||||
@@ -104,9 +98,9 @@ export function generateDayAssets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distribute assets evenly across consecutive days
|
Distribute assets evenly across consecutive days
|
||||||
*
|
|
||||||
* @returns Array of generated timeline assets
|
@returns Array of generated timeline assets
|
||||||
*/
|
*/
|
||||||
export function generateConsecutiveDays(
|
export function generateConsecutiveDays(
|
||||||
year: number,
|
year: number,
|
||||||
@@ -133,9 +127,7 @@ export function generateConsecutiveDays(
|
|||||||
return assets;
|
return assets;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Generate assets for a month with specified distribution pattern */
|
||||||
* Generate assets for a month with specified distribution pattern
|
|
||||||
*/
|
|
||||||
export function generateMonthAssets(
|
export function generateMonthAssets(
|
||||||
year: number,
|
year: number,
|
||||||
month: number,
|
month: number,
|
||||||
@@ -173,9 +165,7 @@ export function generateMonthAssets(
|
|||||||
return assets;
|
return assets;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Main generator function for timeline data */
|
||||||
* Main generator function for timeline data
|
|
||||||
*/
|
|
||||||
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
|
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
|
||||||
validateTimelineConfig(config);
|
validateTimelineConfig(config);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
AlbumUserRole,
|
AlbumUserRole,
|
||||||
@@ -19,9 +17,7 @@ import { signupDto } from 'src/fixtures.js';
|
|||||||
import { parseTimeBucketKey } from 'src/ui/generators/timeline/utils.js';
|
import { parseTimeBucketKey } from 'src/ui/generators/timeline/utils.js';
|
||||||
import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
|
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 {
|
export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
|
||||||
const result: TimeBucketAssetResponseDto = {
|
const result: TimeBucketAssetResponseDto = {
|
||||||
id: [],
|
id: [],
|
||||||
@@ -70,10 +66,10 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a single bucket from timeline data (mimics getTimeBucket API)
|
Extract a single bucket from timeline data (mimics getTimeBucket API)
|
||||||
* Automatically handles both ISO timestamp and simple month formats
|
Automatically handles both ISO timestamp and simple month formats
|
||||||
* Returns data in columnar format matching the actual API
|
Returns data in columnar format matching the actual API
|
||||||
* When albumId is provided, only returns assets from that album
|
When albumId is provided, only returns assets from that album
|
||||||
*/
|
*/
|
||||||
export function getTimeBucket(
|
export function getTimeBucket(
|
||||||
timelineData: MockTimelineData,
|
timelineData: MockTimelineData,
|
||||||
@@ -141,15 +137,15 @@ export type Changes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to determine if an asset should be included based on filter criteria
|
Helper function to determine if an asset should be included based on filter criteria
|
||||||
* @param asset - The asset to check
|
@param asset - The asset to check
|
||||||
* @param isTrashed - Filter for trashed status (undefined means no filter)
|
@param isTrashed - Filter for trashed status (undefined means no filter)
|
||||||
* @param isArchived - Filter for archived 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 isFavorite - Filter for favorite status (undefined means no filter)
|
||||||
* @param deletedAssetIds - Set of IDs for assets that have been deleted
|
@param deletedAssetIds - Set of IDs for assets that have been deleted
|
||||||
* @param archivedAssetIds - Set of IDs for assets that have been archived
|
@param archivedAssetIds - Set of IDs for assets that have been archived
|
||||||
* @param favoritedAssetIds - Set of IDs for assets that have been favorited
|
@param favoritedAssetIds - Set of IDs for assets that have been favorited
|
||||||
* @returns true if the asset matches all filter criteria
|
@returns true if the asset matches all filter criteria
|
||||||
*/
|
*/
|
||||||
function shouldIncludeAsset(
|
function shouldIncludeAsset(
|
||||||
asset: MockTimelineAsset,
|
asset: MockTimelineAsset,
|
||||||
@@ -175,8 +171,8 @@ function shouldIncludeAsset(
|
|||||||
return isFavorite === undefined || actuallyFavorited === isFavorite;
|
return isFavorite === undefined || actuallyFavorited === isFavorite;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get summary for all buckets (mimics getTimeBuckets API)
|
Get summary for all buckets (mimics getTimeBuckets API)
|
||||||
* When albumId is provided, only includes buckets that contain assets from that album
|
When albumId is provided, only includes buckets that contain assets from that album
|
||||||
*/
|
*/
|
||||||
export function getTimeBuckets(
|
export function getTimeBuckets(
|
||||||
timelineData: MockTimelineData,
|
timelineData: MockTimelineData,
|
||||||
@@ -279,8 +275,8 @@ const createDefaultOwner = (ownerId: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||||
* This matches the response from GET /api/assets/:id
|
This matches the response from GET /api/assets/:id
|
||||||
*/
|
*/
|
||||||
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
|
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -292,7 +288,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
|||||||
make: null,
|
make: null,
|
||||||
model: null,
|
model: null,
|
||||||
exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
|
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,
|
fileSizeInByte: asset.fileSizeInByte,
|
||||||
orientation: '1',
|
orientation: '1',
|
||||||
dateTimeOriginal: asset.fileCreatedAt,
|
dateTimeOriginal: asset.fileCreatedAt,
|
||||||
@@ -348,8 +344,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single asset by ID from timeline data
|
Get a single asset by ID from timeline data
|
||||||
* This matches the response from GET /api/assets/:id
|
This matches the response from GET /api/assets/:id
|
||||||
*/
|
*/
|
||||||
export function getAsset(
|
export function getAsset(
|
||||||
timelineData: MockTimelineData,
|
timelineData: MockTimelineData,
|
||||||
@@ -368,8 +364,8 @@ export function getAsset(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a mock album from timeline data
|
Get a mock album from timeline data
|
||||||
* This matches the response from GET /api/albums/:id
|
This matches the response from GET /api/albums/:id
|
||||||
*/
|
*/
|
||||||
export function getAlbum(
|
export function getAlbum(
|
||||||
timelineData: MockTimelineData,
|
timelineData: MockTimelineData,
|
||||||
|
|||||||
@@ -57,9 +57,7 @@ export type MonthSpec = {
|
|||||||
pattern: DayPattern;
|
pattern: DayPattern;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Configuration for timeline data generation */
|
||||||
* Configuration for timeline data generation
|
|
||||||
*/
|
|
||||||
export type TimelineConfig = {
|
export type TimelineConfig = {
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
months: MonthSpec[];
|
months: MonthSpec[];
|
||||||
@@ -88,9 +86,7 @@ export type SerializedTimelineData = {
|
|||||||
album: MockAlbum;
|
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 {
|
export function validateTimelineConfig(config: TimelineConfig): void {
|
||||||
if (!config.months || config.months.length === 0) {
|
if (!config.months || config.months.length === 0) {
|
||||||
throw new Error('TimelineConfig must contain at least one month');
|
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 {
|
export function createDefaultTimelineConfig(): TimelineConfig {
|
||||||
const months: MonthSpec[] = [
|
const months: MonthSpec[] = [
|
||||||
// 2024 - Mix of patterns
|
// 2024 - Mix of patterns
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config.js';
|
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 {
|
export class SeededRandom {
|
||||||
private seed: number;
|
private seed: number;
|
||||||
|
|
||||||
@@ -11,37 +9,31 @@ export class SeededRandom {
|
|||||||
this.seed = seed;
|
this.seed = seed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Generate next random number in range [0, 1) */
|
||||||
* Generate next random number in range [0, 1)
|
|
||||||
*/
|
|
||||||
next(): number {
|
next(): number {
|
||||||
// LCG parameters from Numerical Recipes
|
// LCG parameters from Numerical Recipes
|
||||||
this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
|
this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
|
||||||
return this.seed / 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 {
|
nextInt(min: number, max: number): number {
|
||||||
return Math.floor(this.next() * (max - min)) + min;
|
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 {
|
nextBoolean(probability = 0.5): boolean {
|
||||||
return this.next() < probability;
|
return this.next() < probability;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select random days using seed variation to avoid collisions.
|
Select random days using seed variation to avoid collisions.
|
||||||
*
|
|
||||||
* @param daysInMonth - Total number of days in the month
|
@param daysInMonth - Total number of days in the month
|
||||||
* @param numDays - Number of days to select
|
@param numDays - Number of days to select
|
||||||
* @param rng - Random number generator instance
|
@param rng - Random number generator instance
|
||||||
* @returns Array of selected day numbers, sorted in descending order
|
@returns Array of selected day numbers, sorted in descending order
|
||||||
*/
|
*/
|
||||||
export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
|
export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
|
||||||
const selectedDays = new Set<number>();
|
const selectedDays = new Set<number>();
|
||||||
@@ -64,9 +56,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
|
|||||||
return [...selectedDays].toSorted((a, b) => b - a);
|
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 {
|
export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
|
||||||
if (arr.length === 0) {
|
if (arr.length === 0) {
|
||||||
throw new Error('Cannot select from empty array');
|
throw new Error('Cannot select from empty array');
|
||||||
@@ -75,9 +65,7 @@ export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
|
|||||||
return arr[index];
|
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[] {
|
export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] {
|
||||||
if (arr.length === 0) {
|
if (arr.length === 0) {
|
||||||
throw new Error('Cannot select from empty array');
|
throw new Error('Cannot select from empty array');
|
||||||
@@ -104,10 +92,10 @@ export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRand
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse timeBucket parameter to extract year-month key
|
Parse timeBucket parameter to extract year-month key
|
||||||
* Handles both formats:
|
Handles both formats:
|
||||||
* - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
|
- ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
|
||||||
* - Simple format: "2024-12-01" -> "2024-12-01"
|
- Simple format: "2024-12-01" -> "2024-12-01"
|
||||||
*/
|
*/
|
||||||
export function parseTimeBucketKey(timeBucket: string): string {
|
export function parseTimeBucketKey(timeBucket: string): string {
|
||||||
if (!timeBucket) {
|
if (!timeBucket) {
|
||||||
@@ -150,6 +138,7 @@ export function getMockAsset(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/no-unnecessary-parameters
|
||||||
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
|
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
|
||||||
if (unit === 'day') {
|
if (unit === 'day') {
|
||||||
return !date1.startOf('day').equals(date2.startOf('day'));
|
return !date1.startOf('day').equals(date2.startOf('day'));
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ test.describe('OCR text interaction', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/no-unnecessary-parameters
|
||||||
const createTouch = (id: number, x: number, y: number) => {
|
const createTouch = (id: number, x: number, y: number) => {
|
||||||
return new Touch({
|
return new Touch({
|
||||||
identifier: id,
|
identifier: id,
|
||||||
|
|||||||
@@ -150,9 +150,9 @@ test.describe('Timeline', () => {
|
|||||||
});
|
});
|
||||||
test.describe('keyboard', () => {
|
test.describe('keyboard', () => {
|
||||||
/**
|
/**
|
||||||
* This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
|
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
|
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.
|
as necessary downwards), then the asset should always be at the lowest row of the grid.
|
||||||
*/
|
*/
|
||||||
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
|
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
|
||||||
await pageUtils.openPhotosPage(page);
|
await pageUtils.openPhotosPage(page);
|
||||||
|
|||||||
+5
-5
@@ -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) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
if (!id && !count) {
|
if (!id && !count) {
|
||||||
reject(new Error('id or count must be provided for waitForWebsocketEvent'));
|
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}`;
|
const type = id ? `id=${id}` : `count=${count}`;
|
||||||
console.log(`Waiting for ${event} [${type}]`);
|
console.log(`Waiting for ${event} [${type}]`);
|
||||||
const set = events[event];
|
const set = events[event];
|
||||||
@@ -550,7 +550,7 @@ export const utils = {
|
|||||||
{
|
{
|
||||||
headers: asBearerAuth(accessToken),
|
headers: asBearerAuth(accessToken),
|
||||||
fetch: (...args: Parameters<typeof fetch>) =>
|
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) => {
|
fetch(...args).then((response) => {
|
||||||
setCookie = response.headers.getSetCookie();
|
setCookie = response.headers.getSetCookie();
|
||||||
return response;
|
return response;
|
||||||
@@ -660,10 +660,10 @@ export const utils = {
|
|||||||
return !jobCounts.active && (!jobCounts.waiting || queues[queue].queueStatus.isPaused);
|
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
|
// eslint-disable-next-line no-async-promise-executor
|
||||||
return new Promise<void>(async (resolve, reject) => {
|
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) {
|
while (true) {
|
||||||
const done = await utils.isQueueEmpty(accessToken, queue);
|
const done = await utils.isQueueEmpty(accessToken, queue);
|
||||||
|
|||||||
@@ -2263,6 +2263,8 @@
|
|||||||
"trigger": "Trigger",
|
"trigger": "Trigger",
|
||||||
"trigger_asset_metadata_extraction": "Asset Metadata Extraction",
|
"trigger_asset_metadata_extraction": "Asset Metadata Extraction",
|
||||||
"trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted",
|
"trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted",
|
||||||
|
"trigger_asset_ocr_extraction": "Asset OCR Extraction",
|
||||||
|
"trigger_asset_ocr_extraction_description": "Triggered when the OCR text of an asset is extracted",
|
||||||
"trigger_asset_tagged": "Asset Tagged",
|
"trigger_asset_tagged": "Asset Tagged",
|
||||||
"trigger_asset_tagged_description": "Triggered when a tag is added to an asset",
|
"trigger_asset_tagged_description": "Triggered when a tag is added to an asset",
|
||||||
"trigger_asset_uploaded": "Asset Upload",
|
"trigger_asset_uploaded": "Asset Upload",
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class Settings(BaseSettings):
|
|||||||
ann_fp16_turbo: bool = False
|
ann_fp16_turbo: bool = False
|
||||||
ann_tuning_level: int = 2
|
ann_tuning_level: int = 2
|
||||||
rknn: bool = True
|
rknn: bool = True
|
||||||
rknn_threads: int = 1
|
rknn_threads: int | None = None
|
||||||
preload: PreloadModelData | None = None
|
preload: PreloadModelData | None = None
|
||||||
max_batch_size: MaxBatchSize = MaxBatchSize()
|
max_batch_size: MaxBatchSize = MaxBatchSize()
|
||||||
model_organization: ModelOrganization = ModelOrganization.APP
|
model_organization: ModelOrganization = ModelOrganization.APP
|
||||||
|
|||||||
@@ -100,8 +100,7 @@ SUPPORTED_PROVIDERS = [
|
|||||||
"CPUExecutionProvider",
|
"CPUExecutionProvider",
|
||||||
]
|
]
|
||||||
|
|
||||||
RKNN_SUPPORTED_SOCS = ["rk3566", "rk3568", "rk3576", "rk3588"]
|
RKNN_NPU_CORES = {"rk3566": 1, "rk3568": 1, "rk3576": 2, "rk3588": 3}
|
||||||
RKNN_COREMASK_SUPPORTED_SOCS = ["rk3576", "rk3588"]
|
|
||||||
|
|
||||||
|
|
||||||
WEBLATE_TO_FLORES200 = {
|
WEBLATE_TO_FLORES200 = {
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import orjson
|
|||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
from immich_ml.config import log, settings
|
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 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
|
is_available = is_available and settings.rknn
|
||||||
model_prefix = Path("rknpu") / soc_name if is_available and soc_name is not None else None
|
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"
|
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:
|
def input_layout(compiled: tuple[int, ...], array: ModelTensor) -> str | None:
|
||||||
if array.ndim != 4:
|
if array.ndim != 4:
|
||||||
return None # token ids carry no layout
|
return None # token ids carry no layout
|
||||||
@@ -39,11 +34,11 @@ def input_layout(compiled: tuple[int, ...], array: ModelTensor) -> str | None:
|
|||||||
|
|
||||||
class RknnSession:
|
class RknnSession:
|
||||||
def __init__(self, model_path: Path) -> None:
|
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.")
|
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)
|
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.")
|
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
|
# the shapes the binary was compiled for, among which the runtime routes itself
|
||||||
batch = self.rknnpool.inputs[0].shape[0]
|
batch = self.rknnpool.inputs[0].shape[0]
|
||||||
declared = orjson.loads(self.rknnpool.custom_string) if self.rknnpool.custom_string else {}
|
declared = orjson.loads(self.rknnpool.custom_string) if self.rknnpool.custom_string else {}
|
||||||
@@ -77,7 +72,7 @@ class RknnSession:
|
|||||||
input_feed: ModelInput,
|
input_feed: ModelInput,
|
||||||
run_options: Any = None,
|
run_options: Any = None,
|
||||||
) -> list[NDArray[np.float32]]:
|
) -> 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]))
|
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 ctypes
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from ctypes import byref, sizeof
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, NamedTuple
|
from queue import SimpleQueue
|
||||||
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
from immich_ml.config import log
|
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
|
from immich_ml.schemas import ModelTensor
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +17,7 @@ def get_soc(device_tree_path: Path | str) -> str | None:
|
|||||||
try:
|
try:
|
||||||
with Path(device_tree_path).open() as f:
|
with Path(device_tree_path).open() as f:
|
||||||
device_compatible_str = f.read()
|
device_compatible_str = f.read()
|
||||||
for soc in RKNN_SUPPORTED_SOCS:
|
for soc in RKNN_NPU_CORES:
|
||||||
if soc in device_compatible_str:
|
if soc in device_compatible_str:
|
||||||
return soc
|
return soc
|
||||||
log.warning("Device is not supported for RKNN")
|
log.warning("Device is not supported for RKNN")
|
||||||
@@ -29,26 +26,34 @@ def get_soc(device_tree_path: Path | str) -> str | None:
|
|||||||
return 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):
|
class RknnNode(NamedTuple):
|
||||||
name: str
|
name: str
|
||||||
shape: tuple[int, ...]
|
shape: tuple[int, ...]
|
||||||
|
|
||||||
|
|
||||||
_CUSTOM_STRING_QUERY = 7 # RKNN_QUERY_CUSTOM_STRING
|
# rknn_query_cmd
|
||||||
# RKNN_QUERY_OUTPUT_ATTR, and RKNN_QUERY_CURRENT_OUTPUT_ATTR for the shape a dynamic binary last produced
|
_IN_OUT_NUM, _INPUT_ATTR, _OUTPUT_ATTR, _CUSTOM_STRING = 0, 1, 2, 7
|
||||||
_OUTPUT_QUERY, _CURRENT_OUTPUT_QUERY = 2, 15
|
_INPUT_DYNAMIC_RANGE, _CURRENT_OUTPUT_ATTR = 13, 15
|
||||||
_DTYPES = {0: np.float32, 1: np.float16} # rknn_tensor_type
|
# 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):
|
class _CustomString(ctypes.Structure):
|
||||||
@@ -76,94 +81,194 @@ class _TensorAttr(ctypes.Structure): # rknn_tensor_attr
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def native_outputs(rknn_lite: "RKNNLite") -> list[NDArray[Any]]:
|
class _InputRange(ctypes.Structure):
|
||||||
"""The outputs in the precision the NPU computed them in, which RKNNLite would widen to fp32 on the CPU."""
|
_fields_ = [
|
||||||
runtime: Any = rknn_lite.rknn_runtime
|
("index", ctypes.c_uint32),
|
||||||
count = runtime.get_in_out_num()[1]
|
("shape_number", ctypes.c_uint32),
|
||||||
buffers = (rknn_runtime.RKNNOutput * count)()
|
("fmt", ctypes.c_int),
|
||||||
for index, buffer in enumerate(buffers):
|
("name", ctypes.c_char * 256),
|
||||||
buffer.index = index
|
("dyn_range", (ctypes.c_uint32 * 16) * 512),
|
||||||
if runtime.lib.rknn_outputs_get(runtime.context, count, buffers, None) != 0:
|
("n_dims", ctypes.c_uint32),
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def tensor_nodes(rknn_lite: "RKNNLite") -> tuple[list[RknnNode], list[RknnNode]]:
|
class _Input(ctypes.Structure):
|
||||||
runtime = rknn_lite.rknn_runtime
|
_fields_ = [
|
||||||
n_inputs, n_outputs = runtime.get_in_out_num()
|
("index", ctypes.c_uint32),
|
||||||
|
("buf", ctypes.c_void_p),
|
||||||
def node(index: int, is_output: bool) -> RknnNode:
|
("size", ctypes.c_uint32),
|
||||||
attr = runtime.get_tensor_attr(index, is_output=is_output)
|
("pass_through", ctypes.c_uint8),
|
||||||
return RknnNode(attr.name.decode(), tuple(attr.dims[: attr.n_dims]))
|
("type", ctypes.c_int),
|
||||||
|
("fmt", ctypes.c_int),
|
||||||
return [node(i, False) for i in range(n_inputs)], [node(i, True) for i in range(n_outputs)]
|
]
|
||||||
|
|
||||||
|
|
||||||
def custom_string(rknn_lite: "RKNNLite") -> str:
|
class _Output(ctypes.Structure):
|
||||||
runtime: Any = rknn_lite.rknn_runtime
|
_fields_ = [
|
||||||
buffer = _CustomString()
|
("want_float", ctypes.c_uint8),
|
||||||
# rknnlite declares argtypes on the shared handle, so pass plain values and let ctypes convert
|
("is_prealloc", ctypes.c_uint8),
|
||||||
runtime.lib.rknn_query(runtime.context, _CUSTOM_STRING_QUERY, ctypes.byref(buffer), ctypes.sizeof(buffer))
|
("index", ctypes.c_uint32),
|
||||||
declared: str = buffer.string.decode()
|
("buf", ctypes.c_void_p),
|
||||||
return declared
|
("size", ctypes.c_uint32),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def init_rknn(model_path: str) -> "RKNNLite":
|
_Key = tuple[tuple[int, ...], ...]
|
||||||
if not is_available:
|
_Spec = tuple[tuple[int, ...], np.dtype[Any]]
|
||||||
raise RuntimeError("rknn is not available!")
|
_context = ctypes.c_uint64 # rknn_context on 64-bit
|
||||||
rknn_lite = RKNNLite()
|
_ctx_p = ctypes.POINTER(_context)
|
||||||
rknn_lite.rknn_log.logger.setLevel(logging.ERROR)
|
_u32 = ctypes.c_uint32
|
||||||
ret = rknn_lite.load_rknn(model_path)
|
|
||||||
|
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:
|
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:
|
def _query(ctx: int, cmd: int, info: ctypes.Structure) -> None:
|
||||||
raise RuntimeError("Failed to initialize RKNN runtime environment")
|
_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:
|
class RknnPoolExecutor:
|
||||||
def __init__(
|
"""One context per thread, all but the first duplicated from it so that they share its weights.
|
||||||
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()
|
|
||||||
|
|
||||||
def run(self, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[np.float32]]:
|
The model is loaded by path rather than from a buffer, so the runtime holds the only copy of it."""
|
||||||
with self.lock:
|
|
||||||
idx = self.num % self.tpes
|
|
||||||
self.num += 1
|
|
||||||
|
|
||||||
fut = self.pool.submit(self.func, self.rknn_pool[idx], inputs, data_format)
|
def __init__(self, model_path: str, tpes: int) -> None:
|
||||||
return fut.result()
|
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:
|
def release(self) -> None:
|
||||||
self.pool.shutdown()
|
self.executor.shutdown()
|
||||||
for rknn_lite in self.rknn_pool:
|
for ctx in reversed(self.contexts): # duplicates before the context whose weights they share
|
||||||
rknn_lite.release()
|
lib.rknn_destroy(ctx)
|
||||||
|
self.contexts = []
|
||||||
|
|
||||||
def __del__(self) -> None:
|
def __del__(self) -> None:
|
||||||
self.release()
|
self.release()
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ cuda = [
|
|||||||
]
|
]
|
||||||
openvino = ["onnxruntime-openvino>=1.24.1,<2"]
|
openvino = ["onnxruntime-openvino>=1.24.1,<2"]
|
||||||
armnn = ["onnxruntime>=1.23.2,<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"]
|
rocm = ["onnxruntime-migraphx>=1.23.2,<2"]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ctypes
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
@@ -63,8 +64,9 @@ from immich_ml.schemas import (
|
|||||||
from immich_ml.sessions.ann import AnnSession
|
from immich_ml.sessions.ann import AnnSession
|
||||||
from immich_ml.sessions.ort import Device, GraphSpec, OrtSession, flush_denormals, fresh, prepared
|
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.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 import model_path as rknn_model_path
|
||||||
|
from immich_ml.sessions.rknn.rknnpool import RknnPoolExecutor
|
||||||
|
|
||||||
|
|
||||||
class TestBase:
|
class TestBase:
|
||||||
@@ -960,9 +962,11 @@ class TestRknnSession:
|
|||||||
mocker.patch("immich_ml.sessions.rknn.is_available", True)
|
mocker.patch("immich_ml.sessions.rknn.is_available", True)
|
||||||
RknnSession(model_path)
|
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:
|
def test_run_rknn(self, rknn_session: mock.Mock, mocker: MockerFixture) -> None:
|
||||||
rknn_session.return_value.load.return_value = 123
|
rknn_session.return_value.load.return_value = 123
|
||||||
@@ -974,7 +978,20 @@ class TestRknnSession:
|
|||||||
session.run(None, input_feed)
|
session.run(None, input_feed)
|
||||||
|
|
||||||
rknn_session.return_value.run.assert_called_once_with([input1, input2], "nchw")
|
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(
|
def test_run_rknn_rejects_a_shape_the_binary_was_not_compiled_for(
|
||||||
self, rknn_session: mock.Mock, mocker: MockerFixture
|
self, rknn_session: mock.Mock, mocker: MockerFixture
|
||||||
@@ -1011,6 +1028,237 @@ class TestRknnSession:
|
|||||||
assert RknnSession(Path("buffalo_l")).shapes == (Shape(batch=1),)
|
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:
|
class TestCLIP:
|
||||||
embedding = np.random.rand(512).astype(np.float32)
|
embedding = np.random.rand(512).astype(np.float32)
|
||||||
cache_dir = Path("test_cache")
|
cache_dir = Path("test_cache")
|
||||||
|
|||||||
Generated
+20
-73
@@ -868,7 +868,6 @@ openvino = [
|
|||||||
]
|
]
|
||||||
rknn = [
|
rknn = [
|
||||||
{ name = "onnxruntime" },
|
{ name = "onnxruntime" },
|
||||||
{ name = "rknn-toolkit-lite2" },
|
|
||||||
]
|
]
|
||||||
rocm = [
|
rocm = [
|
||||||
{ name = "onnxruntime-migraphx" },
|
{ name = "onnxruntime-migraphx" },
|
||||||
@@ -943,7 +942,6 @@ requires-dist = [
|
|||||||
{ name = "python-multipart", specifier = ">=0.0.6,<1.0" },
|
{ name = "python-multipart", specifier = ">=0.0.6,<1.0" },
|
||||||
{ name = "rapidocr", specifier = ">=3.1.0" },
|
{ name = "rapidocr", specifier = ">=3.1.0" },
|
||||||
{ name = "rich", specifier = ">=13.4.2" },
|
{ 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 = "tokenizers", specifier = ">=0.15.0,<1.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.22.0,<1.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.22.0,<1.0" },
|
||||||
]
|
]
|
||||||
@@ -1484,7 +1482,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "onnxruntime"
|
name = "onnxruntime"
|
||||||
version = "1.30.0"
|
version = "1.31.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "flatbuffers" },
|
{ name = "flatbuffers" },
|
||||||
@@ -1493,25 +1491,25 @@ dependencies = [
|
|||||||
{ name = "protobuf" },
|
{ name = "protobuf" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/6f/48169f2e62b405bff5053cbd1d73fb5ce41ef7ecd13bb3bfcc191e689b8a/onnxruntime-1.30.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:001ed726c9bd5e2bc92faade7d37d889e9606a350b7d5529f0227df2e3bb57fd", size = 21544867, upload-time = "2026-09-10T16:31:19.876Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/bd/2ac094311163b803e3626c3937461d6900934bd56cca7601f6150ff860c3/onnxruntime-1.31.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:aaab9b3af536b06ca27ab5e35e3d429c97457ce76cf298af103f687e8b9975c0", size = 20882054, upload-time = "2026-10-09T04:18:18.811Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/bd/cbc5b8f91963689fdd622f463508c01d0aa95d3f944747b1e0b1eb2160b8/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6c32a000d5139a38ba9349030b0032e3331acb559d596b22738d9d2b343a2b83", size = 21345202, upload-time = "2026-09-10T16:31:23.361Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/1a/561b43ca1536d9e81d1785bb8a1a260a9e314ef6d04976ba0411c652bda1/onnxruntime-1.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:35758d7606d578ec5b9d65f6e8a1f488013194c3f6097038a3223cb26d35ef9a", size = 21420804, upload-time = "2026-10-09T04:18:21.729Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/35/e7f862dbacbc99fadd9b14a614e49c99bf0f35fd9927a82f096e3de33531/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa688e7891a6aa206636fe7372e27ee75fd17713289f6b4fc7b190e0a7de9328", size = 23585654, upload-time = "2026-09-10T16:31:26.65Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/44/1e9e762b95b7da0a8424913a1ed7c38cdaf88624a3c41ddba24ebac88bc9/onnxruntime-1.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5e129d6c56abd53e659cb70f00a108d6824086470ff99c2e47a82e5786563db3", size = 23760984, upload-time = "2026-10-09T04:18:24.61Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/13/0f1699f6de549c9324bc9112a2a85b14c517904cd11b562a654643b755a1/onnxruntime-1.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3501472571f1b1eee50e017851e7929f5ea37312d2d8c2494a19e8fc58b4a38", size = 14311470, upload-time = "2026-09-10T16:31:31.273Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/ed/b12cea136ccd7b03d924f46b8393faf7ceac21115c0c50e729faa248cf23/onnxruntime-1.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:09d56445c1753e66e0912de69d3f0184016ad9a191dcd6925bf5dd570d2bfbe5", size = 14888841, upload-time = "2026-10-09T04:18:27.62Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/17/02b13e5f51461f0453b18ab854e2d0bc1b6ec353241b05a2ee6b79e35d87/onnxruntime-1.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc4c706f1935ebb62356e6a095b047859badd854482c40560888e95c328ed262", size = 14175072, upload-time = "2026-09-10T16:31:34.541Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/ad/37bbc51dcb5cd105c5b2fe98f122b23e90171c2719516964edc65bb1d4cc/onnxruntime-1.31.0-cp312-cp312-win_arm64.whl", hash = "sha256:5c54a0eb7b2b4eef3eb9dcfaf82f5ce880db07288dc309574f6657e9da5cc754", size = 14740604, upload-time = "2026-10-09T04:18:30.399Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/75/508454c5d01f31641dabc597fe559594c931a520a2673031179319d0afd8/onnxruntime-1.30.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:05e4fc41711d1f4abd19a9124b5be7a65a506cb2670a7f14b16162ef13c58134", size = 21544990, upload-time = "2026-09-10T16:31:37.685Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/2b/117f94d73a3bac4276c285c47e384e1b3ea67b191aa4c7592df9d3f4a136/onnxruntime-1.31.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0ba02a44acb6203040354d9a1f160e3f37a43feac7bb05caa3e0ea545efed505", size = 20881803, upload-time = "2026-10-09T04:18:33.62Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/06/e603c71f43f4fe3fd156a053af79cbed6e27a2c649f0988a67d97fedd39f/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5327cf6aa15a02bad805fac8bd6882a62571e8b72f6f2938a8f37e6bd1966ce9", size = 21344996, upload-time = "2026-09-10T16:31:40.915Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/d0/3677fe93ec0fa3c637744aa4c3ae6ef89a93ee229cd3c5157820f267c7bd/onnxruntime-1.31.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ad663106f6eeff3d454f24a786450459d07f30e74863851104fc1b8b3f368127", size = 21420629, upload-time = "2026-10-09T04:18:36.731Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/a1/ede48ab5dc54907a2999362777f541e132639fb06628ded1932058aa8a36/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f", size = 23585560, upload-time = "2026-09-10T16:31:44.941Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/ac/67ebbaab4b3083f2a6b27ee6c4aa400c7f8d6c72b5499aac7e4cd6ba74f5/onnxruntime-1.31.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:37fd78cee5160c7a43a1730ccb3682ffd880af9c9e80385d625c0c2f8b125809", size = 23760708, upload-time = "2026-10-09T04:18:40.883Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/dd/c57c529dbc6dd55eca24b12cfbeab1b6a690de72083824eca689085f55b0/onnxruntime-1.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:4b63041bd623a9a9ac5e353948436c6fa7f43edd12d6b4a4ebc340bca959ba93", size = 14311378, upload-time = "2026-09-10T16:31:48.034Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/86/05ed2056f43b27aaf12ebc592ebd9037a26bed315958cf882f43425fd469/onnxruntime-1.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:73e0165d58ece068c2a8a1c477c90b38e5a8adbbd399fdfdfd4bd79cbc28ff8d", size = 14888306, upload-time = "2026-10-09T04:18:43.722Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/2f/ef00b45b911e7f2115273a24ed1b43e73c8c6c9ec17f9bfa13f8492581f8/onnxruntime-1.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:c389b6887fc95e0fcb80e89b156bc2cb18e662c29df55e9326fe64140e7d7b4f", size = 14175134, upload-time = "2026-09-10T16:31:50.799Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/93/d33bae7b1a78780c4946ce03989c59a67d42d7015ad62d2098975fc5a580/onnxruntime-1.31.0-cp313-cp313-win_arm64.whl", hash = "sha256:e51d10d2e2e1e5bbf9b126a0cd9853d3e6c4e21424518dd50160b91471be33dc", size = 14740892, upload-time = "2026-10-09T04:18:46.338Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/0a/284fd6fe701c9a8aff39dbc119c37582ca40e9cff1ea05425a7cc8606a02/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5224ba2b00284cb1c48b3edcd303c109245d66df9ec1b861858fd6de672a38e", size = 21349453, upload-time = "2026-09-10T16:31:53.898Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/05/cf44f7642269b285aada4b662c4662b14ac63f6e03e129d939c4a956a0f5/onnxruntime-1.31.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e0e050bf9ec754950a6ba9830e4032f4004d972c6f38c5642fef26d44d894965", size = 21432644, upload-time = "2026-10-09T04:18:48.925Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/72/4f4466f8fa1ec267a9ef5e2f3bd175c203d3da0facbdf4049dc93abbe91c/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3f9e002417f1e3bbb31ed43dafa4f22ca1b2b68832244fdee192fcaa1ae19bca", size = 23579837, upload-time = "2026-09-10T16:31:56.82Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/8e/673315b2dd2eb99b2f4774d7a5986fe00d933ebed17ee72c441f579226e6/onnxruntime-1.31.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:e93d7c5fad20afa697ac16f376fd0306ed180f9a376e86106cc0b7d84f53ef87", size = 23773868, upload-time = "2026-10-09T04:18:51.776Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/03/05c9a9234688757d2876ddecf80bba908561ee11debf125bc1a427ae6f48/onnxruntime-1.30.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8b6169c16a48429890d2f4a0c774ebf54dfe9066a998514aad0518a16d398547", size = 21545313, upload-time = "2026-09-10T16:31:59.654Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/fb/b4c52e500c6f3d00dfc22fad4d7513524f3ea2100a24a077ee3b0daf552d/onnxruntime-1.31.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:278e0dc922ec69b05a28f59110d5421e2ec8b1d0dd46c6b10c063069a4051e72", size = 20883462, upload-time = "2026-10-09T04:18:54.978Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/bc/1069e58b24779ba9d2fd479db5ecb3a15a6f49b585107c898819c0789558/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d2184fddb6798136e7c478244391ca82443f5c757f59f15bb9e5ad2da5e03175", size = 21346830, upload-time = "2026-09-10T16:32:02.275Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/fb/8be04665b700cb6e874d944e9932bb3c3969d3f53e820f5c42bfd26565d0/onnxruntime-1.31.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:984c0a2c1ad6a41fbc101dc3949abe4a72254892d01a5e70d9b792711e0bfa54", size = 21421618, upload-time = "2026-10-09T04:18:58.1Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/38/8138eed225c5bc6ddfc05879ecac7dacc63c34b9b6f99be72839c1f6dc49/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:8b611d24db2954545ce6bd9acd4670183cb368e4642450de7a9ab6474eb374ec", size = 23586333, upload-time = "2026-09-10T16:32:04.996Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/2e/5c6ec7e26a097e97ee70f2dee68b8ca4d9d26701f2f33c3f8ab585cb89fe/onnxruntime-1.31.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4efa4a1a0bb0b5173c6a3292c181d518b8323f9d56e978635d0c09d38c94d1a", size = 23762993, upload-time = "2026-10-09T04:19:01.236Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/0a/748b86000fbf9518b5dfc5bdc1b924eeaca976203740fd9d30649e568ee6/onnxruntime-1.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bdd1752a8502ac1a7ccc6e878d16db6943df574d54ed7f01058a6806ae05be4", size = 14675319, upload-time = "2026-09-10T16:32:07.5Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/66/0bf4fdb9f58efa69cf4eddde24c72aebcc628d6ff1d67c9546145c6b9922/onnxruntime-1.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:83e3dbcf6abc6189c4bdf7d329c07ba1133c88172134c266d84b4409aa3b9dbf", size = 15268709, upload-time = "2026-10-09T04:19:04.2Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/db/db8e4c0cf6f70f1311060560630fd21763648e01dfbba5195a2592c052ef/onnxruntime-1.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:83d543843cbd352cfa9996a6c7b92f8a480f114a18f95ff2a1acaf6921e85b6d", size = 14569360, upload-time = "2026-09-10T16:32:09.896Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/99/75a36172c1ed1d74ac0e91c11d642548081e2c9c63f15ee796564619556f/onnxruntime-1.31.0-cp314-cp314-win_arm64.whl", hash = "sha256:d2d5ac22f896c810be2b2b171392bb908f80b6c9a7e2d592ddb7435c928044e1", size = 15153795, upload-time = "2026-10-09T04:19:06.609Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/0a/ec0a9d656e39b43887c378a5388b20c3b1e1ee43bded84580b0936466694/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:265de607ba6f9814264e1d5d413fa7069d70f48e3049b5e460b3b04bdfcef294", size = 21348433, upload-time = "2026-09-10T16:32:12.772Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/ec/23b7749edc7aad53bf4632de190399fda69a9195499426637ef1b02f06c6/onnxruntime-1.31.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d25cd65874b75fdf16149120a04d0cd4551f860a3c8e2ecec785a1903e41d8aa", size = 21432344, upload-time = "2026-10-09T04:19:09.646Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/f0/40f74b7c00077e1e25627067ed98a70df1fef5c0e21b82849190d312554e/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:67ad7f03433b6462c627d0f555dece80e6a26bc71e8542ced35cebd32142d1b7", size = 23579340, upload-time = "2026-09-10T16:32:15.532Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/76/155ab0b265e9ceade28a8dd3858fdfa509b039f78010042c875940e32e58/onnxruntime-1.31.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1ecc1450af28d2cf362990e188ccc81b51388f317f641ad973ab4301473200f2", size = 23772576, upload-time = "2026-10-09T04:19:12.731Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Immich Mobile Application - Flutter
|
# Immich Mobile Application - Flutter
|
||||||
|
|
||||||
The Immich mobile app is a Flutter-based solution leveraging the Isar Database for local storage and Riverpod for state management. This structure optimizes functionality and maintainability, allowing for efficient development and robust performance.
|
The Immich mobile app is a Flutter-based solution leveraging the Drift (SQLite) database for local storage and Riverpod for state management. This structure optimizes functionality and maintainability, allowing for efficient development and robust performance.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ object NativeImage {
|
|||||||
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
|
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
|
* [cancel], downscaled to cover [targetWidth] x [targetHeight] unless either is 0 and to fit
|
||||||
* within [maxPixelSize].
|
* within [maxPixelSize].
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
external fun decodeJxl(
|
external fun decode(
|
||||||
address: Long,
|
address: Long,
|
||||||
length: Int,
|
length: Int,
|
||||||
targetWidth: Int,
|
targetWidth: Int,
|
||||||
@@ -43,7 +43,7 @@ object NativeImage {
|
|||||||
outInfo: IntArray
|
outInfo: IntArray
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
/** Stops the [decodeJxl] given this one-byte direct buffer. */
|
/** Stops the [decode] given this one-byte direct buffer. */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
external fun cancel(cancelled: ByteBuffer)
|
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.
|
// The fetch is done, so cancelling now stops the decode instead.
|
||||||
val cancelled = ByteBuffer.allocateDirect(1)
|
val cancelled = ByteBuffer.allocateDirect(1)
|
||||||
signal.setOnCancelListener { NativeImage.cancel(cancelled) }
|
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.pointer,
|
||||||
buffer.offset,
|
buffer.offset,
|
||||||
width?.toInt() ?: 0,
|
width?.toInt() ?: 0,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ enum NativeCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static let coreThumbhash = symbol("native_core_thumbhash", as: NativeCoreThumbhashFn.self)
|
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)
|
private static let coreCancel = symbol("native_core_cancel", as: NativeCoreCancelFn.self)
|
||||||
|
|
||||||
static func thumbhash(_ hash: Data) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
|
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)? {
|
static func decodeImage(_ data: Data, targetWidth: Int64?, targetHeight: Int64?, maxPixelSize: Int, cancelled: UnsafeMutablePointer<Bool>) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
|
||||||
guard let coreJxlDecode else { return nil }
|
guard let coreDecodeImage else { return nil }
|
||||||
var info = NativeCoreImageInfo()
|
var info = NativeCoreImageInfo()
|
||||||
return data.withUnsafeBytes { bytes in
|
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 }
|
else { return nil }
|
||||||
return (Int(info.width), Int(info.height), UnsafeMutableRawPointer(pointer))
|
return (Int(info.width), Int(info.height), UnsafeMutableRawPointer(pointer))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
return request.completion(ImageProcessing.cancelledResult)
|
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)
|
registry.remove(requestId: request.id)
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return request.completion(ImageProcessing.cancelledResult)
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
|
|
||||||
// The function pointer type comes from the declaration, so nothing is retyped here.
|
// The function pointer type comes from the declaration, so nothing is retyped here.
|
||||||
typedef __typeof__(&native_core_thumbhash) NativeCoreThumbhashFn;
|
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;
|
typedef __typeof__(&native_core_cancel) NativeCoreCancelFn;
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ class TimelineFactory {
|
|||||||
|
|
||||||
TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy));
|
TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy));
|
||||||
|
|
||||||
TimelineService person(List<String> userIds, String personId) =>
|
TimelineService person(String userId, String personId) =>
|
||||||
TimelineService(_timelineRepository.person(userIds, personId, groupBy));
|
TimelineService(_timelineRepository.person(userId, personId, groupBy));
|
||||||
|
|
||||||
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
|
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||||
TimelineService(_timelineRepository.fromAssets(assets, type));
|
TimelineService(_timelineRepository.fromAssets(assets, type));
|
||||||
|
|||||||
@@ -923,7 +923,7 @@ class SyncStreamRepository extends DatabaseAccessor<Drift> with $SyncStreamRepos
|
|||||||
try {
|
try {
|
||||||
await _db.batch((batch) {
|
await _db.batch((batch) {
|
||||||
for (final assetOcr in data) {
|
for (final assetOcr in data) {
|
||||||
batch.deleteWhere(_db.assetOcrEntity, (row) => row.id.equals(assetOcr.id));
|
batch.deleteWhere(_db.assetOcrEntity, (row) => row.assetId.equals(assetOcr.assetId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
|
|||||||
@@ -366,10 +366,10 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
|
|||||||
origin: TimelineOrigin.place,
|
origin: TimelineOrigin.place,
|
||||||
);
|
);
|
||||||
|
|
||||||
TimelineQuery person(List<String> userIds, String personId, GroupAssetsBy groupBy) => (
|
TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => (
|
||||||
bucketSource: () => _watchPersonBucket(userIds, personId, groupBy: groupBy),
|
bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy),
|
||||||
assetSource: (offset, count) =>
|
assetSource: (offset, count) =>
|
||||||
_getPersonBucketAssets(userIds, personId, groupBy: groupBy, offset: offset, count: count),
|
_getPersonBucketAssets(userId, personId, groupBy: groupBy, offset: offset, count: count),
|
||||||
origin: TimelineOrigin.person,
|
origin: TimelineOrigin.person,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -430,7 +430,7 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
|
|||||||
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
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()
|
final faceAssetIds = _db.assetFaceEntity.selectOnly()
|
||||||
..addColumns([_db.assetFaceEntity.assetId])
|
..addColumns([_db.assetFaceEntity.assetId])
|
||||||
..where(
|
..where(
|
||||||
@@ -438,22 +438,23 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
|
|||||||
_db.assetFaceEntity.isVisible.equals(true) &
|
_db.assetFaceEntity.isVisible.equals(true) &
|
||||||
_db.assetFaceEntity.deletedAt.isNull(),
|
_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]);
|
final albumAssetIds = _db.remoteAlbumAssetEntity.selectOnly()..addColumns([_db.remoteAlbumAssetEntity.assetId]);
|
||||||
return _db.remoteAssetEntity.id.isInQuery(faceAssetIds) &
|
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.deletedAt.isNull() &
|
||||||
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline);
|
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<Bucket>> _watchPersonBucket(
|
Stream<List<Bucket>> _watchPersonBucket(String userId, String personId, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
|
||||||
List<String> userIds,
|
|
||||||
String personId, {
|
|
||||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
|
||||||
}) {
|
|
||||||
if (groupBy == GroupAssetsBy.none) {
|
if (groupBy == GroupAssetsBy.none) {
|
||||||
final query = _db.remoteAssetEntity.selectOnly()
|
final query = _db.remoteAssetEntity.selectOnly()
|
||||||
..addColumns([_db.remoteAssetEntity.id.count()])
|
..addColumns([_db.remoteAssetEntity.id.count()])
|
||||||
..where(_personAssetFilter(userIds, personId));
|
..where(_personAssetFilter(userId, personId));
|
||||||
|
|
||||||
return query.map((row) {
|
return query.map((row) {
|
||||||
final count = row.read(_db.remoteAssetEntity.id.count())!;
|
final count = row.read(_db.remoteAssetEntity.id.count())!;
|
||||||
@@ -466,7 +467,7 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
|
|||||||
|
|
||||||
final query = _db.remoteAssetEntity.selectOnly()
|
final query = _db.remoteAssetEntity.selectOnly()
|
||||||
..addColumns([assetCountExp, dateExp])
|
..addColumns([assetCountExp, dateExp])
|
||||||
..where(_personAssetFilter(userIds, personId))
|
..where(_personAssetFilter(userId, personId))
|
||||||
..groupBy([dateExp])
|
..groupBy([dateExp])
|
||||||
..orderBy([OrderingTerm.desc(dateExp)]);
|
..orderBy([OrderingTerm.desc(dateExp)]);
|
||||||
|
|
||||||
@@ -478,14 +479,14 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<BaseAsset>> _getPersonBucketAssets(
|
Future<List<BaseAsset>> _getPersonBucketAssets(
|
||||||
List<String> userIds,
|
String userId,
|
||||||
String personId, {
|
String personId, {
|
||||||
required int offset,
|
required int offset,
|
||||||
required int count,
|
required int count,
|
||||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||||
}) {
|
}) {
|
||||||
final query = _db.remoteAssetEntity.select()
|
final query = _db.remoteAssetEntity.select()
|
||||||
..where((_) => _personAssetFilter(userIds, personId))
|
..where((_) => _personAssetFilter(userId, personId))
|
||||||
..orderBy(_assetDateOrder(groupBy))
|
..orderBy(_assetDateOrder(groupBy))
|
||||||
..limit(count, offset: offset);
|
..limit(count, offset: offset);
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,7 @@ class _PersonPageState extends ConsumerState<PersonPage> {
|
|||||||
throw Exception('User must be logged in to view person timeline');
|
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(user.id, _person.id);
|
||||||
final timelineService = ref.watch(timelineFactoryProvider).person(users, _person.id);
|
|
||||||
ref.onDispose(timelineService.dispose);
|
ref.onDispose(timelineService.dispose);
|
||||||
return timelineService;
|
return timelineService;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -42,12 +42,16 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures));
|
final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures));
|
||||||
final textSearchType = useState<TextSearchType>(
|
// Stays null until the user picks a type, so the default follows the server features once they are loaded
|
||||||
serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename,
|
final selectedTextSearchType = useState<TextSearchType?>(null);
|
||||||
);
|
final textSearchType =
|
||||||
final searchHintText = useState<String>(
|
selectedTextSearchType.value ?? (serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename);
|
||||||
serverFeatures.smartSearch ? context.t.sunrise_on_the_beach : context.t.file_name_or_extension,
|
final searchHintText = switch (textSearchType) {
|
||||||
);
|
TextSearchType.context => context.t.sunrise_on_the_beach,
|
||||||
|
TextSearchType.filename => context.t.file_name_or_extension,
|
||||||
|
TextSearchType.description => context.t.search_by_description_example,
|
||||||
|
TextSearchType.ocr => context.t.search_by_ocr_example,
|
||||||
|
};
|
||||||
final textSearchController = useTextEditingController();
|
final textSearchController = useTextEditingController();
|
||||||
final filter = useState<SearchFilter>(
|
final filter = useState<SearchFilter>(
|
||||||
SearchFilter(
|
SearchFilter(
|
||||||
@@ -491,14 +495,14 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleTextSubmitted(String value) => search(switch (textSearchType.value) {
|
void handleTextSubmitted(String value) => search(switch (textSearchType) {
|
||||||
TextSearchType.context => filter.value.copyWith(filename: '', context: value, description: '', ocr: ''),
|
TextSearchType.context => filter.value.copyWith(filename: '', context: value, description: '', ocr: ''),
|
||||||
TextSearchType.filename => filter.value.copyWith(filename: value, context: '', description: '', ocr: ''),
|
TextSearchType.filename => filter.value.copyWith(filename: value, context: '', description: '', ocr: ''),
|
||||||
TextSearchType.description => filter.value.copyWith(filename: '', context: '', description: value, ocr: ''),
|
TextSearchType.description => filter.value.copyWith(filename: '', context: '', description: value, ocr: ''),
|
||||||
TextSearchType.ocr => filter.value.copyWith(filename: '', context: '', description: '', ocr: value),
|
TextSearchType.ocr => filter.value.copyWith(filename: '', context: '', description: '', ocr: value),
|
||||||
});
|
});
|
||||||
|
|
||||||
IconData getSearchPrefixIcon() => switch (textSearchType.value) {
|
IconData getSearchPrefixIcon() => switch (textSearchType) {
|
||||||
TextSearchType.context => Icons.image_search_rounded,
|
TextSearchType.context => Icons.image_search_rounded,
|
||||||
TextSearchType.filename => Icons.abc_rounded,
|
TextSearchType.filename => Icons.abc_rounded,
|
||||||
TextSearchType.description => Icons.text_snippet_outlined,
|
TextSearchType.description => Icons.text_snippet_outlined,
|
||||||
@@ -543,15 +547,14 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
context.t.search_by_context,
|
context.t.search_by_context,
|
||||||
style: context.textTheme.bodyLarge?.copyWith(
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null,
|
color: textSearchType == TextSearchType.context ? context.colorScheme.primary : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
selectedColor: context.colorScheme.primary,
|
selectedColor: context.colorScheme.primary,
|
||||||
selected: textSearchType.value == TextSearchType.context,
|
selected: textSearchType == TextSearchType.context,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
textSearchType.value = TextSearchType.context;
|
selectedTextSearchType.value = TextSearchType.context;
|
||||||
searchHintText.value = context.t.sunrise_on_the_beach;
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -562,15 +565,14 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
context.t.search_filter_filename,
|
context.t.search_filter_filename,
|
||||||
style: context.textTheme.bodyLarge?.copyWith(
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: textSearchType.value == TextSearchType.filename ? context.colorScheme.primary : null,
|
color: textSearchType == TextSearchType.filename ? context.colorScheme.primary : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
selectedColor: context.colorScheme.primary,
|
selectedColor: context.colorScheme.primary,
|
||||||
selected: textSearchType.value == TextSearchType.filename,
|
selected: textSearchType == TextSearchType.filename,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
textSearchType.value = TextSearchType.filename;
|
selectedTextSearchType.value = TextSearchType.filename;
|
||||||
searchHintText.value = context.t.file_name_or_extension;
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
@@ -580,15 +582,14 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
context.t.search_by_description,
|
context.t.search_by_description,
|
||||||
style: context.textTheme.bodyLarge?.copyWith(
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: textSearchType.value == TextSearchType.description ? context.colorScheme.primary : null,
|
color: textSearchType == TextSearchType.description ? context.colorScheme.primary : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
selectedColor: context.colorScheme.primary,
|
selectedColor: context.colorScheme.primary,
|
||||||
selected: textSearchType.value == TextSearchType.description,
|
selected: textSearchType == TextSearchType.description,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
textSearchType.value = TextSearchType.description;
|
selectedTextSearchType.value = TextSearchType.description;
|
||||||
searchHintText.value = context.t.search_by_description_example;
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
FeatureCheck(
|
FeatureCheck(
|
||||||
@@ -600,15 +601,14 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
context.t.search_by_ocr,
|
context.t.search_by_ocr,
|
||||||
style: context.textTheme.bodyLarge?.copyWith(
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null,
|
color: textSearchType == TextSearchType.ocr ? context.colorScheme.primary : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
selectedColor: context.colorScheme.primary,
|
selectedColor: context.colorScheme.primary,
|
||||||
selected: textSearchType.value == TextSearchType.ocr,
|
selected: textSearchType == TextSearchType.ocr,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
textSearchType.value = TextSearchType.ocr;
|
selectedTextSearchType.value = TextSearchType.ocr;
|
||||||
searchHintText.value = context.t.search_by_ocr_example;
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -631,7 +631,7 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: SearchField(
|
child: SearchField(
|
||||||
hintText: searchHintText.value,
|
hintText: searchHintText,
|
||||||
key: const Key('search_text_field'),
|
key: const Key('search_text_field'),
|
||||||
controller: textSearchController,
|
controller: textSearchController,
|
||||||
contentPadding: filter.value.assetId != null ? const EdgeInsets.only(left: 24) : const EdgeInsets.all(8),
|
contentPadding: filter.value.assetId != null ? const EdgeInsets.only(left: 24) : const EdgeInsets.all(8),
|
||||||
|
|||||||
+2
-6
@@ -8,6 +8,7 @@ import 'package:immich_mobile/generated/translations.g.dart';
|
|||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
|
import 'package:immich_mobile/utils/camera.utils.dart';
|
||||||
|
|
||||||
const _kSeparator = ' • ';
|
const _kSeparator = ' • ';
|
||||||
|
|
||||||
@@ -114,12 +115,7 @@ class TechnicalDetails extends ConsumerWidget {
|
|||||||
if (exifInfo == null) {
|
if (exifInfo == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return switch ((exifInfo.make, exifInfo.model)) {
|
return getCameraName(exifInfo.make, exifInfo.model);
|
||||||
(null, null) => null,
|
|
||||||
(final String make, null) => make,
|
|
||||||
(null, final String model) => model,
|
|
||||||
(final String make, final String model) => '$make $model',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
|
static String? _getCameraInfoSubtitle(ExifInfo? exifInfo) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
final _whitespace = RegExp(r'\s+');
|
||||||
|
final _wordChar = RegExp(r'\w');
|
||||||
|
|
||||||
|
/// Builds the display name of a camera from its EXIF make and model.
|
||||||
|
///
|
||||||
|
/// Many cameras (e.g. Canon) already include the manufacturer in the model
|
||||||
|
/// (`Canon EOS R6m2`), so prefixing the make again would produce
|
||||||
|
/// "Canon Canon EOS R6m2". The make is only prepended when the model does not
|
||||||
|
/// already start with it. Makes made up of several words are matched word by
|
||||||
|
/// word, since the model usually repeats only part of them:
|
||||||
|
/// `NIKON CORPORATION` / `NIKON D850` and
|
||||||
|
/// `EASTMAN KODAK COMPANY` / `KODAK EASYSHARE C875`.
|
||||||
|
///
|
||||||
|
/// Returns `null` when neither a make nor a model is available.
|
||||||
|
String? getCameraName(String? make, String? model) {
|
||||||
|
final trimmedMake = make?.trim() ?? '';
|
||||||
|
final trimmedModel = model?.trim() ?? '';
|
||||||
|
|
||||||
|
if (trimmedMake.isEmpty && trimmedModel.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedMake.isEmpty) {
|
||||||
|
return trimmedModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedModel.isEmpty) {
|
||||||
|
return trimmedMake;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lowerModel = trimmedModel.toLowerCase();
|
||||||
|
final modelIncludesMake = trimmedMake.toLowerCase().split(_whitespace).any((word) {
|
||||||
|
final charAfterWord = lowerModel.length > word.length ? lowerModel[word.length] : '';
|
||||||
|
return lowerModel.startsWith(word) && !_wordChar.hasMatch(charAfterWord);
|
||||||
|
});
|
||||||
|
|
||||||
|
return modelIncludesMake ? trimmedModel : '$trimmedMake $trimmedModel';
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 3.3.5-local.12+3030504
|
version: 3.3.5-local.14+3030506
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.12.0 <4.0.0'
|
sdk: '>=3.12.0 <4.0.0'
|
||||||
|
|||||||
@@ -363,4 +363,65 @@ void main() {
|
|||||||
expect(rows.map((r) => r.id).toSet(), {last.id, lastLib.id});
|
expect(rows.map((r) => r.id).toSet(), {last.id, lastLib.id});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('SyncStreamRepository - deleteAssetOcrV1 (#32337)', () {
|
||||||
|
test('removes OCR rows for deleted assets', () async {
|
||||||
|
await sut.updateUsersV1([_createUser()]);
|
||||||
|
final known1 = _createAsset(
|
||||||
|
id: 'known1',
|
||||||
|
checksum: 'AAA',
|
||||||
|
fileName: 'a.jpg',
|
||||||
|
);
|
||||||
|
final known2 = _createAsset(
|
||||||
|
id: 'known2',
|
||||||
|
checksum: 'BBB',
|
||||||
|
fileName: 'b.jpg',
|
||||||
|
);
|
||||||
|
await sut.updateAssetsV1([known1, known2]);
|
||||||
|
|
||||||
|
await sut.updateAssetOcrV1([
|
||||||
|
SyncAssetOcrV1(
|
||||||
|
assetId: known1.id,
|
||||||
|
boxScore: 0.9,
|
||||||
|
id: 'ocr-1',
|
||||||
|
isVisible: true,
|
||||||
|
text: 'this text',
|
||||||
|
textScore: 0.9,
|
||||||
|
x1: 1,
|
||||||
|
x2: 2,
|
||||||
|
x3: 3,
|
||||||
|
x4: 4,
|
||||||
|
y1: 1,
|
||||||
|
y2: 2,
|
||||||
|
y3: 3,
|
||||||
|
y4: 4,
|
||||||
|
),
|
||||||
|
SyncAssetOcrV1(
|
||||||
|
assetId: known2.id,
|
||||||
|
boxScore: 0.8,
|
||||||
|
id: 'ocr-2',
|
||||||
|
isVisible: false,
|
||||||
|
text: 'this text again',
|
||||||
|
textScore: 0.8,
|
||||||
|
x1: 1,
|
||||||
|
x2: 2,
|
||||||
|
x3: 3,
|
||||||
|
x4: 4,
|
||||||
|
y1: 1,
|
||||||
|
y2: 2,
|
||||||
|
y3: 3,
|
||||||
|
y4: 4,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sut.deleteAssetOcrV1([
|
||||||
|
SyncAssetOcrDeleteV1(id: 'log-id', assetId: known1.id, deletedAt: DateTime(2024, 1, 5)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final removedRows = await (db.assetOcrEntity.select()..where((t) => t.assetId.equals(known1.id))).get();
|
||||||
|
expect(removedRows, isEmpty);
|
||||||
|
final keepRows = await (db.assetOcrEntity.select()..where((t) => t.assetId.equals(known2.id))).get();
|
||||||
|
expect(keepRows, hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,22 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('person assets', () {
|
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 {
|
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
|
// 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
|
// 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);
|
||||||
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;
|
final buckets = await query.bucketSource().first;
|
||||||
expect(buckets, hasLength(1));
|
expect(buckets, hasLength(1));
|
||||||
@@ -140,7 +156,7 @@ void main() {
|
|||||||
await ctx.newFace(assetId: shiftedLater.id, personId: person.id);
|
await ctx.newFace(assetId: shiftedLater.id, personId: person.id);
|
||||||
await ctx.newFace(assetId: shiftedEarlier.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;
|
final buckets = await query.bucketSource().first;
|
||||||
expect(buckets, hasLength(2));
|
expect(buckets, hasLength(2));
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/search/search.page.dart';
|
||||||
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
|
|
||||||
|
import '../../unit/presentation/presentation_context.dart';
|
||||||
|
|
||||||
|
/// Server info whose features arrive after the page is built, like on a slow or late connection
|
||||||
|
class DelayedServerInfoNotifier extends ServerInfoNotifier {
|
||||||
|
DelayedServerInfoNotifier(super.serverInfoService);
|
||||||
|
|
||||||
|
void enableSmartSearch() => state = state.copyWith(serverFeatures: state.serverFeatures.copyWith(smartSearch: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PresentationContext context;
|
||||||
|
|
||||||
|
setUp(() async => context = await PresentationContext.create());
|
||||||
|
|
||||||
|
tearDown(() async => context.dispose());
|
||||||
|
|
||||||
|
final contextSearchIcon = find.byIcon(Icons.image_search_rounded);
|
||||||
|
final filenameSearchIcon = find.byIcon(Icons.abc_rounded);
|
||||||
|
|
||||||
|
Future<DelayedServerInfoNotifier> pumpSearchPage(WidgetTester tester) async {
|
||||||
|
final serverInfo = DelayedServerInfoNotifier(context.service.serverInfo);
|
||||||
|
await tester.pumpTestWidget(
|
||||||
|
context,
|
||||||
|
const SearchPage(),
|
||||||
|
overrides: [serverInfoProvider.overrideWith((ref) => serverInfo)],
|
||||||
|
);
|
||||||
|
return serverInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('uses context search once smart search is reported by the server', (tester) async {
|
||||||
|
final serverInfo = await pumpSearchPage(tester);
|
||||||
|
expect(filenameSearchIcon, findsOneWidget);
|
||||||
|
|
||||||
|
serverInfo.enableSmartSearch();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(contextSearchIcon, findsOneWidget);
|
||||||
|
expect(filenameSearchIcon, findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('keeps the search type selected by the user', (tester) async {
|
||||||
|
final serverInfo = await pumpSearchPage(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.text_snippet_outlined));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
serverInfo.enableSmartSearch();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.text_snippet_outlined), findsOneWidget);
|
||||||
|
expect(contextSearchIcon, findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/utils/camera.utils.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('getCameraName', () {
|
||||||
|
test('does not repeat the make when the model already starts with it', () {
|
||||||
|
expect(getCameraName('Canon', 'Canon EOS R6m2'), 'Canon EOS R6m2');
|
||||||
|
expect(getCameraName('Canon', 'Canon EOS 5D Mark IV'), 'Canon EOS 5D Mark IV');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compares the make and model case-insensitively', () {
|
||||||
|
expect(getCameraName('NIKON', 'Nikon Z 6'), 'Nikon Z 6');
|
||||||
|
expect(getCameraName('canon', 'CANON EOS R5'), 'CANON EOS R5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compares against the first word of a multi-word make', () {
|
||||||
|
expect(getCameraName('NIKON CORPORATION', 'NIKON D850'), 'NIKON D850');
|
||||||
|
expect(getCameraName('OLYMPUS IMAGING CORP.', 'OLYMPUS E-M1'), 'OLYMPUS E-M1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches any word of a multi-word make', () {
|
||||||
|
expect(getCameraName('EASTMAN KODAK COMPANY', 'KODAK EASYSHARE C875'), 'KODAK EASYSHARE C875');
|
||||||
|
expect(getCameraName('EASTMAN KODAK COMPANY', 'EASTMAN KODAK 1'), 'EASTMAN KODAK 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepends the make when the model does not include it', () {
|
||||||
|
expect(getCameraName('SONY', 'ILCE-7M4'), 'SONY ILCE-7M4');
|
||||||
|
expect(getCameraName('FUJIFILM', 'X-T5'), 'FUJIFILM X-T5');
|
||||||
|
expect(getCameraName('Apple', 'iPhone 15 Pro'), 'Apple iPhone 15 Pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only treats the make as a prefix on a word boundary', () {
|
||||||
|
expect(getCameraName('Canon', 'Canonical X1'), 'Canon Canonical X1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles a missing make or model', () {
|
||||||
|
expect(getCameraName('Canon', null), 'Canon');
|
||||||
|
expect(getCameraName(null, 'EOS R6m2'), 'EOS R6m2');
|
||||||
|
expect(getCameraName(null, null), isNull);
|
||||||
|
expect(getCameraName('', ''), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores surrounding whitespace', () {
|
||||||
|
expect(getCameraName(' Canon ', ' Canon EOS R6m2 '), 'Canon EOS R6m2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+13
-6
@@ -56,6 +56,18 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "codecs"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"jxl",
|
||||||
|
"libc",
|
||||||
|
"moxcms",
|
||||||
|
"pic-scale",
|
||||||
|
"quick_cache",
|
||||||
|
"rayon",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.8"
|
version = "4.6.8"
|
||||||
@@ -287,12 +299,7 @@ dependencies = [
|
|||||||
name = "native_core"
|
name = "native_core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jxl",
|
"codecs",
|
||||||
"libc",
|
|
||||||
"moxcms",
|
|
||||||
"pic-scale",
|
|
||||||
"quick_cache",
|
|
||||||
"rayon",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/native_core", "crates/native_core_ffi"]
|
members = ["crates/codecs", "crates/native_core", "crates/native_core_ffi"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+3
-2
@@ -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
|
in `mobile/` sets one up, otherwise install [rustup](https://rustup.rs) once and the build
|
||||||
fetches the pinned toolchain itself.
|
fetches the pinned toolchain itself.
|
||||||
|
|
||||||
- `crates/native_core`: the shared logic
|
- `crates/codecs`: the image decoders, a feature each, behind one `decode`
|
||||||
- `crates/native_core_ffi`: the C ABI, cbindgen writes `include/native_core.h`
|
- `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
|
- `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)
|
(`NOTICES` carries the licenses of the crates compiled into the core, update it with the dependencies)
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
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();
|
let mut options = JxlDecoderOptions::default();
|
||||||
// Colour transforms need straight alpha; premultiplying happens last.
|
// Colour transforms need straight alpha; premultiplying happens last.
|
||||||
options.alpha_mode = AlphaMode::Unpremultiplied;
|
options.alpha_mode = AlphaMode::Unpremultiplied;
|
||||||
@@ -85,7 +85,7 @@ impl<'a> Jxl<'a> {
|
|||||||
.ok()?;
|
.ok()?;
|
||||||
let profile = decoder.output_color_profile()?;
|
let profile = decoder.output_color_profile()?;
|
||||||
if let Some(cicp) = cicp(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) {
|
if color::is_hdr(cicp) {
|
||||||
decoder
|
decoder
|
||||||
@@ -121,11 +121,11 @@ impl<'a> Jxl<'a> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes the first frame unless cancelled, downscaled to cover `target` pixels and to fit
|
/// Decodes the first frame unless cancelled, downscaled to cover `target_size` pixels and to
|
||||||
/// within `max_pixel_size`.
|
/// fit within `max_pixel_size`.
|
||||||
pub fn decode(
|
pub fn decode(
|
||||||
mut self,
|
mut self,
|
||||||
target: Option<(usize, usize)>,
|
target_size: Option<(usize, usize)>,
|
||||||
max_pixel_size: usize,
|
max_pixel_size: usize,
|
||||||
cancelled: &AtomicBool,
|
cancelled: &AtomicBool,
|
||||||
) -> Option<Image> {
|
) -> Option<Image> {
|
||||||
@@ -139,7 +139,7 @@ impl<'a> Jxl<'a> {
|
|||||||
let pixel_bytes =
|
let pixel_bytes =
|
||||||
format.color_type.samples_per_pixel() * format.color_data_format?.bytes_per_sample();
|
format.color_type.samples_per_pixel() * format.color_data_format?.bytes_per_sample();
|
||||||
let stride = w.checked_mul(pixel_bytes)?;
|
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 samples = if color::is_hdr(self.cicp) {
|
||||||
let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?;
|
let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?;
|
||||||
self.decode_frame(&mut rgba, stride, cancelled)?;
|
self.decode_frame(&mut rgba, stride, cancelled)?;
|
||||||
@@ -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
|
/// The size of `size` scaled down to cover `target_size` and to fit within `max_pixel_size`, if
|
||||||
/// is smaller.
|
/// that is smaller.
|
||||||
pub(crate) fn cover(
|
pub(crate) fn cover(
|
||||||
(w, h): (usize, usize),
|
(w, h): (usize, usize),
|
||||||
target: Option<(usize, usize)>,
|
target_size: Option<(usize, usize)>,
|
||||||
max_pixel_size: usize,
|
max_pixel_size: usize,
|
||||||
) -> Option<(usize, usize)> {
|
) -> Option<(usize, usize)> {
|
||||||
// Scales as side ratios, so the side that sets the scale lands exactly on its limit.
|
// 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 fit = (max_pixel_size, w.max(h));
|
||||||
let (num, den) = match target {
|
let (num, den) = match target_size {
|
||||||
Some((tw, th)) => {
|
Some((tw, th)) => {
|
||||||
let cover = if tw * h >= th * w { (tw, w) } else { (th, h) };
|
let cover = if tw * h >= th * w { (tw, w) } else { (th, h) };
|
||||||
if cover.0 * fit.1 < fit.0 * cover.1 {
|
if cover.0 * fit.1 < fit.0 * cover.1 {
|
||||||
Vendored
Vendored
Vendored
@@ -1,11 +1,19 @@
|
|||||||
|
#![cfg(feature = "jxl")]
|
||||||
|
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
|
|
||||||
use native_core::color::{CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
|
use codecs::Options;
|
||||||
use native_core::image::{Image, Pixels, Samples};
|
use codecs::color::{CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
|
||||||
use native_core::jxl::Jxl;
|
use codecs::image::{Image, Pixels, Samples};
|
||||||
|
use codecs::jxl::Jxl;
|
||||||
|
|
||||||
fn decode(data: &[u8], target: Target) -> Option<Image> {
|
fn decode(data: &[u8], color_space: Target) -> Option<Image> {
|
||||||
Jxl::open(data, target)?.decode(None, 16384, &AtomicBool::new(false))
|
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>> {
|
fn decode_srgb(data: &[u8]) -> Option<Pixels<u8>> {
|
||||||
@@ -4,13 +4,16 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
convert = []
|
||||||
|
rotate = []
|
||||||
|
thumbhash = []
|
||||||
|
jxl = ["codecs/jxl"]
|
||||||
|
# One group per target.
|
||||||
|
flutter = ["convert", "rotate", "thumbhash", "jxl"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
jxl = { workspace = true }
|
codecs = { path = "../codecs", default-features = false, optional = true }
|
||||||
libc = { workspace = true }
|
|
||||||
moxcms = { workspace = true }
|
|
||||||
pic-scale = { workspace = true }
|
|
||||||
quick_cache = { workspace = true }
|
|
||||||
rayon = { workspace = true }
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
pub mod color;
|
#[cfg(feature = "codecs")]
|
||||||
|
pub use codecs;
|
||||||
|
#[cfg(feature = "convert")]
|
||||||
pub mod convert;
|
pub mod convert;
|
||||||
pub mod image;
|
#[cfg(feature = "rotate")]
|
||||||
pub mod jxl;
|
|
||||||
mod resize;
|
|
||||||
pub mod rotate;
|
pub mod rotate;
|
||||||
|
#[cfg(feature = "thumbhash")]
|
||||||
pub mod thumbhash;
|
pub mod thumbhash;
|
||||||
|
|
||||||
pub fn version() -> &'static str {
|
pub fn version() -> &'static str {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![cfg(feature = "convert")]
|
||||||
|
|
||||||
use native_core::convert::convert_1010102;
|
use native_core::convert::convert_1010102;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![cfg(feature = "rotate")]
|
||||||
|
|
||||||
use native_core::rotate::{Orientation, rotate};
|
use native_core::rotate::{Orientation, rotate};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![cfg(feature = "thumbhash")]
|
||||||
|
|
||||||
use native_core::thumbhash::decode;
|
use native_core::thumbhash::decode;
|
||||||
|
|
||||||
// Expected pixels come from the old Swift decoder patched to round like the Java did (it truncated),
|
// Expected pixels come from the old Swift decoder patched to round like the Java did (it truncated),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ name = "native_core_ffi"
|
|||||||
crate-type = ["cdylib", "staticlib"]
|
crate-type = ["cdylib", "staticlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
native_core = { path = "../native_core" }
|
native_core = { path = "../native_core", features = ["flutter"] }
|
||||||
libc = { workspace = true }
|
libc = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::path::Path;
|
|||||||
fn main() {
|
fn main() {
|
||||||
// Files that define exported items. A directory here would make Flutter rerun the hook every build.
|
// 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/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=src/thumbhash.rs");
|
||||||
println!("cargo:rerun-if-changed=cbindgen.toml");
|
println!("cargo:rerun-if-changed=cbindgen.toml");
|
||||||
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||||
|
|||||||
+17
-14
@@ -10,9 +10,9 @@ use jni::{
|
|||||||
objects::{JByteBuffer, JClass, JIntArray},
|
objects::{JByteBuffer, JClass, JIntArray},
|
||||||
sys::{jint, jlong},
|
sys::{jint, jlong},
|
||||||
};
|
};
|
||||||
use native_core::color::Target;
|
use native_core::codecs::color::Target;
|
||||||
use native_core::image::{Image, Samples};
|
use native_core::codecs::image::{Image, Samples};
|
||||||
use native_core::jxl::Jxl;
|
use native_core::codecs::{self, Options};
|
||||||
|
|
||||||
use super::guard;
|
use super::guard;
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ pub struct NativeCoreImageInfo {
|
|||||||
pub transfer: u8,
|
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.
|
/// 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
|
/// 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
|
/// 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`.
|
/// `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`.
|
/// `info` must be null or a valid writable pointer, outside `data`.
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
pub unsafe extern "C" fn native_core_jxl_decode(
|
pub unsafe extern "C" fn native_core_decode_image(
|
||||||
data: *const u8,
|
data: *const u8,
|
||||||
len: size_t,
|
len: size_t,
|
||||||
keep_space: bool,
|
keep_space: bool,
|
||||||
@@ -60,16 +60,19 @@ pub unsafe extern "C" fn native_core_jxl_decode(
|
|||||||
let cancelled = unsafe { AtomicBool::from_ptr(cancelled) };
|
let cancelled = unsafe { AtomicBool::from_ptr(cancelled) };
|
||||||
// SAFETY: the caller provides a buffer readable for len bytes.
|
// SAFETY: the caller provides a buffer readable for len bytes.
|
||||||
let input = unsafe { slice::from_raw_parts(data, len) };
|
let input = unsafe { slice::from_raw_parts(data, len) };
|
||||||
let target = if keep_space {
|
let color_space = if keep_space {
|
||||||
Target::Source
|
Target::Source
|
||||||
} else {
|
} else {
|
||||||
Target::Srgb
|
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));
|
.then_some((target_width as usize, target_height as usize));
|
||||||
let Some(Image { cicp, samples }) = Jxl::open(input, target)
|
let options = Options {
|
||||||
.and_then(|image| image.decode(cover, max_pixel_size as usize, cancelled))
|
color_space,
|
||||||
else {
|
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();
|
return ptr::null_mut();
|
||||||
};
|
};
|
||||||
let (width, height, bit_depth, pixels) = match samples {
|
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.
|
/// Asks the decode given `flag` to stop early.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # 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)]
|
#[unsafe(no_mangle)]
|
||||||
pub unsafe extern "C" fn native_core_cancel(flag: *mut bool) {
|
pub unsafe extern "C" fn native_core_cancel(flag: *mut bool) {
|
||||||
if !flag.is_null() {
|
if !flag.is_null() {
|
||||||
@@ -104,7 +107,7 @@ pub unsafe extern "C" fn native_core_cancel(flag: *mut bool) {
|
|||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
#[unsafe(no_mangle)]
|
#[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>,
|
mut env: EnvUnowned<'caller>,
|
||||||
_class: JClass<'caller>,
|
_class: JClass<'caller>,
|
||||||
data: jlong,
|
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
|
// SAFETY: Kotlin passes a live native buffer of `length` bytes and a direct buffer it only
|
||||||
// changes through `cancel`, and the output is valid.
|
// changes through `cancel`, and the output is valid.
|
||||||
let rgba = unsafe {
|
let rgba = unsafe {
|
||||||
native_core_jxl_decode(
|
native_core_decode_image(
|
||||||
data as *const u8,
|
data as *const u8,
|
||||||
length as size_t,
|
length as size_t,
|
||||||
false,
|
false,
|
||||||
@@ -154,7 +157,7 @@ pub extern "system" fn Java_app_alextran_immich_NativeImage_cancel<'caller>(
|
|||||||
) {
|
) {
|
||||||
env.with_env(|env| -> jni::errors::Result<()> {
|
env.with_env(|env| -> jni::errors::Result<()> {
|
||||||
let flag = env.get_direct_buffer_address(&flag)?.cast::<bool>();
|
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) };
|
unsafe { native_core_cancel(flag) };
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -9,7 +9,7 @@ mod android;
|
|||||||
mod bitmap;
|
mod bitmap;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod convert;
|
mod convert;
|
||||||
mod jxl;
|
mod decode;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod rotate;
|
mod rotate;
|
||||||
mod thumbhash;
|
mod thumbhash;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const _manifests = [
|
|||||||
'../.cargo/config.toml',
|
'../.cargo/config.toml',
|
||||||
'../Cargo.toml',
|
'../Cargo.toml',
|
||||||
'../Cargo.lock',
|
'../Cargo.lock',
|
||||||
|
'../crates/codecs/Cargo.toml',
|
||||||
'../crates/native_core/Cargo.toml',
|
'../crates/native_core/Cargo.toml',
|
||||||
'$_crate/Cargo.toml',
|
'$_crate/Cargo.toml',
|
||||||
'$_crate/rust-toolchain.toml',
|
'$_crate/rust-toolchain.toml',
|
||||||
|
|||||||
@@ -6343,7 +6343,7 @@
|
|||||||
"state": "Added"
|
"state": "Added"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"x-immich-permission": "clusterGroup.read"
|
"x-immich-permission": "clusterGroup.admin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/cluster-groups/{id}/requests": {
|
"/cluster-groups/{id}/requests": {
|
||||||
@@ -25811,6 +25811,7 @@
|
|||||||
"backup.delete",
|
"backup.delete",
|
||||||
"clusterGroup.read",
|
"clusterGroup.read",
|
||||||
"clusterGroup.leave",
|
"clusterGroup.leave",
|
||||||
|
"clusterGroup.admin",
|
||||||
"clusterGroupRequest.create",
|
"clusterGroupRequest.create",
|
||||||
"clusterGroupRequest.read",
|
"clusterGroupRequest.read",
|
||||||
"clusterGroupRequest.delete",
|
"clusterGroupRequest.delete",
|
||||||
@@ -28466,11 +28467,13 @@
|
|||||||
"ServerFeatureLevel": {
|
"ServerFeatureLevel": {
|
||||||
"description": "Server feature level",
|
"description": "Server feature level",
|
||||||
"enum": [
|
"enum": [
|
||||||
1
|
1,
|
||||||
|
2
|
||||||
],
|
],
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"x-enum-varnames": [
|
"x-enum-varnames": [
|
||||||
"FeatureLevel"
|
"FeatureLevel",
|
||||||
|
"Passkeys"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ServerFeaturesDto": {
|
"ServerFeaturesDto": {
|
||||||
@@ -34670,6 +34673,7 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"AssetCreate",
|
"AssetCreate",
|
||||||
"AssetMetadataExtraction",
|
"AssetMetadataExtraction",
|
||||||
|
"AssetOcrExtraction",
|
||||||
"AssetTagged"
|
"AssetTagged"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"eslint": "^10.0.0",
|
"eslint": "^10.0.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^75.0.0",
|
"eslint-plugin-unicorn": "^78.0.0",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"mock-fs": "^5.2.0",
|
"mock-fs": "^5.2.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
|
|||||||
+15
-15
@@ -28,10 +28,10 @@ export const defaultQueueOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An in-memory queue that processes tasks in parallel with a given concurrency.
|
An in-memory queue that processes tasks in parallel with a given concurrency.
|
||||||
* @see {@link https://www.npmjs.com/package/fastq}
|
@see {@link https://www.npmjs.com/package/fastq}
|
||||||
* @template T - The type of the worker task data.
|
@template T - The type of the worker task data.
|
||||||
* @template R - The type of the worker output data.
|
@template R - The type of the worker output data.
|
||||||
*/
|
*/
|
||||||
export class Queue<T, R> {
|
export class Queue<T, R> {
|
||||||
private readonly queue: fastq.queueAsPromised<string, Task<T, R>>;
|
private readonly queue: fastq.queueAsPromised<string, Task<T, R>>;
|
||||||
@@ -40,9 +40,9 @@ export class Queue<T, R> {
|
|||||||
readonly worker: (data: T) => Promise<R>;
|
readonly worker: (data: T) => Promise<R>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new queue.
|
Create a new queue.
|
||||||
* @param worker - The worker function that processes the task.
|
@param worker - The worker function that processes the task.
|
||||||
* @param options - The queue options.
|
@param options - The queue options.
|
||||||
*/
|
*/
|
||||||
constructor(worker: (data: T) => Promise<R>, options?: QueueOptions) {
|
constructor(worker: (data: T) => Promise<R>, options?: QueueOptions) {
|
||||||
this.options = { ...defaultQueueOptions, ...options };
|
this.options = { ...defaultQueueOptions, ...options };
|
||||||
@@ -65,20 +65,20 @@ export class Queue<T, R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for the queue to be empty.
|
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.
|
@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`.
|
This promise could be ignored as it will not lead to a `unhandledRejection`.
|
||||||
*/
|
*/
|
||||||
drained(): Promise<void> {
|
drained(): Promise<void> {
|
||||||
return this.queue.drained();
|
return this.queue.drained();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a task at the end of the queue.
|
Add a task at the end of the queue.
|
||||||
* @see {@link https://www.npmjs.com/package/fastq}
|
@see {@link https://www.npmjs.com/package/fastq}
|
||||||
* @param data
|
@param data
|
||||||
* @returns Promise<void> - A Promise that will be fulfilled (rejected) when the task is completed successfully (unsuccessfully).
|
@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`.
|
This promise could be ignored as it will not lead to a `unhandledRejection`.
|
||||||
*/
|
*/
|
||||||
async push(data: T): Promise<Task<T, R>> {
|
async push(data: T): Promise<Task<T, R>> {
|
||||||
const id = uniqueId();
|
const id = uniqueId();
|
||||||
|
|||||||
@@ -218,8 +218,8 @@ export const sha1 = (filepath: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batches items and calls onBatch to process them
|
Batches items and calls onBatch to process them
|
||||||
* when the batch size is reached or the debounce time has passed.
|
when the batch size is reached or the debounce time has passed.
|
||||||
*/
|
*/
|
||||||
export class Batcher<T = unknown> {
|
export class Batcher<T = unknown> {
|
||||||
private items: T[] = [];
|
private items: T[] = [];
|
||||||
|
|||||||
@@ -75,6 +75,32 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"uiHints": ["SmartAlbum"]
|
"uiHints": ["SmartAlbum"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "receipts",
|
||||||
|
"title": "Receipts",
|
||||||
|
"description": "Automatically add receipt assets to an album",
|
||||||
|
"trigger": "AssetOcrExtraction",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"method": "immich-plugin-core#assetOcrFilter",
|
||||||
|
"config": {
|
||||||
|
"caseSensitive": false,
|
||||||
|
"pattern": "receipt.+total",
|
||||||
|
"matchType": "regex",
|
||||||
|
"matchScope": "full",
|
||||||
|
"minScore": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "immich-plugin-core#assetAddToAlbums",
|
||||||
|
"config": {
|
||||||
|
"albumName": "Receipts",
|
||||||
|
"albumIds": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"uiHints": ["SmartAlbum"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"methods": [
|
"methods": [
|
||||||
@@ -407,7 +433,7 @@
|
|||||||
"matchType": {
|
"matchType": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Match type",
|
"title": "Match type",
|
||||||
"enum": ["contains", "startsWith", "exact", "regex"],
|
"enum": ["contains", "startsWith", "exact", "regex", "present", "missing"],
|
||||||
"default": "contains",
|
"default": "contains",
|
||||||
"description": "Type of pattern matching to perform",
|
"description": "Type of pattern matching to perform",
|
||||||
"uiHint": {
|
"uiHint": {
|
||||||
@@ -424,7 +450,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["property", "pattern"]
|
"required": ["property"]
|
||||||
|
},
|
||||||
|
"uiHints": ["Filter"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assetOcrFilter",
|
||||||
|
"title": "Filter by OCR text",
|
||||||
|
"description": "Filter assets by OCR text pattern using text matching or regular expressions",
|
||||||
|
"types": ["AssetV1"],
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "OCR text pattern",
|
||||||
|
"description": "Text or regex pattern to match against OCR text"
|
||||||
|
},
|
||||||
|
"matchType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["contains", "startsWith", "exact", "regex"],
|
||||||
|
"default": "contains",
|
||||||
|
"title": "Match type",
|
||||||
|
"description": "Type of pattern matching to perform"
|
||||||
|
},
|
||||||
|
"caseSensitive": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"title": "Case sensitive",
|
||||||
|
"description": "Whether matching should be case-sensitive"
|
||||||
|
},
|
||||||
|
"minScore": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 0,
|
||||||
|
"title": "Minimum score",
|
||||||
|
"description": "Minimum OCR confidence score to match"
|
||||||
|
},
|
||||||
|
"matchScope": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["full", "box"],
|
||||||
|
"default": "full",
|
||||||
|
"title": "Match scope",
|
||||||
|
"description": "Scope of the OCR text to match against"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
},
|
},
|
||||||
"uiHints": ["Filter"]
|
"uiHints": ["Filter"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { AssetVisibility } from '@immich/sdk';
|
|||||||
import type { Manifest } from '../dist/index.d.ts';
|
import type { Manifest } from '../dist/index.d.ts';
|
||||||
|
|
||||||
type MatchValueConfig = {
|
type MatchValueConfig = {
|
||||||
pattern: string;
|
pattern?: string;
|
||||||
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
|
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith' | 'missing' | 'present';
|
||||||
caseSensitive?: boolean;
|
caseSensitive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const matchValueResult = (value: string, config: MatchValueConfig) => {
|
const matchValueResult = (value: string | null, config: MatchValueConfig) => {
|
||||||
const { pattern, matchType = 'contains', caseSensitive = false } = config;
|
const { pattern = '', matchType = 'contains', caseSensitive = false } = config;
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return { workflow: { continue: matchType === 'missing' } };
|
||||||
|
}
|
||||||
|
|
||||||
const searchName = caseSensitive ? value : value.toLowerCase();
|
const searchName = caseSensitive ? value : value.toLowerCase();
|
||||||
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
||||||
|
|
||||||
@@ -32,6 +37,15 @@ const matchValueResult = (value: string, config: MatchValueConfig) => {
|
|||||||
return { workflow: { continue: regex.test(value) } };
|
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: {
|
default: {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -135,11 +149,10 @@ const methods = wrapper<Manifest>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
assetExifFilter: ({ config, data }) => {
|
assetExifFilter: ({ config, data }) => {
|
||||||
if (!data.asset.exifInfo || data.asset.exifInfo[config.property] === null) {
|
let value = data.asset.exifInfo?.[config.property];
|
||||||
return { workflow: { continue: false } };
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }) => {
|
assetDateFilter: ({ config, data }) => {
|
||||||
@@ -181,6 +194,13 @@ const methods = wrapper<Manifest>({
|
|||||||
return { workflow: { continue: hasTimeZone === needsTimeZone } };
|
return { workflow: { continue: hasTimeZone === needsTimeZone } };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
assetOcrFilter: ({ config, data }) => {
|
||||||
|
const entries = (data.asset.ocr ?? []).filter((ocr) => ocr.textScore >= (config.minScore ?? 0));
|
||||||
|
const texts = config.matchScope === 'full' ? [entries.map((ocr) => ocr.text).join(' ')] : entries.map((ocr) => ocr.text);
|
||||||
|
const matched = texts.some((t) => matchValueResult(t, config).workflow?.continue);
|
||||||
|
return { workflow: { continue: matched } };
|
||||||
|
},
|
||||||
|
|
||||||
assetTagFilter: ({ config, data }) => {
|
assetTagFilter: ({ config, data }) => {
|
||||||
const assetTags = data.asset.tags.map((tag) => tag.id);
|
const assetTags = data.asset.tags.map((tag) => tag.id);
|
||||||
|
|
||||||
@@ -241,6 +261,7 @@ const {
|
|||||||
assetDateFilter,
|
assetDateFilter,
|
||||||
assetLock,
|
assetLock,
|
||||||
assetMissingTimeZoneFilter,
|
assetMissingTimeZoneFilter,
|
||||||
|
assetOcrFilter,
|
||||||
assetTagFilter,
|
assetTagFilter,
|
||||||
assetTypeFilter,
|
assetTypeFilter,
|
||||||
assetVisibility,
|
assetVisibility,
|
||||||
@@ -261,6 +282,7 @@ export {
|
|||||||
assetDateFilter,
|
assetDateFilter,
|
||||||
assetLock,
|
assetLock,
|
||||||
assetMissingTimeZoneFilter,
|
assetMissingTimeZoneFilter,
|
||||||
|
assetOcrFilter,
|
||||||
assetTagFilter,
|
assetTagFilter,
|
||||||
assetTypeFilter,
|
assetTypeFilter,
|
||||||
assetVisibility,
|
assetVisibility,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
|
|||||||
export enum WorkflowTrigger {
|
export enum WorkflowTrigger {
|
||||||
AssetCreate = 'AssetCreate',
|
AssetCreate = 'AssetCreate',
|
||||||
AssetMetadataExtraction = 'AssetMetadataExtraction',
|
AssetMetadataExtraction = 'AssetMetadataExtraction',
|
||||||
|
AssetOcrExtraction = 'AssetOcrExtraction',
|
||||||
AssetTagged = 'AssetTagged',
|
AssetTagged = 'AssetTagged',
|
||||||
// PersonRecognized = 'PersonRecognized',
|
// PersonRecognized = 'PersonRecognized',
|
||||||
}
|
}
|
||||||
@@ -127,6 +128,11 @@ export type AssetV1 = {
|
|||||||
tags: string[] | null;
|
tags: string[] | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
ocr?: {
|
||||||
|
text: string;
|
||||||
|
textScore: number;
|
||||||
|
boxScore: number;
|
||||||
|
}[]
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8351,6 +8351,7 @@ export enum Permission {
|
|||||||
BackupDelete = "backup.delete",
|
BackupDelete = "backup.delete",
|
||||||
ClusterGroupRead = "clusterGroup.read",
|
ClusterGroupRead = "clusterGroup.read",
|
||||||
ClusterGroupLeave = "clusterGroup.leave",
|
ClusterGroupLeave = "clusterGroup.leave",
|
||||||
|
ClusterGroupAdmin = "clusterGroup.admin",
|
||||||
ClusterGroupRequestCreate = "clusterGroupRequest.create",
|
ClusterGroupRequestCreate = "clusterGroupRequest.create",
|
||||||
ClusterGroupRequestRead = "clusterGroupRequest.read",
|
ClusterGroupRequestRead = "clusterGroupRequest.read",
|
||||||
ClusterGroupRequestDelete = "clusterGroupRequest.delete",
|
ClusterGroupRequestDelete = "clusterGroupRequest.delete",
|
||||||
@@ -8602,6 +8603,7 @@ export enum WorkflowType {
|
|||||||
export enum WorkflowTrigger {
|
export enum WorkflowTrigger {
|
||||||
AssetCreate = "AssetCreate",
|
AssetCreate = "AssetCreate",
|
||||||
AssetMetadataExtraction = "AssetMetadataExtraction",
|
AssetMetadataExtraction = "AssetMetadataExtraction",
|
||||||
|
AssetOcrExtraction = "AssetOcrExtraction",
|
||||||
AssetTagged = "AssetTagged"
|
AssetTagged = "AssetTagged"
|
||||||
}
|
}
|
||||||
export enum QueueJobStatus {
|
export enum QueueJobStatus {
|
||||||
@@ -8823,7 +8825,8 @@ export enum ReleaseType {
|
|||||||
Prerelease = "prerelease"
|
Prerelease = "prerelease"
|
||||||
}
|
}
|
||||||
export enum ServerFeatureLevel {
|
export enum ServerFeatureLevel {
|
||||||
FeatureLevel = 1
|
FeatureLevel = 1,
|
||||||
|
Passkeys = 2
|
||||||
}
|
}
|
||||||
export enum UserMetadataKey {
|
export enum UserMetadataKey {
|
||||||
Preferences = "preferences",
|
Preferences = "preferences",
|
||||||
|
|||||||
Generated
+486
-447
File diff suppressed because it is too large
Load Diff
@@ -115,13 +115,3 @@ password: demo
|
|||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|||||||
@@ -115,16 +115,6 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## История на звездите
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Сътрудници
|
## Сътрудници
|
||||||
|
|
||||||
<a href="https://github.com/immich-app/immich/graphs/contributors">
|
<a href="https://github.com/immich-app/immich/graphs/contributors">
|
||||||
|
|||||||
@@ -118,16 +118,6 @@ Mehr zum Thema Übersetzungen kannst du [hier](https://docs.immich.app/developer
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Github Sterne
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Mitwirkende
|
## Mitwirkende
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
|||||||
@@ -110,16 +110,6 @@ Lea mas acerca de las traducciones [acá](https://docs.immich.app/developer/tran
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Historial de Estrellas
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Contribuidores
|
## Contribuidores
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
|||||||
@@ -111,13 +111,3 @@ mot de passe: demo
|
|||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Historique des favoris
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|||||||
@@ -117,16 +117,6 @@ Scopri di più sulle traduzioni [qui](https://docs.immich.app/developer/translat
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Cronologia delle stelle
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Grafico storico delle stelle" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Contributori
|
## Contributori
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
|||||||
@@ -115,16 +115,6 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 스타 기록
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="스타 기록 차트" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## 기여자
|
## 기여자
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
|||||||
@@ -117,16 +117,6 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## സ്റ്റാർ ചരിത്രം
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&type=date&legend=top-left">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## സംഭാവന ചെയ്തവർ
|
## സംഭാവന ചെയ്തവർ
|
||||||
|
|
||||||
<a href="https://github.com/immich-app/immich/graphs/contributors">
|
<a href="https://github.com/immich-app/immich/graphs/contributors">
|
||||||
|
|||||||
@@ -109,16 +109,6 @@ Je kunt [hier](https://docs.immich.app/developer/translations) meer over vertali
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Ster geschiedenis
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Contributie-leden
|
## Contributie-leden
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
|||||||
@@ -122,16 +122,6 @@ Leia mais sobre as traduções
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Histórico de estrelas
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Gráfico de histórico de estrelas" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Contribuidores
|
## Contribuidores
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
|||||||
@@ -117,16 +117,6 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## История звёзд
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Участники
|
## Участники
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
|||||||
@@ -113,13 +113,3 @@ lösenord: demo
|
|||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Stjärn-Historik
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|||||||
@@ -119,16 +119,6 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ประวัติการให้ดาว
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="แผนภูมิประวัติการให้ดาว" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## ผู้ร่วมพัฒนา
|
## ผู้ร่วมพัฒนา
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
|||||||
@@ -117,16 +117,6 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Історія зірок
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Автори
|
## Автори
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
|||||||
@@ -117,16 +117,6 @@ Truy cập bản demo [tại đây](https://demo.immich.app). Đối với ứng
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Lịch sử Đánh dấu sao
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Biểu đồ Lịch sử Đánh dấu" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Người đóng góp
|
## Người đóng góp
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
|||||||
@@ -118,16 +118,6 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Star 數量歷史紀錄
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
|
||||||
<img alt="Star 歷史紀錄圖表" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## 貢獻者
|
## 貢獻者
|
||||||
|
|
||||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM ghcr.io/immich-app/base-server-dev:202610211607@sha256:f99c605b44b291834cb680b80fd606f2b7c4bee5d8f9591bfdac51fdbd68c21d AS builder
|
FROM ghcr.io/immich-app/base-server-dev:202610291416@sha256:5f4f9f77059e1cd0182994419a19f4ee045c3fd48e611f5e77623ed63f7aa1e4 AS builder
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
COREPACK_HOME=/tmp \
|
COREPACK_HOME=/tmp \
|
||||||
@@ -86,7 +86,7 @@ RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \
|
|||||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||||
mise //:plugins
|
mise //:plugins
|
||||||
|
|
||||||
FROM ghcr.io/immich-app/base-server-prod:202610211607@sha256:e50a1b36e2638dce42981ddfa84c134246c48fb2ad290fab6acfc1bb9b2bd891
|
FROM ghcr.io/immich-app/base-server-prod:202610291416@sha256:6211bec52cbccc19076889b0c39ef473a1a1d02ceb635ccd2e0ecb4683e5fd4d
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user