Compare 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 | ||
|
|
a5b2dcb3c2 | ||
|
|
f94fbfe14e | ||
|
|
70ef2884f0 | ||
|
|
918d66fbdb | ||
|
|
5d1167a36c | ||
|
|
e3bf54e2ba | ||
|
|
d17640a283 | ||
|
|
9e889bd4dc | ||
|
|
ca4d8d5a7a | ||
|
|
5f54700344 | ||
|
|
0991f506bb | ||
|
|
fa77f168a0 | ||
|
|
3aa65809b1 | ||
|
|
9a66f16102 | ||
|
|
493870ad7b | ||
|
|
1e20bdd74e | ||
|
|
99a7830f6c |
@@ -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'));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { playwrightHost } from 'src/../playwright.config.js';
|
||||
import { adminConfig, publicConfig, userConfig } from 'src/ui/mock-network/config-fixtures.js';
|
||||
|
||||
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
|
||||
await context.addCookies([
|
||||
@@ -113,6 +114,27 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
||||
},
|
||||
});
|
||||
});
|
||||
await context.route('**/api/public/config', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: publicConfig,
|
||||
});
|
||||
});
|
||||
await context.route('**/api/config', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: userConfig,
|
||||
});
|
||||
});
|
||||
await context.route('**/api/admin/config', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: adminConfig,
|
||||
});
|
||||
});
|
||||
await context.route('**/api/server/features', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
export const publicConfig = {
|
||||
ffmpeg: { realtime: { enabled: false } },
|
||||
map: {
|
||||
enabled: true,
|
||||
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
},
|
||||
oauth: { autoLaunch: false, buttonText: 'Login with OAuth', enabled: false },
|
||||
passkey: { enabled: true },
|
||||
passwordLogin: { enabled: true },
|
||||
server: { loginPageMessage: '' },
|
||||
};
|
||||
|
||||
export const userConfig = {
|
||||
ffmpeg: { realtime: { enabled: false, videoCodecs: ['h264', 'hevc'], resolutions: [480, 720, 1080] } },
|
||||
machineLearning: {
|
||||
enabled: false,
|
||||
clip: { enabled: true },
|
||||
duplicateDetection: { enabled: true },
|
||||
facialRecognition: { enabled: true, minFaces: 3 },
|
||||
ocr: { enabled: true },
|
||||
},
|
||||
map: {
|
||||
enabled: true,
|
||||
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
},
|
||||
reverseGeocoding: { enabled: true },
|
||||
oauth: { autoLaunch: false, buttonText: 'Login with OAuth', enabled: false, accountManagementUrl: '' },
|
||||
passkey: { enabled: true },
|
||||
passwordLogin: { enabled: true },
|
||||
image: { thumbnail: { size: 250 }, preview: { size: 1440 }, fullsize: { enabled: false } },
|
||||
trash: { enabled: true, days: 30 },
|
||||
notifications: { smtp: { enabled: false } },
|
||||
server: { externalDomain: '', loginPageMessage: '', publicUsers: true },
|
||||
user: { deleteDelay: 7 },
|
||||
};
|
||||
|
||||
export const adminConfig = {
|
||||
backup: { database: { enabled: true, cronExpression: '0 02 * * *', keepLastAmount: 14 } },
|
||||
ffmpeg: {
|
||||
crf: 23,
|
||||
threads: 0,
|
||||
preset: 'ultrafast',
|
||||
targetVideoCodec: 'h264',
|
||||
acceptedVideoCodecs: ['h264'],
|
||||
targetAudioCodec: 'aac',
|
||||
acceptedAudioCodecs: ['aac', 'mp3', 'opus'],
|
||||
acceptedContainers: ['mov', 'ogg', 'webm'],
|
||||
targetResolution: '720',
|
||||
maxBitrate: '0',
|
||||
bframes: -1,
|
||||
refs: 0,
|
||||
gopSize: 0,
|
||||
temporalAQ: false,
|
||||
cqMode: 'auto',
|
||||
twoPass: false,
|
||||
preferredHwDevice: 'auto',
|
||||
transcode: 'required',
|
||||
tonemap: 'hable',
|
||||
accel: 'disabled',
|
||||
accelDecode: true,
|
||||
realtime: { enabled: false, videoCodecs: ['h264', 'hevc'], resolutions: [480, 720, 1080] },
|
||||
},
|
||||
integrityChecks: {
|
||||
missingFiles: { enabled: true, cronExpression: '0 03 * * *' },
|
||||
untrackedFiles: { enabled: true, cronExpression: '0 03 * * *' },
|
||||
checksumFiles: { enabled: true, cronExpression: '0 03 * * *', timeLimit: 3_600_000, percentageLimit: 1 },
|
||||
},
|
||||
job: {
|
||||
thumbnailGeneration: { concurrency: 3 },
|
||||
metadataExtraction: { concurrency: 5 },
|
||||
videoConversion: { concurrency: 1 },
|
||||
faceDetection: { concurrency: 2 },
|
||||
smartSearch: { concurrency: 2 },
|
||||
backgroundTask: { concurrency: 5 },
|
||||
migration: { concurrency: 5 },
|
||||
search: { concurrency: 5 },
|
||||
sidecar: { concurrency: 5 },
|
||||
library: { concurrency: 5 },
|
||||
notifications: { concurrency: 5 },
|
||||
ocr: { concurrency: 1 },
|
||||
workflow: { concurrency: 5 },
|
||||
editor: { concurrency: 2 },
|
||||
integrityCheck: { concurrency: 1 },
|
||||
},
|
||||
logging: { enabled: true, level: 'log' },
|
||||
machineLearning: {
|
||||
enabled: false,
|
||||
urls: ['http://immich-machine-learning:3003'],
|
||||
availabilityChecks: { enabled: true, timeout: 2000, interval: 30_000 },
|
||||
clip: { enabled: true, modelName: 'ViT-B-32__openai' },
|
||||
duplicateDetection: { enabled: true, maxDistance: 0.01 },
|
||||
facialRecognition: { enabled: true, modelName: 'buffalo_l', minScore: 0.7, maxDistance: 0.5, minFaces: 3 },
|
||||
ocr: {
|
||||
enabled: true,
|
||||
modelName: 'PP-OCRv5_mobile',
|
||||
minDetectionScore: 0.5,
|
||||
minRecognitionScore: 0.8,
|
||||
maxResolution: 736,
|
||||
},
|
||||
},
|
||||
map: {
|
||||
enabled: true,
|
||||
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
},
|
||||
reverseGeocoding: { enabled: true },
|
||||
metadata: { faces: { import: false } },
|
||||
oauth: {
|
||||
autoLaunch: false,
|
||||
autoRegister: true,
|
||||
buttonText: 'Login with OAuth',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
defaultStorageQuota: null,
|
||||
enabled: false,
|
||||
issuerUrl: '',
|
||||
accountManagementUrl: '',
|
||||
endSessionEndpoint: '',
|
||||
mobileOverrideEnabled: false,
|
||||
mobileRedirectUri: '',
|
||||
prompt: '',
|
||||
scope: 'openid email profile',
|
||||
signingAlgorithm: 'RS256',
|
||||
profileSigningAlgorithm: 'none',
|
||||
storageLabelClaim: 'preferred_username',
|
||||
storageQuotaClaim: 'immich_quota',
|
||||
roleClaim: 'immich_role',
|
||||
tokenEndpointAuthMethod: 'client_secret_post',
|
||||
timeout: 30_000,
|
||||
allowInsecureRequests: false,
|
||||
},
|
||||
passkey: { enabled: true, domain: null, additionalDomains: [] },
|
||||
passwordLogin: { enabled: true },
|
||||
storageTemplate: {
|
||||
enabled: false,
|
||||
hashVerificationEnabled: true,
|
||||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
},
|
||||
image: {
|
||||
thumbnail: { format: 'webp', size: 250, quality: 80, progressive: false },
|
||||
preview: { format: 'jpeg', size: 1440, quality: 80, progressive: false },
|
||||
colorspace: 'p3',
|
||||
extractEmbedded: false,
|
||||
fullsize: { enabled: false, format: 'jpeg', quality: 80, progressive: false },
|
||||
},
|
||||
newVersionCheck: { enabled: true, channel: 'stable' },
|
||||
nightlyTasks: {
|
||||
startTime: '00:00',
|
||||
databaseCleanup: true,
|
||||
generateMemories: true,
|
||||
syncQuotaUsage: true,
|
||||
missingThumbnails: true,
|
||||
clusterNewFaces: true,
|
||||
},
|
||||
trash: { enabled: true, days: 30 },
|
||||
theme: { customCss: '' },
|
||||
library: { scan: { enabled: true, cronExpression: '0 0 * * *' }, watch: { enabled: false } },
|
||||
server: { externalDomain: '', loginPageMessage: '', publicUsers: true },
|
||||
notifications: {
|
||||
smtp: {
|
||||
enabled: false,
|
||||
from: '',
|
||||
replyTo: '',
|
||||
transport: { ignoreCert: false, host: '', port: 587, secure: false, username: '', password: '' },
|
||||
},
|
||||
},
|
||||
templates: { email: { welcomeTemplate: '', albumInviteTemplate: '', albumUpdateTemplate: '' } },
|
||||
user: { deleteDelay: 7 },
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.12)
|
||||
|
||||
set(CMAKE_C_STANDARD 17)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
project(native_buffer LANGUAGES C)
|
||||
|
||||
add_library(native_buffer SHARED
|
||||
src/main/cpp/native_buffer.c
|
||||
src/main/cpp/native_image.c
|
||||
)
|
||||
|
||||
target_link_libraries(native_buffer jnigraphics)
|
||||
@@ -79,11 +79,6 @@ android {
|
||||
}
|
||||
namespace 'app.alextran.immich'
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path "CMakeLists.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeBuffer_allocate(
|
||||
JNIEnv *env, jclass clazz, jint size) {
|
||||
void *ptr = malloc(size);
|
||||
return (jlong) ptr;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_alextran_immich_NativeBuffer_free(
|
||||
JNIEnv *env, jclass clazz, jlong address) {
|
||||
free((void *) address);
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeBuffer_realloc(
|
||||
JNIEnv *env, jclass clazz, jlong address, jint size) {
|
||||
void *ptr = realloc((void *) address, size);
|
||||
return (jlong) ptr;
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_app_alextran_immich_NativeBuffer_wrap(
|
||||
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
|
||||
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_alextran_immich_NativeBuffer_copy(
|
||||
JNIEnv *env, jclass clazz, jobject buffer, jlong destAddress, jint offset, jint length) {
|
||||
void *src = (*env)->GetDirectBufferAddress(env, buffer);
|
||||
if (src != NULL) {
|
||||
memcpy((void *) destAddress, (char *) src + offset, length);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JNI global reference to the given object and returns its address.
|
||||
* The caller is responsible for deleting the global reference when it's no longer needed.
|
||||
*/
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
|
||||
if (obj == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
jobject globalRef = (*env)->NewGlobalRef(env, obj);
|
||||
return (jlong) globalRef;
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <android/bitmap.h>
|
||||
|
||||
// Cache-friendly block size for the tiled rotation (in pixels). 32x32 uint32 = 4KB, fits L1.
|
||||
#define TILE 32
|
||||
|
||||
// EXIF orientation values (androidx.exifinterface.media.ExifInterface.ORIENTATION_*).
|
||||
enum {
|
||||
ORIENTATION_FLIP_HORIZONTAL = 2,
|
||||
ORIENTATION_ROTATE_180 = 3,
|
||||
ORIENTATION_FLIP_VERTICAL = 4,
|
||||
ORIENTATION_TRANSPOSE = 5,
|
||||
ORIENTATION_ROTATE_90 = 6,
|
||||
ORIENTATION_TRANSVERSE = 7,
|
||||
ORIENTATION_ROTATE_270 = 8,
|
||||
};
|
||||
|
||||
// The orientations that swap width and height. Must stay in sync with affine_for's dim usage.
|
||||
static int swaps_dims(int o) {
|
||||
return o == ORIENTATION_ROTATE_90 || o == ORIENTATION_ROTATE_270 ||
|
||||
o == ORIENTATION_TRANSPOSE || o == ORIENTATION_TRANSVERSE;
|
||||
}
|
||||
|
||||
// A source pixel (sx, sy) maps to destination index base + sx*stepX + sy*stepY, where dw is the
|
||||
// destination width. This affine form covers all 8 EXIF orientations and matches the pixel layout
|
||||
// of Bitmap.createBitmap(src, matrixForExifOrientation(o)). int64_t so it stays correct on
|
||||
// armeabi-v7a (32-bit long) regardless of how large MAX_RAW_DECODE_PIXELS grows.
|
||||
static void affine_for(int o, int sw, int sh, int dw, int64_t *base, int64_t *stepX, int64_t *stepY) {
|
||||
switch (o) {
|
||||
case ORIENTATION_ROTATE_90: *base = sh - 1; *stepX = dw; *stepY = -1; break;
|
||||
case ORIENTATION_ROTATE_270: *base = (int64_t) (sw - 1) * dw; *stepX = -dw; *stepY = 1; break;
|
||||
case ORIENTATION_ROTATE_180: *base = (int64_t) (sh - 1) * dw + (sw - 1); *stepX = -1; *stepY = -dw; break;
|
||||
case ORIENTATION_FLIP_HORIZONTAL: *base = sw - 1; *stepX = -1; *stepY = dw; break;
|
||||
case ORIENTATION_FLIP_VERTICAL: *base = (int64_t) (sh - 1) * dw; *stepX = 1; *stepY = -dw; break;
|
||||
case ORIENTATION_TRANSPOSE: *base = 0; *stepX = dw; *stepY = 1; break;
|
||||
case ORIENTATION_TRANSVERSE: *base = (int64_t) (sw - 1) * dw + (sh - 1); *stepX = -dw; *stepY = -1; break;
|
||||
default: *base = 0; *stepX = 1; *stepY = dw; break;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy each source pixel (whole uint32, so channel order/premult is irrelevant) to its rotated
|
||||
// destination, walking TILE x TILE blocks so the scattered writes of a 90/270 transpose stay
|
||||
// cache-resident. dst is densely packed (rowBytes == dw*4, no padding), which the affine math relies on.
|
||||
static void rotate_tiled(const uint8_t *src, int srcStride, uint32_t *dst,
|
||||
int sw, int sh, int64_t base, int64_t stepX, int64_t stepY) {
|
||||
for (int ty = 0; ty < sh; ty += TILE) {
|
||||
int yEnd = ty + TILE < sh ? ty + TILE : sh;
|
||||
for (int tx = 0; tx < sw; tx += TILE) {
|
||||
int xEnd = tx + TILE < sw ? tx + TILE : sw;
|
||||
for (int sy = ty; sy < yEnd; sy++) {
|
||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) sy * srcStride);
|
||||
int64_t idx = base + (int64_t) sy * stepY + (int64_t) tx * stepX;
|
||||
for (int sx = tx; sx < xEnd; sx++) {
|
||||
dst[idx] = srcRow[sx];
|
||||
idx += stepX;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rotates an RGBA_8888 bitmap to the given EXIF orientation into a freshly malloc'd buffer (free it
|
||||
// via NativeBuffer.free). Fills outInfo with {width, height, rowBytes} and returns the buffer
|
||||
// address, or 0 if the bitmap can't be handled (e.g. a non-8888 format) so the caller can fall back.
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeImage_rotate(
|
||||
JNIEnv *env, jclass clazz, jobject bitmap, jint orientation, jintArray outInfo) {
|
||||
AndroidBitmapInfo info;
|
||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sw = (int) info.width;
|
||||
int sh = (int) info.height;
|
||||
int dw = swaps_dims(orientation) ? sh : sw;
|
||||
int dh = swaps_dims(orientation) ? sw : sh;
|
||||
|
||||
uint32_t *dst = (uint32_t *) malloc((size_t) dw * dh * 4);
|
||||
if (dst == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void *srcPixels = NULL;
|
||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int64_t base, stepX, stepY;
|
||||
affine_for(orientation, sw, sh, dw, &base, &stepX, &stepY);
|
||||
rotate_tiled((const uint8_t *) srcPixels, (int) info.stride, dst, sw, sh, base, stepX, stepY);
|
||||
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
|
||||
jint dims[3] = {dw, dh, dw * 4};
|
||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
||||
// Keep ownership in C until the buffer is safely handed back: if outInfo was somehow too small,
|
||||
// SetIntArrayRegion left a pending exception and Kotlin will never receive (or free) dst.
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
|
||||
// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
|
||||
// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
|
||||
// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
|
||||
// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
|
||||
// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole
|
||||
// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes
|
||||
// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
|
||||
static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
|
||||
for (int y = 0; y < h; y++) {
|
||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
|
||||
uint32_t *dstRow = dst + (size_t) y * w;
|
||||
for (int x = 0; x < w; x++) {
|
||||
uint32_t px = srcRow[x];
|
||||
uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t a = ((px >> 30) & 0x3) * 85u;
|
||||
dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
|
||||
// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
|
||||
// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
|
||||
// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeImage_convert1010102(
|
||||
JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
|
||||
AndroidBitmapInfo info;
|
||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int w = (int) info.width;
|
||||
int h = (int) info.height;
|
||||
|
||||
uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
|
||||
if (dst == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void *srcPixels = NULL;
|
||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
|
||||
convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
|
||||
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
|
||||
jint dims[3] = {w, h, w * 4};
|
||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const val INITIAL_BUFFER_SIZE = 32 * 1024
|
||||
|
||||
object NativeBuffer {
|
||||
init {
|
||||
System.loadLibrary("native_buffer")
|
||||
System.loadLibrary("native_core_ffi")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object NativeImage {
|
||||
init {
|
||||
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
|
||||
System.loadLibrary("native_buffer")
|
||||
System.loadLibrary("native_core_ffi")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,4 +26,24 @@ object NativeImage {
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
|
||||
|
||||
/**
|
||||
* 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 decode(
|
||||
address: Long,
|
||||
length: Int,
|
||||
targetWidth: Int,
|
||||
targetHeight: Int,
|
||||
maxPixelSize: Int,
|
||||
cancelled: ByteBuffer,
|
||||
outInfo: IntArray
|
||||
): Long
|
||||
|
||||
/** Stops the [decode] given this one-byte direct buffer. */
|
||||
@JvmStatic
|
||||
external fun cancel(cancelled: ByteBuffer)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.exifinterface.media.ExifInterface
|
||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||
import app.alextran.immich.NativeBuffer
|
||||
import app.alextran.immich.NativeByteBuffer
|
||||
import app.alextran.immich.NativeImage
|
||||
import app.alextran.immich.core.HttpClientManager
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.Cache
|
||||
@@ -29,6 +30,8 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
|
||||
// Match the upstream GPU texture bound without depending on a newer local image decoder.
|
||||
private const val MAX_JXL_PIXEL_SIZE = 16384
|
||||
|
||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||
|
||||
@@ -56,10 +59,9 @@ private val RAW_MIME_TYPES = setOf(
|
||||
"image/x-sony-sr2", "image/x-sony-srf", "image/x3f",
|
||||
)
|
||||
|
||||
private fun isRawMime(contentType: String?): Boolean {
|
||||
val mime = contentType?.substringBefore(';')?.trim()?.lowercase() ?: return false
|
||||
return mime in RAW_MIME_TYPES
|
||||
}
|
||||
private fun mimeType(contentType: String?) = contentType?.substringBefore(';')?.trim()?.lowercase()
|
||||
|
||||
private fun isRawMime(contentType: String?) = mimeType(contentType) in RAW_MIME_TYPES
|
||||
|
||||
class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
|
||||
@@ -97,6 +99,42 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
return@fetch callback(CANCELLED)
|
||||
}
|
||||
|
||||
// Neither Android nor Flutter can decode JPEG XL, so this applies to encoded requests too.
|
||||
if (mimeType(contentType) == "image/jxl") {
|
||||
decodeExecutor.execute {
|
||||
val info = IntArray(3)
|
||||
// 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.decode(
|
||||
buffer.pointer,
|
||||
buffer.offset,
|
||||
width?.toInt() ?: 0,
|
||||
height?.toInt() ?: 0,
|
||||
MAX_JXL_PIXEL_SIZE,
|
||||
cancelled,
|
||||
info
|
||||
)
|
||||
buffer.free()
|
||||
requestMap.remove(requestId)
|
||||
callback(
|
||||
when {
|
||||
pointer != 0L -> Result.success(
|
||||
mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to info[0].toLong(),
|
||||
"height" to info[1].toLong(),
|
||||
"rowBytes" to info[2].toLong()
|
||||
)
|
||||
)
|
||||
signal.isCanceled -> CANCELLED
|
||||
else -> Result.failure(IOException("Failed to decode JPEG XL image"))
|
||||
}
|
||||
)
|
||||
}
|
||||
return@fetch
|
||||
}
|
||||
|
||||
// Decode natively when the caller wants pixels: Flutter's fallback decoder copies
|
||||
// 10-bit bitmaps (RGBA_1010102) as if they were rgba8888, garbling colors. Decode on a
|
||||
// dedicated pool - the fetch callback threads are shared with video streaming. On any
|
||||
@@ -272,7 +310,11 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
requestBuilder.addHeader(key, value)
|
||||
}
|
||||
val request = requestBuilder.build()
|
||||
signal.setOnCancelListener(request::cancel)
|
||||
// Cronet's disk cache can still use a read buffer after onCanceled, so only cancel between reads.
|
||||
signal.setOnCancelListener {
|
||||
callback.requestCancel = true
|
||||
if (!callback.receivingResponse) request.cancel()
|
||||
}
|
||||
request.start()
|
||||
}
|
||||
|
||||
@@ -328,12 +370,16 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
) : UrlRequest.Callback() {
|
||||
private var buffer: NativeByteBuffer? = null
|
||||
private var error: Exception? = null
|
||||
@Volatile var receivingResponse = false
|
||||
@Volatile var requestCancel = false
|
||||
|
||||
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
|
||||
request.followRedirect()
|
||||
}
|
||||
|
||||
override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) {
|
||||
receivingResponse = true
|
||||
if (requestCancel) return request.cancel()
|
||||
if (info.httpStatusCode !in 200..299) {
|
||||
error = IOException("HTTP ${info.httpStatusCode}: ${info.httpStatusText}")
|
||||
return request.cancel()
|
||||
@@ -361,6 +407,7 @@ private class CronetImageFetcher : ImageFetcher {
|
||||
info: UrlResponseInfo,
|
||||
byteBuffer: ByteBuffer
|
||||
) {
|
||||
if (requestCancel) return request.cancel()
|
||||
try {
|
||||
// Always pass a fresh wrap so byteBuffer.position() represents only the
|
||||
// bytes Cronet wrote in this iteration. Reusing the caller-supplied
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:native_core/native_core.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('native core loads on device', () {
|
||||
expect(version(), isNotEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
enum NativeCore {
|
||||
private static let library = dlopen("@rpath/native_core_ffi.framework/native_core_ffi", RTLD_NOW)
|
||||
|
||||
private static func symbol<T>(_ name: String, as type: T.Type) -> T? {
|
||||
guard let library, let symbol = dlsym(library, name) else { return nil }
|
||||
return unsafeBitCast(symbol, to: type)
|
||||
}
|
||||
|
||||
private static let coreThumbhash = symbol("native_core_thumbhash", as: NativeCoreThumbhashFn.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)? {
|
||||
guard let coreThumbhash else { return nil }
|
||||
var width: Int32 = 0
|
||||
var height: Int32 = 0
|
||||
return hash.withUnsafeBytes { bytes in
|
||||
guard let pointer = coreThumbhash(bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, &width, &height)
|
||||
else { return nil }
|
||||
return (Int(width), Int(height), UnsafeMutableRawPointer(pointer))
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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))
|
||||
}
|
||||
}
|
||||
|
||||
static func cancel(_ flag: UnsafeMutablePointer<Bool>) {
|
||||
coreCancel?(flag)
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,6 @@ enum ImageProcessing {
|
||||
return q
|
||||
}()
|
||||
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
|
||||
// Impeller shrinks each side to the GPU texture limit on its own, which squishes long images.
|
||||
static let maxPixelSize = 16384
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
requestOptions.version = .current
|
||||
return requestOptions
|
||||
}()
|
||||
private static let maxPixelSize: CGFloat = 16384
|
||||
|
||||
private static let registry = RequestRegistry<ImageRequest>()
|
||||
|
||||
@@ -111,7 +110,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
|
||||
let isOriginal = !(width > 0 && height > 0)
|
||||
let targetSize = isOriginal
|
||||
? CGSize(width: Self.maxPixelSize, height: Self.maxPixelSize)
|
||||
? CGSize(width: ImageProcessing.maxPixelSize, height: ImageProcessing.maxPixelSize)
|
||||
: CGSize(width: Double(width), height: Double(height))
|
||||
let contentMode: PHImageContentMode = isOriginal ? .aspectFit : .aspectFill
|
||||
var image: UIImage?
|
||||
@@ -136,7 +135,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
}
|
||||
|
||||
// .fast can return larger than the target, so retry with .exact to guarantee the bound.
|
||||
if max(cgImage.width, cgImage.height) > Int(Self.maxPixelSize) {
|
||||
if max(cgImage.width, cgImage.height) > ImageProcessing.maxPixelSize {
|
||||
let exactOptions = Self.requestOptions.copy() as! PHImageRequestOptions
|
||||
exactOptions.resizeMode = .exact
|
||||
image = nil
|
||||
|
||||
@@ -7,15 +7,23 @@ import Photos
|
||||
final class RemoteImageRequest: ImageRequest {
|
||||
var task: URLSessionDataTask?
|
||||
let id: Int64
|
||||
/// Lets cancellation stop a native decode partway through.
|
||||
let decodeCancelled = UnsafeMutablePointer<Bool>.allocate(capacity: 1)
|
||||
|
||||
init(id: Int64, completion: @escaping @Sendable (Result<[String: Int64]?, any Error>) -> Void) {
|
||||
self.id = id
|
||||
super.init(completion: completion)
|
||||
decodeCancelled.initialize(to: false)
|
||||
}
|
||||
|
||||
deinit {
|
||||
decodeCancelled.deallocate()
|
||||
}
|
||||
|
||||
override func cancel() {
|
||||
super.cancel()
|
||||
task?.cancel()
|
||||
NativeCore.cancel(decodeCancelled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +73,38 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
|
||||
}
|
||||
|
||||
// Flutter can't decode JPEG XL and older iOS versions can't either, so this applies to encoded requests too.
|
||||
if response?.mimeType == "image/jxl" {
|
||||
ImageProcessing.queue.addOperation {
|
||||
if request.isCancelled {
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode JPEG XL image", details: nil)))
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
free(pointer)
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
registry.remove(requestId: request.id)
|
||||
request.completion(
|
||||
.success([
|
||||
"pointer": Int64(Int(bitPattern: pointer)),
|
||||
"width": Int64(width),
|
||||
"height": Int64(height),
|
||||
"rowBytes": Int64(width * 4),
|
||||
]))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if encoded {
|
||||
let length = data.count
|
||||
let pointer = malloc(length)!
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
#include "../../../native/crates/native_core_ffi/include/native_core.h"
|
||||
|
||||
// The function pointer type comes from the declaration, so nothing is retyped here.
|
||||
typedef __typeof__(&native_core_thumbhash) NativeCoreThumbhashFn;
|
||||
typedef __typeof__(&native_core_decode_image) NativeCoreDecodeImageFn;
|
||||
typedef __typeof__(&native_core_cancel) NativeCoreCancelFn;
|
||||
|
||||
@@ -122,7 +122,7 @@ class Drift extends $Drift {
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await super.close();
|
||||
_updatePool?.close();
|
||||
await _updatePool?.close();
|
||||
}
|
||||
|
||||
Future<void> reset() async {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -79,8 +79,7 @@ abstract class ImageRequest {
|
||||
return (codec, descriptor);
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length, {ui.Size? decodeSize}) async {
|
||||
final result = await _codecFromEncodedPlatformImage(address, length, decodeSize: decodeSize);
|
||||
Future<ui.FrameInfo?> _firstFrame((ui.Codec, ui.ImageDescriptor)? result) async {
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -116,7 +115,12 @@ abstract class ImageRequest {
|
||||
return ((width * scale).ceil(), (height * scale).ceil());
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
|
||||
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromDecodedPlatformImage(
|
||||
int address,
|
||||
int width,
|
||||
int height,
|
||||
int rowBytes,
|
||||
) async {
|
||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||
if (_isCancelled) {
|
||||
malloc.free(pointer);
|
||||
@@ -152,14 +156,6 @@ abstract class ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
final frame = await codec.getNextFrame();
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
if (_isCancelled) {
|
||||
frame.image.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
return frame;
|
||||
return (codec, descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ class LocalImageRequest extends ImageRequest {
|
||||
return null;
|
||||
}
|
||||
|
||||
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
|
||||
final frame = await _firstFrame(
|
||||
await _codecFromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!),
|
||||
);
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,22 +21,7 @@ class RemoteImageRequest extends ImageRequest {
|
||||
width: decodeSize?.width.ceil(),
|
||||
height: decodeSize?.height.ceil(),
|
||||
);
|
||||
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
|
||||
final frame = switch (info) {
|
||||
{'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage(
|
||||
pointer,
|
||||
length,
|
||||
decodeSize: decodeSize,
|
||||
),
|
||||
{
|
||||
'pointer': final int pointer,
|
||||
'width': final int width,
|
||||
'height': final int height,
|
||||
'rowBytes': final int rowBytes,
|
||||
} =>
|
||||
await _fromDecodedPlatformImage(pointer, width, height, rowBytes),
|
||||
_ => null,
|
||||
};
|
||||
final frame = await _firstFrame(await _codecFromResponse(info, decodeSize: decodeSize));
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
@@ -53,14 +38,29 @@ class RemoteImageRequest extends ImageRequest {
|
||||
width: null,
|
||||
height: null,
|
||||
);
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
|
||||
return codec;
|
||||
final result = await _codecFromResponse(info);
|
||||
return result?.$1;
|
||||
}
|
||||
|
||||
// Android falls back to encoded data if native decoding fails, and JPEG XL arrives decoded even when the encoded image
|
||||
// is requested, so check for both shapes of the response.
|
||||
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromResponse(Map<String, int>? info, {ui.Size? decodeSize}) =>
|
||||
switch (info) {
|
||||
{'pointer': final int pointer, 'length': final int length} => _codecFromEncodedPlatformImage(
|
||||
pointer,
|
||||
length,
|
||||
decodeSize: decodeSize,
|
||||
),
|
||||
{
|
||||
'pointer': final int pointer,
|
||||
'width': final int width,
|
||||
'height': final int height,
|
||||
'rowBytes': final int rowBytes,
|
||||
} =>
|
||||
_codecFromDecodedPlatformImage(pointer, width, height, rowBytes),
|
||||
_ => Future.value(),
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> _onCancelled() {
|
||||
return remoteImageApi.cancelRequest(requestId);
|
||||
|
||||
@@ -12,7 +12,9 @@ class ThumbhashImageRequest extends ImageRequest {
|
||||
}
|
||||
|
||||
final Map<String, int> info = await localImageApi.getThumbhash(thumbhash);
|
||||
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
|
||||
final frame = await _firstFrame(
|
||||
await _codecFromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!),
|
||||
);
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -121,14 +121,14 @@ class DeleteAction extends AssetActionBuilder {
|
||||
required bool isLocalOnly,
|
||||
}) async {
|
||||
final assetService = ref.read(assetServiceProvider);
|
||||
final message = context.t.trash_action_prompt(count: remoteIds.length);
|
||||
if (localIds.isNotEmpty) {
|
||||
await _cleanupLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
|
||||
if (!context.mounted) {
|
||||
final count = await _cleanupLocalAssets(context, ref, localIds, isLocalOnly: isLocalOnly);
|
||||
if (count <= 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final message = context.t.trash_action_prompt(count: remoteIds.length);
|
||||
await assetService.trash(remoteIds);
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
[tools]
|
||||
java = "21.0.2"
|
||||
rust = { version = "1.99.1", targets = "aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios" }
|
||||
|
||||
[tools."aqua:flutter/flutter"]
|
||||
version = "3.47.2"
|
||||
@@ -34,6 +35,7 @@ depends = [
|
||||
"codegen:dart",
|
||||
"codegen:drift:migration",
|
||||
"codegen:drift:schema",
|
||||
"codegen:native",
|
||||
"codegen:pigeon",
|
||||
"codegen:translation",
|
||||
]
|
||||
@@ -80,6 +82,11 @@ alias = "watch"
|
||||
description = "Watch and auto-generate dart code"
|
||||
run = "dart run build_runner watch"
|
||||
|
||||
[tasks."codegen:native"]
|
||||
description = "Generate the native C header and Dart bindings"
|
||||
dir = "../native/native_core"
|
||||
run = ["cargo build -p native_core_ffi", "flutter pub get", "dart run ffigen --config ffigen.yaml"]
|
||||
|
||||
[tasks."codegen:pigeon"]
|
||||
alias = "pigeon"
|
||||
description = "Generate pigeon platform code"
|
||||
|
||||
+29
-6
@@ -796,10 +796,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
version: "2.2.0"
|
||||
hooks_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1132,6 +1132,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
native_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../native/native_core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1140,6 +1147,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.1"
|
||||
native_toolchain_rust:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_rust
|
||||
sha256: "8469a0e32a47a5c84b7fd300de9fd0e8082aac76c37922986324f5082a58350d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.6"
|
||||
native_video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1458,10 +1473,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
version: "1.1.1"
|
||||
riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1664,10 +1679,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3_connection_pool
|
||||
sha256: ad51972d37bdf82929888e4c85e722bd9a0b5ff8ad389f89b1b00d19603c0d2d
|
||||
sha256: ad8413d9100598ee9ef1a27628f5673b7d9212fb9d15b2742f7fde690f743832
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.7"
|
||||
version: "0.2.13"
|
||||
sqlite3_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1772,6 +1787,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
toml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: toml
|
||||
sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.18.0"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+4
-2
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.3.5-local.7+3030500
|
||||
version: 3.3.5-local.13+3030505
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
@@ -39,6 +39,8 @@ dependencies:
|
||||
hooks_riverpod: ^2.6.1
|
||||
http: ^1.6.0
|
||||
image_picker: ^1.2.3
|
||||
native_core:
|
||||
path: ../native/native_core
|
||||
immich_ui:
|
||||
path: './packages/ui'
|
||||
intl: ^0.20.2
|
||||
@@ -69,7 +71,7 @@ dependencies:
|
||||
stream_transform: ^2.1.1
|
||||
sqlite3: ^3.4.0
|
||||
sqlite_async: 0.14.2
|
||||
sqlite3_connection_pool: ^0.2.7
|
||||
sqlite3_connection_pool: ^0.2.13
|
||||
thumbhash: 0.1.0+1
|
||||
timezone: ^0.9.4
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
@@ -57,7 +57,7 @@ Future<void> _writerTask(String path) async {
|
||||
} finally {
|
||||
await db.close();
|
||||
await dbConnection.close();
|
||||
sqlitePool.close();
|
||||
await sqlitePool.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,24 @@ void main() {
|
||||
expect(args[4], isNull);
|
||||
});
|
||||
|
||||
test('accepts a decoded image for an animation request', () async {
|
||||
final pointer = malloc<Uint8>(8)..asTypedList(8).setAll(0, [255, 0, 0, 255, 0, 0, 255, 255]);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(channel, (
|
||||
message,
|
||||
) async {
|
||||
return <Object?>[
|
||||
<Object?, Object?>{'pointer': pointer.address, 'width': 2, 'height': 1, 'rowBytes': 8},
|
||||
];
|
||||
});
|
||||
|
||||
final codec = (await RemoteImageRequest(uri: 'https://example.test/animation').loadCodec())!;
|
||||
final image = (await codec.getNextFrame()).image;
|
||||
|
||||
expect((image.width, image.height), (2, 1));
|
||||
image.dispose();
|
||||
codec.dispose();
|
||||
});
|
||||
|
||||
test('keeps portrait cover quality in a wide tile', () async {
|
||||
final image = await loadEncoded('assets/feature_message/ocr.webp', const ui.Size(963, 642));
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -97,6 +99,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('trashes a merged asset and removes its device copy', (tester) async {
|
||||
when(() => cleanupService.deleteLocalAssets(['local'])).thenAnswer((_) async => 1);
|
||||
final asset = owned(localId: 'local');
|
||||
|
||||
await pumpDelete(tester, {asset});
|
||||
@@ -106,6 +109,65 @@ void main() {
|
||||
verify(() => assetService.trash([asset.id])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('does not trash a merged asset when the MANAGE_MEDIA prompt is cancelled', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
final asset = owned(localId: 'local');
|
||||
|
||||
await pumpDelete(tester, {asset});
|
||||
await respondToDialog(tester, confirm: false);
|
||||
|
||||
verifyNever(() => cleanupService.deleteLocalAssets(any()));
|
||||
verifyNever(() => assetService.trash(any()));
|
||||
verifyNever(() => assetService.delete(any()));
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('does not trash a merged asset when the Android system prompt is cancelled', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 30);
|
||||
when(() => cleanupService.deleteLocalAssets(['local'])).thenAnswer((_) async => 0);
|
||||
final asset = owned(localId: 'local');
|
||||
|
||||
await pumpDelete(tester, {asset});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
|
||||
verifyNever(() => assetService.trash(any()));
|
||||
verifyNever(() => assetService.delete(any()));
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('finishes trashing when the action context is disposed during local cleanup', (tester) async {
|
||||
final asset = owned(localId: 'local');
|
||||
final cleanupResult = Completer<int>();
|
||||
when(() => cleanupService.deleteLocalAssets(['local'])).thenAnswer((_) => cleanupResult.future);
|
||||
var showAction = true;
|
||||
late StateSetter updateHost;
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
StatefulBuilder(
|
||||
builder: (_, setState) {
|
||||
updateHost = setState;
|
||||
return showAction
|
||||
? const ActionIconButton(action: DeleteAction(source: .timeline))
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
overrides: context.selected({asset}),
|
||||
);
|
||||
|
||||
await tester.tap(find.byType(ImmichIconButton));
|
||||
await tester.pump();
|
||||
updateHost(() => showAction = false);
|
||||
await tester.pump();
|
||||
cleanupResult.complete(1);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => assetService.trash([asset.id])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('offers an undo that restores the trashed assets', (tester) async {
|
||||
final asset = owned();
|
||||
|
||||
@@ -283,6 +345,7 @@ void main() {
|
||||
when(context.repository.permission.getAndroidSdkVersion).thenAnswer((_) async => 28);
|
||||
final backedUp = owned(localId: 'local');
|
||||
final localOnly = LocalAssetFactory.create();
|
||||
when(() => cleanupService.deleteLocalAssets(['local', localOnly.id])).thenAnswer((_) async => 1);
|
||||
|
||||
await pumpDelete(tester, {backedUp, localOnly});
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Link against minSdk (mobile/android/app/build.gradle) instead of the API 35 that native_toolchain_rust picks.
|
||||
[target.aarch64-linux-android]
|
||||
rustflags = ["-C", "link-arg=--target=aarch64-linux-android26"]
|
||||
|
||||
# Neon on 32 bit arm is the NDK's default for C, the rust target leaves it off.
|
||||
[target.armv7-linux-androideabi]
|
||||
rustflags = ["-C", "link-arg=--target=armv7a-linux-androideabi26", "-C", "target-feature=+neon"]
|
||||
|
||||
[target.x86_64-linux-android]
|
||||
rustflags = ["-C", "link-arg=--target=x86_64-linux-android26"]
|
||||
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
/crates/native_core_ffi/include/native_core.h
|
||||
/native_core/lib/src/bindings.g.dart
|
||||
Generated
+666
@@ -0,0 +1,666 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "array-init"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cbindgen"
|
||||
version = "0.29.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"indexmap",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 2.0.119",
|
||||
"tempfile",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror",
|
||||
"walkdir",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jxl"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8379fc83d79477900c9aed78cb8370a556d834081bebcb771c6010381c4e230"
|
||||
dependencies = [
|
||||
"array-init",
|
||||
"byteorder",
|
||||
"jxl_macros",
|
||||
"jxl_simd",
|
||||
"jxl_transforms",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jxl_macros"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeb27c06a9957bd9c8e65d0aeea0db6a555a9e1c85597e284f8304015c410c87"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jxl_simd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a645451b4d34b4017caf24110a14eb737e275b5d3d121bc754e67727ee65af0e"
|
||||
|
||||
[[package]]
|
||||
name = "jxl_transforms"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21c4b7f8957f5ba578b2e28cd5ebbc97dad58a3ad5ff26765da61e8dab8f1cad"
|
||||
dependencies = [
|
||||
"jxl_simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396077b717181ce3f9a6b66739b269b82d756be5ec0f76be0bc40a7c51775830"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"codecs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native_core_ffi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cbindgen",
|
||||
"jni",
|
||||
"libc",
|
||||
"native_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pic-scale"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick_cache"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "403c1a912fec895cafb223201e368234842acb9220aaf08ab042ae89ba5f135c"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
dependencies = [
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,27 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/codecs", "crates/native_core", "crates/native_core_ffi"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
[workspace.dependencies]
|
||||
cbindgen = { version = "0.29", default-features = false }
|
||||
jni = { version = "0.22", default-features = false }
|
||||
jxl = { version = "0.8", default-features = false, features = ["neon"] }
|
||||
libc = { version = "0.2", default-features = false }
|
||||
moxcms = { version = "0.9", default-features = false, features = ["neon_luts", "neon_shaper_paths"] }
|
||||
pic-scale = { version = "0.7", default-features = false, features = ["neon", "rdm"] }
|
||||
quick_cache = { version = "0.7", default-features = false }
|
||||
rayon = { version = "1.11", default-features = false }
|
||||
|
||||
[workspace.lints.clippy]
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
|
||||
# catch_unwind at the FFI boundary needs unwinding, so no panic = "abort" here.
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
@@ -0,0 +1,14 @@
|
||||
# Native core
|
||||
|
||||
Rust code that ships inside the mobile app. Flutter compiles it from source through the
|
||||
`native_core` build hook, so building the app needs a Rust toolchain: `mise install`
|
||||
in `mobile/` sets one up, otherwise install [rustup](https://rustup.rs) once and the build
|
||||
fetches the pinned toolchain itself.
|
||||
|
||||
- `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)
|
||||
|
||||
`mise run build`, `test`, `lint`, and `fmt`. The header and the Dart bindings come from `mise run //mobile:codegen:native`.
|
||||
@@ -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
|
||||
@@ -0,0 +1,92 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
pub use moxcms::{CicpColorPrimaries, CicpProfile, TransferCharacteristics};
|
||||
use moxcms::{
|
||||
ColorProfile, DataColorSpace, Layout, MatrixCoefficients, RenderingIntent,
|
||||
Transform16BitExecutor, TransformOptions,
|
||||
};
|
||||
|
||||
use crate::image::Pixels;
|
||||
|
||||
/// The colour space decoded pixels should be in.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Target {
|
||||
/// sRGB, converting and tone-mapping as needed.
|
||||
Srgb,
|
||||
/// The image's own space whenever CICP can describe it, otherwise sRGB.
|
||||
Source,
|
||||
}
|
||||
|
||||
pub(crate) const SRGB: CicpProfile = CicpProfile {
|
||||
color_primaries: CicpColorPrimaries::Bt709,
|
||||
transfer_characteristics: TransferCharacteristics::Srgb,
|
||||
matrix_coefficients: MatrixCoefficients::Identity,
|
||||
full_range: true,
|
||||
};
|
||||
|
||||
pub(crate) fn is_srgb(cicp: CicpProfile) -> bool {
|
||||
cicp.color_primaries == SRGB.color_primaries
|
||||
&& cicp.transfer_characteristics == SRGB.transfer_characteristics
|
||||
}
|
||||
|
||||
pub(crate) fn is_hdr(cicp: CicpProfile) -> bool {
|
||||
matches!(
|
||||
cicp.transfer_characteristics,
|
||||
TransferCharacteristics::Smpte2084 | TransferCharacteristics::Hlg
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the transform from an ICC profile to sRGB, and the layout of the 12-bit samples it reads.
|
||||
pub(crate) fn to_srgb(icc: &[u8]) -> Option<(Arc<Transform16BitExecutor>, Layout)> {
|
||||
let source = ColorProfile::new_from_slice(icc).ok()?;
|
||||
let layout = match source.color_space {
|
||||
DataColorSpace::Gray => Layout::GrayAlpha,
|
||||
_ => Layout::Rgba,
|
||||
};
|
||||
let options = TransformOptions {
|
||||
// Profiles that tone-map HDR, like those jxl-rs makes, may only have a perceptual table.
|
||||
rendering_intent: RenderingIntent::Perceptual,
|
||||
// The fixed-point path loses precision with the narrower samples it reads.
|
||||
prefer_fixed_point: false,
|
||||
..Default::default()
|
||||
};
|
||||
// Going past 12-bit makes the transform's tables much slower to build for negligible benefit.
|
||||
let transform = source
|
||||
.create_transform_12bit(layout, &ColorProfile::new_srgb(), Layout::Rgba, options)
|
||||
.ok()?;
|
||||
Some((transform, layout))
|
||||
}
|
||||
|
||||
/// Converts the 12-bit samples in `rgba`, `stride` bytes per row, to RGBA8 with `transform`.
|
||||
pub(crate) fn convert_bands(
|
||||
transform: &Transform16BitExecutor,
|
||||
rgba: &mut Pixels<u8>,
|
||||
stride: usize,
|
||||
cancelled: &AtomicBool,
|
||||
) -> Option<()> {
|
||||
let (w, h) = (rgba.width(), rgba.height());
|
||||
let buf = rgba.bytes_mut();
|
||||
// Narrowing in place keeps peak memory at the wide decode plus one band.
|
||||
let rows = 64;
|
||||
let mut converted = vec![0u16; w * 4 * rows.min(h)];
|
||||
for y in (0..h).step_by(rows) {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let n = rows.min(h - y);
|
||||
let band = &buf[y * stride..][..n * stride];
|
||||
// SAFETY: any two bytes are a valid u16.
|
||||
let ([], src, []) = (unsafe { band.align_to::<u16>() }) else {
|
||||
return None;
|
||||
};
|
||||
transform.transform(src, &mut converted[..n * w * 4]).ok()?;
|
||||
// Pixels decode at least as wide as RGBA8, so this only overwrites converted rows.
|
||||
let narrowed = &mut buf[y * w * 4..][..n * w * 4];
|
||||
for (dst, &src) in narrowed.iter_mut().zip(&converted) {
|
||||
const NARROW: u32 = ((255 << 16) + 4095 / 2) / 4095;
|
||||
*dst = ((u32::from(src) * NARROW + (1 << 15)) >> 16) as u8;
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use std::marker::PhantomData;
|
||||
use std::mem::ManuallyDrop;
|
||||
use std::ptr::NonNull;
|
||||
use std::slice;
|
||||
|
||||
use crate::color::CicpProfile;
|
||||
use crate::resize::{self, Lanczos};
|
||||
|
||||
/// Decoded pixels and the colour space they are in.
|
||||
pub struct Image {
|
||||
pub cicp: CicpProfile,
|
||||
pub samples: Samples,
|
||||
}
|
||||
|
||||
pub enum Samples {
|
||||
U8(Pixels<u8>),
|
||||
/// For HDR transfers, which need more than 8 bits.
|
||||
U16(Pixels<u16>),
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA pixels in a malloc'd buffer.
|
||||
pub struct Pixels<T> {
|
||||
/// Initialized bytes from calloc or realloc that only self references.
|
||||
buf: NonNull<[u8]>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
sample: PhantomData<T>,
|
||||
}
|
||||
|
||||
pub(crate) trait Sample: Lanczos + Ord {
|
||||
const MAX: Self;
|
||||
|
||||
/// Rounds like dividing the product by MAX would.
|
||||
fn scale(self, alpha: Self) -> Self;
|
||||
}
|
||||
|
||||
impl Sample for u8 {
|
||||
const MAX: Self = u8::MAX;
|
||||
|
||||
fn scale(self, alpha: Self) -> Self {
|
||||
let x = u16::from(self) * u16::from(alpha) + 128;
|
||||
((x + (x >> 8)) >> 8) as u8
|
||||
}
|
||||
}
|
||||
|
||||
impl Sample for u16 {
|
||||
const MAX: Self = u16::MAX;
|
||||
|
||||
fn scale(self, alpha: Self) -> Self {
|
||||
let x = u32::from(self) * u32::from(alpha) + 32768;
|
||||
((x + (x >> 16)) >> 16) as u16
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Pixels<T> {
|
||||
/// Allocates pixels of u8 or u16 samples, the only kinds this module makes, with `pixel_bytes`
|
||||
/// each if a decoder first writes them wider.
|
||||
pub(crate) fn zeroed(width: usize, height: usize, pixel_bytes: usize) -> Option<Self>
|
||||
where
|
||||
T: Sample,
|
||||
{
|
||||
let len = width
|
||||
.checked_mul(height)?
|
||||
.checked_mul(pixel_bytes.max(size_of::<[T; 4]>()))?;
|
||||
// SAFETY: calloc has no preconditions.
|
||||
let ptr = NonNull::new(unsafe { libc::calloc(len, 1) }.cast())?;
|
||||
Some(Self {
|
||||
buf: NonNull::slice_from_raw_parts(ptr, len),
|
||||
width,
|
||||
height,
|
||||
sample: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn bytes_mut(&mut self) -> &mut [u8] {
|
||||
// SAFETY: self owns these initialized bytes.
|
||||
unsafe { self.buf.as_mut() }
|
||||
}
|
||||
|
||||
pub(crate) fn pixels_mut(&mut self) -> &mut [[T; 4]] {
|
||||
// SAFETY: as for pixels, and self is borrowed mutably.
|
||||
unsafe { slice::from_raw_parts_mut(self.buf.as_ptr().cast(), self.width * self.height) }
|
||||
}
|
||||
|
||||
/// Frees everything after the pixels.
|
||||
fn shrink(mut self) -> Option<Self> {
|
||||
let len = self.width * self.height * 4 * size_of::<T>();
|
||||
// SAFETY: the buffer is live and the pixels fill its first len bytes.
|
||||
let shrunk = unsafe { libc::realloc(self.buf.as_ptr().cast(), len) };
|
||||
// A failed realloc leaves the original buffer, which self still frees.
|
||||
self.buf = NonNull::slice_from_raw_parts(NonNull::new(shrunk.cast())?, len);
|
||||
Some(self)
|
||||
}
|
||||
|
||||
/// Premultiplies if `alpha`, then resizes to `size`, or else frees everything after the pixels.
|
||||
pub(crate) fn premultiply_resize(
|
||||
mut self,
|
||||
alpha: bool,
|
||||
size: Option<(usize, usize)>,
|
||||
) -> Option<Self>
|
||||
where
|
||||
T: Sample,
|
||||
{
|
||||
if alpha {
|
||||
self.premultiply();
|
||||
}
|
||||
let Some(size) = size else {
|
||||
return self.shrink();
|
||||
};
|
||||
let mut out = self.resize(size)?;
|
||||
if alpha {
|
||||
// Lanczos3 overshoots, while premultiplied colour must stay within alpha.
|
||||
out.clamp();
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn premultiply(&mut self)
|
||||
where
|
||||
T: Sample,
|
||||
{
|
||||
// Alpha scaling itself at full opacity lets this vectorize as every lane does the same work.
|
||||
for px in self.pixels_mut() {
|
||||
let [r, g, b, a] = *px;
|
||||
*px = [r.scale(a), g.scale(a), b.scale(a), a.scale(T::MAX)];
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamps colour to alpha.
|
||||
fn clamp(&mut self)
|
||||
where
|
||||
T: Sample,
|
||||
{
|
||||
for px in self.pixels_mut() {
|
||||
let a = px[3];
|
||||
*px = px.map(|c| c.min(a));
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels resampled to `size` with Lanczos3, in a new buffer.
|
||||
fn resize(&self, (tw, th): (usize, usize)) -> Option<Self>
|
||||
where
|
||||
T: Sample,
|
||||
{
|
||||
let mut out = Self::zeroed(tw, th, size_of::<[T; 4]>())?;
|
||||
resize::lanczos(self, &mut out)?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
|
||||
pub fn pixels(&self) -> &[[T; 4]] {
|
||||
// SAFETY: zeroed made room for at least these initialized pixels, and their u8 or u16 samples are
|
||||
// valid for any bytes and aligned by malloc.
|
||||
unsafe { slice::from_raw_parts(self.buf.as_ptr().cast(), self.width * self.height) }
|
||||
}
|
||||
|
||||
/// Hands the buffer to a C caller, who frees it with free().
|
||||
pub fn into_raw(self) -> *mut u8 {
|
||||
ManuallyDrop::new(self).buf.as_ptr().cast()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Drop for Pixels<T> {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: self owns the buffer, which came from calloc or realloc.
|
||||
unsafe { libc::free(self.buf.as_ptr().cast()) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_colour_within_alpha() {
|
||||
fn check<T: Sample>(half: T) {
|
||||
let (zero, max) = (T::default(), T::MAX);
|
||||
let mut px = Pixels::<T>::zeroed(16, 16, size_of::<[T; 4]>()).unwrap();
|
||||
// Opaque black against translucent white rings both ways across the edge.
|
||||
for (i, p) in px.pixels_mut().iter_mut().enumerate() {
|
||||
*p = if i % 16 < 8 {
|
||||
[zero, zero, zero, max]
|
||||
} else {
|
||||
[max, max, max, half]
|
||||
};
|
||||
}
|
||||
let px = px.premultiply_resize(true, Some((5, 5))).unwrap();
|
||||
for p in px.pixels() {
|
||||
assert!(p[..3].iter().all(|&c| c <= p[3]), "{p:?}");
|
||||
}
|
||||
}
|
||||
check(128u8);
|
||||
check(32768u16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use jxl::api::{
|
||||
AlphaMode, Endianness, Event, ExtraChannel, JxlColorEncoding, JxlColorProfile, JxlColorType,
|
||||
JxlDataFormat, JxlDecoder, JxlDecoderOptions, JxlOutputBuffer, JxlParallelRunner,
|
||||
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};
|
||||
use crate::image::{Image, Pixels, Samples};
|
||||
use crate::resize;
|
||||
|
||||
/// A JPEG XL image whose header has been read.
|
||||
pub struct Jxl<'a> {
|
||||
decoder: JxlDecoder,
|
||||
input: &'a [u8],
|
||||
/// The space of the output.
|
||||
cicp: CicpProfile,
|
||||
/// Converts wide samples to sRGB when the output is not in the source space.
|
||||
transform: Option<Arc<Transform16BitExecutor>>,
|
||||
}
|
||||
|
||||
/// Runs on the global pool with up to this many threads, or inline with one.
|
||||
struct Rayon(usize);
|
||||
|
||||
impl JxlParallelRunner for Rayon {
|
||||
fn run(&mut self, num: usize, fun: &JxlParallelRunnerFun<'_>) -> jxl::error::Result<()> {
|
||||
(0..num).into_par_iter().try_for_each(fun)
|
||||
}
|
||||
|
||||
fn num_threads(&self) -> usize {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops handing out work once the flag is set.
|
||||
struct Cancellable<'a> {
|
||||
pool: Rayon,
|
||||
cancelled: &'a AtomicBool,
|
||||
}
|
||||
|
||||
impl JxlParallelRunner for Cancellable<'_> {
|
||||
fn run(&mut self, num: usize, fun: &JxlParallelRunnerFun<'_>) -> jxl::error::Result<()> {
|
||||
self.pool.run(num, fun)
|
||||
}
|
||||
|
||||
fn num_threads(&self) -> usize {
|
||||
self.pool.num_threads()
|
||||
}
|
||||
|
||||
// jxl-rs only calls this, and each of its tasks loops many items, so check cancellation here.
|
||||
fn run_ordered(
|
||||
&mut self,
|
||||
num: usize,
|
||||
max_threads: Option<usize>,
|
||||
fun: &JxlParallelRunnerFun<'_>,
|
||||
) -> jxl::error::Result<()> {
|
||||
self.pool.run_ordered(num, max_threads, &|i| {
|
||||
if self.cancelled.load(Ordering::Relaxed) {
|
||||
// Any error stops the decode.
|
||||
return Err(jxl::error::Error::ApiUsageError("cancelled"));
|
||||
}
|
||||
fun(i)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Jxl<'a> {
|
||||
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;
|
||||
let mut decoder = JxlDecoder::new(options);
|
||||
let Event::BasicInfo = decoder.process(&mut input, None, None).ok()? else {
|
||||
return None;
|
||||
};
|
||||
let extra = decoder.basic_info()?.extra_channels.len();
|
||||
// An integer pixel format ensures the output profile below is sRGB rather than float scRGB.
|
||||
decoder
|
||||
.set_pixel_format(JxlPixelFormat::rgba8(extra))
|
||||
.ok()?;
|
||||
let profile = decoder.output_color_profile()?;
|
||||
if let Some(cicp) = cicp(profile)
|
||||
&& (color_space == Target::Source || color::is_srgb(cicp))
|
||||
{
|
||||
if color::is_hdr(cicp) {
|
||||
decoder
|
||||
.set_pixel_format(JxlPixelFormat::rgba16(extra))
|
||||
.ok()?;
|
||||
}
|
||||
return Some(Self {
|
||||
decoder,
|
||||
input,
|
||||
cicp,
|
||||
transform: None,
|
||||
});
|
||||
}
|
||||
let (transform, layout) = srgb_transform(profile)?;
|
||||
decoder
|
||||
.set_pixel_format(JxlPixelFormat {
|
||||
color_type: match layout {
|
||||
Layout::GrayAlpha => JxlColorType::GrayscaleAlpha,
|
||||
_ => JxlColorType::Rgba,
|
||||
},
|
||||
color_data_format: Some(JxlDataFormat::U16 {
|
||||
endianness: Endianness::native(),
|
||||
bit_depth: 12,
|
||||
}),
|
||||
extra_channel_format: vec![None; extra],
|
||||
})
|
||||
.ok()?;
|
||||
Some(Self {
|
||||
decoder,
|
||||
input,
|
||||
cicp: color::SRGB,
|
||||
transform: Some(transform),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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_size: Option<(usize, usize)>,
|
||||
max_pixel_size: usize,
|
||||
cancelled: &AtomicBool,
|
||||
) -> Option<Image> {
|
||||
let info = self.decoder.basic_info()?;
|
||||
let (w, h) = info.size;
|
||||
let alpha = info
|
||||
.extra_channels
|
||||
.iter()
|
||||
.any(|ec| ec.ec_type == ExtraChannel::Alpha);
|
||||
let format = self.decoder.current_pixel_format()?;
|
||||
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_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)?;
|
||||
Samples::U16(rgba.premultiply_resize(alpha, size)?)
|
||||
} else {
|
||||
// The buffer is sized for the wide decode and ends up holding the RGBA8 result at its
|
||||
// front.
|
||||
let mut rgba = Pixels::zeroed(w, h, pixel_bytes)?;
|
||||
self.decode_frame(&mut rgba, stride, cancelled)?;
|
||||
if let Some(transform) = &self.transform {
|
||||
color::convert_bands(transform.as_ref(), &mut rgba, stride, cancelled)?;
|
||||
}
|
||||
Samples::U8(rgba.premultiply_resize(alpha, size)?)
|
||||
};
|
||||
Some(Image {
|
||||
cicp: self.cicp,
|
||||
samples,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_frame<T>(
|
||||
&mut self,
|
||||
rgba: &mut Pixels<T>,
|
||||
stride: usize,
|
||||
cancelled: &AtomicBool,
|
||||
) -> Option<()> {
|
||||
let (w, h) = (rgba.width(), rgba.height());
|
||||
let buf = rgba.bytes_mut();
|
||||
// The pool only pays off for large images, since thumbs are already decoded in parallel.
|
||||
let threads = if w * h > 512 * 512 {
|
||||
rayon::current_num_threads()
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let mut runner = Cancellable {
|
||||
pool: Rayon(threads),
|
||||
cancelled,
|
||||
};
|
||||
loop {
|
||||
let output = &mut [JxlOutputBuffer::new(buf, h, stride)];
|
||||
match self
|
||||
.decoder
|
||||
.process(&mut self.input, Some(output), Some(&mut runner))
|
||||
.ok()?
|
||||
{
|
||||
Event::FrameHeader => {}
|
||||
// Resizing costs ~10% of a thumbnail decode, wasted on tiles that scrolled away
|
||||
// meanwhile.
|
||||
Event::FrameComplete { .. } => {
|
||||
return (!cancelled.load(Ordering::Relaxed)).then_some(());
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The space of the decoded samples, if CICP can describe it.
|
||||
fn cicp(profile: &JxlColorProfile) -> Option<CicpProfile> {
|
||||
let JxlColorProfile::Simple(encoding) = profile else {
|
||||
return None;
|
||||
};
|
||||
let (primaries, transfer_function) = match encoding {
|
||||
JxlColorEncoding::RgbColorSpace {
|
||||
white_point: JxlWhitePoint::D65,
|
||||
primaries,
|
||||
transfer_function,
|
||||
..
|
||||
} => (primaries, transfer_function),
|
||||
// Gray samples decode to neutral RGB, which any primaries reproduce.
|
||||
JxlColorEncoding::GrayscaleColorSpace {
|
||||
white_point: JxlWhitePoint::D65,
|
||||
transfer_function,
|
||||
..
|
||||
} => (&JxlPrimaries::SRGB, transfer_function),
|
||||
_ => return None,
|
||||
};
|
||||
Some(CicpProfile {
|
||||
color_primaries: match primaries {
|
||||
JxlPrimaries::SRGB => CicpColorPrimaries::Bt709,
|
||||
JxlPrimaries::BT2100 => CicpColorPrimaries::Bt2020,
|
||||
JxlPrimaries::P3 => CicpColorPrimaries::Smpte432,
|
||||
JxlPrimaries::Chromaticities { .. } => return None,
|
||||
},
|
||||
transfer_characteristics: match transfer_function {
|
||||
JxlTransferFunction::SRGB => TransferCharacteristics::Srgb,
|
||||
JxlTransferFunction::PQ => TransferCharacteristics::Smpte2084,
|
||||
JxlTransferFunction::HLG => TransferCharacteristics::Hlg,
|
||||
_ => return None,
|
||||
},
|
||||
matrix_coefficients: MatrixCoefficients::Identity,
|
||||
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,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use std::fmt::Debug;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use pic_scale::{
|
||||
ImageSize, ImageStore, ImageStoreMut, Resampling, ResamplingFunction, Scaler, WorkloadStrategy,
|
||||
};
|
||||
use quick_cache::sync::Cache;
|
||||
|
||||
use crate::image::Pixels;
|
||||
|
||||
/// Reuse the resampling plan for a given src-dst image size pair.
|
||||
type Plans<T> = Cache<[usize; 4], Arc<Resampling<T, 4>>>;
|
||||
|
||||
const PLANS: usize = 16;
|
||||
|
||||
pub(crate) trait Lanczos: Copy + Default + Debug + 'static {
|
||||
fn plan(from: ImageSize, to: ImageSize) -> Option<Arc<Resampling<Self, 4>>>;
|
||||
|
||||
fn cache() -> &'static Plans<Self>;
|
||||
}
|
||||
|
||||
impl Lanczos for u8 {
|
||||
fn plan(from: ImageSize, to: ImageSize) -> Option<Arc<Resampling<Self, 4>>> {
|
||||
// The 8-bit speed path brightens by up to two levels and is barely faster.
|
||||
Scaler::new(ResamplingFunction::Lanczos3)
|
||||
.set_workload_strategy(WorkloadStrategy::PreferQuality)
|
||||
.plan_rgba_resampling(from, to, false)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn cache() -> &'static Plans<Self> {
|
||||
static CACHE: LazyLock<Plans<u8>> = LazyLock::new(|| Cache::new(PLANS));
|
||||
&CACHE
|
||||
}
|
||||
}
|
||||
|
||||
impl Lanczos for u16 {
|
||||
fn plan(from: ImageSize, to: ImageSize) -> Option<Arc<Resampling<Self, 4>>> {
|
||||
Scaler::new(ResamplingFunction::Lanczos3)
|
||||
.plan_rgba_resampling16(from, to, false, 16)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn cache() -> &'static Plans<Self> {
|
||||
static CACHE: LazyLock<Plans<u16>> = LazyLock::new(|| Cache::new(PLANS));
|
||||
&CACHE
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_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_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 {
|
||||
cover
|
||||
} else {
|
||||
fit
|
||||
}
|
||||
}
|
||||
None => fit,
|
||||
};
|
||||
(num < den).then(|| ((w * num).div_ceil(den), (h * num).div_ceil(den)))
|
||||
}
|
||||
|
||||
/// Resamples `src` into `dst` with Lanczos3.
|
||||
pub(crate) fn lanczos<T: Lanczos>(src: &Pixels<T>, dst: &mut Pixels<T>) -> Option<()> {
|
||||
let [w, h, tw, th] = [src.width(), src.height(), dst.width(), dst.height()];
|
||||
let from = ImageStore::borrow(src.pixels().as_flattened(), w, h).ok()?;
|
||||
let mut to = ImageStoreMut::borrow(dst.pixels_mut().as_flattened_mut(), tw, th).ok()?;
|
||||
to.bit_depth = 8 * size_of::<T>();
|
||||
T::cache()
|
||||
.get_or_insert_with(&[w, h, tw, th], || {
|
||||
T::plan(from.size(), to.size()).ok_or(())
|
||||
})
|
||||
.ok()?
|
||||
.resample(&from, &mut to)
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn covers_within_max_pixel_size() {
|
||||
// A 720p thumbnail for a 266 px tile and for a larger target, and panoramas past the limit.
|
||||
assert_eq!(cover((960, 720), Some((266, 266)), 16384), Some((355, 266)));
|
||||
assert_eq!(cover((960, 720), Some((1000, 1000)), 16384), None);
|
||||
assert_eq!(cover((40000, 1000), None, 16384), Some((16384, 410)));
|
||||
assert_eq!(
|
||||
cover((1000, 40000), Some((20000, 20000)), 16384),
|
||||
Some((410, 16384))
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,238 @@
|
||||
#![cfg(feature = "jxl")]
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use codecs::Options;
|
||||
use codecs::color::{CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
|
||||
use codecs::image::{Image, Pixels, Samples};
|
||||
use codecs::jxl::Jxl;
|
||||
|
||||
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>> {
|
||||
let Image {
|
||||
cicp:
|
||||
CicpProfile {
|
||||
color_primaries: CicpColorPrimaries::Bt709,
|
||||
transfer_characteristics: TransferCharacteristics::Srgb,
|
||||
..
|
||||
},
|
||||
samples: Samples::U8(rgba),
|
||||
} = decode(data, Target::Srgb)?
|
||||
else {
|
||||
panic!("not decoded to sRGB");
|
||||
};
|
||||
Some(rgba)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_to_premultiplied_srgb() {
|
||||
let cases: [(&str, &[u8], [u8; 8]); 5] = [
|
||||
(
|
||||
"sRGB with alpha",
|
||||
include_bytes!("fixtures/srgb_alpha.jxl"),
|
||||
[128, 0, 0, 128, 0, 0, 255, 255],
|
||||
),
|
||||
(
|
||||
"Display P3",
|
||||
include_bytes!("fixtures/p3.jxl"),
|
||||
[255, 0, 0, 255, 255, 255, 255, 255],
|
||||
),
|
||||
(
|
||||
"grayscale",
|
||||
include_bytes!("fixtures/gray.jxl"),
|
||||
[0, 0, 0, 255, 128, 128, 128, 255],
|
||||
),
|
||||
(
|
||||
"grayscale with ICC profile",
|
||||
include_bytes!("fixtures/gray_icc.jxl"),
|
||||
[81, 81, 81, 255, 203, 203, 203, 255],
|
||||
),
|
||||
(
|
||||
"animation",
|
||||
include_bytes!("fixtures/animated.jxl"),
|
||||
[255, 0, 0, 255, 255, 0, 0, 255],
|
||||
),
|
||||
];
|
||||
for (name, data, expected) in cases {
|
||||
let rgba = decode_srgb(data).expect(name);
|
||||
assert_eq!(
|
||||
(rgba.width(), rgba.height(), rgba.pixels().as_flattened()),
|
||||
(2, 1, &expected[..]),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_lossy_icc_to_srgb() {
|
||||
// jxl-rs renders XYB straight to sRGB, while a JPEG transcode decodes in its ICC space and
|
||||
// takes our transform.
|
||||
for data in [
|
||||
&include_bytes!("fixtures/xyb_icc.jxl")[..],
|
||||
include_bytes!("fixtures/jpeg_icc.jxl"),
|
||||
] {
|
||||
let rgba = decode_srgb(data).unwrap();
|
||||
for px in rgba.pixels() {
|
||||
assert!(
|
||||
px.iter()
|
||||
.zip([210, 119, 65, 255])
|
||||
.all(|(&a, b)| a.abs_diff(b) <= 3),
|
||||
"{px:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
let levels: Vec<u8> = rgba
|
||||
.pixels()
|
||||
.iter()
|
||||
.map(|px| {
|
||||
assert!(
|
||||
px[..3].iter().max().unwrap() - px[..3].iter().min().unwrap() <= 4,
|
||||
"{px:?} is not neutral"
|
||||
);
|
||||
px[1]
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
levels.is_sorted_by(|a, b| a < b) && levels[2] < 255,
|
||||
"{levels:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_spaces_that_cicp_describes() {
|
||||
let pq = decode(include_bytes!("fixtures/pq_alpha.jxl"), Target::Source);
|
||||
let Some(Image {
|
||||
cicp:
|
||||
CicpProfile {
|
||||
color_primaries: CicpColorPrimaries::Bt2020,
|
||||
transfer_characteristics: TransferCharacteristics::Smpte2084,
|
||||
..
|
||||
},
|
||||
samples: Samples::U16(rgba),
|
||||
}) = pq
|
||||
else {
|
||||
panic!("not decoded to BT.2100 PQ");
|
||||
};
|
||||
assert_eq!(
|
||||
rgba.pixels(),
|
||||
[[20000, 15000, 10000, 32768], [49271, 49271, 49271, 65535]]
|
||||
);
|
||||
let p3 = decode(include_bytes!("fixtures/p3.jxl"), Target::Source);
|
||||
let Some(Image {
|
||||
cicp:
|
||||
CicpProfile {
|
||||
color_primaries: CicpColorPrimaries::Smpte432,
|
||||
transfer_characteristics: TransferCharacteristics::Srgb,
|
||||
..
|
||||
},
|
||||
samples: Samples::U8(rgba),
|
||||
}) = p3
|
||||
else {
|
||||
panic!("not decoded to Display P3");
|
||||
};
|
||||
assert_eq!(rgba.pixels(), [[255, 0, 0, 255], [255, 255, 255, 255]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_other_spaces_to_srgb() {
|
||||
let data = include_bytes!("fixtures/gray_icc.jxl");
|
||||
let Some(Image {
|
||||
cicp:
|
||||
CicpProfile {
|
||||
color_primaries: CicpColorPrimaries::Bt709,
|
||||
transfer_characteristics: TransferCharacteristics::Srgb,
|
||||
..
|
||||
},
|
||||
samples: Samples::U8(rgba),
|
||||
}) = decode(data, Target::Source)
|
||||
else {
|
||||
panic!("not decoded to sRGB");
|
||||
};
|
||||
assert_eq!(rgba.pixels(), decode_srgb(data).unwrap().pixels());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downscales_to_cover() {
|
||||
let data = include_bytes!("fixtures/jpeg_icc.jxl");
|
||||
let Some(Image {
|
||||
samples: Samples::U8(rgba),
|
||||
..
|
||||
}) =
|
||||
Jxl::open(data, Target::Srgb)
|
||||
.unwrap()
|
||||
.decode(Some((8, 4)), 16384, &AtomicBool::new(false))
|
||||
else {
|
||||
panic!("not decoded to 8 bits");
|
||||
};
|
||||
// 16x16 covers 8x4 at 8x8, read through the wide decode buffer.
|
||||
assert_eq!((rgba.width(), rgba.height()), (8, 8));
|
||||
for px in rgba.pixels() {
|
||||
assert!(
|
||||
px.iter()
|
||||
.zip([210, 119, 65, 255])
|
||||
.all(|(&a, b)| a.abs_diff(b) <= 3),
|
||||
"{px:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_input() {
|
||||
let data = include_bytes!("fixtures/srgb_alpha.jxl");
|
||||
assert!(decode_srgb(&data[..data.len() / 2]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_when_cancelled() {
|
||||
let data = include_bytes!("fixtures/large.jxl");
|
||||
let decode = |cancelled| {
|
||||
Jxl::open(data, Target::Srgb)
|
||||
.unwrap()
|
||||
.decode(None, 16384, &AtomicBool::new(cancelled))
|
||||
.is_some()
|
||||
};
|
||||
assert_eq!((decode(false), decode(true)), (true, false));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "native_core"
|
||||
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]
|
||||
codecs = { path = "../codecs", default-features = false, optional = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,15 @@
|
||||
/// Converts `h` rows of `w` RGBA_1010102 pixels, `stride` pixels apart in `src`, to dense RGBA_8888
|
||||
/// in `dst`.
|
||||
pub fn convert_1010102(src: &[u32], stride: usize, w: usize, h: usize, dst: &mut [u32]) {
|
||||
// Each 10-bit channel rounds v*255/1023 (16.16 fixed point) and alpha maps to a*85. Plain
|
||||
// arithmetic on purpose: the loop auto-vectorizes and beat a lookup table on-device.
|
||||
for y in 0..h {
|
||||
for (out, &px) in dst[y * w..][..w].iter_mut().zip(&src[y * stride..][..w]) {
|
||||
let r = ((px & 0x3ff) * 16336 + 32768) >> 16;
|
||||
let g = (((px >> 10) & 0x3ff) * 16336 + 32768) >> 16;
|
||||
let b = (((px >> 20) & 0x3ff) * 16336 + 32768) >> 16;
|
||||
let a = (px >> 30) * 85;
|
||||
*out = r | (g << 8) | (b << 16) | (a << 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user