Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c1f321627 | ||
|
|
e8c1fd297d | ||
|
|
8e3e3a48ae | ||
|
|
dc5553b239 | ||
|
|
32e02a05d2 | ||
|
|
c581bce715 | ||
|
|
dd23264385 | ||
|
|
6dbe72b6a1 | ||
|
|
72f96701f0 | ||
|
|
b4ae7f0dcf | ||
|
|
09bb2a9fb5 | ||
|
|
8f2bfa099e | ||
|
|
07083aec22 | ||
|
|
64d50dc4b7 | ||
|
|
7fb7699c06 | ||
|
|
3127dbec71 | ||
|
|
f418967d55 | ||
|
|
1f422ea4c4 | ||
|
|
8119a5ee22 | ||
|
|
54881128d6 | ||
|
|
d15be0dd83 | ||
|
|
47cc38061e | ||
|
|
db411a4497 | ||
|
|
f1a09c1389 | ||
|
|
c8222bba94 | ||
|
|
af66681b99 | ||
|
|
a93bbdf3e7 | ||
|
|
1707635743 | ||
|
|
7a6123f8e6 | ||
|
|
4960eddaf3 | ||
|
|
d35a6613e6 | ||
|
|
f96816b55f | ||
|
|
eca952720a | ||
|
|
c482c1e53e | ||
|
|
75a1d7c5c8 | ||
|
|
5630e541e3 | ||
|
|
27ccbee82d | ||
|
|
3458767d66 | ||
|
|
cd28300f94 | ||
|
|
d8030f3d14 | ||
|
|
0ff6470791 | ||
|
|
717152b191 | ||
|
|
06dbcf7c84 | ||
|
|
9767ea9c02 | ||
|
|
071b8db397 | ||
|
|
1c9813e130 | ||
|
|
6569aad605 | ||
|
|
a2794f0986 | ||
|
|
6ffc365b75 | ||
|
|
dc19d3a259 | ||
|
|
f2a502448d | ||
|
|
a3ffe3b3c3 | ||
|
|
8125af4859 | ||
|
|
e5b4dd1041 | ||
|
|
621504fcda | ||
|
|
919a4fd9ef | ||
|
|
2ed9207c3f | ||
|
|
c48394aa6e | ||
|
|
86c8b9e04d | ||
|
|
1190d0cc80 | ||
|
|
9669f7cc6e |
@@ -26,10 +26,17 @@ jobs:
|
||||
env:
|
||||
BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
BODY=$(printf '%s\n' "$BODY" | tr -d '\r' | perl -0pe 's/<!--.*?-->//gs')
|
||||
section() { printf '%s\n' "$BODY" | awk -v h="$1" 'index($0, h) == 1 { f = 1; next } /^## / { f = 0 } f'; }
|
||||
|
||||
OK=true
|
||||
while IFS= read -r header; do
|
||||
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
||||
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
||||
done < <(perl -0pe 's/<!--.*?-->//gs' .github/pull_request_template.md | grep "^## ")
|
||||
|
||||
section "## Checklist" | grep -qE '^\s*- \[[xX]\]' || OK=false
|
||||
[[ "$(section "## Please describe to which degree" | tr -d '[:space:]')" =~ ^(\.\.\.)?$ ]] && OK=false
|
||||
|
||||
echo "uses_template=$OK" | tee --append "$GITHUB_OUTPUT"
|
||||
|
||||
close_template:
|
||||
@@ -51,7 +58,7 @@ jobs:
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="This PR has been automatically closed as the description doesn't follow [our template](https://github.com/immich-app/immich/blob/main/.github/pull_request_template.md). After you edit it to match the template, the PR will automatically be reopened." \
|
||||
-f body="This PR has been automatically closed as the description doesn't follow [our template](https://github.com/immich-app/immich/blob/main/.github/pull_request_template.md). Please make sure every section is present and filled in. After you edit it to match the template, the PR will automatically be reopened." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
working_directory: ./mobile
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
|
||||
uses: ruby/setup-ruby@e1bd6146574da5e0795075b5319b300edb30c5b0 # v1.333.0
|
||||
with:
|
||||
ruby-version: '3.4'
|
||||
bundler-cache: true # zizmor: ignore[cache-poisoning] release branches cannot read caches written by pull requests
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@5e81b5c380accc6b523f9d32a637ca630e33620b # v0.1.17
|
||||
uses: oasdiff/oasdiff-action/breaking@9ce3a29eb0e6478dd0140932a678c7fb59d08f22 # v0.1.20
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -71,10 +71,10 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
# в„№пёЏ Command-line programs to run using the OS shell.
|
||||
# рџ“љ See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -35,6 +35,7 @@ jobs:
|
||||
filters: |
|
||||
docs:
|
||||
- 'docs/**'
|
||||
- 'deployment/**'
|
||||
open-api:
|
||||
- 'open-api/immich-openapi-specs.json'
|
||||
force-filters: |
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<a href="readme_i18n/README_uk_UA.md">Українська</a>
|
||||
<a href="readme_i18n/README_ru_RU.md">Русский</a>
|
||||
<a href="readme_i18n/README_bg_BG.md">Български</a>
|
||||
<a href="readme_i18n/README_pl_PL.md">Polski</a>
|
||||
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
|
||||
<a href="readme_i18n/README_sv_SE.md">Svenska</a>
|
||||
<a href="readme_i18n/README_ar_JO.md">العربية</a>
|
||||
|
||||
+42
-42
@@ -1,69 +1,69 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||
|
||||
[[tools.opentofu]]
|
||||
version = "1.13.1"
|
||||
version = "1.13.2"
|
||||
backend = "aqua:opentofu/opentofu"
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64"]
|
||||
checksum = "sha256:9c1ef375aa1852db0b2888aa921b640c71f8140d4682aa4fec99378a64fa7dc3"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708055"
|
||||
checksum = "sha256:88e2d9e3f94f97b76ab2faf55ade87578f56aed19c5e45fa139d70e994366d1f"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742799"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64"]
|
||||
checksum = "sha256:378ada19d4bc70c43732004e8159be771b23b9a5afdf059e5f8a2b3fa2c70a69"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708017"
|
||||
checksum = "sha256:ac05d9952662c7db28dd57e93f0a68018837bfdf9342384ec0987977c16a9645"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742636"
|
||||
|
||||
[tools.opentofu."platforms.macos-arm64"]
|
||||
checksum = "sha256:be78f659f04ef06a9dbd9b3934d46af95d787a3aa38396d459dea395261816a9"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708059"
|
||||
checksum = "sha256:0d1cdc85f38992055b48e7fb161304a2ddfe2e5ee0ff68ffec9d0349c8bd1b59"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742637"
|
||||
|
||||
[tools.opentofu."platforms.macos-x64"]
|
||||
checksum = "sha256:a73720443ba38712d7d96dc1e857add02c15a790919c653ad07492e9952f8c27"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708142"
|
||||
checksum = "sha256:dc6e4eb1d3ed6550c2ef052596f933dbcd8c806957665ea83ae7537147cb3074"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742982"
|
||||
|
||||
[tools.opentofu."platforms.windows-x64"]
|
||||
checksum = "sha256:6825ae311e9a15747ce2ede7b7e97c8bb744a4d271fa8113259eeeee3d09438c"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708102"
|
||||
checksum = "sha256:e74fc2bda0c81ce696b6b7016a4d5c1f557638161239e4101e9ee7d8e0faba59"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742635"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
version = "1.0.3"
|
||||
version = "1.2.0"
|
||||
backend = "aqua:gruntwork-io/terragrunt"
|
||||
|
||||
[tools.terragrunt."platforms.linux-arm64"]
|
||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
|
||||
|
||||
[tools.terragrunt."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
|
||||
checksum = "sha256:1e13b7f0646fa7962bc6c5bfba14a30d6a589325b82561e21b66be5981d86395"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437526"
|
||||
|
||||
[tools.terragrunt."platforms.linux-x64"]
|
||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
|
||||
|
||||
[tools.terragrunt."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
|
||||
checksum = "sha256:23b5f2f9203d34919f7fd270a4b635b721b4903beca9762a1e4853b159bc9be8"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437263"
|
||||
|
||||
[tools.terragrunt."platforms.macos-arm64"]
|
||||
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084"
|
||||
checksum = "sha256:79790d841ce71fe94c689fb6f5034afd31ad53d5904937c7a10934cca9d9734b"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436516"
|
||||
|
||||
[tools.terragrunt."platforms.macos-x64"]
|
||||
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970"
|
||||
checksum = "sha256:0b0e63e80509eb1ffb4f89b892bc6fbf7728f27f0a73974400f1bd82a80b8627"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436142"
|
||||
|
||||
[tools.terragrunt."platforms.windows-x64"]
|
||||
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412"
|
||||
checksum = "sha256:c9a146b9aad456a289614023ad5778123d819af3cf9efd1c6130f196cfcb1999"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_windows_amd64.exe.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662438255"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "1.1.1"
|
||||
opentofu = "1.13.1"
|
||||
terragrunt = "1.2.0"
|
||||
opentofu = "1.13.2"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
+18
-28
@@ -2,34 +2,24 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.9"
|
||||
constraints = "4.52.9"
|
||||
version = "5.28.1"
|
||||
constraints = "5.28.1"
|
||||
hashes = [
|
||||
"h1:LRBYMKjZyuld8kgVhzoceRtdtkht+w8LunrdpgqrL28=",
|
||||
"h1:Q2NeLtrjSTZwHHsMZXKpqOAbxF8DWgqpmH5j+1pUZGw=",
|
||||
"h1:Q4wiux11bDVWRmJScdjp8jLbxMRsj8k3xGtPtY4BPDo=",
|
||||
"h1:VVW111jEbRMTu7hXeGQMmZ/oSTB8LIYhjM5ztyymqAI=",
|
||||
"h1:Z7sTk8/B9XYFAEpTq1ib/WsB2QvpegGwef395tHCUSo=",
|
||||
"h1:eakzS6UsflV4gN/oQWdRPYvvmySKkhgKPyykCfPSVH0=",
|
||||
"h1:eesNMA1JUNBKCnuEvRLW2CT3tNrIejbXJG4A176nA9Q=",
|
||||
"h1:o8txgjqnDDODxQQ6ITwHsvPqHllCbMGnJkjCWNJK5ME=",
|
||||
"h1:rDTPIzab3tbGdtU3NmES08+tRlKG8jygNAFDFbheiuM=",
|
||||
"h1:sIoAt5vaxgFBrst1Esfm7ixQQ/T7/JNPZu0m4bw+Bc4=",
|
||||
"h1:sPCs3KnD/db5TMz2CNgiYix7sRS8+FP92T4ipSYT9vw=",
|
||||
"h1:uXPLsMQUYVJjRGUfmfDQXsKzYr1scvF6PYKj+3f0Nr4=",
|
||||
"h1:vQibC9lgBRQjRQ5KxFH61wd3/q2I40groLPjZ/jiikQ=",
|
||||
"zh:0826cad9b57368f06466ec3e1f766086f44a373288753e144f681ddc0e61ab49",
|
||||
"zh:15bf8d8abdcd202652466f3ef91110f143a7fad13c6173c7180e3dc0dbe9254b",
|
||||
"zh:1a60d1d2e55a17942902b1ccc91e8f6cb150c035843d3eefc074f1291a0e7d5f",
|
||||
"zh:23373a63fe06bbfa90c6c105c08429031719bedafa2458973cbdad32a384b5a7",
|
||||
"zh:256a97b79cedee2575002f77ac3bf18888ce0fc97170ae8a357db406bb0a88e3",
|
||||
"zh:3dd9f93c167cd59491ae77781032491d36a80843a1e44f9ee68b41b3f8e3ab8e",
|
||||
"zh:5182d2d61930dd83584d8feb56102614f80ddeec532b939b58085ad74b796a6e",
|
||||
"zh:5e3ca1888ba3b3bd8abd0f7dfcdd82fb5a8819affee899ed7fd891a33c17e0cb",
|
||||
"zh:7a3ce2119e39fb3a90a6a459a26863851309dbc92e0735336cbc98692539cb4d",
|
||||
"zh:c095dc680a646cc078c10bb0551e2ea8f08e7abdbcbc58cfd332c56b58788631",
|
||||
"zh:d2400e010e069c19916c99136e200cf873eba6db2b18d3eb9541b38eb1f565fa",
|
||||
"zh:dbc2f19f09aaefbd69334005bca2bcb0c9b690de1ed76f7ba97ce8e59a6e85df",
|
||||
"zh:e685225f432da3e23b8717848de8c0ad0e22363450b2ac92cd24a56739ab5e9b",
|
||||
"h1:9YL6g+iEfg3OzX6qSZOYsictVO0fNP09D6jRwIZKwQ0=",
|
||||
"h1:GjOKfCmvcyE06fUp5WrAzfCLkTQaCg2Ko/kFlq8OfpM=",
|
||||
"h1:QTIohIPsVCqEmknrR2PdbVXyK1fjS805rMaZpEEpzFQ=",
|
||||
"h1:SFb952CVUFUoFjfor+GUqAV6DQ6bnUfaFcWL2yJlFBo=",
|
||||
"h1:aso3s6IuIQju5L/OLrywCfQl7aA5TF+sb5pZzo8JG20=",
|
||||
"h1:cxGa9LRKCzwNacgnLjXnHUygkvAEd6pb8g8sRwzPSRE=",
|
||||
"h1:kBW0qcDqNupOnsi7vvx/0TQRxXLtmDKAHwOwH5l03pc=",
|
||||
"h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
|
||||
"zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
|
||||
"zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
|
||||
"zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
|
||||
"zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
|
||||
"zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
|
||||
"zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
|
||||
"zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
|
||||
"zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.9"
|
||||
version = "5.28.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-28
@@ -2,34 +2,24 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.52.9"
|
||||
constraints = "4.52.9"
|
||||
version = "5.28.1"
|
||||
constraints = "5.28.1"
|
||||
hashes = [
|
||||
"h1:LRBYMKjZyuld8kgVhzoceRtdtkht+w8LunrdpgqrL28=",
|
||||
"h1:Q2NeLtrjSTZwHHsMZXKpqOAbxF8DWgqpmH5j+1pUZGw=",
|
||||
"h1:Q4wiux11bDVWRmJScdjp8jLbxMRsj8k3xGtPtY4BPDo=",
|
||||
"h1:VVW111jEbRMTu7hXeGQMmZ/oSTB8LIYhjM5ztyymqAI=",
|
||||
"h1:Z7sTk8/B9XYFAEpTq1ib/WsB2QvpegGwef395tHCUSo=",
|
||||
"h1:eakzS6UsflV4gN/oQWdRPYvvmySKkhgKPyykCfPSVH0=",
|
||||
"h1:eesNMA1JUNBKCnuEvRLW2CT3tNrIejbXJG4A176nA9Q=",
|
||||
"h1:o8txgjqnDDODxQQ6ITwHsvPqHllCbMGnJkjCWNJK5ME=",
|
||||
"h1:rDTPIzab3tbGdtU3NmES08+tRlKG8jygNAFDFbheiuM=",
|
||||
"h1:sIoAt5vaxgFBrst1Esfm7ixQQ/T7/JNPZu0m4bw+Bc4=",
|
||||
"h1:sPCs3KnD/db5TMz2CNgiYix7sRS8+FP92T4ipSYT9vw=",
|
||||
"h1:uXPLsMQUYVJjRGUfmfDQXsKzYr1scvF6PYKj+3f0Nr4=",
|
||||
"h1:vQibC9lgBRQjRQ5KxFH61wd3/q2I40groLPjZ/jiikQ=",
|
||||
"zh:0826cad9b57368f06466ec3e1f766086f44a373288753e144f681ddc0e61ab49",
|
||||
"zh:15bf8d8abdcd202652466f3ef91110f143a7fad13c6173c7180e3dc0dbe9254b",
|
||||
"zh:1a60d1d2e55a17942902b1ccc91e8f6cb150c035843d3eefc074f1291a0e7d5f",
|
||||
"zh:23373a63fe06bbfa90c6c105c08429031719bedafa2458973cbdad32a384b5a7",
|
||||
"zh:256a97b79cedee2575002f77ac3bf18888ce0fc97170ae8a357db406bb0a88e3",
|
||||
"zh:3dd9f93c167cd59491ae77781032491d36a80843a1e44f9ee68b41b3f8e3ab8e",
|
||||
"zh:5182d2d61930dd83584d8feb56102614f80ddeec532b939b58085ad74b796a6e",
|
||||
"zh:5e3ca1888ba3b3bd8abd0f7dfcdd82fb5a8819affee899ed7fd891a33c17e0cb",
|
||||
"zh:7a3ce2119e39fb3a90a6a459a26863851309dbc92e0735336cbc98692539cb4d",
|
||||
"zh:c095dc680a646cc078c10bb0551e2ea8f08e7abdbcbc58cfd332c56b58788631",
|
||||
"zh:d2400e010e069c19916c99136e200cf873eba6db2b18d3eb9541b38eb1f565fa",
|
||||
"zh:dbc2f19f09aaefbd69334005bca2bcb0c9b690de1ed76f7ba97ce8e59a6e85df",
|
||||
"zh:e685225f432da3e23b8717848de8c0ad0e22363450b2ac92cd24a56739ab5e9b",
|
||||
"h1:9YL6g+iEfg3OzX6qSZOYsictVO0fNP09D6jRwIZKwQ0=",
|
||||
"h1:GjOKfCmvcyE06fUp5WrAzfCLkTQaCg2Ko/kFlq8OfpM=",
|
||||
"h1:QTIohIPsVCqEmknrR2PdbVXyK1fjS805rMaZpEEpzFQ=",
|
||||
"h1:SFb952CVUFUoFjfor+GUqAV6DQ6bnUfaFcWL2yJlFBo=",
|
||||
"h1:aso3s6IuIQju5L/OLrywCfQl7aA5TF+sb5pZzo8JG20=",
|
||||
"h1:cxGa9LRKCzwNacgnLjXnHUygkvAEd6pb8g8sRwzPSRE=",
|
||||
"h1:kBW0qcDqNupOnsi7vvx/0TQRxXLtmDKAHwOwH5l03pc=",
|
||||
"h1:t6x20B8TpQAG0TsUlyWLrAWNO+V8JE8K3Ij0hW0feSU=",
|
||||
"zh:219d464dd727c0e5c90a97b44aa5d7988acc4eec47f3684f7ecd006b17c46d9c",
|
||||
"zh:2511f2380209c81121e6eb943d7e68d03602a4fcf4f0311dfc67e198179b9303",
|
||||
"zh:2ae200482bf592cd703981c4d4fe87a52c0dfae81a70261295f59b1bb0d01a6c",
|
||||
"zh:7df6fe6573379c2faf2e801fdbca58ac909266dbee604feaa993439b8616c9b3",
|
||||
"zh:9fb5a295588351ce565515dc2a770047e8bab786a4a9665511ab9a9ca2db5f1b",
|
||||
"zh:a182f4aec86c74e9db4affa22323c5a21f5d92c1fcf91b934570753d04f4daf8",
|
||||
"zh:a39bb8624fb6c714e38bc0e632aae117a9fede5a18a248ab15dd1b60013c0324",
|
||||
"zh:bef2ffbdb68569549fe432b550592e07456759a7288f84f7c37c9d933a493f54",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.9"
|
||||
version = "5.28.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
container_name: immich_prometheus
|
||||
ports:
|
||||
- 9090:9090
|
||||
image: prom/prometheus@sha256:5ce7540c3c00ef4ab0c9d2c995c6a5b9c421f44b4a115d97a2c7af3b1c21cbb0
|
||||
image: prom/prometheus@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
|
||||
@@ -85,7 +85,9 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
||||
- RKNPU driver V0.9.8 or later must be available in the host server
|
||||
- You may confirm this by running `cat /sys/kernel/debug/rknpu/version` to check the version
|
||||
- Optional: Configure your `.env` file, see [environment variables](/install/environment-variables) for RKNN specific settings
|
||||
- In particular, setting `MACHINE_LEARNING_RKNN_THREADS` to 2 or 3 can _dramatically_ improve performance for RK3576 and RK3588 compared to the default of 1, at the expense of multiplying the amount of RAM each model uses by that amount.
|
||||
- By default each model gets one context per NPU core (1 on RK3566/RK3568, 2 on RK3576, 3 on RK3588), so concurrent requests run on all cores at once. The contexts of a model share its weights, but each one needs its own working memory. Extra contexts therefore cost little for models that are mostly weights, such as CLIP, but for OCR detection each one can add close to the model's own memory again, more so at higher OCR resolutions. Set `MACHINE_LEARNING_RKNN_THREADS=1` to trade throughput for memory.
|
||||
- Keep `MACHINE_LEARNING_WORKERS=1` unless you have a specific reason not to: each worker process still loads its own copy of the weights.
|
||||
- Raise Immich job concurrency (Smart Search, Face Detection, OCR) a bit above the number of NPU cores, e.g. `4`–`6`, so a prepared image is always waiting when a core frees up.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -100,7 +102,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
||||
|
||||
You can confirm the device is being recognized and used by checking its utilization. There are many tools to display this, such as `nvtop` for NVIDIA or Intel, `intel_gpu_top` for Intel, and `radeontop` for AMD.
|
||||
|
||||
You can also check the logs of the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, or when you search with text in Immich, you should either see a log for `Available ORT providers` containing the relevant provider (e.g. `CUDAExecutionProvider` in the case of CUDA), or a `Loaded ANN model` log entry without errors in the case of ARM NN.
|
||||
You can also check the logs of the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, or when you search with text in Immich, you should either see a log for `Available ORT providers` containing the relevant provider (e.g. `CUDAExecutionProvider` in the case of CUDA), a `Loaded ANN model` log entry without errors in the case of ARM NN, or a `Loaded RKNN model ... threads sharing its weights` log entry for RKNN.
|
||||
|
||||
#### Single Compose File
|
||||
|
||||
@@ -174,7 +176,7 @@ Note that you should increase job concurrencies to increase overall utilization
|
||||
- Less heat generation
|
||||
- Very slightly lower accuracy (RKNPU always uses FP16, while ARM NN by default uses higher precision FP32 unless `MACHINE_LEARNING_ANN_FP16_TURBO` is enabled)
|
||||
- Varying speed (tested on RK3588):
|
||||
- If `MACHINE_LEARNING_RKNN_THREADS` is at the default of 1, RKNPU will have substantially lower throughput for ML jobs than ARM NN in most cases, but similar latency (such as when searching)
|
||||
- If `MACHINE_LEARNING_RKNN_THREADS` is set to 3, it will be somewhat faster than ARM NN at FP32, but somewhat slower than ARM NN if `MACHINE_LEARNING_ANN_FP16_TURBO` is enabled
|
||||
- With a single context (`MACHINE_LEARNING_RKNN_THREADS=1`), RKNPU will have substantially lower throughput for ML jobs than ARM NN in most cases, but similar latency (such as when searching)
|
||||
- With three contexts (the default on RK3588), it will be somewhat faster than ARM NN at FP32, but somewhat slower than ARM NN if `MACHINE_LEARNING_ANN_FP16_TURBO` is enabled
|
||||
- When other tasks also use the GPU (like transcoding), RKNPU has a significant advantage over ARM NN as it uses the otherwise idle NPU instead of competing for GPU usage
|
||||
- Lower RAM usage if `MACHINE_LEARNING_RKNN_THREADS` is at the default of 1, but significantly higher if greater than 1 (which is necessary for it to fully utilize the NPU and hence be comparable in speed to ARM NN)
|
||||
- Lower RAM usage than earlier RKNN builds: the contexts of a model share its weights instead of each loading their own copy. Keep `MACHINE_LEARNING_WORKERS=1` and raise Immich ML job concurrency a bit above the core count so every NPU core stays busy.
|
||||
|
||||
@@ -179,7 +179,7 @@ Redis (Sentinel) URL example JSON before encoding:
|
||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | `4` | machine learning |
|
||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
|
||||
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
|
||||
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_RKNN_THREADS` | Number of RKNN contexts per model, which share its weights | number of NPU cores | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_REVISION` | Model revision to download; `main` selects the original exports and `v2` selects the newer exports | `main` | machine learning |
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||
|
||||
[[tools.wrangler]]
|
||||
version = "4.151.0"
|
||||
version = "4.152.0"
|
||||
backend = "npm:wrangler"
|
||||
|
||||
[tools.wrangler.options]
|
||||
|
||||
+1
-1
@@ -28,4 +28,4 @@ run = "prettier --write ."
|
||||
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
|
||||
|
||||
[tools]
|
||||
wrangler = "4.151.0"
|
||||
wrangler = "4.152.0"
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^75.0.0",
|
||||
"eslint-plugin-unicorn": "^78.0.0",
|
||||
"exiftool-vendored": "^39.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"luxon": "^3.4.4",
|
||||
|
||||
@@ -628,25 +628,19 @@ describe('/albums', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not be able to remove foreign asset from foreign album', async () => {
|
||||
it('should be able to remove foreign asset from shared album as an editor', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/albums/${user1Albums[0].id}/assets`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({
|
||||
id: user1Asset1.id,
|
||||
success: false,
|
||||
error: 'no_permission',
|
||||
}),
|
||||
]);
|
||||
expect(body).toEqual([expect.objectContaining({ id: user1Asset1.id, success: true })]);
|
||||
});
|
||||
|
||||
it('should be able to remove own asset from own album', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/albums/${user1Albums[0].id}/assets`)
|
||||
.delete(`/albums/${user1Albums[2].id}/assets`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
|
||||
@@ -355,7 +355,7 @@ describe('/asset', () => {
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ livePhotoVideoId: asset.id });
|
||||
|
||||
expect(body).toEqual(errorDto.badRequest('Live photo video does not belong to the user'));
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no asset.update access'));
|
||||
expect(status).toEqual(400);
|
||||
});
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ test.describe('Shared Links', () => {
|
||||
|
||||
test('show error for invalid shared link', async ({ page }) => {
|
||||
await page.goto('/share/invalid');
|
||||
await page.getByRole('heading', { name: 'Invalid share key' }).waitFor();
|
||||
await page.getByRole('heading', { name: 'Page not found' }).waitFor();
|
||||
});
|
||||
|
||||
test('auth on navigation from shared link to timeline', async ({ context, page }) => {
|
||||
|
||||
@@ -14,9 +14,7 @@ type DayDistributionStrategy = (
|
||||
rng: SeededRandom,
|
||||
) => MockTimelineAsset[];
|
||||
|
||||
/**
|
||||
* Strategies for determining total asset count per month
|
||||
*/
|
||||
/** Strategies for determining total asset count per month */
|
||||
export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = {
|
||||
empty: null, // Special case - handled separately
|
||||
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
|
||||
@@ -25,9 +23,7 @@ export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStra
|
||||
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
|
||||
};
|
||||
|
||||
/**
|
||||
* Strategies for distributing assets across days within a month
|
||||
*/
|
||||
/** Strategies for distributing assets across days within a month */
|
||||
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
|
||||
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
|
||||
// All assets on one day in the middle of the month
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/**
|
||||
* Generator functions for timeline model objects
|
||||
*/
|
||||
/** Generator functions for timeline model objects */
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
@@ -12,9 +10,7 @@ import { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './distribution-patterns';
|
||||
import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
|
||||
import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config';
|
||||
|
||||
/**
|
||||
* Generate a random aspect ratio based on weighted probabilities
|
||||
*/
|
||||
/** Generate a random aspect ratio based on weighted probabilities */
|
||||
export function generateAspectRatio(rng: SeededRandom): string {
|
||||
const random = rng.next();
|
||||
let cumulative = 0;
|
||||
@@ -89,9 +85,7 @@ export function generateAsset(
|
||||
return asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate assets for a specific day
|
||||
*/
|
||||
/** Generate assets for a specific day */
|
||||
export function generateDayAssets(
|
||||
year: number,
|
||||
month: number,
|
||||
@@ -104,10 +98,10 @@ export function generateDayAssets(
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute assets evenly across consecutive days
|
||||
*
|
||||
* @returns Array of generated timeline assets
|
||||
*/
|
||||
Distribute assets evenly across consecutive days
|
||||
|
||||
@returns Array of generated timeline assets
|
||||
*/
|
||||
export function generateConsecutiveDays(
|
||||
year: number,
|
||||
month: number,
|
||||
@@ -133,9 +127,7 @@ export function generateConsecutiveDays(
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate assets for a month with specified distribution pattern
|
||||
*/
|
||||
/** Generate assets for a month with specified distribution pattern */
|
||||
export function generateMonthAssets(
|
||||
year: number,
|
||||
month: number,
|
||||
@@ -173,9 +165,7 @@ export function generateMonthAssets(
|
||||
return assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main generator function for timeline data
|
||||
*/
|
||||
/** Main generator function for timeline data */
|
||||
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
|
||||
validateTimelineConfig(config);
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/**
|
||||
* REST API output functions for converting timeline data to API response formats
|
||||
*/
|
||||
/** REST API output functions for converting timeline data to API response formats */
|
||||
|
||||
import {
|
||||
AlbumUserRole,
|
||||
@@ -19,9 +17,7 @@ import { signupDto } from 'src/fixtures.js';
|
||||
import { parseTimeBucketKey } from 'src/ui/generators/timeline/utils.js';
|
||||
import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
|
||||
|
||||
/**
|
||||
* Convert timeline/asset models to columnar format (parallel arrays)
|
||||
*/
|
||||
/** Convert timeline/asset models to columnar format (parallel arrays) */
|
||||
export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
|
||||
const result: TimeBucketAssetResponseDto = {
|
||||
id: [],
|
||||
@@ -70,11 +66,11 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single bucket from timeline data (mimics getTimeBucket API)
|
||||
* Automatically handles both ISO timestamp and simple month formats
|
||||
* Returns data in columnar format matching the actual API
|
||||
* When albumId is provided, only returns assets from that album
|
||||
*/
|
||||
Extract a single bucket from timeline data (mimics getTimeBucket API)
|
||||
Automatically handles both ISO timestamp and simple month formats
|
||||
Returns data in columnar format matching the actual API
|
||||
When albumId is provided, only returns assets from that album
|
||||
*/
|
||||
export function getTimeBucket(
|
||||
timelineData: MockTimelineData,
|
||||
timeBucket: string,
|
||||
@@ -141,16 +137,16 @@ export type Changes = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to determine if an asset should be included based on filter criteria
|
||||
* @param asset - The asset to check
|
||||
* @param isTrashed - Filter for trashed status (undefined means no filter)
|
||||
* @param isArchived - Filter for archived status (undefined means no filter)
|
||||
* @param isFavorite - Filter for favorite status (undefined means no filter)
|
||||
* @param deletedAssetIds - Set of IDs for assets that have been deleted
|
||||
* @param archivedAssetIds - Set of IDs for assets that have been archived
|
||||
* @param favoritedAssetIds - Set of IDs for assets that have been favorited
|
||||
* @returns true if the asset matches all filter criteria
|
||||
*/
|
||||
Helper function to determine if an asset should be included based on filter criteria
|
||||
@param asset - The asset to check
|
||||
@param isTrashed - Filter for trashed status (undefined means no filter)
|
||||
@param isArchived - Filter for archived status (undefined means no filter)
|
||||
@param isFavorite - Filter for favorite status (undefined means no filter)
|
||||
@param deletedAssetIds - Set of IDs for assets that have been deleted
|
||||
@param archivedAssetIds - Set of IDs for assets that have been archived
|
||||
@param favoritedAssetIds - Set of IDs for assets that have been favorited
|
||||
@returns true if the asset matches all filter criteria
|
||||
*/
|
||||
function shouldIncludeAsset(
|
||||
asset: MockTimelineAsset,
|
||||
isTrashed: boolean | undefined,
|
||||
@@ -175,9 +171,9 @@ function shouldIncludeAsset(
|
||||
return isFavorite === undefined || actuallyFavorited === isFavorite;
|
||||
}
|
||||
/**
|
||||
* Get summary for all buckets (mimics getTimeBuckets API)
|
||||
* When albumId is provided, only includes buckets that contain assets from that album
|
||||
*/
|
||||
Get summary for all buckets (mimics getTimeBuckets API)
|
||||
When albumId is provided, only includes buckets that contain assets from that album
|
||||
*/
|
||||
export function getTimeBuckets(
|
||||
timelineData: MockTimelineData,
|
||||
isTrashed: boolean | undefined,
|
||||
@@ -279,9 +275,9 @@ const createDefaultOwner = (ownerId: string) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||
* This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||
This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
@@ -292,7 +288,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
||||
make: null,
|
||||
model: null,
|
||||
exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
|
||||
exifImageHeight: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(3000 * asset.ratio),
|
||||
exifImageHeight: Math.round(asset.ratio > 1 ? 4000 : 3000 / asset.ratio),
|
||||
fileSizeInByte: asset.fileSizeInByte,
|
||||
orientation: '1',
|
||||
dateTimeOriginal: asset.fileCreatedAt,
|
||||
@@ -348,9 +344,9 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single asset by ID from timeline data
|
||||
* This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
Get a single asset by ID from timeline data
|
||||
This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
export function getAsset(
|
||||
timelineData: MockTimelineData,
|
||||
assetId: string,
|
||||
@@ -368,9 +364,9 @@ export function getAsset(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mock album from timeline data
|
||||
* This matches the response from GET /api/albums/:id
|
||||
*/
|
||||
Get a mock album from timeline data
|
||||
This matches the response from GET /api/albums/:id
|
||||
*/
|
||||
export function getAlbum(
|
||||
timelineData: MockTimelineData,
|
||||
ownerId: string,
|
||||
|
||||
@@ -57,9 +57,7 @@ export type MonthSpec = {
|
||||
pattern: DayPattern;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for timeline data generation
|
||||
*/
|
||||
/** Configuration for timeline data generation */
|
||||
export type TimelineConfig = {
|
||||
ownerId?: string;
|
||||
months: MonthSpec[];
|
||||
@@ -88,9 +86,7 @@ export type SerializedTimelineData = {
|
||||
album: MockAlbum;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a TimelineConfig object to ensure all values are within expected ranges
|
||||
*/
|
||||
/** Validates a TimelineConfig object to ensure all values are within expected ranges */
|
||||
export function validateTimelineConfig(config: TimelineConfig): void {
|
||||
if (!config.months || config.months.length === 0) {
|
||||
throw new Error('TimelineConfig must contain at least one month');
|
||||
@@ -146,9 +142,7 @@ export function validateTimelineConfig(config: TimelineConfig): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default timeline configuration
|
||||
*/
|
||||
/** Create a default timeline configuration */
|
||||
export function createDefaultTimelineConfig(): TimelineConfig {
|
||||
const months: MonthSpec[] = [
|
||||
// 2024 - Mix of patterns
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config.js';
|
||||
|
||||
/**
|
||||
* Linear Congruential Generator for deterministic pseudo-random numbers
|
||||
*/
|
||||
/** Linear Congruential Generator for deterministic pseudo-random numbers */
|
||||
export class SeededRandom {
|
||||
private seed: number;
|
||||
|
||||
@@ -11,38 +9,32 @@ export class SeededRandom {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next random number in range [0, 1)
|
||||
*/
|
||||
/** Generate next random number in range [0, 1) */
|
||||
next(): number {
|
||||
// LCG parameters from Numerical Recipes
|
||||
this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
|
||||
return this.seed / 2_147_483_647;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random integer in range [min, max)
|
||||
*/
|
||||
/** Generate random integer in range [min, max) */
|
||||
nextInt(min: number, max: number): number {
|
||||
return Math.floor(this.next() * (max - min)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random boolean with given probability
|
||||
*/
|
||||
/** Generate random boolean with given probability */
|
||||
nextBoolean(probability = 0.5): boolean {
|
||||
return this.next() < probability;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select random days using seed variation to avoid collisions.
|
||||
*
|
||||
* @param daysInMonth - Total number of days in the month
|
||||
* @param numDays - Number of days to select
|
||||
* @param rng - Random number generator instance
|
||||
* @returns Array of selected day numbers, sorted in descending order
|
||||
*/
|
||||
Select random days using seed variation to avoid collisions.
|
||||
|
||||
@param daysInMonth - Total number of days in the month
|
||||
@param numDays - Number of days to select
|
||||
@param rng - Random number generator instance
|
||||
@returns Array of selected day numbers, sorted in descending order
|
||||
*/
|
||||
export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
|
||||
const selectedDays = new Set<number>();
|
||||
const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit
|
||||
@@ -64,9 +56,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
|
||||
return [...selectedDays].toSorted((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select item from array using seeded random
|
||||
*/
|
||||
/** Select item from array using seeded random */
|
||||
export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
|
||||
if (arr.length === 0) {
|
||||
throw new Error('Cannot select from empty array');
|
||||
@@ -75,9 +65,7 @@ export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
|
||||
return arr[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select multiple random items from array using seeded random without duplicates
|
||||
*/
|
||||
/** Select multiple random items from array using seeded random without duplicates */
|
||||
export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] {
|
||||
if (arr.length === 0) {
|
||||
throw new Error('Cannot select from empty array');
|
||||
@@ -104,11 +92,11 @@ export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRand
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse timeBucket parameter to extract year-month key
|
||||
* Handles both formats:
|
||||
* - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
|
||||
* - Simple format: "2024-12-01" -> "2024-12-01"
|
||||
*/
|
||||
Parse timeBucket parameter to extract year-month key
|
||||
Handles both formats:
|
||||
- ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
|
||||
- Simple format: "2024-12-01" -> "2024-12-01"
|
||||
*/
|
||||
export function parseTimeBucketKey(timeBucket: string): string {
|
||||
if (!timeBucket) {
|
||||
throw new Error('timeBucket parameter cannot be empty');
|
||||
@@ -150,6 +138,7 @@ export function getMockAsset(
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/no-unnecessary-parameters
|
||||
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
|
||||
if (unit === 'day') {
|
||||
return !date1.startOf('day').equals(date2.startOf('day'));
|
||||
|
||||
@@ -100,7 +100,7 @@ test.describe('OCR with stacked assets', () => {
|
||||
|
||||
const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
|
||||
await expect(stackThumbnails).toHaveCount(2);
|
||||
await stackThumbnails.nth(1).click();
|
||||
await page.locator(`#stack-slideshow [data-asset="${secondAssetDto.id}"]`).click();
|
||||
|
||||
// refreshOcr() clears showOverlay when switching assets, so re-enable it
|
||||
await expect(ocrBoxes).toHaveCount(0);
|
||||
@@ -242,6 +242,7 @@ test.describe('OCR text interaction', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/no-unnecessary-parameters
|
||||
const createTouch = (id: number, x: number, y: number) => {
|
||||
return new Touch({
|
||||
identifier: id,
|
||||
|
||||
@@ -75,8 +75,8 @@ test.describe('asset-viewer stack', () => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||
await stackAssets.nth(1).click();
|
||||
const secondAsset = page.locator(`#stack-slideshow [data-asset="${secondAssetDto.id}"]`);
|
||||
await secondAsset.click();
|
||||
|
||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||
await expect(tags.first()).toHaveText('test/2');
|
||||
|
||||
@@ -150,10 +150,10 @@ test.describe('Timeline', () => {
|
||||
});
|
||||
test.describe('keyboard', () => {
|
||||
/**
|
||||
* This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
|
||||
* scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
|
||||
* as necessary downwards), then the asset should always be at the lowest row of the grid.
|
||||
*/
|
||||
This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
|
||||
scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
|
||||
as necessary downwards), then the asset should always be at the lowest row of the grid.
|
||||
*/
|
||||
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
|
||||
await pageUtils.openPhotosPage(page);
|
||||
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
|
||||
|
||||
+5
-5
@@ -270,13 +270,13 @@ export const utils = {
|
||||
}
|
||||
},
|
||||
|
||||
waitForWebsocketEvent: ({ event, id, total: count, timeout: ms }: WaitOptions): Promise<void> => {
|
||||
waitForWebsocketEvent: ({ event, id, total: count, timeout: ms = 10_000 }: WaitOptions): Promise<void> => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!id && !count) {
|
||||
reject(new Error('id or count must be provided for waitForWebsocketEvent'));
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000);
|
||||
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms);
|
||||
const type = id ? `id=${id}` : `count=${count}`;
|
||||
console.log(`Waiting for ${event} [${type}]`);
|
||||
const set = events[event];
|
||||
@@ -550,7 +550,7 @@ export const utils = {
|
||||
{
|
||||
headers: asBearerAuth(accessToken),
|
||||
fetch: (...args: Parameters<typeof fetch>) =>
|
||||
// eslint-disable-next-line unicorn/no-invalid-argument-count, unicorn/prefer-await
|
||||
// eslint-disable-next-line unicorn/prefer-await
|
||||
fetch(...args).then((response) => {
|
||||
setCookie = response.headers.getSetCookie();
|
||||
return response;
|
||||
@@ -660,10 +660,10 @@ export const utils = {
|
||||
return !jobCounts.active && (!jobCounts.waiting || queues[queue].queueStatus.isPaused);
|
||||
},
|
||||
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
|
||||
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms: number = 15_000) => {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 15_000);
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms);
|
||||
|
||||
while (true) {
|
||||
const done = await utils.isQueueEmpty(accessToken, queue);
|
||||
|
||||
+3
-3
@@ -87,7 +87,7 @@
|
||||
"failed_job_command": "Command {command} failed for job: {job}",
|
||||
"force_delete_user_warning": "WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be recovered.",
|
||||
"image_format": "Format",
|
||||
"image_format_description": "WebP produces smaller files than JPEG, but is slower to encode.",
|
||||
"image_format_description": "WebP produces smaller files than JPEG, but is slower to encode. JPEG XL is the best option for high-quality compression, but not all browsers and apps can display it.",
|
||||
"image_fullsize_description": "Full-size image with stripped metadata, used when zoomed in",
|
||||
"image_fullsize_enabled": "Enable full-size image generation",
|
||||
"image_fullsize_enabled_description": "Generate full-size image for non-web-friendly formats. When \"Prefer embedded preview\" is enabled, embedded previews are used directly without conversion. Does not affect web-friendly formats like JPEG.",
|
||||
@@ -101,7 +101,7 @@
|
||||
"image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.",
|
||||
"image_preview_title": "Preview Settings",
|
||||
"image_progressive": "Progressive",
|
||||
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.",
|
||||
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on other formats.",
|
||||
"image_quality": "Quality",
|
||||
"image_resolution": "Resolution",
|
||||
"image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",
|
||||
@@ -2338,7 +2338,7 @@
|
||||
"user": "User",
|
||||
"user_has_been_deleted": "This user has been deleted.",
|
||||
"user_id": "User ID",
|
||||
"user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}",
|
||||
"user_liked": "{user} liked {type, select, image {this photo} video {this video} other {this}}",
|
||||
"user_pin_code_settings": "PIN Code",
|
||||
"user_pin_code_settings_description": "Manage your PIN code",
|
||||
"user_privacy": "User Privacy",
|
||||
|
||||
@@ -70,7 +70,7 @@ class Settings(BaseSettings):
|
||||
ann_fp16_turbo: bool = False
|
||||
ann_tuning_level: int = 2
|
||||
rknn: bool = True
|
||||
rknn_threads: int = 1
|
||||
rknn_threads: int | None = None
|
||||
preload: PreloadModelData | None = None
|
||||
max_batch_size: MaxBatchSize = MaxBatchSize()
|
||||
model_organization: ModelOrganization = ModelOrganization.APP
|
||||
|
||||
@@ -100,8 +100,7 @@ SUPPORTED_PROVIDERS = [
|
||||
"CPUExecutionProvider",
|
||||
]
|
||||
|
||||
RKNN_SUPPORTED_SOCS = ["rk3566", "rk3568", "rk3576", "rk3588"]
|
||||
RKNN_COREMASK_SUPPORTED_SOCS = ["rk3576", "rk3588"]
|
||||
RKNN_NPU_CORES = {"rk3566": 1, "rk3568": 1, "rk3576": 2, "rk3588": 3}
|
||||
|
||||
|
||||
WEBLATE_TO_FLORES200 = {
|
||||
|
||||
@@ -7,10 +7,14 @@ import numpy as np
|
||||
import orjson
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image
|
||||
from pillow_jxl import JpegXLImagePlugin
|
||||
|
||||
_PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in Image.Resampling}
|
||||
_PUNCTUATION_TRANS = str.maketrans("", "", string.punctuation)
|
||||
|
||||
# Requests are already decoded in parallel, so don't start a thread per core for each one
|
||||
JpegXLImagePlugin.DECODE_THREADS = 0
|
||||
|
||||
|
||||
def resize_pil(img: Image.Image, size: int, resample: Image.Resampling = Image.Resampling.BICUBIC) -> Image.Image:
|
||||
if img.width < img.height:
|
||||
|
||||
@@ -9,9 +9,10 @@ import orjson
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from immich_ml.config import log, settings
|
||||
from immich_ml.models.constants import RKNN_NPU_CORES
|
||||
from immich_ml.schemas import ModelInput, ModelTensor, SessionNode, Shape
|
||||
|
||||
from .rknnpool import RknnNode, RknnPoolExecutor, is_available, native_outputs, soc_name
|
||||
from .rknnpool import RknnNode, RknnPoolExecutor, is_available, soc_name
|
||||
|
||||
is_available = is_available and settings.rknn
|
||||
model_prefix = Path("rknpu") / soc_name if is_available and soc_name is not None else None
|
||||
@@ -21,12 +22,6 @@ def model_path(model_dir: Path, variant: str = "") -> Path:
|
||||
return (model_dir / model_prefix if model_prefix else model_dir) / variant / "model.rknn"
|
||||
|
||||
|
||||
def run_inference(rknn_lite: Any, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[np.float32]]:
|
||||
rknn_lite.rknn_runtime.set_inputs(inputs, None, data_format)
|
||||
rknn_lite.rknn_runtime.run(False)
|
||||
return native_outputs(rknn_lite)
|
||||
|
||||
|
||||
def input_layout(compiled: tuple[int, ...], array: ModelTensor) -> str | None:
|
||||
if array.ndim != 4:
|
||||
return None # token ids carry no layout
|
||||
@@ -39,11 +34,11 @@ def input_layout(compiled: tuple[int, ...], array: ModelTensor) -> str | None:
|
||||
|
||||
class RknnSession:
|
||||
def __init__(self, model_path: Path) -> None:
|
||||
self.tpe = settings.rknn_threads
|
||||
self.tpe = settings.rknn_threads or RKNN_NPU_CORES.get(soc_name or "", 1)
|
||||
|
||||
log.info(f"Loading RKNN model from {model_path} with {self.tpe} threads.")
|
||||
self.rknnpool = RknnPoolExecutor(model_path=model_path.as_posix(), tpes=self.tpe, func=run_inference)
|
||||
log.info(f"Loaded RKNN model from {model_path} with {self.tpe} threads.")
|
||||
self.rknnpool = RknnPoolExecutor(model_path=model_path.as_posix(), tpes=self.tpe)
|
||||
log.info(f"Loaded RKNN model from {model_path} with {self.tpe} threads sharing its weights.")
|
||||
# the shapes the binary was compiled for, among which the runtime routes itself
|
||||
batch = self.rknnpool.inputs[0].shape[0]
|
||||
declared = orjson.loads(self.rknnpool.custom_string) if self.rknnpool.custom_string else {}
|
||||
@@ -77,7 +72,7 @@ class RknnSession:
|
||||
input_feed: ModelInput,
|
||||
run_options: Any = None,
|
||||
) -> list[NDArray[np.float32]]:
|
||||
inputs = [array if array.flags.c_contiguous else array.copy() for array in input_feed.values()]
|
||||
inputs = list(input_feed.values())
|
||||
return self.rknnpool.run(inputs, input_layout(self.rknnpool.inputs[0].shape, inputs[0]))
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
# This code is from leafqycc/rknn-multi-threaded
|
||||
# Following Apache License 2.0
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from ctypes import byref, sizeof
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, NamedTuple
|
||||
from queue import SimpleQueue
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from immich_ml.config import log
|
||||
from immich_ml.models.constants import RKNN_COREMASK_SUPPORTED_SOCS, RKNN_SUPPORTED_SOCS
|
||||
from immich_ml.models.constants import RKNN_NPU_CORES
|
||||
from immich_ml.schemas import ModelTensor
|
||||
|
||||
|
||||
@@ -20,7 +17,7 @@ def get_soc(device_tree_path: Path | str) -> str | None:
|
||||
try:
|
||||
with Path(device_tree_path).open() as f:
|
||||
device_compatible_str = f.read()
|
||||
for soc in RKNN_SUPPORTED_SOCS:
|
||||
for soc in RKNN_NPU_CORES:
|
||||
if soc in device_compatible_str:
|
||||
return soc
|
||||
log.warning("Device is not supported for RKNN")
|
||||
@@ -29,26 +26,34 @@ def get_soc(device_tree_path: Path | str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
soc_name = None
|
||||
is_available = False
|
||||
try:
|
||||
from rknnlite.api import RKNNLite, rknn_runtime
|
||||
|
||||
soc_name = get_soc("/proc/device-tree/compatible")
|
||||
is_available = soc_name is not None
|
||||
except ImportError:
|
||||
log.debug("RKNN is not available")
|
||||
|
||||
|
||||
class RknnNode(NamedTuple):
|
||||
name: str
|
||||
shape: tuple[int, ...]
|
||||
|
||||
|
||||
_CUSTOM_STRING_QUERY = 7 # RKNN_QUERY_CUSTOM_STRING
|
||||
# RKNN_QUERY_OUTPUT_ATTR, and RKNN_QUERY_CURRENT_OUTPUT_ATTR for the shape a dynamic binary last produced
|
||||
_OUTPUT_QUERY, _CURRENT_OUTPUT_QUERY = 2, 15
|
||||
_DTYPES = {0: np.float32, 1: np.float16} # rknn_tensor_type
|
||||
# rknn_query_cmd
|
||||
_IN_OUT_NUM, _INPUT_ATTR, _OUTPUT_ATTR, _CUSTOM_STRING = 0, 1, 2, 7
|
||||
_INPUT_DYNAMIC_RANGE, _CURRENT_OUTPUT_ATTR = 13, 15
|
||||
# rknn_tensor_format
|
||||
_NCHW, _NHWC = 0, 1
|
||||
# rknn_tensor_type, in enum order
|
||||
_DTYPES: list[type[np.generic]] = [
|
||||
np.float32,
|
||||
np.float16,
|
||||
np.int8,
|
||||
np.uint8,
|
||||
np.int16,
|
||||
np.uint16,
|
||||
np.int32,
|
||||
np.uint32,
|
||||
np.int64,
|
||||
np.bool_,
|
||||
]
|
||||
_TYPES = {np.dtype(dtype): index for index, dtype in enumerate(_DTYPES)}
|
||||
|
||||
|
||||
class _IoNum(ctypes.Structure):
|
||||
_fields_ = [("n_input", ctypes.c_uint32), ("n_output", ctypes.c_uint32)]
|
||||
|
||||
|
||||
class _CustomString(ctypes.Structure):
|
||||
@@ -76,94 +81,194 @@ class _TensorAttr(ctypes.Structure): # rknn_tensor_attr
|
||||
]
|
||||
|
||||
|
||||
def native_outputs(rknn_lite: "RKNNLite") -> list[NDArray[Any]]:
|
||||
"""The outputs in the precision the NPU computed them in, which RKNNLite would widen to fp32 on the CPU."""
|
||||
runtime: Any = rknn_lite.rknn_runtime
|
||||
count = runtime.get_in_out_num()[1]
|
||||
buffers = (rknn_runtime.RKNNOutput * count)()
|
||||
for index, buffer in enumerate(buffers):
|
||||
buffer.index = index
|
||||
if runtime.lib.rknn_outputs_get(runtime.context, count, buffers, None) != 0:
|
||||
raise RuntimeError("RKNN inference failed!")
|
||||
query = _CURRENT_OUTPUT_QUERY if runtime.is_dynamic_shape else _OUTPUT_QUERY
|
||||
outputs = []
|
||||
for buffer in buffers:
|
||||
attr = _TensorAttr(index=buffer.index)
|
||||
runtime.lib.rknn_query(runtime.context, query, ctypes.byref(attr), ctypes.sizeof(attr))
|
||||
data = ctypes.cast(buffer.buf, ctypes.POINTER(ctypes.c_char * buffer.size)).contents
|
||||
outputs.append(np.frombuffer(data, _DTYPES[attr.type]).reshape(attr.dims[: attr.n_dims]).copy())
|
||||
runtime.lib.rknn_outputs_release(runtime.context, count, buffers)
|
||||
return outputs
|
||||
class _InputRange(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("index", ctypes.c_uint32),
|
||||
("shape_number", ctypes.c_uint32),
|
||||
("fmt", ctypes.c_int),
|
||||
("name", ctypes.c_char * 256),
|
||||
("dyn_range", (ctypes.c_uint32 * 16) * 512),
|
||||
("n_dims", ctypes.c_uint32),
|
||||
]
|
||||
|
||||
|
||||
def tensor_nodes(rknn_lite: "RKNNLite") -> tuple[list[RknnNode], list[RknnNode]]:
|
||||
runtime = rknn_lite.rknn_runtime
|
||||
n_inputs, n_outputs = runtime.get_in_out_num()
|
||||
|
||||
def node(index: int, is_output: bool) -> RknnNode:
|
||||
attr = runtime.get_tensor_attr(index, is_output=is_output)
|
||||
return RknnNode(attr.name.decode(), tuple(attr.dims[: attr.n_dims]))
|
||||
|
||||
return [node(i, False) for i in range(n_inputs)], [node(i, True) for i in range(n_outputs)]
|
||||
class _Input(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("index", ctypes.c_uint32),
|
||||
("buf", ctypes.c_void_p),
|
||||
("size", ctypes.c_uint32),
|
||||
("pass_through", ctypes.c_uint8),
|
||||
("type", ctypes.c_int),
|
||||
("fmt", ctypes.c_int),
|
||||
]
|
||||
|
||||
|
||||
def custom_string(rknn_lite: "RKNNLite") -> str:
|
||||
runtime: Any = rknn_lite.rknn_runtime
|
||||
buffer = _CustomString()
|
||||
# rknnlite declares argtypes on the shared handle, so pass plain values and let ctypes convert
|
||||
runtime.lib.rknn_query(runtime.context, _CUSTOM_STRING_QUERY, ctypes.byref(buffer), ctypes.sizeof(buffer))
|
||||
declared: str = buffer.string.decode()
|
||||
return declared
|
||||
class _Output(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("want_float", ctypes.c_uint8),
|
||||
("is_prealloc", ctypes.c_uint8),
|
||||
("index", ctypes.c_uint32),
|
||||
("buf", ctypes.c_void_p),
|
||||
("size", ctypes.c_uint32),
|
||||
]
|
||||
|
||||
|
||||
def init_rknn(model_path: str) -> "RKNNLite":
|
||||
if not is_available:
|
||||
raise RuntimeError("rknn is not available!")
|
||||
rknn_lite = RKNNLite()
|
||||
rknn_lite.rknn_log.logger.setLevel(logging.ERROR)
|
||||
ret = rknn_lite.load_rknn(model_path)
|
||||
_Key = tuple[tuple[int, ...], ...]
|
||||
_Spec = tuple[tuple[int, ...], np.dtype[Any]]
|
||||
_context = ctypes.c_uint64 # rknn_context on 64-bit
|
||||
_ctx_p = ctypes.POINTER(_context)
|
||||
_u32 = ctypes.c_uint32
|
||||
|
||||
soc_name = None
|
||||
is_available = False
|
||||
try:
|
||||
lib = ctypes.CDLL("librknnrt.so") # CDLL releases the GIL for each call, so contexts run in parallel
|
||||
lib.rknn_init.argtypes = _ctx_p, ctypes.c_char_p, _u32, _u32, ctypes.c_void_p
|
||||
lib.rknn_dup_context.argtypes = _ctx_p, _ctx_p
|
||||
lib.rknn_destroy.argtypes = (_context,)
|
||||
lib.rknn_query.argtypes = _context, ctypes.c_int, ctypes.c_void_p, _u32
|
||||
lib.rknn_set_input_shapes.argtypes = _context, _u32, ctypes.POINTER(_TensorAttr)
|
||||
lib.rknn_inputs_set.argtypes = _context, _u32, ctypes.POINTER(_Input)
|
||||
lib.rknn_run.argtypes = _context, ctypes.c_void_p
|
||||
lib.rknn_outputs_get.argtypes = _context, _u32, ctypes.POINTER(_Output), ctypes.c_void_p
|
||||
lib.rknn_outputs_release.argtypes = _context, _u32, ctypes.POINTER(_Output)
|
||||
|
||||
soc_name = get_soc("/proc/device-tree/compatible")
|
||||
is_available = soc_name is not None
|
||||
except OSError:
|
||||
log.debug("RKNN is not available")
|
||||
|
||||
|
||||
def _check(ret: int, call: str) -> None:
|
||||
if ret != 0:
|
||||
raise RuntimeError("Failed to load RKNN model")
|
||||
raise RuntimeError(f"{call} failed with RKNN error {ret}")
|
||||
|
||||
if soc_name in RKNN_COREMASK_SUPPORTED_SOCS:
|
||||
ret = rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_AUTO)
|
||||
else:
|
||||
ret = rknn_lite.init_runtime() # Please do not set this parameter on other platforms.
|
||||
|
||||
if ret != 0:
|
||||
raise RuntimeError("Failed to initialize RKNN runtime environment")
|
||||
def _query(ctx: int, cmd: int, info: ctypes.Structure) -> None:
|
||||
_check(lib.rknn_query(ctx, cmd, byref(info), sizeof(info)), "rknn_query")
|
||||
|
||||
return rknn_lite
|
||||
|
||||
def _attr(ctx: int, cmd: int, index: int) -> _TensorAttr:
|
||||
attr = _TensorAttr(index=index)
|
||||
_query(ctx, cmd, attr)
|
||||
return attr
|
||||
|
||||
|
||||
def _specs(attrs: list[_TensorAttr]) -> list[_Spec]:
|
||||
return [(tuple(attr.dims[: attr.n_dims]), np.dtype(_DTYPES[attr.type])) for attr in attrs]
|
||||
|
||||
|
||||
class RknnPoolExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
model_path: str,
|
||||
tpes: int,
|
||||
func: Callable[["RKNNLite", list[ModelTensor], str | None], list[NDArray[np.float32]]],
|
||||
) -> None:
|
||||
self.tpes = tpes
|
||||
self.rknn_pool = [init_rknn(model_path) for _ in range(tpes)]
|
||||
self.inputs, self.outputs = tensor_nodes(self.rknn_pool[0])
|
||||
self.custom_string = custom_string(self.rknn_pool[0])
|
||||
self.pool = ThreadPoolExecutor(max_workers=tpes)
|
||||
self.func = func
|
||||
self.num = 0
|
||||
self.lock = threading.Lock()
|
||||
"""One context per thread, all but the first duplicated from it so that they share its weights.
|
||||
|
||||
def run(self, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[np.float32]]:
|
||||
with self.lock:
|
||||
idx = self.num % self.tpes
|
||||
self.num += 1
|
||||
The model is loaded by path rather than from a buffer, so the runtime holds the only copy of it."""
|
||||
|
||||
fut = self.pool.submit(self.func, self.rknn_pool[idx], inputs, data_format)
|
||||
return fut.result()
|
||||
def __init__(self, model_path: str, tpes: int) -> None:
|
||||
self.contexts: list[int] = []
|
||||
self.executor = ThreadPoolExecutor(tpes, thread_name_prefix="rknn")
|
||||
master = _context()
|
||||
_check(lib.rknn_init(byref(master), model_path.encode(), 0, 0, None), "rknn_init")
|
||||
self.contexts.append(master.value)
|
||||
for _ in range(tpes - 1):
|
||||
child = _context()
|
||||
_check(lib.rknn_dup_context(byref(master), byref(child)), "rknn_dup_context")
|
||||
self.contexts.append(child.value)
|
||||
self.idle: SimpleQueue[int] = SimpleQueue()
|
||||
for ctx in self.contexts:
|
||||
self.idle.put(ctx)
|
||||
|
||||
io = _IoNum()
|
||||
_query(master.value, _IN_OUT_NUM, io)
|
||||
self.input_attrs = [_attr(master.value, _INPUT_ATTR, i) for i in range(io.n_input)]
|
||||
self.output_attrs = [_attr(master.value, _OUTPUT_ATTR, i) for i in range(io.n_output)]
|
||||
self.inputs = [RknnNode(a.name.decode(), tuple(a.dims[: a.n_dims])) for a in self.input_attrs]
|
||||
self.outputs = [RknnNode(a.name.decode(), tuple(a.dims[: a.n_dims])) for a in self.output_attrs]
|
||||
custom = _CustomString()
|
||||
lib.rknn_query(master.value, _CUSTOM_STRING, byref(custom), sizeof(custom))
|
||||
self.custom_string = custom.string.decode()
|
||||
|
||||
ranges = [_InputRange(index=index) for index in range(io.n_input)]
|
||||
for rng in ranges:
|
||||
lib.rknn_query(master.value, _INPUT_DYNAMIC_RANGE, byref(rng), sizeof(rng))
|
||||
self.formats = [rng.fmt for rng in ranges]
|
||||
self.compiled = [{tuple(rng.dyn_range[i][: rng.n_dims]) for i in range(rng.shape_number)} for rng in ranges]
|
||||
self.dynamic = any(self.compiled)
|
||||
self.shape_attrs: dict[_Key, ctypes.Array[_TensorAttr]] = {}
|
||||
self.output_specs: dict[_Key, list[_Spec]] = {(): _specs(self.output_attrs)}
|
||||
self.current: dict[int, _Key] = {}
|
||||
|
||||
def run(self, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[Any]]:
|
||||
return self.executor.submit(self._next, inputs, data_format).result()
|
||||
|
||||
def _next(self, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[Any]]:
|
||||
ctx = self.idle.get()
|
||||
try:
|
||||
return self._run(ctx, inputs, data_format)
|
||||
finally:
|
||||
self.idle.put(ctx)
|
||||
|
||||
def _run(self, ctx: int, inputs: list[ModelTensor], data_format: str | None) -> list[NDArray[Any]]:
|
||||
fed: list[NDArray[Any]] = [
|
||||
np.ascontiguousarray(array.transpose(0, 2, 3, 1) if data_format == "nchw" and array.ndim == 4 else array)
|
||||
for array in inputs
|
||||
]
|
||||
key = self._set_shapes(ctx, fed) if self.dynamic else ()
|
||||
feed = (_Input * len(fed))(
|
||||
*(
|
||||
_Input(index=i, buf=array.ctypes.data, size=array.nbytes, type=_TYPES[array.dtype], fmt=_NHWC)
|
||||
for i, array in enumerate(fed)
|
||||
)
|
||||
)
|
||||
_check(lib.rknn_inputs_set(ctx, len(fed), feed), "rknn_inputs_set")
|
||||
_check(lib.rknn_run(ctx, None), "rknn_run")
|
||||
|
||||
specs = self.output_specs.get(key)
|
||||
if specs is None:
|
||||
attrs = [_attr(ctx, _CURRENT_OUTPUT_ATTR, i) for i in range(len(self.output_attrs))]
|
||||
specs = self.output_specs[key] = _specs(attrs)
|
||||
outputs = [np.empty(dims, dtype) for dims, dtype in specs]
|
||||
buffers = (_Output * len(outputs))(
|
||||
*(
|
||||
_Output(is_prealloc=1, index=i, buf=array.ctypes.data, size=array.nbytes)
|
||||
for i, array in enumerate(outputs)
|
||||
)
|
||||
)
|
||||
_check(lib.rknn_outputs_get(ctx, len(outputs), buffers, None), "rknn_outputs_get")
|
||||
lib.rknn_outputs_release(ctx, len(outputs), buffers)
|
||||
return outputs
|
||||
|
||||
def _set_shapes(self, ctx: int, inputs: list[NDArray[Any]]) -> _Key:
|
||||
key = tuple(
|
||||
self._compiled_shape(fmt, compiled, array.shape)
|
||||
for fmt, compiled, array in zip(self.formats, self.compiled, inputs)
|
||||
)
|
||||
if self.current.get(ctx) == key:
|
||||
return key
|
||||
attrs = self.shape_attrs.get(key)
|
||||
if attrs is None:
|
||||
attrs = self.shape_attrs[key] = (_TensorAttr * len(key))()
|
||||
for i, (source, compiled, fmt, shape) in enumerate(zip(self.input_attrs, self.compiled, self.formats, key)):
|
||||
attrs[i] = _TensorAttr.from_buffer_copy(source)
|
||||
if compiled:
|
||||
attrs[i].n_dims = len(shape)
|
||||
attrs[i].dims[: len(shape)] = shape
|
||||
attrs[i].fmt = fmt
|
||||
_check(lib.rknn_set_input_shapes(ctx, len(key), attrs), "rknn_set_input_shapes")
|
||||
self.current[ctx] = key
|
||||
return key
|
||||
|
||||
@staticmethod
|
||||
def _compiled_shape(fmt: int, compiled: set[tuple[int, ...]], fed: tuple[int, ...]) -> tuple[int, ...]:
|
||||
shape = (fed[0], fed[3], fed[1], fed[2]) if len(fed) == 4 and fmt == _NCHW else fed
|
||||
if compiled and shape not in compiled:
|
||||
raise ValueError(f"binary was compiled for {sorted(compiled)}, fed {list(fed)}")
|
||||
return shape
|
||||
|
||||
def release(self) -> None:
|
||||
self.pool.shutdown()
|
||||
for rknn_lite in self.rknn_pool:
|
||||
rknn_lite.release()
|
||||
self.executor.shutdown()
|
||||
for ctx in reversed(self.contexts): # duplicates before the context whose weights they share
|
||||
lib.rknn_destroy(ctx)
|
||||
self.contexts = []
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.release()
|
||||
|
||||
@@ -14,7 +14,8 @@ dependencies = [
|
||||
"onnx>=1.22.0",
|
||||
"opencv-python-headless>=4.7.0.72,<5.0",
|
||||
"orjson>=3.9.5",
|
||||
"pillow>=12.2,<13",
|
||||
"pillow>=13,<14",
|
||||
"pillow-jxl-plugin>=1.3.8,<2.0",
|
||||
"pydantic>=2.0.0,<3",
|
||||
"pydantic-settings>=2.5.2,<3",
|
||||
"python-multipart>=0.0.6,<1.0",
|
||||
@@ -56,7 +57,7 @@ cuda = [
|
||||
]
|
||||
openvino = ["onnxruntime-openvino>=1.24.1,<2"]
|
||||
armnn = ["onnxruntime>=1.23.2,<2"]
|
||||
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
|
||||
rknn = ["onnxruntime>=1.23.2,<2"]
|
||||
rocm = ["onnxruntime-migraphx>=1.23.2,<2"]
|
||||
|
||||
[tool.uv]
|
||||
@@ -64,7 +65,7 @@ compile-bytecode = true
|
||||
override-dependencies = ["opencv-python ; sys_platform == 'never'"] # only install the headless version
|
||||
|
||||
[tool.uv.sources]
|
||||
immich-model = { git = "https://github.com/immich-app/ml-models.git", rev = "8bf3fd1b44b0c8608f52fb6e50ddff52826576d8" }
|
||||
immich-model = { git = "https://github.com/immich-app/ml-models.git", rev = "a1eeff2c2118d76067efecf4cc4630998eac519a" }
|
||||
onnxruntime-gpu = { index = "onnxruntime-cuda-12" }
|
||||
openvino = { index = "openvino-nightly" }
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
@@ -63,8 +64,9 @@ from immich_ml.schemas import (
|
||||
from immich_ml.sessions.ann import AnnSession
|
||||
from immich_ml.sessions.ort import Device, GraphSpec, OrtSession, flush_denormals, fresh, prepared
|
||||
from immich_ml.sessions.policy import ShapePolicy, batches, runs
|
||||
from immich_ml.sessions.rknn import RknnSession, run_inference
|
||||
from immich_ml.sessions.rknn import RknnSession
|
||||
from immich_ml.sessions.rknn import model_path as rknn_model_path
|
||||
from immich_ml.sessions.rknn.rknnpool import RknnPoolExecutor
|
||||
|
||||
|
||||
class TestBase:
|
||||
@@ -960,9 +962,11 @@ class TestRknnSession:
|
||||
mocker.patch("immich_ml.sessions.rknn.is_available", True)
|
||||
RknnSession(model_path)
|
||||
|
||||
rknn_session.assert_called_once_with(model_path=model_path.as_posix(), tpes=tpe, func=run_inference)
|
||||
rknn_session.assert_called_once_with(model_path=model_path.as_posix(), tpes=tpe)
|
||||
|
||||
info.assert_has_calls([mock.call(f"Loaded RKNN model from {model_path} with {tpe} threads.")])
|
||||
info.assert_has_calls(
|
||||
[mock.call(f"Loaded RKNN model from {model_path} with {tpe} threads sharing its weights.")]
|
||||
)
|
||||
|
||||
def test_run_rknn(self, rknn_session: mock.Mock, mocker: MockerFixture) -> None:
|
||||
rknn_session.return_value.load.return_value = 123
|
||||
@@ -974,7 +978,20 @@ class TestRknnSession:
|
||||
session.run(None, input_feed)
|
||||
|
||||
rknn_session.return_value.run.assert_called_once_with([input1, input2], "nchw")
|
||||
assert all(fed.flags.c_contiguous for fed in rknn_session.return_value.run.call_args.args[0])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("soc", "threads", "expected"),
|
||||
[("rk3566", None, 1), ("rk3576", None, 2), ("rk3588", None, 3), ("rk3588", 2, 2)],
|
||||
)
|
||||
def test_uses_a_context_per_npu_core_unless_set(
|
||||
self, soc: str, threads: int | None, expected: int, rknn_session: mock.Mock, mocker: MockerFixture
|
||||
) -> None:
|
||||
mocker.patch("immich_ml.sessions.rknn.soc_name", soc)
|
||||
mocker.patch.object(settings, "rknn_threads", threads)
|
||||
|
||||
RknnSession(Path("ViT-B-32__openai"))
|
||||
|
||||
assert rknn_session.call_args.kwargs["tpes"] == expected
|
||||
|
||||
def test_run_rknn_rejects_a_shape_the_binary_was_not_compiled_for(
|
||||
self, rknn_session: mock.Mock, mocker: MockerFixture
|
||||
@@ -1011,6 +1028,237 @@ class TestRknnSession:
|
||||
assert RknnSession(Path("buffalo_l")).shapes == (Shape(batch=1),)
|
||||
|
||||
|
||||
class FakeRknn:
|
||||
"""librknnrt for one input and one output, writing through the pointers as the runtime does."""
|
||||
|
||||
def __init__(self, ranges: list[tuple[int, ...]] | None = None) -> None:
|
||||
self.ranges = ranges
|
||||
self.contexts = 0
|
||||
self.fed: list[tuple[int, int, int]] = [] # (context, type, fmt) per run
|
||||
self.data: list[bytes] = []
|
||||
self.buffers: list[int] = []
|
||||
self.output_queries = 0
|
||||
self.set_shapes: list[tuple[int, tuple[int, ...]]] = []
|
||||
self.rknn_destroy = mock.Mock(return_value=0)
|
||||
self.rknn_outputs_release = mock.Mock(return_value=0)
|
||||
self.rknn_run = mock.Mock(return_value=0)
|
||||
|
||||
def rknn_init(self, ctx: Any, model: bytes, size: int, flag: int, extend: None) -> int:
|
||||
self.contexts += 1
|
||||
ctx._obj.value = self.contexts
|
||||
return 0
|
||||
|
||||
def rknn_dup_context(self, ctx_in: Any, ctx_out: Any) -> int:
|
||||
self.contexts += 1
|
||||
ctx_out._obj.value = self.contexts
|
||||
return 0
|
||||
|
||||
def rknn_query(self, ctx: int, cmd: int, info: Any, size: int) -> int:
|
||||
obj = info._obj
|
||||
if cmd == 0:
|
||||
obj.n_input, obj.n_output = 1, 1
|
||||
elif cmd == 1:
|
||||
self._fill(obj, b"x", self.ranges[0] if self.ranges else (1, 48, 320, 3), 0)
|
||||
obj.fmt = 1
|
||||
elif cmd == 2:
|
||||
self._fill(obj, b"logits", (1, 40, 97), 1)
|
||||
elif cmd == 7:
|
||||
obj.string = b'{"dims":[{}]}'
|
||||
elif cmd == 13:
|
||||
if self.ranges is None:
|
||||
return -1
|
||||
obj.shape_number, obj.n_dims, obj.fmt = len(self.ranges), 4, 1
|
||||
for i, shape in enumerate(self.ranges):
|
||||
obj.dyn_range[i][:4] = shape
|
||||
elif cmd == 15:
|
||||
self.output_queries += 1
|
||||
width = dict(self.set_shapes)[ctx][2]
|
||||
self._fill(obj, b"logits", (1, width // 8, 97), 1)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _fill(attr: Any, name: bytes, dims: tuple[int, ...], dtype: int) -> None:
|
||||
attr.name, attr.n_dims, attr.type = name, len(dims), dtype
|
||||
attr.dims[: len(dims)] = dims
|
||||
|
||||
def rknn_set_input_shapes(self, ctx: int, n: int, attrs: Any) -> int:
|
||||
self.set_shapes.append((ctx, tuple(attrs[0].dims[: attrs[0].n_dims])))
|
||||
return 0
|
||||
|
||||
def rknn_inputs_set(self, ctx: int, n: int, inputs: Any) -> int:
|
||||
self.fed.append((ctx, inputs[0].type, inputs[0].fmt))
|
||||
self.data.append(ctypes.string_at(inputs[0].buf, inputs[0].size))
|
||||
self.buffers.append(inputs[0].buf)
|
||||
return 0
|
||||
|
||||
def rknn_outputs_get(self, ctx: int, n: int, outputs: Any, extend: None) -> int:
|
||||
assert outputs[0].is_prealloc and not outputs[0].want_float
|
||||
np.frombuffer((ctypes.c_char * outputs[0].size).from_address(outputs[0].buf), np.uint8)[:] = 0
|
||||
return 0
|
||||
|
||||
|
||||
class TestRknnPool:
|
||||
@pytest.fixture
|
||||
def lib(self, mocker: MockerFixture) -> FakeRknn:
|
||||
fake = FakeRknn()
|
||||
mocker.patch("immich_ml.sessions.rknn.rknnpool.lib", fake, create=True)
|
||||
return fake
|
||||
|
||||
@pytest.fixture
|
||||
def dynamic_lib(self, mocker: MockerFixture) -> FakeRknn:
|
||||
fake = FakeRknn(ranges=[(1, 48, 320, 3), (1, 48, 640, 3)])
|
||||
mocker.patch("immich_ml.sessions.rknn.rknnpool.lib", fake, create=True)
|
||||
return fake
|
||||
|
||||
def test_duplicates_one_context_per_thread(self, lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 3)
|
||||
|
||||
assert pool.contexts == [1, 2, 3]
|
||||
assert pool.inputs[0] == ("x", (1, 48, 320, 3))
|
||||
assert pool.outputs[0] == ("logits", (1, 40, 97))
|
||||
assert pool.custom_string == '{"dims":[{}]}'
|
||||
assert not pool.dynamic
|
||||
|
||||
pool.release()
|
||||
|
||||
assert lib.rknn_destroy.call_args_list == [mock.call(3), mock.call(2), mock.call(1)]
|
||||
|
||||
def test_feeds_the_dtype_given_as_nhwc(self, lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 1)
|
||||
image = np.arange(48 * 320 * 3, dtype=np.uint8).reshape(1, 48, 320, 3)
|
||||
|
||||
pool.run([image], "nhwc")
|
||||
pool.run([image.transpose(0, 3, 1, 2).astype(np.float32)], "nchw")
|
||||
|
||||
assert lib.fed == [(1, 3, 1), (1, 0, 1)] # (context, rknn_tensor_type, rknn_tensor_format)
|
||||
assert lib.data[0] == image.tobytes()
|
||||
assert lib.data[1] == image.astype(np.float32).tobytes() # transposed back to channels last
|
||||
|
||||
def test_feeds_a_channels_first_view_of_channels_last_data_without_a_copy(self, lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 1)
|
||||
image = np.zeros((1, 48, 320, 3), dtype=np.float32)
|
||||
|
||||
pool.run([image.transpose(0, 3, 1, 2)], "nchw")
|
||||
|
||||
assert lib.buffers == [image.ctypes.data]
|
||||
|
||||
def test_makes_every_input_contiguous(self, lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 1)
|
||||
image = np.arange(48 * 640 * 3, dtype=np.uint8).reshape(1, 48, 640, 3)[:, :, ::2]
|
||||
|
||||
pool.run([image], "nhwc")
|
||||
|
||||
assert lib.data == [np.ascontiguousarray(image).tobytes()]
|
||||
|
||||
def test_returns_outputs_in_the_precision_computed(self, lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 1)
|
||||
|
||||
[output] = pool.run([np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc")
|
||||
|
||||
assert output.dtype == np.float16
|
||||
assert output.shape == (1, 40, 97)
|
||||
lib.rknn_outputs_release.assert_called_once()
|
||||
|
||||
def test_a_failed_init_destroys_nothing(self, lib: FakeRknn, mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(lib, "rknn_init", return_value=-6)
|
||||
|
||||
with pytest.raises(RuntimeError, match="rknn_init failed"):
|
||||
RknnPoolExecutor("model.rknn", 3)
|
||||
|
||||
lib.rknn_destroy.assert_not_called()
|
||||
|
||||
def test_returns_the_context_when_a_run_fails(self, lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 2)
|
||||
lib.rknn_run.return_value = -1
|
||||
|
||||
with pytest.raises(RuntimeError, match="rknn_run failed"):
|
||||
pool.run([np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc")
|
||||
|
||||
assert pool.idle.qsize() == 2
|
||||
|
||||
def test_runs_on_every_context_at_once(self, lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 3)
|
||||
barrier = threading.Barrier(3, timeout=5) # breaks unless all three are inside rknn_run together
|
||||
lib.rknn_run.side_effect = lambda ctx, extend: barrier.wait() * 0
|
||||
|
||||
with ThreadPoolExecutor(3) as executor:
|
||||
futures = [executor.submit(pool.run, [np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc") for _ in range(3)]
|
||||
[future.result() for future in futures]
|
||||
|
||||
assert sorted(ctx for ctx, _, _ in lib.fed) == [1, 2, 3]
|
||||
|
||||
def test_a_waiting_run_is_not_overtaken_by_the_caller_that_just_ran(self, lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 1)
|
||||
|
||||
def run(ctx: int, extend: None) -> int:
|
||||
time.sleep(0.01)
|
||||
return 0
|
||||
|
||||
lib.rknn_run.side_effect = run
|
||||
busy, waiting = np.zeros((1, 48, 320, 3), np.uint8), np.ones((1, 48, 320, 3), np.uint8)
|
||||
caller = threading.Thread(target=lambda: [pool.run([busy], "nhwc") for _ in range(20)])
|
||||
|
||||
caller.start()
|
||||
time.sleep(0.005)
|
||||
pool.run([waiting], "nhwc")
|
||||
caller.join(5)
|
||||
|
||||
assert [data[0] for data in lib.data].index(1) <= 2
|
||||
|
||||
def test_does_not_set_shapes_for_a_static_binary(self, lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 1)
|
||||
|
||||
pool.run([np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc")
|
||||
|
||||
assert lib.set_shapes == []
|
||||
|
||||
def test_routes_a_dynamic_binary_to_the_shape_fed(self, dynamic_lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 1)
|
||||
|
||||
[wide] = pool.run([np.zeros((1, 3, 48, 640), dtype=np.float32)], "nchw")
|
||||
[narrow] = pool.run([np.zeros((1, 48, 320, 3), dtype=np.uint8)], "nhwc")
|
||||
|
||||
assert pool.dynamic
|
||||
assert dynamic_lib.set_shapes == [(1, (1, 48, 640, 3)), (1, (1, 48, 320, 3))]
|
||||
assert wide.shape == (1, 80, 97)
|
||||
assert narrow.shape == (1, 40, 97)
|
||||
|
||||
def test_sets_the_shape_on_the_context_that_runs(self, dynamic_lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 2)
|
||||
image = np.zeros((1, 48, 640, 3), dtype=np.uint8)
|
||||
|
||||
pool.run([image], "nhwc")
|
||||
pool.run([image], "nhwc")
|
||||
|
||||
assert dynamic_lib.set_shapes == [(1, (1, 48, 640, 3)), (2, (1, 48, 640, 3))]
|
||||
|
||||
def test_sets_a_shape_again_only_when_it_changes(self, dynamic_lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 1)
|
||||
wide, narrow = np.zeros((1, 48, 640, 3), dtype=np.uint8), np.zeros((1, 48, 320, 3), dtype=np.uint8)
|
||||
|
||||
for image in (wide, wide, narrow, narrow, wide):
|
||||
pool.run([image], "nhwc")
|
||||
|
||||
assert dynamic_lib.set_shapes == [(1, (1, 48, 640, 3)), (1, (1, 48, 320, 3)), (1, (1, 48, 640, 3))]
|
||||
|
||||
def test_queries_output_shapes_once_per_input_shape(self, dynamic_lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 2)
|
||||
image = np.zeros((1, 48, 640, 3), dtype=np.uint8)
|
||||
|
||||
outputs = [pool.run([image], "nhwc")[0] for _ in range(4)]
|
||||
|
||||
assert dynamic_lib.output_queries == 1
|
||||
assert all(output.shape == (1, 80, 97) for output in outputs)
|
||||
|
||||
def test_rejects_a_shape_the_binary_was_not_compiled_for(self, dynamic_lib: FakeRknn) -> None:
|
||||
pool = RknnPoolExecutor("model.rknn", 1)
|
||||
|
||||
with pytest.raises(ValueError, match="compiled for"):
|
||||
pool.run([np.zeros((1, 48, 480, 3), dtype=np.uint8)], "nhwc")
|
||||
|
||||
assert pool.idle.qsize() == 1
|
||||
|
||||
|
||||
class TestCLIP:
|
||||
embedding = np.random.rand(512).astype(np.float32)
|
||||
cache_dir = Path("test_cache")
|
||||
|
||||
Generated
+772
-587
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,22 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||
|
||||
lockfile_version = 3
|
||||
|
||||
[[tools."github:extism/cli"]]
|
||||
version = "1.6.3"
|
||||
backend = "github:extism/cli"
|
||||
specifiers = ["1.6.3"]
|
||||
|
||||
[tools."github:extism/cli"."platforms.linux-arm64"]
|
||||
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
|
||||
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
|
||||
|
||||
[tools."github:extism/cli"."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
|
||||
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
|
||||
|
||||
[tools."github:extism/cli"."platforms.linux-x64"]
|
||||
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
|
||||
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
|
||||
|
||||
[tools."github:extism/cli"."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
|
||||
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
|
||||
|
||||
[tools."github:extism/cli"."platforms.macos-arm64"]
|
||||
checksum = "sha256:b4ddbc575b5ac000115247f781723f9b9f284ed87b29c600539d72161b5b29fc"
|
||||
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-arm64.tar.gz"
|
||||
@@ -42,27 +35,18 @@ url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694035"
|
||||
[[tools."github:extism/js-pdk"]]
|
||||
version = "1.7.0"
|
||||
backend = "github:extism/js-pdk"
|
||||
specifiers = ["v1.7.0"]
|
||||
|
||||
[tools."github:extism/js-pdk"."platforms.linux-arm64"]
|
||||
checksum = "sha256:025f4050b199d68413c159bde1187271ae270021a9f7171e7beb509922821f2a"
|
||||
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-aarch64-linux-v1.7.0.gz"
|
||||
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632503"
|
||||
|
||||
[tools."github:extism/js-pdk"."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:025f4050b199d68413c159bde1187271ae270021a9f7171e7beb509922821f2a"
|
||||
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-aarch64-linux-v1.7.0.gz"
|
||||
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632503"
|
||||
|
||||
[tools."github:extism/js-pdk"."platforms.linux-x64"]
|
||||
checksum = "sha256:63b72da2f5e88655522dc21477de549f238a2f40546a69ce4e0fce7e78654035"
|
||||
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-x86_64-linux-v1.7.0.gz"
|
||||
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632991"
|
||||
|
||||
[tools."github:extism/js-pdk"."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:63b72da2f5e88655522dc21477de549f238a2f40546a69ce4e0fce7e78654035"
|
||||
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-x86_64-linux-v1.7.0.gz"
|
||||
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632991"
|
||||
|
||||
[tools."github:extism/js-pdk"."platforms.macos-arm64"]
|
||||
checksum = "sha256:12c01c2bb2240a6a05a4f8babe680c793c397d84e6a1497a7ca1312e78a475c3"
|
||||
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-aarch64-macos-v1.7.0.gz"
|
||||
@@ -81,6 +65,7 @@ url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518636347"
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
specifiers = ["7.1.3-6"]
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.windows-x64"]
|
||||
checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1"
|
||||
@@ -90,18 +75,33 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
specifiers = ["7.1.3-6"]
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
|
||||
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
|
||||
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
|
||||
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
specifiers = ["7.1.3-6"]
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
specifiers = ["7.1.3-6"]
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
|
||||
@@ -114,61 +114,31 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
specifiers = ["7.1.3-6"]
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
|
||||
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
|
||||
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
|
||||
|
||||
[[tools."github:webassembly/binaryen"]]
|
||||
version = "version_124"
|
||||
backend = "github:webassembly/binaryen"
|
||||
specifiers = ["version_124"]
|
||||
|
||||
[tools."github:webassembly/binaryen"."platforms.linux-arm64"]
|
||||
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
|
||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
|
||||
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
|
||||
|
||||
[tools."github:webassembly/binaryen"."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
|
||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
|
||||
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
|
||||
|
||||
[tools."github:webassembly/binaryen"."platforms.linux-x64"]
|
||||
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
|
||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
|
||||
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
|
||||
|
||||
[tools."github:webassembly/binaryen"."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
|
||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
|
||||
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
|
||||
|
||||
[tools."github:webassembly/binaryen"."platforms.macos-arm64"]
|
||||
checksum = "sha256:86a2c960ff62c6d2ea6009d1f89745c22c70100d394a095eab45eb941bdaa24c"
|
||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-arm64-macos.tar.gz"
|
||||
@@ -187,9 +157,10 @@ url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288
|
||||
[[tools.java]]
|
||||
version = "21.0.2"
|
||||
backend = "core:java"
|
||||
specifiers = ["21.0.2"]
|
||||
|
||||
[tools.java.options]
|
||||
shorthand_vendor = "openjdk"
|
||||
shorthand_vendor = "temurin"
|
||||
|
||||
[tools.java."platforms.linux-arm64"]
|
||||
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
|
||||
@@ -245,123 +216,118 @@ checksum = "sha256:d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95
|
||||
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip"
|
||||
|
||||
[[tools."npm:@openapitools/openapi-generator-cli"]]
|
||||
version = "2.41.0"
|
||||
version = "2.42.0"
|
||||
aube = { path = ".mise/locks/npm-openapitools-openapi-generator-cli/2.42.0", digest = "sha256:ef9f5cf15f958d31297bb706ee83d1347633603f3079431938665c7bc4136856" }
|
||||
backend = "npm:@openapitools/openapi-generator-cli"
|
||||
specifiers = ["2.42.0"]
|
||||
|
||||
[[tools."npm:oazapfts"]]
|
||||
version = "7.5.0"
|
||||
aube = { path = ".mise/locks/npm-oazapfts/7.5.0", digest = "sha256:74214614b0338eaa2baee026d615afde514311c5c76e274f420f8de8b1f0e017" }
|
||||
backend = "npm:oazapfts"
|
||||
specifiers = ["7.5.0"]
|
||||
|
||||
[[tools.opentofu]]
|
||||
version = "1.13.1"
|
||||
version = "1.13.2"
|
||||
backend = "aqua:opentofu/opentofu"
|
||||
specifiers = ["1.13.1"]
|
||||
specifiers = ["1.13.2"]
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64"]
|
||||
checksum = "sha256:9c1ef375aa1852db0b2888aa921b640c71f8140d4682aa4fec99378a64fa7dc3"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708055"
|
||||
|
||||
[tools.opentofu."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
|
||||
checksum = "sha256:88e2d9e3f94f97b76ab2faf55ade87578f56aed19c5e45fa139d70e994366d1f"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742799"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64"]
|
||||
checksum = "sha256:378ada19d4bc70c43732004e8159be771b23b9a5afdf059e5f8a2b3fa2c70a69"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708017"
|
||||
|
||||
[tools.opentofu."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
|
||||
checksum = "sha256:ac05d9952662c7db28dd57e93f0a68018837bfdf9342384ec0987977c16a9645"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742636"
|
||||
|
||||
[tools.opentofu."platforms.macos-arm64"]
|
||||
checksum = "sha256:be78f659f04ef06a9dbd9b3934d46af95d787a3aa38396d459dea395261816a9"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708059"
|
||||
checksum = "sha256:0d1cdc85f38992055b48e7fb161304a2ddfe2e5ee0ff68ffec9d0349c8bd1b59"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742637"
|
||||
|
||||
[tools.opentofu."platforms.macos-x64"]
|
||||
checksum = "sha256:a73720443ba38712d7d96dc1e857add02c15a790919c653ad07492e9952f8c27"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708142"
|
||||
checksum = "sha256:dc6e4eb1d3ed6550c2ef052596f933dbcd8c806957665ea83ae7537147cb3074"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742982"
|
||||
|
||||
[tools.opentofu."platforms.windows-x64"]
|
||||
checksum = "sha256:6825ae311e9a15747ce2ede7b7e97c8bb744a4d271fa8113259eeeee3d09438c"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708102"
|
||||
checksum = "sha256:e74fc2bda0c81ce696b6b7016a4d5c1f557638161239e4101e9ee7d8e0faba59"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_windows_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742635"
|
||||
|
||||
[[tools.pnpm]]
|
||||
version = "12.14.0"
|
||||
version = "12.15.0"
|
||||
backend = "aqua:pnpm/pnpm"
|
||||
specifiers = ["12.14.0"]
|
||||
specifiers = ["12.15.0"]
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64"]
|
||||
checksum = "sha256:cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-linux-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957409"
|
||||
checksum = "sha256:d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-linux-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173352"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64"]
|
||||
checksum = "sha256:3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-linux-x64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957410"
|
||||
checksum = "sha256:55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-linux-x64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173360"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.macos-arm64"]
|
||||
checksum = "sha256:50f48e0e17508952790a3fbf58fcf14f3b82efa5f594ca04ad904e29aaf854b0"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-darwin-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957413"
|
||||
checksum = "sha256:6a41a2877773e97f19832067187cc6fc82e186d8a2cf91fec4b4c1a5fbd6ef27"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-darwin-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173343"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.macos-x64"]
|
||||
checksum = "sha256:2c412e07382049b4edb04fa6da78f3d4ef7810b75853c80544dcfb7c0ed79880"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-darwin-x64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957411"
|
||||
checksum = "sha256:2d7b336884574aee8a765bee48591212e9caf4fcf985fb3279588c1fb73fb47f"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-darwin-x64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173347"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.windows-x64"]
|
||||
checksum = "sha256:6f0f38e917c8ce76ef31db528c357e2bfa354a1bcb16464e3cdec39e6530d784"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-win32-x64.zip"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957404"
|
||||
checksum = "sha256:84cf69db1be19e95bb4e5bef329291a046f2821b390d87e00f5690809d8a2ed7"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-win32-x64.zip"
|
||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173351"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
backend = "aqua:gruntwork-io/terragrunt"
|
||||
specifiers = ["1.2.0"]
|
||||
|
||||
[tools.terragrunt."platforms.linux-arm64"]
|
||||
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
|
||||
|
||||
[tools.terragrunt."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
|
||||
checksum = "sha256:1e13b7f0646fa7962bc6c5bfba14a30d6a589325b82561e21b66be5981d86395"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437526"
|
||||
|
||||
[tools.terragrunt."platforms.linux-x64"]
|
||||
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
|
||||
|
||||
[tools.terragrunt."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
|
||||
checksum = "sha256:23b5f2f9203d34919f7fd270a4b635b721b4903beca9762a1e4853b159bc9be8"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437263"
|
||||
|
||||
[tools.terragrunt."platforms.macos-arm64"]
|
||||
checksum = "sha256:9ec8f678b9ae6c81d5d9d77b94cbf6349ce639d5938694b4adc9dea73e416794"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785628"
|
||||
checksum = "sha256:79790d841ce71fe94c689fb6f5034afd31ad53d5904937c7a10934cca9d9734b"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436516"
|
||||
|
||||
[tools.terragrunt."platforms.macos-x64"]
|
||||
checksum = "sha256:73e768a69fa44a60f9d9174f54aaf179327e08706f633c9c79d5f5c9622c91c2"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785520"
|
||||
checksum = "sha256:0b0e63e80509eb1ffb4f89b892bc6fbf7728f27f0a73974400f1bd82a80b8627"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_amd64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436142"
|
||||
|
||||
[tools.terragrunt."platforms.windows-x64"]
|
||||
checksum = "sha256:dd50a324691e072a3ac879e9ec43d6310e2936c929fda658eb2811fefcc75115"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_windows_amd64.exe.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786421"
|
||||
checksum = "sha256:c9a146b9aad456a289614023ad5778123d819af3cf9efd1c6130f196cfcb1999"
|
||||
checksum_source = "mise-versions"
|
||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_windows_amd64.exe.tar.gz"
|
||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662438255"
|
||||
|
||||
@@ -16,10 +16,10 @@ config_roots = [
|
||||
|
||||
[tools]
|
||||
node = "24.22.1"
|
||||
pnpm = "12.14.0"
|
||||
terragrunt = "1.1.1"
|
||||
opentofu = "1.13.1"
|
||||
"npm:@openapitools/openapi-generator-cli" = "2.41.0"
|
||||
pnpm = "12.15.0"
|
||||
terragrunt = "1.2.0"
|
||||
opentofu = "1.13.2"
|
||||
"npm:@openapitools/openapi-generator-cli" = "2.42.0"
|
||||
"npm:oazapfts" = "7.5.0"
|
||||
"github:extism/cli" = "1.6.3"
|
||||
"github:webassembly/binaryen" = "version_124"
|
||||
|
||||
@@ -28,12 +28,12 @@ object NativeImage {
|
||||
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
|
||||
|
||||
/**
|
||||
* Like [convert1010102], but decodes the JPEG XL image of [length] bytes at [address], stopped by
|
||||
* Like [convert1010102], but decodes the image of [length] bytes at [address], stopped by
|
||||
* [cancel], downscaled to cover [targetWidth] x [targetHeight] unless either is 0 and to fit
|
||||
* within [maxPixelSize].
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun decodeJxl(
|
||||
external fun decode(
|
||||
address: Long,
|
||||
length: Int,
|
||||
targetWidth: Int,
|
||||
@@ -43,7 +43,7 @@ object NativeImage {
|
||||
outInfo: IntArray
|
||||
): Long
|
||||
|
||||
/** Stops the [decodeJxl] given this one-byte direct buffer. */
|
||||
/** Stops the [decode] given this one-byte direct buffer. */
|
||||
@JvmStatic
|
||||
external fun cancel(cancelled: ByteBuffer)
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
// The fetch is done, so cancelling now stops the decode instead.
|
||||
val cancelled = ByteBuffer.allocateDirect(1)
|
||||
signal.setOnCancelListener { NativeImage.cancel(cancelled) }
|
||||
val pointer = if (signal.isCanceled) 0L else NativeImage.decodeJxl(
|
||||
val pointer = if (signal.isCanceled) 0L else NativeImage.decode(
|
||||
buffer.pointer,
|
||||
buffer.offset,
|
||||
width?.toInt() ?: 0,
|
||||
|
||||
@@ -9,7 +9,7 @@ enum NativeCore {
|
||||
}
|
||||
|
||||
private static let coreThumbhash = symbol("native_core_thumbhash", as: NativeCoreThumbhashFn.self)
|
||||
private static let coreJxlDecode = symbol("native_core_jxl_decode", as: NativeCoreJxlDecodeFn.self)
|
||||
private static let coreDecodeImage = symbol("native_core_decode_image", as: NativeCoreDecodeImageFn.self)
|
||||
private static let coreCancel = symbol("native_core_cancel", as: NativeCoreCancelFn.self)
|
||||
|
||||
static func thumbhash(_ hash: Data) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
|
||||
@@ -23,11 +23,11 @@ enum NativeCore {
|
||||
}
|
||||
}
|
||||
|
||||
static func decodeJxl(_ data: Data, targetWidth: Int64?, targetHeight: Int64?, maxPixelSize: Int, cancelled: UnsafeMutablePointer<Bool>) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
|
||||
guard let coreJxlDecode else { return nil }
|
||||
static func decodeImage(_ data: Data, targetWidth: Int64?, targetHeight: Int64?, maxPixelSize: Int, cancelled: UnsafeMutablePointer<Bool>) -> (width: Int, height: Int, pointer: UnsafeMutableRawPointer)? {
|
||||
guard let coreDecodeImage else { return nil }
|
||||
var info = NativeCoreImageInfo()
|
||||
return data.withUnsafeBytes { bytes in
|
||||
guard let pointer = coreJxlDecode(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, false, UInt32(clamping: targetWidth ?? 0), UInt32(clamping: targetHeight ?? 0), UInt32(clamping: maxPixelSize), cancelled, &info)
|
||||
guard let pointer = coreDecodeImage(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, false, UInt32(clamping: targetWidth ?? 0), UInt32(clamping: targetHeight ?? 0), UInt32(clamping: maxPixelSize), cancelled, &info)
|
||||
else { return nil }
|
||||
return (Int(info.width), Int(info.height), UnsafeMutableRawPointer(pointer))
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
guard let (width, height, pointer) = NativeCore.decodeJxl(data, targetWidth: width, targetHeight: height, maxPixelSize: ImageProcessing.maxPixelSize, cancelled: request.decodeCancelled) else {
|
||||
guard let (width, height, pointer) = NativeCore.decodeImage(data, targetWidth: width, targetHeight: height, maxPixelSize: ImageProcessing.maxPixelSize, cancelled: request.decodeCancelled) else {
|
||||
registry.remove(requestId: request.id)
|
||||
if request.isCancelled {
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
|
||||
// The function pointer type comes from the declaration, so nothing is retyped here.
|
||||
typedef __typeof__(&native_core_thumbhash) NativeCoreThumbhashFn;
|
||||
typedef __typeof__(&native_core_jxl_decode) NativeCoreJxlDecodeFn;
|
||||
typedef __typeof__(&native_core_decode_image) NativeCoreDecodeImageFn;
|
||||
typedef __typeof__(&native_core_cancel) NativeCoreCancelFn;
|
||||
|
||||
@@ -60,7 +60,6 @@ class AssetService {
|
||||
}
|
||||
|
||||
final stack = await _remoteRepository.getStackChildren(asset);
|
||||
// Include the primary asset in the stack as the first item
|
||||
return [asset, ...stack];
|
||||
}
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@ class TimelineFactory {
|
||||
|
||||
TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy));
|
||||
|
||||
TimelineService person(List<String> userIds, String personId) =>
|
||||
TimelineService(_timelineRepository.person(userIds, personId, groupBy));
|
||||
TimelineService person(String userId, String personId) =>
|
||||
TimelineService(_timelineRepository.person(userId, personId, groupBy));
|
||||
|
||||
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||
TimelineService(_timelineRepository.fromAssets(assets, type));
|
||||
|
||||
@@ -41,6 +41,23 @@ class RemoteAssetRepository extends DatabaseAccessor<Drift> with $RemoteAssetRep
|
||||
return _assetSelectable(id).watchSingleOrNull();
|
||||
}
|
||||
|
||||
Stream<Set<String>> watchDeletedAssetIds(Iterable<String> ids, AssetVisibility visibility) {
|
||||
final idList = ids.toList(growable: false);
|
||||
if (idList.isEmpty) {
|
||||
return Stream.value(const <String>{});
|
||||
}
|
||||
|
||||
final query = _db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([_db.remoteAssetEntity.id])
|
||||
..where(
|
||||
_db.remoteAssetEntity.id.isIn(idList) &
|
||||
(_db.remoteAssetEntity.deletedAt.isNotNull() |
|
||||
_db.remoteAssetEntity.visibility.equalsValue(visibility).not()),
|
||||
);
|
||||
|
||||
return query.map((row) => row.read(_db.remoteAssetEntity.id)!).watch().map((rows) => rows.toSet());
|
||||
}
|
||||
|
||||
Future<RemoteAsset?> get(String id) {
|
||||
return _assetSelectable(id).getSingleOrNull();
|
||||
}
|
||||
@@ -59,7 +76,7 @@ class RemoteAssetRepository extends DatabaseAccessor<Drift> with $RemoteAssetRep
|
||||
|
||||
final query = _db.remoteAssetEntity.select()
|
||||
..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
|
||||
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
|
||||
..orderBy([(row) => OrderingTerm.asc(row.createdAt)]);
|
||||
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
|
||||
@@ -366,10 +366,10 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
|
||||
origin: TimelineOrigin.place,
|
||||
);
|
||||
|
||||
TimelineQuery person(List<String> userIds, String personId, GroupAssetsBy groupBy) => (
|
||||
bucketSource: () => _watchPersonBucket(userIds, personId, groupBy: groupBy),
|
||||
TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => (
|
||||
bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy),
|
||||
assetSource: (offset, count) =>
|
||||
_getPersonBucketAssets(userIds, personId, groupBy: groupBy, offset: offset, count: count),
|
||||
_getPersonBucketAssets(userId, personId, groupBy: groupBy, offset: offset, count: count),
|
||||
origin: TimelineOrigin.person,
|
||||
);
|
||||
|
||||
@@ -430,7 +430,7 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
|
||||
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
||||
}
|
||||
|
||||
Expression<bool> _personAssetFilter(List<String> userIds, String personId) {
|
||||
Expression<bool> _personAssetFilter(String userId, String personId) {
|
||||
final faceAssetIds = _db.assetFaceEntity.selectOnly()
|
||||
..addColumns([_db.assetFaceEntity.assetId])
|
||||
..where(
|
||||
@@ -438,22 +438,23 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
|
||||
_db.assetFaceEntity.isVisible.equals(true) &
|
||||
_db.assetFaceEntity.deletedAt.isNull(),
|
||||
);
|
||||
final partnerIds = _db.partnerEntity.selectOnly()
|
||||
..addColumns([_db.partnerEntity.sharedById])
|
||||
..where(_db.partnerEntity.sharedWithId.equals(userId));
|
||||
final albumAssetIds = _db.remoteAlbumAssetEntity.selectOnly()..addColumns([_db.remoteAlbumAssetEntity.assetId]);
|
||||
return _db.remoteAssetEntity.id.isInQuery(faceAssetIds) &
|
||||
(_db.remoteAssetEntity.ownerId.isIn(userIds) | _db.remoteAssetEntity.id.isInQuery(albumAssetIds)) &
|
||||
(_db.remoteAssetEntity.ownerId.equals(userId) |
|
||||
_db.remoteAssetEntity.ownerId.isInQuery(partnerIds) |
|
||||
_db.remoteAssetEntity.id.isInQuery(albumAssetIds)) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
Stream<List<Bucket>> _watchPersonBucket(
|
||||
List<String> userIds,
|
||||
String personId, {
|
||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||
}) {
|
||||
Stream<List<Bucket>> _watchPersonBucket(String userId, String personId, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
|
||||
if (groupBy == GroupAssetsBy.none) {
|
||||
final query = _db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([_db.remoteAssetEntity.id.count()])
|
||||
..where(_personAssetFilter(userIds, personId));
|
||||
..where(_personAssetFilter(userId, personId));
|
||||
|
||||
return query.map((row) {
|
||||
final count = row.read(_db.remoteAssetEntity.id.count())!;
|
||||
@@ -466,7 +467,7 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
|
||||
|
||||
final query = _db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([assetCountExp, dateExp])
|
||||
..where(_personAssetFilter(userIds, personId))
|
||||
..where(_personAssetFilter(userId, personId))
|
||||
..groupBy([dateExp])
|
||||
..orderBy([OrderingTerm.desc(dateExp)]);
|
||||
|
||||
@@ -478,14 +479,14 @@ class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositor
|
||||
}
|
||||
|
||||
Future<List<BaseAsset>> _getPersonBucketAssets(
|
||||
List<String> userIds,
|
||||
String userId,
|
||||
String personId, {
|
||||
required int offset,
|
||||
required int count,
|
||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||
}) {
|
||||
final query = _db.remoteAssetEntity.select()
|
||||
..where((_) => _personAssetFilter(userIds, personId))
|
||||
..where((_) => _personAssetFilter(userId, personId))
|
||||
..orderBy(_assetDateOrder(groupBy))
|
||||
..limit(count, offset: offset);
|
||||
|
||||
|
||||
@@ -82,8 +82,7 @@ class _PersonPageState extends ConsumerState<PersonPage> {
|
||||
throw Exception('User must be logged in to view person timeline');
|
||||
}
|
||||
|
||||
final users = ref.watch(timelineUsersProvider).valueOrNull ?? [user.id];
|
||||
final timelineService = ref.watch(timelineFactoryProvider).person(users, _person.id);
|
||||
final timelineService = ref.watch(timelineFactoryProvider).person(user.id, _person.id);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,9 @@ import 'dart:async';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/search.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
|
||||
|
||||
final searchPreFilterProvider = NotifierProvider<SearchFilterProvider, SearchFilter?>(SearchFilterProvider.new);
|
||||
@@ -32,16 +34,21 @@ class SearchState {
|
||||
}
|
||||
|
||||
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
|
||||
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
|
||||
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider), ref.watch(driftProvider).remoteAssetRepository),
|
||||
);
|
||||
|
||||
class PaginatedSearchNotifier extends StateNotifier<SearchState> {
|
||||
final SearchService _searchService;
|
||||
final RemoteAssetRepository _remoteAssetRepository;
|
||||
final _assetCountController = StreamController<int>.broadcast();
|
||||
|
||||
PaginatedSearchNotifier(this._searchService) : super(const SearchState());
|
||||
StreamSubscription<Set<String>>? _deletedIdsSubscription;
|
||||
List<BaseAsset> _results = const [];
|
||||
int? _nextPage = 1;
|
||||
|
||||
Stream<int> get assetCount => _assetCountController.stream;
|
||||
PaginatedSearchNotifier(this._searchService, this._remoteAssetRepository) : super(const SearchState());
|
||||
|
||||
Stream<int> get assetCount => _assetCountController.stream.distinct();
|
||||
|
||||
Future<void> search(SearchFilter filter) async {
|
||||
if (state.nextPage == null || state.isLoading) {
|
||||
@@ -52,24 +59,47 @@ class PaginatedSearchNotifier extends StateNotifier<SearchState> {
|
||||
|
||||
final result = await _searchService.search(filter, state.nextPage!);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result == null) {
|
||||
state = SearchState(assets: state.assets, nextPage: state.nextPage);
|
||||
return;
|
||||
}
|
||||
|
||||
final assets = [...state.assets, ...result.assets];
|
||||
state = SearchState(assets: assets, nextPage: result.nextPage);
|
||||
|
||||
_assetCountController.add(assets.length);
|
||||
_results = [..._results, ...result.assets];
|
||||
_nextPage = result.nextPage;
|
||||
_watchDeletedIds(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = const SearchState();
|
||||
_assetCountController.add(0);
|
||||
unawaited(_deletedIdsSubscription?.cancel());
|
||||
_deletedIdsSubscription = null;
|
||||
_results = const [];
|
||||
_nextPage = 1;
|
||||
_emit(const {});
|
||||
}
|
||||
|
||||
void _watchDeletedIds(AssetVisibility visibility) {
|
||||
unawaited(_deletedIdsSubscription?.cancel());
|
||||
|
||||
final ids = _results.whereType<RemoteAsset>().map((asset) => asset.id);
|
||||
_deletedIdsSubscription = _remoteAssetRepository.watchDeletedAssetIds(ids, visibility).listen(_emit);
|
||||
}
|
||||
|
||||
void _emit(Set<String> deletedIds) {
|
||||
final visible = deletedIds.isEmpty
|
||||
? _results
|
||||
: _results.where((asset) => asset is! RemoteAsset || !deletedIds.contains(asset.id)).toList(growable: false);
|
||||
|
||||
state = SearchState(assets: visible, nextPage: _nextPage);
|
||||
_assetCountController.add(visible.length);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_deletedIdsSubscription?.cancel());
|
||||
unawaited(_assetCountController.close());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.3.5-local.9+3030502
|
||||
version: 3.3.5-local.13+3030505
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
|
||||
import '../repository_context.dart';
|
||||
@@ -16,6 +18,37 @@ void main() {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
group('getStackChildren', () {
|
||||
test('returns stack children from oldest to newest', () async {
|
||||
final user = await ctx.newUser();
|
||||
const stackId = 'stack';
|
||||
final primary = await ctx.newRemoteAsset(
|
||||
id: 'primary',
|
||||
ownerId: user.id,
|
||||
stackId: stackId,
|
||||
createdAt: DateTime(2026, 1, 2),
|
||||
);
|
||||
final beforePrimary = await ctx.newRemoteAsset(
|
||||
id: 'before-primary',
|
||||
ownerId: user.id,
|
||||
stackId: stackId,
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
final afterPrimary = await ctx.newRemoteAsset(
|
||||
id: 'after-primary',
|
||||
ownerId: user.id,
|
||||
stackId: stackId,
|
||||
createdAt: DateTime(2026, 1, 3),
|
||||
);
|
||||
await ctx.newRemoteAsset(ownerId: user.id, stackId: 'other-stack');
|
||||
|
||||
final primaryAsset = await sut.get(primary.id);
|
||||
final stack = await sut.getStackChildren(primaryAsset!);
|
||||
|
||||
expect(stack.map((asset) => asset.id), [beforePrimary.id, afterPrimary.id]);
|
||||
});
|
||||
});
|
||||
|
||||
group('getByChecksum', () {
|
||||
late String userId;
|
||||
|
||||
@@ -64,4 +97,53 @@ void main() {
|
||||
expect(result[0].id, remote.id);
|
||||
});
|
||||
});
|
||||
|
||||
group('watchDeletedAssetIds', () {
|
||||
late String userId;
|
||||
|
||||
setUp(() async {
|
||||
final user = await ctx.newUser();
|
||||
userId = user.id;
|
||||
await ctx.newAuthUser(id: userId);
|
||||
});
|
||||
|
||||
test('hides trashed and archived assets but keeps matching and unsynced ones', () async {
|
||||
final timeline = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final trashed = await ctx.newRemoteAsset(ownerId: userId, deletedAt: DateTime(2020));
|
||||
final archived = await ctx.newRemoteAsset(ownerId: userId, visibility: AssetVisibility.archive);
|
||||
|
||||
final result = await sut.watchDeletedAssetIds([
|
||||
timeline.id,
|
||||
trashed.id,
|
||||
archived.id,
|
||||
'never-synced',
|
||||
], AssetVisibility.timeline).first;
|
||||
|
||||
expect(result, {trashed.id, archived.id});
|
||||
});
|
||||
|
||||
test('hides assets not matching the requested visibility for archive searches', () async {
|
||||
final timeline = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final archived = await ctx.newRemoteAsset(ownerId: userId, visibility: AssetVisibility.archive);
|
||||
|
||||
final result = await sut.watchDeletedAssetIds([timeline.id, archived.id], AssetVisibility.archive).first;
|
||||
|
||||
expect(result, {timeline.id});
|
||||
});
|
||||
|
||||
test('emits when an asset is trashed and again when it is restored', () async {
|
||||
final asset = await ctx.newRemoteAsset(ownerId: userId);
|
||||
final queue = StreamQueue(sut.watchDeletedAssetIds([asset.id], AssetVisibility.timeline));
|
||||
|
||||
expect(await queue.next, isEmpty);
|
||||
|
||||
await sut.trash([asset.id]);
|
||||
expect(await queue.next, {asset.id});
|
||||
|
||||
await sut.restoreTrash([asset.id]);
|
||||
expect(await queue.next, isEmpty);
|
||||
|
||||
await queue.cancel();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,6 +103,22 @@ void main() {
|
||||
});
|
||||
|
||||
group('person assets', () {
|
||||
test('includes partner assets even when the partner is not shown in the timeline', () async {
|
||||
final user = await ctx.newUser();
|
||||
final partner = await ctx.newUser();
|
||||
final stranger = await ctx.newUser();
|
||||
await ctx.newPartner(sharedById: partner.id, sharedWithId: user.id, inTimeline: false);
|
||||
|
||||
final person = await ctx.newPerson(ownerId: user.id);
|
||||
final partnerAsset = await ctx.newRemoteAsset(ownerId: partner.id);
|
||||
final strangerAsset = await ctx.newRemoteAsset(ownerId: stranger.id);
|
||||
await ctx.newFace(assetId: partnerAsset.id, personId: person.id);
|
||||
await ctx.newFace(assetId: strangerAsset.id, personId: person.id);
|
||||
|
||||
final assets = await sut.person(user.id, person.id, .day).assetSource(0, 10);
|
||||
expect(assets.map((a) => (a as RemoteAsset).id), [partnerAsset.id]);
|
||||
});
|
||||
|
||||
test('does not duplicate an asset that has multiple face records for the same person', () async {
|
||||
// Regression check for #26723: an INNER JOIN between remote_asset_entity and asset_face_entity
|
||||
// fanned out one asset into N rows when N face records pointed at the same (asset, person) pair
|
||||
@@ -113,7 +129,7 @@ void main() {
|
||||
await ctx.newFace(assetId: asset.id, personId: person.id);
|
||||
await ctx.newFace(assetId: asset.id, personId: person.id);
|
||||
|
||||
final query = sut.person([user.id], person.id, .day);
|
||||
final query = sut.person(user.id, person.id, .day);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
expect(buckets, hasLength(1));
|
||||
@@ -140,7 +156,7 @@ void main() {
|
||||
await ctx.newFace(assetId: shiftedLater.id, personId: person.id);
|
||||
await ctx.newFace(assetId: shiftedEarlier.id, personId: person.id);
|
||||
|
||||
final query = sut.person([user.id], person.id, .day);
|
||||
final query = sut.person(user.id, person.id, .day);
|
||||
|
||||
final buckets = await query.bucketSource().first;
|
||||
expect(buckets, hasLength(2));
|
||||
|
||||
Generated
+15
-8
@@ -56,6 +56,18 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "codecs"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"jxl",
|
||||
"libc",
|
||||
"moxcms",
|
||||
"pic-scale",
|
||||
"quick_cache",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -251,9 +263,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
@@ -287,12 +299,7 @@ dependencies = [
|
||||
name = "native_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"jxl",
|
||||
"libc",
|
||||
"moxcms",
|
||||
"pic-scale",
|
||||
"quick_cache",
|
||||
"rayon",
|
||||
"codecs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/native_core", "crates/native_core_ffi"]
|
||||
members = ["crates/codecs", "crates/native_core", "crates/native_core_ffi"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
|
||||
+3
-2
@@ -5,8 +5,9 @@ Rust code that ships inside the mobile app. Flutter compiles it from source thro
|
||||
in `mobile/` sets one up, otherwise install [rustup](https://rustup.rs) once and the build
|
||||
fetches the pinned toolchain itself.
|
||||
|
||||
- `crates/native_core`: the shared logic
|
||||
- `crates/native_core_ffi`: the C ABI, cbindgen writes `include/native_core.h`
|
||||
- `crates/codecs`: the image decoders, a feature each, behind one `decode`
|
||||
- `crates/native_core`: the shared logic, a feature per part and a group per target
|
||||
- `crates/native_core_ffi`: the C ABI on the `flutter` group, cbindgen writes `include/native_core.h`
|
||||
- `native_core`: Flutter package with the build hook and the ffigen bindings
|
||||
(`NOTICES` carries the licenses of the crates compiled into the core, update it with the dependencies)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use jxl::api::{
|
||||
AlphaMode, Endianness, Event, ExtraChannel, JxlColorEncoding, JxlColorProfile, JxlColorType,
|
||||
@@ -7,6 +7,7 @@ use jxl::api::{
|
||||
JxlParallelRunnerFun, JxlPixelFormat, JxlPrimaries, JxlTransferFunction, JxlWhitePoint,
|
||||
};
|
||||
use moxcms::{Layout, MatrixCoefficients, Transform16BitExecutor};
|
||||
use quick_cache::sync::Cache;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::color::{self, CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
|
||||
@@ -69,7 +70,7 @@ impl JxlParallelRunner for Cancellable<'_> {
|
||||
}
|
||||
|
||||
impl<'a> Jxl<'a> {
|
||||
pub fn open(mut input: &'a [u8], target: Target) -> Option<Self> {
|
||||
pub fn open(mut input: &'a [u8], color_space: Target) -> Option<Self> {
|
||||
let mut options = JxlDecoderOptions::default();
|
||||
// Colour transforms need straight alpha; premultiplying happens last.
|
||||
options.alpha_mode = AlphaMode::Unpremultiplied;
|
||||
@@ -84,7 +85,7 @@ impl<'a> Jxl<'a> {
|
||||
.ok()?;
|
||||
let profile = decoder.output_color_profile()?;
|
||||
if let Some(cicp) = cicp(profile)
|
||||
&& (target == Target::Source || color::is_srgb(cicp))
|
||||
&& (color_space == Target::Source || color::is_srgb(cicp))
|
||||
{
|
||||
if color::is_hdr(cicp) {
|
||||
decoder
|
||||
@@ -98,7 +99,7 @@ impl<'a> Jxl<'a> {
|
||||
transform: None,
|
||||
});
|
||||
}
|
||||
let (transform, layout) = color::to_srgb(&profile.try_as_icc()?)?;
|
||||
let (transform, layout) = srgb_transform(profile)?;
|
||||
decoder
|
||||
.set_pixel_format(JxlPixelFormat {
|
||||
color_type: match layout {
|
||||
@@ -120,11 +121,11 @@ impl<'a> Jxl<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Decodes the first frame unless cancelled, downscaled to cover `target` pixels and to fit
|
||||
/// within `max_pixel_size`.
|
||||
/// Decodes the first frame unless cancelled, downscaled to cover `target_size` pixels and to
|
||||
/// fit within `max_pixel_size`.
|
||||
pub fn decode(
|
||||
mut self,
|
||||
target: Option<(usize, usize)>,
|
||||
target_size: Option<(usize, usize)>,
|
||||
max_pixel_size: usize,
|
||||
cancelled: &AtomicBool,
|
||||
) -> Option<Image> {
|
||||
@@ -138,7 +139,7 @@ impl<'a> Jxl<'a> {
|
||||
let pixel_bytes =
|
||||
format.color_type.samples_per_pixel() * format.color_data_format?.bytes_per_sample();
|
||||
let stride = w.checked_mul(pixel_bytes)?;
|
||||
let size = resize::cover((w, h), target, max_pixel_size);
|
||||
let size = resize::cover((w, h), target_size, max_pixel_size);
|
||||
let samples = if color::is_hdr(self.cicp) {
|
||||
let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?;
|
||||
self.decode_frame(&mut rgba, stride, cancelled)?;
|
||||
@@ -233,3 +234,23 @@ fn cicp(profile: &JxlColorProfile) -> Option<CicpProfile> {
|
||||
full_range: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reuse the transform to sRGB for a given source profile.
|
||||
type Transforms<K> = Cache<K, (Arc<Transform16BitExecutor>, Layout)>;
|
||||
|
||||
/// [`color::to_srgb`] for `profile`, built once per distinct profile.
|
||||
fn srgb_transform(profile: &JxlColorProfile) -> Option<(Arc<Transform16BitExecutor>, Layout)> {
|
||||
const TRANSFORMS: usize = 8;
|
||||
static ENCODINGS: LazyLock<Transforms<String>> = LazyLock::new(|| Cache::new(TRANSFORMS));
|
||||
static ICCS: LazyLock<Transforms<Vec<u8>>> = LazyLock::new(|| Cache::new(TRANSFORMS));
|
||||
match profile {
|
||||
JxlColorProfile::Simple(encoding) => ENCODINGS
|
||||
.get_or_insert_with(&encoding.get_color_encoding_description(), || {
|
||||
color::to_srgb(&profile.try_as_icc().ok_or(())?).ok_or(())
|
||||
}),
|
||||
JxlColorProfile::Icc(icc) => {
|
||||
ICCS.get_or_insert_with(icc.as_slice(), || color::to_srgb(icc).ok_or(()))
|
||||
}
|
||||
}
|
||||
.ok()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use color::Target;
|
||||
use image::Image;
|
||||
|
||||
pub mod color;
|
||||
pub mod image;
|
||||
#[cfg(feature = "jxl")]
|
||||
pub mod jxl;
|
||||
mod resize;
|
||||
|
||||
enum Format {
|
||||
#[cfg(feature = "jxl")]
|
||||
Jxl,
|
||||
}
|
||||
|
||||
pub struct Options {
|
||||
pub color_space: Target,
|
||||
|
||||
/// Downscales to the smallest size that still fills this.
|
||||
pub target_size: Option<(usize, usize)>,
|
||||
|
||||
/// Downscales to fit within this many pixels per side.
|
||||
pub max_pixel_size: usize,
|
||||
}
|
||||
|
||||
fn probe(data: &[u8]) -> Option<Format> {
|
||||
#[cfg(feature = "jxl")]
|
||||
if matches!(
|
||||
::jxl::api::check_signature(data),
|
||||
::jxl::api::JxlSignature::Codestream | ::jxl::api::JxlSignature::Container
|
||||
) {
|
||||
return Some(Format::Jxl);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Decodes the first frame of `data` unless cancelled.
|
||||
pub fn decode(data: &[u8], options: &Options, cancelled: &AtomicBool) -> Option<Image> {
|
||||
match probe(data)? {
|
||||
#[cfg(feature = "jxl")]
|
||||
Format::Jxl => jxl::Jxl::open(data, options.color_space)?.decode(
|
||||
options.target_size,
|
||||
options.max_pixel_size,
|
||||
cancelled,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -47,16 +47,16 @@ impl Lanczos for u16 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The size of `size` scaled down to cover `target` and to fit within `max_pixel_size`, if that
|
||||
/// is smaller.
|
||||
/// The size of `size` scaled down to cover `target_size` and to fit within `max_pixel_size`, if
|
||||
/// that is smaller.
|
||||
pub(crate) fn cover(
|
||||
(w, h): (usize, usize),
|
||||
target: Option<(usize, usize)>,
|
||||
target_size: Option<(usize, usize)>,
|
||||
max_pixel_size: usize,
|
||||
) -> Option<(usize, usize)> {
|
||||
// Scales as side ratios, so the side that sets the scale lands exactly on its limit.
|
||||
let fit = (max_pixel_size, w.max(h));
|
||||
let (num, den) = match target {
|
||||
let (num, den) = match target_size {
|
||||
Some((tw, th)) => {
|
||||
let cover = if tw * h >= th * w { (tw, w) } else { (th, h) };
|
||||
if cover.0 * fit.1 < fit.0 * cover.1 {
|
||||
Vendored
Vendored
Vendored
@@ -1,11 +1,19 @@
|
||||
#![cfg(feature = "jxl")]
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use native_core::color::{CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
|
||||
use native_core::image::{Image, Pixels, Samples};
|
||||
use native_core::jxl::Jxl;
|
||||
use codecs::Options;
|
||||
use codecs::color::{CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
|
||||
use codecs::image::{Image, Pixels, Samples};
|
||||
use codecs::jxl::Jxl;
|
||||
|
||||
fn decode(data: &[u8], target: Target) -> Option<Image> {
|
||||
Jxl::open(data, target)?.decode(None, 16384, &AtomicBool::new(false))
|
||||
fn decode(data: &[u8], color_space: Target) -> Option<Image> {
|
||||
let options = Options {
|
||||
color_space,
|
||||
target_size: None,
|
||||
max_pixel_size: 16384,
|
||||
};
|
||||
codecs::decode(data, &options, &AtomicBool::new(false))
|
||||
}
|
||||
|
||||
fn decode_srgb(data: &[u8]) -> Option<Pixels<u8>> {
|
||||
@@ -83,6 +91,36 @@ fn converts_lossy_icc_to_srgb() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_decodes_keep_profile_colors_when_transforms_are_reused() {
|
||||
let cases: [(&[u8], [u8; 8]); 3] = [
|
||||
(
|
||||
include_bytes!("fixtures/p3.jxl"),
|
||||
[255, 0, 0, 255, 255, 255, 255, 255],
|
||||
),
|
||||
(
|
||||
include_bytes!("fixtures/gray_icc.jxl"),
|
||||
[81, 81, 81, 255, 203, 203, 203, 255],
|
||||
),
|
||||
(
|
||||
include_bytes!("fixtures/srgb_alpha.jxl"),
|
||||
[128, 0, 0, 128, 0, 0, 255, 255],
|
||||
),
|
||||
];
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..4 {
|
||||
scope.spawn(|| {
|
||||
for _ in 0..8 {
|
||||
for (data, expected) in cases {
|
||||
let rgba = decode_srgb(data).unwrap();
|
||||
assert_eq!(rgba.pixels().as_flattened(), &expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_maps_hdr() {
|
||||
let rgba = decode_srgb(include_bytes!("fixtures/pq_grays.jxl")).unwrap();
|
||||
@@ -4,13 +4,16 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
convert = []
|
||||
rotate = []
|
||||
thumbhash = []
|
||||
jxl = ["codecs/jxl"]
|
||||
# One group per target.
|
||||
flutter = ["convert", "rotate", "thumbhash", "jxl"]
|
||||
|
||||
[dependencies]
|
||||
jxl = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
moxcms = { workspace = true }
|
||||
pic-scale = { workspace = true }
|
||||
quick_cache = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
codecs = { path = "../codecs", default-features = false, optional = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
pub mod color;
|
||||
#[cfg(feature = "codecs")]
|
||||
pub use codecs;
|
||||
#[cfg(feature = "convert")]
|
||||
pub mod convert;
|
||||
pub mod image;
|
||||
pub mod jxl;
|
||||
mod resize;
|
||||
#[cfg(feature = "rotate")]
|
||||
pub mod rotate;
|
||||
#[cfg(feature = "thumbhash")]
|
||||
pub mod thumbhash;
|
||||
|
||||
pub fn version() -> &'static str {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg(feature = "convert")]
|
||||
|
||||
use native_core::convert::convert_1010102;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg(feature = "rotate")]
|
||||
|
||||
use native_core::rotate::{Orientation, rotate};
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg(feature = "thumbhash")]
|
||||
|
||||
use native_core::thumbhash::decode;
|
||||
|
||||
// Expected pixels come from the old Swift decoder patched to round like the Java did (it truncated),
|
||||
|
||||
@@ -10,7 +10,7 @@ name = "native_core_ffi"
|
||||
crate-type = ["cdylib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
native_core = { path = "../native_core" }
|
||||
native_core = { path = "../native_core", features = ["flutter"] }
|
||||
libc = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::path::Path;
|
||||
fn main() {
|
||||
// Files that define exported items. A directory here would make Flutter rerun the hook every build.
|
||||
println!("cargo:rerun-if-changed=src/lib.rs");
|
||||
println!("cargo:rerun-if-changed=src/jxl.rs");
|
||||
println!("cargo:rerun-if-changed=src/decode.rs");
|
||||
println!("cargo:rerun-if-changed=src/thumbhash.rs");
|
||||
println!("cargo:rerun-if-changed=cbindgen.toml");
|
||||
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
|
||||
+17
-14
@@ -10,9 +10,9 @@ use jni::{
|
||||
objects::{JByteBuffer, JClass, JIntArray},
|
||||
sys::{jint, jlong},
|
||||
};
|
||||
use native_core::color::Target;
|
||||
use native_core::image::{Image, Samples};
|
||||
use native_core::jxl::Jxl;
|
||||
use native_core::codecs::color::Target;
|
||||
use native_core::codecs::image::{Image, Samples};
|
||||
use native_core::codecs::{self, Options};
|
||||
|
||||
use super::guard;
|
||||
|
||||
@@ -30,7 +30,7 @@ pub struct NativeCoreImageInfo {
|
||||
pub transfer: u8,
|
||||
}
|
||||
|
||||
/// Decodes a JPEG XL image to premultiplied RGBA in sRGB, or with `keep_space`, in the image's own
|
||||
/// Decodes an image to premultiplied RGBA in sRGB, or with `keep_space`, in the image's own
|
||||
/// space when it has BT.709, BT.2020 or Display P3 primaries with the sRGB, PQ or HLG transfer.
|
||||
/// Unless either is 0, it is downscaled to cover `target_width` x `target_height`, and it never
|
||||
/// exceeds `max_pixel_size` pixels per side. `info` gets the size and space. The caller frees the
|
||||
@@ -42,7 +42,7 @@ pub struct NativeCoreImageInfo {
|
||||
/// `cancelled` must be null or valid for the call and only changed through `native_core_cancel`.
|
||||
/// `info` must be null or a valid writable pointer, outside `data`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn native_core_jxl_decode(
|
||||
pub unsafe extern "C" fn native_core_decode_image(
|
||||
data: *const u8,
|
||||
len: size_t,
|
||||
keep_space: bool,
|
||||
@@ -60,16 +60,19 @@ pub unsafe extern "C" fn native_core_jxl_decode(
|
||||
let cancelled = unsafe { AtomicBool::from_ptr(cancelled) };
|
||||
// SAFETY: the caller provides a buffer readable for len bytes.
|
||||
let input = unsafe { slice::from_raw_parts(data, len) };
|
||||
let target = if keep_space {
|
||||
let color_space = if keep_space {
|
||||
Target::Source
|
||||
} else {
|
||||
Target::Srgb
|
||||
};
|
||||
let cover = (target_width > 0 && target_height > 0)
|
||||
let target_size = (target_width > 0 && target_height > 0)
|
||||
.then_some((target_width as usize, target_height as usize));
|
||||
let Some(Image { cicp, samples }) = Jxl::open(input, target)
|
||||
.and_then(|image| image.decode(cover, max_pixel_size as usize, cancelled))
|
||||
else {
|
||||
let options = Options {
|
||||
color_space,
|
||||
target_size,
|
||||
max_pixel_size: max_pixel_size as usize,
|
||||
};
|
||||
let Some(Image { cicp, samples }) = codecs::decode(input, &options, cancelled) else {
|
||||
return ptr::null_mut();
|
||||
};
|
||||
let (width, height, bit_depth, pixels) = match samples {
|
||||
@@ -93,7 +96,7 @@ pub unsafe extern "C" fn native_core_jxl_decode(
|
||||
/// Asks the decode given `flag` to stop early.
|
||||
///
|
||||
/// # Safety
|
||||
/// `flag` must be null or valid and otherwise only read by `native_core_jxl_decode`.
|
||||
/// `flag` must be null or valid and otherwise only read by `native_core_decode_image`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn native_core_cancel(flag: *mut bool) {
|
||||
if !flag.is_null() {
|
||||
@@ -104,7 +107,7 @@ pub unsafe extern "C" fn native_core_cancel(flag: *mut bool) {
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_app_alextran_immich_NativeImage_decodeJxl<'caller>(
|
||||
pub extern "system" fn Java_app_alextran_immich_NativeImage_decode<'caller>(
|
||||
mut env: EnvUnowned<'caller>,
|
||||
_class: JClass<'caller>,
|
||||
data: jlong,
|
||||
@@ -121,7 +124,7 @@ pub extern "system" fn Java_app_alextran_immich_NativeImage_decodeJxl<'caller>(
|
||||
// SAFETY: Kotlin passes a live native buffer of `length` bytes and a direct buffer it only
|
||||
// changes through `cancel`, and the output is valid.
|
||||
let rgba = unsafe {
|
||||
native_core_jxl_decode(
|
||||
native_core_decode_image(
|
||||
data as *const u8,
|
||||
length as size_t,
|
||||
false,
|
||||
@@ -154,7 +157,7 @@ pub extern "system" fn Java_app_alextran_immich_NativeImage_cancel<'caller>(
|
||||
) {
|
||||
env.with_env(|env| -> jni::errors::Result<()> {
|
||||
let flag = env.get_direct_buffer_address(&flag)?.cast::<bool>();
|
||||
// SAFETY: the buffer is the live one-byte flag Kotlin passed to `decodeJxl`.
|
||||
// SAFETY: the buffer is the live one-byte flag Kotlin passed to `decode`.
|
||||
unsafe { native_core_cancel(flag) };
|
||||
Ok(())
|
||||
})
|
||||
@@ -9,7 +9,7 @@ mod android;
|
||||
mod bitmap;
|
||||
#[cfg(target_os = "android")]
|
||||
mod convert;
|
||||
mod jxl;
|
||||
mod decode;
|
||||
#[cfg(target_os = "android")]
|
||||
mod rotate;
|
||||
mod thumbhash;
|
||||
|
||||
@@ -11,6 +11,7 @@ const _manifests = [
|
||||
'../.cargo/config.toml',
|
||||
'../Cargo.toml',
|
||||
'../Cargo.lock',
|
||||
'../crates/codecs/Cargo.toml',
|
||||
'../crates/native_core/Cargo.toml',
|
||||
'$_crate/Cargo.toml',
|
||||
'$_crate/rust-toolchain.toml',
|
||||
|
||||
@@ -1237,6 +1237,7 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "notification.create",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -1306,6 +1307,7 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "notification.create",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -1366,6 +1368,7 @@
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "notification.create",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
@@ -6340,7 +6343,7 @@
|
||||
"state": "Added"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "clusterGroup.read"
|
||||
"x-immich-permission": "clusterGroup.admin"
|
||||
}
|
||||
},
|
||||
"/cluster-groups/{id}/requests": {
|
||||
@@ -23130,6 +23133,7 @@
|
||||
"description": "Image format",
|
||||
"enum": [
|
||||
"jpeg",
|
||||
"jxl",
|
||||
"webp"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -25807,6 +25811,7 @@
|
||||
"backup.delete",
|
||||
"clusterGroup.read",
|
||||
"clusterGroup.leave",
|
||||
"clusterGroup.admin",
|
||||
"clusterGroupRequest.create",
|
||||
"clusterGroupRequest.read",
|
||||
"clusterGroupRequest.delete",
|
||||
@@ -28462,11 +28467,13 @@
|
||||
"ServerFeatureLevel": {
|
||||
"description": "Server feature level",
|
||||
"enum": [
|
||||
1
|
||||
1,
|
||||
2
|
||||
],
|
||||
"type": "integer",
|
||||
"x-enum-varnames": [
|
||||
"FeatureLevel"
|
||||
"FeatureLevel",
|
||||
"Passkeys"
|
||||
]
|
||||
},
|
||||
"ServerFeaturesDto": {
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
|
||||
"spaces": 2,
|
||||
"generator-cli": {
|
||||
"version": "7.25.0"
|
||||
"version": "7.26.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"format": "prettier --cache --check i18n/",
|
||||
"format:fix": "prettier --cache --write --list-different i18n"
|
||||
},
|
||||
"packageManager": "pnpm@12.14.0",
|
||||
"packageManager": "pnpm@12.15.0",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.22.1-trixie-slim@sha256:fdc9220097e0ae3bd947c44b10cd3af5867a67a6d3a23b30b30b70929219034e AS core
|
||||
FROM node:24.22.1-trixie-slim@sha256:43b9ba1142b8dcf33fa8f922ca1be85714adc444e0586c7cf7726daaeabc1bea AS core
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY package* pnpm* .pnpmfile.cjs ./
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^75.0.0",
|
||||
"eslint-plugin-unicorn": "^78.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.7.4",
|
||||
@@ -62,7 +62,7 @@
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"chokidar": "^6.0.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fastq": "^1.17.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
+19
-19
@@ -28,11 +28,11 @@ export const defaultQueueOptions = {
|
||||
};
|
||||
|
||||
/**
|
||||
* An in-memory queue that processes tasks in parallel with a given concurrency.
|
||||
* @see {@link https://www.npmjs.com/package/fastq}
|
||||
* @template T - The type of the worker task data.
|
||||
* @template R - The type of the worker output data.
|
||||
*/
|
||||
An in-memory queue that processes tasks in parallel with a given concurrency.
|
||||
@see {@link https://www.npmjs.com/package/fastq}
|
||||
@template T - The type of the worker task data.
|
||||
@template R - The type of the worker output data.
|
||||
*/
|
||||
export class Queue<T, R> {
|
||||
private readonly queue: fastq.queueAsPromised<string, Task<T, R>>;
|
||||
private readonly store = new Map<string, Task<T, R>>();
|
||||
@@ -40,10 +40,10 @@ export class Queue<T, R> {
|
||||
readonly worker: (data: T) => Promise<R>;
|
||||
|
||||
/**
|
||||
* Create a new queue.
|
||||
* @param worker - The worker function that processes the task.
|
||||
* @param options - The queue options.
|
||||
*/
|
||||
Create a new queue.
|
||||
@param worker - The worker function that processes the task.
|
||||
@param options - The queue options.
|
||||
*/
|
||||
constructor(worker: (data: T) => Promise<R>, options?: QueueOptions) {
|
||||
this.options = { ...defaultQueueOptions, ...options };
|
||||
this.worker = worker;
|
||||
@@ -65,21 +65,21 @@ export class Queue<T, R> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the queue to be empty.
|
||||
* @returns Promise<void> - The returned Promise will be resolved when all tasks in the queue have been processed by a worker.
|
||||
* This promise could be ignored as it will not lead to a `unhandledRejection`.
|
||||
*/
|
||||
Wait for the queue to be empty.
|
||||
@returns Promise<void> - The returned Promise will be resolved when all tasks in the queue have been processed by a worker.
|
||||
This promise could be ignored as it will not lead to a `unhandledRejection`.
|
||||
*/
|
||||
drained(): Promise<void> {
|
||||
return this.queue.drained();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a task at the end of the queue.
|
||||
* @see {@link https://www.npmjs.com/package/fastq}
|
||||
* @param data
|
||||
* @returns Promise<void> - A Promise that will be fulfilled (rejected) when the task is completed successfully (unsuccessfully).
|
||||
* This promise could be ignored as it will not lead to a `unhandledRejection`.
|
||||
*/
|
||||
Add a task at the end of the queue.
|
||||
@see {@link https://www.npmjs.com/package/fastq}
|
||||
@param data
|
||||
@returns Promise<void> - A Promise that will be fulfilled (rejected) when the task is completed successfully (unsuccessfully).
|
||||
This promise could be ignored as it will not lead to a `unhandledRejection`.
|
||||
*/
|
||||
async push(data: T): Promise<Task<T, R>> {
|
||||
const id = uniqueId();
|
||||
const task: Task<T, R> = { id, status: 'idle', error: undefined, count: 0, data, result: undefined };
|
||||
|
||||
@@ -218,9 +218,9 @@ export const sha1 = (filepath: string) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Batches items and calls onBatch to process them
|
||||
* when the batch size is reached or the debounce time has passed.
|
||||
*/
|
||||
Batches items and calls onBatch to process them
|
||||
when the batch size is reached or the debounce time has passed.
|
||||
*/
|
||||
export class Batcher<T = unknown> {
|
||||
private items: T[] = [];
|
||||
private readonly batchSize: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.22.1-trixie-slim@sha256:fdc9220097e0ae3bd947c44b10cd3af5867a67a6d3a23b30b30b70929219034e
|
||||
FROM node:24.22.1-trixie-slim@sha256:43b9ba1142b8dcf33fa8f922ca1be85714adc444e0586c7cf7726daaeabc1bea
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"oidc-provider": "^9.0.0",
|
||||
"tsx": "^4.20.6"
|
||||
},
|
||||
"packageManager": "pnpm@12.14.0"
|
||||
"packageManager": "pnpm@12.15.0"
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@
|
||||
"matchType": {
|
||||
"type": "string",
|
||||
"title": "Match type",
|
||||
"enum": ["contains", "startsWith", "exact", "regex"],
|
||||
"enum": ["contains", "startsWith", "exact", "regex", "present", "missing"],
|
||||
"default": "contains",
|
||||
"description": "Type of pattern matching to perform",
|
||||
"uiHint": {
|
||||
@@ -424,7 +424,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["property", "pattern"]
|
||||
"required": ["property"]
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
},
|
||||
|
||||
@@ -3,13 +3,18 @@ import { AssetVisibility } from '@immich/sdk';
|
||||
import type { Manifest } from '../dist/index.d.ts';
|
||||
|
||||
type MatchValueConfig = {
|
||||
pattern: string;
|
||||
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
|
||||
pattern?: string;
|
||||
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith' | 'missing' | 'present';
|
||||
caseSensitive?: boolean;
|
||||
};
|
||||
|
||||
const matchValueResult = (value: string, config: MatchValueConfig) => {
|
||||
const { pattern, matchType = 'contains', caseSensitive = false } = config;
|
||||
const matchValueResult = (value: string | null, config: MatchValueConfig) => {
|
||||
const { pattern = '', matchType = 'contains', caseSensitive = false } = config;
|
||||
|
||||
if (value === null) {
|
||||
return { workflow: { continue: matchType === 'missing' } };
|
||||
}
|
||||
|
||||
const searchName = caseSensitive ? value : value.toLowerCase();
|
||||
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
||||
|
||||
@@ -32,6 +37,15 @@ const matchValueResult = (value: string, config: MatchValueConfig) => {
|
||||
return { workflow: { continue: regex.test(value) } };
|
||||
}
|
||||
|
||||
case 'present': {
|
||||
return { workflow: { continue: true } };
|
||||
}
|
||||
|
||||
case 'missing': {
|
||||
// value is not null at this point
|
||||
return { workflow: { continue: false } };
|
||||
}
|
||||
|
||||
default: {
|
||||
return {};
|
||||
}
|
||||
@@ -135,11 +149,10 @@ const methods = wrapper<Manifest>({
|
||||
},
|
||||
|
||||
assetExifFilter: ({ config, data }) => {
|
||||
if (!data.asset.exifInfo || data.asset.exifInfo[config.property] === null) {
|
||||
return { workflow: { continue: false } };
|
||||
}
|
||||
let value = data.asset.exifInfo?.[config.property];
|
||||
|
||||
return matchValueResult(String(data.asset.exifInfo[config.property]), config);
|
||||
// Empty EXIF value is treated as missing to mirror behavior of the web UI
|
||||
return matchValueResult(value === null || value === undefined || value === '' ? null : String(value), config);
|
||||
},
|
||||
|
||||
assetDateFilter: ({ config, data }) => {
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"packageManager": "pnpm@12.14.0",
|
||||
"packageManager": "pnpm@12.15.0",
|
||||
"devDependencies": {
|
||||
"@extism/js-pdk": "^1.1.1",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@types/node": "^24.19.2",
|
||||
"esbuild": "^0.28.0",
|
||||
"tsc-alias": "1.9.5",
|
||||
"tsc-alias": "1.9.9",
|
||||
"typescript": "^7.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -33,5 +33,5 @@
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@12.14.0"
|
||||
"packageManager": "pnpm@12.15.0"
|
||||
}
|
||||
|
||||
@@ -8214,6 +8214,7 @@ export enum Colorspace {
|
||||
}
|
||||
export enum ImageFormat {
|
||||
Jpeg = "jpeg",
|
||||
Jxl = "jxl",
|
||||
Webp = "webp"
|
||||
}
|
||||
export enum LogLevel {
|
||||
@@ -8350,6 +8351,7 @@ export enum Permission {
|
||||
BackupDelete = "backup.delete",
|
||||
ClusterGroupRead = "clusterGroup.read",
|
||||
ClusterGroupLeave = "clusterGroup.leave",
|
||||
ClusterGroupAdmin = "clusterGroup.admin",
|
||||
ClusterGroupRequestCreate = "clusterGroupRequest.create",
|
||||
ClusterGroupRequestRead = "clusterGroupRequest.read",
|
||||
ClusterGroupRequestDelete = "clusterGroupRequest.delete",
|
||||
@@ -8822,7 +8824,8 @@ export enum ReleaseType {
|
||||
Prerelease = "prerelease"
|
||||
}
|
||||
export enum ServerFeatureLevel {
|
||||
FeatureLevel = 1
|
||||
FeatureLevel = 1,
|
||||
Passkeys = 2
|
||||
}
|
||||
export enum UserMetadataKey {
|
||||
Preferences = "preferences",
|
||||
|
||||
Generated
+852
-758
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user